Skip to content

八、在多 Worker 环境下的行为 ​

8.1 单队列 + 多 Worker ​

8.1.1 基本模型 ​

10 个 Worker 监听同一个队列 task:queue:

                        task:queue
                        ┌─────────┐
Worker-1  BRPOP ───────►│  (空)   │◄─────── BRPOP  Worker-6
Worker-2  BRPOP ───────►│         │◄─────── BRPOP  Worker-7
Worker-3  BRPOP ───────►│         │◄─────── BRPOP  Worker-8
Worker-4  BRPOP ───────►│         │◄─────── BRPOP  Worker-9
Worker-5  BRPOP ───────►│         │◄─────── BRPOP  Worker-10
                        └─────────┘

Redis 内部等待链表:
  task:queue → [W1, W2, W3, W4, W5, W6, W7, W8, W9, W10]
                 ↑ 最先 BRPOP 的排在最前面

所有 Worker 都在阻塞等待,CPU 占用为零。

8.1.2 单个任务到达 ​

时间 0s:  API 执行 LPUSH task:queue 6

Redis 内部执行(原子操作):
  1. 列表变为 [6]
  2. 检查等待链表 → W1 是第一个
  3. 把 "6" 直接发给 W1(不经过列表)
  4. 从列表删除 "6",列表变为 []
  5. 从等待链表移除 W1

结果:
  等待链表: [W2, W3, W4, W5, W6, W7, W8, W9, W10]
  Worker-1: 收到 "6",开始处理任务
  Worker-2~10: 继续等待
  task:queue: [](空)

8.1.3 连续多个任务到达 ​

时间 0s:  LPUSH task:queue 6      → W1 拿到 6
时间 1s:  LPUSH task:queue 7      → W2 拿到 7
时间 2s:  LPUSH task:queue 8      → W3 拿到 8
时间 3s:  LPUSH task:queue 9      → W4 拿到 9
时间 4s:  LPUSH task:queue 10     → W5 拿到 10

等待链表变化:
  初始:     [W1, W2, W3, W4, W5, W6, W7, W8, W9, W10]
  0s 后:    [W2, W3, W4, W5, W6, W7, W8, W9, W10]
  1s 后:    [W3, W4, W5, W6, W7, W8, W9, W10]
  2s 后:    [W4, W5, W6, W7, W8, W9, W10]
  3s 后:    [W5, W6, W7, W8, W9, W10]
  4s 后:    [W6, W7, W8, W9, W10]

8.1.4 批量推入(一次 LPUSH 多个元素) ​

bash
LPUSH task:queue 6 7 8

Redis 一次插入 3 个元素,然后逐个唤醒等待的 Worker:

LPUSH 执行(原子):
  列表变为 [8, 7, 6]
  调用 signalKeyAsReady

主线程处理 ready_keys(仍在 LPUSH 命令内):

  第1轮循环:
    检查 task:queue → 有元素
    弹出 "6"(右端)→ 发给 W1
    从等待链表移除 W1
    列表变为 [8, 7]
    break ← 只唤醒一个

  主线程继续事件循环,发现 task:queue 还在 ready_keys 里

  第2轮循环:
    检查 task:queue → 还有元素
    弹出 "7" → 发给 W2
    从等待链表移除 W2
    列表变为 [8]
    break

  第3轮循环:
    检查 task:queue → 还有元素
    弹出 "8" → 发给 W3
    从等待链表移除 W3
    列表变为 []
    break

  第4轮循环:
    检查 task:queue → 空,不弹出
    结束处理

结果:3 个元素分别发给 W1、W2、W3,W4~W10 继续等待。

等待链表:
  初始:  [W1, W2, W3, W4, W5, W6, W7, W8, W9, W10]
  之后:  [W4, W5, W6, W7, W8, W9, W10]
         ↑ W1 W2 W3 都已拿到任务

8.1.5 Worker 完成任务后回到队列 ​

Worker 不是一次性消费者,而是循环消费。完成任务后重新调用 BRPOP:

时间 0s:   W1 拿到任务 6
时间 4m:   W1 完成任务 6,回到循环顶部,再次 BRPOP
           W1 重新加入等待链表尾部

等待链表变化:
  0s:  [W1, W2, W3, W4, W5, W6, W7, W8, W9, W10]
  0s 后 LPUSH: W1 拿到任务,移除
       [W2, W3, W4, W5, W6, W7, W8, W9, W10]
  4m:  W1 回来,加到尾部
       [W2, W3, W4, W5, W6, W7, W8, W9, W10, W1]
                         ↑ W1 排到最后面

公平性保证:先完成先回来,但排在队尾。不会出现某个 Worker 一直抢任务而其他 Worker 饥饿。

如果任务持续到达:
  轮次1: W1 W2 W3 W4 W5 W6 W7 W8 W9 W10 各拿一个
  轮次2: 取决于谁先完成
         W1 完成4分钟 → W3 完成5分钟 → W2 完成6分钟
         回来顺序: W1 → W3 → W2
         等待链表: [..., W1, W3, W2]
         下批任务: W1 先拿(先回来排到最前?不是!)

纠正:Worker 回来后排在等待链表尾部,不是头部。所以不会"先回来先拿"。

实际: 任务持续到达时
  W1 完成(4m) → 排到队尾 → W2 完成(6m) → 排到队尾
  → W3 完成(5m) → 排到队尾

  如果任务在 4m~6m 之间到达:
    等待链表: [W4, W5, W6, W7, W8, W9, W10, W1]
                                       ↑ W1 回来排这里
    新任务 → W4 拿到(W4 在链表头部)

8.1.6 任务多于 Worker ​

100 个任务,10 个 Worker:

时间 0s:   10 个任务同时到达
           W1~W10 各拿 1 个
           队列剩余: 90 个任务在列表中

时间 4m:   W1 完成任务 1,BRPOP
           队列中有 90 个任务 → 立即拿到第 11 个
           不需要等待
           
时间 4m:   W3 完成任务 3,BRPOP
           立即拿到第 12 个

此时:
  task:queue: [任务13, 任务14, ..., 任务100]  (还有 88 个)
  等待链表: [](没有 Worker 在等待,所有 Worker 都在处理任务)

当队列中还有积压任务时:
  Worker BRPOP → 列表非空 → 立即弹出 → 不阻塞
  等待链表始终为空(Worker 不会进入等待状态)

8.1.7 Worker 多于任务 ​

10 个 Worker,3 个任务:

时间 0s:   3 个任务到达
           W1 W2 W3 各拿 1 个
           队列: []
           等待链表: [W4, W5, W6, W7, W8, W9, W10]

时间 4m:   W1 完成任务,BRPOP
           队列为空 → W1 加入等待链表尾部
           等待链表: [W4, W5, W6, W7, W8, W9, W10, W1]

时间 6m:   W3 完成任务,BRPOP
           队列为空 → W3 加入等待链表尾部
           等待链表: [W4, W5, W6, W7, W8, W9, W10, W1, W3]

状态: 7 个 Worker 空闲等待,2 个 Worker 处理中
       不浪费 CPU(BRPOP 阻塞不占 CPU)

8.1.8 Worker 动态加入和退出 ​

初始: 5 个 Worker 运行
      等待链表: [W1, W2, W3, W4, W5]

新增 Worker-6(启动新进程):
  W6 执行 BRPOP task:queue 5
  Redis 把 W6 加入等待链表尾部
  等待链表: [W1, W2, W3, W4, W5, W6]

此时有任务到达:
  W1 拿到任务(链表头部优先)

Worker-3 崩溃(Ctrl+C 或 kill):
  Redis 检测到连接断开
  从等待链表移除 W3
  等待链表: [W1, W2, W4, W5, W6]
  不影响其他 Worker

Redis 如何感知 Worker 退出:
  方式1: 客户端 TCP 连接断开 → Redis 事件循环检测到 fd 关闭
  方式2: BRPOP 超时 → 自动从等待链表移除
  方式3: 新 Worker 启动 → 不影响现有链表

8.1.9 单队列 vs 多队列完整时间线对比 ​

左列为单队列场景(3 Worker,6 个任务全部推入 task:queue) 右列为多队列场景(3 Worker,VIP 任务推入 task:queue:vip,普通任务推入 task:queue:normal) 两场景任务数量相同(3+3=6),Worker 数量相同,对比聚焦于队列模型差异

阶段一:启动 + 前 3 个任务到达,Worker 逐个被唤醒

时刻单队列:事件单队列:队列单队列:等待链表多队列:事件多队列:vip / normal 队列多队列:vip / normal 等待链表关键差异
0s3 Worker BRPOP task:queue[][W1,W2,W3]3 Worker BRPOP [vip,normal][] / [][W1,W2,W3] / [W1,W2,W3]多队列每个 Worker 同时注册在两个等待链表上
0.5sLPUSH 6 → W1 拿到[][W2,W3]LPUSH vip 101 → W1 拿到[] / [][W2,W3] / [W2,W3]唤醒 W1 后,多队列从两个链表同时移除 W1
0.6sLPUSH 7 → W2 拿到[][W3]LPUSH normal 201 → W2 拿到[] / [][W3] / [W3]任务来源不同,但唤醒机制相同
0.7sLPUSH 8 → W3 拿到[][]LPUSH normal 202 → W3 拿到[] / [][] / []所有 Worker 都在忙,等待链表清空

此阶段两种模型行为几乎一致:任务到达 → Worker 被唤醒。差异仅在于多队列的 Worker 注册在两个链表上。

阶段二:更多任务到达,队列积压

时刻单队列:事件单队列:队列单队列:等待链表多队列:事件多队列:vip / normal 队列多队列:vip / normal 等待链表关键差异
0.8sLPUSH 9[9][]————
0.9sLPUSH 10[10,9][]————
1.0sLPUSH 11[11,10,9][]LPUSH vip 102 + LPUSH normal 203[102] / [203][] / []单队列 3 个任务混在一起;多队列按优先级分两个队列
4.0s———LPUSH vip 102(提前到达)[102] / [][] / []多队列任务到达时间分散
5.0s———LPUSH normal 203[102] / [203][] / []两个队列各自积压

核心差异:单队列所有任务在同一个列表中排队(FIFO);多队列任务按类型分到不同队列,为后续优先级消费埋下伏笔。

阶段三:Worker 完成,消费积压任务

时刻单队列:事件单队列:队列单队列:等待链表多队列:事件多队列:vip / normal 队列多队列:vip / normal 等待链表关键差异
4.5sW1 完成任务6,BRPOP → 队列非空,弹出 "9"[11,10][]W1 完成 VIP 101,BRPOP → 检查 vip 有 102 → 弹出 "102"[] / [203][] / []单队列按 FIFO 弹出最早任务;多队列先查 vip,弹出 VIP 任务
5.6sW2 完成任务7,BRPOP → 弹出 "10"[11][]W2 完成普通 201,BRPOP → vip 空,检查 normal → 弹出 "203"[] / [][] / []单队列继续 FIFO;多队列 vip 空了才消费 normal
6.7sW3 完成任务8,BRPOP → 弹出 "11"[][]———单队列积压清空

核心差异:积压消费顺序不同。

  • 单队列:9 → 10 → 11(纯 FIFO)
  • 多队列:102(VIP)→ 203(普通),VIP 永远先于普通被消费

阶段四:积压清空,Worker 回到等待

时刻单队列:事件单队列:队列单队列:等待链表多队列:事件多队列:vip / normal 队列多队列:vip / normal 等待链表关键差异
7.0s———LPUSH vip 103 → 无 Worker 在等,留在队列[103] / [][] / []多队列新任务到达但无空闲 Worker
9.5s———W1 完成 102,BRPOP → 检查 vip 有 103 → 弹出[] / [][] / []多队列 Worker 完成后仍优先查 vip
10.5sW1 完成任务9,BRPOP → 队列为空,加入等待[][W1]W3 完成 202,BRPOP → 都空,加入等待[] / [][W3] / [W3]单队列加入 1 个链表;多队列加入 2 个链表
11.6sW2 完成任务10,BRPOP → 加入等待[][W1,W2]W2 完成 203,BRPOP → 都空,加入等待[] / [][W3,W2] / [W3,W2]回到等待时,多队列 Worker 排在两个链表的尾部
12.7sW3 完成任务11,BRPOP → 加入等待[][W1,W2,W3]W1 完成 103,BRPOP → 都空,加入等待[] / [][W3,W2,W1] / [W3,W2,W1]回到初始状态

核心差异:回到等待时,单队列 Worker 加入一个链表;多队列 Worker 同时加入两个链表,等待任意一个队列有新元素。

差异总结

对比维度单队列 + 多 Worker多队列 + 多 Worker
Worker 注册1 个等待链表2 个等待链表(同时等 vip 和 normal)
唤醒后移除从 1 个链表移除从 2 个链表同时移除
积压消费顺序纯 FIFO(9→10→11)优先级(VIP 102 → 普通 203)
BRPOP 检查逻辑直接弹出右端元素先查 vip,空了再查 normal
回到等待时加入 1 个链表加入 2 个链表
公平性所有任务平等排队VIP 任务优先消费,普通可能饥饿
复杂度简单,1 个队列 1 个链表复杂,2 个队列 2 个链表,需处理饥饿

8.2 多队列 + 多 Worker ​

8.2.1 基本模型 ​

3 个 Worker 监听 2 个队列(VIP 优先 + 普通队列):

                        task:queue:vip          task:queue:normal
                        ┌────────────┐          ┌────────────────┐
Worker-1  BRPOP ───────►│            │◄───────►│                │
Worker-2  BRPOP ───────►│   (空)     │◄───────►│    (空)        │
Worker-3  BRPOP ───────►│            │◄───────►│                │
                        └────────────┘          └────────────────┘

每个 Worker 的 BRPOP 命令:
  BRPOP task:queue:vip task:queue:normal 5
  ↑ 先查 VIP,VIP 为空才查 normal

Redis 内部等待链表(每个 key 独立):
  task:queue:vip    → [W1, W2, W3]
  task:queue:normal → [W1, W2, W3]
                       ↑ 同一个 Worker 同时等在两个 key 上

注意:每个 Worker 的 BRPOP 命令同时注册在两个 key 的等待链表上。当任意一个 key 有新元素时,Worker 被唤醒,同时从两个链表移除。

8.2.2 VIP 任务优先到达 ​

时间 0s:  所有队列空,W1 W2 W3 都在等待

时间 1s:  LPUSH task:queue:vip 101

Redis 执行:
  1. vip 列表变为 [101]
  2. 检查 vip 等待链表 → W1 是第一个
  3. 弹出 "101" → 发给 W1
  4. 从 vip 等待链表移除 W1
  5. 从 normal 等待链表也移除 W1(因为 W1 是多 key BRPOP)

结果:
  task:queue:vip:     []
  task:queue:normal:  []
  vip 等待链表:       [W2, W3]
  normal 等待链表:    [W2, W3]
  Worker 状态:
    W1: 处理 VIP 任务 101
    W2: 等待
    W3: 等待

8.2.3 VIP 和普通任务混合到达 ​

时间 0s:   等待链表: vip=[W1,W2,W3]  normal=[W1,W2,W3]

时间 1s:   LPUSH vip 101
           → W1 拿到 101
           等待链表: vip=[W2,W3]  normal=[W2,W3]

时间 2s:   LPUSH normal 201
           → W2 拿到 201(W1 已不在链表)
           等待链表: vip=[W3]  normal=[W3]

时间 3s:   LPUSH vip 102
           → W3 拿到 102
           等待链表: vip=[]  normal=[]

时间 4s:   LPUSH normal 202
           没有等待的 Worker
           "202" 留在 normal 列表中
           等待链表: vip=[]  normal=[]

Worker 状态:
  W1: 处理 VIP 101(4 分钟后完成)
  W2: 处理普通 201(4 分钟后完成)
  W3: 处理 VIP 102(4 分钟后完成)
  normal 队列积压: [202]

8.2.4 Worker 完成后回到多队列 ​

时间 5s:   W1 完成 VIP 101,BRPOP [vip, normal] 5

Redis 执行 BRPOP:
  1. 检查 vip 队列 → 空
  2. 检查 normal 队列 → 有 "202"
  3. 弹出 "202" → 发给 W1
  4. 返回 ("task:queue:normal", "202")

W1 拿到普通任务 202

时间 6s:   W2 完成普通 201,BRPOP [vip, normal] 5

Redis 执行 BRPOP:
  1. 检查 vip 队列 → 空
  2. 检查 normal 队列 → 空
  3. 都空 → W2 加入等待链表
  等待链表: vip=[W2]  normal=[W2]

时间 7s:   W3 完成 VIP 102,BRPOP [vip, normal] 5

Redis 执行 BRPOP:
  1. 检查 vip → 空
  2. 检查 normal → 空
  3. 都空 → W3 加入等待链表
  等待链表: vip=[W2, W3]  normal=[W2, W3]

8.2.5 优先级关键场景:VIP 和普通同时积压 ​

初始状态:
  vip 队列: [103, 102, 101]      ← 3 个 VIP 任务积压
  normal 队列: [206, 205, 204, 203, 202, 201]  ← 6 个普通任务积压
  等待链表: []                   ← 没有 Worker 在等(任务积压说明 Worker 都在忙)

Worker-1 完成当前任务,执行 BRPOP [vip, normal]:
  1. 检查 vip → 有元素
  2. 弹出 "101"(右端,最早推入的)
  3. 返回 ("task:queue:vip", "101")
  4. 不会检查 normal 队列

Worker-2 完成,执行 BRPOP [vip, normal]:
  1. 检查 vip → 还有元素
  2. 弹出 "102"
  3. 返回 ("task:queue:vip", "102")

Worker-3 完成,执行 BRPOP [vip, normal]:
  1. 检查 vip → 还有元素
  2. 弹出 "103"
  3. 返回 ("task:queue:vip", "103")

Worker-1 再次完成,执行 BRPOP [vip, normal]:
  1. 检查 vip → 空
  2. 检查 normal → 有元素
  3. 弹出 "201"
  4. 返回 ("task:queue:normal", "201")

消费顺序: 101 → 102 → 103 → 201 → 202 → 203 → 204 → 205 → 206
          ↑─────────↑   ↑─────────────────────────────────────↑
          VIP 先消费完    然后才消费普通

8.2.6 VIP 饥饿问题 ​

场景: VIP 任务持续到达,普通任务永远不被消费

时间 0s:   vip=[101]  normal=[201]
           W1 拿到 101 (VIP)

时间 4s:   W1 完成,又来了新的 VIP
           vip=[102]  normal=[201]
           W1 检查 vip → 有 102 → 拿到 102 (VIP)

时间 8s:   W1 完成,又来了新的 VIP
           vip=[103]  normal=[201]  ← 201 等了 8 秒还没被消费
           W1 拿到 103 (VIP)

时间 100s: vip 持续有任务
           normal=[201]  ← 201 等了 100 秒,饥饿!

解决方案:计数器强制消费普通队列

python
def process_queue() -> None:
    vip_count = 0  # 连续消费 VIP 的计数

    while True:
        if vip_count >= 3:
            # 连续消费 3 个 VIP 后,强制消费 1 个普通
            result = redis_client.brpop("task:queue:normal", timeout=1)
            vip_count = 0
        else:
            result = redis_client.brpop(
                ["task:queue:vip", "task:queue:normal"],
                timeout=5
            )

        if result:
            queue_name, task_id = result
            if queue_name == "task:queue:vip":
                vip_count += 1
            else:
                vip_count = 0  # 消费了普通任务,重置计数
            run_task(int(task_id))
消费顺序(VIP 持续到达时):
  VIP 101 → VIP 102 → VIP 103 → 普通 201 → VIP 104 → VIP 105 → VIP 106 → 普通 202 → ...
  ↑─── 3个VIP ───↑  ↑强制普通↑  ↑───── 3个VIP ─────↑  ↑强制普通↑

8.2.7 不同 Worker 监听不同队列 ​

某些场景下,不同 Worker 可以监听不同队列(比如 Worker-1 专门处理 VIP):

Worker-1: BRPOP task:queue:vip 5         ← 只听 VIP
Worker-2: BRPOP task:queue:normal 5      ← 只听普通
Worker-3: BRPOP [vip, normal] 5          ← 都听(灵活调度)

Redis 等待链表:
  task:queue:vip    → [W1, W3]
  task:queue:normal → [W2, W3]

LPUSH vip 101:
  vip 等待链表: [W1, W3] → W1 拿到 101
  等待链表变为: vip=[W3]  normal=[W2, W3]

LPUSH normal 201:
  normal 等待链表: [W2, W3] → W2 拿到 201
  等待链表变为: vip=[W3]  normal=[W3]

LPUSH vip 102:
  vip 等待链表: [W3] → W3 拿到 102
  等待链表变为: vip=[]  normal=[](W3 从两个链表都移除)

8.2.8 完整时间线对比 ​

多队列场景的完整时间线已与单队列场景合并为对比表格,参见 8.1.9 单队列 vs 多队列完整时间线对比。

8.2.9 多队列多 Worker 设计要点总结 ​

要点说明
等待链表按 key 独立每个 key 有自己的等待链表,互不影响
Worker 多 key 注册一个 BRPOP [k1,k2] 让 Worker 同时注册在两个 key 的链表上
唤醒时双移除Worker 被唤醒后从所有 key 的链表中移除
优先级是检查顺序BRPOP [vip,normal] 是"先查 vip 再查 normal",不是"只查 vip"
积压时优先级最明显队列有积压时,BRPOP 先弹 vip 再弹 normal,优先级效果最强
空闲时无优先级所有队列为空时,谁先 LPUSH 谁被消费,优先级无意义
饥饿需主动干预VIP 持续到达时,需要计数器强制消费普通队列
Worker 回到队尾完成任务后重新 BRPOP,排在等待链表尾部,不会抢占