ZSet不支持成员自动过期,需用score存时间戳并定时扫描;zrangebyscore必须用"-inf"和动态计算的时间戳,避免硬编码;推荐Lua脚本原子性执行“取+删”;秒/毫秒精度依业务选择;无真正自动过期,依赖外部轮询或事件驱动。

ZSet 本身不支持单个成员过期,但用 score 当时间戳 + 定时扫描,是目前最常用、最可控的延迟任务落地方式。
zrangebyscore 拉取到期任务必须用 "-inf" 范围
不能写成 zrangebyscore key 0 now 这种硬编码数值——因为 score 是 Unix 时间戳(秒级或毫秒级),而 now 不是 Redis 内置变量。实际要用客户端计算当前时间戳传入,或在 Lua 脚本里调用 redis.call("TIME") 获取。
- 正确示例(Python):
r.zrangebyscore("delay_queue", "-inf", str(int(time.time()))) - 错误写法:
zrangebyscore delay_queue 0 1720862160—— 每次都要动态算,硬编码会漏任务 - 注意:如果 score 存的是毫秒时间戳(如 Java
System.currentTimeMillis()),那比较时也得用毫秒,别混用秒和毫秒
zremrangebyscore 清理已处理任务要配合原子性保障
拉出任务 → 执行业务 → 删除任务,这三步不是原子的。若执行完业务但删除失败,会导致重复触发。所以推荐用 zremrangebyscore 在确认执行前就删,或者用 Lua 脚本把“取+删”打包成一步。
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
- 安全做法(Lua):
local tasks = redis.call("ZRANGEBYSCORE", KEYS[1], "-inf", ARGV[1])<br>if #tasks > 0 then<br> redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", ARGV[1])<br>end<br>return tasks - 不推荐裸用
zrangebyscore+zrem两步:中间 crash 或网络中断会导致任务残留或重复 - 如果任务执行耗时长,建议先
zrem再执行,避免其他 worker 同时捞到同一任务
score 精度选秒还是毫秒取决于业务容忍度
秒级精度适合分钟级延迟任务(如短信重发、订单超时关单);毫秒级适合亚秒级调度(如实时竞价、心跳续期),但会增加 Redis 内存占用和排序开销。
- Java 示例存毫秒:
template.opsForZSet().add("queue", taskId, System.currentTimeMillis() + 5000) - Python 存秒更省:
r.zadd("queue", {task_id: int(time.time()) + 30}) - 注意:Redis 的
TIME命令返回的是秒级时间戳(数组第一项),想对齐毫秒需乘 1000,否则 Lua 里比大小会错位
没有真正的“自动过期”,只有主动轮询或事件驱动模拟
Redis 不会像 key 过期那样自动触发 ZSet 成员清理。所有“过期”逻辑都依赖外部控制流:要么起一个常驻线程/进程定时扫,要么用消息队列+监听器做事件分发,要么靠客户端在读取时懒检查。
- 轮询间隔别太密:100ms 一次对 QPS 高的实例压力大,1~5s 更常见
- 空集合时一定要 sleep,否则 CPU 空转;sleep 时间可随负载动态调整
- 生产环境别用
time.sleep()简单阻塞,应改用异步等待(如 asyncio.sleep 或 ScheduledExecutorService)
真正难的不是怎么写脚本,而是怎么让“取-判-删-执”这四个动作在分布式环境下不丢、不重、不卡——score 设计、Lua 封装、失败重试、幂等标记,缺一不可。

















