库存扣减与数据一致性从 Redis 预扣到分段锁本文是「10Wqps 秒杀架构」系列的第五篇。在前几篇中我们搭建了分层架构、异步下单和缓存体系本篇聚焦秒杀系统最核心的数据一致性难题——库存扣减。超卖和少卖是秒杀事故的头号杀手本文逐一给出应对方案。一、开篇超卖库存扣成负数和少卖库存有剩余但系统显示售罄是秒杀系统最致命的两类 Bug。超卖导致平台赔钱、超发货、客服投诉激增少卖导致营收损失、用户体验受损。而且这两类 Bug 往往是在并发压力下才显现开发和测试环境极难复现。库存扣减的挑战在于它跨越了三个存储层次Redis → MQ → MySQL涉及两种锁机制本地锁 vs 分布式锁还要处理支付超时回退的逆向流程。这是一个典型的分布式数据一致性问题。二、问题拆解2.1 一个简单的库存扣减代码错在哪// 看似正确的代码实则暗藏超卖风险intstockredisClient.queryStock(productId);if(stock0){return0;// 库存不足}redisClient.incrby(productId,-1);// 扣减库存redisClient.add(productId,userId);// 记录秒杀成功return1;问题出在哪查询库存queryStock和扣减库存incrby不是原子操作。在高并发下多个线程可能同时读取到stock 1然后各自执行incrby -1最终库存变成 -2、-3 甚至更低。这个 Bug 的核心原因是“判断 操作” 之间存在时间窗口在这个窗口内状态可能已经改变。这就是并发编程中最经典的 TOCTOUTime-of-Check-Time-of-Use问题。2.2 库存管理的三个阶段秒杀库存的管理远比普通电商复杂因为它跨越了三个时间阶段第一阶段: Redis 预扣减同步毫秒级 ↓ 第二阶段: MySQL 最终扣减异步秒级 ↓ 第三阶段: 支付超时回退15 分钟后通过延迟消息触发每个阶段都有自己的并发控制和一致性挑战。三、核心方案3.1 第一阶段Redis Lua 原子预扣用 Redis 的incrby单独命令虽然是原子的但判断 扣减两步无法保证原子性。解决办法是将整个判断 扣减逻辑写成一条 Lua 脚本提交给 Redis 执行——Redis 执行 Lua 脚本期间是单线程的不会插入其他命令。-- seckill_deduct.lua-- KEYS[1]: 商品库存 key-- KEYS[2]: 用户秒杀记录 key-- ARGV[1]: 用户 ID-- 返回值: 1成功, 0库存不足, -1已秒杀过localstockKeyKEYS[1]localuserKeyKEYS[2]localuserIdARGV[1]-- 1. 检查是否已秒杀过ifredis.call(exists,userKey)1thenreturn-1end-- 2. 检查库存localstocktonumber(redis.call(get,stockKey))ifstocknilthenreturn0endifstock0thenreturn0end-- 3. 原子扣减 记录redis.call(incrby,stockKey,-1)redis.call(set,userKey,userId,EX,3600)-- 1小时过期return1Java 调用方式StringluaScriptloadLuaScript(seckill_deduct.lua);Longresult(Long)redisClient.eval(luaScript,Arrays.asList(stock:seckill:seckillId,user:seckill:seckillId:userId),Arrays.asList(userId));if(result1){// 预扣成功 → 发送 MQ 下单消息sendOrderMessage(seckillId,userId);}elseif(result0){// 库存不足return已抢完;}else{// 已秒杀过return请勿重复秒杀;}Lua 脚本的优势原子性整个脚本在 Redis 单线程中执行不会被中断网络开销极小一次eval命令完成所有操作减少网络往返可扩展复杂逻辑如库存 -1 表示不限量也能封装在同一脚本中3.2 第二阶段MySQL 最终扣减Redis 预扣成功只是暂扣真正的持久化扣减在 MySQL 中完成。这里的并发风险是多个 MQ 消费者同时执行同一商品的扣减 SQL。-- 简单的扣减 SQLUPDATEproductSETstockstock-1WHEREid123ANDstock0;这个 SQL 本身是原子的InnoDB 行锁但如果消费者先查询再更新就会重蹈 Java 代码的覆辙// 错误示范查询和更新分离intstockmapper.selectStock(productId);if(stock0){mapper.updateStock(productId);// 此时 stock 可能已经变了addOrder(productId,userId);}正确做法使用SELECT ... FOR UPDATE加行锁将查询和更新放在同一事务中TransactionalpublicvoiddeductStockAndCreateOrder(LongproductId,LonguserId){// FOR UPDATE 加行锁阻止其他事务并发修改ProductStockstockmapper.selectStockForUpdate(productId);if(stock.getStock()0){thrownewBusinessException(库存不足);}// 扣减mapper.updateStock(productId,stock.getStock()-1);// 创建订单mapper.insertOrder(buildOrder(productId,userId));}3.3 分布式锁为什么 Redisson 是正确的选择setNx expire 的陷阱最简单的 Redis 分布式锁实现// 有 Bug 的加锁方式if(jedis.setnx(lockKey,requestId)1){jedis.expire(lockKey,30);// 设置 30 秒过期// ... 执行业务jedis.del(lockKey);// 释放锁}致命缺陷setnx 和 expire 不是原子操作。如果 setnx 成功但 expire 失败进程 crash、网络超时、Redis 重启这个锁永远不会过期——死锁。虽然可以用SET key value NX EX 30的原子命令替代但这只是解决了第一步。锁续期Redisson 的 WatchDog 机制Redisson 的 WatchDog看门狗是解决锁续期问题的关键方式行为适用场景lock()默认 30s 过期WatchDog 每 10s 自动续期到 30s不确定业务执行时间的场景lock(10, SECONDS)10s 后自动释放不触发 WatchDog确定业务能在 10s 内完成tryLock()尝试获取失败立即返回 false不触发 WatchDog非阻塞场景tryLock(waitTime, leaseTime, unit)在 waitTime 内等待leaseTime 后自动释放有超时容忍的非阻塞场景WatchDog 的工作原理T0: 获取锁 → 启动定时任务expire 30s T10: WatchDog 检查 → 锁仍被持有 → 续期 expire 30s T20: WatchDog 检查 → 锁仍被持有 → 续期 expire 30s T30: 业务完成 → 释放锁 → WatchDog 停止重要提示lock(leaseTime, unit)指定了过期时间的方法不会激活 WatchDog。如果业务执行时间超过 leaseTime锁会被自动释放其他线程可能获取到锁——这叫锁提前释放。完整使用示例RLocklockredissonClient.getLock(lock:seckill:product:productId);try{// 使用 lock() 而非 lock(leaseTime)让 WatchDog 自动续期lock.lock();// 执行库存扣减业务逻辑deductStockAndCreateOrder(productId,userId);}finally{// 必须是当前线程持有锁才能释放if(lock.isHeldByCurrentThread()){lock.unlock();}}3.4 分段锁突破单 Key 的性能瓶颈当所有请求都竞争同一把锁如lock:seckill:product:123时这把锁成为系统的吞吐上限——即使 Redis 能扛 10W qps但所有请求在这个锁上被串行化实际有效 QPS 取决于单次加锁-业务-解锁的耗时。分段锁的核心思想是将一个商品的库存拆成 N 个段每段独立一个 Key 和一把锁请求按用户 ID 哈希到不同段上商品库存 1000分 10 段每段 100 件 lock:seckill:product:123:0 → stock:seckill:product:123:0 100 lock:seckill:product:123:1 → stock:seckill:product:123:1 100 ... lock:seckill:product:123:9 → stock:seckill:product:123:9 100 用户 A → hash(userId) % 10 3 → 竞争段 3 的锁 用户 B → hash(userId) % 10 7 → 竞争段 7 的锁两个用户哈希到不同段时可以并发执行互不影响。分段锁的理论并发度提升 分段数。但分段数不是越多越好——分段过多会增加管理复杂度且单段库存过少时容易出现段库存不足但总库存有余的情况。分段锁的详细设计超出了本文范围核心原则是当单 Key 竞争成为瓶颈时用空间多 Key换并发度。3.5 第三阶段支付超时库存回退用户秒杀成功后需要在 15 分钟内完成支付。如果超时未支付需要将预扣的库存退回。这个场景使用 RocketMQ 的延迟消息是最优解// 下单成功后发送延迟消息MessagemessagenewMessage(ORDER_TIMEOUT_TOPIC,buildOrderTimeoutMsg(orderId));// 设置延迟级别: 15 分钟RocketMQ 预定义的延迟级别之一message.setDelayTimeLevel(DelayTimeLevel._15M);rocketMQTemplate.send(message);// 消费者处理延迟消息RocketMQMessageListener(topicORDER_TIMEOUT_TOPIC,consumerGrouporder-timeout)publicvoidonMessage(MessageExtmsg){OrderTimeoutMessagetimeoutMsgparseMessage(msg);OrderorderorderService.getOrder(timeoutMsg.getOrderId());if(order.getStatus()OrderStatus.PENDING_PAYMENT){// 仍未支付 → 取消订单 回退库存orderService.cancelOrder(order);// 回退 Redis 库存同样使用 Lua 原子操作stockService.rollbackStock(order.getSeckillId(),order.getQuantity());}// 已支付 → 不做任何操作}为什么不使用定时 Job 扫描延迟消息的优势是实时性——15 分钟到了立刻触发不需要等 Job 的下一次执行周期。而且延迟消息是精确到单条订单的Job 需要扫描所有未支付订单效率低。四、边界与异常4.1 Redis 预扣成功MQ 发送失败这是最危险的异常——库存扣了但订单消息没发出去用户永远收不到订单。解决方案RocketMQ 事务消息确保预扣库存和发送消息的原子性MQ 篇 详述定时任务兜底每分钟扫描 Redis 预扣记录超过 10 分钟未确认的自动回退库存告警MQ 发送失败率 0.1% 时立即告警4.2 Lua 脚本执行超时Redis 执行 Lua 脚本是阻塞的如果脚本中有死循环或耗时操作会阻塞整个 Redis 实例。应对Lua 脚本只做简单的数据操作不加循环配置lua-time-limit默认 5 秒超时后 Redis 记录日志但不中断脚本对 Lua 脚本做代码审查确保复杂度为 O(1)4.3 库存对账不一致定时任务每 5 分钟对比 Redis 库存 预扣记录 与 MySQL 库存Scheduled(fixedDelay300000)// 每 5 分钟publicvoidreconcileStock(){for(Seckillseckill:activeSeckills){intredisStockredis.get(stock:seckill:seckill.getId());intpreDeductedredis.scard(pre:seckill:seckill.getId());// 预扣未确认数intmysqlStockmysql.queryStock(seckill.getProductId());if(redisStockpreDeducted!mysqlStock){log.error(库存不一致: seckillId{}, redisStock{}, preDeducted{}, mysqlStock{},seckill.getId(),redisStock,preDeducted,mysqlStock);// 以 MySQL 为准修复 Redisredis.set(stock:seckill:seckill.getId(),mysqlStock-preDeducted);}}}五、总结Lua 原子操作是 Redis 预扣的基石将判断 扣减 记录封装在一条 Lua 脚本中消除 TOCTOU 竞争窗口。MySQL 扣减用 FOR UPDATE 行锁不要分离查询和更新将两者放在同一个事务中。分布式锁首选 RedissonsetNx expire 存在原子性和续期双重问题。Redisson 的 WatchDog 自动续期是生产环境的标配。分段锁是单 Key 竞争的热升级方案当单商品秒杀 QPS 超过单把锁的吞吐上限时用分段策略线性提升并发度。延迟消息处理库存回退比定时扫描更优实时触发、精确到单条订单、不产生扫描开销。定时对账是最后一道防线每 5 分钟对比 Redis 和 MySQL 库存不一致时以 MySQL 为准修复。