Python 连接池父进程预热 + multiprocessing fork:多进程抢读同一条 TCP 连接导致缓存「张冠李戴」静默数据污染(明文 TCP 零报错)完整排查与修复
日期:2026-09-14 | 第 55 题 | 分支:
2026-09-14-0800技术栈 / 关键词:Python、multiprocessing、fork、os.fork、文件描述符表继承、fd 继承、 连接池、连接复用、长连接、父进程预热、warmup、多进程抢读同一 socket、 请求响应错配、response mismatch、串包、静默数据污染、缓存串号、缓存张冠李戴、TimeoutError: timed out、json.decoder.JSONDecodeError、Expecting value: line 1 column 1 (char 0)、ConnectionError: gateway closed connection、created_pid != os.getpid、os.register_at_fork、set_start_method("spawn")、threading.Lock无法跨进程、 Redis 网关、一问一答、长度前缀分帧、明文 TCP 无记录层 MAC 环境:Docker / docker compose,3 容器(redis / gateway / app),纯 Python 标准库,无需特权
一、问题现象(一句话)
缓存预热时每个 worker 都"成功"拿到值、全程零报错,但读到的值是别人的;
worker 越多越容易复现,--workers 1 单进程则一切正常。
典型现场(4 worker × 6 job):
预热请求总数 24 拿到值 17 空值 0 抢到别人响应 4
SUMMARY_JSON {"total": 24, "ok": 17, "empty": 0, "mismatch": 4, "errors": 7}
w1-job2 @ pool-3: RESPONSE_MISMATCH got={'requestId': 'w0-job3', 'cacheHit': 'v-w0',
'reader_pid': 8, 'conn_state': 'INHERITED_BY_FORK'}
w0-job1 @ pool-1: TimeoutError: timed out
w0-job3 @ pool-3: JSONDecodeError: Expecting value: line 1 column 1 (char 0)
[app] 数据正确性自检(读到的值 vs 本 worker 的期望值):
worker1 期望值(应为 'v-w1') 读到=['v-w2','v-w1',...] → 污染
[app] ❌ 3/4 个 worker 读到了别人的值:缓存被静默污染(注意:全程没有任何报错)
最要命的是第 1 条:这是一次静默的数据污染,不是一次宕机。 上层只判断"有没有拿到值",于是把错误数据当正常缓存写进了下游。
二、故障成因(根因)
问题代码的写法(线上很常见):
_POOL = None
def init_pool(cfg):
global _POOL
_POOL = GatewayPool(cfg["gwHost"], cfg["gwPort"], cfg["poolSize"])
_POOL.warmup(...) # ← 在【父进程】里建好 4 条长连接
return _POOL
init_pool(cfg) # 父进程预热
for i in range(cfg["workers"]):
p = mp.Process(target=process_orders, args=(i, jobs, cfg)) # Linux 默认 fork
p.start()
Linux 上 multiprocessing 默认 fork。fork 会把父进程的:
- 文件描述符表(父进程那几条 socket fd 被原样继承)
- 用户态池对象 / 连接对象(连同 Python 层接收缓冲区)
一起复制给每个子进程。于是:
- 内核里其实只有
poolSize条到网关的 TCP 连接; - 却是
poolSize × workers个"进程"以为自己独占其中某一条; - 谁先
recv谁先拿到数据,同一条连接上的数据被多个进程轮流抢读。
因为每个 worker 发出去的 requestId 长得都一样(w0-job0 / w1-job0 …),
抢到的响应"格式正确",没有任何报错,但值不是自己那份。
为什么本题没有报错?和 TLS 场景的区别
| 第 54 题(TLS) | 第 55 题(明文 TCP) | |
|---|---|---|
| 记录层序号 / MAC | 有,会被校验 | 没有 |
| 抢读后果 | DECRYPTION_FAILED_OR_BAD_RECORD_MAC、SSLEOFError | 静默,值被别人换掉 |
| 能否靠日志发现 | 能(一堆 SSL 报错) | 不能,必须核对"值对不对" |
所以第 55 题比第 54 题更隐蔽:它没有任何异常日志,只有脏数据。
三、如何定位
排查顺序(每步都能缩小范围):
- 看数值正确性,而不是看有没有报错。
本例
SUMMARY_JSON里ok看着不低,但mismatch > 0—— 这才是真问题。 - 单进程对照:
--workers 1全对 → 说明网关 / Redis 本身没问题,问题在 app 侧的进程边界。 - 打印连接视图:每个 worker 的池都是
pool-0 fd=3 created_pid=<父进程pid>,而 worker 自己的 pid 是别的值 → 子进程在用父进程建的连接。 - 看内核事实:
ls -l /proc/<worker_pid>/fd里那几条 fd, 指向的内核 socket 和父进程是同一个(fd 编号相同、连接相同)。 - 一条判据定案:
conn_state == "INHERITED_BY_FORK", 即读数据的 pid ≠ 建连接的 pid。
关键记忆点:
threading.Lock防不住 fork。 fork 之后每个子进程各拿一份锁副本,多个子进程可以同时"持有锁"。 所以本题不能靠加锁解决,必须让连接的生命周期跟在进程边界内。
四、修复方案
核心原则:谁用谁建(连接生命周期跟随进程边界)
class GatewayConnection:
def __init__(self, host, port, timeout=10.0, name=""):
...
self.created_pid = os.getpid() # 记录"出生 pid"
def is_owned_by_current_process(self) -> bool:
return self.sock is not None and self.created_pid == os.getpid()
def discard_inherited_copy(self) -> None:
"""fork 后丢弃继承副本:只丢引用,绝不能 close()。"""
self.sock = None
self._lock = threading.Lock()
class GatewayPool:
def ensure_owner_process(self) -> None:
pid = os.getpid()
if pid == self.owner_pid:
return # 本进程自己的池,直接复用
for conn in self.conns:
conn.discard_inherited_copy() # 1) 丢弃父进程副本
self.conns = []
self._connect_all() # 2) 在本进程内重建
self.owner_pid = pid
def get(self, idx: int) -> GatewayConnection:
self.ensure_owner_process() # ✅ 取连接前先做 fork 自查
return self.conns[idx % len(self.conns)]
三个容易踩错的细节
- 绝不能
close()继承来的 fd。 子进程close()关的是同一张 fd 表里的同一编号,会把父进程(及兄弟进程) 持有的那条共享连接一起废掉,反而制造新的EOF: gateway closed connection。 正确做法是只清 Python 引用,让 GC 回收副本。 - fork 自查要放在"取连接"入口,而不是模块 import 时。 子进程 import 时父进程的池已经存在;只有真正要发请求时才知道"当前是谁在用"。
- 不要把
threading.Lock当解法。 锁也随 fork 复制,跨进程形同虚设(同样踩坑的还有第 25 / 30 题)。
五、验证结果
# 修复前(真实 app 代码,4 worker × 6 job)
SUMMARY_JSON {"total": 24, "ok": 7, "empty": 0, "mismatch": 6, "errors": 17}
[app] ❌ 4/4 个 worker 读到了别人的值
# 修复后
# 单进程 1 × 10 :{"total": 10, "ok": 10, "empty": 0, "mismatch": 0, "errors": 0}
# 4 worker × 6 :{"total": 24, "ok": 24, "empty": 0, "mismatch": 0, "errors": 0}
# 8 worker × 12 :{"total": 96, "ok": 96, "empty": 0, "mismatch": 0, "errors": 0}
worker0: pid=1705 池created_pid=1705 ← 每个 worker 的连接都是自己建的
worker1: pid=1712 池created_pid=1712
[app] ✅ 全部 4 个 worker 读到的都是自己的值
最小对照实验:
python3 experiments/fork_probe.py --phase=both
# before 出现 TimeoutError / JSONDecodeError,reader_pid=子进程 而 conn_created_pid=父进程
# after 合计 OK=16/16 错=0(本次实跑 before 12/16、after 16/16)
六、方案对比
| 方案 | 做法 | 评价 |
|---|---|---|
| pid 自查 + 子进程重建(推荐) | 取连接前比对 pid,不符就丢副本重建 | 改动小、保留长连接复用、fork / spawn 都成立 |
os.register_at_fork(after_in_child=...) | fork 回调里重置池 | 可行;但隐性依赖 fork,spawn 下不触发 |
set_start_method("spawn") | 子进程不继承 fd | 能解决,但改变进程模型、启动变慢,属"绕开" |
initializer= 里各建各的池 | 每个 worker 启动时 init_pool | 等价;注意别复用父进程那份 |
| 加锁 | —— | ❌ 无效,锁随 fork 复制 |
七、复现与验收命令速查
git checkout 2026-09-14-0800
cd scenario/2026-09-14-0800
# 有 Docker
bash run.sh # 一键复现问题现象
bash verify.sh # 验收:修复前 PASS=3 FAIL=4 → 修复后 PASS=7 FAIL=0
# verify.sh 共 7 处 ok() 判定,全通过即 PASS=7 FAIL=0,与 README/Issue 要求一致
# 无 Docker(本轮 CNB pipeline 环境采用)
python3 experiments/fork_probe.py --phase=both # 最小对照实验,不需 docker
# 进阶:直接用真实 app.py 配本地网关/RESP 服务跑一轮,见 SOLUTION.md「本次修复环境的说明」
八、真实 Docker 环境复核(补充验证)
本节由 CodeBuddy-Solver 追加。 原始验证环境没有 docker CLI,用的是「真实 gateway + RESP 子集桩 + 真实 app」等价方式。 本次复核环境有完整 Docker,于是用 3 容器真实
docker compose栈 + 原版verify.sh重跑了一遍,结论一致,存档如下。
docker version # Docker 29.6.2 / Compose v5.3.1,可用
cd scenario/2026-09-14-0800
docker compose up -d --build # redis Healthy → gateway Healthy → app Started
bash verify.sh
真实 Docker 下的结果:
| 状态 | verify.sh 结果 |
|---|---|
修复前(代码回到 8d3b2d1) | PASS=3 FAIL=4(8×12 压测仅 44/96 对) |
| 修复后 | PASS=7 FAIL=0(8×12 压测 96/96 全对) |
关键判据(docker compose exec -T app python3 /srv/app.py --workers 4 --jobs 6):
[worker0] pid=13 有值=6/6 空值=0 抢到别人响应=0 池created_pid=13
[worker1] pid=14 有值=6/6 空值=0 抢到别人响应=0 池created_pid=14
[worker2] pid=15 有值=6/6 空值=0 抢到别人响应=0 池created_pid=15
[worker3] pid=16 有值=6/6 空值=0 抢到别人响应=0 池created_pid=16
[app] ✅ 全部 4 个 worker 读到的都是自己的值
池created_pid == 自身 pid,不再是父进程的 7。
⚠️ 复核踩到的一个易混点:修复后各 worker 的
fd依然显示3/4/5/6。 这不是没修好 —— fd 是每进程独立的编号空间,每个进程的 fd=3 指向 各自不同的内核 TCP 连接。判据只能是created_pid,不能是 fd 数字。 我第一眼看到 fd 一样还愣了一下,特此记录。
服务端佐证("我回对了"的矛盾依然可复现):
docker compose logs gateway | grep '\[gw\] -> reply' | tail
# [gw] -> reply requestId=w0-job0 task=cache:order:1001:w0 cacheHit='v-w0' ...
无 Docker 最小复现(注意 before 每轮错数会波动 —— 这正是"时好时坏"的来源):
python3 experiments/fork_probe.py --phase=both
# before: 合计 OK=8/16 错=2
# after : 合计 OK=16/16 错=0
收尾:docker compose down -v
九、一句话总结
fork 复制的是 fd,不是连接。 连接池一旦在父进程预热,就必须在
"取连接"时校验 created_pid == os.getpid(),让每个进程用自己建的连接 ——
否则多个进程会抢读同一条内核 TCP 流,在明文 TCP 上表现为完全静默的数据污染。
