跳到主内容

从超卖 37 单到零误差:Redis INCR+Lua 秒杀限流避坑实录 ​

问题背景:秒杀场景为什么不能用普通 INCR ​

大促秒杀的核心矛盾是:瞬时上万请求争抢有限库存,同时还要对单个用户做频率限制。很多团队第一反应是用 Redis 的 INCR 做计数器——它本身确实是原子的,但「读计数 → 判断 → 扣库存」一旦拆成多条命令,在并发下就会出现竞态。

典型的错误写法是先 GET stock,应用层判断大于 0 后再 DECR stock。这两条命令之间存在时间窗口,多个请求可能同时读到 stock=1,最终把库存扣成负数,线上就出现了超卖。同理,限流场景里「先查次数再自增」也会让限流失效。

解决思路是把判断与计数放进一段 Lua 脚本,利用 Redis 单线程执行脚本的特性,让整段逻辑原子完成。

方案对比:四种计数限流手段怎么选 ​

方案原子性窗口重置适用场景主要缺点

| | 裸 INCR + 应用层判断 | 单命令原子、组合不原子 | 需手动 EXPIRE | 低并发统计 | 高并发超卖 | | INCR + EXPIRE 管道 | 部分保证 | 依赖首次设置 | 简单限流 | 窗口边界易漂移 | | INCR + Lua 脚本 | 整段原子 | 脚本内精确控制 | 秒杀、限流 | 需处理脚本缓存 | | Token Bucket / 滑动窗口 ZSET | 原子 | 平滑限流 | 精细化流控 | 结构复杂、内存高 |

对于「库存防超卖 + 固定窗口限流」这类强一致诉求,INCR + Lua 是性价比最高的组合:逻辑清晰、性能接近原生命令、内存占用极小。

执行原理:Lua 脚本为何能防超卖 ​

客户端请求EVAL 提交 Lua一次性携带 KEYS/ARGVRedis 单线程执行判断+INCR+EXPIRE中途不被其他命令打断返回 1 成功 / 0 拒绝

Redis 执行 EVAL 期间,整个脚本作为一个命令排队运行,期间不会插入其他客户端命令,因此「读库存、判断、扣减」天然具备隔离性。脚本内还能同时完成窗口过期时间设置,把计数与重置绑定在一起。

实操步骤:可运行的秒杀扣库存脚本 ​

先用 redis-cli 初始化 10 件库存:

bash
# 初始化秒杀库存为 10 件
SET seckill:stock:1001 10

# 查看当前剩余库存
GET seckill:stock:1001

下面这段 Lua 同时完成「库存校验 + 原子扣减」,返回 1 表示成功,0 表示已售罄:

bash
# KEYS[1]=库存 key,ARGV[1]=本次购买数量
EVAL "local stock = tonumber(redis.call('GET', KEYS[1]) or '0') 
local buy = tonumber(ARGV[1]) 
if stock < buy then 
  return 0 
end 
redis.call('DECRBY', KEYS[1], buy) 
return 1" 1 seckill:stock:1001 1

关键数据结构就是一个 String 计数器。可以连续执行观察,库存绝不会被扣成负数:

bash
# 连续压测式扣减(可用 shell 循环模拟并发)
for i in $(seq 1 12); do
  redis-cli EVAL "local s=tonumber(redis.call('GET',KEYS[1]) or '0') if s<1 then return 0 end redis.call('DECR',KEYS[1]) return 1" 1 seckill:stock:1001
done

# 最终库存停在 0,而不是 -2
GET seckill:stock:1001

窗口重置边界:固定窗口限流的正确姿势 ​

对单用户限流(例如每秒最多 5 次)时,最容易出错的是 EXPIRE 的设置时机。正确做法是:仅在计数从无到有的第一次设置过期,后续请求只 INCR,不重复刷新 TTL,否则窗口会被无限拉长。

bash
# 限流脚本:KEYS[1]=计数key ARGV[1]=上限 ARGV[2]=窗口秒数
EVAL "local key = KEYS[1] 
local limit = tonumber(ARGV[1]) 
local window = tonumber(ARGV[2]) 
local current = tonumber(redis.call('GET', key) or '0') 
if current >= limit then 
  return 0 
end 
local n = redis.call('INCR', key) 
if n == 1 then 
  redis.call('EXPIRE', key, window) 
end 
return 1" 1 rate:user:8888 5 1

验证窗口重置:连续请求第 6 次会被拒绝,等待 1 秒后 key 自动过期,计数归零又能重新放行。

bash
# 连续 6 次,前 5 次返回 1,第 6 次返回 0
for i in $(seq 1 6); do
  redis-cli EVAL "local c=tonumber(redis.call('GET',KEYS[1]) or '0') if c>=5 then return 0 end local n=redis.call('INCR',KEYS[1]) if n==1 then redis.call('EXPIRE',KEYS[1],1) end return 1" 1 rate:user:8888
done

# 观察剩余存活时间,归零即窗口重置
TTL rate:user:8888

生产环境建议用 EVALSHA 替代每次发送整段脚本:先 SCRIPT LOAD 拿到 sha1,再用摘要调用,减少网络带宽;Redis 报 NOSHA 时回退为 EVAL 重新加载即可。

避坑总结:来自线上的三个真实教训 ​

坑一:GET 与 DECR 拆成两条命令导致超卖。 现象是压测后库存出现负数、订单数大于备货量。根因是多条命令之间被其他请求插入。修正方法是把判断与扣减全部收进同一个 Lua 脚本,依赖单线程原子执行,绝不在应用层做读后写。

坑二:每次 INCR 都调用 EXPIRE 刷新窗口。 现象是高活跃用户的限流 key 永不过期,限流失效、窗口边界不断后移。修正方法是判断 INCR 返回值等于 1 时才设置 TTL,让窗口起点固定在首次请求时刻;更稳妥可用 SET key 1 EX window NX 语义或在脚本中用 redis.call('TTL') 检测残留无过期 key 并补设。

坑三:脚本里混用返回类型或未做数字转换。 Lua 的 redis.call('GET') 返回字符串,直接与数字比较可能得到非预期结果;空 key 返回 false 时做算术会报错。修正方法是统一 tonumber(redis.call(...) or '0'),并保证所有分支返回一致的整数状态码,避免客户端解析异常。

此外还需注意:Lua 脚本要尽量短,禁止在脚本内执行大 KEYS 遍历或慢命令,因为脚本执行期间会阻塞整个 Redis 实例;主从复制与 Cluster 下,脚本涉及的多个 key 必须落在同一 slot(可用 {hash_tag} 聚合),否则跨槽执行直接报错。把脚本设计成纯函数、KEYS 全部显式声明,也能兼容 Cluster 与 AOF 重写。

掌握「原子脚本 + 首次设 TTL + 一致返回值」这三点,INCR 配合 Lua 就能在秒杀与限流场景中稳定做到零超卖、窗口精确重置。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。