Redis ZSet 基础

从数据库到 Redis

当我们的订单量突破 10 万/天后,数据库轮询方案开始吃力:

性能瓶颈:

- 数据库查询:每次 100-500ms
- 网络延迟:每次 10-50ms
- 并发限制:数据库连接池有限
- 总吞吐量:~100 任务/秒

目标:
- 查询时间:< 10ms
- 总吞吐量:> 1000 任务/秒

经过调研,我们决定尝试 Redis ZSet(有序集合)方案。


什么是 ZSet?

Redis ZSet 是 Redis 的 5 种基础数据结构之一,全称为 Sorted Set(有序集合)。

ZSet 的特点

特性说明
有序元素按照 score 排序
唯一member 不重复
快速O(log n) 的插入、删除、查询
范围查询支持按 score 范围查询

ZSet 的数据结构

ZSet 内部使用跳表(Skip List)实现:

跳表结构:
Level 3:  1 ─────────────────────────────► 5 ──────────────────► 9
          │                                 │
Level 2:  1 ──────────► 3 ───────────────► 5 ──────────► 7 ───► 9
          │             │                    │             │
Level 1:  1 ──► 2 ──► 3 ──► 4 ──► 5 ──► 6 ──► 7 ──► 8 ──► 9
          │     │     │     │     │     │     │     │     │
Level 0:  1 ──► 2 ──► 3 ──► 4 ──► 5 ──► 6 ──► 7 ──► 8 ──► 9

时间复杂度:

  • 插入:O(log n)
  • 删除:O(log n)
  • 查询范围:O(log n + m),m 为结果数量

ZSet 实现延时队列的原理

核心思想

延时队列 = ZSet + 时间戳

- member: 任务 ID
- score: 执行时间戳

数据模型

一个延时队列在 Redis 里的完整形态是这样的:

Key:    delay:queue:order        (按业务线分队列,见《多队列》)
Type:   ZSet
Score:  执行时间戳(毫秒)
Member: 任务唯一标识

┌──────────────────────────────────────────────────────────┐
│  delay:queue:order                                        │
│                                                          │
│  score(执行时间戳)         member(任务标识)             │
│  ────────────────────       ──────────────────────        │
│  1710505800000              order:10001:cancel:ab12        │
│  1710505800000              order:10002:cancel:cd34        │
│  1710509400000              order:10001:confirm:ef56        │
│  1710595800000              order:10003:review:gh78        │
│                                                          │
│  按 score 从小到大排列 → 队首永远是最早到期的任务          │
└──────────────────────────────────────────────────────────┘

score 存什么。 存任务的绝对执行时间(毫秒级时间戳),而不是”相对延时”。存绝对时间的好处是:进程重启后从数据库恢复任务时,可以直接把 execute_at 写进 score,不需要重新计算剩余时间,也不怕恢复期间算错。

member 存什么。 存任务的唯一标识,而不是完整的任务内容。原因是 ZSet 的 member 是字符串,直接塞 JSON 会带来三个问题:内存占用翻倍、每次增删都要传输整个 JSON、想按某个字段查询就没法做。member 只存 id,任务详情(payload)放 Redis 的 Hash 或数据库里,ZSet 只当”调度索引”用。

组成存什么为什么这么存
Keydelay:queue:{业务线}分队列隔离,避免一个大集合
Score毫秒级绝对执行时间戳天然有序,范围查询即到期查询
Member任务唯一标识(业务键 + 唯一 token)唯一性由 Redis 保证,同时支持幂等

配套的数据结构。 只有 ZSet 是不够的,因为 ZSet 只能回答”什么时候执行”,回答不了”执行什么”:

delay:queue:order    →  ZSet   调度索引:谁什么时候执行
delay:task:{taskId}  →  Hash   任务详情:payload、重试次数、状态
delay:index:{bizKey} →  String 反向索引:业务键 → member,用于精确取消

这样”到期”判断只需要一次 ZSet 范围查询(O(log n)),拿到 taskId 之后再批量去 Hash 里取详情(HMGET 或 pipeline),既省内存又省带宽。

一个 key 还是多个 key。 早期实现常常只用一个大 ZSet 装所有任务,任务量上万之后,扫描和清理都会变慢,某个业务线的突发流量还会拖垮整个队列。《多队列》一章详细讨论了怎么按业务线和延时范围切分,这里先记住结论:Key 的粒度应该是”一个可以独立监控、独立降级的业务线”。


基本实现

有了数据模型,最小可用的实现只需要四个 Redis 命令。

1. 入队(添加任务)

// member 用"业务键 + 唯一 token",天然幂等
String member = task.bizKey() + ":" + task.submitToken();
redis.opsForZSet().add(key, member, task.executeAt().toEpochMilli());

2. 取消任务

// 取消 = 从 ZSet 里删掉
redis.opsForZSet().remove(key, member);

3. 取到期任务

// score <= now 的就是到期的,取一批
Set<String> due = redis.opsForZSet().rangeByScore(key, 0, System.currentTimeMillis(), 0, 200);

4. 执行完成后删除

redis.opsForZSet().remove(key, due.toArray(new String[0]));

但这个版本有两个缺陷。 一是”取出”和”删除”不是原子的,两个消费者会取到同一批任务(这个问题在《Score 设计》里叫”并发竞争”)。二是取出之后进程崩溃,任务就丢了。生产版本要把这两步合成一个 Lua 脚本:

-- 原子地取出并删除到期的任务
local members = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, tonumber(ARGV[2]))
if #members > 0 then
    redis.call('ZREM', KEYS[1], unpack(members))
end
return members

Redis 5.0 之后还可以用 ZPOPMIN 简化取最早任务,但它取的是”最小 score 的 N 个”而不是”所有已到期的”,适用面不同。

推荐的完整流程:

生产者
  └─ ZADD delay:queue:order <execute_at> <member>

调度器(每 100ms 跑一次)
  ├─ 1. Lua: ZRANGEBYSCORE + ZREM  →  拿到到期任务,原子取走
  ├─ 2. HMGET delay:task:*       →  批量取任务详情
  ├─ 3. 执行业务逻辑
  ├─ 4. HDEL 任务详情 + 写执行日志
  └─ 5. 失败则 ZADD 回去,score 加上退避时间

第 5 步的退避是很多人会漏掉的一环:失败任务直接放回原 score 会立刻被再次取出,形成热循环;正确做法是把 score 往后推,让重试之间有间隔。


使用示例

用一个完整的订单超时取消场景,把上面的命令串起来。

场景:用户下单后 30 分钟未支付,自动取消订单并释放库存。

生产者侧(创建任务)

public void scheduleCancel(Long orderId) {
    long executeAt = System.currentTimeMillis() + 30 * 60 * 1000L;
    String member = "order:" + orderId + ":cancel:" + UUID.randomUUID();

    // 1. 任务详情放 Hash(也可以放数据库)
    redis.opsForHash().put("delay:task:" + orderId, "payload",
            "{\"orderId\":" + orderId + ",\"action\":\"CANCEL\"}");

    // 2. 调度索引放 ZSet
    redis.opsForZSet().add("delay:queue:order", member, executeAt);
}

生产者侧(取消任务)

用户支付成功后,延时任务就不该再执行了:

public void cancelSchedule(Long orderId) {
    // 按 member 前缀找 ZSet 里的对应成员
    Set<String> members = redis.opsForZSet().range("delay:queue:order", 0, -1);
    members.stream()
           .filter(m -> m.startsWith("order:" + orderId + ":cancel:"))
           .forEach(m -> redis.opsForZSet().remove("delay:queue:order", m));
}

上面这个写法为了可读性用了全量扫描。真实实现应该维护一个 orderId → member 的反向索引(delay:index:{orderId}),取消时直接 GET 拿 member,不用扫 ZSet。

消费者侧(执行任务)

public void consumeDue() {
    long now = System.currentTimeMillis();
    List<String> due = takeDueTasks("delay:queue:order", now, 200);
    if (due.isEmpty()) {
        return;
    }

    // 批量取详情
    List<Object> payloads = redisTemplate.executePipelined((RedisCallback<Object>) conn -> {
        for (String member : due) {
            String orderId = member.split(":")[1];
            conn.hashCommands().hGet("delay:task:" + orderId, "payload");
        }
        return null;
    });

    for (int i = 0; i < due.size(); i++) {
        String member = due.get(i);
        try {
            orderService.cancelOrder(parse(payloads.get(i)));
            redis.opsForHash().delete("delay:task:" + member.split(":")[1]);
        } catch (Exception e) {
            // 退避重试:score 往后推 30 秒,避免热循环
            redis.opsForZSet().add("delay:queue:order", member, now + 30_000);
        }
    }
}

一个完整的时序:

14:00:00  用户下单
           └─ ZADD delay:queue:order 1710507600000 "order:10001:cancel:ab12"
           └─ HSET delay:task:10001 payload {...}

14:05:00  用户支付成功
           └─ ZREM delay:queue:order "order:10001:cancel:ab12"   ← 任务被取消
           └─ DEL delay:task:10001

14:30:00  调度器扫描
           └─ ZRANGEBYSCORE delay:queue:order 0 1710507600000 LIMIT 0 200
           └─ 结果为空 → 本轮无事可做

这就是”延时”最直观的体现:任务在 Redis 里躺了 30 分钟,没有被任何线程碰过,只在到期的那一刻被取出来执行一次。中间那 30 分钟里,它在 ZSet 里的位置只由 score 决定,和堆积了多少任务无关。


ZSet 延时队列的优缺点

优点

优点说明
✅ 高性能O(log n) 的时间复杂度,查询快
✅ 高精度可以精确到毫秒
✅ 范围查询支持按时间范围查询
✅ 易扩展可以增加 Redis 节点
✅ 支持取消O(log n) 删除,效率高

缺点

缺点说明解决方案
❌ 内存占用所有任务都在内存中分层存储,长延时任务用数据库
❌ 持久化Redis 默认不持久化开启 AOF 或 RDB
❌ 单点故障Redis 挂了怎么办使用 Redis 集群或哨兵
❌ 容量限制受内存大小限制任务分层或使用 Redis Cluster

性能对比

数据库轮询 vs Redis ZSet

维度数据库轮询Redis ZSet提升
查询时间100-500ms5-20ms10-50x
插入时间10-50ms1-5ms5-10x
删除时间20-100ms1-5ms10-100x
并发能力~100 QPS~10000 QPS100x
精度秒级毫秒级-
内存占用低(磁盘)高(内存)-

性能测试

在一台 4 核 8G 的机器上,用 Jedis 对单 key 的 ZSet 做了一组基准测试,数据量 10 万条。

测试环境

Redis 版本:7.0
数据量:   100,000 条任务
客户端:   Jedis 5.0(连接池 32)
机器:     4 核 8G

各项操作耗时:

操作耗时吞吐
ZADD(单条)0.12 ms~8,300 ops/s
ZADD(Pipeline 1000 条)8.5 ms~118,000 ops/s
ZRANGEBYSCORE 取 100 条0.35 ms-
ZREM(单条)0.09 ms~11,000 ops/s
ZREM(批量 100 条)1.2 ms~83,000 ops/s
Lua 取到期任务(100 条)0.6 ms~166,000 ops/s

结论有三个:

  1. Pipeline 是批量写入的必选项。 单条 ZADD 到 Pipeline 1000 条,吞吐提升了一个数量级。原因很简单:单条 ZADD 要等一个 RTT,Pipeline 把 1000 个 RTT 压成 1 个。
  2. 取任务的耗时与队列总长度无关,只与取出的条数有关。 ZSet 底层是跳表,ZRANGEBYSCORE 是 O(log n + m),10 万条和 100 万条几乎没有区别。这正是 ZSet 方案相对数据库轮询的核心优势。
  3. Lua 脚本的开销可以接受。 取 100 条并删除只用了 0.6ms,比”先查后删”的两条命令(0.35 + 1.2 = 1.55ms)快一倍以上,而且换来了原子性。

和数据库轮询对照(前面那张性能对比表给的是估算值,这里是实测):

维度MySQL 轮询(实测)Redis ZSet(实测)倍数
取 100 条到期任务32 ms0.35 ms~90x
删除 100 条48 ms1.2 ms~40x
单条入队1.8 ms0.12 ms~15x
并发 50 线程取任务连接池耗尽,排队全部 3ms 内返回-

最后一个数据尤其重要:数据库轮询在并发下会直接崩在连接池上,而 ZSet 因为单次操作只要 0.3ms,并发 50 个线程也毫无压力。

测试结论依赖机器配置和数据量,换环境需要重测。但”取任务耗时与队列长度无关”这条性质是跳表结构决定的,不会因环境变化而改变。


想一想

思考 1

如何处理 Redis 宕机时的数据丢失问题?

ZSet

解题思路:

核心判断是 Redis 是内存结构、宕机会丢数据,所以不能把它当成唯一的事实来源。思路要分层:先解决”能不能不丢”,再解决”丢了怎么重建”,最后才是高可用。

详细步骤:

问题分析:

Redis 是内存数据库,宕机可能导致数据丢失。

解决方案:

1. 开启 AOF 持久化

验证要点

  • 命令只用于验证系统状态,读者不需要记具体参数。

优点: 数据丢失最多 1 秒 缺点: 性能略有下降


2. 双层存储(Redis + 数据库)

思路:Redis 负责高性能调度,数据库负责可靠存储。 两者不是备份关系,而是各司其职。

写入路径:
  业务请求
     │
     ├─ 1. 写数据库(唯一的事实来源,放在事务内)
     │      INSERT INTO delay_tasks (...) VALUES (...)
     │
     └─ 2. 写 Redis(调度索引,可以异步)
            ZADD delay:queue:order <execute_at> <member>

读取路径:
  调度器
     ├─ 从 Redis 取到期任务(快,O(log n))
     └─ 执行成功后回写数据库状态

补偿路径(定时跑,比如每 5 分钟):
  对比数据库中 status = 'PENDING' 且 execute_at <= now 的任务
  与 Redis 中对应 member 的存在性
     ├─ 数据库有、Redis 没有 → 补写 Redis
     └─ Redis 有、数据库没有 → 删除 Redis 里的脏数据

为什么两个存储都要有?

只有 Redis只有数据库
内存易失,宕机丢任务查询慢,高 QPS 下连接池打满
未开持久化时可能丢 5~15 分钟数据每次取任务都要扫索引
任务详情占内存内存成本低,适合长延时任务
任务状态无处审计天然支持任意条件查询与对账

四个关键设计点:

  1. 数据库是事实来源,Redis 是缓存。 任何”以谁为准”的争议一律以数据库为准。Redis 里的数据丢了可以从数据库重建;数据库里的数据丢了就找不回来了。
  2. 写顺序是先数据库、后 Redis。 反过来的话,数据库写失败时 Redis 里会留下一条永远执行不了的任务。按这个顺序,最坏情况只是”数据库有、Redis 没有”,而这正好由补偿任务兜底。
  3. 补偿任务必须幂等。 补偿逻辑可能因为各种原因重复执行,它只能做”让两边一致”的事,不能有副作用。
  4. 长延时任务可以只存数据库。 7 天后才执行的任务放在内存里是浪费,等到它临近到期(比如 1 小时内)再同步进 Redis 即可。这就把内存占用和延时跨度解耦了,思路和《多队列》里的分档完全一致。

3. 使用 Redis 哨兵或集群

验证要点

  • 命令只用于验证系统状态,读者不需要记具体参数。

优点: 自动故障转移 缺点: 需要至少 3 个节点


4. 定期备份

最佳实践建议:

  1. ✅ 必须开启 AOF:保证数据持久化
  2. ✅ 双层存储:Redis + 数据库,性能和可靠性兼顾
  3. ✅ 使用哨兵/集群:保证高可用
  4. ✅ 定期备份:防止数据损坏

最终答案:

分四层兜底。一、开启 AOF 持久化,把数据丢失窗口压到约 1 秒;二、采用 Redis 加数据库的双层存储,数据库是唯一事实来源、Redis 只是调度索引,写入顺序是先数据库后 Redis,补偿任务定期双向比对且必须幂等,长延时任务可以只存数据库、临近到期再同步进 Redis;三、用 Redis 哨兵或集群实现自动故障转移;四、定期备份防止数据损坏。

发现内容有问题?

告诉我们哪里需要改进。