登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  数据库 >  Redis

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。删除结果只能证明谁删掉了集合成员,不能追回已经开始的业务动作。

Redis ZSet 延迟任务中 worker-a 与 worker-b 同时读取 ZRANGEBYSCORE 后竞争 ZREM 的并发路径

用 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 再运行脚本时,通常只会得到空结果。

Redis Lua 将 ZRANGEBYSCORE、ZREM 与 job_id 返回合并为一次原子抢占

别把“从集合移走”误当成业务成功

原子抢占解决的是重复领取,不是任务可靠完成。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 和状态存储中,各自的职责清楚,问题也更容易复查。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>