结论:ZPOPMIN+Lua脚本+delay_processing状态跟踪比ZRANGEBYSCORE轮询更可靠省资源,但需设TTL、处理消费中断、校验score精度,否则三天必出问题。

直接说结论:用 ZPOPMIN + Lua 原子脚本 + delay_processing hash 状态跟踪,比轮询 ZRANGEBYSCORE 更可靠、更省资源;但不设 TTL、不处理消费中断、不校验 score 精度,上线三天必出问题。
为什么不能只靠 ZRANGEBYSCORE 轮询
很多初版实现用 goroutine 每秒执行一次 ZRANGEBYSCORE delay_queue -inf <now_ms></now_ms> 扫描到期任务,看似简单,实际埋了三个坑:
- 高并发下大量空轮询:哪怕队列为空,每秒都打 Redis,QPS 白白升高
- 竞态丢失:多个消费者同时扫到同一批任务,
ZREM非原子,导致重复投递或漏处理 - 无法感知“已取出但未消费”:任务从 zset 移出后,若 consumer panic 或网络中断,就彻底消失,无从追溯
改用 ZPOPMIN 是第一步——它天然带原子性移出 + 返回,配合 Lua 就能闭环“取-标记-入处理中”三步。
ZPOPMIN 必须搭配 TTL 和 score 精度校验
ZPOPMIN 只解决“取”的原子性,但不解决“长期堆积”和“时间戳碰撞”:
初始化并管理阿里云SDK Go 客户端,涵盖 sync.Once 单例、goroutine 安全、endpoint 与 region 配置、VPC 端点及调试。
立即学习“go语言免费学习笔记(深入)”;
- zset key 必须显式设置
EXPIRE delay_queue 86400,否则失败任务、测试脏数据永久滞留,zset 内存持续上涨 - 如果用秒级时间戳(
time.Now().Unix())作 score,且单秒内任务量 >10 条,ZPOPMIN可能一次返回几十条——这不是吞吐高,是 score 分辨率崩了,应切到毫秒:time.Now().UnixMilli() - 生产环境建议加预检:
ZCOUNT delay_queue -inf <now_ms></now_ms>返回 0 才跳过ZPOPMIN,避免无效调用
消费中断后状态怎么恢复:别只靠 HDEL
常见错误是 consumer 拿到任务后,先 HSET delay_processing <payload><timestamp></timestamp></payload>,处理完再 HDEL delay_processing <payload></payload>。但 panic、OOM、节点宕机都会让 HDEL 永远不执行,delay_processing hash 里堆满“幽灵任务”。
- 守护 goroutine 必须定期扫描
delay_processing,用HGETALL拿出所有 entry,对比timestamp是否超时(建议阈值设为业务最长耗时的 2 倍,如 120 秒) - 发现超时 entry,不能直接重投——得先用
HEXISTS delay_processing <payload></payload>二次确认,再ZADD delay_queue <new_score><payload></payload></new_score>回滚,最后HDEL - 关键点:回滚动作必须用 Lua 封装,否则
ZADD成功但HDEL失败,又进死循环
Lua 脚本必须覆盖“取-标-转”全链路
把“从 zset 取任务 → 写入 processing hash → 推送至待消费 list”这三步塞进一个 Lua 脚本,是唯一能避开分布式竞态的方式:
- 脚本内用
redis.call("ZPOPMIN", KEYS[1])取出任务,立即用redis.call("HSET", KEYS[2], payload, os.time())标记,再redis.call("LPUSH", KEYS[3], payload) - KEYS 数组要传三个 key:
delay_queue、delay_processing、ready_list,避免硬编码 - 注意:Lua 里
os.time()是秒级,若需毫秒精度,得从 Go 侧传入ARGV[1](当前毫秒时间戳),再在脚本里用ARGV[1]
真正难的不是写脚本,而是所有 consumer 必须共用同一份脚本 SHA1,且首次加载要 SCRIPT LOAD;线上更新脚本前,得确保旧 consumer 已全部下线,否则混合执行会错乱。

















