主题
第一章: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)只有出现以下两种情况之一,代码才会继续往下执行:
- 队列中有新任务进入,Redis 唤醒当前客户端并返回任务数据;
- 等待时间超过
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 秒,如此反复,直到拿到任务或程序被外部终止。
所以超时后的完整路径是:
- 等了
timeout=5没任务,Redis 返回None(正常返回值); result is None成立,执行continue;- 回到
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