ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis与MySQL数据一致性:秒杀系统压测故障复盘与修复实践

Redis与MySQL数据一致性:秒杀系统压测故障复盘与修复实践 做秒杀系统最怕什么不是 QPS 上不去而是流量明明扛住了账却对不上。前阵子我负责的项目做了一次 5200 并发的真实流量压测结果就撞上了这个经典问题Redis 里的库存已经扣减数据库里的库存却纹丝没动。消息队列里没有报错日志里也查不到异常但用户的真实体验就是“有货拍不下、没货又下单失败”。这篇文章围绕秒杀场景下 Redis 与 DB 的数据一致性完整复盘从故障现象、根因分析、问题复现到最终修复落地的全过程顺手把压测时踩过的一堆坑也整理出来给做高并发交易系统的同行做个参考。1. 故障现场5200 并发压测下的“库存悬空”1.1 秒杀系统的核心交易链路先交代一下项目背景。我们做的秒杀系统是典型的三层结构接入层用 Nginx 加网关做流量清洗和限流中间是独立的秒杀服务后端挂 Redis 集群和 MySQL 业务库。用户点击秒杀按钮后请求链路大概是这个样子的网关先校验用户的登录态和秒杀令牌通过后请求进入秒杀服务。秒杀服务调用一段 Lua 脚本对 Redis 中的库存 key 做原子扣减扣减成功的请求继续进入订单创建逻辑生成一条订单记录然后发送一条异步消息到 RocketMQ。下游的订单消费者收到消息后在 MySQL 里真正落库订单明细同时执行库存表的 UPDATE 扣减库存。这种“Redis 预扣 异步落库”的设计在秒杀场景里非常常见原因不难理解Redis 能扛住瞬时高并发DB 则通过异步削峰保护避免大量写请求直接把 MySQL 打死。理想情况下Redis 扣了多少库存DB 最终就要扣多少两边保持一致。如果这个等式不成立系统就是带病运行的。1.2 压测现场Redis 扣完 12 万DB 只落了 9.7 万5200 并发指的是施压机模拟的同时在线用户数我们用了 Wrk 和 JMeter 混合施压整个压测大约持续 3 分钟。压测结束后做数据核对时大家盯着报表半天没说话——数字太刺眼了Redis 实际扣减成功 120,000 单MySQL 库存字段却只减少了 97,000中间差了整整 23,000。多出来的 23,000 单属于典型的“Redis 已扣减但 DB 未落库”状态。更让人恼火的是订单表里确实存在这 23,000 条明细但库存表里找不到对应的扣减记录订单状态也有一部分卡在“待支付”和“创建失败”之间。这说明链路里有一段异步处理出现了静默丢失而且这个丢失不是偶发是成规模的。这时候业务方已经在群里追问了库存到底还有多少用户能不能下单我们只好一边安抚一边紧急排查。1.3 为什么说这是数据一致性问题而不是性能问题压测期间我们其实记录了性能指标。Redis 平均延迟不到 2msMySQL QPS 峰值 8000离数据库上限还远得很。从性能视角看这次压测的吞吐、延迟、错误率几乎是全部达标的唯独数据对不上。这正是秒杀系统最迷惑人的地方。很多人做高并发只关心“扛不扛得住”却忽略了“数据对不对”。前者是性能问题后者是正确性问题而正确性问题的破坏力往往比性能问题大得多。性能不行最多是用户多等几秒数据不一致会让库存虚高、超卖、对账失败甚至引发资损。就这次故障而言Redis 扣减与 DB 落库之间没有强制的一致性保证问题被 5200 并发流量直接放大了于是从“小概率隐患”变成了“必然事故”。2. 根因剖析Redis 已扣减但 DB 未落库到底断在哪一环2.1 Redis 与 DB 天然是两套独立事务没法“同生共死”首先是底层原理问题。Redis 的扣减操作和 MySQL 的 UPDATE 语句分属两个完全不同的存储系统它们之间无法共享同一个本地事务。在秒杀这种高并发场景下如果硬要上分布式事务框架比如 XA 两阶段提交或者 TCC事务协调器的开销会直接把性能拖垮做不到那么高的吞吐因此行业里普遍采用“本地事务 消息 重试”的软状态方案。软状态的意思是允许数据在短时间窗口内不一致但必须通过后续机制让数据最终收敛到一致状态。换句话说中间可以短暂对不上但最终一定要对上。我们这次的问题恰恰就出在“最终一定要对上”这个兜底机制没有做好。2.2 消息静默丢失的三种典型方式我沿着订单消息的完整流转链路排查了很久定位到三个静默丢失点任何一个都足以造成库存悬空。第一个是消息发送时用了 fire-and-forget 模式也就是只管把消息发出去不关心 Broker 是否真的收到、是否刷盘成功。RocketMQ 在这种模式下虽然丢消息的概率很低但绝对不等于零尤其是 Broker 重启或者刷盘策略配置为异步刷盘时极端情况下会有消息丢失。第二个是消费端的异常处理方式有问题。当时的消费者在本地事务里先执行了 UPDATE 库存再更新订单状态一旦 UPDATE 抛异常整个事务回滚但这条消息却没有被重新投递也没有进入死信队列而是被消费框架直接吞掉只打印了一行 error 日志。这就是非常典型的“重试与幂等策略缺失”导致的丢失。第三个最隐蔽Spring Boot 里 Transactional 和消息监听器混用的时候容易出现事务还没提交消息就已经被消费的竞态或者反过来事务提交后的回调通知丢失。当时的代码里没有记录消息处理状态也没有配套的重试机制任何一环出问题数据就悄无声息地不一致了。2.3 连接池打满导致的“假失败”压测时还出现了一个次生故障让局面雪上加霜。MySQL 连接池在流量峰值时被打满部分库存 UPDATE 语句在等待连接时直接超时抛出Connection is not available, request timed out异常。此时 Redis 的扣减已经成功订单也已经生成但数据库扣库存这步失败了而且失败后没有任何补偿逻辑被触发。这种“假失败”在压测中特别容易踩到因为它不是业务逻辑写错而是资源竞争导致的临时失败。压测结束后连接池恢复了数据库也正常了但库存已经悬空后面再想补救就非常被动。2.4 真正缺的是最基础的“对账”把上面几个问题串起来你会发现根子不在于某一处代码写错了而在于整条链路缺少一个最终一致性对账机制。我们没有一张表记录“Redis 扣了多少、DB 扣了多少、哪些订单没有落库”也没有定时任务去扫描差异。没有对账机制问题就只能靠人工翻日志发现。可在 5200 并发的日志量级下靠人工去找那 23,000 条丢单数据简直是大海捞针。所以后来我在复盘时一直强调一句话没有对账的秒杀系统等于默认接受数据可以不一致。这不是技术态度问题是工程风险问题。3. 问题复现如何把“偶发”变成“可复现”3.1 搭建与线上同构的最小实验环境修复一致性问题的前提是先把问题稳定复现出来。如果问题不可复现你怎么知道自己改对了我当时做的第一件事是搭建一个和线上结构一致的最小实验环境3 台 4C8G 的虚拟机分别部署 Redis 6.2、MySQL 8.0、RocketMQ 4.9秒杀服务打包成镜像后用 docker compose 整体拉起。库存初始化为 100,000先用 JMeter 以 1000 并发跑一轮基线测试确认环境本身是正常的。3.2 故障注入的三种实操方法复现的关键在于人为制造异常。我用了三种故障注入手段分别对应线上出现过的三类问题。第一种是直接杀掉消费者进程。把订单消费者进程停 10 秒这期间压测继续跑Redis 在疯狂扣减库存但数据库没有任何写入然后重启消费者。结果发现消费者停止期间产生的部分消息消失了消费者恢复后也没有重新消费。这不完全等同于线上故障但能验证“消费者不可用期间”的丢单问题。第二种是调小数据库连接池。把 HikariCP 的maximum-pool-size从 50 调到 5再开启压测。1000 并发瞬间就把连接池打满UPDATE 语句开始排队随后批量超时。我们记录到 187 笔订单出现 Redis 扣减成功但 DB 未扣减的情况和线上事故表现完全一致。第三种是模拟消息中间件抖动。用 Linux 的tc命令对 RocketMQ Broker 端口增加 300ms 网络延迟观察消息发送超时和消费重复的情况用来验证消息在极端链路下是否会出现乱序或重复投递。3.3 复现判定四个数字一比对每次实验结束后我都要核实四个数字Redis 扣减成功数、订单表新增数、MySQL 库存变化数、消息队列积压数。判定复现成功的标准非常直接Redis 扣减数不等于 MySQL 库存变化数并且订单表里出现了没有对应库存扣减记录的脏数据。按照这个标准杀消费者 10 秒的实验中1000 并发跑到第 30 秒就稳定出现了 42 笔脏数据。连接池调小的实验更明显1000 并发下产生了 187 笔超时未补偿单据。两个实验都稳定复现说明线上那次事故不是偶然的代码 bug而是架构缺陷在这个流量模型下的必然结果。4. 修复方案落地从“可复现”到“可修复”4.1 四个候选方案的技术权衡复现问题之后我花了将近两天时间做方案选型前后对比了四个方向第一个是同步双写也就是 Redis 扣减后立即 UPDATE MySQL。这个方案强一致最好但秒杀峰值下数据库会瞬间变成瓶颈基本等于放弃了秒杀系统的性能设计初衷直接淘汰。第二个是 Redis 扣减加异步落库加无限重试。它解决了一部分消息丢失问题但无法应对 DB 长时间不可用的情况。如果数据库抖动持续几分钟重试消息会大量积压消费者被压垮问题反而更严重。第三个是引入 Seata 这样的分布式事务框架用 AT 模式管理全局事务。这确实能保证最终一致但秒杀峰值下 undo_log 的写入和全局锁的竞争开销相当大实测下来吞吐掉了近一半不能满足业务指标。第四个是本地消息表加定时补偿加最终一致性对账。这个方案不过度牺牲性能又能在分钟级自动收敛不一致数据可以在业务低峰期或极少数极端场景下用补偿任务兜底是成本与可靠性的最佳平衡点。我们最终选的就是这个。4.2 主链路改造扣减流水表与本地消息表方向定下后我对主链路做了三步改造。第一步在 Redis 扣减成功之后把“库存扣减流水记录”和“待发送消息记录”这两张表的写入放到同一个 MySQL 本地事务里。这里有一个设计要点Redis 扣减和 MySQL 写入一定不能做成跨系统的强一致事务但“流水表和消息表”这两张 MySQL 表的写入必须在同一个数据库事务里这样至少能保证“扣减事实一定有据可查、消息一定不会丢”。第二步后台增加一个定时任务每隔 5 秒扫描消息表中状态为待发送的记录通过 RocketMQ 重新投递并记录投递次数和下次投递时间。如果连续投递 5 次失败把状态标记为失败由最终对账任务介入。第三步消费端在本地事务里执行库存 UPDATE 和订单状态更新成功后把消息表里对应记录的状态置为已消费。整条链路变成“发送之后必须确认消费成功”消息最终可达性就有了保障。4.3 消费端改造幂等消费与失败重试消费者这侧的逻辑也做了重写核心顺序变为根据订单号查流水表判断这条数据是否已经处理过处理过就直接返回避免重复扣减如果没处理过开启本地事务先 UPDATE 库存再更新订单状态和消息状态如果 UPDATE 影响行数为 0说明库存不足或订单状态异常记录失败原因并进入补偿队列如果抛异常整个事务回滚消息不确认触发 RocketMQ 的重新消费机制这里最关键的是幂等。秒杀下消息重复消费几乎是必然事件不用“是否已处理”拦住重复请求库存就会被多扣超卖问题就出来了。用订单号加流水表做唯一约束是在消费端最便宜的幂等方案。4.4 兜底补偿分钟级对账任务最后一道防线是补偿对账任务。我写了一个定时任务每 5 分钟执行一次逻辑分三步统计当天 Redis 扣减流水的总条数就等于“应当落库的订单量”统计 MySQL 库存扣减记录的总条数也就是“实际落库成功的数量”如果两边差值超过阈值把差异订单捞出来逐个发起补偿补偿动作是有优先级的。如果 DB 库存还有余量就补扣数据库库存并把订单状态修正为成功如果 DB 库存已经耗尽那就反过来调用 Lua 脚本把 Redis 的预扣库存加回去同时将订单改成失败。通过这两种补偿路径保证 Redis 侧和 DB 侧最终一定收敛到同一个事实。对账任务跑完之后把差异数和处理结果输出到监控大盘运维人员一眼就能看出系统有没有处于一致状态。4.5 Lua 扣减脚本的幂等细节原版 Lua 扣减脚本只做了数量校验没有幂等设计同一笔订单在高并发重试时可能被扣两次。我做了个很小的改动扣减的时候同时写入一条扣减明细 key格式是order:deduct:{orderId}这个 key 如果已经存在直接返回“重复扣减”标志不再执行减库存操作。这个改动不算复杂但对正确性帮助很大把乱序请求、重试请求堵在了 Redis 这一层。5. 修复后压测验证数字终于对上了5.1 同条件复测的对比结果代码改完后我用完全相同的 5200 并发压力、相同的 40 万初始库存重新跑了一轮压测。这次结果让人松了一口气Redis 扣减成功 119,857 单MySQL 库存扣减记录同样 119,857 单差值归零。消息表最终积压为 0对账任务连续跑了三轮每一轮差异数都是 0。性能指标几乎没退化Redis P99 延迟 2.1msMySQL QPS 峰值 7800吞吐与修复前基本持平。这说明补偿机制没有成为链路瓶颈一致性保障的代价在可接受范围内。5.2 一致性校验脚本压测结束后我写了一个很简单的 SQL 校验脚本每次压测完直接跑一遍三张表的数字必须完全对上SELECT (SELECT COUNT(*) FROM deduct_record WHERE create_date CURDATE()) AS redis_deduct_cnt, (SELECT COUNT(*) FROM inventory_log WHERE create_date CURDATE()) AS db_deduct_cnt, (SELECT COUNT(*) FROM order_creation_message WHERE status 0) AS pending_msg_cnt;判定标准很明确redis_deduct_cnt必须等于db_deduct_cntpending_msg_cnt必须等于 0。另外还要在 Redis 侧对比当前库存 key 的值与 MySQL 库存余额差额只要在“未支付订单占用”的合理范围内就算通过。这套校验逻辑后来被固化成了发布流水线上的一道卡点每次压测或上线前都必须跑一次。6. 经验沉淀与避坑指南6.1 秒杀一致性设计的四条原则这次事故给我的教训很深我把它提炼成了四条原则写进了团队的技术规范。第一Redis 预扣绝不改变 DB 的最终事实。一切以数据库落库为准Redis 只是预占资源的高速通道它不能成为数据的最终判定者。第二任何异步环节都要有“至少一次投递”的语义同时消费端必须做幂等。宁可重复消费也不能静默丢失因为重复消费靠幂等可以挡住丢失却是不可逆的。第三没有对账就等于没有一致性保证。对账不是可选项是高并发交易系统的必选项。不要指望每条消息都不会丢而是确保丢了之后能被发现、被补偿。第四压测必须设计故障注入场景不能只跑理想流量。真实线上会有抖动、会有超时、会有进程重启如果压测时没有验证过这些异常路径上线后它们迟早会找上门。6.2 压测工具与监控的几个实操坑压测本身的门道也不少这里多说几句。我们最初用 Wrk 单线程施压结果根本打不出 5200 并发后来才知道要指定参数wrk -t 8 -c 1024线程数和连接数缺一不可。JMeter 的聚合报告里默认的响应时间包含了思考时间用那个数字判断系统真实吞吐会得出偏乐观的错误结论一定要在脚本里显式关闭思考时间。另一个必须提前做的是全链路 trace。最开始秒杀链路没有接入 trace出了问题只能在大日志里 grep效率低到让人绝望。后来我用 SLF4J MDC 把 traceId 贯穿到每个日志行再接到 SkyWalking整个排查效率提升了至少一个量级。强烈建议任何秒杀业务上线前先把 trace 接好不然压测一开人力和日志就是瓶颈。6.3 最后的体会这次 5200 并发压测暴露的问题本质上不是某个组件坏了而是架构设计里缺少一个“一致性闭环”没有可靠投递、没有幂等保护、没有对账兜底。修复方案本身并不复杂复杂的是愿意花时间去复现它、验证它并且把校验动作固化到日常流程里。把“Redis 已扣减但 DB 未落库”从偶发故障变成可复现、可验证、可监控的问题这本身就是秒杀系统走向成熟的过程。如果你也在维护类似的系统真心建议把对账任务排上日程趁问题还没被流量引爆之前先用脚本把所有不一致的订单都捞出来看看。
返回列表