在后端系统里,有些任务并不需要立刻执行,比如三十分钟未支付关闭订单、失败通知隔五秒重试。这类需求本质是一个“到点触发”的模型。Redis提供的ZSet(有序集合)结构,天然带着“按分值排序”的能力,把任务的执行时间戳写成分值,服务只要不断找出分值小于当前时间的成员,就能实现一个轻量延时队列。它不需要引入RabbitMQ的死信队列或Kafka的时间轮,适合中小业务快速落地。

ZSet作为延时队列的底层原理
ZSet和普通集合不同,它为每一个成员关联了一个double类型的分值(score)。Redis在底层使用跳表与哈希表混合存储,跳表保证了按分值从小到大遍历的效率为O(log N),哈希表则支持按成员名直接定位。当我们把一条延时消息的唯一标识作为member,把期望执行时间的时间戳作为score写入ZSet后,所有待处理任务就按时间先后排好了序。
消费方只需要调用ZRANGEBYSCORE命令,传入最大值等于当前毫秒时间戳,就能拿到所有已到点的任务。因为跳表有序,最早到期的一定排在前面,不会出现“后面的先执行”的乱序。相比用List做定时轮询,ZSet不需要每次扫描全量数据,也不会因为任务多就拖慢查询,这是它适合做延时队列的根本原因。
还有一个容易被忽略的点:ZSet的score是双精度浮点,存毫秒时间戳在2038年之前都不会有精度问题。如果业务跨时区,建议统一用UTC毫秒,避免服务器本地时区不同导致任务提前或滞后。此外member必须全局唯一,否则新任务会覆盖旧任务的分值,造成消息丢失。
生产端与消费端的代码实现
下面是一段Java使用Jedis向ZSet投递延时任务的代码。我们把订单号作为member,延迟十分钟就用当前时间加600000毫秒作为score。注意异常时要重试,保证写入成功,否则任务永远不会被调度。
import redis.clients.jedis.Jedis;
public class DelayProducer {
private Jedis jedis;
private static final String KEY = "delay_queue";
public DelayProducer(Jedis jedis) {
this.jedis = jedis;
}
// 投递延时任务,delayMillis为延迟毫秒数
public void send(String taskId, long delayMillis) {
long score = System.currentTimeMillis() + delayMillis;
// 使用XX或NX可按业务需要控制覆盖行为,这里直接添加
jedis.zadd(KEY, score, taskId);
}
}
消费端要避免多个实例同时抢到同一条任务。常见做法是先ZRANGEBYSCORE取出,再ZREM删除,但这在并发下会重复消费。更稳的方式是用Lua脚本把查询和删除原子化,或者借助ZPOPMIN这类命令。下面给出一段Lua保证原子出队的示例,它一次性把到期任务移出ZSet并返回。
-- 原子取出并删除到期任务
local key = KEYS[1]
local now = tonumber(ARGV[1])
local max = tonumber(ARGV[2])
local tasks = redis.call('ZRANGEBYSCORE', key, 0, now, 'LIMIT', 0, max)
if #tasks > 0 then
redis.call('ZREM', key, unpack(tasks))
end
return tasks
消费线程每隔一秒执行一次上述脚本,拿到tasks后在本地线程池处理。处理成功即可;若失败,应记录到本地重试表或重新ZADD并拉长延迟,防止无限阻塞。由于Lua在Redis单线程执行,不会出现两个节点重复领走同一批任务的情况,这是简易延时队列能上生产的关键。
可靠性问题与替代方案对比
纯ZSet方案最大的弱点是“消费方挂掉期间任务不会自动重投”。如果服务宕机十分钟,这期间到期的任务仍安静躺在ZSet里,恢复后会被一次性捞出,可能导致瞬时压力。解决思路是给任务加心跳:消费前先把member移到“处理中”的ZSet并设过期,完成再删;崩溃则靠另一个扫描线程把卡住的任务回填。
和Redisson的RDelayedQueue相比,手写ZSet更透明、依赖少,但你要自己处理原子出队、重试和监控。Redisson内部用ZSet加定时转移线程,已经封装了上述细节,适合不想造轮子的团队。如果业务量极小,甚至可以用ZSET加Linux的at命令,但那样就脱离了集中式队列,不利于观测。
下表列出三种常见方案的差异,帮助你按场景选择:
| 方案 | 实现复杂度 | 消息可靠性 | 适用规模 |
|---|---|---|---|
| 手写ZSet+Lua | 中 | 需自研补偿 | 日百万级以内 |
| Redisson延迟队列 | 低 | 较高 | 通用 |
| RabbitMQ死信 | 高 | 高 | 大流量 |
总的来说,用Redis的ZSet实现延时队列是一种成本低、见效快的做法。只要守住“member唯一、出队原子、失败重试”三条线,就能在大部分后台任务调度里替代重型消息中间件。当业务成长到需要严格事务和回溯时,再平滑迁移到专业队列也不迟。