Skip to content

第三章:单线程模型与原子性 ​

本章导读 ​

本章围绕“单线程模型与原子性”展开,先交代适用场景,再说明核心原理,随后给出典型示例,最后补充小结与延伸阅读。

阅读顺序: 结论 → 原理 → 场景 → 代码 → 小结

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右侧操作,对应队尾推入和弹出

简单来说,这张图要表达三件事:

  1. 等待链是有序的:W1 最先到,排在队头;W4 最后到,排在队尾。
  2. 唤醒只取队头:LPUSH 推入元素后,Redis 只会唤醒队头的 W1,不会同时通知 W2、W3、W4。
  3. 出队后队列前移: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 → break

Q10: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 不可能"同时"执行,两条命令之间不存在"间隙"让数据不一致。