主题
Redis 如何保证只有一个 Worker 能拿到元素
从单线程模型到 BRPOP 等待链表,逐层拆解 Redis 的原子性保证 日期:2026-08-26
一、问题的本质
10 个 Worker 同时执行:
python
Worker-1: BRPOP task:queue 5
Worker-2: BRPOP task:queue 5
Worker-3: BRPOP task:queue 5
...
Worker-10: BRPOP task:queue 5此时有人推入一个任务:
python
LPUSH task:queue 6问题:为什么不会出现两个 Worker 同时拿到 "6"?
答案核心:Redis 是单线程串行执行命令的。不管多少个客户端同时发命令,Redis 内部把它们排成一队,一条一条执行,不存在两条命令"同时执行"的窗口。
二、Redis 单线程模型
2.1 "单线程"到底指什么
Redis 的"单线程"不是说整个进程只有一个线程,而是说命令执行引擎是单线程的。
Redis 进程
├── 主线程(命令执行引擎) ← 所有命令在这里排队执行
├── BIO 线程 1(关闭 fd) ← 后台任务,不碰数据
├── BIO 线程 2(AOF fsync) ← 后台任务,不碰数据
└── BIO 线程 3(lazyfree) ← 后台任务,不碰数据关键:所有读写数据的命令(GET、SET、LPUSH、BRPOP 等)都在主线程中串行执行。后台线程只做 I/O 和清理,不碰数据结构。
2.2 命令执行流程
客户端发送命令
│
▼
┌──────────────┐
│ I/O 多路复用 │ ← epoll/kqueue,监听所有客户端连接
│ (Reactor) │ 单线程,但能同时管理万级连接
└──────┬───────┘
│ 有数据可读
▼
┌──────────────┐
│ 命令解析器 │ ← 解析 RESP 协议,识别命令和参数
└──────┬───────┘
│
▼
┌──────────────┐
│ 命令执行器 │ ← 单线程,串行执行,操作内存数据结构
│ (核心) │ 一条命令执行完才执行下一条
└──────┬───────┘
│
▼
┌──────────────┐
│ 命令回复器 │ ← 把结果写回客户端
└──────────────┘核心保证:在"命令执行器"这一层,同一个时刻只有一条命令在执行。不管多少个客户端同时发命令,它们都在这一层排队。
2.3 为什么单线程还很快
| 因素 | 说明 |
|---|---|
| 纯内存操作 | 内存读写纳秒级,不需要磁盘 I/O |
| 无锁竞争 | 单线程不需要加锁、解锁、等待锁 |
| 无上下文切换 | 线程切换成本约 5-10 微秒,Redis 完全避免 |
| 高效数据结构 | 哈希表、跳表、压缩列表,O(1) 或 O(logN) |
| I/O 多路复用 | epoll 让单线程管理万级连接,不阻塞 |
单命令耗时通常在 100 纳秒以内,即使 10 万 QPS,CPU 利用率也不到 50%。
三、LPUSH 和 BRPOP 的原子性
3.1 什么是原子性
原子性(Atomicity):一条命令要么完整执行完,要么完全不执行,中间不会被其他命令插入。
← LPUSH 开始执行 ──→ ← LPUSH 执行结束 ──→
时间轴: ──────────────────────────────────────────────────
← BRPOP 不可能在这个间隙执行 ──→
Redis 保证:LPUSH 执行期间,BRPOP 不可能插入执行3.2 LPUSH 的执行过程
LPUSH task:queue 6
执行步骤:
1. 查找 "task:queue" 对应的列表对象 ← 内存哈希表查找,O(1)
2. 在列表头部插入 "6" ← 链表头插,O(1)
3. 检查是否有客户端在 BRPOP 等待这个 key ← 查等待链表,O(1)
4. 如果有,直接把 "6" 发给第一个等待者 ← 不经过列表中转
5. 从等待链表移除该客户端 ← O(1)
6. 返回 OK 给 LPUSH 的客户端全部步骤在主线程一次执行完成,中间不会被任何其他命令打断。
3.3 BRPOP 的执行过程
BRPOP task:queue 5
执行步骤:
1. 查找 "task:queue" 对应的列表对象
2. 如果列表非空:
a. 从右端弹出最后一个元素 ← O(1)
b. 返回 (key, value) 给客户端
c. 命令结束(不阻塞)
3. 如果列表为空:
a. 把当前客户端加入 "task:queue" 的等待链表
b. 注册一个定时器(5 秒后超时)
c. 标记客户端为"阻塞等待"状态
d. 命令执行"暂停"(不返回,释放 CPU 给下一条命令)
e. 等待被唤醒(LPUSH 触发 或 超时触发)3.4 关键:BRPOP "暂停"后发生了什么
BRPOP 发现队列为空时,不会一直占着主线程等待。它把客户端加入等待链表后,立刻让出 CPU,让 Redis 处理下一条命令。
时间轴:
Worker-1 发送 BRPOP task:queue 5
│
▼
Redis 主线程执行 BRPOP:
1. task:queue 为空
2. 把 Worker-1 加入等待链表
3. 让出 CPU ←━━━━━━━━━━━━━━ 主线程空闲,可以处理其他命令
│
▼
Worker-2 发送 BRPOP task:queue 5
│
▼
Redis 主线程执行 BRPOP:
1. task:queue 为空
2. 把 Worker-2 加入等待链表(排在 Worker-1 后面)
3. 让出 CPU
│
▼
API 发送 LPUSH task:queue 6
│
▼
Redis 主线程执行 LPUSH:
1. 把 "6" 插入 task:queue 列表头部
2. 检查等待链表 → 发现 Worker-1 在等
3. 把 "6" 直接发给 Worker-1
4. 从列表删除 "6"(不留在列表里)
5. 从等待链表移除 Worker-1
6. 返回 OK 给 API
│
▼
Worker-2 发送的下一条命令被处理(Worker-2 还在等待链表里)四、等待链表(Waiting List)
4.1 数据结构
Redis 为每个可能被阻塞的 key 维护一个等待链表,存储等待该 key 的客户端。
key: "task:queue"
waiting_clients: [Client-Worker-1, Client-Worker-2, Client-Worker-3]
(按 BRPOP 调用时间排队,FIFO)这个链表存储在 Redis 的 db->blocking_keys 字典中:
c
// Redis 源码(简化)
typedef struct redisDb {
dict *dict; // 所有键值对
dict *blocking_keys; // 被阻塞等待的 key → 客户端链表
// ...
} redisDb;4.2 等待链表的操作
加入等待(BRPOP 执行时):
BRPOP task:queue 5
1. task:queue 为空
2. 在 db->blocking_keys["task:queue"] 链表尾部追加当前客户端
3. 客户端状态标记为 BLOCKED
4. 注册 5 秒超时定时器
5. 返回控制权给主线程事件循环唤醒(LPUSH 执行时):
LPUSH task:queue 6
1. 在 task:queue 列表头部插入 "6"
2. 检查 db->blocking_keys["task:queue"] 是否非空
3. 取链表第一个客户端(Worker-1)
4. 把 "6" 直接发给 Worker-1
5. 从 task:queue 列表删除 "6"
6. 从等待链表删除 Worker-1
7. 取消 Worker-1 的超时定时器
8. Worker-1 状态改为 UNBLOCKED关键:步骤 1-8 全在 LPUSH 的一条命令执行中完成,是原子的。
4.3 多 Worker 等待链表演示
初始状态:
task:queue: []
waiting_clients: []
Worker-1 调用 BRPOP:
task:queue: []
waiting_clients: [Worker-1]
Worker-2 调用 BRPOP:
task:queue: []
waiting_clients: [Worker-1, Worker-2]
Worker-3 调用 BRPOP:
task:queue: []
waiting_clients: [Worker-1, Worker-2, Worker-3]
API 调用 LPUSH task:queue 6:
┌────────────────────────────────────────────┐
│ LPUSH 原子执行: │
│ │
│ 1. 列表变为 [6] │
│ 2. 检查 waiting_clients → Worker-1 │
│ 3. 把 "6" 发给 Worker-1 │
│ 4. 列表变为 [] │
│ 5. waiting_clients 变为 [Worker-2, Worker-3]│
└────────────────────────────────────────────┘
结果:
Worker-1 收到 "6" ✅
Worker-2 仍在等待
Worker-3 仍在等待
task:queue: [](元素已被 Worker-1 拿走)五、为什么不可能两个 Worker 同时拿到
5.1 场景推演
假设 Worker-1 和 Worker-2 几乎同时发送 BRPOP:
时间 0.000ms: Worker-1 发出 BRPOP task:queue 5
时间 0.001ms: Worker-2 发出 BRPOP task:queue 5
时间 0.002ms: API 发出 LPUSH task:queue 6由于网络延迟,三条命令到达 Redis 的顺序可能不同。但不管什么顺序,Redis 串行执行:
情况 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 继续等待 ✅所有情况都只有一个 Worker 拿到元素。
5.2 根本原因
Redis 保证的核心机制是单线程串行执行:
任意时刻,只有一条命令在执行
↓
LPUSH 和 BRPOP 不可能"同时"执行
↓
LPUSH 执行时检查等待链表是 LPUSH 自己的原子步骤
↓
BRPOP 弹出元素是 BRPOP 自己的原子步骤
↓
两条命令之间不存在"间隙"让数据不一致5.3 与多线程数据库的对比
如果用多线程模型实现同样的队列:
多线程数据库:
Thread-1 执行 LPUSH:
1. 锁住列表
2. 插入 "6"
3. 检查等待队列
4. 发给 Worker-1
5. 解锁列表
Thread-2 同时执行 BRPOP:
1. 等待锁...
2. 获取锁
3. 列表非空(如果 LPUSH 还没删 "6")
4. 弹出 "6" ← 两个线程拿到同一个 "6"!
5. 解锁多线程必须用锁保证安全,但锁有性能开销和死锁风险。Redis 用单线程直接避免了这些问题。
六、Redis 6.0 多线程 I/O 的变化
6.1 Redis 6.0 引入了多线程 I/O
Redis 6.0 可以配置 io-threads,用多线程处理网络读写:
Redis 6.0+ 进程
├── 主线程(命令执行) ← 仍然单线程,串行执行所有命令
├── I/O 线程 1(读 socket) ← 并行读取客户端数据
├── I/O 线程 2(读 socket)
├── I/O 线程 3(写 socket) ← 并行写回客户端数据
└── ...6.2 多线程 I/O 不影响原子性
I/O 线程负责:读取网络数据 → 解析命令 → 放入队列
主线程负责:从队列取命令 → 执行 → 放入回复队列
I/O 线程负责:从回复队列取结果 → 写回网络命令执行仍然在主线程串行进行,多线程只加速了网络 I/O,不改变数据安全保证。
6.3 对 BRPOP 的影响
Worker-1 的 BRPOP 命令到达
→ I/O 线程读取并解析
→ 放入命令队列
→ 主线程执行 BRPOP(加入等待链表)
→ 主线程标记 "需要延迟回复"
→ 主线程继续处理下一条命令
API 的 LPUSH 到达
→ I/O 线程读取并解析
→ 放入命令队列
→ 主线程执行 LPUSH
→ LPUSH 内部检查等待链表,发现 Worker-1
→ 把 "6" 放入 Worker-1 的回复缓冲区
→ I/O 线程把回复写回 Worker-1整个唤醒过程仍在主线程的 LPUSH 命令执行中完成,多线程 I/O 只是加速了网络传输。
七、LPUSH 唤醒 BRPOP 的源码级流程
7.1 LPUSH 命令的执行
c
// Redis 源码 t_list.c(简化)
void lpushCommand(client *c) {
// 1. 查找或创建列表
robj *o = lookupCreateKey(c->db, c->argv[1]);
// 2. 插入元素到列表头部
listTypePush(o, c->argv[2], LIST_HEAD);
// 3. 关键:检查是否有 BRPOP 等待者
signalKeyAsReady(c->db, c->argv[1]);
}7.2 signalKeyAsReady 的作用
c
void signalKeyAsReady(redisDb *db, robj *key) {
// 检查是否有客户端在等待这个 key
if (dictFind(db->blocking_keys, key)) {
// 如果有,把这个 key 加入 "ready_keys" 队列
// 主线程会在当前命令执行完后,处理 ready_keys
readyList *rl = zmalloc(sizeof(readyList));
rl->db = db;
rl->key = key;
listAddNodeTail(server.ready_keys, rl);
}
}7.3 唤醒等待客户端
c
// 在命令执行完成后,主线程检查 ready_keys
void handleClientsBlockedOnKeys(void) {
while (listLength(server.ready_keys) > 0) {
// 取出一个 ready key
listNode *ln = listFirst(server.ready_keys);
readyList *rl = ln->value;
// 查找等待这个 key 的客户端链表
dictEntry *de = dictFind(rl->db->blocking_keys, rl->key);
list *clients = dictGetVal(de);
// 遍历等待的客户端
listNode *clientNode = listFirst(clients);
while (clientNode) {
client *receiver = clientNode->value;
// 如果 key 中有数据
robj *value = listTypePop(o, LIST_TAIL);
if (value) {
// 把值发给这个客户端
addReplyArrayLen(receiver, 2);
addReplyBulk(receiver, key);
addReplyBulk(receiver, value);
// 从等待链表移除
listDelNode(clients, clientNode);
// 从超时链表移除
// 标记为 UNBLOCKED
break; // 只唤醒一个客户端(关键!)
}
clientNode = listNextNode(clientNode);
}
}
}7.4 关键代码:break
c
// 唤醒逻辑
while (clientNode) {
client *receiver = clientNode->value;
robj *value = listTypePop(o, LIST_TAIL);
if (value) {
// 发给 receiver
addReply(receiver, value);
listDelNode(clients, clientNode);
break; // ← 只弹出一个值,只唤醒一个客户端,然后跳出循环
}
clientNode = listNextNode(clientNode);
}break 是核心:弹出一个元素、发给一个客户端后立即退出循环。即使等待链表中有 10 个 Worker,也只唤醒第一个。
如果 LPUSH 推入了 3 个元素,需要调用 3 次 LPUSH(或一次 LPUSH 批量推入后循环处理),每次只唤醒一个等待者。
7.5 完整时序图
Worker-1 Worker-2 Worker-3 API Redis 主线程
│ │ │ │ │
│ BRPOP ────┤ │ │ │
│ │ │ │ 执行 BRPOP │
│ │ │ │ 列表空 │
│ │ │ │ W1→等待链表 │
│ 等待... │ │ │ │
│ │ BRPOP ─────┤ │ │
│ │ │ │ 执行 BRPOP │
│ │ │ │ 列表空 │
│ │ │ │ W2→等待链表 │
│ │ 等待... │ │ │
│ │ │ │ LPUSH "6" ─────┤
│ │ │ │ │
│ │ │ │ ┌─执行 LPUSH─┐│
│ │ │ │ │ 1.插入"6" ││
│ │ │ │ │ 2.检查链表 ││
│ │ │ │ │ 3.W1在等 ││
│ │ │ │ │ 4.弹出"6" ││
│ │ │ │ │ 5.发给W1 ││
│ │ │ │ │ 6.移除W1 ││
│ │ │ │ │ 7.break ││
│ │ │ │ └────────────┘│
│ ← "6" ────┤ │ │ │
│ │ │ │ "6" 已被W1取走│
│ │ 仍在等待 │ │ │
│ │ │ │ │八、超时时的原子性保证
8.1 超时触发
当 BRPOP 的 timeout 到期时,Redis 会把客户端从等待链表移除,并返回 nil。
c
// 超时处理
int blockingClientTimeout(client *c) {
// 从等待链表移除
unblockClient(c);
// 返回 nil
addReplyNull(c);
}8.2 超时与 LPUSH 的竞争
如果在超时发生的同一瞬间,有人 LPUSH 了新元素:
情况 A:LPUSH 先执行
1. LPUSH 执行,发现 W1 在等待链表
2. 把元素发给 W1
3. 移除 W1
4. W1 超时定时器还在,但 W1 已不在等待链表
5. 超时触发时发现 W1 不在链表 → 跳过
结果:W1 拿到元素 ✅
情况 B:超时先执行
1. 超时触发,W1 从等待链表移除
2. 返回 nil 给 W1
3. LPUSH 执行,发现等待链表空(或只有 W2)
4. 元素留在列表中
结果:W1 收到 nil,元素留给下一个 BRPOP ✅两种情况都不会出现"W1 既收到 nil 又拿到元素"或"元素丢失"。
8.3 超时移除也是原子的
超时检查在主线程的事件循环中执行,与命令执行一样是串行的:
主线程事件循环:
处理命令1(LPUSH)
处理命令2(GET)
检查超时定时器 ← 如果有 BRPOP 超时了,在这里处理
处理命令3(SET)超时处理不会被 LPUSH 打断,LPUSH 也不会被超时处理打断。
九、BRPOP vs BLPOP 的方向差异
9.1 推入与弹出方向
LPUSH(左推) RPOP / BRPOP(右弹)
←───────── ─────────→
[4] [3] [2] [1]
BRPOP 取出 1(右端)
[4] [3] [2]
BRPOP 取出 2
[4] [3]RPUSH(右推) LPOP / BLPOP(左弹)
─────────→ ←─────────
[1] [2] [3] [4]
BLPOP 取出 1(左端)9.2 对原子性保证的影响
没有任何影响。不管哪个方向,弹出操作都是原子的。BRPOP 和 BLPOP 都使用同一个等待链表机制,区别只是从列表的哪一端弹出。
| 推入 | 弹出 | 组合 | 效果 |
|---|---|---|---|
| LPUSH | BRPOP | 左推右弹 | FIFO 队列 |
| LPUSH | BLPOP | 左推左弹 | LIFO 栈 |
| RPUSH | BLPOP | 右推左弹 | FIFO 队列 |
| RPUSH | BRPOP | 右推右弹 | LIFO 栈 |
本项目使用 LPUSH + BRPOP = FIFO 队列。
十、特殊场景分析
10.1 LPUSH 批量推入
bash
LPUSH task:queue 6 7 8一次推入 3 个元素。Redis 会按顺序插入,然后检查等待链表。
c
void lpushCommand(client *c) {
for (int j = 2; j < c->argc; j++) {
listTypePush(o, c->argv[j], LIST_HEAD);
}
signalKeyAsReady(c->db, c->argv[1]);
}signalKeyAsReady 只调用一次,但后续的 handleClientsBlockedOnKeys 会循环检查:
LPUSH 6 7 8
→ 列表: [8, 7, 6]
→ signalKeyAsReady
handleClientsBlockedOnKeys:
→ 弹出 "6" → 发给 Worker-1 → break
→ 列表: [8, 7]
→ 下一次事件循环
→ 弹出 "7" → 发给 Worker-2 → break
→ 列表: [8]
→ 下一次事件循环
→ 弹出 "8" → 发给 Worker-3 → break
→ 列表: []3 个元素唤醒 3 个 Worker,每个 Worker 拿到不同的元素。
10.2 多 key BRPOP 的原子性
python
BRPOP task:queue:vip task:queue:normal 5Redis 按顺序检查每个 key:
c
// 简化逻辑
for (int i = 0; i < numkeys; i++) {
robj *o = lookupKey(db, keys[i]);
if (o && listTypeLength(o) > 0) {
// 第一个非空的 key,弹出并返回
robj *value = listTypePop(o, LIST_TAIL);
addReply(c, value);
return; // ← 只处理第一个找到的 key
}
}
// 全部为空,阻塞等待所有 key原子保证:检查所有 key 和弹出操作在一条命令中完成,不可能出现"Worker-1 拿到 vip 队列的元素,Worker-2 同时拿到 normal 队列的元素"——因为它们是串行的。
但多 key 阻塞时,任意一个 key 有新元素都会唤醒:
BRPOP key1 key2 5
等待链表:
key1 → [Worker-1]
key2 → [Worker-1] ← 同一个 Worker 等待两个 key
LPUSH key2 "hello"
→ 检查 key2 的等待链表 → Worker-1 在等
→ 把 "hello" 发给 Worker-1
→ 从 key1 和 key2 的等待链表都移除 Worker-1十一、对比其他消息队列的保证机制
| 队列系统 | 原子性保证 | 机制 |
|---|---|---|
| Redis BRPOP | 单线程串行 | 命令执行器一次只处理一条命令 |
| RabbitMQ | Channel 级别锁 | Channel 串行处理消息投递 |
| Kafka | 分区单消费者 | 同一 partition 同一时刻只被一个消费者消费 |
| Amazon SQS | Visibility Timeout | 消息被取走后进入"不可见"状态,超时后才重新可见 |
| Python asyncio.Queue | 单线程事件循环 | 协程在事件循环中串行执行 |
Redis 的方案最简洁:不需要锁、不需要 visibility timeout、不需要 ACK 机制,单线程天然保证。
代价:如果 Worker 取走任务后崩溃,任务就丢了(没有 ACK 机制)。这需要 RPOPLPUSH 模式来弥补:
python
# 弹出并转入"处理中"队列
task_id = redis_client.rpoplpush("task:queue", "task:processing")
# 处理完成后删除
redis_client.lrem("task:processing", 1, task_id)
# Worker 重启时扫描 task:processing 恢复十二、总结:四层保证
第一层:单线程命令执行
── 所有命令串行执行,不存在并发
── LPUSH 和 BRPOP 不可能同时执行
第二层:命令原子性
── 一条命令内部的多个步骤不可被打断
── LPUSH 的"插入 + 检查等待链表 + 唤醒"是一个原子操作
第三层:等待链表 FIFO
── 多个 Worker 等待时,按 BRPOP 调用顺序排队
── 唤醒时只取链表第一个(break),不批量唤醒
第四层:事件循环串行
── 命令执行、超时检查、唤醒操作都在同一个事件循环中
── 不存在"命令执行了一半被超时打断"的情况这四层叠加,构成了 Redis 对"只有一个 Worker 能拿到元素"的完整保证。