电商秒杀场景下,如何用Redis+Lua脚本实现库存原子性扣减(附完整代码)
又到了一年一度的大促季,技术团队最紧张的时刻也随之而来。去年双十一,我们团队负责的核心商品秒杀模块,在峰值流量冲击下,后台日志里惊现了几笔“库存为负”的诡异订单。虽然通过紧急人工核对与补偿机制解决了问题,但那次惊心动魄的经历,让我们彻底明白:在高并发扣减库存这个战场上,任何理论上的“大概率可行”都是危险的,必须追求绝对的原子性与一致性。经过后续几个月的重构与压测,我们最终将一套基于Redis与Lua脚本的方案打磨成熟,成功扛住了后续数次大促的考验。今天,我就把这套经过实战检验的“防超卖”核心方案拆解开来,从设计思想到每一行代码,毫无保留地分享给你。
这套方案的核心在于,将库存扣减这个最关键的“读-判断-写”操作,从应用层下推到Redis服务器内部,通过Lua脚本保证其执行的原子性。这听起来简单,但魔鬼藏在细节里:如何设计键结构?如何处理预扣减与最终扣减?脚本异常了怎么办?缓存与数据库如何最终一致?我会结合具体的业务场景,一步步带你搭建起这个既能扛住流量洪峰,又能保证数据准确性的系统。
1. 为什么传统方案在秒杀场景下会“失灵”?
在讨论具体技术实现之前,我们必须先理解秒杀场景带来的独特挑战。它不仅仅是“高并发”那么简单,而是具备几个鲜明的特征:瞬时流量极高、资源竞争极度集中(热门SKU)、对响应延迟极其敏感、要求绝对的数据一致性(不能超卖)。在这种背景下,很多常规的并发控制方案会显得力不从心。
数据库乐观锁是最常被首先考虑的方案。它的原理是为库存记录增加一个版本号字段,更新时校验版本号是否变化。在中小流量下,这确实有效。但在秒杀场景,当一万个请求同时查询到同一个版本号并尝试更新时,最终只有一个请求能成功,其余九千九百九十九个请求都会更新失败,需要重试。这会导致两个严重问题:一是数据库承受了大量无效的更新操作,连接池迅速被占满;二是用户体验极差,用户反复点击却总是提示“库存不足”或“系统繁忙”。本质上,这是将并发控制的压力完全转移给了数据库,而数据库的锁竞争和行锁开销在这种场景下是灾难性的。
数据库悲观锁(如SELECT FOR UPDATE) 则走入了另一个极端。它确实能保证强一致性,但在秒杀开始的一瞬间,所有请求都会在数据库层面排队等待锁释放,系统吞吐量会急剧下降,甚至可能因为大量连接等待超时而导致服务雪崩。这相当于用一条极其狭窄的通道去疏导洪水。
纯应用层的分布式锁,例如使用Redis的SETNX命令,是一个进步。它通过一个中心化的锁服务来串行化请求。然而,其性能瓶颈在于网络往返(RTT)和锁的粒度。每一次扣减都需要“获取锁->查询库存->判断->扣减库存->释放锁”至少三次网络通信。在每秒数万次请求下,这些网络开销累积起来非常可观。更棘手的是,如果在执行扣减业务逻辑时客户端发生GC停顿或网络抖动,可能导致锁超时被其他请求获取,进而出现重复扣减的超卖风险。
提示:在高并发场景下,网络通信次数是影响性能的关键因素之一。应尽可能将多个操作合并,减少客户端与服务器之间的往返交互。
那么,理想的方案应该是什么样的?它需要满足以下几个条件:
- 原子性:库存查询、判断、扣减必须作为一个不可分割的整体执行。
- 高性能:操作必须在内存中完成,延迟极低。
- 高吞吐:能够并行处理大量请求,避免串行化瓶颈。
- 可扩展:能够轻松应对分布式部署。
而 Redis + Lua脚本 的组合,恰好能完美契合这些要求。Redis提供内存级的高速访问,而Lua脚本则在Redis服务器端原子化地执行一系列命令。
2. 核心架构设计:分层与状态流转
直接上代码之前,我们需要一个清晰的顶层设计。我们的目标不是简单地用Redis替代数据库,而是构建一个缓存为主、数据库兜底、异步同步的协同体系。整个库存管理的生命周期被划分为几个关键状态,如下图所示(概念模型):
用户可售库存 (Available Stock)
|
| 下单预扣减
v
冻结库存 (Frozen Stock)
|
| 支付成功
v
已售库存 (Sold Stock)
|
| 支付失败/取消
v
用户可售库存 (Available Stock)
为了在Redis中高效映射这个状态模型,我们设计了以下键结构:
| Redis键名 | 类型 | 描述 | 示例 |
|---|---|---|---|
stock:avail:{sku_id} |
String | 商品的可售库存数量。秒杀开始时从数据库加载。 | stock:avail:1001 |

&spm=1001.2101.3001.5002&articleId=152749517&d=1&t=3&u=59267dc743f94578bbce58bc1880e00f)
5179

被折叠的 条评论
为什么被折叠?



