多队列
单一 ZSet 队列的问题:
问题:任务量过大
假设每天 1000 万任务:
- 30 分钟内:450 万
- 30 分钟~7 天:350 万
- 7 天以上:200 万
所有任务都在同一个 ZSet:
- 内存占用:1000 万 × 1KB = 10GB
- 查询效率:扫描整个 ZSet 找到期任务
- 清理困难:已完成的任务需要单独清理
这三条问题里,第三条最容易被忽略,也最容易在半年后爆掉。
单个 ZSet 里的成员只增不减——任务到期被取走之后,必须显式 ZREM 才能真正释放内存。如果所有任务都混在一个集合里,调度器就必须对整个集合做清理扫描;而一旦某个业务的任务量突然暴涨,这个扫描的耗时会跟着上涨,最终把调度线程拖垮。
第一条是成本问题,第二条是性能问题,第三条是稳定性问题。 它们指向同一个解法:不要把所有任务塞进同一个集合,而是按延时范围分成多个队列,让每个队列有清晰的边界、清晰的清理时机和清晰的监控指标。
除了延时范围,划分队列还有另一个正交的维度:业务维度(按租户、按业务线、按用户)。这两个维度可以叠加,形成一个”队列矩阵”。
| 划分维度 | 切法 | 解决的问题 |
|---|---|---|
| 按延时范围 | 短、中、长三档 | 控制单个集合的规模,隔离不同到期密度的任务 |
| 按业务线 | 每个业务一个队列 | 防止大客户刷爆共享队列 |
| 按租户 / 用户 | 每个主体一个队列 | 逻辑隔离,防止单主体制造热点 |
但这三个维度不能一起无限切下去。队列数量越多,每条队列的样本越少,扫描效率反而下降;Redis 里几万个空 key 也会带来内存和运维负担。队列数量应该由”能容忍的最大延迟和最小队列规模”倒推,而不是拍脑袋定。
| 维度 | 单队列 | 多队列 |
|---|---|---|
| 内存 | 所有任务挤在一起,峰值即上限 | 按档位分摊,峰值被摊薄 |
| 扫描 | 每次扫描一个大集合 | 每个集合都小,扫描更快 |
| 清理 | 混杂,难以判断清理时机 | 每档有独立的清理窗口 |
| 隔离 | 一个业务拖垮全部 | 故障被关在单个队列里 |
| 运维复杂度 | 低 | 中高(多套 key、多套监控、多套降级) |
| 风险 | 单点集中 | 队列切太碎导致资源浪费 |
按延时范围分层
┌─────────────────────────────────────────────────────────┐
│ 多队列延时架构 │
├─────────────────────────────────────────────────────────┤
│ │
│ 热数据层(短延时队列) │
│ • 延时范围:< 30 分钟 │
│ • 精度:秒级 │
│ • 内存:少量(任务快速完成) │
│ • Redis: zset:short │
│ │
│ 温数据层(中延时队列) │
│ • 延时范围:30 分钟 ~ 7 天 │
│ • 精度:分钟级 │
│ • 内存:中等 │
│ • Redis: zset:medium │
│ │
│ 冷数据层(长延时队列) │
│ • 延时范围:> 7 天 │
│ • 精度:小时级 │
│ • 内存:少(使用数据库轮询) │
│ • Database: delay_tasks_long │
│ │
└─────────────────────────────────────────────────────────┘
三层划分的依据不是拍脑袋,而是三个可量化的信号:
- 到期密度:短延时队列每天到期几十次,长延时队列可能一天才跑一轮。调度频率必须跟着到期密度走,否则就是空转。
- 精度需求:订单超时取消要求秒级,自动确认收货要求小时级,精度需求和延时长度是正相关的。
- 成本敏感度:长延时任务要等很多天,留在 Redis 内存里纯属浪费,挪到磁盘上的数据库更划算。
关键在于每层的调度节奏不一样。短队列每秒扫一次,中队列每分钟扫一次,冷队列每十分钟扫一次。三者互不阻塞,各自独立伸缩。
三层队列的调度节奏:
zset:short 每 1 秒扫一次 批量取 500 条 → 秒级触发
zset:medium 每 1 分钟扫一次 批量取 200 条 → 分钟级触发
delay_tasks_long 每 10 分钟扫一次 批量取 100 条 → 10 分钟级触发,
满足小时级的精度要求
三者跑在不同的调度线程(或同一个线程的不同分片任务)上,
短队列的抖动不会影响冷队列。
方案落地
落到代码上,多队列方案要解决四件事:key 怎么命名、消息怎么路由、怎么原子地取走、任务怎么在层之间迁移。
第一步:key 命名与路由。
key 要带上业务维度,否则隔离无从谈起。推荐的结构是 dq:{业务}:{档位}:
public class DelayQueueRouter {
private static final long SHORT_MAX_MS = 30 * 60 * 1000L; // 30 分钟
private static final long MEDIUM_MAX_MS = 7L * 24 * 3600 * 1000; // 7 天
public enum Tier { SHORT, MEDIUM, LONG }
/** 按剩余延时决定任务落在哪一层 */
public Tier route(long delayMs) {
if (delayMs <= SHORT_MAX_MS) {
return Tier.SHORT;
}
if (delayMs <= MEDIUM_MAX_MS) {
return Tier.MEDIUM;
}
return Tier.LONG;
}
/** 拼出 Redis key */
public String key(String biz, Tier tier) {
return "dq:" + biz + ":" + tier.name().toLowerCase();
}
/** 长延时任务落到数据库时,表名按业务分表 */
public String table(String biz) {
return "delay_tasks_" + biz;
}
}
第二步:原子取走到期任务。
多个调度器同时扫描同一条队列时,“查到”和”取走”必须是一个原子动作,否则同一个任务会被两个节点执行。这段逻辑用 Lua 脚本实现,Redis 保证脚本执行期间不会被其他命令打断:
-- KEYS[1] = 队列 key
-- ARGV[1] = 当前时间戳
-- ARGV[2] = 本次最多取多少条
local now = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local due = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', now, 'LIMIT', 0, limit)
if #due > 0 then
redis.call('ZREM', KEYS[1], unpack(due))
end
return due
对应 Java 调用:
public List<String> claimDue(String queueKey, int limit) {
List<String> result = defaultRedisTemplate.execute(
new DefaultRedisScript<>(CLAIM_DUE_LUA, List.class),
Collections.singletonList(queueKey),
String.valueOf(System.currentTimeMillis()),
String.valueOf(limit));
// claim 之后任务必须进入"执行中"状态,否则取走但执行失败就丢了
// 这里把任务搬到 processing 集合,失败可以回捞
if (!result.isEmpty()) {
redisTemplate.opsForZSet().add(queueKey + ":processing",
result.stream().map(this::toMember).toArray(String[]::new));
}
return result;
}
注意 ZREM 和”标记执行中”之间的窗口:脚本里 ZREM 之后、写入 processing 集合之前如果进程挂了,任务就丢了。所以更稳的做法是在同一个 Lua 脚本里同时完成”取出 + 移入 processing 集合”,让两步成为一个原子动作。这也是《可靠性》里”任务不能丢”这条要求的落地方式。
第三步:分层的调度循环。
@Scheduled(fixedDelay = 1_000)
public void scanShortQueue() {
claimAndDispatch("dq:order:short", 500);
}
@Scheduled(fixedDelay = 60_000)
public void scanMediumQueue() {
claimAndDispatch("dq:order:medium", 200);
}
@Scheduled(fixedDelay = 600_000)
public void scanLongQueue() {
// 冷队列走数据库,注意批量大小和索引
List<DelayTask> tasks = delayTaskMapper.selectDue(new Date(), 100);
...
}
三个调度方法的批量大小和间隔是配套的:单次批量乘以频率,要略大于该层的峰值到期速率,否则会出现”扫完立刻又攒满”的锯齿。
第四步:层间迁移。
中延时的任务如果一直待在 zset:medium 里,等到它进入 30 分钟窗口时再迁到 zset:short,短队列的精度才不会被中队列拖累。迁移有三种做法:
| 迁移方式 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 到期时才迁移 | 扫中队列时发现任务已进入短窗口,顺手写进短队列 | 实现最简单,无额外开销 | 中队列要一直占着内存直到迁移 |
| 定时搬运 | 一个后台任务每分钟把”剩余时间进入短窗口”的任务批量搬到短队列 | 短队列触发更准时 | 多一次搬运,搬运失败要补偿 |
| 按窗口取,不迁移 | 中队列保持原样,调度器直接按 score 精确扫描 | 队列数最少 | 中队列扫描成本随数据量增长 |
我们线上用的是到期时才迁移,但加了一条兜底:如果中队列的成员数超过阈值,就启动一次批量搬运,把内存压力降下来。
最后是监控。 多队列最大的运维风险是”某个队列悄悄不消费了”。每条队列都必须有这四个指标:
| 指标 | 含义 | 告警阈值参考 |
|---|---|---|
| 队列长度 | 待处理任务数 | 超过历史同期 3 倍 |
| 最早任务的到期时间 | 队列头部的 score | 距今超过 2 个调度周期 |
| 单位时间出队数 | 实际处理吞吐 | 连续 3 个周期为 0 |
| claim 耗时 | 单次扫描的耗时 | 超过调度间隔的一半 |
其中”最早任务的到期时间”是最关键的一个:队列长度可能因为业务变化而波动,但只要最早任务在按时出队,就说明调度器是健康的。
| 优势 | 说明 |
|---|---|
| ✅ 内存优化 | 长延时任务不占用 Redis 内存 |
| ✅ 性能优化 | 短延时任务查询更快 |
| ✅ 精度优化 | 不同层级使用不同精度 |
| ✅ 成本优化 | 减少内存使用,降低成本 |
| ✅ 可扩展 | 可以独立扩展不同层级 |
这五条优势背后都对应着具体的代价,评估方案时必须一起看:
| 优势 | 代价 | 缓解手段 |
|---|---|---|
| 内存优化 | 长延时任务搬进数据库后,查询和领取要走 SQL,性能下降 | 给 execute_at 建索引,用批量领取控制扫描量 |
| 性能优化 | 队列切分后,单队列数据量变小,CPU 缓存命中率下降 | 控制队列数量,别切太碎 |
| 精度优化 | 不同精度意味着不同层的触发误差不一致,排查问题时容易困惑 | 在任务详情里记录实际触发时间,方便对账 |
| 成本优化 | 多了一套存储,运维和监控成本上升 | 统一的任务注册中心,所有队列一屏可见 |
| 可扩展 | 层级边界是硬编码的,需求变了要改代码 | 用配置化的路由表,边界可调 |
关于”要不要每个用户一个队列”
这是多队列设计里最容易被提出的需求:给每个用户一个专属队列,这样天然就隔离了。结论是——绝大多数情况下不要这么做。
| 方案 | 队列数量 | 隔离效果 | 运维成本 |
|---|---|---|---|
| 单队列 | 1 | 无 | 最低 |
| 按业务线分队列 | 几十 | 好 | 中 |
| 按租户分队列 | 几百到几千 | 很好 | 中高 |
| 按用户分队列 | 百万级 | 看似最好 | 不可接受 |
按用户分队列的问题很直接:Redis 里会出现几百万个小 key,每个 key 里平均只有零点几个任务。这会带来内存碎片、Redis 的键空间遍历变慢、监控无法聚合、而且用户行为改变时队列会被大量创建和销毁。更要命的是,“每个用户一个队列”并没有真正解决热点问题——一个超级用户依然会让自己的队列积压,只是不再影响别人而已。
推荐的做法是按租户分队列,配合租户级限流:
dq:{租户ID}:short ← 每个活跃租户一条队列
dq:{租户ID}:medium
dq:{租户ID}:long
租户数量通常在千级以内,完全可以接受。同时在投递入口做限流:单个租户的待处理任务数超过阈值时,要么拒绝,要么降级到低精度队列。这样既保留了隔离性,又把队列数量控制住了。
多队列的最终取舍
多队列的本质是用”管理复杂度”换”隔离性和成本”。队列切得越细,隔离越好、单个集合越小,但监控、降级、排查的复杂度呈线性上升。
我们的经验是:先按延时范围分三层,业务线只在大客户场景下单独拆队列,其余都放共享队列并加限流。三层足够覆盖绝大多数场景,又不会让运维团队陷在几百条队列的告警里。
发现内容有问题?
告诉我们哪里需要改进。
