主题
第三章:单线程模型与原子性
本章导读
本章围绕“单线程模型与原子性”展开,先交代适用场景,再说明核心原理,随后给出典型示例,最后补充小结与延伸阅读。
阅读顺序: 结论 → 原理 → 场景 → 代码 → 小结
Q8:Redis 是单线程的,为什么还能支撑 10 万 QPS?单线程不会成为瓶颈吗?
答:
单线程不会成为瓶颈,原因有四个:
| 因素 | 说明 |
|---|---|
| 纯内存操作 | 内存读写纳秒级,不需要磁盘 I/O |
| 无锁竞争 | 单线程不需要加锁、解锁、等待锁 |
| 无上下文切换 | 线程切换成本约 5-10 微秒,Redis 完全避免 |
| I/O 多路复用 | epoll 让单线程管理万级连接,不阻塞 |
单命令耗时通常在 100 纳秒以内:
BRPOP 弹出: ~100ns
LPUSH 推入: ~100ns
LLEN 长度: ~50ns
唤醒等待客户端: ~200ns即使 10 万 QPS,CPU 利用率也不到 50%。
在本项目中:瓶颈永远在 Worker 处理速度(Seedance API 调用 4 分钟/任务),而非 Redis 队列操作(100 纳秒/操作),差距 7 个数量级。
Q9:10 个 Worker 同时 BRPOP task:queue 5,这时有人 LPUSH task:queue 6,为什么不会有两个 Worker 同时拿到 "6"?
答:
Redis 通过四层保证确保只有一个 Worker 能拿到元素:
第一层:单线程命令执行
Redis 命令执行引擎只有一个线程。不管多少个客户端同时发命令,它们在主线程中排队执行,不存在两条命令"同时执行"的窗口。
第二层:命令原子性
每条 Redis 命令是原子操作。LPUSH 的"插入元素 → 检查等待链表 → 唤醒客户端"是一个不可被打断的原子步骤。
第三层:等待链表 FIFO
多个 Worker 等待时,Redis 会按 BRPOP 真正到达服务端的先后顺序,把对应客户端连接挂到 task:queue 的等待链表中。这里的多个 Worker 隐含的是多个独立线程或多个独立进程,它们各自通过一条 TCP 连接向 Redis 发起 BRPOP。
假设 10 个 Worker 依次进入阻塞等待,等待链表不是一个无序集合,而是一条有队头和队尾的 FIFO 队列。下面这张图可以更直观地看出等待链和唤醒顺序:
注:原「Redis List 阻塞等待链表 FIFO 结构」手绘图缺失,见下方「图解说明」表格。
图解说明:
| 图中元素 | 含义 |
|---|---|
| list 头部(左) | 队头方向,LPUSH 从这里推入元素,LPOP 从这里弹出;也是最早等待的 Worker 所在位置 |
| list 尾部(右) | 队尾方向,RPUSH 从这里推入元素,RPOP 从这里弹出;最后到达的 Worker 排在这一端 |
| W1(黄色框) | 最早进入 BRPOP 等待的 Worker,位于队头,LPUSH 到达时第一个被唤醒 |
| W2 / W3(米色框) | 随后依次进入等待的 Worker,排在 W1 后面,只有 W1 出队后才轮到 W2 |
| W4(绿色框) | 最后进入等待的 Worker,位于队尾,需要等前面所有 Worker 都被唤醒后才会轮到它 |
| 实线箭头 → | 等待链的方向,从队头到队尾依次排列 |
| 虚线弧形箭头 | 一次完整的唤醒流程:LPUSH 到达 → 只唤醒队头 W1 → W1 出队 → 队头变为 W2 |
| LPUSH / LPOP | 左侧操作,对应队头推入和弹出 |
| RPUSH / RPOP | 右侧操作,对应队尾推入和弹出 |
简单来说,这张图要表达三件事:
- 等待链是有序的:W1 最先到,排在队头;W4 最后到,排在队尾。
- 唤醒只取队头:
LPUSH推入元素后,Redis 只会唤醒队头的 W1,不会同时通知 W2、W3、W4。 - 出队后队列前移:W1 拿到元素离开等待链后,W2 自动成为新的队头,等待下一次
LPUSH唤醒。
当有人执行 LPUSH task:queue 6 时,Redis 只会从队头取出最早等待的 W1,并把元素 6 交给 W1。
因此,6 只会交给一个等待者;后面的 W2、W3 不会同时拿到同一个元素,它们必须等待后续新的元素进入队列。
LPUSH 唤醒时只处理队头第一个等待客户端(break),不批量唤醒:
c
// Redis 源码 handleClientsBlockedOnKeys(简化)
while (clientNode) {
client *receiver = clientNode->value;
robj *value = listTypePop(o, LIST_TAIL);
if (value) {
addReply(receiver, value); // 发给这个客户端
listDelNode(clients, clientNode); // 从等待链表移除
break; // ← 只唤醒一个!
}
clientNode = listNextNode(clientNode);
}第四层:事件循环串行
即使 LPUSH 6 7 8 一次推入 3 个元素,也是一个一个唤醒:
第1轮: 弹出 "6" → 发给 W1 → break
第2轮: 弹出 "7" → 发给 W2 → break
第3轮: 弹出 "8" → 发给 W3 → breakQ10:LPUSH 唤醒 BRPOP 时,数据是先存入列表再弹出,还是直接传递?
答:
直接传递,数据不经过列表中转。
LPUSH task:queue 6 到达 Redis:
Redis 主线程执行 LPUSH:
1. 在列表头部插入 "6" → 列表变为 [6]
2. 检查等待链表 → 发现 W1 在等
3. 直接把 "6" 发给 W1(通过 fd 写回)
4. 从列表删除 "6" → 列表变为 []
5. 从等待链表移除 W1
6. LPUSH 命令完成
整个过程耗时: ~200 纳秒步骤 2-5 全在 LPUSH 的一条命令执行中完成,是原子的。数据从 LPUSH 方直接传递给 BRPOP 方,O(1) 时间完成。
注意:如果等待链表为空(没有 Worker 在等),"6" 会留在列表中,等下一个 BRPOP 来弹出。
Q10.1:BRPOP 的"推送"本质是什么?是延迟响应还是真正的推送?
答:
BRPOP 的唤醒机制本质是延迟响应(Delayed Response),不是 TCP 层面的全双工推送,也不是客户端主动拉取。
1. 三种模型的严格区分
| 模型 | 是否符合 BRPOP | 说明 |
|---|---|---|
| 纯拉(客户端轮询) | ❌ 否 | 客户端没有反复发请求查询队列状态 |
| 纯推(服务端主动连客户端) | ❌ 否 | 客户端必须先发 BRPOP 请求,服务端才会响应 |
| 延迟响应 | ✅ 是 | 客户端发一次请求,服务端延迟到条件满足才响应 |
2. 延迟响应的完整流程
普通命令(HSET)—— 立即响应:
Client ──HSET──► Redis
Client ◄──OK──── Redis ← 立即响应(~0.1ms)
BRPOP(延迟响应):
Client ──BRPOP─► Redis
Redis: 队列空 → 加入等待链表 → 不回复(命令挂起)
Redis: 主线程继续处理其他客户端命令
(等待中... 可能几秒)
Redis: 收到 LPUSH → 弹出元素 → addReply(Client, 元素)
Client ◄──元素── Redis ← 延迟响应(可能几秒后才回复)3. 延迟响应的三个关键特征
| 特征 | 说明 |
|---|---|
| 命令发送 | 客户端主动发送 BRPOP(一次请求) |
| 响应时机 | 由服务端决定(队列有元素或超时时才响应) |
| 等待期间 | 客户端 socket 阻塞在 read_response(),服务端主线程不阻塞 |
4. "推送"说法的精确含义
LPUSH 唤醒 BRPOP 时,Redis 服务端通过 addReply() 把结果写入等待客户端的输出缓冲区——这一步看起来像"推送",但本质是延迟回复之前挂起的 BRPOP 请求:
c
// Redis 源码 t_list.c(简化)
void handleClientsBlockedOnKeys(void) {
// LPUSH 执行后,主线程事件循环处理 ready_keys
while (listLength(server.ready_keys) > 0) {
// 查找等待这个 key 的客户端链表
list *clients = dictGetVal(de);
// 遍历等待的客户端
client *receiver = listNode->value;
// 弹出列表元素
robj *value = listTypePop(o, LIST_TAIL);
if (value) {
// ★ 关键:把结果写入等待客户端的输出缓冲区
addReplyArrayLen(receiver, 2);
addReplyBulk(receiver, key); // 队列名
addReplyBulk(receiver, value); // 弹出的元素
// ↑ 这就是"延迟响应"——回复之前挂起的 BRPOP 请求
}
}
}5. 与全双工推送的对比
| 对比项 | BRPOP 延迟响应 | TCP 全双工推送 |
|---|---|---|
| 谁先发起 | 客户端先发 BRPOP | 服务端可主动发 |
| 响应触发 | 服务端条件满足时回复 | 服务端任意时刻推送 |
| TCP 模型 | 单工请求-响应(延迟版本) | 双工独立通道 |
| 一次请求响应次数 | 一次请求对应一次响应 | 一次请求可触发多次推送 |
6. 为什么说是"服务端主动推送"
虽然本质是延迟响应,但从客户端视角看:
- 客户端发完 BRPOP 后就阻塞在
read_response() - 没有再发任何请求
- 数据"自动"到达了 socket
所以从客户端视角称为"推送"也没错——但严格来说这是延迟响应,因为客户端必须先发 BRPOP 请求,服务端才能"推"。
7. 总结
| 说法 | 是否准确 |
|---|---|
| "BRPOP 是客户端轮询拉取" | ❌ 错误(客户端没有反复发请求) |
| "BRPOP 是服务端主动推送" | ⚠️ 不严谨(本质是延迟响应,但客户端视角像推送) |
| "BRPOP 是延迟响应" | ✅ 准确(一次请求,服务端延迟回复) |
| "LPUSH 触发 BRPOP 的回复" | ✅ 准确(LPUSH 执行时调用 addReply 写入客户端缓冲区) |
| "BRPOP 期间 TCP 是全双工" | ❌ 错误(TCP 是全双工,但 BRPOP 是单工请求-响应) |
Q11:假设 Worker-1 和 Worker-2 几乎同时发送 BRPOP,API 也几乎同时发送 LPUSH,三种命令到达 Redis 的顺序不同,结果会怎样?
答:
不管命令到达顺序如何,只有一个 Worker 能拿到元素。推演三种情况:
情况 A:BRPOP(W1) → BRPOP(W2) → LPUSH
BRPOP(W1) 执行:列表空 → W1 加入等待链表
BRPOP(W2) 执行:列表空 → W2 加入等待链表(排在 W1 后面)
LPUSH 执行:
1. 插入 "6"
2. 检查等待链表 → W1 是第一个
3. "6" 发给 W1
4. 删除 "6"
5. 移除 W1
结果:W1 拿到 "6",W2 继续等待 ✅情况 B:LPUSH → BRPOP(W1) → BRPOP(W2)
LPUSH 执行:
1. 插入 "6"
2. 等待链表为空,没人等待
3. "6" 留在列表中
BRPOP(W1) 执行:列表非空 → 直接弹出 "6",返回给 W1
BRPOP(W2) 执行:列表空 → W2 加入等待链表
结果:W1 拿到 "6",W2 继续等待 ✅情况 C:BRPOP(W1) → LPUSH → BRPOP(W2)
BRPOP(W1) 执行:列表空 → W1 加入等待链表
LPUSH 执行:
1. 插入 "6"
2. 检查等待链表 → W1 在等
3. "6" 发给 W1
4. 删除 "6"
5. 移除 W1
BRPOP(W2) 执行:列表空 → W2 加入等待链表
结果:W1 拿到 "6",W2 继续等待 ✅根本原因:Redis 单线程串行执行,LPUSH 和 BRPOP 不可能"同时"执行,两条命令之间不存在"间隙"让数据不一致。