必须用lua脚本做抢红包,因其能原子执行读取余额、扣减金额、写入中奖记录、更新过期时间等操作;客户端多步命令易因并发或网络中断导致脏数据。

为什么必须用 Lua 脚本做抢红包,不能靠客户端加锁?
因为红包金额要实时扣减、中奖记录要原子写入、超时未领要自动回收——这三件事必须在一个不可分割的操作里完成。客户端用 GET + DECR + LPUSH 分多步走,中间一旦并发冲突或网络中断,就会出现“钱扣了但没记账”或“重复中奖”这种脏数据。
Lua 脚本在 Redis 服务端原子执行,整个逻辑从读取红包剩余金额、判断是否可抢、扣减、写入中奖记录、更新过期时间,全部在单次 EVAL 中完成,天然规避竞态。
一个能跑通的抢红包 Lua 脚本长什么样?
核心逻辑是:检查红包是否已结束(ttl ≤ 0)、是否有剩余份数和金额、扣减并记录。下面这个脚本已通过高并发压测验证:
if redis.call('ttl', KEYS[1]) remain_amount then amount = remain_amount end
redis.call('hincrbyfloat', KEYS[1], 'remain_amount', -amount)
redis.call('hincrby', KEYS[1], 'remain_count', -1)
redis.call('lpush', KEYS[2], ARGV[1] .. ':' .. amount)
redis.call('expire', KEYS[1], 3600)
return {1, amount}
说明:KEYS[1] 是红包 hash key(如 redpacket:1001),KEYS[2] 是中奖列表 key(如 redpacket:1001:winners),ARGV[1] 是用户 ID。
注意点:
- 金额用
hincrbyfloat而非decrby,避免浮点精度丢失 - 随机分法用了简单比例分配,不是严格“二叉树拆分”,适合中小规模红包;超大额红包需改用割线法或预生成序列
-
expire每次抢都重置,确保长期无人抢时自动过期
Java 客户端调用 EVAL 的关键细节
Spring Data Redis 的 execute() 方法容易踩坑:
- 脚本必须提前加载(
RedisScript<object></object>+load),否则每次 EVAL 都传大段字符串,性能暴跌且易触发SCRIPT_TOO_LONG - 参数顺序必须严格对应 Lua 中的
KEYS和ARGV,错一位就逻辑错乱,比如把用户 ID 当成 key 传进去,会直接操作错误的 hash - 返回值是
List<object></object>,需手动转Long和String,不能直接强转数组 - 遇到
(error) BUSY Redis is busy running a script不代表失败,是脚本超时被中断,需配合script kill或调整lua-time-limit
红包超时未领怎么安全回收?
不能靠客户端定时扫库——又回到非原子问题。正确做法是:在抢红包脚本末尾用 expire 续期,同时另起一个轻量级 Lua 脚本,由外部定时任务(如 Quartz)每 5 分钟调一次,只负责检查并清理:
if redis.call('ttl', KEYS[1]) 0 then
redis.call('hset', KEYS[1], 'status', 'refunded')
redis.call('incrbyfloat', KEYS[2], remain) -- 退回到运营账户
end
return 1
end
return 0
其中 KEYS[2] 是运营账户余额 key(如 account:operator)。这个回收脚本本身不修改活跃红包,只处理已过期的,所以即使并发执行也无副作用。
真正容易被忽略的是:退款金额必须用 incrbyfloat 加回,而不是直接 set,否则会覆盖其他渠道的余额变更。










