1. 为什么我们需要重新思考分布式事务方案
在传统数据库架构中,当业务发展到一定规模后,系统往往会面临一个关键选择:是继续使用单机数据库承受性能压力,还是转向分布式架构引入额外的复杂度。特别是在需要保证数据一致性的场景下,这个选择尤为艰难。
我经历过太多这样的架构讨论会:开发团队坚持要上分布式事务,DBA团队则拼命反对,理由永远是"2PC(两阶段提交)性能太差"。双方各执一词,最后往往演变成一场没有赢家的拉锯战。但事实上,很多场景下我们可能走入了思维定式——并不是所有跨服务的数据操作都需要2PC这种重量级方案。
2. 本地事务+补偿机制的核心思想
2.1 基本原理剖析
这种方案的核心在于充分利用MySQL单机事务的ACID特性,将需要跨服务保证一致性的操作拆分为两个阶段:
- 主事务阶段:在本地事务中完成核心数据变更,同时记录补偿操作
- 补偿阶段:当后续步骤失败时,根据记录执行逆向操作
这种模式最经典的实现就是TCC(Try-Confirm-Cancel)模式。以一个电商下单为例:
-- Try阶段(主事务) BEGIN; -- 1. 扣减库存(核心操作) UPDATE inventory SET stock = stock - 1 WHERE item_id = 1001; -- 2. 记录补偿日志(关键步骤) INSERT INTO tcc_log (biz_id, operation, params) VALUES ('order_123', 'restore_inventory', '{"item_id":1001,"delta":1}'); COMMIT; -- Confirm阶段(无事务) -- 调用支付服务成功后删除补偿日志 DELETE FROM tcc_log WHERE biz_id = 'order_123'; -- Cancel阶段(需要时执行) -- 如果支付失败,读取补偿日志执行逆向操作 BEGIN; UPDATE inventory SET stock = stock + 1 WHERE item_id = 1001; DELETE FROM tcc_log WHERE biz_id = 'order_123'; COMMIT;2.2 与2PC的本质区别
这种方案与传统的2PC有几点关键差异:
- 事务边界不同:2PC是跨数据库的分布式事务,而本地事务+补偿是在单库内完成
- 失败处理方式:2PC依赖协调者进行全局回滚,补偿机制是事后补救
- 性能影响:2PC需要多次网络通信和锁保持,补偿机制主事务结束后立即释放资源
3. 详细实现方案设计
3.1 数据模型设计
需要设计几个核心表来支持这个机制:
CREATE TABLE tcc_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_id VARCHAR(64) NOT NULL COMMENT '业务ID', operation VARCHAR(128) NOT NULL COMMENT '补偿操作类型', params JSON NOT NULL COMMENT '补偿参数', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_biz_id (biz_id) ) ENGINE=InnoDB; CREATE TABLE async_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_id VARCHAR(64) NOT NULL COMMENT '关联的业务ID', task_type VARCHAR(64) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待处理,1-处理中,2-成功,3-失败', retry_count INT NOT NULL DEFAULT 0, execute_after DATETIME NOT NULL COMMENT '最早执行时间', params JSON NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status_execute (status, execute_after) ) ENGINE=InnoDB;3.2 核心流程实现
完整的事务+补偿流程实现如下:
- 主事务执行:
@Transactional public void createOrder(OrderDTO order) { // 1. 扣减库存 inventoryService.reduceStock(order.getItemId(), order.getQuantity()); // 2. 生成订单记录 orderMapper.insert(order); // 3. 记录补偿日志 TccLog log = new TccLog(); log.setBizId(order.getOrderNo()); log.setOperation("restore_inventory"); log.setParams(JSON.toJSONString(Map.of( "itemId", order.getItemId(), "quantity", order.getQuantity() ))); tccLogMapper.insert(log); // 4. 创建异步任务(调用支付服务) AsyncTask task = new AsyncTask(); task.setBizId(order.getOrderNo()); task.setTaskType("PAYMENT"); task.setExecuteAfter(new Date()); // 立即执行 task.setParams(JSON.toJSONString(order)); asyncTaskMapper.insert(task); }- 补偿处理器实现:
public void handlePaymentResult(String orderNo, boolean success) { if(success) { // 成功则删除补偿日志 tccLogMapper.deleteByBizId(orderNo); } else { // 失败则执行补偿 TccLog log = tccLogMapper.selectByBizId(orderNo); if(log != null) { Map<String, Object> params = JSON.parseObject(log.getParams()); inventoryService.increaseStock( (Long)params.get("itemId"), (Integer)params.get("quantity") ); tccLogMapper.deleteByBizId(orderNo); } } }4. 关键问题与解决方案
4.1 补偿操作的幂等性
补偿操作必须保证幂等,因为网络问题可能导致补偿被多次触发。解决方案:
- 在补偿日志表中记录执行状态
- 使用乐观锁控制并发
- 补偿操作本身设计为可重复执行
改进后的补偿日志表:
ALTER TABLE tcc_log ADD COLUMN status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待补偿,1-补偿中,2-已完成'; ALTER TABLE tcc_log ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本';对应的补偿执行逻辑:
@Transactional public void compensate(String bizId) { // 乐观锁控制 TccLog log = tccLogMapper.selectForUpdate(bizId); if(log == null || log.getStatus() == 2) { return; // 已处理过 } if(log.getStatus() == 1 && System.currentTimeMillis() - log.getUpdateTime().getTime() < 60000) { return; // 其他线程正在处理 } // 更新状态为处理中 tccLogMapper.updateStatus(bizId, 1, log.getVersion()); // 执行补偿逻辑 Map<String, Object> params = JSON.parseObject(log.getParams()); inventoryService.increaseStock( (Long)params.get("itemId"), (Integer)params.get("quantity") ); // 更新状态为已完成 tccLogMapper.updateStatus(bizId, 2, log.getVersion() + 1); }4.2 补偿遗漏问题
系统崩溃可能导致补偿未被触发。解决方案:
- 定时扫描长时间未完成的业务
- 设置合理的超时时间
- 实现补偿重试机制
定时任务实现示例:
@Scheduled(fixedRate = 60000) public void scanTimeoutOrders() { // 查询超过30分钟未完成的订单 List<Order> timeoutOrders = orderMapper.selectTimeoutOrders(30); for(Order order : timeoutOrders) { // 检查是否已经处理过补偿 TccLog log = tccLogMapper.selectByBizId(order.getOrderNo()); if(log != null && log.getStatus() == 0) { compensateExecutor.execute(() -> compensate(order.getOrderNo())); } } }5. 性能优化实践
5.1 批量补偿处理
当需要处理大量补偿时,单个处理效率低下。可以优化为批量模式:
public void batchCompensate(List<String> bizIds) { // 按业务类型分组 Map<String, List<String>> groups = bizIds.stream() .collect(Collectors.groupingBy(bizId -> { TccLog log = tccLogMapper.selectByBizId(bizId); return log.getOperation(); })); // 批量处理同类型补偿 groups.forEach((operation, ids) -> { List<TccLog> logs = tccLogMapper.selectBatchForUpdate(ids); // 按操作类型路由到不同的处理器 switch(operation) { case "restore_inventory": batchRestoreInventory(logs); break; case "cancel_order": batchCancelOrders(logs); break; // 其他操作类型... } }); }5.2 补偿日志归档
长期积累的补偿日志会影响性能,需要定期归档:
-- 创建归档表 CREATE TABLE tcc_log_archive LIKE tcc_log; -- 每月归档一次 INSERT INTO tcc_log_archive SELECT * FROM tcc_log WHERE status = 2 AND created_at < DATE_SUB(NOW(), INTERVAL 3 MONTH); DELETE FROM tcc_log WHERE status = 2 AND created_at < DATE_SUB(NOW(), INTERVAL 3 MONTH);6. 实际应用中的经验总结
6.1 适合场景判断
经过多个项目实践,我发现这种方案特别适合以下场景:
- 主从业务模式:核心业务在主系统,边缘业务在子系统
- 有明确补偿逻辑的业务:如库存增减、账户余额变动等
- 对实时性要求不严格的场景:允许秒级延迟最终一致
6.2 踩坑记录
补偿顺序问题:多个补偿操作间如果有依赖关系,需要特别注意执行顺序。我们曾经遇到过先退款后恢复库存导致的负库存问题。
日志表索引设计:最初没有为biz_id加唯一索引,导致重复补偿。后来加上了唯一索引并配合乐观锁才彻底解决。
网络超时设置:调用外部服务时,超时时间设置过短会导致误补偿。现在我们会根据历史数据动态调整超时阈值。
6.3 监控指标建议
为了确保方案可靠运行,建议监控以下指标:
- 补偿触发率(补偿次数/总事务数)
- 平均补偿延迟(从主事务完成到补偿执行的时间差)
- 补偿成功率
- 单次补偿耗时
我们使用的Prometheus监控配置示例:
- name: tcc_metrics rules: - record: tcc_compensation_rate expr: rate(tcc_compensation_total[5m]) / rate(tcc_transaction_total[5m]) - record: tcc_compensation_latency_seconds expr: histogram_quantile(0.95, sum(rate(tcc_compensation_latency_seconds_bucket[5m])) by (le))这种本地事务+补偿的方案,在保证数据一致性的同时,避免了分布式事务的性能开销。经过多个项目的验证,在单机MySQL环境下,TPS可以比2PC方案提升3-5倍,同时99%的补偿能在1秒内完成。对于大多数业务规模适中的系统,这确实是一个值得考虑的折中方案。