直接用 set 无法可靠实现幂等消费,因 nx 只防重复写入、不防处理中状态丢失;需用唯一 message_id + token 占坑 + lua 原子校验,区分“处理中”与“已完成”状态。

为什么直接用 SET 无法可靠实现幂等消费
很多初学者会想:用 SET key value EX 300 NX 就能保证只处理一次。但实际在消息队列场景中,这会漏掉重试失败后再次投递的合法消息——因为 NX 只防重复写入,不防「处理中状态丢失」。比如消费者 A 写入成功、开始处理、但进程崩溃,后续消费者 B 拿到同一条消息时发现 key 已存在,就直接跳过,导致消息丢失。
真正要防的是「同一消息被多个消费者并发处理」,而不是「key 是否存在」。所以必须把「处理中」和「已成功」两个状态区分开,且状态变更需原子。
用 SET + 过期时间 + 唯一 token 实现安全去重
核心思路是:每条消息带一个全局唯一 message_id(如 UUID),消费者先尝试用带随机 token 的 SET 占坑,再用 Lua 脚本做原子状态跃迁。这样即使消费者崩溃,过期时间也能兜底释放资源。
-
SET message_id:123456 <token> EX 600 NX</token>—— 占坑,600 秒是预估最大处理耗时 - 若占坑失败(返回
nil),说明已有其他实例在处理或刚完成,此时需查最终状态(见下一点) - 占坑成功后执行业务逻辑;成功后用
SET message_id:123456 "done" EX 86400标记终态(长期保留用于审计) - 若处理失败或超时,不做任何清理,靠
EX 600自动过期
用 Lua 脚本判断并更新状态,避免竞态
单纯靠两次 Redis 请求(先 GET 再 SET)会引发竞态:两个消费者同时读到空值,都去执行业务。必须用 Lua 把「检查当前状态 + 决定是否允许处理」打包成原子操作。
以下脚本用于消费入口校验:
if redis.call("GET", KEYS[1]) == false then
return redis.call("SET", KEYS[1], ARGV[1], "EX", ARGV[2], "NX")
elseif redis.call("GET", KEYS[1]) == "done" then
return "already_done"
else
return "processing"
end
调用时传入:KEYS[1] = "msg:abc123",ARGV[1] = "token_xyz",ARGV[2] = "600"。返回 1 表示占坑成功,"already_done" 表示已成功,"processing" 表示别人正在处理。
Go 客户端实操要点与易错点
用 github.com/go-redis/redis/v9 时,别直接拼接命令字符串。要用 script.Load() 预加载 Lua,并用 script.Eval() 执行。
- 不要用
client.SetNX()替代 Lua——它只能做单步判断,无法区分「处理中」和「已完成」 -
message_id必须由上游生成并透传,不能在消费者里用time.Now().UnixNano()之类生成,否则重试消息会变成新 ID - Redis 连接池大小建议 ≥ 消费者 goroutine 并发数,否则
EVAL可能阻塞等待连接 - 如果业务逻辑耗时可能超过 Redis key 过期时间,需在处理中定期用
EXPIRE续期(但要小心续期失败导致误删)
最常被忽略的是:没对 "processing" 返回值做重试退避。遇到这个状态,应该 sleep 几百毫秒再查一次,而不是立刻放弃——因为对方可能 just 一秒后就写入 "done"。











