多队列

为什么需要多队列?

单一 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

租户数量通常在千级以内,完全可以接受。同时在投递入口做限流:单个租户的待处理任务数超过阈值时,要么拒绝,要么降级到低精度队列。这样既保留了隔离性,又把队列数量控制住了。

多队列的最终取舍

多队列的本质是用”管理复杂度”换”隔离性和成本”。队列切得越细,隔离越好、单个集合越小,但监控、降级、排查的复杂度呈线性上升。

我们的经验是:先按延时范围分三层,业务线只在大客户场景下单独拆队列,其余都放共享队列并加限流。三层足够覆盖绝大多数场景,又不会让运维团队陷在几百条队列的告警里。

发现内容有问题?

告诉我们哪里需要改进。