半夜两点被电话叫起来运营语气很急商品A的库存变成负数了。打开数据库一看下单记录两条——用户点了一次下单按钮前端超时后自动重试了一次网关层的重试机制又补了一刀三笔请求最终都执行了库存扣减钱却只付了一份。库存对不对得上账仓库、物流、财务全跟着乱。这类事故在微服务架构里几乎是必经之路而SpringCloud分布式事务本质上就是来解决两个问题的钱不能算错库存不能扣重复。这篇文章我会从下单链路说起把分布式事务的来龙去脉、方案选型、幂等设计、代码落地和生产事故完整讲一遍。内容偏实战适合正在做订单、库存、账户这类系统的团队参考也适合想从单体过渡到微服务的同学把这块短板补上。1. 一次下单背后的三笔账先把问题看清楚1.1 单体时代的一个事务有多舒服在单体应用里创建订单、扣库存、扣账户余额全部发生在同一个数据库内。Spring的Transactional包一层中间任何一步抛异常整体回滚。数据要么全部成功要么全部失败不需要额外的协调机制。这也是很多从单体转微服务的团队首先感到不适的地方。订单、库存、账户被拆成三个独立服务各自拥有独立数据库但业务上仍然要求下单成功就必须扣库存库存扣了就必须扣钱。这三个动作跨三个数据源Spring的本地事务完全管不到其他服务。我第一次接分布式事务需求时曾天真地想把三个服务的数据源指向同一个库不就行了结果被DBA一口回绝。后来才意识到问题的本质不是数据放哪而是多个独立进程协作完成一个业务单元时如何保证它们状态的一致性。1.2 微服务拆分后一致性风险从哪些环节冒出来一次普通的下单背后至少有三个服务参与。订单服务插入一条订单记录状态设为待支付库存服务扣减商品库存数量账户服务扣减用户账户余额三个RPC调用串起来任意一个失败都可能出问题。库存扣减成功了账户扣款超时——钱没扣到库存却少了事后无法自动对平。订单创建成功但库存服务突然宕机用户拿着订单去发货仓库根本找不到货可发。更隐蔽的是重复扣库存。调用方在超时后通常会发起重试库存服务可能同时收到两条请求MQ消费端收到重复投递的消息时没做幂等处理库存也会被多扣一次。钱的错误更麻烦账户余额一旦被重复扣减用户直接投诉资金平账的流程非常痛苦甚至要人工介入修复。1.3 把业务约束翻译成技术语言钱不能算错不是要求所有操作都实时强一致而是最终账目必须精确。余额不能凭空多扣每一笔流水必须和业务单据一一对应任何重复请求都不能造成重复扣款。库存不能扣重复比账目还要敏感。钱扣错了可以退款库存扣错了只能人工补数据而且很难恢复原状。数据库里库存一旦变成负数对账、补单、物流全链路都会出现连锁问题。把这两个约束翻译成技术语言就是两个核心课题分布式事务和幂等。下面从理论基础开始聊方案取舍再落到代码和生产。2. 为什么强一致方案在分布式场景里叫好不叫座2.1 CAP定理给分布式事务划出的边界分布式绕不开CAP定理一致性、可用性、分区容错性最多只能同时满足两个。网络分区在分布式环境下无法避免所以必须在一致性C和可用性A之间做取舍。极端追求强一致CP网络抖动时请求只能等待或失败服务在用户眼里就变不可用了。选择最终一致AP则要接受短时间内数据状态不一致再通过补偿机制让数据慢慢收敛。微服务业务以用户体验和系统可用性为重客户绝不会因为库存服务慢就容忍整个下单接口挂掉。所以现实方案几乎都走AP路线。2.2 2PC为什么在微服务里被放弃教科书里的两阶段提交2PC把事务分为prepare和commit两个阶段协调者要求所有参与者先预留好资源全部就绪后统一提交。听起来很完美落地到微服务却问题重重。资源长时间被锁prepare阶段把数据行全部锁住一个分支网络慢其他分支的数据就被一直锁着高并发下整个服务跟着被拖垮。协调者单点和恢复复杂协调者在prepare完成后宕机所有分支都不知道该提交还是回滚只能干等超时这种悬而未决的状态非常难处理。参与者类型难以统一订单、库存、账户用的数据库、中间件类型五花八门很多中间件根本不支持XA协议。生产环境中认真用2PC做微服务分布式事务的团队很少它适合数据库层面的强一致不适合业务层面的长事务。2.3 BASE理论才是分布式事务的生存土壤BASE理论核心是三个词基本可用、软状态、最终一致。通俗讲就是允许操作过程中出现中间状态但不允许中间状态永远停在那最终必须收敛到一致。在这个框架下原本一个事务里完成的动作被拆成正向操作 补偿/回滚机制。正向操作先成功如果后续步骤失败就通过补偿把前面步骤撤销。业务响应速度快了数据也在可接受的时间窗内归位。这也是为什么后来的分布式事务方案没有叫分布式事务中间件之外还会强调柔性事务最终一致性。因为分布式事务真正的难点不在于让所有节点同时成功而在于如何在部分失败时把系统拉回正确的终态。3. 分布式事务方案盘点TCC、SAGA、本地消息表、Seata怎么选3.1 TCCTry-Confirm-Cancel灵活但开销大TCC把每个业务动作拆成三个方法。Try先尝试预留资源。库存服务冻结N件商品账户服务冻结M元ConfirmTry全部成功后确认执行把冻结转成真实扣减CancelTry不成功释放冻结这种设计最大的优势是业务语义可控粒度细不需要全局锁性能和并发能力都很好。但代价也极其明显每个参与方都得写Try、Confirm、Cancel三套方法还要处理空回滚Try没执行但收到了Cancel和悬挂Cancel先到Try后到才处理这些边界问题。我曾经在一个库存项目里写过TCC单是冻结库存资源的清理逻辑就花了一周测试各种边界情况更是焦头烂额。库存、账户这类敏感场景TCC在团队能力允许时是很好的选择但对大多数中小团队来说开发量偏大容易出细节bug。3.2 SAGA适合长链路补偿逻辑要自己扛SAGA把整个链路拆成若干正向事务和对应的补偿事务不抢占资源靠流程编排在失败时逐级反向补偿。适合下单→发券→履约→出账这类横跨很多服务的超长流程。代价是每个反向步骤的补偿方法都得自己写补偿本身还要满足幂等。如果补偿执行了两次数据照样被搞坏。SAGA比较适合那种失败后不追求立即回滚靠异步补偿慢慢纠正的业务形态。3.3 本地消息表最朴素但非常实用本地消息表的思路是在业务库建一张消息表业务操作和消息写入放在同一个本地事务里。业务成功后消息状态标记为待发送异步把消息发到MQ消费者处理完再更新消息状态。如果发送失败定时任务扫描重发。它的优点是不引入额外的全局协调器适合发起方业务稳定、消费方接受最终一致的单向流场景比如下单成功后发通知、更新搜索引擎索引。缺点也很明显它只能处理一边写本地、一边对外发消息这种单向流程对跨服务多分支的复杂场景支持不足。3.4 Seata AT模式把回滚自动化Seata的AT模式Automatic Transaction是用起来最接近单机事务的方案。在入口方法上标一个GlobalTransactional各参与服务仍然写普通本地TransactionalSeata通过代理数据源自动记录操作前后的镜像生成undo_log出错时自动反向补偿。它特别适合已有Spring Cloud或Dubbo技术栈、业务主要操作数据库的团队。相比TCC的研发量AT模式落地快相比SAGA的纯手工补偿AT模式自动化程度高。代价同样存在全局锁可能降低并发undo_log表需要定期治理。3.5 选型怎么拍板方案强一致程度开发量对业务侵入关键代价适合场景2PC高中中长锁、协调者故障恢复难数据库级强一致微服务里很少直接用TCC较高高高三套方法 空回滚/悬挂处理资金、库存等敏感业务团队能力强SAGA最终一致中高中每个反向步骤要手写补偿长流程、跨多服务本地消息表最终一致低中适合单向异步流下单发消息、索引同步Seata AT最终一致低低全局锁与undo_log开销Spring Cloud系、数据库操作型业务以下单扣库存为例如果库存和账户都是常规数据库操作我更推荐先上Seata AT。等真正出现性能瓶颈时再针对具体热行升级成TCC或异步削峰通常比一开始就上一套复杂方案稳妥得多。4. 库存不能扣重复从SQL到幂等设计的完整防线4.1 重复扣库存的三个来源用户重复提交是最常见的来源。前端没做防抖或者防抖失效用户连点两三次浏览器发出多个请求。第二类来源是调用超时重试订单服务调用库存服务时网络抖动响应超时但库存服务可能已经把扣减执行完了订单服务发起重试库存又被扣一次。第三类是消息重复消费MQ保证的是至少一次投递消费端如果不做幂等一条消息被消费多次对应多次扣减。这三类问题叠加在一起光靠扣减SQL写得严是不够了必须在多个层面设置防线。4.2 最可靠的兜底数据库唯一约束我最推荐的防重手段是数据库唯一索引不依赖任何中间件可靠而且是天然的去重机制。具体做法是在库存扣减日志表上对业务幂等键建立唯一索引。CREATE TABLE stock_deduct_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(64) NOT NULL, product_id BIGINT NOT NULL, deduct_count INT NOT NULL, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;order_no是整个下单流程里最稳定的幂等键一个订单最多扣一次库存。服务在扣库存前先尝试插入stock_deduct_log插入成功说明本次扣减是首次继续执行插入时捕获到重复键异常说明已经扣过直接返回业务成功而不是报错。注意幂等键的范围不能太宽。如果有人把唯一键建成了user_id product_id同一用户对同一商品只能扣一次库存后续合法的新订单全被误杀了。幂等键应该用每次业务唯一的ID比如order_no或operation_no。4.3 扣减SQL的正确写法扣库存核心SQL强烈建议用带条件的原子更新。UPDATE stock SET stock_qty stock_qty - #{count} WHERE product_id #{productId} AND stock_qty #{count}stock_qty count这个条件很关键它保证剩余库存不足时UPDATE影响行数为0代码据此返回库存不足不会出现库存扣成负数再回滚的尴尬。数据库的行锁机制下并发扣减同一商品虽然会排队但每条语句拿到的都是准确的最新库存。有团队喜欢在此基础上加乐观锁version字段但多数场景stock_qty count已经够用。加version反而要小心version过期的请求会直接失败需要额外的重试逻辑复杂度反而上去了。4.4 分布式锁应该放在哪个粒度Redis分布式锁经常被拿来做扣库存的防重但我实际项目里的用法很克制。锁粒度太粗对整件商品加锁同一商品所有下单互相阻塞扣库存变串行高并发下性能难看锁粒度适中对userId orderNo加锁能防同一个用户重复提交但防不了不同用户并发扣同件商品——不过这个已经被数据库原子更新解决了所以我的结论是能用数据库约束解决的就不用Redis锁。Redis锁只用来做用户级防重复提交这一层还要统一用固定过期时间兜底防止锁服务故障时锁一直不释放。4.5 消息消费端必须自己保证幂等如果用MQ做异步解耦不能假设上游一定会去重。消费端收到扣减消息后依然要先查stock_deduct_log是否存在存在就确认消费甚至直接丢弃不存在才执行真正的扣减逻辑。消息投递的至少一次机制决定了消费端幂等不是可选项是必选项。很多团队只看重消息发送端可靠性忽略了消费端幂等结果上游明明只发了一次消息消费者却因为重启或网络问题消费了两次库存照样重复扣。5. 代码落地用Seata AT模式托住订单-库存-账户链路理论和方案讲完直接跑通一条链路最实在。下面用一个Spring Cloud Seata AT模式的最小可运行案例覆盖订单创建、库存扣减、账户扣款三个服务。5.1 依赖与基础设施准备中间件选Nacos做注册中心和配置中心Seata Server做事务协调器MySQL存业务数据。三个服务的核心依赖基本一致。dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-nacos-discovery/artifactId /dependency dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-seata/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId /dependency每个业务库都需要建一张undo_log表AT模式靠它实现自动回滚。CREATE TABLE undo_log ( id BIGINT(20) NOT NULL AUTO_INCREMENT, branch_id BIGINT(20) NOT NULL, xid VARCHAR(100) NOT NULL, context VARCHAR(128) NOT NULL, rollback_info LONGBLOB NOT NULL, log_status INT(11) NOT NULL, log_created DATETIME NOT NULL, log_modified DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY ux_undo_log (xid,branch_id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8;Seata Server启动也不复杂下载对应版本包配置好Nacos地址和存储方式再启动即可。我建议把存储方式配置成DB而不是file否则集群多实例时事务状态不一致线上容易出奇怪问题。5.2 数据源代理配置AT模式的关键是把业务数据源替换成DataSourceProxy。没有这层代理Seata无法拦截SQL也就无法生成前后镜像和undo_log。Configuration public class SeataDataSourceConfig { Bean Primary public DataSource dataSource(DataSourceProperties properties) { DruidDataSource druidDataSource new DruidDataSource(); druidDataSource.setUrl(properties.getUrl()); druidDataSource.setUsername(properties.getUsername()); druidDataSource.setPassword(properties.getPassword()); return new DataSourceProxy(druidDataSource); } }很多团队配置完依赖后忘了替换数据源执行调用看起来一切正常但一查undo_log表里根本没有记录回滚自然也不会生效。遇到这种情况第一反应先查数据源是不是被代理了。5.3 核心服务代码与事务注解下单入口在OrderService加上GlobalTransactional。这个注解里的数据库操作和远程调用会被Seata纳入同一个全局事务。Service public class OrderService { Autowired private OrderMapper orderMapper; Autowired private StockFeignClient stockClient; Autowired private AccountFeignClient accountClient; GlobalTransactional(name order-create-global-tx, rollbackFor Exception.class) public void createOrder(OrderCreateDTO dto) { Order order new Order(); order.setOrderNo(dto.getOrderNo()); order.setUserId(dto.getUserId()); order.setProductId(dto.getProductId()); order.setAmount(dto.getAmount()); order.setStatus(OrderStatus.WAIT_PAYMENT); orderMapper.insert(order); stockClient.deductStock(dto.getOrderNo(), dto.getProductId(), dto.getCount()); accountClient.deductBalance(dto.getOrderNo(), dto.getUserId(), dto.getAmount()); } }库存服务里扣库存时写入幂等日志同时执行带条件的原子更新。Transactional public void deductStock(String orderNo, Long productId, Integer count) { StockDeductLog log new StockDeductLog(); log.setOrderNo(orderNo); log.setProductId(productId); log.setDeductCount(count); try { stockDeductLogMapper.insert(log); } catch (DuplicateKeyException e) { return; } int rows stockMapper.deductStock(productId, count); if (rows 0) { throw new BusinessException(库存不足); } }Mapper里的SQL对应update iddeductStock UPDATE stock SET stock_qty stock_qty - #{count} WHERE product_id #{productId} AND stock_qty #{count} /update账户服务同理扣款SQL里要带上balance amount守卫同时用account_flow_log幂等表保证一个order_no只扣一次。update iddeductBalance UPDATE account SET balance balance - #{amount} WHERE user_id #{userId} AND balance #{amount} /update5.4 验证全局回滚是否生效模拟第3步账户扣款抛异常比如在AccountService里放一个开关条件满足时直接抛RuntimeException再调用下单接口。你会看到数据库里的现象是订单表的新增订单被回滚库存表刚扣减的数量被恢复库存幂等日志表里对应的记录也被清理。这一切不需要手写任何补偿方法Seata AT在全局事务失败时自动利用各分支的undo_log反向执行补偿。代价是库存行在全局事务执行期间被全局锁保护同一商品的并发订单在极端情况下会排队等待。6. 生产事故复盘钱算错、库存扣重复的几种隐蔽场景6.1 事故一全局事务里夹带了第三方长调用有次团队A上线时把调用外部支付网关的动作放在了GlobalTransactional里。支付接口是真实第三方响应经常要2到5秒极端情况到10秒。后果是全局事务分支长锁库存和账户行一直被锁着订单接口P99直线飙升支付网关回调同时进来连接池被占满服务开始一连串超时。教训很明确全局事务的作用域要控制在数据库操作和同一套事务框架能管理的范围。外部系统调用、第三方API、消息发送这类不确定耗时的动作要么挪到全局事务之外通过可靠消息或补偿流程处理要么引SAGA式的异步编排绝不硬塞进长事务。6.2 事故二防重日志被回滚干净导致重试再次扣重这是我最想提醒的一次。有个团队为了全局回滚不留痕迹在一个分支的回滚逻辑里把幂等记录也删了。结果意外的是全局事务超时回滚幂等记录被删除但调用方又触发了重试第二次扣库存时insert stock_deduct_log成功等于重复扣减。库存没等你发现已经错了。我的建议是幂等日志只增不改、只增不删。业务数据可以回滚但防重记录保存着这个请求已经处理过的铁证删了就等于引狼入室。宁可多留几条历史记录也不能在这个环节贪图清爽。6.3 事故三幂等键建得太宽正常的第二次下单被拦截运营反馈一个用户对同一个商品下两次单第二次永远失败。查了防重表唯一键是user_id product_id。第一次下单把记录插了进去第二次下单同用户同商品插入重复键后被当成重复请求返回了业务成功但订单根本没落库。幂等键的正确设计是一个业务请求一个固定值而不是一类请求共用一个值。还是那句老话防重表防的是同一个业务请求的重复不是防正常的新请求。把键的范围缩小到order_no或request_no这个问题自然消失。6.4 事故四大促期间库存行锁冲突拖垮全链路Seata AT的全局锁在低并发下无感但大促场景同一商品被大量用户抢购时库存行的锁冲突会非常严重。我们有一次压测库存服务RT从30ms涨到800ms原因就是所有下单全局事务都在等同一行库存的全局锁锁等待耗时比业务执行本身还长。后来在流量入口做了挡截和削峰把同一商品的并发扣减在应用层做排队RT才降下来。更务实的做法是秒杀类场景别走全量强事务把库存预扣改成前置校验加异步落库用最终一致去弥补常规订单走完整事务链路两者分开互不拖累。6.5 可观测性分布式事务出问题怎么快速定位线上故障定位比功能实现更吃功夫。强烈建议把事务的关键信息打点全局事务xid、分支branchId、幂等键orderNo、各参与方执行状态。Seata内置的GlobalSession查询接口能查到会话状态但真正到线上日志里把xid贯穿起来比什么都好用。我给团队定的规矩是所有下游调用日志统一带上xid回滚时顺着xid能立刻定位到哪一步失败、哪个分支回滚失败。日志里看到GlobalTransaction rolled back和Branch transaction rolled back的配对出现问题基本一目了然。7. 一些落地经验也算给项目收尾做钱不能算错、库存不能扣重复这类系统我的体会可以总结成三条。第一先保底再谈优雅。唯一索引、原子UPDATE、防重表这些数据库层面的东西永远最可靠先把它们做对再考虑中间件层面的协调。基础防线不稳上层方案再高端也是空中楼阁。第二方案要克制。不是每个项目都需要全局事务。很多场景把本地事务 幂等表 异步重试玩明白能解决八成问题剩下的再用Seata AT或TCC补位也不迟。项目里最忌惮的就是一上来上最重的方案把团队拖进了复杂的调试泥潭。第三线上定位能力要提前做功课。把日志的xid打全、幂等键设计正确、防重记录设成只增不删这些细节能省掉无数个半夜排查的痛苦。真到出问题时考验的不是你选的多高级而是你把这些基础防线打得有多扎实。
阅读完成 · 觉得有帮助?