ZPOPMIN不能直接当“取任务”用,因其仅原子删除不标记状态,若消费者崩溃会导致任务丢失;必须配合delay_processing哈希记录处理状态,并用Lua脚本保障ZADD重试与HDEL清理的原子性。

为什么ZPOPMIN不能直接当“取任务”用
ZPOPMIN本身是原子的,但它只负责“删”,不负责“标”——返回后 consumer panic 或网络中断,任务就彻底消失。线上跑三天必出问题的,基本都卡在这儿。
- 别只调用
ZPopMin然后直接处理 payload,必须立刻写入delay_processinghash 记录 worker ID 和开始时间 - Go 客户端(如
github.com/redis/go-redis/v9)返回的是ZSetZ{Member: "task_id", Score: 1714000000000},Score是毫秒时间戳,得用time.UnixMilli(int64(score))校验是否真到期,防时钟回拨 - 单次调用别只传
count=1,高吞吐场景建议count=10批量取,但前提是 score 精度够(否则同秒内一堆任务全被吐出来)
score 必须用毫秒,且所有地方要统一
用 time.Now().Unix() 存、却用 time.Now().UnixMilli() 查,或反过来,都会导致任务永远不触发或批量误触发。
- 所有
ZADD的score必须是time.Now().UnixMilli(),不是Unix(),更不是UnixNano()(转 float64 会丢精度) -
ZRANGEBYSCORE或ZCOUNT的Max参数也得对应毫秒值,比如strconv.FormatInt(time.Now().UnixMilli(), 10) - Redis 服务器时间必须校准:运行
redis-cli time对比本地date +%s%3N,偏差超 500ms 就得同步 NTP
失败重试不能简单 ZADD 回原 key
原 score 覆盖掉,等于取消了原始延迟计划;重试时没带退避策略,容易雪崩打垮下游。
- 重试新 score 必须基于当前时间计算:比如指数退避,第一次
time.Now().Add(1 * time.Second).UnixMilli(),第二次Add(2 * time.Second) - 重试次数要记进 payload JSON 里(如
{"retry_count": 2, "max_retries": 5}),超过阈值就推到dlq:delay - 别在重试时 patch 原 member 字符串,而是整个 payload 重新序列化再
ZADD,避免反序列化结构不一致
processing 状态清理不能靠 defer + HDEL
panic、context timeout、write 超时都会让 HDEL 没发出去,结果任务完成但 delay_processing 里还留着脏 key,30 秒后被守护协程扫到,又扔回队列重复执行。
立即学习“go语言免费学习笔记(深入)”;
- 消费成功后,必须用 Lua 脚本做“条件清理”:只有
HGET delay_processing task_id返回非空,才执行HDEL - 守护 goroutine 扫描
delay_processing时,先HGETALL拿全量,对每个 entry 检查 timestamp 是否超时(阈值设为业务最长耗时 ×2) - 回滚动作必须原子:Lua 脚本里同时做
ZADD delay_queue new_score task_id和HDEL delay_processing task_id,拆成两步就可能只成功一半
真正难的不是第一次跑通,而是节点宕机、consumer crash 后系统能自己续上——所有状态变更都得有反向可逆路径,且每条路径都得进 Lua。


















