1. 从一次超卖事故说起:为什么单机事务思维在分布式场景下必然翻车
库存扣减、订单创建、优惠券核销、积分发放——这四个动作在单体应用里就是四个本地方法调用,包在一个@Transactional注解里,数据库帮我们搞定一切。但一旦系统拆分成独立的库存服务、订单服务、营销服务、会员服务,事情就完全变了。
我见过一个真实的线上事故:大促期间,某商品库存只剩 3 件,结果瞬间涌进来 5 个下单请求,每个请求都先查库存"还有 3 件,够",然后各自扣减,最终库存变成了 -2。这就是典型的并发场景下分布式事务缺失导致的数据不一致。更麻烦的是,订单创建成功了,但优惠券核销失败,用户拿着已经用掉的券来投诉;或者积分发放超时,用户下单后迟迟收不到积分,客服电话被打爆。
这些问题的根源在于:分布式环境下,没有全局的事务协调者来保证多个服务的操作要么全部成功,要么全部回滚。每个服务只能看到自己的本地数据库,它们之间通过网络通信,而网络是不可靠的——可能超时、可能丢包、可能重复。你无法像单机数据库那样简单地"回滚"。
所以,库存、订单、优惠券、积分这四个核心业务对象的分布式事务处理,本质上是要解决三个问题:原子性(要么全成功要么全失败)、一致性(数据最终正确)、隔离性(并发操作互不干扰)。而这三个问题,在不同的业务场景下,需要用不同的方案来权衡。
这篇文章我会从实际业务出发,把库存扣减、订单创建、优惠券核销、积分发放这四个动作的分布式事务方案掰开揉碎讲清楚。每个方案我都会说明它解决了什么问题、在什么场景下适用、有哪些坑,以及我是怎么在真实项目里做取舍的。无论你是刚接触分布式事务的新手,还是正在为线上数据不一致头疼的资深开发,都能从中找到可以直接参考的思路。
2. 库存与订单的强一致诉求:TCC 模式在交易链路中的落地细节
2.1 为什么库存扣减和订单创建必须强一致
库存和订单的关系最紧密。用户下单时,库存必须扣减成功,订单才能创建;如果库存扣减失败,订单就不应该存在。反过来,如果订单创建失败,库存扣减也必须回滚。这两个操作之间是强一致的诉求,不能接受"订单有了但库存没扣"或者"库存扣了但订单没有"的中间状态。
有人可能会说,用消息队列做最终一致性不行吗?订单先创建,然后发消息让库存服务去扣减,失败了就重试。这个方案在大多数情况下没问题,但它有一个致命缺陷:在库存扣减成功之前,订单已经存在了。如果此时库存服务发现库存不足,扣减失败,订单怎么办?取消订单?那用户已经看到的订单号就失效了,体验很差。而且如果取消订单的操作也失败了呢?就陷入了无限循环。
所以对于库存和订单这种核心交易链路,我倾向于用TCC(Try-Confirm-Cancel)模式来保证强一致性。TCC 的核心思想是:每个服务都提供三个接口——Try 做资源预留,Confirm 做确认提交,Cancel 做取消释放。事务协调者先调用所有服务的 Try 接口,如果全部成功,再调用 Confirm;如果有任何一个 Try 失败,就调用所有已成功服务的 Cancel。
2.2 TCC 三个接口在库存服务中的具体设计
以库存服务为例,Try 接口不是直接扣减库存,而是冻结库存。比如商品 A 有 100 件库存,用户下单 2 件,Try 阶段会把 100 件中的 2 件冻结起来,可用库存变成 98,冻结库存变成 2。Confirm 阶段再把冻结的 2 件真正扣掉,冻结库存归零。Cancel 阶段则把冻结的 2 件释放回可用库存。
这个设计的关键在于:Try 阶段一定要做资源预留,而不是直接扣减。我见过有团队为了图省事,Try 阶段直接扣库存,Confirm 阶段什么都不做,Cancel 阶段再把库存加回去。这样做在大多数时候没问题,但有一个隐患:如果 Confirm 阶段因为网络问题超时了,协调者不知道到底成功没有,可能会重试 Confirm。如果 Confirm 是空操作,重试无害;但如果 Try 已经扣了库存,Confirm 又扣一次,就重复扣减了。所以 Try 阶段做冻结、Confirm 阶段做实际扣减,才能保证幂等性。
库存服务的 TCC 接口设计大概是这样:
// Try:冻结库存 public boolean tryFreezeStock(Long itemId, Integer quantity, String txId) { // 检查可用库存是否充足 // 如果充足,可用库存 -= quantity,冻结库存 += quantity // 记录事务日志,txId 作为唯一标识 } // Confirm:确认扣减 public boolean confirmDeductStock(String txId) { // 根据 txId 找到冻结记录 // 冻结库存 -= quantity // 标记事务已完成 } // Cancel:释放冻结 public boolean cancelFreezeStock(String txId) { // 根据 txId 找到冻结记录 // 可用库存 += quantity,冻结库存 -= quantity // 标记事务已取消 }这里有一个非常重要的细节:每个接口都必须支持幂等。因为网络超时后协调者会重试,同一个 txId 的 Try 可能被调用多次。我的做法是在数据库中建一张事务日志表,用 txId 做唯一索引,每次操作前先查日志,如果已经执行过就直接返回成功。
2.3 订单服务的 TCC 设计与空回滚、悬挂问题
订单服务的 Try 阶段通常是创建订单并设置为"待确认"状态,Confirm 阶段把订单状态改为"已确认",Cancel 阶段把订单状态改为"已取消"。这里会遇到 TCC 模式的两个经典问题:空回滚和悬挂。
空回滚是指:Try 请求因为网络问题没有到达订单服务,但协调者以为失败了,发起了 Cancel。此时订单服务收到 Cancel,但根本没有对应的 Try 记录,如果直接执行 Cancel 逻辑,可能会误操作。解决方案是在 Cancel 时先查事务日志,如果没有 Try 记录,就插入一条"空回滚"记录,直接返回成功。
悬挂是指:Try 请求因为网络延迟,在 Cancel 之后才到达。此时 Cancel 已经执行完了,Try 又来了,如果 Try 正常执行,就会留下一个永远无法 Confirm 或 Cancel 的中间状态。解决方案是在 Try 时检查事务日志,如果发现已经有 Cancel 记录,就拒绝执行 Try。
这两个问题的处理逻辑,我在实际项目中是这么做的:
| 问题类型 | 触发场景 | 解决方案 |
|---|---|---|
| 空回滚 | Cancel 先于 Try 到达 | Cancel 时检查 Try 记录,不存在则插入空回滚标记 |
| 悬挂 | Try 后于 Cancel 到达 | Try 时检查是否有 Cancel 记录,有则直接拒绝 |
| 幂等 | 同一请求重复到达 | 用 txId 做唯一索引,重复请求直接返回成功 |
2.4 TCC 模式的实际代价与适用边界
TCC 虽然能保证强一致性,但它的代价也不小。首先,每个服务都要改造三个接口,开发成本高。其次,Try 阶段冻结资源会降低并发能力,比如库存冻结后,其他用户就不能买这些库存了,直到 Confirm 或 Cancel。最后,事务协调者本身也可能故障,需要做高可用。
所以我的经验是:TCC 只用在核心交易链路上,比如库存扣减和订单创建。对于优惠券核销和积分发放这种允许最终一致的场景,完全可以用更轻量的方案。接下来我就讲讲这两类场景的处理方式。
3. 优惠券与积分的最终一致方案:可靠消息 + 本地消息表的组合拳
3.1 为什么优惠券和积分可以接受最终一致
优惠券核销和积分发放有一个共同特点:它们不是交易成功的必要条件。用户下单时,优惠券核销失败,大不了这次不用券,按原价支付;积分发放延迟几秒甚至几分钟,用户也不会立刻感知到。只要最终能核销、能到账,业务上就是可接受的。
这就给了我们很大的灵活性。不需要像库存那样强一致,可以用最终一致性方案,通过消息队列来解耦。订单服务创建订单成功后,发一条消息到消息队列,优惠券服务和积分服务各自消费这条消息,完成核销和发放。如果消费失败,就重试,直到成功。
但这里有一个关键问题:如何保证订单创建成功和消息发送成功这两个操作的一致性?如果订单创建成功了,但消息发送失败了,优惠券和积分就永远不会被处理。这就是经典的"本地事务与消息发送的一致性"问题。
3.2 本地消息表:把消息发送变成本地事务的一部分
解决这个问题的经典方案是本地消息表。具体做法是:在订单服务的数据库里建一张消息表,订单创建和消息插入在同一个本地事务里完成。然后有一个独立的定时任务或后台线程,不断扫描消息表,把未发送的消息投递到消息队列。投递成功后,更新消息状态为"已发送"。
这个方案的精妙之处在于:订单创建和消息记录在同一个本地事务里,天然保证原子性。要么都成功,要么都失败。消息的投递是异步的,即使投递失败,定时任务会不断重试,直到成功。这样就把分布式事务问题转化成了本地事务 + 异步重试。
消息表的设计大概是这样:
CREATE TABLE local_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, tx_id VARCHAR(64) NOT NULL UNIQUE, -- 事务ID,用于幂等 topic VARCHAR(128) NOT NULL, -- 消息主题 payload TEXT NOT NULL, -- 消息内容 status TINYINT NOT NULL DEFAULT 0, -- 0待发送 1已发送 2已消费 retry_count INT NOT NULL DEFAULT 0, -- 重试次数 next_retry_time DATETIME, -- 下次重试时间 create_time DATETIME NOT NULL, update_time DATETIME NOT NULL );订单服务在创建订单的同一个事务里,插入一条消息记录,payload 里包含订单号、用户ID、优惠券ID、积分数量等信息。然后后台线程每隔几秒扫描一次 status=0 的记录,投递到消息队列。投递成功后把 status 改为 1。
3.3 消费端的幂等处理:为什么去重比你想的更重要
消息队列通常保证"至少一次"投递,这意味着消费端可能收到重复消息。如果优惠券服务收到两条相同的核销消息,不做去重就会核销两次,用户就亏了。所以消费端必须做幂等。
我的做法是在消费端也建一张消息去重表,用消息的 txId 做唯一索引。每次消费前先插入去重表,如果插入成功说明是第一次消费,继续处理业务;如果插入失败(唯一键冲突),说明已经消费过了,直接返回成功。
这里有一个容易踩的坑:去重表的插入和业务处理必须在同一个本地事务里。如果先插去重表,再处理业务,业务处理失败了,去重表里已经有记录了,下次重试时会被误判为已消费,导致业务永远不执行。正确的做法是把去重表插入和业务处理放在一个事务里,要么都成功,要么都回滚。
优惠券核销的消费逻辑大概是这样:
@Transactional public void consumeCouponMessage(CouponMessage message) { // 1. 插入去重表,唯一键冲突则直接返回 try { dedupMapper.insert(message.getTxId()); } catch (DuplicateKeyException e) { return; // 已消费过,直接返回 } // 2. 核销优惠券 couponService.useCoupon(message.getCouponId(), message.getUserId()); // 3. 更新本地消息表状态为已消费 localMessageMapper.updateStatus(message.getTxId(), 2); }3.4 消息积压与重试策略的实战调优
本地消息表方案在正常情况下运行得很好,但在大促期间会遇到消息积压的问题。订单量暴增,消息表里待发送的记录越来越多,后台线程处理不过来。这时候需要做几件事:
第一,增加投递线程数。单线程扫描消息表,每秒可能只能处理几百条,改成多线程分片处理,可以线性提升吞吐量。分片的方式可以按消息ID取模,每个线程负责一部分。
第二,批量投递。一次从消息表里取一批记录(比如100条),批量发送到消息队列,而不是一条一条发。这样能大幅减少网络往返次数。
第三,设置合理的重试策略。消息投递失败后不能无限重试,否则会拖垮系统。我的做法是设置最大重试次数(比如10次),超过后把消息标记为"死信",人工介入处理。重试间隔采用指数退避,第一次1秒,第二次2秒,第三次4秒,以此类推,避免频繁重试给消息队列造成压力。
第四,监控告警。消息表里 status=0 的记录数超过阈值时,触发告警。这样能在消息积压的早期发现问题,及时扩容。
4. 四种业务对象的方案选型对比:一张表看清取舍逻辑
4.1 从一致性要求、性能、改造成本三个维度做决策
库存、订单、优惠券、积分这四个业务对象,对分布式事务的要求各不相同。我在做方案选型时,主要看三个维度:一致性要求(强一致还是最终一致)、性能要求(高并发还是低频)、改造成本(能接受多大的开发量)。
库存和订单是核心交易链路,一致性要求最高,必须强一致。虽然 TCC 的改造成本高、性能有损耗,但这是必要的代价。优惠券和积分是辅助功能,最终一致就够了,用本地消息表 + 消息队列的方案,改造成本低,性能也好。
下面这张表是我在实际项目中总结的选型对比:
| 业务对象 | 一致性要求 | 推荐方案 | 性能影响 | 改造成本 | 适用场景 |
|---|---|---|---|---|---|
| 库存 | 强一致 | TCC | 中等(冻结资源) | 高 | 核心交易、秒杀 |
| 订单 | 强一致 | TCC | 中等 | 高 | 核心交易 |
| 优惠券 | 最终一致 | 本地消息表 + MQ | 低 | 低 | 营销活动 |
| 积分 | 最终一致 | 本地消息表 + MQ | 低 | 低 | 会员体系 |
4.2 混合方案:核心链路 TCC + 非核心链路消息队列
在实际项目中,我通常不会只用一种方案,而是混合使用。库存和订单用 TCC 保证强一致,优惠券和积分用本地消息表保证最终一致。这样既能保证核心链路的正确性,又能控制整体改造成本。
具体流程是这样的:用户下单时,订单服务先调用库存服务的 TCC Try 接口冻结库存,然后创建订单(待确认状态)。如果库存冻结成功,订单创建成功,就进入 Confirm 阶段,确认扣减库存,订单状态改为已确认。同时,订单服务在本地事务里插入两条消息记录,分别用于优惠券核销和积分发放。后台线程把消息投递到消息队列,优惠券服务和积分服务各自消费。
如果库存冻结失败,订单服务调用库存服务的 Cancel 接口释放冻结,订单创建失败,直接返回用户"库存不足"。整个过程对用户来说是原子的:要么下单成功且库存扣减、优惠券核销、积分发放都最终完成,要么下单失败且没有任何副作用。
4.3 异常情况的兜底:对账与补偿任务
即使方案设计得再好,线上还是会出现各种异常。比如 TCC 的 Confirm 阶段超时了,协调者不知道库存到底扣没扣;或者消息队列的消息丢了,优惠券永远没核销。这时候就需要兜底方案。
我的做法是每天跑一次对账任务。对账任务会扫描前一天的所有订单,检查每个订单的库存扣减记录、优惠券核销记录、积分发放记录是否都存在且一致。如果发现不一致,就生成补偿任务,人工或自动修复。
对账的维度包括:
- 订单已确认,但库存未扣减 → 补扣库存
- 订单已确认,但优惠券未核销 → 补核销优惠券
- 订单已确认,但积分未发放 → 补发积分
- 订单已取消,但库存已扣减 → 补回库存
对账任务虽然不能实时解决问题,但它是一道最后的安全网,能保证数据最终一致。我在实际项目中,对账任务帮我发现了好几个隐藏的 bug,比如某个消息因为序列化问题一直投递失败,对账时才发现。
5. 踩坑实录:TCC 空回滚、消息重复消费、对账遗漏的排查过程
5.1 一次空回滚导致的库存误释放
有一次线上出现了一个诡异的问题:某个商品的库存对不上,明明卖出去 10 件,但库存只扣了 8 件。排查了半天,最后发现是 TCC 的空回滚导致的。
事情是这样的:用户下单时,订单服务调用库存服务的 Try 接口冻结库存,但网络超时了,订单服务以为失败,发起了 Cancel。实际上 Try 请求到达了库存服务并成功执行了冻结。Cancel 请求到达后,库存服务发现没有对应的 Try 记录(因为 Try 的记录还没写入完成),就执行了空回滚逻辑,把库存加回去了。结果就是:库存冻结了,但又释放了,最终库存没扣。
这个问题的根因是Try 和 Cancel 的并发执行。解决方案是在库存服务里加一个分布式锁,保证同一个 txId 的 Try 和 Cancel 串行执行。或者更简单的方式:Cancel 时如果发现没有 Try 记录,不要立即执行空回滚,而是等待一段时间再重试,给 Try 足够的时间写入。
5.2 消息重复消费导致的积分多发
另一个坑是消息重复消费。有一次用户反馈,下单后收到了双倍积分。排查发现是消息队列在某个节点重启时,把一部分消息重新投递了,而积分服务的去重逻辑有 bug。
去重逻辑的 bug 在于:去重表的插入和积分发放不在同一个事务里。代码是先插去重表,再发积分,但发积分的方法有自己的事务,两个事务是独立的。结果就是:去重表插入成功,但发积分失败回滚了,下次重试时去重表已经有记录,直接返回成功,积分永远发不出去。反过来,如果去重表插入失败但发积分成功了,就会重复发积分。
修复方案是把去重表插入和积分发放放在同一个事务里,用@Transactional注解保证原子性。这个坑让我深刻体会到:幂等设计不是简单加个唯一索引就完事了,事务边界必须仔细考虑。
5.3 对账任务漏掉的边界情况
对账任务也不是万能的。有一次对账发现某个订单的优惠券没核销,补偿任务去核销时,发现优惠券已经被用户手动使用了。原来用户在下单后,又用同一张券下了另一单,第二单核销了券,第一单的补偿任务就失败了。
这个问题的根源是对账任务没有考虑优惠券的状态变化。解决方案是在补偿前先检查优惠券的当前状态,如果已经被使用,就跳过补偿,并记录一条异常日志,人工确认。同时,对账任务的时间窗口要覆盖足够长,避免因为消息延迟导致误判。
5.4 从这些坑里总结出的检查清单
踩了这些坑之后,我总结了一份分布式事务的检查清单,每次上线前都会对照检查:
- TCC 的 Try、Confirm、Cancel 三个接口是否都支持幂等?
- Cancel 是否处理了空回滚?Try 是否处理了悬挂?
- 本地消息表的插入和业务操作是否在同一个事务里?
- 消费端的去重和业务处理是否在同一个事务里?
- 消息重试是否有最大次数限制和死信处理?
- 对账任务是否覆盖了所有异常场景?补偿逻辑是否考虑了状态变化?
- 是否有监控告警?消息积压、事务失败率是否在监控范围内?
这份清单看起来简单,但每一条背后都是血泪教训。分布式事务没有银弹,只有对细节的极致把控,才能保证数据最终一致。
6. 个人在实际项目中的取舍心得
做了这么多年的分布式事务,我最大的体会是:不要为了技术而技术,要根据业务场景选择最合适的方案。TCC 很强,但不是所有场景都需要;消息队列很简单,但也不是万能的。关键是要清楚每个方案的边界和代价。
另外,监控和对账比方案本身更重要。再好的方案也会有异常,关键是要能及时发现和修复。我在项目中一定会做两件事:一是所有分布式事务操作都打点监控,失败率超过阈值就告警;二是每天跑对账任务,作为最后的安全网。
最后分享一个小技巧:在 TCC 的 Try 阶段记录详细的日志,包括 txId、业务参数、执行时间、执行结果。这样一旦出问题,可以通过 txId 快速定位到所有相关的操作,排查效率会高很多。这个习惯帮我省了无数个加班的夜晚。