Redis ZSet 延迟任务如何处理:ZRANGEBYSCORE 取出与 ZREM 抢占的竞态
来源:17golang原创
时间:2026-08-29 16:27:59 192浏览 收藏
订单预约时间到了以后,两个 worker 都从 Redis 的延迟集合里拿到同一个任务,这类重复执行通常不是 ZSet 排序失效,而是“先读后删”把抢占拆成了两个时间点。把到期筛选和删除放进同一个 Lua 脚本,才能让一个 job_id 只被一个 worker 取走;脚本返回空结果时,另一个 worker 应该等待下一轮扫描。
Redis ZSet 只负责按 score 找到到期任务,真正的并发安全点是让 ZRANGEBYSCORE 与 ZREM 在同一次原子操作里完成。
- ZRANGEBYSCORE 负责找出 score 不大于当前时间的候选任务。
- 单独先读后删会让 worker-a 与 worker-b 同时看到同一个 job_id。
- Lua 脚本把 ZRANGEBYSCORE、ZREM 和 job_id 返回串成一次抢占。
- 任务业务执行失败仍要靠重试状态、补偿队列或幂等键处理。
先把延迟任务拆成“到期”和“已抢占”
可以把 delay:jobs 设计成一个 Redis ZSet:member 是 job_id,score 是 Unix 时间戳。写入任务时用 ZADD delay:jobs 1787991500 order-731,worker 扫描时只关心 score 小于等于当前时间的成员。
这里有两个容易混在一起的动作。ZRANGEBYSCORE 只是读取候选,ZREM 才是把候选从待处理集合中移走。移走成功意味着当前 worker 获得了处理资格,不等于业务动作已经成功。
ZRANGEBYSCORE delay:jobs -inf 1787991600 LIMIT 0 1
ZREM delay:jobs order-731
Redis 官方文档说明,ZRANGEBYSCORE 按 score 范围返回成员,ZREM 则删除指定 member。两条命令分开发送时,中间天然存在一段空隙。
两个 worker 为什么会同时拿到同一个 job_id
线上最难发现的情况是:worker-a 读完候选后还没来得及 ZREM,worker-b 已经完成同样的读取。两边拿到的都是 job_id,随后各自执行订单通知、库存刷新或回调,重复副作用就发生了。
下面这段伪代码故意保留竞态,适合在代码评审中定位问题:
items := redis.ZRangeByScore("delay:jobs", "-inf", now, 0, 1)
if len(items) == 0 {
return nil
}
jobID := items[0]
redis.ZRem("delay:jobs", jobID)
return jobID
即使 ZREM 最终只有一个调用返回 1,也已经晚了:两个 worker 都可能在删除前拿到了同一个 job_id。删除结果只能证明谁删掉了集合成员,不能追回已经开始的业务动作。

用 Lua 把筛选和删除收成一个抢占点
抢占脚本只做三件事:从 delay:jobs 找一个到期 member,把这个 member 从集合中删除,再把 job_id 返回给调用方。Redis 执行 Lua 脚本期间不会插入另一个客户端的命令,因此两个 worker 不会在这三个动作之间交叉。
local items = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, 1)
if #items == 0 then
return false
end
local job_id = items[1]
local removed = redis.call('ZREM', KEYS[1], job_id)
if removed == 1 then
return job_id
end
return false
调用方把当前时间作为 ARGV[1],把 delay:jobs 作为唯一 key 传入。第一个 worker 得到 job_id 后开始业务处理;第二个 worker 再运行脚本时,通常只会得到空结果。

别把“从集合移走”误当成业务成功
原子抢占解决的是重复领取,不是任务可靠完成。worker 在拿到 job_id 后宕机,任务已经从 delay:jobs 消失;如果没有状态记录,它就不会自动回来。
更稳妥的做法是把状态放在独立的哈希或数据库中:脚本成功返回后立即写入处理中记录,完成后改为成功;超时扫描处理中记录,再把仍可重试的 job_id 放回 ZSet。业务接口还应使用幂等键,避免“重试成功但第一次请求其实已落库”的反转情况。
| 阶段 | Redis 证据 | 业务含义 |
|---|---|---|
| 待处理 | delay:jobs 中存在 job_id | 等待到期 |
| 已抢占 | ZREM 返回 1 | 一个 worker 获得处理资格 |
| 已完成 | 状态记录为 success | 业务动作已确认 |
| 待重试 | job_id 重新进入 ZSet | 补偿流程接管 |
高并发下的边界:批量、时钟和脚本长度
每次只抢一个任务最容易解释,但吞吐可能不够。批量抢占时可以让脚本取前 N 个到期 member,再逐个 ZREM,并返回成功删除的列表;N 不宜无限增大,否则一次脚本占用 Redis 主线程的时间会变长。
score 与 worker 当前时间必须使用同一时间基准。若生产机时钟漂移,某些任务会提前或延后进入候选范围。扫描间隔也不是精确触发器,延迟任务系统应该接受少量调度误差,真正的截止时间仍由业务处理逻辑复核。
上线前用四个问题检查抢占设计
- 是否存在把 ZRANGEBYSCORE 和 ZREM 分成两次客户端请求的代码?
- Lua 返回空结果时,worker 是否会在下一轮继续扫描而不是把它记成失败?
- 任务从 ZSet 移走后,是否有处理中状态和超时补偿?
- 业务副作用是否以 job_id 做幂等,能够承受重复回调?
相关问题
ZRANGEBYSCORE 能直接保证任务只执行一次吗?
不能。它只是按 score 读取成员;只读命令不负责抢占,必须配合原子删除或其他领取协议。
ZREM 返回 0 说明任务失败了吗?
不一定。它更可能表示 member 已被另一个 worker 删除,调用方应把它视为未获得处理资格,并交给下一轮扫描或状态查询。
为什么不只依赖 Redis 的过期键?
过期键适合清理和通知触发,不适合表达带 score 排序的任务领取、处理中状态和补偿关系;延迟任务通常需要单独的状态模型。
把判断留在抢占边界上
这套设计的关键不在于把所有逻辑塞进 Lua,而是只把“找一个到期任务并移走它”放进原子边界。业务处理、幂等、失败补偿仍然留在 worker 和状态存储中,各自的职责清楚,问题也更容易复查。
-
459 收藏
-
264 收藏
-
285 收藏
-
140 收藏
-
470 收藏
-
135 收藏
-
189 收藏
-
351 收藏
-
数据库 · Redis | 2小时前 | Redis · hash · 数据生命周期 · 缓存过期 · Redis 7.4 · redis Hash HEXPIRE HPTTL 字段过期 Redis 7.4450 收藏
-
409 收藏
-
122 收藏
-
数据库 · Redis | 6小时前 | Redis · 消息队列 · Stream · 消费组 · 重试 · XAUTOCLAIM · redis streams XPENDING XAUTOCLAIM PEL pending JUSTID501 收藏
-
474 收藏
-
500 收藏
-
327 收藏
-
数据库 · Redis | 8小时前 | Redis · 集群 · 命令解析 · 排障 · redis COMMAND GETKEYSANDFLAGS COMMAND GETKEYS 集群路由 键位分析353 收藏
-
112 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习