supervisor 多 worker 缓存键取 str(pid)[:3](PID 前 3 位)导致键碰撞,把「别人预热的表」发给了你 —— 偶发 500 + 同一请求两种结果排查实录
日期:2026-09-16 分支:
2026-09-16-0000第 59 题 技术栈标签:supervisornumprocsforkPythoneventletmonkey_patchgunicornDocker共享卷预热缓存键PID 回绕PID 进位Working outside of request context幽灵 500偶发 500同一请求两种结果静默串号缓存键碰撞关键词:supervisor 多 worker 缓存键用 PID 前几位;PID 回绕导致 key 碰撞;预热表张冠李戴;eventlet monkey_patch 未生效导致并发串行;str(pid)[:3]不是单射
一、问题背景
一个内网「模型推理服务」relgen(200 行玩具版),部署方式是 supervisord 托管 + numprocs=3
(3 个 worker 一起监听 :5000,靠内核调度分流)。启动时做一次「模型预热」,把 32 张编码器表
(真实项目里是 tokenizer embedding / 权重分片,这里是 /dev/urandom(64) 的替身)建出来。
为了「少占内存」,预热出来的表不放在 Python 全局变量里,而是落到共享目录, 按「表访问键」读取:
$PRELOAD_DIR/tables-<表访问键>.json
这个「表访问键」的算法是:
def table_key(pid=None):
pid = os.getpid() if pid is None else pid
return str(pid)[:3] # ← 祖传优化:拿 PID 的前 3 位数字
现场症状(业务同学的原话)
- 重启后第一批请求偶发 500,日志里出现
RuntimeError: Working outside of request context.,然后 worker 直接退出, supervisor 又拉起一个新的 —— 看起来像"自己的锅,多试几次就好了"。 - 同一个 token,digest 有时是 A,有时是 B。报表表现为「同一份数据两次跑出来不一样」, 重跑一次又对了,没有任何异常日志。
- 3 个 worker 的日志里都写着
预热完成 ... 访问键 RELGEN_TABLE_KEY=xxx, 每个进程都坚信自己的键是对的。 - 重启一次好一次,压一下就又不行。
已排除的方向
不是网络(/healthz 一直 200);不是磁盘/权限(表文件都能解析);不是内存(free 正常);
不是 supervisor 配置写错(numprocs=3 是产品要求,不能删)。
二、复现步骤
git fetch origin
git checkout 2026-09-16-0000
cd scenario/2026-09-16-0000
docker compose up -d --build
docker compose exec relgen supervisorctl status
1) 确定性证明「键函数不是单射」(不用碰运气)
docker compose exec relgen python3 -c '
import sys; sys.path.insert(0,"/srv/relgen")
from srv import table_key
for p in (997,998,999,1000,1001,1002,1003):
print(p, "->", table_key(p))'
997 -> 997
998 -> 998
999 -> 999
1000 -> 100 ← PID 一进位到 4 位,整体塌缩成 "100"
1001 -> 100
1002 -> 100
1003 -> 100
2) 同一个 token 出现多种 digest
docker compose exec -e RELGEN_BASE=http://relgen:5000 probe \
python -u client/load_test.py --n 300 --quiet
状态码分布: {200: 300}
worker 分布: {7: 98, 8: 94, 9: 108}
digest 分布: {'66f2d02c4ff7': 98, '67c173c96d80': 94, '8b49a11a01ec': 108}
⚠️ 同一个 token 出现了 3 种 digest —— 模型表在上游被动过
3) 把 PID 烧过 1000,抓真实的键碰撞
docker compose exec -T relgen python3 -c '
import os,time
kids=[]
for i in range(1200):
try:
p=os.fork()
if p==0: time.sleep(0.03); os._exit(0)
kids.append(p)
except OSError: break
for p in kids:
try: os.waitpid(p,0)
except Exception: pass
print("PID 水位:", max(int(x) for x in os.listdir("/proc") if x.isdigit()))'
docker compose exec -T relgen sh -c '
rm -f /data/preload/tables-*
for i in $(seq 1 4); do
supervisorctl restart relgen:relgen_01 relgen:relgen_02 relgen:relgen_03 >/dev/null 2>&1
done
sleep 3; supervisorctl status; ls -l /data/preload/'
relgen:relgen_01 RUNNING pid 1345
relgen:relgen_02 RUNNING pid 1353
relgen:relgen_03 RUNNING pid 1354
-rw-r--r-- 1 root root 4306 ... tables-134.json
-rw-r--r-- 1 root root 4306 ... tables-135.json
1353 与 1354 共用键 135(都写 tables-135.json)。
4) 铁证:worker 服务的是「别人的表」
docker compose exec -T relgen sh -c '
curl -s "http://127.0.0.1:5000/infer?token=3"; echo
grep -E "首次读表|预热完成" /var/log/supervisor/relgen.log | tail -5'
{"worker_pid": 1353, "table_key": "135", "producer_pid": 1354, ...}
[relgen] 预热完成 pid=1353 表数=32 访问键 RELGEN_TABLE_KEY=135 -> .../tables-135.json
[relgen] 预热完成 pid=1354 表数=32 访问键 RELGEN_TABLE_KEY=135 -> .../tables-135.json
[relgen] worker pid=1353 首次读表: 键=135 生产者 pid=1354 ← ★ 用了别人的表,零报错
5) 故障期验收
docker compose exec -e RELGEN_BASE=http://relgen:5000 probe bash verify/verify.sh
[FAIL] A2 同一 token 出现了 3 种 digest
[FAIL] F1 键函数在 PID 进位处发生碰撞:{'100': 6}
[FAIL] F2 键函数在更大号段同样碰撞(1000000 与 1000 都映射到 100)
PASS=8 FAIL=3
三、根本原因
三个互相独立的缺陷,全在同一条启动/取数链路上 —— 这就是"偶发"其实是"确定性"的原因。
① 表键不是单射:str(pid)[:3] 把 PID 的前 3 位当键
PID 是十进制,一旦进位到 4 位(1000+),str(pid)[:3] 整体塌缩:
999 → "999" 1000 → "100" 1001 → "100" 1002 → "100"
于是 1000/1001/1002 三个 worker 会写同一个 tables-100.json,
后写者覆盖先写者;任何 worker 按 env 继承来的键去读,都会读到
「别人预热的表」—— 而表文件格式完全合法,零报错、零日志。
这就是"同一个 token 两种 digest""没有异常日志"的直接来源: 不是随机,是读到了别人的表。
⚠️ 更深的根因:把「进程身份」(PID)当成了「资源身份」(模型表)。 模型表是全服务共用的一份资源,其键必须与进程无关。
② 预热在 bind 之前:Connection refused / 「幽灵 500」
if __name__ == "__main__":
preload() # ← 里面 time.sleep(2)
serve() # ← 这里才 ev_listen() 把端口 bind 上去
preload() 的 2 秒里端口根本没人听;而 supervisor startsecs=1 早在第 1 秒
就把进程标成 RUNNING —— "看起来起来了,其实还没准备好",上游拿到的就是
连接被拒 / 502 / 500。
# 复现:重启瞬间猛打请求
ERR URLError: <urlopen error [Errno 111] Connection refused> ← ×7
PASS 之后才 HTTP 200
③ monkey_patch() 从未执行 → eventlet 并发退化成串行
eventlet.monkey_patch() 被写在 eventlet_patch_conf.py 里且被注释掉
(jira 备注"启动慢"),而 supervisor 版根本没有代码 import 这个文件:
socket patched = False
socket.socket = <class 'socket.socket'> ← 原生实现,没换成 GreenSocket
后果量化(5 个 greenlet 各做 1 秒阻塞工作):
总耗时 5.01s ← 5 个"并发"任务串行执行,eventlet 的并发是假的
请求一排队就超时 → 上游 500。这是"压一下就又不行"的来源。
④ 关于 RuntimeError: Working outside of request context.
原版 srv.py 是纯 WSGI app(只用 environ/start_response),代码里根本没有
request context。grep 全仓只出现在注释里。这句话是业务/框架侧对"启动期 500"的
转述(真实项目里 Flask 在 request 上下文之外被调用才抛这个错)。
在本环境里可复现的等价物是 ② 的启动窗口 Connection refused 与 ③ 的串行超时。
我们把原生代码反复压测 5 轮 × 80 请求并同步重启,也没有写出这个 traceback
(README 亦提示「docker compose logs relgen 只会看到 worker 退出,连日志都写不出来」)。
所以判据以 verify.sh 的 A1(无 500)为准,而 A2/F1/F2 才是确定性可复现的判据。
⑤ 验收脚本自身也复刻了 bug
verify.sh 的 B2 断言写成 str(producer_pid)[:3] == 文件名键 ——
把 bug 的语义当成了正确性标准,与 F1/F2「键必须唯一(不得截断)」自相矛盾:
PID ≥ 1000 时,B2 与 F1/F2 必有一方 FAIL,永远无法 FAIL=0。
四、解决办法
1) 键拆成两个:进程键(唯一)+ 模型键(稳定)
def table_key(pid=None):
"""进程身份键 = 完整 PID(绝不截断)"""
pid = os.getpid() if pid is None else pid
return str(int(pid))
def model_key():
"""模型产物键 = 与进程无关的稳定名字"""
return os.environ.get("RELGEN_MODEL_KEY", MODEL_NAME) # relgen-encoder
- 表文件命名
tables-<生产者完整PID>.json→键 ↔ 生产者自洽、可追溯; - 指针文件
current-<模型名>.json→ 告诉所有 worker「当前生效的表是哪一份」。
2) 预热:flock 单生产者 + 原子发布 + 复用
lock_path = os.path.join(PRELOAD_DIR, "preload-%s.lock" % MODEL_NAME)
with open(lock_path, "w") as lock:
fcntl.flock(lock.fileno(), fcntl.LOCK_EX) # 跨进程互斥
try:
if os.path.exists(ptr): # 已有人生成 → 复用
blob = ...读指针指向的表...; role = "复用"
else: # 我是第一个 → 生成
key = table_key()
...os.fsync + os.replace(table_path)... # 原子发布表
...os.fsync + os.replace(ptr)... # 原子发布指针
role = "生成"
finally:
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
_load_tables() 加三重自证,读到别人的表就拒绝:
if str(blob.get("key")) != key: raise RuntimeError("内容键 != 请求键")
if name_key != key: raise RuntimeError("文件名键 != 请求键")
if table_key(blob["producer_pid"]) != key: raise RuntimeError("生产者 pid → 键不自洽")
表内容仍然是随机的(
/dev/urandom),没有用固定种子伪造稳定性。
3) 启动顺序反转 + 就绪闸门
if __name__ == "__main__":
serve() # serve 内部:先 ev_listen() bind,再 spawn_n(preload)
def serve():
sock = ev_listen(("0.0.0.0", PORT)) # 毫秒级,端口立刻可用
_ev.spawn_n(preload) # 后台预热,不阻塞 accept 循环
wsgi.server(sock, _wsgi_call, custom_pool=greenpool.GreenPool(size=2000), log_output=False)
加 _READY 闸门 + /readyz:
_READY = threading.Event()
if path == "/readyz":
if _READY.is_set(): start_response("200 OK", ...); return [b"ready\n"]
start_response("503 Service Unavailable", ...); return [b"warming up\n"]
if not _READY.is_set(): # 业务请求:预热没好就快速 503,别拖成超时/500
start_response("503 Service Unavailable",
[("Content-Type", "text/plain"), ("Retry-After", "1")])
return [b"warming up, retry later\n"]
4) monkey_patch 放到最前面
# srv.py 任何其它 import 之前
import eventlet # noqa: E402
eventlet.monkey_patch() # noqa: E402 ← 必须最先执行
import os
import sys
...
eventlet_patch_conf.py 只保留说明 + 幂等 apply_patch(),不再承担打补丁职责。
5) 修正验收脚本 B2
sys.path.insert(0, "/srv/relgen")
from srv import table_key as _key_of_pid # 用被测代码自己的键函数
expect_key = str(_key_of_pid(blob["producer_pid"]))
if expect_key != name_key:
bad("B2 %s 的键(%s) ≠ 由生产者 pid %s 算出的键(%s)" % (...))
五、验证
干净一条龙
docker compose down -v
docker compose up -d --build
sleep 5
docker compose exec relgen supervisorctl status
docker compose exec -e RELGEN_BASE=http://relgen:5000 probe bash verify/verify.sh
预热日志(1 个生产者、其余复用):
[relgen] wsgi 监听 0.0.0.0:5000 pid=7 (eventlet=0.36.1)
[relgen] 预热生成 pid=7 表数=32 表键=7 生产者 pid=7
[relgen] 预热复用 pid=8 表数=32 表键=7 生产者 pid=7
[relgen] 预热复用 pid=9 表数=32 表键=7 生产者 pid=7
压测(3 个 worker,1 个表键、1 种 digest):
状态码分布: {200: 300}
worker 分布: {7: 91, 9: 111, 8: 98}
表键分布: {'7': 300}
digest 分布: {'b29f98be987b': 300}
成功 300 / 300,异常 0
验收:
[PASS] A1 全部 300 个请求返回 200
[PASS] A2 同一 token 的 digest 稳定(只有 1 种)
[PASS] B3 tables-7.json 键与生产者 pid 自洽
[PASS] C1 每个预热门进程都有自己的表文件(producer 数=1,文件数=1)
[PASS] D2 响应 digest 与共享目录里的表一致
[PASS] F1 键函数在所有探测点都唯一
[PASS] F2 大号段也未碰撞
PASS=9 FAIL=0 exit code = 0
高 PID 段复核(真正的碰撞区,pid 1399/1400/1408)
-rw-r--r-- 1 root root 4335 ... tables-1363.json ← 只有 1 份表
[relgen] 预热复用 pid=1399 表数=32 表键=1363 生产者 pid=1363
[relgen] 预热复用 pid=1400 表数=32 表键=1363 生产者 pid=1363
[relgen] 预热复用 pid=1408 表数=32 表键=1363 生产者 pid=1363
表键分布: {'1363': 300}; digest 分布: {'53b5b0c32c91': 300}; PASS=8 FAIL=0
修复前后对照
| 项 | 原始代码 | 修复后 |
|---|---|---|
table_key(1000..1003) | 100,100,100,100(碰撞) | 1000,1001,1002,1003(唯一) |
| 同 token digest 数(高位 PID) | 2 种 | 1 种 |
| 共享目录表文件数(高位 PID) | 6 份(两组互相覆盖) | 1 份 |
worker 1353 实际用的表 | producer_pid=1354(别人的) | 自己的 |
| 启动窗口 | Connection refused ×7 | 端口立即可连,/readyz 报 503 |
| 5 并发各 1s 阻塞 | 5.01s(串行) | 1.48s(并发) |
monkey_patch | 未执行(原生 socket) | 已执行(GreenSocket) |
verify.sh | PASS=8 FAIL=3 | PASS=9 FAIL=0 |
重启稳定性
6 次连续重启,digest 全程稳定:
重启#1: HTTP 200 {"worker_pid": 114, "table_key": "7", "producer_pid": 7, "digest": "6048cc460ccf"}
...
重启#6: HTTP 200 {"worker_pid": 244, "table_key": "7", "producer_pid": 7, "digest": "6048cc460ccf"}
numprocs=3 未改动,预热功能保留,表内容依然是随机的。
六、经验与避坑
线上 5 分钟只读自查法(不重启、不改配置)
# ① 键函数是不是单射?(问代码,最准)
docker compose exec relgen python3 -c '
import sys; sys.path.insert(0,"/srv/relgen")
from srv import table_key
probes=[999,1000,1001,1002,1003,1000000]
ks=[(p,table_key(p)) for p in probes]; print(ks)
dup={k for p,k in ks if len([q for q,_ in ks if table_key(q)==k])>1}
print("碰撞键:", dup or "无 (单射 OK)")'
# ② 共享目录「键 → 生产者」是否一对多(同一键被多份文件占用 = 碰撞)
docker compose exec relgen python3 -c '
import json,glob,collections
c=collections.Counter()
for f in glob.glob("/data/preload/tables-*.json"):
c[json.load(open(f))["key"]]+=1
print("一钥多文件的:", {k:v for k,v in c.items() if v>1} or "无")'
# ③ PID 水位离最近进位点还有多远
docker compose exec relgen sh -c 'ps -eo pid --no-headers | sort -n | tail -1'
# ④ 是否有就绪端点(区分"端口 LISTEN"与"服务就绪")
docker compose exec relgen sh -c 'curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:5000/readyz'
# ⑤ 并发是否真并发:monkey_patch 是否生效
docker compose exec relgen python3 -c '
import sys; sys.path.insert(0,"/srv/relgen"); import srv, socket
from eventlet import patcher
print("patched:", patcher.is_monkey_patched("socket"), "| socket:", socket.socket)'
只看不改,5 分钟能判死:① 有碰撞键 / ② 出现一钥多文件 / ⑤ patched: False → 中招。
避坑清单
- 任何"用 ID 的一段前缀当键"的写法:
str(pid)[:3]、str(uuid)[:8]、hash(...) % 100、md5(x)[:6]—— 只要用途是"唯一定位一份资源",就必须先证明它单射,否则别截断。 - 把「进程身份」当「资源身份」:
PID / 线程号 / 容器 ID 会变、会复用;资源键应当是内容或名字(content-addressed)。
正解是把两者拆开:
table_key(进程,完整 PID)+model_key(模型,稳定名字)。 - 预热/初始化放在
bind之前: 端口没在听,进程却已被标 RUNNING → 上游Connection refused/ 500。 正确顺序:先 bind(或注册到 LB)→ 后台预热 →/readyz就绪; 同时把 supervisorstartsecs调大于真实预热耗时,别让它"假就绪"。 monkey_patch()写在"没人 import 的文件"里: eventlet/gevent 的 monkey_patch 必须在任何其它 import 之前调用。 放进.conf/.cfg/被注释掉的代码 = 一次都没执行,并发全是假的。- 加锁不配合原子落盘:
只
flock不os.replace,读方仍可能读到"半个文件"。tmp + fsync + rename 三件套。 - 验收脚本复刻 bug 语义:
判据里写死
str(producer_pid)[:3]会与"键必须唯一"的断言自相矛盾。 判据应调用被测代码自己的键函数,而不是再抄一遍 bug。 - 看到"偶发"先怀疑"确定性": 偶发 500 / 结果不稳定,先问「这个键唯一吗?」「共享资源有没有被两个进程同时写?」 而不是去重启。「重启一次好一次、压一下就又不行」= 典型的键碰撞 + PID 号段漂移。
一句话复盘
坏结果看起来像"偶发",实际上它是确定性的 —— 只不过触发条件(PID 跨过 1000)在你重启的那几次里没恰好命中。 别重启了,去证明那个键是单射。
七、复现命令(最短路径)
git fetch origin
git checkout 2026-09-16-0000
cd scenario/2026-09-16-0000
docker compose up -d --build
docker compose exec -e RELGEN_BASE=http://relgen:5000 probe bash verify/verify.sh
# 期望:PASS=9 FAIL=0,exit 0
完整操作过程(含每一步命令、关键输出、踩坑)见
scenario/2026-09-16-0000/SOLUTION.md。
