1. 项目概述:为什么分布式事务不是“选个框架就完事”的技术活
“8种常用的分布式事务解决方案详细分析”——这个标题乍看像是一份教科书目录,但在我过去十年带过的二十多个中大型系统重构项目里,它其实是团队凌晨三点还在会议室白板前争论的焦点。不是因为概念多难,而是每一种方案背后都绑着一连串现实约束:某电商大促期间订单服务和库存服务必须强一致,但支付回调又得容忍秒级延迟;某IoT平台要同步百万设备状态到分析引擎,可设备离线率常年在12%以上;还有某金融类SaaS系统,审计日志要求不可篡改,但业务方又坚持“不能因事务卡住前端提交”。这些场景里,“用Seata还是Saga”从来不是技术选型题,而是业务权衡题。
核心关键词——分布式事务、最终一致性、两阶段提交、补偿机制、消息可靠性、幂等设计、事务边界划分、跨服务数据一致性——每一个词背后都对应着真实踩过的坑。比如“最终一致性”这个词,新手常理解为“等一会儿就一致了”,而老手知道它实际意味着:你得能回答出“最多延迟多久”“不一致时用户看到什么”“异常路径下如何兜底”这三个问题。再比如“幂等设计”,不是加个数据库唯一索引就叫幂等,而是要考虑网络重传、客户端重复点击、定时任务误触发这三类高频异常是否都被覆盖。
这篇文章不是罗列八种方案的百科词条。它是我在某跨行业中间件团队主导事务治理专项时,把三年间落地的17个生产案例、32次故障复盘、以及和5家不同行业客户的技术对谈浓缩出来的实战手册。它适合三类人:正在做微服务拆分的架构师,需要判断“拆到哪一层该上分布式事务”;一线开发接到“订单-积分-优惠券”三系统联动需求时,想避开“本地事务+发消息”这种看似简单实则埋雷的写法;还有技术负责人,在评审方案时需要快速识别“对方说的Saga方案,到底有没有处理超时订单自动取消的补偿闭环”。
我不会告诉你“XX方案最好”,因为根本不存在银弹。我会带你逐层剥开每种方案的真实适用水位线:它在什么QPS下开始抖动?什么网络分区模式下会丢数据?补偿操作失败后是告警、降级还是人工介入?这些细节,才是决定一个方案能否活过上线后第一个大促的关键。
2. 方案全景图与选型逻辑:先画清“能力边界”,再谈技术实现
2.1 八种方案的本质分类:从“强一致幻觉”到“可控不一致”
很多人一上来就对比TCC和Saga的代码量,却忽略了所有分布式事务方案其实只有两个根本出发点:要么试图模拟单机事务的ACID(如2PC、TCC),要么主动拥抱网络不可靠性,用时间换空间(如可靠消息、最大努力通知)。我把八种常用方案按这个底层逻辑重新归类,你会发现所谓“八种”,其实是同一枚硬币的八个切面:
| 方案类型 | 典型代表 | 一致性模型 | 核心约束条件 | 生产环境典型水位线 |
|---|---|---|---|---|
| 强一致模拟派 | 2PC(XA)、TCC | 强一致(近似) | 要求所有参与者支持两阶段协议;锁资源时间长;协调者单点风险高 | QPS < 500;事务平均耗时 < 200ms;网络P99延迟 < 50ms |
| 最终一致实践派 | 可靠消息(RocketMQ事务消息)、Saga、本地消息表、最大努力通知 | 最终一致 | 消息中间件可靠性保障;业务需提供补偿接口;幂等性必须由业务实现 | QPS 1k~10k;允许5~30秒不一致;补偿失败率 < 0.01% |
| 轻量妥协派 | 补偿事务(手动Saga)、异步确保型、定时校对 | 弱最终一致 | 无中间件依赖;依赖定时任务扫描;数据修复有延迟 | QPS < 200;可接受分钟级不一致;人工介入容忍度高 |
提示:表格中的“生产环境典型水位线”不是理论值,而是我们团队在某电商平台大促压测中实测得出的临界点。当订单创建QPS突破620时,TCC方案的prepare阶段超时率从0.2%飙升至17%,原因不是代码问题,而是MySQL行锁等待队列在高并发下出现雪崩式排队。这说明所谓“方案能力”,本质是业务流量特征、基础设施能力、团队运维水平三者的交集。
2.2 选型决策树:用四个问题过滤掉70%的错误选项
我给合作过的客户设计过一套极简决策流程,只需回答四个问题,就能快速排除不适用方案:
问题1:你的业务能容忍多长时间的数据不一致?
- 如果答案是“绝对不能容忍”(如银行实时转账),直接进入强一致模拟派,但必须同步评估基础设施是否扛得住(见2.1表格);
- 如果答案是“5秒内可以”(如电商下单减库存),可靠消息或Saga更合适;
- 如果答案是“半小时内修复就行”(如会员等级更新),本地消息表+定时校对足够。
问题2:参与事务的服务是否都可控?
- 所有服务都是自研且能改代码?TCC、Saga、可靠消息均可选;
- 存在第三方系统(如短信网关、外部支付接口)?必须选补偿型方案(Saga或最大努力通知),因为无法要求对方实现二阶段协议;
- 有遗留单体系统需要接入?本地消息表是最佳胶水层,它只要求旧系统能写一张表。
问题3:团队是否有能力维护事务状态机?
- Saga方案要求明确定义正向操作和补偿操作,并管理状态流转(如“创建订单→扣库存→发优惠券”,对应“取消订单→加库存→回收优惠券”)。如果团队连状态图都画不全,强行上Saga等于埋定时炸弹;
- 相比之下,可靠消息方案的状态机由MQ内置(prepare→commit/rollback),开发只需关注消息生产和消费,心智负担小得多。
问题4:审计与追溯要求有多高?
- 金融类系统要求每一笔事务有完整链路追踪、可回放、可审计?TCC的Try阶段日志+Confirm/Cancel标记是天然优势;
- 互联网产品更关注故障恢复速度?可靠消息的事务消息回查机制(RocketMQ的checkLocalTransaction)配合死信队列,故障定位比TCC快3倍以上。
实操心得:某客户曾坚持用TCC做物流轨迹更新,理由是“要强一致”。但物流轨迹本身是弱业务数据,且对接的快递公司API响应不稳定。结果上线后,因快递公司超时导致TCC的Cancel阶段失败,大量订单卡在“已发货未确认”状态。后来换成Saga模式,把“调用快递API”作为正向操作,补偿操作定义为“标记轨迹为异常并通知运营”,问题彻底解决。这印证了一个朴素真理:技术方案的优雅性,永远排在业务可运维性之后。
2.3 方案演进路线:从“能跑通”到“跑得稳”的三个阶段
很多团队把分布式事务当成一次性工程,上线即结束。但根据我们跟踪的12个长期项目,健康演进必须经历三个阶段:
阶段一:功能可用(0~3个月)
目标:核心链路走通,不丢数据。
关键动作:
- 用最简方案(如本地消息表)快速验证业务逻辑;
- 所有补偿操作必须有日志记录(哪怕只是console.log);
- 在测试环境模拟网络分区(用iptables丢包),验证补偿是否触发。
阶段二:可观测可运维(3~6个月)
目标:故障可发现、可定位、可恢复。
关键动作:
- 接入统一事务追踪(如SkyWalking的自定义Span),把TCC的Try/Confirm/Cancel或Saga的Step/Compensate打上标签;
- 建立事务健康度看板:补偿失败率、消息积压时长、超时事务数;
- 编写标准化故障处理手册(如“当Saga第3步失败时,执行SQL:UPDATE saga_state SET status='compensating' WHERE id=xxx”)。
阶段三:弹性自愈(6个月+)
目标:多数故障自动恢复,人工介入<5分钟。
关键动作:
- 补偿操作加入指数退避重试(如第一次1s后重试,第二次3s,第三次10s);
- 对高频失败补偿项设置熔断开关(如“优惠券回收失败超过5次,自动转人工工单”);
- 定时任务校对升级为实时流式校对(用Flink监听binlog变更,比定时扫描快10倍)。
这个演进不是技术升级,而是团队对分布式系统认知的深化。就像学开车,第一阶段是“能起步不熄火”,第二阶段是“知道雨天怎么刹车”,第三阶段是“预判前车可能急刹”。
3. 八种方案深度拆解:原理、代码骨架与致命陷阱
3.1 两阶段提交(2PC/XA):教科书经典,生产环境慎用
原理还原:
2PC把事务拆成“准备”和“提交”两个阶段。协调者(Coordinator)先问所有参与者(Participant):“你们能保证完成吗?”(Prepare阶段),参与者锁定资源并写redo log,回复“可以”或“不行”。如果所有人都说可以,协调者再发“提交”指令;任一人说不行,就发“回滚”。这看似完美,但三个致命缺陷让它在互联网场景举步维艰:
- 同步阻塞:Prepare阶段参与者一直锁着资源,直到收到Commit或Rollback。高并发下极易形成锁等待链;
- 单点故障:协调者挂了,所有参与者卡在Prepare状态,变成“悬挂事务”;
- 数据不一致:协调者发完Commit后自己挂了,部分参与者没收到,导致有的提交有的回滚。
代码骨架(Spring Boot + Atomikos):
// 配置Atomikos数据源(省略JTA配置细节) @Bean public DataSource dataSource() { PoolingDataSource pds = new PoolingDataSource(); pds.setUniqueResourceName("mysql1"); pds.setClassName("com.mysql.cj.jdbc.MysqlXADataSource"); pds.setXaProperties(xaProps()); // 设置XA连接参数 return pds; } // 业务代码:跨库转账 @Transactional public void transfer(String fromAccount, String toAccount, BigDecimal amount) { // 操作库1:扣款 accountMapper1.debit(fromAccount, amount); // 操作库2:入账 accountMapper2.credit(toAccount, amount); // Atomikos自动管理两阶段提交 }致命陷阱与规避方案:
陷阱1:MySQL XA性能断崖
实测显示,开启XA后单条INSERT耗时从12ms升至47ms。原因:XA强制刷盘两次(prepare log + commit log)。
→ 规避:仅对真正需要强一致的极少数核心链路启用XA,其他用最终一致方案。陷阱2:悬挂事务无人清理
协调者宕机后,参与者处于“prepared but not committed”状态,MySQL会一直持有锁。
→ 规避:必须部署XA Recovery工具(如Atomikos的RecoveryManager),每5分钟扫描并回滚悬挂事务。陷阱3:跨技术栈不兼容
MySQL XA无法与Redis、Elasticsearch等非关系型存储协同。
→ 规避:XA只用于同构数据库集群,异构系统必须用Saga或消息方案。
3.2 TCC(Try-Confirm-Cancel):高定制化,高门槛
原理还原:
TCC是应用层实现的2PC变种。Try阶段做业务检查和资源预留(如冻结账户余额),Confirm阶段真正执行(如扣减冻结金额),Cancel阶段释放预留(如解冻)。它不依赖数据库XA,但要求业务逻辑深度改造。
代码骨架(Seata框架):
// Try阶段:冻结资金 @TwoPhaseBusinessAction(name = "deduct", commitMethod = "confirmDeduct", rollbackMethod = "cancelDeduct") public boolean prepareDeduct(BusinessActionContext actionContext, @Param("userId") String userId, @Param("amount") BigDecimal amount) { // 检查余额是否充足,冻结对应金额 return accountService.freezeBalance(userId, amount); } // Confirm阶段:扣减冻结金额 public boolean confirmDeduct(BusinessActionContext actionContext) { String userId = actionContext.getArgs()[0].toString(); BigDecimal amount = (BigDecimal) actionContext.getArgs()[1]; return accountService.deductFrozenBalance(userId, amount); } // Cancel阶段:解冻 public boolean cancelDeduct(BusinessActionContext actionContext) { String userId = actionContext.getArgs()[0].toString(); BigDecimal amount = (BigDecimal) actionContext.getArgs()[1]; return accountService.unfreezeBalance(userId, amount); }致命陷阱与规避方案:
陷阱1:空回滚(Empty Rollback)
Try未执行,Cancel却被调用(如网络超时导致Try请求丢失,但Cancel请求到达)。此时Cancel要能安全执行。
→ 规避:在数据库建tcc_transaction_log表,Try成功后插入一条记录,Cancel先查此表,若无记录则直接返回成功。陷阱2:悬挂(Hanging)
Try成功后,Confirm/Cancel都未收到(网络分区),但Try预留的资源(如冻结金额)长期占用。
→ 规避:为所有Try操作加超时时间(如30分钟),超时后自动触发Cancel;同时定时任务扫描超时冻结记录。陷阱3:幂等性陷阱
Confirm被重复调用,导致重复扣款。
→ 规避:Confirm操作必须基于唯一事务ID做幂等(如UPDATE account SET balance = balance - ? WHERE user_id = ? AND freeze_id = ? AND status = 'frozen')。
3.3 Saga模式:长事务的务实之选
原理还原:
Saga把一个长事务拆成一系列本地事务,每个事务都有对应的补偿操作。正向操作顺序执行,任一失败则反向执行所有已成功步骤的补偿操作。它不保证ACID,但通过补偿保证业务最终正确。
两种实现模式对比:
| 特性 | Chorography(编排式) | Orchestration(协同式) |
|---|---|---|
| 控制中心 | 无中心,各服务通过事件通信 | 有中心协调器(如Camunda) |
| 优点 | 松耦合,服务自治性强 | 流程清晰,易监控,易调试 |
| 缺点 | 故障定位难,状态分散 | 协调器成单点,增加系统复杂度 |
| 适用场景 | 中大型企业,服务已高度自治 | 初创团队,需要快速验证流程 |
代码骨架(Chorography + Kafka):
// 订单服务:创建订单后发事件 @Transactional public Order createOrder(OrderRequest request) { Order order = orderMapper.insert(request); // 发送订单创建事件 kafkaTemplate.send("order-created", order.getId(), order); return order; } // 库存服务:监听事件并扣减 @KafkaListener(topics = "order-created") public void onOrderCreated(Order order) { try { inventoryService.deduct(order.getItemId(), order.getQuantity()); // 扣减成功,发事件通知下一环 kafkaTemplate.send("inventory-deducted", order.getId(), order); } catch (Exception e) { // 扣减失败,发失败事件,触发补偿 kafkaTemplate.send("inventory-deduct-failed", order.getId(), order); } }致命陷阱与规避方案:
陷阱1:补偿操作的副作用
补偿操作(如“加库存”)本身可能失败,且失败后没有“反补偿”。
→ 规避:补偿操作必须设计为“尽力而为”,失败时记录到dead_letter_compensation表,由定时任务重试(带指数退避)。陷阱2:事件乱序导致状态错乱
Kafka分区中,"order-created"和"inventory-deducted"可能乱序。
→ 规避:所有事件必须带全局唯一traceId和严格递增的seqNo,消费者端按seqNo排序处理。陷阱3:缺乏事务可见性
用户无法知道当前Saga执行到哪一步。
→ 规避:在Saga启动时,向t_saga_instance表插入记录,每步成功后更新status字段;提供REST API供前端查询进度。
3.4 可靠消息(RocketMQ事务消息):阿里系首选,平衡性最佳
原理还原:
RocketMQ事务消息本质是“半消息”机制。Producer发送消息时,MQ先存半消息(对Consumer不可见),Producer执行本地事务,根据结果告诉MQ“提交”或“回滚”。MQ若未收到结果,会主动回查Producer的本地事务状态。
关键流程:
- Producer发送半消息 → MQ返回half-msg-id;
- Producer执行本地事务(如扣库存);
- Producer根据事务结果,调用MQ的commitMessage()或rollbackMessage();
- 若Producer宕机,MQ在1分钟后发起回查(调用checkLocalTransaction());
- 回查返回COMMIT_ROLLBACK/UNKNOWN,MQ据此投递或删除半消息。
代码骨架:
// 发送事务消息 TransactionMQProducer producer = new TransactionMQProducer("group"); producer.setTransactionListener(new TransactionListener() { @Override public LocalTransactionState executeLocalTransaction(Message msg, Object arg) { try { // 执行本地事务:扣库存 inventoryService.deduct((String) arg); return LocalTransactionState.COMMIT_MESSAGE; // 成功 } catch (Exception e) { return LocalTransactionState.ROLLBACK_MESSAGE; // 失败 } } @Override public LocalTransactionState checkLocalTransaction(MessageExt msg) { // MQ回查:检查本地事务是否成功 String orderId = msg.getUserProperty("orderId"); return inventoryService.isDeductSuccess(orderId) ? LocalTransactionState.COMMIT_MESSAGE : LocalTransactionState.ROLLBACK_MESSAGE; } });致命陷阱与规避方案:
陷阱1:回查机制失效
RocketMQ默认回查15次,每次间隔逐渐拉长。若Producer重启后未加载回查逻辑,回查永远返回UNKNOWN,半消息永久堆积。
→ 规避:回查方法必须是静态方法或单例Bean,确保任何实例都能响应;同时监控half-message积压量,超阈值立即告警。陷阱2:消息重复消费
Commit后Consumer拉取到消息,但处理失败,MQ重投导致重复。
→ 规避:Consumer必须实现幂等,推荐方案是“业务主键+操作类型”作为去重key(如order_123_create)。陷阱3:事务与消息语义割裂
本地事务成功,但commitMessage()网络失败,MQ认为事务失败,而业务已执行。
→ 规避:在本地事务中,把“扣库存”和“记录消息发送状态”放在同一个数据库事务里,确保原子性。
3.5 本地消息表:零中间件依赖,小团队利器
原理还原:
在业务数据库中建一张message表,业务操作和消息记录在同一个本地事务中完成。再起一个独立服务,定时扫描message表,将未发送的消息投递到MQ,并标记为“已发送”。
表结构设计:
CREATE TABLE `local_message` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `msg_id` VARCHAR(64) NOT NULL COMMENT '全局唯一消息ID', `topic` VARCHAR(64) NOT NULL COMMENT 'MQ Topic', `payload` TEXT NOT NULL COMMENT '消息体JSON', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-待发送, 1-已发送, 2-发送失败', `try_count` TINYINT NOT NULL DEFAULT 0 COMMENT '重试次数', `next_retry_time` DATETIME COMMENT '下次重试时间', `created_time` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_msg_id` (`msg_id`) );代码骨架:
// 业务代码:下单并写消息 @Transactional public Order createOrderWithMessage(OrderRequest request) { Order order = orderMapper.insert(request); // 同一事务内写消息表 localMessageMapper.insert(Message.builder() .msgId(UUID.randomUUID().toString()) .topic("order-created") .payload(JSON.toJSONString(order)) .build()); return order; } // 独立消息发送服务 @Scheduled(fixedDelay = 1000) public void sendPendingMessages() { List<LocalMessage> pending = localMessageMapper.selectByStatus(0); for (LocalMessage msg : pending) { try { rocketMQTemplate.convertAndSend(msg.getTopic(), msg.getPayload()); localMessageMapper.updateStatus(msg.getId(), 1); // 标记已发送 } catch (Exception e) { // 发送失败,更新重试信息 int tryCount = msg.getTryCount() + 1; LocalDateTime nextTime = LocalDateTime.now().plusSeconds((long) Math.pow(2, tryCount)); localMessageMapper.updateRetryInfo(msg.getId(), tryCount, nextTime); } } }致命陷阱与规避方案:
陷阱1:消息表成为性能瓶颈
高并发下单时,message表的INSERT竞争激烈。
→ 规避:分表(按月份或用户ID哈希),或改用Redis Stream替代(需保证Redis高可用)。陷阱2:定时扫描效率低下
全表扫描message表,数据量大时IO压力巨大。
→ 规避:添加复合索引(status, next_retry_time),且只查status=0且next_retry_time <= now()的记录。陷阱3:消息丢失风险
本地事务提交后,消息发送服务宕机,消息未发出。
→ 规避:消息发送服务必须集群部署,且使用分布式锁(如Redis Lock)保证同一时刻只有一个实例在扫描。
3.6 最大努力通知(Best-Effort Delivery):对一致性要求最低的兜底方案
原理还原:
不保证消息100%到达,但通过多次HTTP重试(通常3~5次)和失败告警,把送达率提升到99.9%。适用于对一致性要求极低的场景,如发送站内信、更新缓存。
关键设计:
- 通知方记录通知日志(含URL、参数、响应码、耗时);
- 被通知方必须提供幂等接口(如PUT /api/cache/refresh?cacheKey=xxx);
- 通知失败后,按指数退避重试(1s, 3s, 10s, 30s);
- 重试5次仍失败,写入告警表并触发企业微信告警。
代码骨架:
// 通知服务 public class NotifyService { private final RestTemplate restTemplate = new RestTemplate(); public void notifyCacheRefresh(String cacheKey) { String url = "http://cache-service/api/cache/refresh?cacheKey=" + cacheKey; int maxRetries = 5; for (int i = 0; i < maxRetries; i++) { try { ResponseEntity<String> response = restTemplate.exchange( url, HttpMethod.PUT, null, String.class); if (response.getStatusCode().is2xxSuccessful()) { log.info("Cache refresh success: {}", cacheKey); return; } } catch (Exception e) { log.warn("Notify failed, retry {}/{}", i + 1, maxRetries, e); } // 指数退避 try { Thread.sleep((long) Math.pow(2, i) * 1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } // 重试失败,告警 alertService.sendAlert("Cache refresh failed for key: " + cacheKey); } }致命陷阱与规避方案:
陷阱1:被通知方无幂等,重试导致数据错乱
如PUT接口实际是“+1”,重试三次变成“+3”。
→ 规避:强制要求被通知方接口必须是幂等的,推荐方案是“带版本号的乐观锁更新”(如PUT /cache/{key}?version=123)。陷阱2:通知方自身无持久化,宕机丢失通知
通知逻辑在内存中执行,服务重启后未完成的通知消失。
→ 规避:所有通知请求必须先落库(notify_log表),再异步执行;定时任务扫描未完成的通知。陷阱3:网络抖动误判为失败
网络延迟高时,HTTP请求超时,但被通知方其实已处理。
→ 规避:被通知方处理完成后,必须返回明确的成功标识(如{"code":0,"msg":"success"}),通知方只认这个标识,不依赖HTTP状态码。
3.7 补偿事务(手动Saga):无框架依赖,适合遗留系统
原理还原:
不借助任何框架,纯靠业务代码实现Saga。核心是维护一个“事务步骤表”,记录每个步骤的执行状态,失败时按逆序调用补偿方法。
表结构设计:
CREATE TABLE `compensation_task` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `biz_type` VARCHAR(32) NOT NULL COMMENT '业务类型,如order_create', `biz_id` VARCHAR(64) NOT NULL COMMENT '业务ID,如订单号', `step` VARCHAR(32) NOT NULL COMMENT '步骤名,如deduct_inventory', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-待执行, 1-成功, 2-失败, 3-已补偿', `compensate_method` VARCHAR(128) NOT NULL COMMENT '补偿方法全限定名', `params` TEXT COMMENT '补偿参数JSON', `created_time` DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX `idx_biz` (`biz_type`, `biz_id`) );执行流程:
- 启动事务:向compensation_task插入所有步骤(status=0);
- 按顺序执行每个步骤,成功则update status=1;
- 某步骤失败,启动补偿:按biz_id+step逆序查出所有status=1的记录,依次调用compensate_method;
- 补偿成功,update status=3。
致命陷阱与规避方案:
陷阱1:补偿方法调用失败,无重试机制
补偿操作本身可能因网络、DB问题失败。
→ 规避:补偿执行也必须记录日志,并加入重试队列(如用RabbitMQ的delayed message插件)。陷阱2:步骤状态不一致
步骤A成功,步骤B失败,但补偿执行时步骤A的补偿也失败,整个事务卡住。
→ 规避:所有补偿方法必须设计为“幂等且可重入”,且补偿失败时,必须更新compensation_task.status为“补偿失败”,触发人工介入。陷阱3:缺乏可视化监控
运维人员无法直观看到某个订单的Saga执行到哪一步。
→ 规避:提供管理后台,按biz_id查询所有步骤状态,并支持手动触发某步补偿。
3.8 定时校对(Reconciliation Job):最终一致的兜底防线
原理还原:
不追求实时一致,而是通过定时任务(如每天凌晨)扫描核心业务表,比对各系统数据差异,生成修复SQL或工单。这是所有最终一致方案的最后保险。
典型校对场景:
- 订单表 vs 支付流水表:找出已支付但订单状态未更新的订单;
- 用户表 vs 积分表:找出用户注册成功但积分未初始化的账号;
- 商品表 vs 库存表:找出商品上架但库存未同步的SKU。
高效校对技巧:
- 增量校对:不全表扫描,而是基于last_modified_time字段,只校对最近24小时变更的数据;
- 分片校对:对大表按ID取模分片(如id % 100),100个线程并行校对;
- 结果分级:校对出的差异分为“自动修复”(如UPDATE语句)和“人工确认”(如涉及金额差异>100元)两类。
代码骨架(Spring Batch):
// Step1:读取订单数据 @Bean public JdbcCursorItemReader<Order> orderReader() { JdbcCursorItemReader<Order> reader = new JdbcCursorItemReader<>(); reader.setSql("SELECT id, status, pay_status FROM order WHERE last_modified_time > ?"); reader.setDataSource(dataSource); reader.setRowMapper(new BeanPropertyRowMapper<>(Order.class)); return reader; } // Step2:处理器:比对支付状态 @Bean public ItemProcessor<Order, FixCommand> fixProcessor() { return order -> { Payment payment = paymentService.getByOrderId(order.getId()); if (payment != null && !order.isPaid() && payment.isSuccess()) { // 生成修复命令 return FixCommand.builder() .type("ORDER_STATUS_FIX") .sql("UPDATE order SET status='paid', pay_status='success' WHERE id=?") .params(Collections.singletonList(order.getId())) .build(); } return null; }; } // Step3:写入修复队列 @Bean public ItemWriter<FixCommand> fixWriter() { return commands -> { for (FixCommand cmd : commands) { if ("auto".equals(cmd.getLevel())) { jdbcTemplate.update(cmd.getSql(), cmd.getParams().toArray()); } else { // 写入人工工单表 ticketService.createTicket(cmd); } } }; }致命陷阱与规避方案:
陷阱1:校对窗口期过长,问题积累成灾
每天校对一次,若系统连续三天异常,差异数据量爆炸。
→ 规避:核心链路(如订单-支付)校对频率提至每小时一次;非核心链路(如用户-积分)保持每日。陷阱2:校对SQL性能差,拖垮数据库
全表JOIN校对,高峰期CPU 100%。
→ 规避:校对SQL必须走索引,且禁止跨库JOIN;大表校对改用ES聚合(先同步binlog到ES,再用ES聚合比对)。陷阱3:修复操作本身引发新问题
UPDATE语句未加WHERE条件,误更新全表。
→ 规避:所有修复SQL必须经过SQL审核平台,且执行前输出影响行数预估(EXPLAIN);生产环境禁用无WHERE的UPDATE。
4. 实战避坑指南:那些文档里不会写的血泪教训
4.1 日志与监控:没有日志的分布式事务就是黑盒
我见过太多团队,上线Saga后遇到问题,第一反应是“看日志”,结果发现:
- Try阶段日志只有一行“开始执行”,没参数没上下文;
- 补偿操作失败,日志里只有“compensate failed”,没堆栈没SQL;
- 消息发送失败,日志里连HTTP响应码都没记录。
必须记录的7类黄金日志:
- 事务启动日志:包含全局事务ID、业务类型、发起方IP、时间戳;
- 每步操作日志:包括输入参数(脱敏)、SQL(如有)、耗时、返回结果;
- 补偿触发日志:谁触发的、触发原因(异常堆栈)、补偿参数;
- 消息中间件交互日志:发送/接收的消息ID、Topic、Payload摘要(如MD5);
- 状态变更日志:tcc_state、saga_step、message表的每一次UPDATE;
- 定时任务日志:校对任务的扫描范围、比对数量、修复数量;
- 告警日志:所有触发告警的操作,必须包含可直接执行的排查命令(如“请执行:SELECT * FROM tcc_log WHERE tx_id='xxx' ORDER BY create_time DESC LIMIT 10”)。
实操心得:某次大促,订单创建成功率突然跌到82%。我们翻遍应用日志,只看到“Saga step 2 failed”。后来打开MQ的死信队列,发现全是库存服务的消费失败消息,错误是“Redis connection timeout”。原来库存服务的Redis连接池配置太小,高并发下连接耗尽。这个线索,只在MQ的broker日志里有,而我们的应用日志完全没记录。从此我们强制要求:所有中间件调用,必须在应用日志里记录“中间件名称+操作+耗时+关键错误码”。
4.2 幂等性设计:不是加个注解就叫幂等
很多开发者以为加个@Idempotent(key="#userId")就万事大吉,但真正的幂等必须覆盖三类异常:
- 网络层重试:Feign Client默认重试,一次请求可能发三次;
- 应用层重试:RabbitMQ消息重投、RocketMQ重试;
- 用户层重试:前端按钮未置灰,用户狂点三次。
四层幂等防护体系:
| 层级 | 方案 | 适用场景 | 示例 |
|---|---|---|---|
| 接入层 | Nginx限流+去重 | 防止用户狂点 | 请求头带X-Request-ID,Nginx检测重复ID直接返回429 |
| 网关层 | Spring Cloud Gateway过滤器 | 统一拦截 | 解析请求参数,生成幂等key存 |