Redis ZSET 实现延迟队列的分数设计
用 Redis ZSET 做延迟队列时,最稳妥的默认设计是:score 保存任务应执行的绝对 Unix 毫秒时间戳,member 保存稳定且唯一的任务 ID。不要直接把“延迟 30 秒”存成 score,也不要一开始就把优先级、重试次数和时间拼进一个复合数字。到期判断只需查询 score ,不同生产者创建的任务也能放在同一条时间线上比较。
一个可恢复的 ZSET 延迟队列至少要同时解决四件事:毫秒时间戳的精度、同分任务的顺序、多个消费者的原子领取,以及消费者崩溃后的租约回收。ZSET 负责“什么时候可执行”,业务幂等负责“重复执行也不出错”。
score = due_at_ms,即绝对毫秒截止时间;当前纪元毫秒值处于 Redis 可精确表示的整数范围内。member = job_id,载荷单独保存;同一个 member 再次ZADD会更新分数,可用NX防止意外覆盖。- 领取时用短小的 Lua 脚本或 Redis Function,把到期任务原子地从
schedule移到processing。 - 系统语义按“至少一次”设计,消费端必须幂等,并监控到期积压与处理租约超时。
先明确负载和语义约束
假设业务里同时有订单自动取消、通知补发和失败重试。最初的实现往往只有一个 ZSET:生产者写入任务,消费者轮询到期成员并删除。单消费者时看起来够用,一旦扩成多个消费者,“先查询再删除”就会让同一任务被多次读取;如果消费者先删除再处理,进程崩溃又会让任务永久丢失。
因此,选 score 之前先固定约束。时间精度是否需要毫秒?允许同一毫秒有多少任务?消费者是单实例还是多实例?任务允许至少一次,还是业务要求更强的投递保证?Redis 是否运行在 Cluster 模式?单个队列会不会成为热点?这些答案决定了键模型和领取方式,而不是只决定一条 ZADD 命令。
| 约束 | 推荐判断 | 对设计的影响 |
|---|---|---|
| 时间精度 | 大多数业务用毫秒 | score 统一为绝对 due_at_ms |
| 同刻任务 | 允许同 score | 不依赖插入顺序,必要时在 member 中增加稳定序号 |
| 并发消费者 | 按多实例设计 | 查询与移动必须原子化 |
| 失败恢复 | 采用处理租约 | 增加 processing ZSET 和超时回收 |
| 消费语义 | 至少一次 | 业务处理必须幂等 |
秒、毫秒和复合分数怎么选
Redis 有序集合的 score 是 64 位双精度浮点数。官方文档说明,-2^53 到 +2^53 之间的整数可以精确表示。当前 Unix 毫秒时间戳约为 13 位整数,作为 score 有充足精度;纳秒时间戳则会很快超过精确整数范围,不适合直接作为调度分数。
| score 方案 | 优点 | 主要问题 | 建议 |
|---|---|---|---|
| 绝对秒时间戳 | 简单、数值小 | 同一秒碰撞多,延迟精度不足 | 只适合分钟级任务 |
| 绝对毫秒时间戳 | 精度够、查询自然、整数可精确表示 | 同一毫秒仍可能有多个任务 | 默认选择 |
| 相对延迟值 | 生产端看起来直观 | 不同写入时刻无法直接比较,必须先换算 | 不要直接入队 |
| 时间戳乘倍率再拼优先级 | 试图在一个值里表达两种顺序 | 降低数值余量,规则难维护,容易产生精度和迁移问题 | 优先级单独建模 |
生产者收到“30 秒后执行”时,应在写入前换算成 now_ms + 30000。如果多个生产者机器可能有时钟偏差,可以统一使用可信时间源,或通过 Redis TIME 获取服务端时间后换算;关键是所有任务最终落成同一种绝对时间单位。
同 score 的成员并不按写入先后排列。Redis 会按 member 的字典序处理同分成员。如果业务只要求“到期后尽快处理”,通常无需额外排序;如果同一毫秒必须稳定 FIFO,可以把固定宽度的单调序号放进 member 前缀,但要同时处理任务重排和去重复杂度,不能把“字典序”误当成天然插入顺序。
把 score、member 和载荷分开设计
推荐把 ZSET 控制在最小职责:score 是 due_at_ms,member 是 job_id。任务类型、订单号、重试次数和业务参数放在 Hash、String 或持久数据库中。这样修改任务内容不会改变调度身份,也不会让大段 JSON 参与 ZSET 的成员比较和内存复制。
# 任务 ID 作为唯一 member,绝对毫秒时间戳作为 score。
redis-cli ZADD 'delay:{orders}:schedule' NX 1790629000000 'job-9f3c'
# 载荷与调度索引分开保存,消费者按 job_id 读取。
redis-cli HSET 'delay:{orders}:payload' 'job-9f3c' '{"order_id":"A1001","action":"close"}'
# 需要主动改期时只更新已存在任务,避免创建不存在的 ID。
redis-cli ZADD 'delay:{orders}:schedule' XX 1790629300000 'job-9f3c'
NX 适合第一次入队:如果相同任务 ID 已存在,不会悄悄覆盖原来的到期时间。XX 适合明确的改期操作。到底允许覆盖还是拒绝重复,要由业务接口定义,不能让普通重试请求无条件改变 schedule。

这里的键名使用了相同的 {orders} 哈希标签。Redis Cluster 对一个脚本涉及的多个键要求它们位于同一哈希槽,因此 schedule、processing 和需要原子访问的元数据键必须提前按同一队列分组。哈希标签会把这些相关键放到同槽,但也意味着这一组键共享同一分片;流量很大时应按租户、业务域或确定性分片拆成多组,而不是让一个全局 ZSET 承担全部任务。
原子领取到期任务并设置处理租约
只执行 ZRANGE ... BYSCORE 再由客户端逐条 ZREM 存在竞争窗口。两个消费者可能同时读到相同 ID。更稳的做法是把“查到期任务、从 schedule 删除、写入 processing 租约”放进同一个短小的 Lua 脚本。Redis 保证脚本原子执行,但脚本运行期间会阻塞其他服务端活动,所以每次只领取受控的小批量。
-- KEYS[1] 是 schedule ZSET,KEYS[2] 是 processing ZSET。
-- ARGV[1] 是 now_ms,ARGV[2] 是 lease_deadline_ms,ARGV[3] 是批量上限。
local due = redis.call(
'ZRANGE', KEYS[1], '-inf', ARGV[1],
'BYSCORE', 'LIMIT', 0, ARGV[3]
)
local claimed = {}
for _, job_id in ipairs(due) do
-- 原子删除成功后才写入处理租约,避免两个消费者重复领取。
if redis.call('ZREM', KEYS[1], job_id) == 1 then
redis.call('ZADD', KEYS[2], ARGV[2], job_id)
table.insert(claimed, job_id)
end
end
-- 返回的任务由调用方读取载荷并执行。
return claimed
其中 processing ZSET 的 score 不再是业务执行时间,而是处理租约的截止时间 lease_deadline_ms。消费者完成业务动作后发送 ACK,从 processing 删除任务并清理载荷;若消费者崩溃,回收器查询 processing score ,把超时任务重新放回 schedule。重试的 schedule score 可以设为当前时间加退避时间,而原始到期时间、尝试次数和最后错误保存在任务元数据中。

Redis 7 及以后也可以把这段逻辑实现为 Redis Function,便于把服务端逻辑作为已部署函数管理;较早版本常用 SCRIPT LOAD 与 EVALSHA。无论选哪一种,都要让脚本参数化且保持短小,不要为每个任务动态生成一份不同脚本。
风险点:至少一次、优先级和热点
原子领取解决的是“同一时刻只有一个消费者成功拿走任务”,但不能自动提供严格一次处理。消费者可能已经完成外部业务操作,却在 ACK 前崩溃;租约超时后,任务会再次被领取。因此支付、关单、发券或通知等操作要带业务幂等键,数据库更新也应使用唯一约束或状态条件保护。
优先级不要粗暴拼进毫秒 score。高优先级如果必须抢占同一到期时间,可以使用独立队列、不同 ZSET,或在领取策略中分配配额。把 due_at_ms * 1000 + priority 当通用公式,会把时间和业务等级耦合在一起,也让精度、改期和迁移更难解释。
另一个风险是单键热点。ZSET 的范围查询很方便,但一个超大 schedule 会把写入和领取集中到同一分片。可按租户或稳定分片键拆队列,每个分片独立轮询;不要按随机键拆分,否则同一任务的 schedule、processing 和元数据难以保持同槽原子操作。分片后还要限制每次领取数量,避免某个积压分片长期占满消费者。
用指标和清单验收
延迟队列上线后,最有价值的不是 ZSET 总长度,而是“已经到期却尚未领取”的数量和最早逾期时间。下面的命令可以作为监控采集入口,实际系统应由客户端或监控任务执行,并避免高频拉取大量成员。
# 统计当前已经到期但仍留在 schedule 的任务数量。
redis-cli ZCOUNT 'delay:{orders}:schedule' -inf "$NOW_MS"
# 只读取最早任务及其 score,用于计算调度延迟。
redis-cli ZRANGE 'delay:{orders}:schedule' 0 0 WITHSCORES
# 统计处理租约已经过期、需要回收的任务数量。
redis-cli ZCOUNT 'delay:{orders}:processing' -inf "$NOW_MS"
# 观察调度集合与处理中集合的总体规模。
redis-cli ZCARD 'delay:{orders}:schedule'
redis-cli ZCARD 'delay:{orders}:processing'
| 验收项 | 检查内容 | 异常信号 |
|---|---|---|
| 分数单位 | 所有生产者统一写绝对毫秒时间戳 | 出现秒、毫秒混用或相对延迟值 |
| 任务身份 | member 是稳定唯一的 job_id | 载荷变化导致重复成员或无法改期 |
| 原子领取 | 到期查询、删除和写租约在一个脚本/函数中 | 多消费者读到同一任务 |
| 失败恢复 | processing 有租约与超时回收 | 崩溃后任务永久消失 |
| 业务幂等 | 重复执行不会产生重复副作用 | ACK 丢失后重复扣款或重复发券 |
| 队列指标 | 监控到期积压、最早逾期和租约超时 | 只看 ZCARD,无法识别延迟 |
常见问题
为什么不直接用 ZPOPMIN?
ZPOPMIN 会弹出当前最低分成员,但它不会替你判断最低分是否已经到期。若直接弹出,可能把未来任务提前移除。延迟队列需要“限定 score 不大于 now”并与处理租约原子组合。
同一毫秒的任务能保证先进先出吗?
不能依赖写入先后。同 score 成员按字典序排列。业务若要求严格稳定顺序,应显式设计固定宽度序号,或选择更符合消息顺序语义的队列结构。
member 可以直接放完整 JSON 吗?
可以,但通常不推荐。JSON 中任何字段变化都会改变 member 身份,也会增加有序集合的内存和网络开销。稳定 job_id 更适合去重、改期、ACK 和载荷更新。
ZSET 延迟队列能保证消息绝不丢失吗?
不能仅靠 ZSET 做出这个承诺。还要结合 Redis 持久化与高可用配置、原子领取、处理租约、超时回收、业务幂等和监控告警。对极高可靠性或复杂消费组语义,应该评估专用消息系统是否更合适。
Go sql.Tx 提交成功后回滚错误的处理约定
- 上一篇
- Go sql.Tx 提交成功后回滚错误的处理约定
- 下一篇
- Lanerc动漫网页版GPU占用高怎么办?硬件加速与浏览器排查说明
-
- 数据库 · Redis | 6小时前 | 事务 · redis集群 · redis 原子操作 Redis Cluster Hash Tag 多 key
- Redis Cluster Hash Tag 组织多 key 原子操作
- 357浏览 收藏
-
- 数据库 · Redis | 8小时前 | 消息队列 · redis 消费者组 XAUTOCLAIM Redis Streams pending消息
- Redis 消费者组 pending 消息的认领与恢复流程
- 101浏览 收藏
-
- 数据库 · Redis | 10小时前 |
- Redis Streams 按业务时间裁剪历史消息的参数方案
- 145浏览 收藏
-
- 数据库 · Redis | 12小时前 | Redis · redis 地理位置 GEOSEARCHSTORE
- Redis GEOSEARCHSTORE 怎么保存附近对象结果
- 351浏览 收藏
-
- 数据库 · Redis | 22小时前 |
- Redis BLMOVE 怎么实现可恢复的阻塞队列
- 460浏览 收藏
-
- 数据库 · Redis | 1天前 |
- Redis BITFIELD 溢出策略 WRAP SAT FAIL 怎么选
- 471浏览 收藏
-
- 数据库 · Redis | 1天前 |
- Redis SET 的 GET 选项怎么原子取得旧值
- 413浏览 收藏
-
- 数据库 · Redis | 1天前 | Redis ·
- Redis 分片 Pub/Sub 与普通 Pub/Sub 有什么区别
- 327浏览 收藏
-
- 数据库 · Redis | 1天前 |
- Redis LATENCY DOCTOR 怎么判断延迟来源
- 169浏览 收藏
-
- 数据库 · Redis | 1天前 |
- Redis ACL 怎么同时限制命令和键前缀
- 244浏览 收藏
-
- 数据库 · Redis | 1天前 |
- Redis Functions 怎么替代需要重复加载的 Lua 脚本
- 177浏览 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 485次学习
-
- PubMedQA
- 深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
- 258次使用
-
- H2O EvalGPT
- H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
- 302次使用
-
- LMArena
- LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
- 282次使用
-
- HELM
- 深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
- 259次使用
-
- MMBench
- MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
- 68次使用
-
- Redis Stream XTRIM 如何避免消费组积压无限增长
- 2026-09-12 501浏览
-
- Redis AOF rewrite 期间如何判断磁盘与内存压力
- 2026-09-12 501浏览
-
- Redis RDB 和 AOF 怎么按可接受数据丢失量选择
- 2026-09-08 501浏览
-
- Redis XAUTOCLAIM 之后为什么仍有 pending:JUSTID、PEL 与消息删除边界
- 2026-08-29 501浏览
-
- Redis SET 的 GET 与 KEEPTTL 怎么一起验收:旧值返回、续期与回滚边界
- 2026-08-20 501浏览

