Skip to content

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 都使用同一个等待链表机制,区别只是从列表的哪一端弹出。

推入弹出组合效果
LPUSHBRPOP左推右弹FIFO 队列
LPUSHBLPOP左推左弹LIFO 栈
RPUSHBLPOP右推左弹FIFO 队列
RPUSHBRPOP右推右弹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 5

Redis 按顺序检查每个 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单线程串行命令执行器一次只处理一条命令
RabbitMQChannel 级别锁Channel 串行处理消息投递
Kafka分区单消费者同一 partition 同一时刻只被一个消费者消费
Amazon SQSVisibility 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 能拿到元素"的完整保证。