朕喜技术(ZhenxiJS)
2026.09.16
1 分钟阅读

supervisor 多 worker 缓存键取 str(pid)[:3](PID 前 3 位)导致键碰撞,把「别人预热的表」发给了你 —— 偶发 500 + 同一请求两种结果排查实录

日期:2026-09-16 分支:2026-09-16-0000 第 59 题 技术栈标签:supervisor numprocs fork Python eventlet monkey_patch gunicorn Docker 共享卷 预热 缓存键 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 位数字

现场症状(业务同学的原话)

  1. 重启后第一批请求偶发 500,日志里出现 RuntimeError: Working outside of request context.,然后 worker 直接退出, supervisor 又拉起一个新的 —— 看起来像"自己的锅,多试几次就好了"。
  2. 同一个 token,digest 有时是 A,有时是 B。报表表现为「同一份数据两次跑出来不一样」, 重跑一次又对了,没有任何异常日志。
  3. 3 个 worker 的日志里都写着 预热完成 ... 访问键 RELGEN_TABLE_KEY=xxx, 每个进程都坚信自己的键是对的。
  4. 重启一次好一次,压一下就又不行。

已排除的方向

不是网络(/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.shPASS=8 FAIL=3PASS=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 → 中招。

避坑清单

  1. 任何"用 ID 的一段前缀当键"的写法: str(pid)[:3]、str(uuid)[:8]、hash(...) % 100、md5(x)[:6] —— 只要用途是"唯一定位一份资源",就必须先证明它单射,否则别截断。
  2. 把「进程身份」当「资源身份」: PID / 线程号 / 容器 ID 会变、会复用;资源键应当是内容或名字(content-addressed)。 正解是把两者拆开:table_key(进程,完整 PID)+ model_key(模型,稳定名字)。
  3. 预热/初始化放在 bind 之前: 端口没在听,进程却已被标 RUNNING → 上游 Connection refused / 500。 正确顺序:先 bind(或注册到 LB)→ 后台预热 → /readyz 就绪; 同时把 supervisor startsecs 调大于真实预热耗时,别让它"假就绪"。
  4. monkey_patch() 写在"没人 import 的文件"里: eventlet/gevent 的 monkey_patch 必须在任何其它 import 之前调用。 放进 .conf/.cfg/被注释掉的代码 = 一次都没执行,并发全是假的。
  5. 加锁不配合原子落盘: 只 flock 不 os.replace,读方仍可能读到"半个文件"。tmp + fsync + rename 三件套。
  6. 验收脚本复刻 bug 语义: 判据里写死 str(producer_pid)[:3] 会与"键必须唯一"的断言自相矛盾。 判据应调用被测代码自己的键函数,而不是再抄一遍 bug。
  7. 看到"偶发"先怀疑"确定性": 偶发 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。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-16-0000-supervisor-pid-key-collision

分享这篇文章