ZRANGEBYSCORE + ZREM 分开调用不原子,会导致重复消费或任务丢失;必须用 Lua 脚本原子执行扫描、删除、标记三步,并配合 ZCOUNT 预检、毫秒级 score 统一和动态 sleep 控制。

直接用 ZRANGEBYSCORE 轮询 + ZREM 删除,线上跑不过三天就会出重复消费或任务丢失——这不是配置问题,是 Redis 命令天然不原子。
为什么 ZRANGEBYSCORE + ZREM 分开调用必翻车
常见错误是先 ZRANGEBYSCORE delay_queue -inf 1716685000 拉出一批到期任务,再 ZREM delay_queue task1 task2 批量删。这两步之间存在竞态窗口:
- 多个 Go worker 同时执行该流程,会各自拉到同一组任务,导致重复消费
- 若消费失败、panic 或网络中断,
ZREM根本没发出去,任务就卡在 zset 里不动,既不重试也不告警 - 哪怕只差几毫秒,分布式环境下也足以让两个 goroutine 同时拿到
task1
Redis 5.0+ 虽有 ZPOPMIN,但它只负责“弹出”,不负责“标记处理中”。单靠它,worker 崩溃后任务就永久消失。
必须用 Lua 封装查 + 删 + 标记三步
真正能落地的方案,是把以下三件事塞进一个 Lua 脚本里,在 Redis 服务端原子执行:
立即学习“go语言免费学习笔记(深入)”;
- 用
ZRANGEBYSCORE扫描-inf到当前毫秒时间戳的区间(建议加 5 秒缓冲:now_ms - 5000,防时钟漂移) - 对每条命中任务,用
ZREM立即从 zset 中移除 - 同时用
HSET delay_processing <payload> <worker_id>记录处理中状态,含时间戳
Go 侧调用示例(go-redis):
script := redis.NewScript(`
local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
for i, task in ipairs(tasks) do
redis.call('ZREM', KEYS[1], task)
redis.call('HSET', KEYS[2], task, ARGV[2])
end
return tasks
`)
result, err := script.Run(ctx, rdb, []string{"delay_queue", "delay_processing"}, strconv.FormatInt(time.Now().UnixMilli(), 10), workerID).Result()注意:ARGV[1] 必须是字符串格式的毫秒时间戳,别传整数;KEYS[2] 是 hash key,别和 zset key 混用。
轮询不能硬 sleep,得看 ZCOUNT 再动
写成 for { rdb.ZRangeByScore(...) time.Sleep(500 * time.Millisecond) } 是典型反模式:
- 空轮询压 Redis CPU,
ZRANGEBYSCORE即使返回空结果也要走完整命令链路 - 连接池被占满,其他业务命令超时
正确做法是每次轮询前先轻量探针:
- 用
ZCOUNT delay_queue -inf <now_ms>快速判断有没有活可干 - 返回 0 就跳过后续扫描,直接
time.Sleep;非 0 再执行 Lua 脚本 - sleep 时间动态调整:连续 3 次
ZCOUNT == 0,就把间隔从 500ms 升到 1s;一旦扫到任务,立刻回落
别省这一步——线上 Redis 实例 CPU 飙高,八成是因为没加 ZCOUNT 预检。
score 必须用毫秒,且所有地方保持一致
用 time.Now().Unix() 存 score,却用 time.Now().UnixMilli() 当 Max 去查,是线上最隐蔽的 bug 来源之一:
- 秒级时间戳下,1 秒内 100 个任务 score 全相同,
ZRANGEBYSCORE返回顺序不可控,可能批量误触发 - Redis 服务端时间和 Go 应用时间偏差超过 1 秒,
redis-cli time和date +%s对不上就得同步 NTP -
ZADD时传float64(time.Since().Seconds())有精度风险,必须用int64(time.Now().UnixMilli())
迁移旧系统时,别只改 producer,consumer 的 Max 参数、Lua 脚本里的比较逻辑、守护协程的超时阈值,全得同步切到毫秒——漏一处,就埋一个毛刺点。


















