🕵️♂️字节跳动面试:zset 延时队列怎么实现的?
前两天一个朋友面试字节回来,一脸生无可恋地跟我说:
❝
面试官让我讲讲延时队列怎么实现的。 我说用 Redis。 面试官:那用 zset 怎么实现? 我:“啊这……😅”
❞
这题真的是很多人挂的经典题。看似是个“八股”,其实考察的是**「你对 Redis 数据结构的理解深度」和「工程应用思维」**。今天就和你聊聊这题背后的“心机”😏。
一、延时队列是个啥?
延时队列,其实就是——「一条消息不立刻消费,而是等到指定时间再处理」。 比如:
- 用户下单 30 分钟未支付 → 关闭订单
- 用户注册后 24 小时未激活 → 发送提醒短信
- 电商系统的“秒杀倒计时通知”
这些操作都需要“延迟执行”。
而普通消息队列(Kafka、RabbitMQ)消息一发出就会被消费,不延迟。 这时候,我们需要“让消息先睡一会儿”,等到时间到了再唤醒它 💤。
二、为什么是 zset?
Redis 的 zset(有序集合)是一种神奇的数据结构:
zadd delay_queue 1735190400 order:1001
zadd delay_queue 1735190500 order:1002
❝
每个元素都有一个“score”,这里我们用 「Unix 时间戳」 表示消息触发时间。
❞
这就像给每条消息都打了个“闹钟”⏰。
然后你定时轮询:
zrangebyscore delay_queue -inf now
取出所有“到点的任务”,再执行并删除:
zremrangebyscore delay_queue -inf now
是不是突然觉得:
❝
“哎?好像挺优雅的!” 😎
❞
三、zset 延时队列的核心逻辑 🧠
可以总结为一句话:
❝
利用 Redis ZSet 的**「有序性」** + 「score 表示触发时间」,实现**「时间驱动的消息队列」**。
❞
流程图👇:
[生产者] --> [ZADD 插入任务 (score=执行时间)] --> [Redis ZSet]
↓
[消费者定期扫描 <= now 的任务]
↓
[执行任务 & ZREM 删除已处理]
四、Java 版本实现 🧩
用 Java 写一个简单的 zset 延时队列示例:
import redis.clients.jedis.Jedis;
import java.util.Set;
public class DelayQueueDemo {
private static final String QUEUE_KEY = "delay_queue";
public static void main(String[] args) throws InterruptedException {
Jedis jedis = new Jedis("localhost", 6379);
// 1️⃣ 生产者:添加延时任务
long delayTime = System.currentTimeMillis() / 1000 + 5; // 延时 5 秒
jedis.zadd(QUEUE_KEY, delayTime, "order:1001");
System.out.println("任务已添加:order:1001");
// 2️⃣ 消费者轮询
while (true) {
long now = System.currentTimeMillis() / 1000;
Set<String> tasks = jedis.zrangeByScore(QUEUE_KEY, 0, now);
if (!tasks.isEmpty()) {
for (String task : tasks) {
System.out.println("执行任务:" + task);
jedis.zrem(QUEUE_KEY, task);
}
}
Thread.sleep(1000);
}
}
}
🧩 思路解读:
- 「ZADD」:插入消息时带上触发时间戳
- 「ZRANGEBYSCORE」:按时间范围查询
- 「ZREM」:消费后删除
- 「轮询间隔」:1s(实际可根据业务调整)
五、Golang 版本实现 🧠
package main
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
const queueKey = "delay_queue"
var ctx = context.Background()
func main() {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
// 1️⃣ 生产者添加任务
delay := 5 * time.Second
score := float64(time.Now().Add(delay).Unix())
rdb.ZAdd(ctx, queueKey, redis.Z{Score: score, Member: "order:2001"})
fmt.Println("任务已添加:order:2001")
// 2️⃣ 消费者轮询
for {
now := float64(time.Now().Unix())
tasks, _ := rdb.ZRangeByScore(ctx, queueKey, &redis.ZRangeBy{
Min: "0",
Max: fmt.Sprintf("%f", now),
}).Result()
for _, task := range tasks {
fmt.Println("执行任务:", task)
rdb.ZRem(ctx, queueKey, task)
}
time.Sleep(1 * time.Second)
}
}
✅ Go 版本思路一致: 用 ZAdd + ZRangeByScore 实现延时任务调度,轻量且高效。
六、延伸思考:zset 延时队列的坑 ⚠️
面试官一般不会只停在“你会写”,他会继续问:
1️⃣ 如果 Redis 宕机了怎么办?
- Redis 是内存数据库,任务会丢。
- 解决:可以加 「持久化(AOF/RDB)」 或搭配 「消息队列兜底」。
2️⃣ 如果任务很多,轮询会不会太慢?
-
是的,O(logN) 查找 + 轮询间隔太大会延迟。
-
优化思路:
- 用 「阻塞式 pop(Redis Stream/Sorted Set + BLPOP)」
- 或者用 「时间最小堆 + 定时器」 实现更精准触发。
3️⃣ 为什么不用 RabbitMQ 延时队列?
- RabbitMQ 需要插件或 TTL 机制,配置复杂;
- Redis 简单暴力,适合中小型场景;
- 大流量业务(订单、支付)还是推荐 「消息队列 + 延迟 Topic」。
七、实际应用案例 🚀
字节系的一些业务(比如推送调度、活动中心)确实有使用类似机制:
- 「Redis ZSet」 存放触发时间;
- 「消费者集群」 定期扫描;
- 「任务分片消费」 防止重复执行;
- 「延时任务落地日志系统」 做幂等保障。
可以说,这是一个“小而精”的方案——简单、够快、好维护。
八、总结一下 🎯
面试官问“zset 延时队列怎么实现”,其实想听的是:
| 维度 | 你说的要点 |
|---|---|
| 思路 | 用 ZSet 存储任务,score 表示执行时间 |
| 实现 | ZADD、ZRANGEBYSCORE、ZREM |
| 优化 | 防丢失、分布式轮询、幂等处理 |
| 对比 | 和 MQ 延时机制的差异 |
一句话总结就是👇
❝
“Redis ZSet 延时队列,其实就是用时间戳当排序键的定时任务池。”
❞
如果你能再补上一句:
❝
“在实际工程中我们还可以结合 Stream、Lua 脚本或时间轮做更稳定的实现。”
❞
——面试官大概率会点点头,说一句:“不错,可以了😊”。
📎彩蛋:延伸阅读
- Redis 官方文档:redis.io/docs/latest…
- 延时任务高精度方案:时间轮(Time Wheel)
- 分布式延时队列中间件:
delay-queue-go,xxl-job,rocketmq-scheduler
👉 「写在最后:」
其实延时队列不止是一道面试题,更是一个“设计思维题”。 能不能在有限资源下,用最小代价实现稳定系统? 这才是面试官想看到的“工程味”✨。