跳到主内容

从超卖到秒空:Redis+Lua 拼手气红包原子拆分实战 ​

问题背景:高并发抢红包为什么容易超卖 ​

拼手气红包是典型的“瞬时高并发 + 强一致库存”场景:一个 100 元、20 个的红包在群内发出后,可能在数百毫秒内被抢完。系统必须同时满足三个约束:总金额严格等于预算、每个红包金额大于最低值、最后一个人抢完后不能再出现第 21 个红包。

如果直接用“读库存、判断数量、随机金额、写回余额”四步命令实现,即使 Redis 单条命令很快,多条命令之间仍会穿插其他客户端请求。两个请求同时读到剩余 1 个红包,就可能都判定成功,造成超卖;若先随机金额再扣余额,还可能出现尾包为负数。

方案对比:预分配、实时拆分与 Lua 原子拆分 ​

常见实现有三类。

  • 数据库行锁:用 SELECT ... FOR UPDATE 锁定红包记录,逻辑简单,但热点行在万级 QPS 下会迅速成为瓶颈。
  • 发红包时预分配:把每个小红包金额提前算好放入 List,抢购时只做 LPOP。优点是抢购链路短,缺点是大红包占内存,且金额在开抢前已固定,运营撤回、过期回收较麻烦。
  • Redis + Lua 实时原子拆分:把“校验、随机、扣减、记录”放进一段 Lua 脚本。Redis 使用单线程执行脚本,期间不会被其他命令打断,因此天然具备原子性,同时保留实时拆分的灵活性。

下面的流程图展示一次原子抢购的关键判断:

客户端发起抢购EVAL 执行 Lua校验库存与重复抢二倍均值随机金额原子扣减余额数量记录领取明细返回金额或已抢光

数据结构设计: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:1001

Lua 脚本:一次执行完成随机拆分与扣减 ​

随机算法采用“二倍均值法”:若剩余金额为 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 只承担热点态,数据库做最终对账。

避坑总结:三类高频线上事故与修正 ​

  1. 浮点金额导致尾包对不上账。 现象是 20 个红包金额相加出现 99.999999 元。修正方法是全程用“分”的整数运算,展示层再除以 100;Lua 中使用 math.floor,不要使用浮点取模。
  2. 随机上界未给后续用户保底。 现象是剩余 3 人、剩余 2 分时仍随机出 2 分,后面两人只能拿 0。修正方法是把上界钳制为 remainAmount - remainCount + 1,并对最后一人直接分配全部余额。
  3. 把判重放在脚本外导致重复领取。 现象是用户快速双击,两个请求在 SISMEMBER 与 SADD 之间穿插。修正方法是判重和登记必须在同一段 Lua 中完成;脚本发布前用 SCRIPT LOAD,并对 NOSCRIPT 错误回退一次 EVAL。
  4. 大 key 与主从切换被忽视。 百万级群红包若把所有领取者塞进一个 Set,可能形成大 key。应按红包 ID 拆分,并设置过期时间;异步复制场景下明确“最多多抢、不会超扣”的业务语义,关键账单以数据库对账为准。

至此,Redis + Lua 用不到三十行脚本就实现了拼手气红包的随机拆分、一人一单与绝不超卖。核心原则只有一句:凡是“读判断再写”的库存操作,都应放进同一个原子执行单元。

免责声明

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