数据一致性痛点

异步路径导致“先写缓存后写库”的窗口期

  • 在之前的系统中,在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兜底
  • 系统异常(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 历史记录。
  • 应对异常
    • 链路以外的操作导致不一致:
      • 运维误操作:直接修改了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。
  • 浪费一点库存换取安全

本站由 Xylumina 使用 Stellar 1.30.0 主题创建。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。

本"页面"访问 次 | 总访问 次 | 总访客