目录锁 never released:release() 持有者校验拿 int 和字符串比,判等恒不成立,锁只在 TTL 过期后被回收
日期:2026-09-29 · 分支:
2026-09-29-1600· 技术栈:Python / 进程互斥(mkdir目录锁)/ 契约配置 / 失败动作与退出码 / IMAP 归档任务 关键词:目录锁没释放、mkdir 锁泄露、lock file 残留、owner 校验失效、int 与 str 比较、静默 return、自述与事实不一致、TTL 兜底、并发互斥 B4/C1、Pythonos.getpid()写文件读回是字符串
一、问题背景
一套邮箱索引任务:每小时把 INBOX 的未读邮件归档到远端 Archive,归档完打 \Seen
避免下轮重复搬。同一账号同一时刻只允许一个 worker,靠目录锁(mkdir 原子创建)互斥。
两个怪现象:
- 锁目录里长期躺着一个锁 ——
/srv/locks/mailbox-mailbox.lock一直在,只有等锁 TTL (MAILBOX_LOCK_TTL,默认 5s)过期后才被下一轮当 stale 回收。两轮任务之间永远有个「假占用」窗口。 - 每轮报告都写「跑成功」 ——
index-report.json里archived=3、errors=[]、actions里明明有lock_acquire和lock_release,scheduler.py打印index round OK、退出码 0。
但外部探针从盘上看,锁一次都没被真正删掉:
[lockstat] 残留锁数量 = 1
[lockstat] - mailbox-mailbox.lock age=2.1s pid=234
[lockstat] 任务已经结束,锁却还留在盘上 —— 后续同账号任务只能等 TTL 过期
RESULT: BAD
二、复现步骤
需要 docker + docker compose:
git fetch origin
git checkout 2026-09-29-1600
cd scenario/2026-09-29-1600
docker compose down -v
docker compose up -d --build
docker compose exec -T indexer bash /srv/ops/repro.sh # 问题态:RESULT: BAD
docker compose exec -T indexer bash /srv/ops/verify.sh # 问题态:PASS=13 FAIL=4
若环境里没有 docker CLI(例如直接在流水线容器里动手):本题代码路径全是硬编码绝对路径 (
/srv/app/...),只要在/srv/下按容器原样铺开app/ ops/ client/ conf/ locks/ state/, 把契约里的MAILBOX_HOST/MAILBOX_IMAP_PORT指向本机(127.0.0.1:14300)即可等价复现 ——ops/verify.sh与client/*.py一行都不用改。
问题态关键证据:verify.sh 红的 4 条(B1 / B2b / B3b / C3)全是「锁没归还」的四个切面,
A 组(活儿干没干成)全绿 —— 说明归档没问题,病灶在归还。
三、根本原因
app/lockmgr.py 的 release() 里有一道「持有者校验」,把两个值的类型和写法搞混了:
# acquire():写 owner 文件
fh.write("pid=%s\n" % handle["lock_owner"]) # 盘上:"pid=584"(带前缀的文本)
handle["lock_owner"] = os.getpid() # 内存:584(int)
# release():读回来直接比
owner_line = fh.read().strip() # "pid=584"(str)
if owner_line != handle.get("lock_owner"): # "pid=584" != 584 → 恒为 True
return # 直接返回,锁没删
实测:
handle lock_owner = 584 int
owner file line = 'pid=584' str
判等 => True
三个放大伤害的点:
- 判等恒不成立 → 走
return分支,锁永远删不掉。 - 这条
return没有日志、没有异常、也没记进 report → 调用方只知道「我调用过release了」, 照样往actions里追加lock_release、照样退出码 0。 - 唯一的兜底是 stale 回收(
age > TTL)→ 表现为「锁总是等 TTL 才消失」, 高频调度或 TTL 更短时就是实打实的漏跑。
两条证据链打架:index-report.json 是自述(写着 lock_release),lockstat.py 是事实(锁还在盘上)。
别被 errors=[] 迷惑 —— lock_release_skipped 这类 action 只说明「没释放」,既不是 error 也不影响 archived。
四、解决办法
只改 app/lockmgr.py(没碰 ops/verify.sh、client/*.py、conf/mailbox.env 的契约键,
也没动 mailboxd.py 的 RESET 语义):
- 统一口径:新增
_owner_pid(),把 owner 文件文本("pid=584")、历史纯数字("584")、 内存 int(584)都解析成 int 再比较; - 保留并发防护:只有真正持有者才删锁 —— 锁被回收并由别人重建后,旧句柄 pid 对不上仍拒绝归还(不误删别人的锁);
- 失败可观测:校验不通过时补一条 stderr 日志,暴露「调用过 release 但没删掉」这件事。
def _owner_pid(value):
if value is None:
return None
if isinstance(value, int):
return value
m = re.search(r"(\d+)", str(value))
return int(m.group(1)) if m else None
def release(handle):
...
file_pid = _owner_pid(owner_line) # "pid=584" -> 584
handle_pid = _owner_pid(handle.get("lock_owner")) # 584 -> 584
if file_pid is None or handle_pid is None or file_pid != handle_pid:
_log_release_skip(path, handle_pid, owner_line) # 不再静默
return
shutil.rmtree(path, ignore_errors=True) # 真正归还
关键:不要错解成「把 TTL 调小」或「每轮开头无条件清锁」 —— 这两者会把并发保护一起废掉, 验收器 B4(新鲜锁必须拦住第二个 worker)与 C1 会立刻变红。
五、验证
验收器(黑盒,17 条断言):
问题态:PASS=13 FAIL=4 / RESULT: BAD
修复后:PASS=17 FAIL=0 / RESULT: OK (连跑 3 次稳定)
其中并发保护 B4 / C1 修复后仍全绿 —— 证明修复「归还」的同时没有改坏「互斥」。
运维复现:
[lockstat] 残留锁数量 = 0
RESULT: OK
单元级边界用例(均不抛异常):
- 自己持有者归还 → 锁目录清空 ✅
- 非持有者归还 → 被拒且锁保留(补上 stderr 日志)✅
- owner 文件缺失 → 校验不可信,保留锁(不误删)✅
- owner 为历史纯数字格式
"584"→ 解析正常,pid 一致即归还 ✅ - 重复
release()/release(None)→ 幂等 ✅
一个别误判的现象:修复后 repro.sh 第二轮 rc=4,看着像没修好 —— 其实第一轮已把 3 封未读
归档并打 \Seen,第二轮 unseen=0,indexer.py 自己的业务规则「全 0 归档」会记 errors 并返回 4。
此时锁目录是空的。别把业务语义的正常报错当成 bug 复发。
六、经验与避坑
- 跨边界的值,落地即变字符串。文件 / 网络 / 环境变量读回来一律是
str, 内存里的可能是int。os.getpid()写进文件读回来就是"584",写的时候加了pid=前缀更不等。 一切判等前先统一口径(类型 + 写法),否则判等会「恒真/恒假」而不报错。 - 静默
return是最危险的失败路径。最阴的不是「报错了」,而是「做没做过都不吭声」—— 调用方以为做过了,监控全绿,只有外部黑盒探针能戳穿。守卫分支要留痕(日志/计数/report 字段)。 - 区分「自述」与「事实」。
index-report.json的actions是自述,lockstat看盘、archive_probe问服务端才是事实。验收器不要读被测方自己的自述字段。 - 别用「调小 TTL / 无条件清锁」糊弄归还问题 —— 那是拿并发保护换绿灯。 修复要精准打到「归还」,并用 B4/C1 这类断言守住「互斥还活着」。
- 同类影响面:任何「写文件记 owner、释放时校验 owner」的锁(
flock封装、PID 文件、docker/k8s的 leader 文件、单实例守护);任何把id/port/pid/timestamp落盘再读回比较的校验逻辑; 任何「守卫不通过就return/pass且不留痕」的代码。
一句话:"pid=584" != 584 恒成立,于是锁永远还不了;
比类型错更可怕的是它不报错 —— 自述一片绿灯,事实锁还在盘上。
修法不是调 TTL,而是统一口径 + 失败留痕。
