Redis ZSet 基础
当我们的订单量突破 10 万/天后,数据库轮询方案开始吃力:
性能瓶颈:
- 数据库查询:每次 100-500ms
- 网络延迟:每次 10-50ms
- 并发限制:数据库连接池有限
- 总吞吐量:~100 任务/秒
目标:
- 查询时间:< 10ms
- 总吞吐量:> 1000 任务/秒
经过调研,我们决定尝试 Redis 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 + 时间戳
- 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 只当”调度索引”用。
| 组成 | 存什么 | 为什么这么存 |
|---|---|---|
| Key | delay: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 决定,和堆积了多少任务无关。
优点
| 优点 | 说明 |
|---|---|
| ✅ 高性能 | O(log n) 的时间复杂度,查询快 |
| ✅ 高精度 | 可以精确到毫秒 |
| ✅ 范围查询 | 支持按时间范围查询 |
| ✅ 易扩展 | 可以增加 Redis 节点 |
| ✅ 支持取消 | O(log n) 删除,效率高 |
缺点
| 缺点 | 说明 | 解决方案 |
|---|---|---|
| ❌ 内存占用 | 所有任务都在内存中 | 分层存储,长延时任务用数据库 |
| ❌ 持久化 | Redis 默认不持久化 | 开启 AOF 或 RDB |
| ❌ 单点故障 | Redis 挂了怎么办 | 使用 Redis 集群或哨兵 |
| ❌ 容量限制 | 受内存大小限制 | 任务分层或使用 Redis Cluster |
数据库轮询 vs Redis ZSet
| 维度 | 数据库轮询 | Redis ZSet | 提升 |
|---|---|---|---|
| 查询时间 | 100-500ms | 5-20ms | 10-50x |
| 插入时间 | 10-50ms | 1-5ms | 5-10x |
| 删除时间 | 20-100ms | 1-5ms | 10-100x |
| 并发能力 | ~100 QPS | ~10000 QPS | 100x |
| 精度 | 秒级 | 毫秒级 | - |
| 内存占用 | 低(磁盘) | 高(内存) | - |
性能测试
在一台 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 |
结论有三个:
- Pipeline 是批量写入的必选项。 单条 ZADD 到 Pipeline 1000 条,吞吐提升了一个数量级。原因很简单:单条 ZADD 要等一个 RTT,Pipeline 把 1000 个 RTT 压成 1 个。
- 取任务的耗时与队列总长度无关,只与取出的条数有关。 ZSet 底层是跳表,
ZRANGEBYSCORE是 O(log n + m),10 万条和 100 万条几乎没有区别。这正是 ZSet 方案相对数据库轮询的核心优势。 - Lua 脚本的开销可以接受。 取 100 条并删除只用了 0.6ms,比”先查后删”的两条命令(0.35 + 1.2 = 1.55ms)快一倍以上,而且换来了原子性。
和数据库轮询对照(前面那张性能对比表给的是估算值,这里是实测):
| 维度 | MySQL 轮询(实测) | Redis ZSet(实测) | 倍数 |
|---|---|---|---|
| 取 100 条到期任务 | 32 ms | 0.35 ms | ~90x |
| 删除 100 条 | 48 ms | 1.2 ms | ~40x |
| 单条入队 | 1.8 ms | 0.12 ms | ~15x |
| 并发 50 线程取任务 | 连接池耗尽,排队 | 全部 3ms 内返回 | - |
最后一个数据尤其重要:数据库轮询在并发下会直接崩在连接池上,而 ZSet 因为单次操作只要 0.3ms,并发 50 个线程也毫无压力。
测试结论依赖机器配置和数据量,换环境需要重测。但”取任务耗时与队列长度无关”这条性质是跳表结构决定的,不会因环境变化而改变。
思考 1
如何处理 Redis 宕机时的数据丢失问题?
解题思路:
核心判断是 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 分钟数据 | 每次取任务都要扫索引 |
| 任务详情占内存 | 内存成本低,适合长延时任务 |
| 任务状态无处审计 | 天然支持任意条件查询与对账 |
四个关键设计点:
- 数据库是事实来源,Redis 是缓存。 任何”以谁为准”的争议一律以数据库为准。Redis 里的数据丢了可以从数据库重建;数据库里的数据丢了就找不回来了。
- 写顺序是先数据库、后 Redis。 反过来的话,数据库写失败时 Redis 里会留下一条永远执行不了的任务。按这个顺序,最坏情况只是”数据库有、Redis 没有”,而这正好由补偿任务兜底。
- 补偿任务必须幂等。 补偿逻辑可能因为各种原因重复执行,它只能做”让两边一致”的事,不能有副作用。
- 长延时任务可以只存数据库。 7 天后才执行的任务放在内存里是浪费,等到它临近到期(比如 1 小时内)再同步进 Redis 即可。这就把内存占用和延时跨度解耦了,思路和《多队列》里的分档完全一致。
3. 使用 Redis 哨兵或集群
验证要点
- 命令只用于验证系统状态,读者不需要记具体参数。
优点: 自动故障转移 缺点: 需要至少 3 个节点
4. 定期备份
最佳实践建议:
- ✅ 必须开启 AOF:保证数据持久化
- ✅ 双层存储:Redis + 数据库,性能和可靠性兼顾
- ✅ 使用哨兵/集群:保证高可用
- ✅ 定期备份:防止数据损坏
最终答案:
分四层兜底。一、开启 AOF 持久化,把数据丢失窗口压到约 1 秒;二、采用 Redis 加数据库的双层存储,数据库是唯一事实来源、Redis 只是调度索引,写入顺序是先数据库后 Redis,补偿任务定期双向比对且必须幂等,长延时任务可以只存数据库、临近到期再同步进 Redis;三、用 Redis 哨兵或集群实现自动故障转移;四、定期备份防止数据损坏。
发现内容有问题?
告诉我们哪里需要改进。
