Go语言延迟队列需用Redis ZSET+Lua脚本实现,因time.AfterFunc等不满足持久化与分布式要求;关键在原子执行查删推三步、毫秒级score、合理轮询与指数退避重试。

Go 语言没有原生延迟队列,time.AfterFunc 和 time.Sleep 只能用于单机、可丢失、无持久化要求的场景;真要落地订单关单、重试通知等业务,必须用 Redis ZSET + Lua 脚本组合——这是中小型项目最稳、最省运维成本的起点。
为什么直接 ZRANGEBYSCORE + ZREM 会重复消费或丢消息
常见错误是轮询时先查再删:两个 worker 同时执行 ZRANGEBYSCORE delay_zset -inf 1718923456000,都拿到同一批消息;一个成功处理并 ZREM,另一个也去 ZREM(可能部分失败),但消息体早已被取走——结果是重复执行,或某条因 panic 没进业务逻辑就从 ZSET 消失了,彻底丢失。
- Redis 不保证两个命令的原子性,查和删之间存在竞态窗口
-
ZPOPMIN看似能解决,但它只弹出不标记,worker 崩溃后任务无法恢复 - 正确做法是用 Lua 脚本把「查 + 删 + 推入待执行 LIST」三步锁死在服务端执行
- 脚本里必须用
ZREMRANGEBYSCORE而非逐条ZREM,否则高并发下性能断崖下跌
score 必须用毫秒时间戳,且所有地方保持单位一致
用 time.Now().Unix()(秒)存 score,同一秒内多个任务会共享相同 score,ZSET 内部按 member 字典序排序,ZRANGEBYSCORE 返回顺序不可控,批量拉取时还容易一次扫出几百条导致 OOM;而用 time.Now().UnixMilli() 能支撑每秒数万任务不冲突,也兼容后续亚秒级调度(如 300ms 重试)。
- producer 写入:
ZADD delay_zset 1718923456789 "order:123" - 轮询参数 Max 必须传
strconv.FormatInt(time.Now().UnixMilli(), 10),不能硬写字符串"1718923456" - 别用
time.Now().UnixNano()——Redis score 是 double 类型,纳秒整数转 float64 会丢失精度 - 如果旧系统已用秒级,迁移时必须全量改写 ZADD 和扫描逻辑,不能只改一边
轮询间隔和原子脚本怎么配才不压垮 Redis
每 100ms 扫一次 ZRANGEBYSCORE 看似响应快,实则浪费连接、抬高 CPU、打满 Redis QPS;更糟的是空轮询时没做限流,Redis 线程一直忙等。
立即学习“go语言免费学习笔记(深入)”;
- 建议基础轮询间隔设为
500ms ~ 1s,首次启动时用ZCARD预热,有积压立刻拉取 - 轮询前加
context.WithTimeout(ctx, 3*time.Second),防止单次命令卡死阻塞 goroutine - 不要在 for 循环里反复
defer conn.Close()——连接必须复用,用redis.NewClient()初始化一次 - Lua 脚本里用
LIMIT 0 100控制单次最多处理 100 条,避免脚本执行过长阻塞 Redis 主线程 - 如果连续几次
ZRANGEBYSCORE返回空,动态把休眠时间升到 2s,有活再回落
消费失败后怎么重试才不雪崩
失败后直接 ZADD delay_zset <original_score> "msg"</original_score> 回插,等于把压力原样再喂给下游;若失败原因是 DB 连接池满或 HTTP 超时,重插只会让问题恶化。
- 必须区分失败类型:参数错误类失败应跳过重试,下游超时类才允许重试
- 重试时间要用指数退避,比如第一次 1s 后,第二次 2s,第三次 4s,避免瞬时洪峰
- 消息体必须带唯一
id字段,消费端做幂等判断——Lua 没兜住时这是最后一道防线 - 别依赖
HDEL delay_processing清理状态,网络 write 超时或 panic 会让这行根本发不出去;得另起守护 goroutine 定期扫delay_processinghash 里超时未清理的 key,回滚到 ZSET
真正难的不是写对那几行 Lua,而是所有环节的时间单位、错误分类、状态清理都得对齐;哪怕只有一个 worker 用了秒级时间戳,或某处漏了幂等字段,线上就会开始偶发重复扣款或订单不关闭。


















