ZSet 存时间戳因支持按时间有序触发与范围查询,而列表仅先进先出且需轮询;score 必为时间戳(推荐毫秒),value 存任务内容;查用 ZRANGEBYSCORE -inf [now] 后立即 ZREM 或用 ZPOPMIN 原子操作。

为什么用 ZSet 存时间戳而不是 LPUSH + BRPOPLPUSH
因为延时任务的核心诉求是「按时间有序触发」,不是先进先出。用列表只能靠客户端轮询或阻塞等待,没法跳过未到时间的任务。ZSet 的 score 天然支持范围查询和排序,ZRANGEBYSCORE 一句就能捞出所有到期任务,省去遍历和判断逻辑。
常见错误现象:
有人把时间戳存在 value 里,score 随便填个 0,结果没法查——score 才是排序和查询依据,value 只存任务内容(比如 JSON 字符串)。
-
score必须是毫秒/秒级时间戳(推荐毫秒,避免精度丢失) - 插入用
ZADD queue_name timestamp task_json,不是LPUSH - 不要用
EXPIRE给 key 设过期——ZSet 本身不支持 per-member 过期,得靠轮询清理
ZRANGEBYSCORE 查任务时为什么总漏掉刚到点的那条
Redis 的 ZRANGEBYSCORE 默认是闭区间,但时间戳是瞬时值,如果用 ZRANGEBYSCORE queue_name -inf now,理论上能取到所有 ≤ now 的任务。问题常出在:客户端取完没删,或者取的时候系统时间与 Redis 时间不同步。
实操建议:
- 查的时候用
ZRANGEBYSCORE queue_name -inf [now]([表示包含边界),别漏方括号 - 查完立刻用
ZREM queue_name task1 task2 ...删除已取出的任务,否则下次还会捞出来 - 更稳妥的做法是用
ZPOPMIN queue_name count(Redis 5.0+),它原子性地取并删,避免竞态 - 如果用老版本 Redis,必须用 Lua 脚本封装「查 + 删」,否则多实例并发会重复消费
任务执行失败后怎么重试,score 怎么更新
重试不是简单把原任务再 ZADD 一次,而是要更新它的 score 为下一次执行时间戳。直接 ZADD 同一个 member 会自动覆盖旧 score,这是 Redis 的设计特性,可以利用。
关键点:
- 任务
member最好带唯一 ID(比如 UUID),别用纯 JSON 做member,否则重试时无法精准定位 - 重试延迟要递增,比如第一次 1s 后,第二次 3s,第三次 10s,避免雪崩;计算新
score时用current_timestamp + delay_ms - 别用
ZINCRBY更新score——它只支持加数字,而你通常要设绝对时间戳,直接ZADD更清晰 - 如果任务需要最大重试次数,可在
value(JSON)里记录尝试次数,每次取出来解析判断
轮询频率设多少才不伤 Redis,又不耽误任务
没有固定值,取决于任务量级和实时性要求。轮询太密(比如 10ms 一次)会让 Redis CPU 持续跑高;太疏(比如 5s 一次)会导致任务平均延迟 2.5s。
建议从实际场景出发:
- 普通后台任务(如发邮件、清缓存):100–500ms 一轮,用
ZCOUNT queue_name -inf now先轻量判断有没有活干,有再查详情 - 对延迟敏感的业务(如订单超时关单):必须用
ZPOPMIN或 Lua 原子操作,配合 10–50ms 轮询,同时监控latency和used_cpu_sys - 千万别在循环里无休止
ZRANGEBYSCORE——加SLEEP或用WAIT配合发布订阅做唤醒(但唤醒逻辑本身又引入复杂度) - 生产环境务必开
slowlog-log-slower-than 1000,盯着有没有 ZSet 查询变慢,大 ZSet(百万级成员)查ZRANGEBYSCORE可能卡几百毫秒
最麻烦的其实是时钟漂移:Redis 实例在容器里、或跨机房部署时,系统时间差几秒就会让任务批量早触发或晚触发。这事容易被忽略,但线上出过不止一次问题。










