主题
七、阻塞期间的连接管理
7.1 redis-py 的连接占用
python
result = redis_client.brpop("task:queue", timeout=5)
# ← 这 5 秒内,这个连接被独占BRPOP 期间,该 Redis 连接被独占,不能用来执行其他命令。如果同时需要查 Redis 进度,需要另一个连接:
python
# ❌ 错误:同一个连接
redis_client.brpop("task:queue", timeout=60) # 阻塞 60 秒
redis_client.get("some_key") # 无法执行,连接被占
# ✅ 正确:连接池自动分配不同连接
# redis-py 的 ConnectionPool 会在 get() 时借用另一个连接
# 但如果 pool_size=1 且连接被 BRPOP 占用,get() 会等待get(key) 参数说明
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
key | str | 是 | 要读取的键名 |
返回值:str | bytes | None
| 返回值 | 含义 |
|---|---|
"50%" | 键存在,返回对应的值 |
None | 键不存在 |
用途:在本项目中用于读取 Redis Hash 中的任务进度(task:{id}:progress)。
与 brpop 的区别:get 是非阻塞命令,立即返回,不等待。
set(key, value, ex=None) 参数说明
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
key | str | 是 | 要设置的键名 |
value | str 或 int 或 bytes | 是 | 要存储的值 |
ex | int | 否 | 过期时间(秒)。不传则永不过期。设置后到时间自动删除 |
返回值:True(总是成功,除非 Redis 故障)
示例:
python
# 设置进度,永不过期
redis_client.set("task:6:progress", "50%")
# 设置进度,60 秒后自动过期
redis_client.set("task:6:progress", "50%", ex=60)ex 参数的作用:避免 Redis 中堆积大量过期的进度数据。任务完成后进度键不再需要,设置过期时间让 Redis 自动清理。
redis.Redis(...) 连接参数说明
建议:Worker 的 Redis 连接池 max_connections 至少设为 2(一个给 BRPOP,一个给进度写入)。
python
# redis_client.py
import redis
redis_client = redis.Redis(
host="localhost",
port=6379,
db=0,
decode_responses=True,
max_connections=10,
socket_timeout=30,
socket_connect_timeout=5,
health_check_interval=30,
retry_on_timeout=True,
)| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
host | str | "localhost" | Redis 服务器地址。本项目用 "localhost"(Docker 映射) |
port | int | 6379 | Redis 端口。本项目 Docker 映射为 6380 |
db | int | 0 | Redis 数据库编号(0-15)。本项目用 0 |
decode_responses | bool | False | True 时所有返回值自动 decode("utf-8") 为 str;False 时返回 bytes,需手动 .decode() |
max_connections | int | 2**31 | 连接池上限。超过时抛 ConnectionError: Too many connections |
socket_timeout | int | None | 单次命令的 socket 超时(秒)。超时抛 TimeoutError。推荐 30 秒 |
socket_connect_timeout | int | None | 建立连接的超时(秒)。推荐 5 秒,避免 Redis 不可用时长时间卡住 |
health_check_interval | int | 0 | 健康检查间隔(秒)。30 表示每 30 秒自动发 PING 检测连接是否存活。0 = 不检查 |
retry_on_timeout | bool | False | True 时遇到 TimeoutError 自动重试一次。适合网络不稳定环境 |
decode_responses 对返回值的影响:
python
# decode_responses=False(默认)
result = redis_client.brpop("task:queue", timeout=5)
# result = (b"task:queue", b"6") ← bytes
# 需要 result[1].decode("utf-8") 才能用
result = redis_client.get("task:6:progress")
# result = b"50%" ← bytes
# decode_responses=True
result = redis_client.brpop("task:queue", timeout=5)
# result = ("task:queue", "6") ← str,直接可用
result = redis_client.get("task:6:progress")
# result = "50%" ← str,直接可用socket_timeout 与 brpop 的关系:
brpop(timeout=5) → BRPOP 最多阻塞 5 秒
socket_timeout=30 → socket 层面最多等 30 秒
如果 Redis 宕机:
brpop 的 5 秒超时不会触发(Redis 没有返回)
socket_timeout=30 在 30 秒后抛 TimeoutError
所以 socket_timeout 必须 > brpop 的 timeout,否则 BRPOP 还没超时 socket 就报错了推荐配置:socket_timeout=30,brpop(timeout=5),确保 BRPOP 能正常超时 6 次后才触发 socket 超时。
7.2 超长阻塞的风险
python
# ❌ timeout=0 永久阻塞
result = redis_client.brpop("task:queue", timeout=0)风险:
Ctrl+C无法中断(需要强制 kill)- 如果 Redis 服务重启,客户端可能不感知(连接假死)
- 无法定期检查其他信号(如优雅退出标志)
推荐:始终用 timeout=5(或不超过 30 秒),在 while True 循环里重新调用。
7.3 Worker 进程与 Redis 连接池的对应关系
7.3.1 核心结论
| 层面 | 关系 | 说明 |
|---|---|---|
| 进程级 | 1 个 Worker 进程 = 1 个 redis_client = 1 个 ConnectionPool | 每个 python -m app.worker 是独立进程,各自 import 模块时创建单例 |
| 连接级 | 1 个 ConnectionPool 内部复用多个 TCP 连接 | brpop 阻塞期间独占 1 个连接,其他命令用其他连接 |
| Redis 端 | 1 个 TCP 连接 = 1 个 client = 等待链表中 1 个节点 | Redis 不关心客户端是进程还是线程,只看 TCP 连接 |
7.3.2 多进程部署模型
本项目通过启动多个 python -m app.worker 进程实现多 Worker:
bash
# 启动 3 个独立 Worker 进程
.venv/bin/python -m app.worker & # 进程 A
.venv/bin/python -m app.worker & # 进程 B
.venv/bin/python -m app.worker & # 进程 C每个进程独立 import redis_client 模块,各自创建 redis_client 单例:
python
# app/redis_client.py — 模块级单例
import redis
from app.config import settings
redis_client = redis.Redis(
host=settings.redis_host,
port=settings.redis_port,
db=0,
decode_responses=True,
max_connections=10,
socket_timeout=30,
socket_connect_timeout=5,
health_check_interval=30,
)python
# app/worker.py — Worker 入口
from app.redis_client import redis_client # ← 每个进程各自 import
def process_queue():
while True:
result = redis_client.brpop("task:queue", timeout=5)
if result:
run_task(int(result[1]))3 个进程的内存结构:
进程 A (PID 1001) 进程 B (PID 1002) 进程 C (PID 1003)
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ redis_client │ │ redis_client │ │ redis_client │
│ └─ ConnectionPool │ │ └─ ConnectionPool │ │ └─ ConnectionPool │
│ max=10 │ │ max=10 │ │ max=10 │
│ ┌─────┬─────┬───┐ │ │ ┌─────┬─────┬───┐ │ │ ┌─────┬─────┬───┐ │
│ │C-1 │C-2 │...│ │ │ │C-1 │C-2 │...│ │ │ │C-1 │C-2 │...│ │
│ │BRPOP│空闲 │ │ │ │ │BRPOP│空闲 │ │ │ │ │BRPOP│空闲 │ │ │
│ └─────┴─────┴───┘ │ │ └─────┴─────┴───┘ │ │ └─────┴─────┴───┘ │
└────────┬────────────┘ └────────┬────────────┘ └────────┬────────────┘
│ TCP │ TCP │ TCP
▼ ▼ ▼
127.0.0.1:54321 127.0.0.1:54322 127.0.0.1:54323
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ Redis 服务端 │
│ client(fd=10, cmd=brpop) ← 等待链表节点 1 │
│ client(fd=11, cmd=brpop) ← 等待链表节点 2 │
│ client(fd=12, cmd=brpop) ← 等待链表节点 3 │
│ │
│ task:queue → waiting_clients: [client(fd=10), client(fd=11), client(fd=12)] │
└─────────────────────────────────────────────────────────────────────────────────────────────┘关键:3 个进程各自独立的连接池,互不共享。Redis 端看到 3 个 TCP 连接,等待链表有 3 个节点。
7.3.3 单进程内的连接复用
同一个 Worker 进程内,redis_client 是单例,所有 Redis 调用共用一个连接池:
python
# 同一个 Worker 进程内的不同阶段
def run_task(task_id: int):
# 阶段1:写进度(借 Conn-2)
redis_client.hset(f"task:{task_id}:progress", "stage", "SCRIPT")
# 阶段2:写进度(复用 Conn-2,Conn-1 仍被 BRPOP 独占)
redis_client.hset(f"task:{task_id}:progress", "stage", "IMAGE")
# 阶段3:完成后推入审核队列(复用 Conn-2)
redis_client.lpush("review:queue", task_id)Worker 进程内部:
redis_client (单例)
└── ConnectionPool (max=10)
├── Conn-1 ←── brpop() 阻塞中(从 process_queue 开始就独占)
├── Conn-2 ←── hset / lpush 轮流借用(用完归还)
└── Conn-3~10 ←── 空闲brpop 独占 Conn-1 直到超时或拿到任务。期间 hset、lpush 等命令从池中借 Conn-2,用完归还。不互相阻塞。
7.3.4 连接借还生命周期
redis-py 的 ConnectionPool 采用"借出-归还"模式:
python
# redis-py 内部简化逻辑
class ConnectionPool:
def __init__(self, max_connections):
self._available = [] # 空闲连接栈
self._in_use = set() # 正在使用的连接
self._max = max_connections
def get_connection(self):
if self._available:
conn = self._available.pop()
self._in_use.add(conn)
return conn
elif len(self._in_use) < self._max:
conn = self._make_connection() # 新建 TCP 连接
self._in_use.add(conn)
return conn
else:
raise ConnectionError("Too many connections")
def release(self, conn):
self._in_use.remove(conn)
self._available.append(conn) # 归还到空闲栈brpop 借出不归还(阻塞期间):
python
# redis_client.brpop("task:queue", timeout=5)
# 内部流程:
conn = pool.get_connection() # 借出 Conn-1
conn.send_command("BRPOP", "task:queue", 5)
result = conn.read_response() # 阻塞!最多等 5 秒
# ← 期间 Conn-1 一直在 _in_use 中
pool.release(conn) # 超时或拿到任务后才归还hset 借出后立即归还:
python
# redis_client.hset("task:6:progress", "stage", "IMAGE")
# 内部流程:
conn = pool.get_connection() # 借出 Conn-2
conn.send_command("HSET", "task:6:progress", "stage", "IMAGE")
result = conn.read_response() # 立即返回
pool.release(conn) # 立即归还 Conn-27.3.5 连接数计算
单个 Worker 进程的连接数:
python
# Worker 运行时的连接使用:
redis_client.brpop("task:queue", timeout=5) # Conn-1: 阻塞 5 秒
# Conn-1 超时归还,循环重调
# 任务处理中:
redis_client.hset(...) # Conn-2: 借→用→还
redis_client.hset(...) # Conn-2: 借→用→还(复用)
redis_client.lpush(...) # Conn-2: 借→用→还(复用)
# 同一时刻最多占用 2 个连接:
# - 1 个被 brpop 独占(但 brpop 在任务处理期间不会执行,因为 process_queue 是串行的)
# - 1 个被当前命令临时使用实际同时占用的连接数:
| 阶段 | 占用连接 | 说明 |
|---|---|---|
process_queue 中 BRPOP 阻塞 | 1 个 | 只有 BRPOP 在执行 |
run_task 中写进度 | 1 个 | BRPOP 已归还,HSET 临时借 |
run_task 中推审核队列 | 1 个 | 同上 |
| 理论峰值 | 1-2 个 | BRPOP 和其他命令不会同时执行 |
多 Worker 进程的总连接数:
| Worker 进程数 | 每进程连接峰值 | 总 TCP 连接数 | Redis max_clients |
|---|---|---|---|
| 1 | 2 | 2 | 默认 10000 |
| 3 | 2 | 6 | 足够 |
| 10 | 2 | 20 | 足够 |
| 50 | 2 | 100 | 足够 |
| 200 | 2 | 400 | 足够 |
7.3.6 fork 场景的陷阱
如果用 multiprocessing.Process 的 fork 模式(Unix 默认)启动 Worker:
python
import multiprocessing
from app.redis_client import redis_client # ← 父进程已创建
def worker():
# ← fork 后子进程继承了父进程的 redis_client
# ← 连接池中的 TCP fd 被复制
# ← 两个进程操作同一个 socket → 数据错乱!
redis_client.brpop("task:queue", timeout=5)
p = multiprocessing.Process(target=worker)
p.start() # fork!问题:fork 复制父进程的内存,包括 redis_client 和已建立的 TCP 连接的 fd。两个进程向同一个 socket 写数据会导致 Redis 协议错乱。
正确做法:子进程中重建 redis_client:
python
import multiprocessing
import redis
def worker():
# 子进程中重新创建,不复用父进程的连接池
client = redis.Redis(
host="localhost",
port=6380,
decode_responses=True,
max_connections=10,
)
client.brpop("task:queue", timeout=5)
p = multiprocessing.Process(target=worker)
p.start()本项目不受影响:python -m app.worker 每次都是全新进程(spawn 模式),不走 fork,不存在继承问题。
7.3.7 对比三种部署模式
python
# 模式 1:多进程(本项目使用)
# 每个 python -m app.worker 是独立进程
import redis
redis_client = redis.Redis(
host="localhost", port=6380,
decode_responses=True, max_connections=10,
)
# 进程 A: redis_client_A, Pool_A, TCP_A
# 进程 B: redis_client_B, Pool_B, TCP_B
# 进程 C: redis_client_C, Pool_C, TCP_C
# → 3 个独立连接池,3 个 TCP 连接
# 模式 2:单进程多线程
import threading
def worker():
# 所有线程共享同一个 redis_client 和连接池
redis_client.brpop("task:queue", timeout=5)
# 线程 1: 借 Conn-1
# 线程 2: 借 Conn-2
# 线程 3: 借 Conn-3
# → 1 个连接池,3 个 TCP 连接
t1 = threading.Thread(target=worker)
t2 = threading.Thread(target=worker)
t3 = threading.Thread(target=worker)
# 模式 3:多进程 spawn(推荐)
import multiprocessing
def worker():
import redis
# 子进程内部创建新连接池
client = redis.Redis(host="localhost", port=6380)
client.brpop("task:queue", timeout=5)
if __name__ == "__main__":
for _ in range(3):
p = multiprocessing.Process(target=worker)
p.start()| 模式 | redis_client 数量 | 连接池数量 | TCP 连接数 | 隔离性 | fork 风险 |
|---|---|---|---|---|---|
多进程(python -m) | 3 | 3 | 3-6 | 完全隔离 | 无 |
| 单进程多线程 | 1 | 1 | 3-10 | 共享池 | 无 |
多进程 fork | 继承父进程 | 继承 | fd 冲突 | 不隔离 | 有 |
多进程 spawn | 3 | 3 | 3-6 | 完全隔离 | 无 |
7.3.8 本项目的完整连接拓扑
3 个 Worker 进程(python -m app.worker)
进程 A ──import──► redis_client ──► ConnectionPool(max=10)
│ ├── Conn-1 (BRPOP 独占)
│ └── Conn-2~10 (空闲)
│
└──TCP──► Redis fd=10 ──► waiting_clients[0]
进程 B ──import──► redis_client ──► ConnectionPool(max=10)
│ ├── Conn-1 (BRPOP 独占)
│ └── Conn-2~10 (空闲)
│
└──TCP──► Redis fd=11 ──► waiting_clients[1]
进程 C ──import──► redis_client ──► ConnectionPool(max=10)
│ ├── Conn-1 (BRPOP 独占)
│ └── Conn-2~10 (空闲)
│
└──TCP──► Redis fd=12 ──► waiting_clients[2]
Redis 等待链表:
task:queue → [client(fd=10), client(fd=11), client(fd=12)]
↑ 进程A的连接 ↑ 进程B的连接 ↑ 进程C的连接
LPUSH task:queue 6:
→ Redis 弹出 client(fd=10) → 通过 fd=10 发送 "6" → 进程A收到
→ fd=10 从等待链表移除
→ 进程A 的 brpop() 返回 → 开始处理任务
→ 进程B、C 继续等待一句话总结:每个 Worker 进程拥有独立的 redis_client 和连接池,进程内复用连接池中的多个连接,进程间互不共享。Redis 端每个 TCP 连接对应等待链表中的一个节点。