Skip to content

第一章:BRPOP 基础语法 ​

本章导读 ​

本章围绕“BRPOP 基础语法”展开,先交代适用场景,再说明核心原理,随后给出典型示例,最后补充小结与延伸阅读。

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

Q1:BRPOP 和 RPOP 的核心区别是什么?为什么本项目用 BRPOP? ​

答:

核心区别在于列表为空时的行为:

命令列表为空时CPU 占用延迟
RPOP立即返回 nil轮询模式持续消耗 CPU取决于 sleep 间隔
BRPOP阻塞等待,直到有元素或超时等待期间零 CPU 占用< 1 毫秒

本项目使用 BRPOP 的主要原因是:Worker 没有任务时不需要反复轮询 Redis,而是挂起等待;一旦有任务进入队列,Redis 会立即唤醒等待中的 Worker,让它继续执行后续任务处理逻辑。

需要注意的是,BRPOP 的“阻塞”要区分客户端侧和 Redis 服务端侧:

  • 对当前调用 BRPOP 的 Worker 线程来说,会阻塞在 brpop() 这一行,后面的代码暂时不会继续执行;
  • 对 Redis 服务端来说,不会阻塞 Redis 主线程,Redis 会把这个客户端连接挂到等待链表中,然后继续处理其他客户端命令;
  • 对其他 Worker 或其他 Redis 客户端来说,不受这个阻塞影响,仍然可以继续执行 HSET、LPUSH、GET 等命令。

也就是说,BRPOP 阻塞的是当前客户端线程的后续流程,不是阻塞整个 Redis 服务。

“阻塞”和“零 CPU 占用”到底是谁、在哪一侧 ​

这里容易把两个现象混在一起,归属关系要分清:

现象发生在哪一侧具体对象
阻塞等待客户端侧当前调用 brpop() 的 Worker 线程
零 CPU 占用客户端侧上面这个被挂起的线程本身(挂起后不在 CPU 上执行任何指令)
不阻塞主线程、继续处理其他命令服务端侧Redis 主线程(此刻仍有 CPU 活动,并非“零占用”)

所以,“阻塞等待”是客户端侧的阻塞;“零 CPU 占用”说的是客户端被挂起的那一个线程,而不是 Redis 服务端。服务端只是把连接挂入 ==等待链表== ,随后继续处理其他客户端命令,服务端 CPU 并没有空闲下来。

RPOP 轮询方式 ​

python
while True:
    result = redis_client.rpop("task:queue")
    if result:
        process(result)
    else:
        time.sleep(0.1)

这种写法的问题是:队列为空时,Worker 仍会不断主动向 Redis 发 RPOP。即使每次查询后都 sleep(0.1),本质上仍然是轮询模式。每一次 RPOP 都是真实发往 Redis 的命令(连接本身仍由连接池复用,并不会每次新建 TCP 连接),只是队列为空时这次查询「取不到数据」,白白消耗了一次网络往返和 Redis 的命令处理开销;同时任务到达后,最多还可能要再等 0.1 秒才被下一轮轮询发现,存在额外的唤醒延迟。

BRPOP 阻塞等待方式 ​

python
def worker_loop():
    while True:
        result = redis_client.brpop("task:queue", timeout=5)

        print("只有 brpop 返回后,才会执行到这里")

        if result is None:
            continue

        queue_name, task_id = result
        process_task(task_id)

上面这段代码中,如果 task:queue 队列为空,程序会停在:

python
result = redis_client.brpop("task:queue", timeout=5)

只有出现以下两种情况之一,代码才会继续往下执行:

  1. 队列中有新任务进入,Redis 唤醒当前客户端并返回任务数据;
  2. 等待时间超过 timeout=5,Redis 返回 None。

所以,在同一个 Worker 线程里,brpop() 后面的 print()、process_task() 等代码,必须等 brpop() 返回后才会执行。

等待超时之后会怎样 ​

BRPOP 的 timeout 触发的超时是正常返回,不会抛异常:等了 5 秒还没任务,Redis 返回 None,代码走到 if result is None: continue,跳回 while True 顶部,发起一次新的 brpop(timeout=5),再等 5 秒,如此反复,直到拿到任务或程序被外部终止。

所以超时后的完整路径是:

  1. 等了 timeout=5 没任务,Redis 返回 None(正常返回值);
  2. result is None 成立,执行 continue;
  3. 回到 while True 顶部,执行一次新的 brpop(timeout=5),继续等待。

需要注意,timeout 引起的超时和「连接超时 / socket 超时」是两回事:

概念含义超时后行为
brpop(timeout=5)队列为空时最多等 5 秒返回 None,不抛异常
连接超时 / socket 超时网络层面的超时抛异常,需要 try/except

真正会抛异常的场景是:连接断开、Redis 不可用、网络错误(ConnectionError 等),或命令参数用错(ResponseError)。而队列空等待超时只是「没等到数据」,redis-py 用返回 None 来表达,属于业务层面的正常分支。

BRPOP 不会阻塞 Redis 服务端主线程 ​

假设有三个独立执行流:Worker A、Worker B、Worker C。它们可以是三个不同进程,也可以是同一进程里的三个不同线程;关键是它们不是同一个顺序执行流,否则 Worker A 阻塞在 brpop() 时,后面的 hset 和 lpush 不会继续执行。

python
# Worker A:独立线程或独立进程
result = redis_client.brpop("task:queue", timeout=5)

# Worker B:另一个独立线程或独立进程
redis_client.hset("task:6:progress", "stage", "IMAGE")

# Worker C:另一个独立线程或独立进程
redis_client.lpush("task:queue", 6)

当 Worker A 执行 BRPOP 且队列为空时:

对象是否被阻塞说明
Worker A 当前线程是卡在 brpop(),后续代码暂时不执行
Worker B否仍然可以正常执行 hset
Worker C否仍然可以正常执行 lpush
Redis 服务端主线程否继续处理其他客户端命令
task:queue 等待链表有变化Worker A 对应的客户端连接被挂入等待链表

当 Worker C 执行:

python
redis_client.lpush("task:queue", 6)

Redis 会在执行 LPUSH 后检查是否有客户端正在等待 task:queue。如果发现 Worker A 正在 BRPOP 等待,就会把任务 6 返回给 Worker A。

此时 Worker A 的代码才会继续执行:

python
queue_name, task_id = result
process_task(task_id)

本章小结 ​

BRPOP 的阻塞是客户端侧的阻塞。它会让当前调用 BRPOP 的线程暂停在这一行,直到有任务或超时返回;但它不会阻塞 Redis 服务端主线程,也不会影响其他客户端继续执行命令。

在任务队列场景中,这正是 BRPOP 的优势:

方式没有任务时CPU 消耗后续代码是否执行适用场景
RPOP 轮询不断主动查询高每次查询后继续循环简单但低效
BRPOP 阻塞等待客户端挂起等待低,接近 0当前线程暂停,返回后继续Worker 消费队列

无论 RPOP 轮询还是 BRPOP,命令都通过 redis-py 的连接池复用同一条 TCP 连接,并不会每发一条命令就新建一次连接;两者的差异在于「队列为空时是否还要反复主动查询」,而不是「是否新建连接」。

所以,“没有任务时挂起,零 CPU;有任务时 < 1ms 唤醒”的准确含义是:

没有任务时,Worker 当前线程会挂起等待,不会反复空转轮询,因此 CPU 消耗接近 0;这里的“零 CPU”指的是客户端被挂起的那个线程本身,而不是 Redis 服务端。一旦有任务入队,Redis 会通过已建立的 TCP 连接把结果返回给等待中的客户端,Worker 被唤醒后继续执行后面的任务处理流程。


Q2:brpop(["task:queue:vip", "task:queue:normal"], timeout=5) 的优先级规则是什么?如果 VIP 队列持续有任务,普通队列会怎样? ​

答:

优先级规则:按 key 的声明顺序从左到右检查。只要左边的队列有元素,就不会弹右边的。

BRPOP [vip, normal] 5

VIP: [101, 102]     Normal: [201, 202]
第1次 BRPOP → 取 102(VIP)
第2次 BRPOP → 取 101(VIP)
第3次 BRPOP → 取 201(Normal)   ← VIP 空了才取 Normal
第4次 BRPOP → 取 202(Normal)

VIP 饥饿问题:如果 VIP 队列持续有任务推入,普通队列的任务可能永远不被消费。

解决方案:计数器策略,每处理 3 个 VIP 任务后强制消费 1 个普通任务:

python
normal_count = 0
while True:
    if normal_count >= 3:
        result = redis_client.brpop(["task:queue:normal"], timeout=1)
        normal_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":
            normal_count += 1
        run_task(int(task_id))

Q3:redis_client.lpush("task:queue", 101) 的返回值是什么?为什么不是 101? ​

答:

返回值是 推入后的列表长度(int 类型),不是你推入的值。

python
result = redis_client.lpush("task:queue", 101)
# 列表内容: [101]     ← 101 被存入列表
# result = 1           ← 返回的是列表长度

result = redis_client.lpush("task:queue", 102)
# 列表内容: [102, 101]
# result = 2           ← 列表现在有 2 个元素

为什么这么设计:你已经知道推入了什么(就是你传的参数),Redis 选择返回更有用的信息——列表当前有多少个元素。

对比其他命令的返回值:

命令返回值含义示例
lpush推入后的列表长度1
brpop弹出的元素值 + 队列名("task:queue", "101")
rpop弹出的元素值"101"
llen列表长度1

Q4:为什么 Worker 取出 task_id 后需要 int(task_id) 转换? ​

答:

因为 Redis 内部统一以字符串存储所有值,不管你推入时传的是 int 还是 str。

python
# API 端存入时是 int
redis_client.lpush("task:queue", task.id)  # task.id = 6 (int)

# Redis 内部存储为字符串 "6"

# Worker 端取出时是字符串
_, task_id = redis_client.brpop("task:queue", timeout=5)
# task_id = "6" (str)

# SQLAlchemy 查询需要 int 主键
task = db.get(Task, int(task_id))  # 必须转回 int

如果不转换,直接用字符串 "6" 查数据库主键,SQLAlchemy 会报类型错误。

decode_responses 参数不影响这个行为——不管返回 b"6" 还是 "6",int() 都能正确转换:

python
int(b"6")   # → 6(bytes 自动解码)
int("6")    # → 6