首页 / 资讯中心 / 文章详情

高并发秒杀库存更新:Redis Lua原子扣减与热点Key打散实战

高并发秒杀库存更新:Redis Lua原子扣减与热点Key打散实战 ★ FEATURED ARTICLE
印象最深的是去年一次大促压测秒杀刚开始三十秒监控大屏上库存扣减成功率直接掉到60%数据库行锁等待飙升到三千以上连接池被打满紧接着Redis热点分片CPU冲到90%——雪崩了。当时我们组的方案和很多团队一样接口进来先查Redis库存再UPDATE数据库库存。这个方案平时没什么问题但一上秒杀就成了灾难。问题就出在“热点库存更新”这五个字上。高并发秒杀里冲进来的流量几乎都打在同一个SKU的同一个库存字段上数据库和缓存都被同一把锁卡住。这篇文章想把这套问题讲透为什么库存更新会成为雪崩点Redis Lua为什么能解决原子扣减异步削峰怎么和它配合热点Key打散怎么做以及压测调优和上线后真正会踩的坑。适合所有正在做秒杀、抢购、限量活动的高并发后端开发、架构师和运维同学。读完不说能一步到位但至少能让你知道从“雪崩”到“稳如磐石”到底要经过哪些环节每个环节里藏着什么坑。1. 从一次真实的秒杀事故说起库存更新雪崩的三条链路1.1 那个“平时没问题秒杀就出事”的方案大多数团队最开始做秒杀都是低并发验证过的老逻辑先查Redis缓存里的库存判断有货然后执行一次数据库库存扣减再创建订单。伪代码大概是这样的// 反例不会扛住秒杀的下单逻辑 if (redis.get(stock: skuId) 0) { int rows stockMapper.reduceStock(skuId); // update stock set stockstock-1 where sku_id? if (rows 0) { orderService.createOrder(...); } }这个逻辑在几百QPS时一点问题没有。但秒杀开始的一瞬间十万个请求同时进来数据库里那个SKU的库存行只有一个。MySQL InnoDB对同一行做更新时会加行锁多个事务同时update同一行除了第一个抢到锁的其他事务全部进入锁等待状态。如果等待时间超过innodb_lock_wait_timeout默认50秒就直接抛异常。应用层往往还习惯做重试结果重试不但没有解决问题反而把原本就打满的行锁队列拉得更长。这里要理解一件事热点库存更新本质是“写热点”不是“读热点”。读热点可以用缓存扛住但写热点会同时卡住数据库连接、线程池、行锁队列这三层资源。很多人以为加了Redis缓存就能高枕无忧实际上如果最终扣减还是要落到数据库同一行瓶颈依然在那里。1.2 雪崩的三条传播链路秒杀库存更新一旦开始雪崩通常是三条链路同时互相加剧第一数据库锁等待导致连接池耗尽。行锁排队越久持有连接的请求越多连接池里的连接很快被占光。后续所有需要数据库的操作全部失败不只库存扣减订单、用户、商品详情全部跟着挂。服务健康检查失败后会被负载均衡摘除但摘掉一台机器新流量又会打到剩下的机器上形成恶性循环。第二Redis热点分片被打满。如果用Redis保存库存所有扣减命令会集中到同一个热点Key上。在Redis Cluster里同一个Key只会落在某一个分片节点上这个分片的CPU和网络带宽就成了瓶颈。分片一慢Redis客户端的命令超时开始出现其他非热点请求也被拖累。更危险的是如果给库存Key设置了不合理的过期时间Key一旦过期所有请求瞬间穿透到数据库雪崩立刻出现。第三失败重试导致流量放大。接口超时后网关和客户端通常会重试。正常秒杀流量已经很大重试机制一旦触发实际到达后端的请求量会成倍增加。系统本来已经接近极限重试流量就是压垮骆驼的最后一根稻草。1.3 “稳如磐石”到底衡量什么很多人以为稳如磐石就是“系统不崩”但实际上在秒杀库存更新场景里需要同时满足几个指标不超卖库存扣减必须精确不能出现负库存也不能出现扣减和订单不一致。高可用接口成功率至少99.9%TP99时延可控。最终一致用户看到的“抢购成功”和后台实际创建的订单可以有时间差但最终必须对上。可降级Redis、数据库、MQ任一组件出问题系统有明确降级路径而不是直接雪崩。用一句话总结目标不是让十万并发所有人都打到数据库而是让十万并发在一条可控的路径上快速结束。真正写数据库的请求只有一小部分其余请求要么快速失败要么被削峰缓冲。后面所有方案都是围绕这个目标设计的。2. 技术选型的冷静对比乐观锁、Redis Lua、异步削峰各自能干什么2.1 数据库乐观锁为什么无法扛住热点行更新数据库乐观锁是很多人首先想到的方案实现也简单UPDATE seckill_stock SET stock stock - 1, version version 1 WHERE sku_id ? AND stock 0 AND version ?;这个方案在低并发场景下很好用冲突率低执行一次基本就能成功。但秒杀场景完全不同所有请求都在抢同一个SKU的同一行冲突率接近100%。每次update不成功都要重试而每次重试都要重新执行一次SQL重新尝试获取行锁。假设一个请求重试十次数据库实际承受的SQL就是正常流量的十倍。即使做了悲观锁SELECT ... FOR UPDATE情况也不会更好因为热点行的锁竞争仍然存在吞吐量会被锁等待时间卡死。分库分表可以一定程度上分散行锁但秒杀热点数据往往就在一个SKU上把一个SKU的库存行分布到多个数据库又会带来全局库存汇总、扣减一致性、超卖控制等一系列新问题。综合来看数据库乐观锁适合库存量小、并发可控的场景不适合真正的十万级秒杀。2.2 Redis Lua原子扣减的正解真正解决原子扣减问题的是Redis的Lua脚本。Redis是单线程执行命令的而Lua脚本执行期间整个脚本内的命令不会被其他请求插入天然保证原子性。相比WATCH MULTI EXEC的事务方案Lua只需要一次网络请求不需要在冲突时反复重试代码也更清晰。一个典型的原子扣减脚本长这样-- KEYS[1]库存剩余 key -- KEYS[2]已售 key -- ARGV[1]本次购买数量 -- ARGV[2]用户限购数量 -- ARGV[3]用户 ID local stock tonumber(redis.call(GET, KEYS[1])) if not stock then return -1 -- 活动未开始或已结束 end local bought tonumber(redis.call(HGET, seckill:bought: .. KEYS[1], ARGV[3]) or 0) if bought tonumber(ARGV[1]) tonumber(ARGV[2]) then return -2 -- 超过限购数量 end if stock tonumber(ARGV[1]) then return 0 -- 库存不足 end redis.call(DECRBY, KEYS[1], ARGV[1]) redis.call(INCRBY, KEYS[2], ARGV[1]) redis.call(HINCRBY, seckill:bought: .. KEYS[1], ARGV[3], ARGV[1]) return 1 -- 扣减成功脚本里同时做了库存判断、限购判断、库存扣减、已售累加、用户购买数累加整个过程不会被打断所以不存在超卖。有人担心GET和DECRBY之间会穿插其他请求这个担心在Lua脚本内不存在因为Redis执行脚本时是原子完成的。为什么不用DECRBY直接判断返回值也可以但可读性和扩展性不如在脚本里先GET判断。尤其当我们需要区分“活动未开始”“库存不足”“超过限购”时自定义返回码比判断负数更直观。2.3 异步削峰为什么扣减库存不能和订单DB操作放一起Redis Lua解决了“扣减库存”这一瞬间的高并发但秒杀请求不是到这里就结束了。用户看到“抢购成功”后后台必须创建真实订单、写数据库。如果还在用户请求的线程里同步创建订单数据库依然会被瞬间打爆。所以必须在“扣减库存”和“创建订单”之间加一个削峰层常见的做法是引入消息队列。扣减成功之后立即把“下单消息”发给MQ接口直接返回“抢购成功/排队中”。真正写数据库的动作由MQ消费者异步执行数据库处理能力被控制在稳定范围内。这里需要接受一个思想秒杀不是强一致而是最终一致。用户刷出“抢购成功”时订单可能还在消息队列里排队但过一两秒就能查到。只要保证消息不丢、不重、最终能下单成功用户体验完全没问题。2.4 选型对比表方案可支撑QPS一致性复杂度适用场景数据库乐观锁千级强一致低低并发秒杀、小范围活动Redis Lua十万级Redis内强一致中热点商品、超高并发扣减Redis Lua MQ万级下单/秒最终一致高电商大促秒杀、限量抢购最终结论其实很清楚Redis Lua负责把并发流量挡在数据库之外MQ负责把数据库写入变成可控的异步流量。但这套组合不是写完就完事后面还有大量细节决定它到底稳不稳定。3. 核心实现细节Redis Lua原子扣减与异步落库的完整链路3.1 库存初始化与Key设计活动开始前需要把数据库里的可用库存一次性加载到Redis并初始化已售数量。Key命名建议带上业务和活动维度seckill:stock:10001 剩余库存 seckill:sold:10001 已售数量 seckill:bought:10001 Hashfield是userIdvalue是已购数量这里有一个特别重要的原则秒杀进行中的库存Key一定不能设置过期时间。一旦Redis因为过期清理或内存淘汰删除掉库存Key所有扣减请求都拿不到库存数据要么全部失败要么穿透数据库雪崩就来了。正确的做法是活动结束后由后台任务在对账完成后主动删除这些Key而不是依靠TTL。初始化时还建议给Redis集群预留足够的key空间避免内存淘汰策略把热点Key挤掉。线上环境如果Redis开启了allkeys-lru秒杀库存Key属于低访问频率的冷数据不对它其实是高访问频率的热数据但内存淘汰会优先淘汰最近最少使用的Key。如果在秒杀前没有提前访问这些Key或者活动间歇期访问量降低就有被淘汰的风险。最稳妥的方案是给秒杀库存Key单独使用一个不淘汰的Redis实例或者至少在秒杀期间关闭淘汰策略。3.2 扣减接口的防御细节接口层不能只依赖Lua脚本还要做好前置过滤否则Redis压力依然很大。第一用户登录态和接口签名要校验避免脚本机器人直接打接口。第二前端防重令牌要和服务端校验结合同一个令牌只能使用一次。第三用户维度的限购判断要放在Lua脚本里做不能先查后写因为“先查后写”在高并发下一定会出现超卖或超限。返回码的设计也建议提前定好-1活动未开始 -2超过限购数量 0库存不足已售罄 1扣减成功网关层不要把这几个返回码转换成统一错误码否则前端无法区分“已售罄”和“限购”用户会反复提交产生无用请求。我在实际项目里就吃过这个亏前端把限购的“-2”当成“已售罄”处理用户一直刷新Redis QPS白白涨了一倍。一个简单的Java调用骨架如下public boolean deductStock(Request req) { Object result redisTemplate.execute( stockLuaScript, Arrays.asList( seckill:stock: req.getSkuId(), seckill:sold: req.getSkuId() ), req.getCount(), req.getLimit(), req.getUserId().toString() ); int code ((Long) result).intValue(); if (code 1) { // 扣减成功发送异步下单消息 mqTemplate.send(seckill_order, StockDeductMessage.from(req)); return true; } // 记录失败原因方便分析热点和攻击 failLogService.record(req, code); return false; }3.3 消息异步下单与幂等消费扣减成功后接口返回给用户同时把消息发给MQ。消息体至少要包含这些字段userId skuId count orderToken timestamporderToken是服务端生成的内部单号不是用户传的随机数。这样做的好处是即使前端同一个请求被多次重放内部单号永远是同一个数据库的唯一索引可以直接拦住重复消息。消费端要做幂等。最简单有效的方式是在订单表上建立(user_id, sku_id, order_token)唯一索引消费消息时执行INSERT如果主键或唯一索引冲突说明订单已经创建过直接丢弃这条重复消息即可。消费者处理完订单后不要直接修改Redis库存因为Redis在扣减那一刻已经“预扣”过了DB订单表只是异步记录最终结果。二者之间的关系靠sold和订单表数量对账来保证。3.4 库存回补与补偿机制异步化之后最怕的情况是Redis扣减成功但MQ消息丢失或消费失败导致用户多了一个“已抢到但无订单”的虚拟单。所以必须要有补偿机制。我们采用的是本地消息表 定时扫描。扣减成功后在同一套逻辑里先写一条本地消息记录状态为“待发送”然后再发送MQ。发送成功后把状态改成“已发送”。定时任务每分钟扫描一次找出超过30秒仍是“待发送”的消息重新投递。如果一条消息连续重试多次仍然失败就进行库存回补调用一个独立的回补服务对Redis执行INCRBY同时记录审计日志。回补操作必须幂等。最简单的做法是用orderToken作为去重键回补之前查一下审计表如果已经回补过就不再处理。否则一旦定时任务重复执行库存被反复增加最后账面全乱。3.5 数据库落库的自保手段异步消费虽然削峰但订单表写入仍然需要注意几个点订单表按user_id分表把写入分散到多个物理表。消费者侧做批量插入攒够100条或者100毫秒批量执行一次减少数据库事务开销。秒杀订单表不要反查库存表来验证库存因为真正的库存数据在Redis里。数据库库存表只作为对账、审计、恢复的数据源。数据库连接池设置最大连接数并配置好等待队列。宁可快速拒绝一部分消息也不能让连接池耗尽把整个数据库拖垮。4. 热点Key打散、本地缓存与降级预案让“稳如磐石”变成可能4.1 Redis单Key热点为什么也是雪崩点很多人会忽略Redis本身也可能因为热点Key而雪崩。Redis Cluster分布式的数据粒度是Key同一个Key只会落到一个分片节点上。秒杀期间所有扣减seckill:stock:10001的请求最终都会打到同一个分片。这个分片的CPU、网络IO、内存带宽都会成为瓶颈。即使只是单个命令当QPS达到几十万单分片一样会先于整个集群崩溃。热点Key问题比数据库行锁更隐蔽因为Redis平时性能太好很多人在小流量下根本测不出来。只有大促压测时才能看到某个分片CPU明显比其他分片高出一大截。这时如果不做打散后续所有优化都会被这个单点卡住。4.2 库存Key拆分成多个Slot解决热点Key最直接的办法是把一个库存Key拆成多个Slot。比如把10000件库存预分配到seckill:stock:10001:0到seckill:stock:10001:31这32个Slot里每个Slot管理300多件库存。Redis Cluster会自动把这些Key散到不同分片上单分片压力直接降到原来的1/32。选Slot的策略很重要。最简单的是按用户ID哈希取模int slot Math.abs(userId.hashCode()) % SLOT_COUNT; String stockKey seckill:stock: skuId : slot; int code executeLua(stockKey, ...);为什么按用户ID而不是随机选因为同一个用户如果每次请求落到不同Slot限购统计就必须在多个Slot间协调复杂度会高很多。按用户ID固定Slot同一个用户的扣减请求永远走同一个Slot限购Hash也只需要存在那个Slot对应的Key里。还有一个问题是Slot之间库存可能不均衡。如果用户分布导致某个Slot先被消耗完但这个用户又恰好落到这个Slot就会明明整体还有库存却提示售罄。后面第6章我们会专门讲怎么兜底这里先记住一个原则主Slot扣减失败后最多再尝试一次备用Slot千万不要无限重试。4.3 本地缓存和售罄兜底扣减请求是写操作不能走本地缓存。但“查询还有多少库存”这类读请求完全可以走本地缓存。我们用Caffeine设置5秒刷新所有库存余量查询全部命中本地基本不会打到Redis。更进一步我们会在应用内存里维护一个“全局售罄标记”。当Redis返回0且sold字段达到总库存时就把这个商品标记为售罄同时通过Redis发布订阅或短暂滞后的本地过期时间让所有节点快速感知。售罄标记设置1到2秒的过期时间避免活动重新补库存时还一直拦截。这样做的效果是一旦售罄绝大多数无效请求会在进入Redis之前就被拦截Redis只需承受一小部分边缘请求。4.4 限流、熔断与降级参数秒杀期间不能指望所有请求都成功合理的拒绝比全链路崩溃要好得多。以下几点参数是我们压测后固定下来的网关层全局令牌桶按预估峰值放行单个用户每秒最多5次请求单IP每秒最多100次。扣库存线程池核心线程数16、最大线程数64、队列容量500拒绝策略直接抛出异常返回“排队中”。不要用无限队列否则线程池被占满后问题更大。Redis客户端连接池最大32最大等待200ms命令超时50ms。连接池太小会排队超时太长会拖垮线程。熔断使用Sentinel或Hystrix当Redis扣库存接口的慢调用比例超过30%时熔断10秒期间直接返回“已售罄”或“系统繁忙”不再向Redis发送请求。降级预案也要提前想好。如果Redis完全不可用可以临时切换回数据库乐观锁接口但并发能力会大幅下降。此时建议直接关闭秒杀入口而不是让流量打到数据库上把核心服务拖死。大多数双十一秒杀系统宁可让用户看到“活动太火爆”也不能让数据库挂掉影响所有订单。4.5 预热和活动前检查清单活动开始前至少完成这几项检查所有秒杀库存Key已写入Redis并验证Lua脚本逻辑。压测过每个Slot散列后的分片QPS确认没有单个分片过热。MQ消费者数量已经预启动消息积压容量的监控告警已配置。回补任务、对账任务已经发布上线且独立于主流程线程池。预热这一步不是可有可无。很多问题只有在真实流量模型下才暴露提前压测能帮你发现90%的隐患。5. 压测与调优全过程把 TP99 从 120ms 压到 15ms5.1 压测目标与流量模型我们当时的压测目标是模拟秒杀开始瞬间的流量读:写比例大约9:1。工具用的JMeter做全链路wrk直接打应用接口测单点扣减能力。一个典型的wrk命令长这样wrk -t8 -c1000 -d60s --latency http://gateway/seckill/deduct?skuId10001要注意带上真实的用户ID参数不能所有人都用同一个ID否则压测结果会被限购逻辑和Slot路由严重扭曲。压测至少分三个场景纯Lua扣减、Lua加MQ发送、全链路数据库落库。5.2 基准数据为什么一开始会“雪崩”我们的基准环境1主2从的Redis Cluster应用节点8核16GMySQL 8.0。初始配置是线程池核心10、Redis连接池8、没有做Slot打散。300并发压测结果TPS7200TP99时延120ms失败率7%监控里Redis某个分片CPU已经到65%应用线程大量阻塞在Redis连接获取上。分析下来有三个问题连接池太小导致请求排队热点Key单分片压力过大限购检查虽然只多几个Redis命令但整体上放大了单分片执行时间。5.3 四步调优过程第一步把Redis连接池从8调到32maxWait设置为200ms命令超时设置为50ms。单纯这一步TP99就从120ms降到了60ms。连接池不是越大越好但8个连接在千级并发下绝对不够。第二步把扣库存线程池核心线程调整到16队列设置500同时把前端的多余轮询请求降频减少无效流量。第三步做库存Key的Slot打散32个Slot按用户哈希分散。这一步之后Redis各分片CPU从65%以上降到30%以下单分片热点消失。第四步把MQ发送改成批量异步扣减成功先写入内存队列由单线程批量向MQ发送消息减少每个请求的网络IO。最终在1000并发下TPS稳定在2.1万TP99时延15ms失败率0.1%。每个环境的硬件和基线都不同这些数值不能直接搬到你的项目里。但调优的思路可以复用先消除线程和连接排队再消除热点Key最后压缩不必要的IO。每次只改一个变量压测验证后再改下一个否则一起改出问题你根本不知道哪个改动有效。5.4 监控与告警设计秒杀期间的监控指标至少要有以下这些Redis命令QPS、慢查询数、各分片CPU和内存、错误率。MQ消息积压数、消费延迟、死信队列数量。数据库活跃连接数、行锁等待数、慢SQL数量。应用扣减接口TP99、成功返回码分布、超时次数。告警阈值建议Redis错误率大于0.5%或TP99大于100ms持续1分钟触发熔断降级。MQ积压大于1万消费者自动扩容。库存误差不为0停止秒杀入口人工介入核对。上线前一定要做故障演练杀掉一个Redis分片、停掉一半消费者、模拟DB连接池打满。不要等活动真的出事再来想怎么处理人的临场反应在那种时刻是不可靠的。6. 上线后遇到的坑和解决经验6.1 扣减成功但订单丢失只发消息不落库的坑第一次上线后我们遇到一个问题MQ集群抖动消息丢了几百条。用户端显示“抢购成功”但订单区一直查不到。复盘发现之前的实现只做了“扣减成功就发消息”没有本地消息表兜底。消息一发出去就完事一旦MQ丢失没有任何机制能知道这笔扣减没有变成订单。解决方法是引入本地消息表。在同一个本地事务里先写入一条“待发送”的消息记录再投递MQ投递成功后更新消息状态。定时任务扫描超过30秒仍未确认的消息重新投递。这样即使MQ短暂不可用消息也不会丢。6.2 Slot分配不均明明有货却有人抢不到Slot打散之后我们碰上了一个新问题活动刚过一半某些Slot已经空了另外一些Slot还剩很多。这时候按用户哈希落到空Slot的请求会直接失败但全局库存明明还有。用户开始投诉“有货抢不到”。解决方案是给每个SKU设置一个全局兜底Key保留总库存的5%左右作为应急库存。固定Slot扣减失败后再去尝试一次全局Key。全局Key也要走Lua脚本原子扣减并且只允许重试一次。同时后台每分钟统计每个Slot的余量如果Slot间差异太大活动结束后再从富余Slot向全局Key做库存迁移。秒杀期间不要动态搬库存容易引发一致性问题。6.3 给库存Key设置了过期时间结果成了最大的雪崩源早期我们为了“防止Redis内存浪费”给库存Key设置了24小时过期。结果第二天某个热门商品的库存Key过期所有扣减请求直接穿透到数据库数据库CPU瞬间100%秒杀入口几乎瘫痪。这个教训相当深刻。秒杀活动中的库存Key绝对不能有TTL内存清理只能靠活动结束后的后台任务。而且后台任务删除Key之前要先确认sold和订单表数量对账完成。如果担心程序异常导致Key长期残留可以设置一个“活动预期结束时间24小时”的延迟清理而不是统一24小时过期。6.4 回补任务别和主流程挤在一起最开始的库存回补逻辑写在消费失败的catch块里赶上大规模消费失败时回补线程和正常扣减线程抢同一个Redis连接池导致正常扣减也大面积超时。后来我们把回补任务拆成了独立的消费者组使用单独的Redis连接池并且给回补操作加了限流。回补审计表记录每一次操作的订单号、原因、操作人和时间方便出问题时回溯。回补逻辑必须幂等。我们曾因为定时任务和死信队列双重触发同一笔订单被回补了两次库存账面对不上。后来用orderToken作为唯一键回补前先查审计表重复回补直接跳过。6.5 区分“未开始”“已售罄”“限购”的状态码前端需要根据不同的状态码渲染按钮未开始显示倒计时已售罄显示灰色限购显示“每人限购X件”。一开始我们偷懒把限购和售罄都返回0结果前端把限购也显示成“已抢光”用户投诉量暴增。后来把所有失败状态拆开并且要求网关不要二次包装返回码。状态码分开还有一个好处监控平台能区分失败原因。如果大量返回“-2”说明限购参数可能有问题或者存在刷量如果大量返回“0”说明库存确实卖完了。这些数据对运营和大促复盘很重要不能混在一起。最后说一点自己的体会。高并发秒杀库存更新本质上不是“让系统变快”而是“把并发集中冲击单点的问题拆成多份并且允许失败和重试”。我在实际项目里踩过很多坑最亏的就是一开始总想着靠数据库硬扛后来才明白库存真的能稳如磐石不是因为某一次优化做得多完美而是因为从Key设计、脚本、异步、补偿到降级每个环节都留了后手。希望这篇文章里的细节能给你提供一个可以抄作业的起点但真正上线前一定要用自己的流量模型压过、练过。再小的改动都可能在大促时暴露问题提前多准备一点现场就少狼狈一分。
阅读完成 · 觉得有帮助?
咨询建站