主题
八、在多 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 8Redis 一次插入 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 等待链表 | 关键差异 |
|---|---|---|---|---|---|---|---|
| 0s | 3 Worker BRPOP task:queue | [] | [W1,W2,W3] | 3 Worker BRPOP [vip,normal] | [] / [] | [W1,W2,W3] / [W1,W2,W3] | 多队列每个 Worker 同时注册在两个等待链表上 |
| 0.5s | LPUSH 6 → W1 拿到 | [] | [W2,W3] | LPUSH vip 101 → W1 拿到 | [] / [] | [W2,W3] / [W2,W3] | 唤醒 W1 后,多队列从两个链表同时移除 W1 |
| 0.6s | LPUSH 7 → W2 拿到 | [] | [W3] | LPUSH normal 201 → W2 拿到 | [] / [] | [W3] / [W3] | 任务来源不同,但唤醒机制相同 |
| 0.7s | LPUSH 8 → W3 拿到 | [] | [] | LPUSH normal 202 → W3 拿到 | [] / [] | [] / [] | 所有 Worker 都在忙,等待链表清空 |
此阶段两种模型行为几乎一致:任务到达 → Worker 被唤醒。差异仅在于多队列的 Worker 注册在两个链表上。
阶段二:更多任务到达,队列积压
| 时刻 | 单队列:事件 | 单队列:队列 | 单队列:等待链表 | 多队列:事件 | 多队列:vip / normal 队列 | 多队列:vip / normal 等待链表 | 关键差异 |
|---|---|---|---|---|---|---|---|
| 0.8s | LPUSH 9 | [9] | [] | — | — | — | — |
| 0.9s | LPUSH 10 | [10,9] | [] | — | — | — | — |
| 1.0s | LPUSH 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.5s | W1 完成任务6,BRPOP → 队列非空,弹出 "9" | [11,10] | [] | W1 完成 VIP 101,BRPOP → 检查 vip 有 102 → 弹出 "102" | [] / [203] | [] / [] | 单队列按 FIFO 弹出最早任务;多队列先查 vip,弹出 VIP 任务 |
| 5.6s | W2 完成任务7,BRPOP → 弹出 "10" | [11] | [] | W2 完成普通 201,BRPOP → vip 空,检查 normal → 弹出 "203" | [] / [] | [] / [] | 单队列继续 FIFO;多队列 vip 空了才消费 normal |
| 6.7s | W3 完成任务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.5s | W1 完成任务9,BRPOP → 队列为空,加入等待 | [] | [W1] | W3 完成 202,BRPOP → 都空,加入等待 | [] / [] | [W3] / [W3] | 单队列加入 1 个链表;多队列加入 2 个链表 |
| 11.6s | W2 完成任务10,BRPOP → 加入等待 | [] | [W1,W2] | W2 完成 203,BRPOP → 都空,加入等待 | [] / [] | [W3,W2] / [W3,W2] | 回到等待时,多队列 Worker 排在两个链表的尾部 |
| 12.7s | W3 完成任务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,排在等待链表尾部,不会抢占 |