ZSet+定时任务是最轻量可控的延迟队列方案,需解决重复消费、漏扫及时钟漂移问题;禁用键过期事件因其不可靠;score须用NTP校准时间戳,value用带id的JSON;扫描用Lua原子取删,间隔1–3秒;消费成功后须显式ZREM确保幂等。

直接用 ZSet + 定时任务是最轻量、最可控的方案,适合中小项目;但必须处理好重复消费、漏扫、时钟漂移这三类问题,否则上线后订单取消失败或重复取消就是常态。
为什么选 ZSet 而不是键过期事件(__keyevent@*__:expired)
键空间通知看似“精准”,实际在生产环境极难靠得住:Redis 默认不开启 notify-keyspace-events,开了之后还依赖 Pub/Sub 的消息可靠性——而 Pub/Sub 是无持久化的,消费者宕机期间的过期事件直接丢失;另外,一个 key 过期只触发一次事件,无法支持“重试延迟”这类动态调整场景。相比之下,ZSet 把时间戳明文存为 score,扫描逻辑完全由应用控制,可重入、可分片、可加幂等键,更适合业务容错。
ZADD 入队时 score 必须是毫秒级时间戳,且不能用 System.currentTimeMillis() 直接拼
常见错误是写成 zadd queue-key (System.currentTimeMillis() + delaySeconds * 1000) message,表面看没问题,但隐患极大:
- 多个服务实例本地时钟不同步(尤其容器/云环境),导致有的实例把任务塞进未来,有的塞进过去
- 没做
message去重,相同订单 ID 多次下单会覆盖前一条(ZSet成员唯一,分数相同时后写覆盖前写) - 没加业务前缀或序列化标识,纯字符串 message 在消费端反序列化失败率高
正确做法:统一用 NTP 校准的服务时间(如调用 http://timeapi.org/utc/now 降频缓存),score 固定为 long 类型毫秒时间戳;value 用 JSON 序列化并带上 id 字段,例如 {"id":"order_12345","type":"cancel","payload":{...}}。
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
定时扫描用 zrangebyscore queue-key 0 (currentTimestamp) 但必须配原子删除
只查不删 = 每次都扫出老任务,重复消费;先删后处理 = 任务失败就永久丢失。安全做法是用 Lua 脚本一次性完成「取+删」:
local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1])
if #tasks > 0 then
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])
end
return tasks然后在 Java 侧对返回的 tasks 列表逐条处理;若某条失败,可记录日志并人工介入,不会影响其他任务。扫描间隔建议设为 1–3 秒,太短加重 Redis 压力,太长导致延迟毛刺(比如设 10 秒,任务可能晚执行近 10 秒)。
消费者处理完必须从 ZSet 彻底移除,不能只靠扫描逻辑
扫描脚本只是“搬任务”,不是“执行任务”。常见疏漏是:任务处理成功后没做任何事,下次扫描又捞出来——因为 ZREMRANGEBYSCORE 只清了已到期部分,但若任务被人工重发或状态回滚,它可能又被塞回去。务必在业务逻辑末尾补一道保险:
- 消费成功后,显式调用
ZREM queue-key "task-json-string"(注意 value 完全匹配) - 或更稳妥:用任务 ID 作为
ZSet的 member,value 存 ID,score 存时间戳,这样ZREM只需传 ID 字符串,不依赖 JSON 序列化一致性 - Redis 7.0+ 可考虑
ZMSCORE先查是否存在,避免无谓的ZREM网络开销
真正难的不是怎么塞进去、怎么扫出来,而是怎么让每条消息“只被成功处理一次”——这要求 score 设计、扫描粒度、删除时机、失败重试全部对齐,缺一不可。

















