字节跳动面试:zset 延时队列怎么实现的?

113 阅读4分钟

🕵️‍♂️字节跳动面试: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

👉 「写在最后:」

其实延时队列不止是一道面试题,更是一个“设计思维题”。 能不能在有限资源下,用最小代价实现稳定系统? 这才是面试官想看到的“工程味”✨。