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

目录锁 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、Python os.getpid() 写文件读回是字符串

一、问题背景

一套邮箱索引任务:每小时把 INBOX 的未读邮件归档到远端 Archive,归档完打 \Seen 避免下轮重复搬。同一账号同一时刻只允许一个 worker,靠目录锁(mkdir 原子创建)互斥。

两个怪现象:

  1. 锁目录里长期躺着一个锁 —— /srv/locks/mailbox-mailbox.lock 一直在,只有等锁 TTL (MAILBOX_LOCK_TTL,默认 5s)过期后才被下一轮当 stale 回收。两轮任务之间永远有个「假占用」窗口。
  2. 每轮报告都写「跑成功」 —— 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

三个放大伤害的点:

  1. 判等恒不成立 → 走 return 分支,锁永远删不掉。
  2. 这条 return 没有日志、没有异常、也没记进 report → 调用方只知道「我调用过 release 了」, 照样往 actions 里追加 lock_release、照样退出码 0。
  3. 唯一的兜底是 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 语义):

  1. 统一口径:新增 _owner_pid(),把 owner 文件文本("pid=584")、历史纯数字("584")、 内存 int(584)都解析成 int 再比较;
  2. 保留并发防护:只有真正持有者才删锁 —— 锁被回收并由别人重建后,旧句柄 pid 对不上仍拒绝归还(不误删别人的锁);
  3. 失败可观测:校验不通过时补一条 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,而是统一口径 + 失败留痕。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-29-1600-never-released-releaseint

分享这篇文章