Redis+Lua脚本实战:彻底解决秒杀超卖问题 1. 秒杀场景下超卖问题的根源一次事故复盘1.1 那次5000件库存卖出5150单的事故我至今记得那场活动上线的惨状。某品牌日在App首页挂了秒杀入口后台配了5000件商品库存活动开始不到3秒前端显示售罄。我本来还挺开心觉得预热做得好、转化率高结果运营拉订单明细的时候傻眼了——后台支付成功的订单数停在了5150单超卖了150单。更棘手的是那150个用户已经付款了。处理办法无非是退款补偿券但对品牌方来说活动还没结束就闹出超卖这种事故后面的合作都差点谈崩。事后排查代码问题出在库存扣减逻辑写得过于天真// 事故代码的简化版 public boolean decreaseStock(Long goodsId) { int stock stockMapper.getStock(goodsId); if (stock 0) { stockMapper.decrease(goodsId); return true; } return false; }这段逻辑单看没有任何问题先查库存如果大于0就扣减。但放到秒杀场景下问题就暴露了——查库存和扣库存是两步操作两步之间有时间间隙大量并发请求同时通过了库存大于0的判断然后依次执行扣减库存自然就变负数了。1.2 根因检查与扣减不是同一个原子操作这里的核心矛盾业内叫check-then-act问题。用银行取款类比就很好理解你去柜台取账户里的100块柜员先查余额发现够正准备给你办取款隔壁窗口另一个柜员也在操作同一个账户也查到了这笔余额也准备取走100块。两个人都以为自己取到了钱但实际上账户里的钱根本不够支付两笔。秒杀场景下的库存扣减就是这个缩影。每一个请求都在执行读库存、判断、扣减三个动作这三个动作只要不是绑定在一起的高并发下必然出现交叉执行。你以为的扣减流程可能是这样的请求A - 读到库存1 - 判断通过 - 扣减 - 成功 请求B - 读到库存1 - 判断通过 - 扣减 - 成功但实际发生的顺序是请求A - 读到库存1 请求B - 读到库存1 请求A - 判断通过 请求B - 判断通过 请求A - 扣减 - 剩余0 请求B - 扣减 - 剩余-1两个请求都读到了1都通过了判断最终库存变成了-1。这就是超卖的根源。所以解决秒杀超卖问题的本质就是让检查库存扣减库存成为一个不可拆分的原子操作。1.3 单机锁与数据库锁为什么都救不了场很多人第一反应是加锁。我在当时的排查过程中也确实试过synchronized结果发现一个尴尬的事实synchronized是JVM级别的锁它只能锁住单台应用服务器内的线程。可现代电商系统为了保证可用性和吞吐量应用层基本都是多实例部署请求会通过负载均衡打到不同的机器上。实例A加的锁实例B根本感知不到两个实例的线程该并发还是并发超卖问题依旧存在。有人会说那把锁粒度放大到进程外不就行了用ZooKeeper、etcd或者Redisson做分布式锁。这个方向没错但引入分布式锁不是没有代价的。它会把所有请求强制串行化在秒杀这种超高并发的场景下TPS会被锁得很难看这一点我在后面对比方案的时候会详细展开。数据库本身也有行锁UPDATE t_stock SET stock stock - 1 WHERE stock 0这种写法确实能保证单行更新的原子性但问题在于数据库的连接数和锁竞争在秒杀峰值流量下会迅速成为瓶颈。我后面用压测数据说明这个问题。先给出结论单纯的数据库锁扛不住秒杀流量单纯的应用层锁解决不了跨实例并发必须有一个既能保证原子性、又能扛住超高并发的中间层方案。RedisLua脚本就是冲着这两个需求去的。2. 方案选型为什么最终选择了RedisLua脚本2.1 数据库乐观锁能守住不超卖但扛不住峰值我先梳理一下当时对比过的几条技术路线。第一条是数据库乐观锁SQL大概长这样UPDATE t_stock SET stock stock - 1 WHERE goods_id #{goodsId} AND stock 0;这条SQL的巧妙之处在于stock 0这个条件是在数据库层面判断的InnoDB对同一行数据更新时是行锁串行的所以它确实能保证不超卖。影响行数返回1说明扣减成功返回0说明库存不足。但实际压测下来它的上限很明显所有请求都在争抢t_stock表里同一行数据的行锁。MySQL单行热点的更新TPS在普通硬件条件下只能跑到几千甚至几百秒杀场景的瞬时请求量动辄几万、几十万这些请求全部怼到数据库上连接池瞬间被打满大量请求等待获取连接最终表现为接口超时、雪崩。数据库资源是非常宝贵的秒杀的绝大部分请求最终都是抢不到的如果让所有无效请求都打到数据库就是在浪费核心资源。理想的设计是让无效请求在更靠前、更廉价的位置被挡掉。2.2 Redis事务与WATCH冲突重试是硬伤第二条路线是用Redis的WATCHMULTI事务。Redis支持WATCH命令实现一种乐观锁机制WATCH seckill:stock:1001 GET seckill:stock:1001 MULTI DECR seckill:stock:1001 EXECWATCH会监控库存keyEXEC执行时如果发现这个key被其他客户端修改过整个事务会被打断返回nil。于是我需要捕获这个nil然后重试整个流程。听起来能解决问题但秒杀场景有个致命特点库存key是同一个热点key冲突率极高。1000个并发请求盯同一个key几乎每个事务都会发现key被改过结果就是所有客户端都在无限重试。重试本身又带来更多读操作和网络交互像滚雪球一样把流量放大最后Redis的负载和网络带宽都会被拖垮。而且MULTI/EXEC有一个更根本的痛点事务里的命令是排队后一次性执行的但它不支持在执行过程中根据当前值做条件判断。没有WATCH配合它根本没法表达如果库存大于0才扣减这种业务逻辑。所以WATCHMULTI这套组合在秒杀场景下理论可行实操很难受。2.3 分布式锁不超卖了但是TPS被锁死第三条路线是用分布式锁比如Redisson代码大致如下RLock lock redissonClient.getLock(seckill:lock:1001); lock.lock(); try { int stock redisTemplate.opsForValue().get(stockKey); if (stock 0) { redisTemplate.opsForValue().decrement(stockKey); } } finally { lock.unlock(); }分布式锁确实能保证跨实例互斥但它的问题是锁的粒度太大。对同一个商品的所有请求来说大家争夺的是同一把锁拿到锁的请求才能去扣库存其他请求全部阻塞等待。这意味着整个商品的扣减流程被强行串行化了并发能力几乎归零。我在压测里看到过这种方案的惨状1000并发进来QPS不到200P99延迟到了秒级。虽然不超卖了但用户体验极差一个秒杀活动如果吞吐量上不去用户感受到的就是永远在转圈加载最终一样是投诉。2.4 Lua脚本原子性、性能与复杂度的综合最优解最后说回标题里的主角。Redis从2.6版本开始内嵌了一个Lua 5.1解释器支持通过EVAL命令执行Lua脚本。关键点是Redis是单线程事件循环模型Lua脚本一旦开始执行就会从头到尾完整运行期间Redis不会处理任何其他客户端的命令。这意味着脚本里的检查库存、判断库存、扣减库存、返回结果这整段逻辑在执行过程中不可能被任何并发请求插入天然就是原子的。这正好命中了check-then-act问题的要害。拿银行取款的类比说RedisLua相当于把查余额、判断、扣款打包给了同一个柜台而且这个柜台在处理你的业务完成之前不允许任何人插进来哪怕隔壁柜台来了也得排队。Lua脚本还有一个额外优势脚本是通过一次网络请求发给Redis执行的不像WATCHMULTI那样需要多次往返交互。对于高并发环境来说单次RTT的节省带来的效果非常明显。用表格总结一下我当时对比的结果方案原子性峰值吞吐实现复杂度综合评价数据库乐观锁能保证低受限于热点行锁低适合低并发场景Redis WATCH事务能保证但冲突重试低重试风暴放大流量中秒杀场景不推荐分布式锁能保证低请求串行化严重高锁粒度难控制RedisLua脚本天然原子高低最优解这个表格不是我拍脑袋出来的是压测数据支撑的具体数据放在第6章。3. 完整的实现代码从Lua脚本到Spring Boot工程3.1 核心扣减Lua脚本逐行拆解先看最核心的Lua脚本其实只有十几行。我把校验逻辑、扣减逻辑、返回结果全部塞进了同一个脚本-- 库存扣减脚本 -- KEYS[1]: 商品库存key例如 seckill:stock:1001 -- ARGV[1]: 扣减数量秒杀场景下一般是1 -- 返回: -- 1 扣减成功 -- 0 库存不足 -- -1 商品不存在或尚未初始化 if redis.call(exists, KEYS[1]) 0 then return -1 end local stock tonumber(redis.call(get, KEYS[1])) local need tonumber(ARGV[1]) if stock need then return 0 end redis.call(decrby, KEYS[1], need) return 1逐行解释几个关键点。第一段redis.call(exists, KEYS[1]) 0这一步是商品存在性校验。秒杀场景里Redis里的库存key必须提前预热如果key不存在说明商品根本没有初始化或者活动已经结束、key被清理了。这时候直接返回-1避免客户端对不存在的商品继续执行扣减。第二段tonumber(redis.call(get, KEYS[1]))。注意这里的tonumber转换非常关键。如果客户端存储库存值时使用了一些对象序列化方式比如JDK序列化get回来的可能不是纯数字字符串tonumber得到的就是nil后面的比较逻辑直接报错。所以生产上我都会用StringRedisTemplate或者确保存储的是纯字符串数字。第三段if stock need then return 0 end。这就是检查库存和扣减库存之间的那个关键判断。在Lua脚本里这个判断和后面的decrby之间没有时间间隙不会有任何其他命令插入。第四段redis.call(decrby, KEYS[1], need)执行真正的扣减。这里用decrby而不是先get再set是因为decrby本身就是原子命令而且不会出现读完旧值、算完新值、写回之间的间隙问题。当然即便用set在Lua脚本内部也是原子的但decrby更简洁、更不容易出错。3.2 Spring Boot侧完整实现有了Lua脚本接下来是工程侧的接入。我用的是Spring Boot Spring Data Redis先引入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency然后定义扣减服务Component public class StockDeductService { Autowired private StringRedisTemplate redisTemplate; // 库存扣减Lua脚本 private static final DefaultRedisScriptLong DEDUCT_SCRIPT new DefaultRedisScript(); static { DEDUCT_SCRIPT.setResultType(Long.class); DEDUCT_SCRIPT.setScriptText( if redis.call(exists, KEYS[1]) 0 then\n return -1\n end\n local stock tonumber(redis.call(get, KEYS[1]))\n local need tonumber(ARGV[1])\n if stock need then\n return 0\n end\n redis.call(decrby, KEYS[1], need)\n return 1 ); } /** * 扣减库存 * * param stockKey 库存key * param quantity 扣减数量 * return 扣减结果 */ public DeductResult deduct(String stockKey, int quantity) { ListString keys Collections.singletonList(stockKey); Long result redisTemplate.execute( DEDUCT_SCRIPT, keys, String.valueOf(quantity) ); if (result null) { return DeductResult.error(500, 库存服务执行异常); } if (result -1) { return DeductResult.error(404, 商品不存在或活动未开始); } if (result 0) { return DeductResult.error(400, 库存不足); } return DeductResult.success(); } }这里有几个工程细节值得说。第一个为什么用StringRedisTemplate而不是RedisTemplate。如果把库存值用JDK序列化存进去Redis里存的是带Java序列化头的一串二进制内容Lua里tonumber根本解析不了脚本会直接报错。用StringRedisTemplate可以保证读写都是纯字符串跟Lua脚本配合最稳。第二个DefaultRedisScript里设置的setResultType(Long.class)不能漏。Spring Data Redis拿到脚本执行结果后需要做类型转换返回类型不对会转换失败运行时直接抛异常。第三个Spring Data Redis执行脚本时会自动优先尝试EVALSHA通过脚本内容SHA1定位已加载的脚本如果Redis里还没有这个脚本会自动回退到全量EVAL。这个机制对开发期很友好但我在生产环境长期运行时还是建议用SCRIPT LOAD预加载后面第5章会专门讲这个坑。3.3 库存预热与数据库最终一致性Lua脚本能扣减库存但它的前提是Redis里的库存key已经存在。所以秒杀活动开始前必须做库存预热把数据库里的可售库存同步到Redispublic void preheatStock(Long goodsId, Integer totalStock) { String stockKey seckill:stock: goodsId; redisTemplate.opsForValue().set(stockKey, String.valueOf(totalStock)); }库存预热一般放在秒杀开始前的一个独立定时任务里。我这边会先对数据源做一次全量校验确保没有已售数量剩余库存 ! 总库存这种脏数据然后再刷入Redis。这里想提醒一个容易忽略的点预热库存的时候别给key设置过期时间。原因我在第5章会详细展开这里先记着。再说数据库最终一致性。秒杀场景下用户下单成功后Redis里的库存已经扣掉了但数据库里的库存也得同步扣。考虑到不能让所有请求都打到数据库常规做法是走异步对账Redis扣减成功 - 用户下单成功 - 发送MQ - 库存服务消费消息 - 扣减DB库存数据库的扣减量实际上只等于真实下单成功的量而不是秒杀进来的全部流量。这样数据库的压力就被控制在了可接受范围内。万一异步任务失败还需要一个定时对账任务去比对Redis剩余库存和DB已扣减量发现不一致就告警并手动修复。3.4 Python等其他技术栈的落地方式不是所有团队都用Java我当时也给Python组同事写了一份示例。redis-py库自带register_script方法用起来比Spring Data Redis还顺手import redis client redis.Redis(hostlocalhost, port6379, decode_responsesTrue) DEDUCT_SCRIPT if redis.call(exists, KEYS[1]) 0 then return -1 end local stock tonumber(redis.call(get, KEYS[1])) local need tonumber(ARGV[1]) if stock need then return 0 end redis.call(decrby, KEYS[1], need) return 1 # register_script 内部会自动完成 SCRIPT LOAD后续执行走 EVALSHA deduct_stock client.register_script(DEDUCT_SCRIPT) def deduct(stock_key: str, quantity: int 1) - int: result deduct_stock(keys[stock_key], args[quantity]) return int(result)register_script会帮你管理脚本的预加载和SHA映射不用自己手搓SCRIPT LOAD算是Python生态里的一个亮点。Go、Node.js等其他语言的Redis客户端也都有类似的脚本执行接口核心逻辑都一样换语言只是换个客户端API而已。4. 秒杀链路中不能忽视的防重、回滚与开关控制4.1 同一个用户重复点击把防重合并进扣减脚本库存不超卖只是第一步。秒杀场景还有一个高频问题同一个用户手快连点两次或者用脚本刷接口结果一个用户抢到了多件。如果业务规则是每人限购一件就需要把用户级的防重逻辑也放进原子操作里。我当时把防重逻辑直接合并进了扣减脚本而不是拆成先防重、再扣库存两步。原因很简单防重和扣库存也有先后间隙两个请求同时穿过防重检查还是会导致一个用户买多件。放进同一个Lua脚本防重和扣库存就一起变成原子操作了-- 进阶版库存扣减 用户防重 -- KEYS[1]: 商品库存key -- KEYS[2]: 用户购买标记key例如 seckill:user:1001:9527 -- ARGV[1]: 扣减数量 -- ARGV[2]: 用户标记过期时间秒 -- 返回: -- 1 扣减成功 -- 0 库存不足 -- -1 商品不存在 -- -2 重复购买 if redis.call(exists, KEYS[1]) 0 then return -1 end local stock tonumber(redis.call(get, KEYS[1])) local need tonumber(ARGV[1]) if stock need then return 0 end -- 设置用户购买标记setnx保证同用户只有一个请求能设置成功 if redis.call(setnx, KEYS[2], 1) 0 then return -2 end redis.call(decrby, KEYS[1], need) redis.call(expire, KEYS[2], ARGV[2]) return 1注意一个细节我是先判断库存是否充足再做用户防重。这样设计是为了在库存已经为0的情况下省掉一次无意义的setnx操作。秒杀后半段用户点进来大概率面临的是库存不足这时候直接返回0不会在Redis里创建大量无用的用户标记key。还有一个细节用户标记key的过期时间ARGV[2]我会设置成秒杀活动时长一小段缓冲比如1天。这样既能覆盖整个秒杀周期又不会在Redis里残留永久key。4.2 订单超时未支付安全释放库存的回滚脚本秒杀业务里有一个常见流程用户抢到库存后需要在下单页确认信息并付款如果30分钟内未支付订单自动关闭占用掉的库存要释放回去。释放库存不能简单地执行incrby。如果只管加回去可能因为代码bug、重复释放导致库存超过初始值。稳妥的做法是把释放库存也做成带上限校验的Lua脚本-- 库存回滚脚本订单超时未支付时调用 -- KEYS[1]: 商品库存key -- KEYS[2]: 商品初始库存key -- ARGV[1]: 回滚数量 -- 返回: -- 1 回滚成功 -- 0 回滚后超过初始库存拒绝 -- -1 商品不存在 if redis.call(exists, KEYS[1]) 0 or redis.call(exists, KEYS[2]) 0 then return -1 end local current tonumber(redis.call(get, KEYS[1])) local initial tonumber(redis.call(get, KEYS[2])) local rollback tonumber(ARGV[1]) if current rollback initial then return 0 end redis.call(set, KEYS[1], current rollback) return 1这里引入了一个新的key初始库存key在库存预热阶段跟库存key一起写入。它的作用就是给回滚操作划定上限防止库存被加到超出初始值。理论上如果所有扣减都走Lua脚本、没有并发bug这个校验是多余的但作为兜底防御我认为这笔开销花得非常值。4.3 秒杀开关与活动生命周期避免未开抢就扣库存做过几次活动之后我意识到一个问题如果库存key在预热阶段已经写入了Redis但秒杀还没正式开始理论上用户只要提前知道接口地址就能绕过前端倒计时直接调接口扣库存。解决方式是在预热阶段只准备库存但不开放扣减同时用一个活动开关key控制扣减是否允许。我把开关判断也放进了Lua脚本-- 完整版库存扣减 用户防重 活动开关 -- KEYS[1]: 商品库存key -- KEYS[2]: 用户购买标记key -- KEYS[3]: 活动开关key -- ARGV[1]: 扣减数量 -- ARGV[2]: 用户标记过期时间秒 -- 返回: -- 1 扣减成功 -- 0 库存不足 -- -1 商品不存在 -- -2 重复购买 -- -3 活动未开始或已结束 if redis.call(exists, KEYS[3]) 0 or redis.call(get, KEYS[3]) ~ 1 then return -3 end if redis.call(exists, KEYS[1]) 0 then return -1 end local stock tonumber(redis.call(get, KEYS[1])) local need tonumber(ARGV[1]) if stock need then return 0 end if redis.call(setnx, KEYS[2], 1) 0 then return -2 end redis.call(decrby, KEYS[1], need) redis.call(expire, KEYS[2], ARGV[2]) return 1活动开关key的值从0变成1就代表秒杀开始了这个切换由后台运营系统统一控制。活动结束后再改成0或者直接删除开关key和库存key整个生命周期就清晰了。这个设计虽然只增加了一小段判断但对于防止活动未开始被薅库存这种事故来说价值很大。5. 上线前我踩过的坑与排查过程5.1 第一次踩坑把参数直接拼进Lua脚本最开始我图省事把参数直接拼到脚本字符串里再发给Redis执行大概长这样DefaultRedisScriptLong script new DefaultRedisScript(); script.setScriptText( local stock tonumber(redis.call(get, KEYS[1]))\n if stock quantity then return 0 end\n // 错误示范 return redis.call(decrby, KEYS[1], quantity ) );这个写法有两个隐患。第一如果quantity来源是用户输入拼接进脚本内容可能导致脚本注入或语法错误。但注入这个说法可能有点吓人Lua脚本本身不会去执行system命令实际风险主要是构造出非法脚本导致Redis执行报错。不过还有一个更隐蔽的问题每个不同的quantity都会生成一个新的脚本内容导致Redis脚本缓存急剧膨胀内存被大量无意义的脚本副本占掉。后来我全部改成了参数化写法脚本内容固定变量通过ARGV传入。这才符合Lua脚本的正确用法脚本是固定的模板数据通过KEYS和ARGV传入。5.2 第二次踩坑每次请求都全量传脚本导致连接被打爆改完参数化之后我又发现压测时Redis的CPU和带宽高得离谱。查了一下原因是Spring Data Redis默认执行逻辑虽然在脚本未加载时会回退到全量EVAL但如果连接池里的每个连接首次执行脚本时都没有对应缓存相当于每台应用实例都会发生一次全量脚本传输。虽然只有一次但应用实例特别多时也还好。真正的问题出在我最初手写的客户端代码上——我图省事直接每次请求都调eval传全量脚本。1000并发下每次请求都带着几百字节的Lua脚本内容在网络上传Redis还要重复解析带宽和CPU都被白白消耗。正确做法是走SCRIPT LOADEVALSHA。先用SCRIPT LOAD把脚本加载到Redis拿到一个40位的SHA值后续请求只传SHA值Redis识别到是已加载脚本就直接执行redis-cli SCRIPT LOAD $(cat deduct.lua) # 返回 e0a8f3d2f1a4...然后在Java代码里用这个SHA执行redisTemplate.execute( new DefaultRedisScript(), // 通过RedisConnection执行evalsha );Spring Data Redis的逻辑实际上是DefaultRedisScript第一次执行时会先计算脚本的SHA1发送EVALSHA如果收到NOSCRIPT错误说明Redis里没有这个脚本回退到EVAL发送全量脚本。这样设计本身没问题但如果你用的是三层封装的自定义客户端一定要确认底层走的是EVALSHA而不是每次都EVAL。我自己就吃过这个亏。5.3 第三次踩坑给库存key设置了过期时间这个坑最隐蔽也最致命。上线初期我给库存key设置了一个30分钟的过期时间想着活动结束后key能自动清理省得做清点任务。结果某次活动因为运营临时把秒杀时间从30分钟延长到了45分钟活动进行到第35分钟时Redis里的库存key过期被自动删除了。后续所有用户请求走进Lua脚本第一步redis.call(exists, KEYS[1]) 0就命中了直接返回-1商品不存在。用户看到的就是商品明明还有库存但一点击就提示活动异常。排查过程花了不少时间因为代码逻辑看起来毫无问题后来是在Redis里手动ttl一个线上库存key才发现过期时间还在倒计时。从那以后我定了两条规矩第一库存key一律不设置过期时间第二活动生命周期控制全部由独立的开关key负责活动结束由清点任务删除库存key而不是依赖Redis的expire。5.4 版本兼容性与Redis 7.0的变化Redis内嵌的Lua解释器版本是5.1不是Lua最新的5.4。这意味着Lua 5.2之后新增的语法比如goto语句、位运算符、table.unpack等新的标准库在Redis Lua脚本里不一定能直接用。我团队里一个同事写脚本时用了Lua 5.3的新语法本地开发环境能跑一上Redis就报语法错误排查了半小时才发现是版本兼容问题。所以在写Redis Lua脚本时保守一点只用最基础的语法就好。另外Redis 7.0开始引入了Redis Functions功能可以把Lua脚本注册为带名字的函数做权限控制也方便脚本的管理和灰度。对于新项目如果团队规范允许我建议关注一下Redis Functions它比散落的脚本字符串更适合大规模团队协作。但对于大多数场景EVAL/EVALSHA已经够用不必为了追新而重构。5.5 压测发现连接池被打爆的排查链路最后说一个压测时必定会遇到的问题连接池打满。我压测时看到的典型报错是redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool第一次看到直接懵了因为自以为代码逻辑没问题。后来一步步排查才发现原因是多方面的第一连接池参数设置不合理。当时配置里maxTotal只设了8而压测的并发是1000连接池早就被占满了。第二部分代码在拿到连接后执行完Redis操作没有及时释放。虽然用了Spring Data Redis的execute方法理论上会自动归还连接但个别手写getConnection()的地方存在连接泄漏。第三单个请求内部太多次Redis交互。比如我最初把查库存、判库存、扣库存、设置用户标记拆成了四次独立Redis调用一个请求占用一个连接的时间被拉长了4倍连接池更容易被打满。解决方案是三层并进调整连接池参数maxTotal200、maxIdle100、minIdle20把多次Redis交互合并进Lua脚本再配合连接池监控确认没有泄漏。压测数据才恢复正常。6. 压测数据与最终收益6.1 同环境下对比方案的真实数据这部分数据来自我当时压测的环境应用层是4台8C16G的实例Redis是6.0版本的主从架构单节点承载读写压测工具是JMeter模拟1000个线程同时发起秒杀请求商品库存设定为5000件。结果如下方案实际TPSP99延迟是否超卖数据库CPU数据库乐观锁约450320ms否85%Redis WATCH事务约230850ms否10%Redisson分布式锁约1801200ms否10%RedisLua脚本约92006ms否12%需要说明这些数据跟机器配置、网络环境、数据量强相关不同团队压出来的绝对值肯定不一样。但从相对关系上能明显看出RedisLua在处理同一热点key的扣减时吞吐量和延迟都远远优于另外三个方案。数据库方案最大的问题是热点行和连接池分布式锁和WATCH方案最大的问题是请求串行化和重试放大。6.2 性能优化细节序列化、连接池、监控方案选型对了工程细节也不能拉胯。我从压测和线上运行里总结了几条优化建议。第一序列化方式用String不要用JDK序列化。JDK序列化会把数字变成一长串二进制Lua里tonumber直接解析失败而且序列化后数据体积大占用带宽和内存。用StringRedisTemplate是成本最低的正确做法。第二连接池参数要跟压测结果对齐。不要照抄网上的配置最好压测时观察连接池的活跃连接数和等待队列长度再逐步上调maxTotal。同时盯住Redis端的connected_clients指标别把Redis连接数撑爆。我当时甚至把默认的Lettuce换成了经过压测验证的配置连接数的稳定性才起来。第三别忽略慢日志和热key监控。Redis的SLOWLOG可以看慢查询热key可以通过redis-cli --hotkeys或者在客户端埋点统计。秒杀场景里商品库存key一定是热key如果QPS实在太高还可以考虑把库存key拆成多个子key分段库存但拆分会显著增加代码复杂度属于不到万不得已不建议做的优化。我自己在中低并发场景下没有拆keyLua脚本单key已经能扛住。6.3 我个人的一点体会回头复盘整个改造过程RedisLua脚本这套方案的胜出并不只是因为它快而是因为它把并发控制这个最头疼的问题用最简单的方式化解掉了。你不需要去设计复杂的锁粒度不需要处理冲突重试也不需要担心中间态导致的超卖只要把业务判断和执行动作写进一个脚本剩下的交给Redis的单线程模型去保证一致性。但这套方案不是没有代价。它把库存数据的一部分搬到了Redis里这就在Redis和数据库之间引入了数据一致性问题。异步对账、补偿任务、监控告警这些配套工程一样都不能省略。我见过不少团队只抄了Lua脚本却没抄对账任务结果某次Redis宕机后Redis里的库存和数据库里对不上活动期间又没法直接改线上数据只能人工处理订单那个痛苦我深有体会。如果让我重新经历一次秒杀改造我会把监控和补偿设计放在跟扣减逻辑同等重要的位置先把对账链路跑通再上压测。毕竟秒杀场景下扣减库存只是第一步整个活动的数据闭环才是最终目的。