☰
分布式事务实战:库存、订单、优惠券、积分的方案选型与踩坑
2026/10/14 6:19:53 网站建设 项目流程

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 快速定位到所有相关的操作,排查效率会高很多。这个习惯帮我省了无数个加班的夜晚。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询