直接用DECR不够用,因其仅原子扣减但无法校验红包是否存在、用户是否已领、余额是否充足等业务条件,高并发下易致超发;必须用Lua脚本将校验与多步操作封装为原子执行单元。

为什么直接用 DECR 不够用?
红包金额扣减看似只是“减一”,但微服务场景下,DECR 本身虽是原子操作,却无法保证「扣减 + 记录领用行为 + 更新用户余额」这一整条链路的原子性。更关键的是,红包有总金额、每人限领一次、需校验是否已抢完等业务约束,单靠一个 DECR 无法表达这些条件。常见错误是先查再判再减,结果在高并发下出现超发——比如两个协程同时读到剩余 1 元,都判定“还能抢”,然后都执行 DECR,实际扣成 -1。
用 Lua 脚本封装完整校验逻辑
Go-Redis 支持通过 Eval 执行 Lua 脚本,Redis 保证脚本内所有命令串行执行,天然满足原子性。核心思路是:把「检查红包是否存在、是否已领、剩余金额是否足够、扣减金额、记录领取者、更新领取计数」全部写进一个 Lua 脚本里,一次提交。
示例脚本逻辑要点:
-
redis.call("EXISTS", KEYS[1]) == 0→ 红包已过期或不存在,直接返回 0 -
redis.call("SISMEMBER", KEYS[2], ARGV[1]) == 1→ 用户已领过,返回 -1 -
local left = redis.call("GET", KEYS[1]);若left <= 0,返回 -2 -
redis.call("DECRBY", KEYS[1], ARGV[2])→ 扣减指定金额(非固定 1) -
redis.call("SADD", KEYS[2], ARGV[1])→ 记录用户 ID 到已领集合 -
redis.call("INCR", KEYS[3])→ 更新领取总人次(可选) - 最后
return left(或新余额),让 Go 层判断结果
Go 中调用:client.Eval(ctx, script, []string{redPacketKey, claimedSetKey, counterKey}, userID, amountStr).Int64()
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
Key 设计与过期策略必须匹配业务生命周期
红包不是永久资源,Key 命名和过期时间直接影响正确性和内存占用:
- 红包主 Key(如
rp:20241105:1001)必须设置EXPIRE,且过期时间应覆盖整个活动周期+一定缓冲(比如活动结束 2 小时后过期),避免残留 Key 占用内存 - 已领用户集合 Key(如
rp:20241105:1001:claimed)建议复用相同过期时间,可用EXPIREAT同步设置,否则会出现“红包 Key 已删,但 claimed 集合还在”的不一致 - 不要用用户 ID 作为红包 Key 的一部分来分片——这会导致无法全局控制总金额,也破坏了“同一红包多人抢”的语义
客户端需要处理 Lua 返回值与网络异常的组合情况
Go-Redis 的 Eval 可能返回三种状态:成功(含业务码)、Redis 错误(如 redis: nil 表示 key 不存在)、网络超时。容易被忽略的是:网络超时后,脚本可能已在 Redis 执行成功,也可能未执行——这时不能简单重试,否则会重复领。
稳妥做法:
- 为每次抢红包请求生成唯一
requestID,并作为参数传入 Lua 脚本 - Lua 中用
SETNX尝试写入rp:20241105:1001:pending:{requestID},成功才继续后续逻辑,失败直接返回 “duplicate” - 客户端对超时请求,先查该
requestID是否已存在(或查用户是否已领),再决定是否告终或补查 - 避免在 Lua 中做耗时操作(如遍历大集合),否则会阻塞 Redis 其他请求
真正难的不是写对脚本,而是让每个参与方(Redis、Go 服务、下游记账服务)对“一次请求是否最终生效”达成确定性共识。这点在补偿和对账环节最明显。

















