订单系统从单体到分布式:一个演示项目如何逐阶段攻克超卖、重复下单与数据一致性难题
作为一个以 React / Node.js 为主的开发者,我一直想亲手验证 Spring 生态是怎么落地分布式事务与最终一致性的。spring-order 就是我为此搭的动手演示项目(spring-order,见 erishen/spring-order):最初的架构是标准的单体——MySQL 存订单和库存,Spring 事务保证一致性,React 前端调用 REST API。超卖、重复下单、消息丢失,这些分布式订单系统的经典难题,正是这个项目要逐阶段去攻克的目标。
接下来是我逐步演进、加固系统的过程,每一阶段都对应着一类典型问题。
速览(TL;DR)
spring-order 是一个 Spring Boot + React 的订单系统演示项目,沿 P0→P4 逐步演进:P0 用 @Version 乐观锁防超卖;P1 引入 Redis 分布式锁(SET NX + Lua 原子解锁)与缓存,把库存扣减在跨实例之间串行化;P2 用 Kafka 事务 Outbox,把「创建订单」与「投递事件」放进同一个本地事务,保证消息不丢;P3 加 Resilience4j 限流、熔断、重试,抗下游抖动与突发流量;P4 用 nginx 多实例 + Prometheus/Grafana 补齐可观测性。所有演进都围绕同一目标——高并发下不超卖、不重复下单、数据最终一致。
P0:单体 + 乐观锁——第一个坑是超卖
最早版本,库存扣减靠 @Version 乐观锁。逻辑很简单:查询库存,检查数量,更新时带上版本号,CAS 失败就重试。
inventoryMapper.updateById(inventory)
这段逻辑在 QPS 几十的时候完全够用。但在高并发压测场景下,同一件商品被几十个请求同时读到"库存充足",然后各自提交——@Version 只保证单条记录的并发安全,不保证跨请求的全局库存上限。数据库层面,多条记录并行更新,版本号冲突只淘汰一部分,另一部分照旧提交,库存直接扣到负数。
我意识到,乐观锁在这个场景下本质上是"最后写入者胜",而不是"库存不能超过上限"。
修复方案是把库存扣减序列化。但序列化不能靠数据库行锁——吞吐量扛不住。我需要一种能让多个服务实例共享的互斥机制。
P1:Redis 分布式锁 + 缓存——跨实例的序列化
我用 Redis SET NX 实现分布式锁。核心逻辑是:在扣减库存之前,先对商品 ID 加锁,锁持有期间串行执行扣减操作。
同时,我把高频读取的库存数据从 MySQL 迁到了 Redis 缓存层,避免每次扣减都穿透到数据库。读取走缓存,扣减走锁+DB 的双重保障。
这套方案解决了超卖问题,但也带来了新挑战:锁超时怎么办?如果持有锁的实例崩了,锁一直不释放,其他请求全部阻塞。解锁时我用一段 Lua 脚本——只有当前持有的随机 token 匹配才执行 del,这样即使锁已过期也不会误删别的实例持有的锁。项目不依赖 Redisson,是手写的 SET NX + Lua 解锁;抢不到锁时降级直接执行,由 @Version 乐观锁兜底不超卖。
但分布式锁本身也引入了新的问题——幂等性。
P2:幂等性服务 + Resilience4j——重复请求与雪崩
超卖修好后,下一个要正视的典型问题是重复下单。常见诱因是用户在网络卡顿时代码重试,或者前端组件在提交后没有立即禁用按钮——请求多次打到后端,每次都能通过锁检查,于是生成了两条订单。
我让客户端在下单时带上 Idempotency-Key 请求头,后端把它存进数据库(幂等记录表)并做三态处理:之前已完成则重放上次响应、进行中则返回 409 冲突、本请求拥有则继续。这样同一个 key 的重复提交只会真正下单一次。
同时,我注意到系统脆弱性——当某个下游服务(比如库存服务)响应变慢时,调用方线程会堆积,最终拖垮整个服务。我加了 Resilience4j 的三重保护:
@RateLimiter:限制单位时间内的调用次数,防止瞬时流量打爆下游@CircuitBreaker:故障率超过阈值时熔断,快速失败而不是无限重试@Retry:对瞬时抖动做有限次数的自动重试
这三层保护让我第一次在监控上看到"熔断器打开了"而不是"线程池满了"。系统的可观测性从"事后诸葛亮"变成了"事中早知道"。
但新的架构问题来了:订单创建和库存扣减之间的数据一致性。
P3:Kafka Outbox——跨服务的数据一致性问题
P2 阶段,订单创建和库存扣减在同一个事务里完成——这在单体架构下没问题。但当我把订单服务和库存服务拆开后,本地事务无法覆盖两个服务。如果用两阶段提交(2PC),系统复杂度骤增,而且 Kafka 的引入让事情变得更微妙。
我的解法是 Outbox 模式。在订单创建事务内,同时写入 order 表和 outbox 表,然后由服务负责将 outbox 记录发布到 Kafka,轮询 outbox 表,批量投递未发送的事件。
库存服务订阅 Kafka 事件后执行扣减,通过幂等键(消息 ID)保证恰好一次处理。即使服务重启,也不会丢失事件——因为 outbox 表本身就是持久化的"待发送队列"。
这个方案的核心收益是:订单创建和 outbox 写入在同一个本地事务中,保证了"订单要么都写,要么都不写"。库存扣减虽然在另一个服务,但通过 Kafka 事件的可靠投递和幂等处理,最终达到了一致效果。
代价是引入了 Kafka 依赖和轮询逻辑。我选了这个代价,因为数据一致性是不可妥协的底线。
P4:多实例部署 + 网关 + 可观测性——最后的拼图
P3 解决了分布式环境下的数据一致性问题,但系统还需要支撑横向扩展。我部署了多个订单服务实例,前面加了 nginx 做负载均衡。同时,我在每个实例上启用了 Actuator 指标暴露,接入了 Prometheus 和 Grafana。
现在我可以看到的不再只是"系统有没有挂",而是:
- 每个接口的 QPS 和 P99 延迟
- 熔断器的状态和触发次数
- Kafka 消费延迟
- Redis 缓存命中率
这些指标让我能在问题发生前就感知到趋势性变化,而不是等问题暴露后再排查。
演进路线总结
回顾整个演进过程,我的核心思路一直没变:先解决典型问题,再考虑架构优雅。
| 阶段 | 问题 | 方案 |
|---|---|---|
| P0 | 超卖 | @Version 乐观锁(不够) |
| P1 | 跨实例并发 | Redis 分布式锁 + 缓存 |
| P2 | 重复下单、雪崩 | 幂等性服务 + Resilience4j |
| P3 | 跨服务数据一致 | Kafka Outbox 模式 |
| P4 | 可观测性不足 | 多实例 + nginx + Prometheus |
每一阶段都是"出问题→定位根因→引入方案→验证效果"的闭环。没有一步到位的架构,只有不断逼近正确性的迭代。
源码导航
- 下单入口与库存乐观锁:
OrderService.java(@Version) - Redis 分布式锁(SET NX + Lua 原子解锁):
RedisLockService.java - 幂等键(
Idempotency-Key请求头 + DB 存储):IdempotencyService.java/OrderController.java - Kafka 事务 Outbox(事件与业务同事务写入):
OrderService.java(saveEvent) - 限流/熔断/重试:
application.yml+GlobalExceptionHandler.java - 多实例网关与可观测:
docker-compose.yml/prometheus.yml/grafana/
乐观锁为什么不能解决超卖问题?
乐观锁只保证单条记录的版本号冲突检测,多个并发请求可以同时读到相同库存值并各自通过校验,最终超额提交。
Redis 分布式锁如何防止锁丢失?
用 Redis 的 SET key token NX PX(默认 3 秒 TTL)加锁,token 是每次随机生成的 UUID;解锁走一段 Lua 脚本,只有当前持有 token 匹配才执行 del——这样即使锁已过期,也不会误删别的实例持有的锁。抢不到锁时降级为直接执行,由 DB 的 @Version 乐观锁兜底不超卖(只是重试更多)。项目未引入 Redisson,是手写的 SET NX + Lua 解锁。
Outbox 模式相比直接发 Kafka 有什么优势?
Outbox 将事件写入与业务操作放在同一个本地事务中,确保”订单创建成功当且仅当 outbox 记录写入成功”,避免了业务成功但事件丢失的不一致状态。
幂等性键是如何设计的?
幂等键由客户端通过 Idempotency-Key 请求头传入(不是服务端组合生成),后端用 checkAndReserve(key) 把它存进数据库(IdempotencyRecord)。三态:HIT 表示之前已完成→直接重放缓存的响应(200);IN_PROGRESS 表示有同 key 请求在途→返回 409 冲突;PROCEED 表示本请求拥有该 key。下单成功则 complete(key) 持久化响应,失败则 fail(key) 释放,客户端可安全用同一 key 重试。并发插入靠唯一索引兜底。
Resilience4j 的三层保护各自解决什么问题?
RateLimiter 限制瞬时流量冲击,CircuitBreaker 在下游故障时快速失败防止雪崩,Retry 对瞬时抖动做有限重试恢复。
P4 阶段引入的可观测性主要关注哪些指标?
接口 QPS 与 P99 延迟、熔断器状态与触发次数、Kafka 消费延迟、Redis 缓存命中率,这些指标支持事前趋势感知而非事后排查。
项目地址
- GitHub 仓库:https://github.com/erishen/spring-order
- 演示架构演进 P0–P4、事务 Outbox、幂等、Resilience4j 限流熔断重试,以及 nginx 多实例网关 + Prometheus/Grafana 可观测。
发表回复