门禁的耗时预算

为什么慢门禁的归宿是 --no-verify

Posted by walikrence on September 8, 2026

背景脱敏:一个 Unity 手游客户端仓库,约 6.5 万个文件。项目没有可用的 CI runner,架构红线(文件 ≤ 300 行、程序集不得反向依赖、协议产物不得手工放置等)全靠本地 pre-push 钩子里的三十多个 Python 脚本把关。本文记录这套门禁从 6 项长到 38 项、耗时卡在 45~52 秒、三轮减负、最后演进到「默认不执行 + 每日全量巡检」的全过程。不站队,只陈述机制与数据。

一个机制

门禁拦下的问题不会变成事故,所以它的收益永远不可见;它的成本每次推送都可见。 当推送等待超过某个阈值,开发者会敲一次 git push --no-verify。用过一次就会成习惯,门禁随即形同虚设。更糟的是,--no-verify 让整个 pre-push 不执行,埋点也不执行,绕过在看板上表现为「没有记录」,而「没有记录」与「这段时间没人推送」长得一模一样。

2026-08-05 的实测是起点:门禁当时全仓扫描,merge origin/main 带进别人的 4 条存量违规,一次无关的推送被拦下。人的自然反应是绕过。于是门禁改成只校验本次推送区间引入的违规,两个逐文件门禁合计 13.8s → 0.12s。耗时不是门禁的可选优化项,是它的存活条件。

预算:单项串行 ≤ 2 秒

2026-08-17 一天之内优化掉六项门禁,累计省下数十秒;同一天别人又加进三项新门禁。优化速度被新增速度整整抵消,总耗时始终卡在 45~52 秒。 没有预算约束,提速就是往一个不断变大的坑里填土。

从那天起的规矩:新增门禁单项串行耗时 ≤ 2 秒,超了先做增量化或缓存,再进 pre-push

测耗时必须用串行数

门禁并行执行,并发数取 nproc。同一个纯文档提交的区间实测:

并发 总耗时 最慢单项
1 77.0s check_accessibility 9.3s
4 66.2s 同一项劣化到 22.7s
8 50.8s check_proto_artifacts 23.0s
16 47.3s check_proto_artifacts 22.7s

瓶颈是磁盘 IO 与 6.5 万个文件的遍历,不是 CPU。并发只是用「每项都变慢」换「总时间变短」,8 路基本见顶。

并发数字还会误导优化对象:check_asmdef_deps 单独串行 0.18s,并发下 12s,膨胀 66 倍。照并发耗时挑最慢项,会去优化一个 0.18 秒的脚本。测耗时一律 GATE_JOBS=1 GATE_PROFILE=1 串行跑。

超预算的头号病灶:重活跑在增量判断之前

传了 --changed-only 不等于省了工作量。同一个病在这个仓库出现了六次:

门禁 病灶 效果
check_accessibility 先读完全仓 .cs 收集 internal 类型,过滤在它之后 22s → 0.46s
check_proto_artifacts 压根没接增量参数,每次遍历整个服务端仓 21s → 0.82s
check_doc_symbol_drift 外层符号、内层文件,全仓 .cs 重读 N 遍 17s → 4.1s
check_module_deps 先建全量 GUID 索引,后做增量过滤 4963ms → 389ms
check_no_svr_bin_registration 无条件 rglob 3120 个 .cs 2337ms → 445ms
compile_check 无条件先建全仓 asmdef 依赖图,而它只在遇到删除的 .cs 时才用得上 11.4s → 985ms

第六次尤其隐蔽:区间里 .cs 数为 0,门禁打印了「无需编译校验」,却仍是全场最慢的一项。范式因此定型为:

if range_touches(rev_range, "*.cs") is False:
    print("本次推送未触及 .cs,跳过")
    return 0
expensive_global_index()   # 重活放在判断之后

range_touches 刻意不过滤删除:删掉一个把某类型声明为 public 的文件,会让别处的 internal 声明生效而凭空产生新违规。

必须全局视野的门禁:文件级缓存

有的门禁天生要看全仓,比如「某类型是不是 internal」。提前返回只在区间没碰 .cs 时有效,而主干推送通常都有 .cs,它一度仍要 12.5 秒。

解法是按 (mtime, size) 缓存每个文件的解析结果,只重算变过的文件,全局集合从缓存求和。实测纯全量 15.8s,缓存命中 2.46s,整个门禁 12.5s → 3.1s。文件列表从 os.walk(3.03s)换成 git ls-files(0.14s)。

缓存最危险的失效模式是「结果悄悄变了没人发现」,加缓存必须证明三件事:

  1. 纯全量 == 首次建缓存 == 缓存命中,比对的是集合内容,不只是数量
  2. 新文件(不在缓存里)能被解析到,否则新代码的违规永远漏
  3. 改动已缓存的文件后,缓存正确失效并抓到新违规,这条最容易写错

古德哈特螺旋与绕行设计

门禁的第二种死法:合规正道的成本高于绕行成本,规则就会逼出绕行设计。实证三类:

  • 一个 2729 行的巨型管理类顶在行数基线上,想加功能只能建独立转发器类去订阅它的事件,一周内同一手法复用了 4 次
  • partial 拆分被堵之后再加一道 partial 门禁,规则追着绕行走
  • 为凑 300 行硬把内聚的类拆成贫血类

审查时的判据只有一条:某个结构存在的唯一理由是让某个数字达标,这就是绕行设计,该修的是门禁不是结构。

行数类门禁的数据最能说明问题:它与 partial 门禁合计拦下全队 30 次居榜首,同一天有人 47 分钟内重推 14 次全被拦,而它们校验的是行数约定,不是主干可用性。2026-08-17 起降级为警告,形成「阻断 / 警告」双轨:警告项埋点仍记真实退出码,日报仍点名,积压超基线 10% 或连涨 3 天走人工复审。

三轮减负

预算与增量化做到极致,总耗时依然 45~52 秒。之后是三轮减负:

时间 措施 剩余成本
08-19 全局非阻断:38 项照跑、照埋点,未通过只警告 前台仍等 31~77 秒
08-24 全部 post 化:前台只读区间、回显上次结果、fork 后台子进程后 exit 0 前台 0.4~0.8s;后台仍扫 6.5 万文件,与编辑器争磁盘 IO
08-31 总开关默认关闭:第一行 exit 0,不 fork、不埋点 0

每一轮都有具体事件推动:08-24 是 GUI 推送被钩子里一次同步 git fetch 卡住数分钟;08-31 是后台扫盘争 IO 的问题前两轮都没解决。

非阻断期间的代价可量化:三天内超标文件从 2 涨到 40,超出合计 1754 行,78% 来自一个仍在迭代的模块;看板近 30 天 419 次推送、67 次判拦截。反方向的数字也有:四个 per-key 棘轮门禁漏做增量,30 天内七成推送报红,而主干实际违规只有个位数,真信号(compile_check 拦下的 32 次主干编译红)淹没在同屏红字里。

默认关闭后,主干质量的唯一兜底是一个独立计划任务:每日全量跑同一份 37 项清单出日报,不走钩子,不受总开关影响。门禁从「推送前拦截」变成「每日体检」。代价如实记录:埋点断流;半截提交不再在推送前被拦,改由推送后编译红暴露。脚本、基线、埋点代码一行未删,改一个默认值即可回滚。

埋点

从 08-17 起每次推送上报耗时与每项退出码到内网看板。收益不可见、成本可见的东西,讨论永远收不了尾;把「上个月拦了 N 次,其中 M 次是主干编译红;p95 是 X 秒」两边都摆出来才能谈。埋点自身遵守同一条铁律:绝不能影响推送,1.5 秒超时、5 分钟熔断、退出码恒 0。没有熔断,服务一挂全团队每次推送白等 1.5 秒,那正是 --no-verify 成为习惯的起点。

教训

  • 耗时是门禁的存活条件。单项串行 ≤ 2 秒的预算不是洁癖,没有它优化速度会被新增速度抵消。
  • 测耗时用串行数。并发下 IO 争抢会让 0.18 秒的脚本显示成 12 秒,照着并发数字选优化对象会选错。
  • 先问「本次区间可能影响结论吗」,再干重活。六次同病实证,--changed-only 传了不等于省了。
  • 合规正道的成本高于绕行成本,规则就会逼出绕行设计。这样的规则不配阻断,进警告名单。
  • 收益不可见的东西要先埋点再讨论。看板上的绕过长得像没人推送,数字是下限。