1. 项目概述:为什么“分布式事务(二)”这个标题本身就藏着关键信号
看到“分布式事务(二)”这五个字,我第一反应不是去翻前一篇讲了什么,而是立刻在脑子里拉出一张图:这张图里没有抽象概念,只有真实系统里正在发生的三件事——订单创建时库存扣减失败、支付成功后发货单没生成、用户退款后积分却没回滚。这些不是教科书里的假设案例,是我在某电商中台项目上线第三周凌晨两点被电话叫醒时,盯着监控面板上那条横跨支付、库存、积分三个服务的失败链路,亲手排查了六小时才定位到的根本问题。所谓“(二)”,从来不是章节编号,而是实战者用血泪划出的分水岭:第一篇讲的是“理论上怎么设计”,第二篇必须直面“线上跑着跑着就崩了,你拿什么兜住”。它背后真正要解决的,是跨服务数据一致性在高并发、网络抖动、节点宕机等现实压力下的可落地保障能力。关键词里没写“Saga”“TCC”“本地消息表”,但这些词就是它的呼吸;热搜词里没提“超时”“幂等”“补偿”,可这三个词才是它真正的心跳。适合谁看?不是刚学完CAP理论的学生,而是已经把微服务拆了七八个、正被生产环境里每天几起“钱对不上”“状态不一致”问题追着跑的后端工程师、系统架构师,或者负责中间件选型的技术负责人。它不教你画漂亮的流程图,只告诉你:当Seata的AT模式在压测中突然卡住,当RocketMQ事务消息的check方法连续返回null,当你发现补偿逻辑里漏写了时间窗口判断——接下来该敲哪行代码、查哪个日志、改哪个参数阈值。这才是“(二)”的全部重量。
2. 内容整体设计与思路拆解:从“理论正确”到“线上稳住”的四层穿透
2.1 为什么必须放弃“教科书式”方案选型?
很多团队在设计分布式事务时,第一步就掉进陷阱:先列技术栈,再套模型。比如看到“Saga”就默认选开源框架,看到“TCC”就立刻让业务方改接口。我参与过三个不同行业的分布式事务改造项目,最后都推翻了初始方案。原因很简单:所有分布式事务方案的本质,不是技术选型,而是风险切分与责任归属的重新定义。举个最典型的例子:某物流系统需要保证“运单生成→运费计算→电子面单打印”三步强一致。如果按传统TCC思路,要求每个环节都提供Try/Confirm/Cancel接口,结果运费计算服务因依赖外部税务API而响应不稳定,整个链路就卡在Try阶段,资源锁死。后来我们换成“本地消息表+最终一致性”方案,核心改动只有一处:运费计算服务在完成计算后,不直接调用面单服务,而是先写入本地消息表(带状态和重试次数),再由独立的消息投递服务异步推送。表面看一致性变弱了,但实际效果是——系统吞吐量提升3倍,超时错误归零,运维同学半夜告警次数从平均每周4次降到0。这个转变的关键,在于我们把“强一致”的压力,从实时链路转移到了可监控、可重试、可人工干预的异步通道上。所以“(二)”的设计起点,永远是先画清业务边界、再标出故障高发点、最后匹配容错能力,而不是先挑框架。
2.2 四层穿透式设计框架:从协议层到业务层的真实映射
我把分布式事务的落地拆成四个不可跳过的层次,每一层都对应着线上最常爆雷的环节:
协议层(Protocol Layer):这是最容易被忽略的底层。比如HTTP调用默认无事务语义,gRPC虽支持流控但不内置事务上下文传递。我们曾遇到一个坑:服务A通过OpenFeign调用服务B,B内部用@Transactional注解,但A的请求头里没传X-B3-TraceId,导致Sleuth链路追踪断裂,事务日志无法关联。解决方案不是换框架,而是在Feign配置里强制注入traceId,并在B服务入口处校验上下文完整性。这一层的核心是:确保事务标识能像血液一样流经所有服务节点,且不被中间件过滤或重写。
协调层(Orchestration Layer):这里决定你是用中心化协调器(如Seata Server)还是去中心化编排(如Camunda)。关键指标不是性能数字,而是故障隔离能力。Seata AT模式下,如果TC(Transaction Coordinator)节点宕机,所有进行中的全局事务会卡住。而我们在金融类项目中采用Saga编排引擎,把协调逻辑下沉到业务服务内,TC只做轻量状态记录,即使协调服务不可用,各服务仍能按预设规则执行本地事务和补偿动作。代价是开发复杂度上升,但换来的是核心链路的降级能力。
执行层(Execution Layer):这是业务代码真正落地的地方。很多人以为加个@GlobalTransactional注解就万事大吉,实则不然。我们在线上发现过最隐蔽的bug:某个订单服务在try阶段扣减库存后,调用支付服务超时,触发rollback,但库存服务的cancel方法里忘了加数据库连接池超时设置,导致cancel请求堆积,最终拖垮整个库存库。所以执行层的硬性要求是:所有分支事务的Try/Confirm/Cancel操作,必须具备独立的超时、重试、熔断三重保护,且超时时间必须短于上游协调器的全局超时阈值(通常设为70%)。
治理层(Governance Layer):这是区分“能跑通”和“能管住”的分水岭。没有治理层的分布式事务,就像没有仪表盘的飞机。我们强制要求每个分布式事务链路必须配置三项监控:① 全局事务平均耗时(P99需<1.5s);② 补偿失败率(>0.1%自动告警);③ 悬挂事务数(持续5分钟未完成即触发人工介入)。这些指标不靠框架自动生成,而是通过在每个事务入口/出口埋点,将上下文ID、耗时、状态写入ELK日志,再用Flink实时计算。治理层的价值,是把“事务是否成功”这个黑盒问题,变成“哪里慢、哪里错、哪里悬”的白盒诊断。
2.3 方案选型的三个铁律:不看文档,只看日志
在多个项目踩坑后,我总结出方案选型的三条铁律,每一条都来自真实日志分析:
铁律一:拒绝任何“开箱即用”的事务框架,除非它能输出完整的事务快照日志。我们测试过五款主流框架,只有两款支持在事务提交/回滚瞬间,将完整上下文(包括各分支事务SQL、参数、耗时、异常堆栈)写入独立日志文件。其余框架的日志要么分散在各服务中难以关联,要么只记录状态不记录数据。没有完整快照,线上排查等于盲人摸象。
铁律二:所有补偿逻辑必须通过“反向查询”而非“正向调用”实现。比如订单退款补偿,不要直接调用积分服务的“增加积分”接口,而是先查订单原始积分扣减记录,再构造补偿指令。原因在于:正向调用可能因服务升级导致接口变更,而反向查询基于已存事实,稳定性高一个数量级。我们在某社交平台项目中,因未遵守此律,一次积分服务API重构导致3天内27万笔补偿失败。
铁律三:全局事务ID必须携带业务语义,且长度可控。见过最离谱的设计是用UUID作为事务ID,结果日志里全是32位随机字符串,运维查问题时得先解码再关联。我们强制要求ID格式为
{业务域}-{时间戳8位}-{序列号4位},如order-23081512-0001。这样在Kibana里直接输入order-*就能筛选出所有订单相关事务,时间戳还能辅助判断是否为批量操作引发的连锁故障。
这三层穿透和三条铁律,构成了“(二)”的设计骨架。它不承诺100%强一致,但确保每次不一致都能在5分钟内定位根因、10分钟内人工修复。这才是生产环境真正需要的“分布式事务”。
3. 核心细节解析与实操要点:那些文档里绝不会写的致命细节
3.1 幂等性不是“加个注解”就能解决的,而是要贯穿数据生命周期
幂等性常被简化为“重复请求返回相同结果”,但在分布式事务中,它本质是对同一业务事实的多次操作,必须收敛到唯一确定的状态。我见过太多团队在Controller层加@RepeatSubmit注解,结果在Service层的事务方法里又做了数据库insert,导致重复提交时主键冲突。真正的幂等设计,必须覆盖三个阶段:
请求接入阶段:前端提交订单时,必须生成唯一业务流水号(如
ORD-{毫秒时间戳}-{机器码}-{随机数}),并随请求头X-Request-ID透传。后端网关层校验该ID在Redis中是否存在(有效期设为15分钟),存在则直接返回409 Conflict,不存在则写入Redis并放行。注意:Redis key必须带业务前缀,避免不同系统ID冲突;过期时间不能设太长,否则占用内存,也不能太短,否则影响用户体验。事务执行阶段:这是最易出错的环节。以库存扣减为例,常见错误写法:
// ❌ 错误:仅校验库存余量,未锁定业务事实 if (stock > orderQty) { update stock set qty = qty - ? where sku = ?; }正确做法是引入“预占库存”表,结构为
(order_id, sku, qty, status),status为PRE_LOCKED/CONFIRMED/CANCELED。Try阶段插入预占记录,Confirm阶段更新status,Cancel阶段删除记录。这样即使同一订单重复提交,第二次Try会因唯一索引冲突失败,而非扣减两次库存。补偿执行阶段:补偿接口必须支持“幂等查询+幂等执行”双校验。比如退款补偿,不能只查“该订单是否已退款”,而要查“该订单的退款记录中,是否存在状态为SUCCESS且金额匹配的记录”。我们曾在线上发现,因网络重传导致补偿服务收到两次相同请求,第一次执行成功,第二次因未校验金额直接返回成功,结果财务对账时发现多退了一笔。现在所有补偿接口强制要求:
SELECT COUNT(*) FROM refund_log WHERE order_id = ? AND amount = ? AND status = 'SUCCESS';只有count=0才执行退款,否则直接返回成功。
提示:幂等校验的SQL必须走索引,建议在
order_id + amount + status上建联合索引。我们曾因未建索引,导致单次补偿查询耗时从5ms飙升至2.3s,拖垮整个补偿队列。
3.2 超时控制不是配置一个参数,而是构建三级防御体系
分布式事务中最常见的“假成功”现象,源于超时设置的混乱。很多团队只在Feign里配readTimeout=5000,却忽略了数据库连接池、MQ消费者、本地事务等环节的超时叠加。我们构建了三级超时防御:
一级防御:全局事务超时(Global Timeout)
这是协调器(如Seata TC)的硬性约束,必须小于所有分支事务最长可能耗时。计算公式:Global Timeout = Max(Try耗时, Confirm耗时, Cancel耗时) × 3 + 网络抖动预留
实际项目中,我们取各环节P99耗时的3倍,再加2秒。例如库存Try P99=800ms,支付Confirm P99=1200ms,积分Cancel P99=600ms,则Global Timeout = max(800,1200,600)×3 + 2000 = 5600ms。这个值必须在启动时注入TC,而非运行时动态调整。二级防御:分支事务超时(Branch Timeout)
每个服务必须独立配置自身事务超时,且必须短于Global Timeout。关键点在于:超时异常必须被捕获并转换为明确的业务异常。例如库存服务在Try方法中:@Transactional(timeout = 3) // Spring事务超时3秒 public void tryDeduct(String sku, int qty) { try { // 执行扣减逻辑 } catch (TransactionTimedOutException e) { throw new BranchTimeoutException("库存Try超时", e); // 明确异常类型 } }这样协调器能识别出是超时而非业务错误,从而触发Cancel而非重试。
三级防御:网络调用超时(Network Timeout)
这是最容易被忽视的一层。Feign、Dubbo等客户端必须配置connectTimeout和readTimeout,且readTimeout必须小于Branch Timeout。我们规定:readTimeout ≤ Branch Timeout × 0.7。例如Branch Timeout=3000ms,则Feign readTimeout最大设为2100ms。同时,所有HTTP调用必须启用OkHttp的连接池,maxIdleConnections设为20,keepAliveDuration设为5分钟,避免频繁建连导致的隐式超时。
注意:三级超时值必须形成严格递减链条(Global > Branch > Network),否则会出现“协调器已超时取消,但分支还在执行”的悬挂状态。我们曾因Feign readTimeout设为5000ms(大于Global Timeout 4000ms),导致12%的全局事务进入悬挂,不得不每日人工清理。
3.3 补偿逻辑的“黄金三原则”:可逆、可查、可人工
补偿不是写个Cancel方法就完事,它是分布式事务的最后防线。我们总结出补偿逻辑的黄金三原则:
可逆原则:每个Confirm操作,必须存在数学上可逆的Cancel操作。比如“账户余额+100”对应的Cancel是“余额-100”,但“发送短信通知”就没有可逆操作。对此类非幂等副作用,必须改为“记录待发送状态”,由独立的短信服务轮询发送,发送成功后更新状态。这样Cancel只需删除待发送记录,无需处理已发短信。
可查原则:所有补偿动作必须留下可审计的痕迹。我们要求每个补偿方法执行前,先插入
compensation_log表,字段包括:id(主键),tx_id(全局事务ID),service_name,action(confirm/cancel),params(JSON序列化参数),status(INIT/EXECUTING/SUCCESS/FAILED),created_at。关键点在于:params必须包含所有影响状态的参数,且不能含敏感信息(如手机号需脱敏)。这样当补偿失败时,DBA可直接查表获取完整上下文,无需翻日志。可人工原则:补偿失败必须支持人工介入。我们在管理后台开发了“补偿任务看板”,展示所有
status=FAILED的记录,并提供“重试”、“跳过”、“标记为成功”三个按钮。重试按钮会重新触发补偿方法;跳过按钮将status设为SKIPPED,避免重复告警;标记成功则需填写原因(如“用户已线下退款”),并记录操作人。这个看板上线后,补偿失败的人工处理时效从平均47分钟缩短至3分钟。
实操中最大的坑是补偿逻辑的“状态漂移”。比如订单服务在Confirm时更新订单状态为“已支付”,但积分服务Cancel时因网络问题失败,此时订单状态已是“已支付”,而积分未扣减。为防此类漂移,我们强制要求:所有Confirm操作必须在更新业务状态前,先检查前置条件是否满足。例如积分扣减Confirm方法:
public void confirmDeduct(String orderId) { // 先查订单支付状态,确保仍是“已支付” Order order = orderService.getOrderByOrderId(orderId); if (!"PAID".equals(order.getStatus())) { throw new CompensationPreconditionException("订单状态非已支付,无法执行积分扣减Confirm"); } // 再执行扣减 pointService.deduct(orderId, order.getPoint()); }这样即使Cancel失败,Confirm也不会误执行,状态漂移概率趋近于零。
4. 实操过程与核心环节实现:从本地验证到全链路压测的七步通关
4.1 本地开发环境:用Docker Compose搭建最小闭环
在编码前,必须搭建一个能复现线上问题的本地环境。我们摒弃了“本地启动所有服务”的笨办法,改用Docker Compose构建最小闭环,包含四个容器:
mysql:8.0:主数据库,初始化脚本创建order_db、stock_db、point_db三个库redis:7-alpine:用于幂等校验和分布式锁rocketmq:5.0:事务消息中间件,配置nameserver和brokernacos:2.2.3:服务注册中心,预置所有服务实例
关键配置在于RocketMQ的事务消息机制。我们编写了一个TransactionListenerImpl,其executeLocalTransaction方法模拟本地事务执行,checkLocalTransaction方法模拟网络中断后的状态回查。重点在于checkLocalTransaction的实现:
@Override public LocalTransactionState checkLocalTransaction(MessageExt msg) { String txId = msg.getUserProperty("TX_ID"); // 从MySQL查该事务ID的最终状态 TransactionStatus status = transactionMapper.getStatusByTxId(txId); switch (status) { case COMMITTED: return LocalTransactionState.COMMIT_MESSAGE; case ROLLED_BACK: return LocalTransactionState.ROLLBACK_MESSAGE; default: // 状态未知时,返回UNKNOW,RocketMQ会间隔1s、5s、30s、120s重试 log.warn("事务[{}]状态未知,触发回查", txId); return LocalTransactionState.UNKNOW; } }这个设计确保了即使应用重启,RocketMQ也能通过回查恢复事务状态。本地验证时,我们故意在executeLocalTransaction中抛异常,观察checkLocalTransaction是否被调用,以及重试间隔是否符合预期。这一步验证通过,才能进入编码。
4.2 分支事务编码:AT模式下的SQL改写陷阱
采用Seata AT模式时,开发者常犯的错误是直接复制原有SQL,导致undo_log无法生成。我们总结出SQL改写的三大禁忌:
禁忌一:禁止在UPDATE语句中使用子查询
错误示例:UPDATE stock SET qty = qty - ? WHERE sku = (SELECT sku FROM order_item WHERE order_id = ?);Seata无法解析子查询,导致undo_log为空。正确做法是拆分为两步:先查出sku,再执行UPDATE。
禁忌二:禁止在WHERE条件中使用函数
错误示例:UPDATE order SET status = 'PAID' WHERE DATE(create_time) = '2023-08-15';Seata的SQL解析器不支持DATE()函数,会报
NotSupportYetException。应改为:UPDATE order SET status = 'PAID' WHERE create_time >= '2023-08-15 00:00:00' AND create_time < '2023-08-16 00:00:00';禁忌三:禁止在INSERT中使用UUID()函数
错误示例:INSERT INTO order (id, user_id, amount) VALUES (UUID(), ?, ?);Seata生成undo_log时,会将UUID()替换为实际值,但回滚时无法还原原值。必须改为应用层生成ID,再传入SQL。
我们编写了SQL静态检查插件,集成到CI流程中。插件扫描所有Mapper XML文件,对上述三种模式报红,阻断提交。这个插件上线后,AT模式相关的undo_log异常从每月17次降至0次。
4.3 全链路压测:用Gatling模拟真实流量洪峰
单元测试无法暴露分布式事务的真问题,必须进行全链路压测。我们选用Gatling(而非JMeter)的原因是:Gatling的DSL更贴近真实业务场景,且能精准控制并发模型。压测脚本核心逻辑如下:
class OrderSimulation extends Simulation { val httpProtocol = http .baseUrl("http://gateway:8080") .acceptHeader("application/json") val scn = scenario("Create Order Flow") .exec(http("Create Order") .post("/api/order") .body(StringBody("""{"userId":"${userId}","items":[{"sku":"SKU001","qty":1}]}""")) .check(status.is(200), jsonPath("$.data.orderId").saveAs("orderId"))) .pause(1) // 模拟用户下单后等待 .exec(http("Query Order Status") .get("/api/order/${orderId}") .check(status.is(200), jsonPath("$.data.status").is("PAID"))) setUp(scn.inject(atOnceUsers(1000))).protocols(httpProtocol) }压测执行分三阶段:
- 基线压测(Baseline):100并发,持续5分钟,目标成功率100%,P95耗时<800ms。此阶段验证基础链路。
- 阶梯压测(Ramp-up):从100并发开始,每30秒+100并发,直至2000并发。重点观察全局事务失败率拐点。我们发现当并发达1500时,失败率从0.02%骤升至1.8%,根因是RocketMQ broker的commitLog刷盘延迟,遂将
flushDiskType从ASYNC改为SYNC。 - 洪峰压测(Peak):2000并发持续10分钟,期间随机kill一个库存服务实例,验证故障转移能力。关键指标是:悬挂事务数<5,补偿失败率<0.05%。
压测报告中,我们重点关注三个图表:① 全局事务成功率趋势图(必须平滑);② 各分支事务耗时热力图(确认无长尾);③ 补偿队列积压量(峰值<100)。任何一项异常,立即冻结发布。
4.4 生产环境灰度:用Nacos配置中心实现渐进式放量
上线不是“一键发布”,而是“可控放量”。我们通过Nacos配置中心实现灰度:
在
application.yml中配置:seata: enabled: ${SEATA_ENABLED:true} # 默认开启在Nacos中创建配置项
seata.enabled,初始值为false。编写灰度开关Filter:
@Component public class SeataGrayFilter implements Filter { @Value("${seata.enabled:true}") private boolean seataEnabled; @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { if (seataEnabled) { chain.doFilter(request, response); } else { // 绕过Seata,走本地事务 TransactionTemplate template = new TransactionTemplate(transactionManager); template.execute(status -> { // 执行本地事务逻辑 return null; }); } } }
灰度步骤:
- 首批1%流量(按用户ID哈希)开启Seata,观察2小时;
- 若无异常,扩至10%,同时开启补偿失败告警(阈值0.1%);
- 若补偿失败率<0.05%,扩至50%,并开启全链路日志采样(10%);
- 最后100%放量,关闭采样,保留告警。
这套灰度机制让我们在某银行项目上线时,提前2小时发现补偿服务因线程池满导致的失败率上升,及时扩容后零事故。
4.5 监控告警:用Prometheus+Grafana构建事务健康度仪表盘
没有监控的分布式事务,如同蒙眼开车。我们构建了四级监控体系:
| 监控层级 | 指标名称 | 告警阈值 | 数据来源 |
|---|---|---|---|
| 全局层 | seata_global_tx_success_rate | <99.5% | Seata TC JMX |
| 分支层 | seata_branch_tx_timeout_total | >5次/5分钟 | Seata RM日志 |
| 补偿层 | compensation_failed_total | >10次/小时 | compensation_log表 |
| 业务层 | order_status_inconsistency_count | >3次/天 | 订单、库存、积分库定时对账 |
Grafana仪表盘包含三个核心视图:
- 健康度总览:显示当前全局事务成功率、平均耗时、悬挂事务数,用红/黄/绿三色标识;
- 链路追踪:点击任一失败事务ID,自动跳转到SkyWalking,展示从订单创建到各分支执行的完整调用栈;
- 补偿看板:列出所有失败补偿,支持按服务、时间、错误码筛选,并提供一键重试按钮。
最关键的告警是“悬挂事务”。我们编写了Python脚本,每5分钟扫描MySQL:
SELECT tx_id, service_name, created_time FROM global_table WHERE status = 'UnKnown' AND gmt_modified < NOW() - INTERVAL 5 MINUTE;发现即告警,并自动触发钉钉机器人推送事务ID和关联日志链接。这个机制让我们将平均故障发现时间从43分钟缩短至2.7分钟。
5. 常见问题与排查技巧实录:那些凌晨三点教会我的事
5.1 “事务卡住不动了”——悬挂事务的七步定位法
这是最让人崩溃的问题:全局事务状态卡在UnKnown,各分支日志显示“已提交”,但业务状态就是不更新。我们的七步定位法:
- 查TC日志:登录Seata TC服务器,
grep "UnKnown" logs/seata-server.log,找到事务ID和最后操作时间; - 查RM日志:在各分支服务日志中搜索该事务ID,确认是否收到Commit/Rollback指令;
- 查数据库:在
branch_table中查该事务ID的status,若为PhaseOne_Done,说明TC已下发指令; - 查网络:用
tcpdump抓包,确认RM是否收到TC的RPC请求(端口8091); - 查线程:在RM服务中执行
jstack -l <pid> | grep "Seata",看是否有线程阻塞在NettyClientChannelManager; - 查连接池:检查Druid连接池
activeCount是否达上限,waitThreadCount是否>0; - 查GC:用
jstat -gc <pid>看是否频繁Full GC,导致线程停顿。
我们曾在一个电商项目中,按此流程定位到:库存服务因Druid连接池maxActive=20,而压测并发达50,导致TC指令无法获取数据库连接,事务卡在UnKnown。解决方案是将maxActive调至100,并增加连接池监控告警。
5.2 “补偿一直失败”——幂等校验失效的三种典型场景
补偿失败率高,90%源于幂等校验失效。我们整理出三种高频场景及解法:
场景一:时间精度丢失
问题:订单服务用new Date()生成时间戳,积分服务用System.currentTimeMillis(),因JVM时钟漂移导致毫秒级差异,幂等校验失败。
解法:统一使用NTP服务器同步时间,或改用Snowflake ID(含时间戳)作为幂等键。场景二:浮点数精度误差
问题:金额字段用Double存储,19.99 * 100 = 1998.9999999999998,与数据库中1999不等。
解法:金额字段强制用BigDecimal,且比较时用compareTo()而非equals()。场景三:状态机跳跃
问题:订单状态从CREATED直接跳到SHIPPED,跳过了PAID,导致积分扣减Confirm时前置校验失败。
解法:在状态机中增加allowTransition(from, to)校验,禁止非法跳跃;同时在补偿逻辑中放宽校验,如if (status in ['PAID','SHIPPED'])。
5.3 “日志找不到关联”——事务上下文丢失的根因分析
分布式事务排查最痛苦的,是日志无法串联。我们发现上下文丢失的四大根因:
根因一:异步线程丢失MDC
问题:@Async方法中,Logback的MDC(Mapped Diagnostic Context)不自动继承。
解法:自定义AsyncTaskExecutor,在execute方法中手动拷贝MDC:public class MdcAwareThreadPoolTaskExecutor extends ThreadPoolTaskExecutor { @Override public void execute(Runnable task) { Map<String, String> context = MDC.getCopyOfContextMap(); super.execute(() -> { if (context != null) MDC.setContextMap(context); try { task.run(); } finally { MDC.clear(); } }); } }根因二:Feign拦截器未透传
问题:Feign Client未配置RequestInterceptor,导致X-B3-TraceId未传递。
解法:在@Configuration类中添加:@Bean public RequestInterceptor requestInterceptor() { return template -> { template.header("X-B3-TraceId", MDC.get("traceId")); template.header("X-B3-SpanId", MDC.get("spanId")); }; }根因三:MQ消息体未携带上下文
问题:RocketMQ发送消息时,未将MDC放入Message的userProperties。
解法:封装RocketMQTemplate,重写convertAndSend方法,自动注入:public void convertAndSend(String destination, Object payload) { Message<?> message = MessageBuilder.withPayload(payload) .setHeader("X-B3-TraceId", MDC.get("traceId")) .setHeader("X-B3-SpanId", MDC.get("spanId")) .build(); super.convertAndSend(destination, message); }根因四:第三方SDK硬编码日志
问题:某支付SDK内部日志不走SLF4J,无法注入MDC。
解法:用Java Agent技术,在java.util.logging.Logger的log方法中注入MDC,或联系SDK厂商提供日志回调接口。
5.4 “性能突然暴跌”——数据库连接池与事务传播的隐式冲突
某次大促前压测,TPS从8000骤降至1200,监控显示数据库连接池activeCount达上限,但慢SQL日志为空。排查发现根源在Spring事务传播行为:
- 服务A的
@Transactional(propagation = Propagation.REQUIRED)方法调用服务B的@Transactional(propagation = Propagation.REQUIRES_NEW)方法; - B方法执行时,会新建数据库连接,但A方法的事务未提交,导致连接无法释放;
- 当并发高时,大量连接被B方法独占,A方法因无连接可用而排队。
解法有三:
- 方案一(推荐):将B方法改为
Propagation.SUPPORTS,使其加入A的事务,共享连接; - 方案二:为B方法单独配置
@Transactional(timeout = 3),并缩短连接池maxWait时间,让超时连接快速释放; - 方案三:在B方法入口加
@Async,使其异步执行,彻底脱离A的事务上下文。
我们选择方案一,因为既保持事务语义,又避免连接池争抢。实施后TPS恢复至8200,连接池activeCount稳定在15以下。
实操心得:所有
Propagation.REQUIRES_NEW的使用,必须配套连接池监控告警。我们新增规则:当DruidDataSource.activeCount > maxActive * 0.8且持续2分钟,立即告警并自动dump线程栈。
6. 经验沉淀与延伸思考:从“解决问题”到“预防问题”
6.1 我们建立的“分布式事务健康度”评估模型
经过十余个项目锤炼,我们提炼出一套量化评估模型,满分100分,低于70分视为高风险:
| 维度 | 评分项 | 权重 | 达标标准 | 得分 |
|---|---|---|---|---|
| 设计规范 | 是否明确定义各环节超时阈值 | 15% | Global/Branch/Network三级超时均配置且递减 | □ |
| 编码质量 | 补偿逻辑是否满足可逆/可查/可人工三原则 | 20% | 补偿日志表存在且字段完整,管理后台支持人工干预 | □ |
| 可观测性 | 是否具备全链路追踪与事务健康度监控 | 25% | Grafana仪表盘包含成功率、悬挂数、补偿失败率三指标 | □ |
| 容错能力 | 是否支持灰度发布与故障降级 | 20% | Nacos配置开关可随时关闭Seata,走本地事务 | □ |
| 治理机制 | 是否有定期对账与补偿失败复盘机制 | 20% | 每周运行对账脚本,每月分析补偿失败根因 | □ |
这个模型不是摆设,而是嵌入到研发流程中:每次分布式事务需求评审,必须出具此表;每次上线前,SRE