外观
从超卖到秒空:Redis+Lua 拼手气红包原子拆分实战
问题背景:高并发抢红包为什么容易超卖
拼手气红包是典型的“瞬时高并发 + 强一致库存”场景:一个 100 元、20 个的红包在群内发出后,可能在数百毫秒内被抢完。系统必须同时满足三个约束:总金额严格等于预算、每个红包金额大于最低值、最后一个人抢完后不能再出现第 21 个红包。
如果直接用“读库存、判断数量、随机金额、写回余额”四步命令实现,即使 Redis 单条命令很快,多条命令之间仍会穿插其他客户端请求。两个请求同时读到剩余 1 个红包,就可能都判定成功,造成超卖;若先随机金额再扣余额,还可能出现尾包为负数。
方案对比:预分配、实时拆分与 Lua 原子拆分
常见实现有三类。
- 数据库行锁:用
SELECT ... FOR UPDATE锁定红包记录,逻辑简单,但热点行在万级 QPS 下会迅速成为瓶颈。 - 发红包时预分配:把每个小红包金额提前算好放入 List,抢购时只做
LPOP。优点是抢购链路短,缺点是大红包占内存,且金额在开抢前已固定,运营撤回、过期回收较麻烦。 - Redis + Lua 实时原子拆分:把“校验、随机、扣减、记录”放进一段 Lua 脚本。Redis 使用单线程执行脚本,期间不会被其他命令打断,因此天然具备原子性,同时保留实时拆分的灵活性。
下面的流程图展示一次原子抢购的关键判断:
数据结构设计:Hash 存库存,List 存明细
使用两个 key 即可承载核心状态:
red_packet:{id}:Hash,字段total(总金额,单位分)、remain_amount(剩余金额)、remain_count(剩余个数)。red_packet:{id}:users:Set,记录已领取用户 ID,用于一人一单判重。
金额一律使用“分”的整数,避免浮点误差。下面命令可直接在 redis-cli 中运行,初始化一个总金额 10000 分、共 20 个的红包:
bash
# 拼手气红包初始化:金额单位为分
redis-cli HSET red_packet:1001 total 10000 remain_amount 10000 remain_count 20
redis-cli EXPIRE red_packet:1001 86400
redis-cli EXPIRE red_packet:1001:users 86400
# 查看库存
redis-cli HGETALL red_packet:1001Lua 脚本:一次执行完成随机拆分与扣减
随机算法采用“二倍均值法”:若剩余金额为 M、剩余人数为 N,本次随机区间为 [1, floor(M/N)*2],最后一个人拿走全部剩余金额。该算法既保留手气波动,又能保证每个人至少拿到 1 分。
将脚本保存为 grab.lua:
lua
-- KEYS[1] 红包库存 Hash,KEYS[2] 已领取用户 Set
-- ARGV[1] 用户 ID
local stockKey = KEYS[1]
local usersKey = KEYS[2]
local userId = ARGV[1]
-- 1. 一人一单判重:返回 1 表示重复领取
if redis.call('SISMEMBER', usersKey, userId) == 1 then
return {1, 0}
end
-- 2. 读取库存;红包不存在或已抢光均返回 2
local data = redis.call('HMGET', stockKey, 'remain_amount', 'remain_count')
local remainAmount = tonumber(data[1])
local remainCount = tonumber(data[2])
if not remainAmount or not remainCount or remainCount <= 0 then
return {2, 0}
end
-- 3. 二倍均值法计算本次金额,单位分
local amount
if remainCount == 1 then
amount = remainAmount
else
local max = math.floor(remainAmount / remainCount) * 2
-- 至少 1 分,且不能把后面的人抢成负数
if max < 1 then max = 1 end
if max > remainAmount - remainCount + 1 then
max = remainAmount - remainCount + 1
end
-- Lua 的 random 含上下界,保证 1 <= amount <= max
amount = math.random(1, max)
end
-- 4. 扣减库存并登记用户,全部在同一脚本内原子完成
redis.call('HSET', stockKey,
'remain_amount', remainAmount - amount,
'remain_count', remainCount - 1)
redis.call('SADD', usersKey, userId)
-- 返回码 0 表示成功,第二个值为抢到的金额(分)
return {0, amount}通过 redis-cli 加载并执行脚本,生产环境建议使用 SCRIPT LOAD + EVALSHA 减少带宽:
bash
# 加载脚本,得到 SHA
redis-cli SCRIPT LOAD "$(cat grab.lua)"
# 假设返回 sha 为 abc123,用户 user_88 抢购
redis-cli EVALSHA abc123 2 red_packet:1001 red_packet:1001:users user_88
# 连续模拟 20 个不同用户抢购
for i in $(seq 1 20); do
redis-cli EVALSHA abc123 2 red_packet:1001 red_packet:1001:users user_$i
done
# 第 21 个用户应返回抢光码 2
redis-cli EVALSHA abc123 2 red_packet:1001 red_packet:1001:users user_21
# 同一用户重复抢应返回重复码 1
redis-cli EVALSHA abc123 2 red_packet:1001 red_packet:1001:users user_1脚本返回数组后,客户端按第一个数字区分成功、重复、抢光;成功时把第二个数字从“分”转换为“元”展示。
并发验证与库存对账
可用下面的命令做简单并发压测,观察是否超卖:
bash
# 30 个用户并发抢 20 个红包
for i in $(seq 1 30); do
redis-cli EVALSHA abc123 2 red_packet:1001 red_packet:1001:users load_user_$i &
done
wait
# 最终剩余必须为 0,领取人数必须等于 20
redis-cli HGET red_packet:1001 remain_count
redis-cli SCARD red_packet:1001:users由于判重、随机、扣减、登记都在同一个 Lua 内完成,Redis 串行执行脚本,因此不会出现两人同时读到相同库存的问题。若需要持久化账单,可在脚本中额外 LPUSH red_packet:{id}:detail userId amount,再由异步任务把 List 迁入数据库;Redis 只承担热点态,数据库做最终对账。
避坑总结:三类高频线上事故与修正
- 浮点金额导致尾包对不上账。 现象是 20 个红包金额相加出现 99.999999 元。修正方法是全程用“分”的整数运算,展示层再除以 100;Lua 中使用
math.floor,不要使用浮点取模。 - 随机上界未给后续用户保底。 现象是剩余 3 人、剩余 2 分时仍随机出 2 分,后面两人只能拿 0。修正方法是把上界钳制为
remainAmount - remainCount + 1,并对最后一人直接分配全部余额。 - 把判重放在脚本外导致重复领取。 现象是用户快速双击,两个请求在
SISMEMBER与SADD之间穿插。修正方法是判重和登记必须在同一段 Lua 中完成;脚本发布前用SCRIPT LOAD,并对 NOSCRIPT 错误回退一次EVAL。 - 大 key 与主从切换被忽视。 百万级群红包若把所有领取者塞进一个 Set,可能形成大 key。应按红包 ID 拆分,并设置过期时间;异步复制场景下明确“最多多抢、不会超扣”的业务语义,关键账单以数据库对账为准。
至此,Redis + Lua 用不到三十行脚本就实现了拼手气红包的随机拆分、一人一单与绝不超卖。核心原则只有一句:凡是“读判断再写”的库存操作,都应放进同一个原子执行单元。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。