背景脱敏:一个 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)。
缓存最危险的失效模式是「结果悄悄变了没人发现」,加缓存必须证明三件事:
- 纯全量 == 首次建缓存 == 缓存命中,比对的是集合内容,不只是数量
- 新文件(不在缓存里)能被解析到,否则新代码的违规永远漏
- 改动已缓存的文件后,缓存正确失效并抓到新违规,这条最容易写错
古德哈特螺旋与绕行设计
门禁的第二种死法:合规正道的成本高于绕行成本,规则就会逼出绕行设计。实证三类:
- 一个 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传了不等于省了。 - 合规正道的成本高于绕行成本,规则就会逼出绕行设计。这样的规则不配阻断,进警告名单。
- 收益不可见的东西要先埋点再讨论。看板上的绕过长得像没人推送,数字是下限。