数据一致性痛点
异步路径导致“先写缓存后写库”的窗口期
- 在之前的系统中,在Redis中做预扣库存,并把消息放到RabbitMQ,消费者order-service再从RabbitMQ中获取信息,写入MySQL,这其中任何一环失败都会造成Redis与MySQL数据不一致。
- 写Redis缓存成功但消息未发送/未到队列:Lua成功执行扣减库存,但是seckill-api在调用MQ发布阶段崩溃或网络丢包,消息没进入MQ。
- 后果:Redis减库存,但是MySQL没订单。丢单,用户体验与账务不一致。
- 消息到队列但是消费者处理失败但超时:消息进入了RabbitMQ,但是order-service在写MySQL时因死锁、主键冲突、DB崩溃而失败。
- 后果:Redis减库存,DB没创建订单。不一致,若消费者重试,在没有幂等保护措施下,可能会造成重复订单。
消息不可见/丢失的多个来源
- 生产者seckill-api崩溃,RabbitMQ挂,Redis崩(未持久化),消费者order-service失败,都可能丢消息或者延迟处理。
至少一次语义下的重复消费
- MQ重试会导致同一条消息被多次处理,若消费者没做幂等会出现重复订单或超扣库存。
分布式系统中的时间、副本延迟
- DB主从复制延迟会导致回源读得不到最新库存,Redis replication配置不当会在宕机时丢数据。
边界与故障组合复杂
- 多种故障组合(生产者成功写Redis但是没写MQ;MQ收到消息但是消费者rollback等)会让错误场景全覆盖很难。
可观察性/补救成本高
- 发现不一致后补偿复杂且风险高,需要对账与幂等化支持。
解决方案
强调:本文讨论的是“最终一致性”方案:
- 不追求“入口写 Redis 时 MySQL 一定同步成功”(那基本只能上两阶段/强 XA);
- 而是保证:一旦 Redis 接受了某次秒杀成功,系统要么最终把它落到 MySQL,要么明确记录为失败并不会对外报成功。
方案A-Redis-stream outbox + Relay + MQ + Consumer(选择)
- 原理:seckill-api 的Lua脚本在Redis原子完成预扣并且XADD(outbox);独立relay-service从Redis Stream拉取并发到RabbitMQ;order-service消费MQ写MySQL并做幂等与库存扣减。
- 优点:前端最快(极低延迟),实现较简单,水平扩展容易
- 缺点:Redis成为临界持久化点,若Redis崩溃、未持久化会丢数据,需要完善的relay/DLQ/reconciliation.
- 适用于追求低延迟且能接受“最终一致性+补偿”策略的场景。
- 工程要点:
- Lua 必须把 库存预扣+用户去重标记+XADD(outbox)做成一个原子脚本;
- Redis 配置可靠(AOF+replica)或Cluster
- relay 等待publisher-confirm
- 成功(ack=true)→ XACK + XDEL + 清理 attempts;
- 失败/超时 → attempts++,在未达阈值前保留在 pending,超过阈值后写入 DLQ,并最终 XACK + XDEL 该 entry。
- consumer 做幂等(DB唯一约束+insert防重),并在失败时抛异常以触发MQ重试和最终DLQ;
- reconciliation实际对账时,只对“一定时间窗口之前的 outbox 记录”做检查,例如只检查 5–10 分钟前的 requestId:
- 避免把仍在重试/还在处理中的消息误判为漏单;
- 可以结合 idle time(XPENDING 信息)来判断消息是否长期不动。
方案B-DB-outbox(在同一DB事务写outbox表)+ Relay 从DB发 MQ + Consumer 落库、后续处理
- 原理:业务写和outbox写在同一数据库事务(写订单或写变更+写outbox row)。保证持久化。独立relay扫描outbox,发MQ,标记为SENT。
- 优点:借助数据库事务保证“业务写入与outbox同时成功”->减少丢单窗口,最终一致性更强。
- 缺点:需要在前端或中间服务写DB(增加延迟/DB负载),实现复杂,DB成为关键瓶颈。
- 适用:对一致性要求更高且可接受额外延迟/运维成本的产品线
- 工程要点:
- outbox表设计(状态/attempts/payload/creates_at/request_id)
- relay可靠扫描与去重、事务/锁保证只发一次;
- 支持回放与重试;
- 监控 outbox backlog
- 若是秒杀,高并发要优化DB(写优化,水平分表)
方案C-事务消息(Broker支持的业务,例如RocketMQ事务消息)
- 原理:使用MQ的事务消息接口:先发送“半消息”,Broker保留,producer执行本地事务并告知Broker commit/rollback。
- 优点:在某些Broker可实现“消息与DB事务一致”——简化outbox;比DB-outbox延迟更小
- 缺点:复杂实现、依赖Broker特性;事务回查(broker回查producer)带来复杂度;并非所有broker都支持,在生态也有坑(网络问题/事务回查压力)。
- 适用:能稳定使用支持事务的broker且想降低DB-outbox套件的复杂度时。
- 工程设计要点:
- 本地事务设计
- 事务回查接口实现、超时、重试策略
- broker的可用性依赖要评估
方案D-SAGA/编排型补偿事务
- 原理:将复杂事务拆成多个本地事务,并用补偿事务回滚(或者完全反向操作)。可用编排或基于消息的链式。
- 优点:适合跨服务分布式业务,能保证业务最终一致(通过补偿)。不依赖两阶段提交。
- 缺点:补偿逻辑常复杂,失败补偿本身也有失败概率;不适合强事务场景。
- 适用:长时操作或者多服务参与的业务流程。秒杀这种库存写入、订单写入场景可以用SAGA,但是实现复杂。
方案E-两阶段提交
- 原理:分布式事务协调器
- 优点:理论上强一致
- 缺点:性能差、扩展性差、在高并发秒杀场景中几乎不可用。
工程实现细节
最终的一致性防线总共分四层
第一层:入口防线(防超卖、防重复)
- Lua 在 Redis 内原子预扣库存 + 用户去重 + XADD outbox。
- 确保:凡是 Redis 视角下的“秒杀成功”,都有一条事件记录,下游只要保证“事件不丢/可补偿”。
第二层:传输防线(防消息消失/卡死)
- relay + Redis Stream(pending + XPENDING/XCLAIM) + publisher confirms + attempts + relay DLQ。
- 确保:Redis 中的事件要么被 MQ 确认,要么明确进入 DLQ,没有“悄悄不见”。
第三层:落库防线(防重复写/超卖)
- order-service 消费 MQ,使用 UPDATE … stock>0 + UNIQUE(request_id) 实现幂等;
- 通过抛异常触发 MQ retry + 消费者侧 DLQ;
- 区分业务异常(可 ACK、不重试)与系统异常(重试 → DLQ)。
第四层:审计防线(事后对账 + 补偿)
- Reconciliation 对比 outbox / DLQ 与 MySQL;
- 对漏单做补单;对超卖做告警/人工处理;
- DLQ replay/脚本以 DB 为准,只做“向前补偿”,不在这里回滚 Redis。
seckill-api
- Redis/Lua原子预扣库存 + 记录user去重 + XADD outbox(Redis Stream)
- 成功返回0 -> 这次抢购被Redis接受,理论上未来要么生成订单,要么明确失败/补偿
relay-service
- 从outbox(Redis) Stream 用 XREADGROUP 拉取条目,outbox中的一条记录可能处于以下几种状态
- 刚XADD,但还没有被relay读过;
- 已被relay XREADGROUP,但还在 pending(relay还没处理完);
- relay正在重试发布到MQ(attempts还没超限);
- 已成功发布到MQ,relay ack + del(不会再出现在outbox);
- 超过尝试上限,被写入DLQ Stream或MQ DLQ,outbox记录被删除。
- 正常路径:publish 到 RabbitMQ,确认publisher confirm ack -> XACK + XDEL -> 清理attempts
- 一旦拉出来但没ack就宕机,那条entry卡在pending -> 通过 XPENDING+XCLAIM 定时把这些“卡住的消息” 捞出来重试
- 防止 relay 在“读到但还没ack”的中间状态崩掉,导致那条消息永远卡在pending不再处理。
- 如果多次publish失败(attempts超限)-> 写入relay的DLQ(Redis Stream或MQ DLQ)->这部分需要人工/脚本审查和决定是否补偿
order-service
- 消费RabbitMQ消息,按幂等逻辑写DB:
UPDATE ... stock>0 + INSERT ... request_id (UNIQUE)- 幂等逻辑约束
- MQ层的重投递:consumer抛异常 -> MQ自动重试 -> 可能多次受到同一个requesId
- relay侧重复publish:RabbitMQ收到消息,但是publisher confirm 丢失,relay以为没成功发送,又重新发送了一次
- 人工回放/DLQ replay:人为脚本把同一条payload重放多次
- 业务异常(库存不足/重复下单):正常抛业务异常,但一般视为逻辑上可接受,记录日志后正常 ACK 消息,不再重试;。
- 绝大多数情况下,seckill-api的Lua脚本已经挡掉了“售罄”和“重复购买”,但是在DB层保留这个保护也是必要的,因为有可能存在
- 数据被人为修改
- Redis和MySQL的数据可能因为某些极端故障不同步(比如恢复备份、AOF 截断)
- DB兜底
- 绝大多数情况下,seckill-api的Lua脚本已经挡掉了“售罄”和“重复购买”,但是在DB层保留这个保护也是必要的,因为有可能存在
- 系统异常(DB down):抛RuntimeException -> MQ触发重投递 -> 多次失败后进入MQ DLQ
Resilience4j
- 只负责防止在DB挂掉时把整个系统拖挂:快速fail + 保护DB;
- 不管消息命运,仍然通过抛异常让MQ做retry或进入DLQ
Reconciliation
- 事后审计:Redis outbox(已被Lua接受的请求) VS MySQL订单 + initial_stock
- 这里的 outbox 可以是:
- Redis Stream 中仍然存在的消息(尚未成功发送到 MQ 或已进 relay DLQ 前的留存);
- 或者一份从 outbox 导出的历史快照(例如定期将 Stream 归档到 DB 表)。
- 实际实现中可按业务选择:
- 若只关心“当前未完成/长期挂起的请求”,对比 Redis 现有 outbox + DLQ 即可;
- 若需要做全链路审计,则需要持久化 outbox 历史记录。
- 这里的 outbox 可以是:
- 应对异常
- 链路以外的操作导致不一致:
- 运维误操作:直接修改了MySQL、手动恢复备份、删了一些记录
- Redis AOF/Master发生主从切换时数据回滚等极端情况。
- 代码/配置的bug:
- relay某个版本写错了ack逻辑,导致部分消息没有被正确publish/没有进入到DLQ
- consumer某一段逻辑漏抛异常,直接ack掉但没写库。
- 非常极端的infra问题:
- MQ磁盘损坏但对应用表现不明显。
- Redis恢复快照时有部分操作丢失。
- 链路以外的操作导致不一致:
- 检查:
- long idle 的outbox requestId没有对应订单 -> 漏单候选(也有可能进入DLQ,需要人工确认)
- 订单总数 > initial_stock -> 超卖/补单过多/人工误操作
DLQ
- relay DLQ:MQ投递失败多次后发不出去的outbox消息
- MQ/consumer DLQ:consumer没法处理的异常消息(DB长期故障/逻辑bug/数据异常)
- 陷阱问题:可不可以再DLQ中回滚Redis?-> 绝对不可以
- 消息有没有真的成功,不可以凭DLQ的状态下结论 -> 比如order-service在事务提交后,ack MQ之前,JVM或网络挂掉,此时MQ没有收到ack,以为消费失败,所以会重投,有可能进入DLQ,但此时MySQL已经真实扣过库存和生成订单了,如果在DLQ中回滚Redis的话,会造成Redis+1,DB-1的彻底错位。
- 即任何“自动回滚Redis库存”的设计,在没有DB参与判断的情况下,都是很危险的。
- 如何在后续中操作DLQ?-> 优先做补单,整个补偿过程中,以DB为准,只往前补,不轻易往回加Redis
- 从DLQ中读取消息payload(里面有 requestId/userId/productId)
- 先查 MYSQL:
- 如果已经有这个requestId的订单 -> 什么都不需要做
- 如果没有订单 -> 再尝试一次用正常的幂等接口去补单
- 补单成功 -> redis和MySQL库存对上了
- 补单失败
- 业务原因:DB库存已经为0,约束冲突等,那这条最终被记为失败
- 系统原因:继续留在DLQ队列中或放到“人工确认”列表中
Redis一直少几件库存,会不会“卖少了”?
策略A 保守策略(选择)
- 不再DLQ中回滚Redis,只做补单
- 对于确实无法补单的requestId:
- Redis 这边TTL到期/缓存重建时,会整体以DB为准重新装一次;
- 在这之前,等于少卖了一些库存:用户可能看到“卖完了”,但实际上DB里还有一点库存没用完,但不会出现超卖。
- 优点:逻辑简单实现安全,不会把DLQ的复杂边缘场景变成超卖来源
- 缺点:极端故障场景下(大量DLQ),可能浪费部分库存,但是一般大故障场景也不希望用户继续购买。
策略B 精细回滚
- 先以DB为准判断状态
- 此requestId是否已在DB有订单?
- 若有订单:禁止回滚,一定不能加Redis
- 若无订单:还要判断product的当前库存和订单数是否合理
- 回滚必须同时处理DB和Redis
- 要么DB也做逻辑补偿,再整库重建Redis
- 要么只在一定窗口内给Redis+1,并记录好补偿日志
- 缺点:非常复杂
策略C 把失败名额单独记录,后续统一reconcile
- 把“经过多次重试仍然失败的 requestId”记录到一个 failed_request 表;
- 再结合对账任务:
- 对比 Redis 预扣总量 vs MySQL 成功订单 + failed_request 数量;
- 确定这部分库存是“业务上作废的名额”,不再回流给 Redis。
- 浪费一点库存换取安全