前面几篇文章里,我已经陆续写到了 Go Learning & Documentation v2 中的 Unified Glossary、Semantic Compatibility,以及为什么术语表变化以后,不应该让整个 locale 的 TranslationUnit 全部重新翻译和审核。
为了做到这一点,系统需要比以前做更多事情。
以前很多判断可能只需要比较:
old SHA == new SHA ?现在则要进一步知道:
Glossary 到底改了什么?
↓
哪些内容与这些变化有关?
↓
已有译文是否已经满足新的规则?
↓
到底是 Compatible,还是 Affected?从语言质量和减少重复工作的角度看,这个方向是对的。
但它很快暴露出了另外一个问题:
CLI 开始越来越慢。
而且不是慢一点。
有些原本应该在几秒内完成的只读检查,后来需要几分钟。
其中一次比较典型的实测是:
6m54.554s优化以后:
10.335s大约快了 40 倍。
这篇文章不再讨论“为什么需要 Semantic Compatibility”,也不再重新讨论之前做过的架构方案选择。
这次只记录一件事:
一个语义设计本身没有问题的功能,为什么会把 CLI 拖到分钟级,以及后来具体怎样把它重新优化回秒级。
性能问题并不是出现在一个特别复杂的算法里
刚开始看到一个命令运行六七分钟时,很容易怀疑:
是不是 Semantic Compatibility 本身太复杂?
是不是扫描的 Page 太多?
是不是 Markdown parser 太慢?
是不是 SHA 计算太重?
是不是 Go 的某段实现有明显的 O(n²) 问题?
但真正定位以后发现,问题没有那么神秘。
不是某一次计算特别慢。
而是:
同一份昂贵的状态,在一次 CLI invocation 中被重复计算了很多次。
这两种情况非常不一样。
如果一次 Compatibility preparation 本身就需要 5 分钟,那么优化空间可能比较有限。
但如果一次 preparation 只需要几秒,却在一个循环里重新做了 100 多次,那么问题其实非常明确:
不应该重复做。
一个 Page loop,偷偷变成了 100 多次完整重建
以当时的 Course SEO stale-subset preflight 为例。
这个命令需要检查 103 个 Page。
从业务逻辑上看,很自然会写成类似:
for each Page:
validate current state
determine whether Page is stale问题在于,当时 Page-level 判断内部会继续进入 Glossary Compatibility。
而 Compatibility preparation 又不是一次简单的字符串查询。
它需要建立比较完整的当前状态,例如:
- glossary delta;
- context inventory;
- archived evidence;
- compatibility edges;
- current candidate;
- QC lineage;
- 相关 identity。
于是实际执行过程逐渐变成了:
Page 1
↓
PrepareGlossaryCompatibility()
Page 2
↓
PrepareGlossaryCompatibility()
Page 3
↓
PrepareGlossaryCompatibility()
...
Page 103
↓
PrepareGlossaryCompatibility()最后还有其他路径再次准备一次。
结果,一个本来应该共享的状态,被重新构建了大约 104 次。
另一个 Quality Check scope 路径中,情况也非常相似。
122 个 TranslationUnit 逐个计算 scope 时,会反复重建同一个包含约 470 个 context 的 compatibility world。
于是表面上只是:
122 个 TU实际上却接近:
122 × 完整 compatibility preparation这就是为什么 CPU 时间会迅速放大。
一次 quality-check scope 实测已经达到大约:
wall ≈ 373s
user CPU ≈ 400s这已经不是“CLI 有一点慢”。
而是直接影响整个工作流能否正常使用。
第一个重要判断:不要砍掉 Semantic Compatibility
遇到这种性能问题时,有一个非常容易想到的解决办法:
Semantic Compatibility 太贵了,那就退回简单 SHA gate。
性能肯定马上恢复。
但这会重新带回前面已经解决的问题。
Glossary 只修改一个很小的术语,也可能导致整个 locale 的已有结果被判 stale。
也就是说:
用语言质量和精确 invalidation,去换 CLI 速度。
我不希望这样解决。
因为性能问题真正说明的并不是:
“Semantic Compatibility 这个设计错了。”
而是:
它的执行方式还没有匹配它的计算成本。
所以优化目标从一开始就比较明确:
保持原来的 fail-closed 语义和 compatibility 结果不变,只减少重复计算。
真正需要的是 invocation-local prepared state
最后采用的核心思路其实很简单:
一次 CLI invocation 中,同一份确定性的昂贵状态只构建一次。
可以把原来的执行方式简化成:
Page 1 → Prepare
Page 2 → Prepare
Page 3 → Prepare
Page 4 → Prepare
...改成:
Prepare once
↓
Prepared Resolver
↓
Page 1
Page 2
Page 3
Page 4
...也就是说,在一次命令运行过程中,先得到一份共享的 prepared state / prepared resolver。
后面的 Page、TranslationUnit 或其他 workflow consumer 都从这份已经构建好的状态中读取结果。
不是每次重新从磁盘、archive、glossary、candidate 和 lineage 开始推导一次。
这个变化听起来很像普通缓存。
但我当时刻意没有把它设计成 persistent cache。
为什么不直接做磁盘缓存
既然计算昂贵,一个自然的想法就是:
第一次算完
↓
保存到磁盘
↓
下次直接复用这样理论上还能更快。
但这会引入另外一整套问题:
缓存 identity 是什么?
什么情况下失效?
Glossary 变了怎么办?
Candidate 变了怎么办?
QC lineage 更新以后怎么办?
旧 cache 与新 authority 冲突时谁优先?
cache 是否也要进入 evidence schema?
Production / Reviewer 是否能够依赖它?
本来只是想解决一次 CLI invocation 中的重复工作,很容易又创造出一套新的持久状态管理系统。
而当时真正的问题其实没有这么复杂。
我们已经知道:
同一次 invocation 中,很多输入根本不会变化。
所以完全没有必要跨 invocation 保存。
最后采用的是:
invocation-local reuse。
命令退出以后,这份 prepared state 直接消失。
下一次执行,再根据新的 current state 重新准备。
这样既能获得绝大部分性能收益,又不需要新增:
- persistent cache;
- cache schema;
- cache migration;
- cache invalidation;
- 新的 receipt;
- 新的 authority。
这点我后来觉得非常重要。
性能优化很容易变成:
为了解决一个复杂度问题,再增加一套新的状态复杂度。
而这次最好的一点,就是没有走到那里。
Prepared state 不是“跳过验证”
这里还有一个容易产生误解的地方。
共享 prepared state 并不是:
第一个 Page 检查过了,剩下的 Page 就不检查了。
真正共享的是:
那些对整个 invocation 来说相同的昂贵基础状态。
每个 Page 自己仍然需要做自己的判断。
例如在 Course SEO 的场景中:
Shared:
glossary compatibility preparation
common inventory
common evidence state
Per Page:
seo:
seo-page:
Page-specific stale decision所以优化以后仍然是:
103 Pages
↓
103 个 Page 分别验证只是不会变成:
103 Pages
↓
104 次完整 compatibility world rebuild这两件事不能混在一起。
如果为了性能直接减少真正需要的 Page-level validation,那只是把问题隐藏了。
我还专门加了 instrumentation
仅仅感觉“应该只跑一次”是不够的。
这种优化最容易出现的问题就是:
代码看起来共享了,实际上某个内部调用又偷偷重新构建了一份。
所以在优化过程中,我还给相关路径加了 instrumentation。
目标非常直接:
对于一次完整 scope invocation,类似下面这些昂贵步骤:
PrepareCalls
FullReviewChecks
ContextInventoryBuilds
EvidenceSetReads
EvidenceVerifications
EdgePreparations正常情况下都应该接近:
1而不是:
122
103
470
...优化以后,相关 instrumentation 最终确认这些 preparation 都收敛到了单次执行。
同时,原来的 exact transition 结果保持不变。
也就是说:
我们改变的是计算过程,没有改变状态判断。
这比单纯看到 benchmark 变快更重要。
第一次效果已经非常明显
在 quality-check scope 这条路径上,原来的完整 scope 实测大约需要:
373 秒优化以后,同类 scope 已经可以回到秒级。
后来其他等价的 Reviewer Bundle preparation 路径也继续复用了同一个 prepared resolver。
其中一次实际结果大约是:
scope wall ≈ 2.10s
reviewer-bundle-equivalent wall ≈ 9.92s这里不同命令承担的工作不同,所以我不会简单把两个数字直接当成完全相同 workload 的 benchmark。
但至少已经可以很明显地看到:
原来那种每个 TU 都重新建立完整 compatibility world 的模式消失了。
CLI 又重新回到了可以正常交互使用的范围。
Course SEO 又把同一个问题暴露了一次
比较有意思的是,Quality Check 优化以后,我后来在 Course SEO 中又遇到了几乎同样的问题。
当时需要计算 103 个 Page 中哪些 SEO 内容因为新的 glossary 状态而 stale。
准确结果只有 6 个 Page:
flowcontrol/9
concurrency/1
concurrency/2
concurrency/5
concurrency/6
concurrency/9但整个 preflight 却耗时:
6m54.554s也就是:
414.554s真正排查以后,又发现了熟悉的模式:
for Page:
PrepareGlossaryCompatibility()大约 103 个 Page,再加上最终额外路径,总共大约 104 次 preparation。
这一次修复就很直接了。
不再重新发明另一套优化方案。
而是把 Quality Check 已经验证过的思路继续推广:
invocation-local shared lazy resolver。
优化以后再次运行:
10.335s原来:
414.554s变成:
10.335s大约是:
40.1×而更重要的是:
stale Pages = 6前后完全没有变化。
这正是我希望看到的性能优化:
同样的答案,只是不要重复做同样的准备工作。
“Lazy” 也很重要
后来这个 resolver 又进一步采用了 lazy 的方式。
原因是并不是每一个 CLI command 都一定会使用 Compatibility 的全部能力。
如果命令启动时无条件准备所有 state,虽然已经解决了 N 次重复问题,但一些只使用少量功能的路径仍然会承担不必要的初始化成本。
所以更合理的结构变成:
invocation
↓
shared resolver
↓
某项能力第一次真正需要
↓
prepare once
↓
后续全部复用这样同时满足两个目标:
第一,昂贵状态不会重复准备。
第二,如果某个命令根本用不到这部分状态,就不必提前支付这笔成本。
这其实也是很普通的软件工程思想。
但在复杂 workflow 中,如果不刻意设计,很容易被一层一层的 helper function 掩盖掉。
真正的问题往往藏在“看起来很便宜”的函数后面
这次优化让我印象比较深的一点,是调用端的代码往往看起来并不危险。
例如:
CheckPage(page)看起来只是检查一个 Page。
或者:
ResolveCompatibility(unit)看起来只是查询一个 TranslationUnit。
但如果这个函数内部每次都会继续:
read archives
↓
load evidence
↓
verify identity
↓
build inventory
↓
prepare edges
↓
construct world那么它就完全不是一个便宜的 getter。
把它放进:
for 122 units和放进:
for 103 pages以后,性能会迅速失控。
这也是为什么后来我觉得:
昂贵 preparation 应该在 architecture 上变得可见。
如果某个操作需要完整构建一个 compatibility world,就不应该让调用方误以为那只是一次普通查询。
Prepared resolver 实际上也起到了这种作用。
它让代码结构变得更加接近真实成本:
prepare expensive shared state
↓
perform many cheap queries而不是:
perform many apparently cheap queries
↓
每次偷偷做一次 expensive preparation这不是某一条命令的特殊优化
另一个很重要的决定,是没有为了 zh-CN 或某一条命令写一个特殊 fast path。
例如完全可以针对:
quality-check scope --locale zh-CN专门加一段逻辑。
或者针对 Course SEO 的 103 个 Page 做一个特殊 batch。
短期可能更快。
但以后增加另外一个 locale,或者新的 command 又进入同一套 Compatibility 逻辑时,问题还是会回来。
所以真正需要优化的是公共执行模型:
同一次 invocation 共享相同的 deterministic prepared state。
这让后来的:
- Quality Check;
- Reviewer Bundle;
- finalization;
- retranslation export;
- Course SEO;
- 其他需要 compatibility 的 workflow;
都可以逐渐复用同一种方式。
也就是说,这不是:
“把一个慢命令修快。”
而是在修:
为什么所有调用这一类昂贵状态的命令都会逐渐变慢。
性能优化还不能破坏 fail-closed
这类共享状态还有一个特别需要小心的问题。
如果 resolver 为了追求性能,发现 identity 不一致时继续使用旧 state,那么速度是快了,但 correctness 已经丢了。
所以 prepared state 可以共享的前提仍然是:
它属于同一个确定的 invocation 和同一组 current inputs。
出现真实的不一致、缺失或者无法证明的状态时,仍然应该:
FAILED而不是:
fallback to something cached所以这次优化没有改变原来的 fail-closed 原则。
也没有为了 benchmark 好看而降低 validation 数量。
真正减少的只有:
确定性的重复准备工作。
从 40 倍提升里,我真正关心的不是 40 倍
414.554s → 10.335s,大约 40.1 倍。
这个数字当然很适合作为 benchmark。
但回头看,我觉得更重要的并不是:
“我把 Go CLI 优化了 40 倍。”
而是这次优化验证了一种比较健康的系统演进方式。
最开始:
SHA gate很快。
后来为了提高准确性,引入:
Semantic Compatibility正确性更好,但执行成本明显上升。
如果这时候因为性能问题直接把新的语义能力删掉,其实就是架构倒退。
更合理的路径应该是:
更精确的语义模型
+
与之匹配的执行模型最终同时保留:
- 更精确的 affected scope;
- trusted result reuse;
- fail-closed;
- deterministic evidence;
- 秒级 CLI。
这才是我觉得这次优化真正有价值的地方。
性能本身也是工作流的一部分
以前做 CLI 时,我有时候会觉得:
只要结果正确,慢一点也没什么。
但这次 v2 做下来以后,我越来越觉得,这种想法只在很小的系统里成立。
当一个 workflow 需要反复运行:
- scope;
- bundle export;
- current check;
- validation;
- QC record;
- revision;
- re-QC;
- finalization;
- Surface;
如果其中每一步都需要等几分钟,真正损失的不只是 CPU 时间。
还有人的注意力。
一个 2 秒完成的 command,可以连续执行和判断下一步。
一个 7 分钟的 command,则会不断打断工作节奏。
更麻烦的是,当某一步失败以后,还要再次等待同样的几分钟才能验证修复结果。
因此对于这种大量依赖 CLI 推进状态的工程:
性能并不是锦上添花。
它其实直接决定了整个 workflow 是否能够高效迭代。
这次最终形成的原则
现在回头看,这次性能优化可以总结成一个非常简单的原则:
昂贵、确定、在同一次 invocation 中不会变化的状态,只准备一次。
然后:
Prepare once
↓
Reuse many times
↓
Per-unit validation remains
↓
Exact result unchanged不新增 persistent cache。
不为某一个 locale 写特殊路径。
不减少真正需要的 validation。
不改变 Compatibility 的判断语义。
不改变 fail-closed。
只是把原本隐藏在循环里的重复工作拿掉。
有时候,几十倍的性能提升并不需要一个特别聪明的新算法。
只是需要先看清:
系统到底在重复做什么。
下一篇
解决 CLI 性能问题以后,v2 还有另外一类比速度更麻烦的问题。
那就是 parser 和 representation。
Tour、Learn、Docs 中同时存在 Markdown、inline code、link binding、完整代码、教学注释等不同结构。
机器必须足够严格,才能保证这些内容不会在 AI 翻译过程中被破坏。
但如果保护规则过于机械,又可能反过来限制目标语言的自然表达。
所以接下来准备继续整理:
Learn / Docs 为什么需要新的 parser 与结构保护,以及怎样在机器安全和自然语言之间找到边界。
Go 学习与文档多语言本地化项目
本系列完整记录 Go 学习与文档多语言本地化项目从 A Tour of Go 起步,逐步扩展到站点首页、项目说明页、学习内容、文档以及未来内容包的实际开发过程,包括架构设计、统一术语治理、整页翻译、结构保护、自动校验、独立质量审核、生产发布与长期维护。
项目源码:
✅ GitHub:shuijingwan/go-tour-i18n
语言版本:
当前跨项目目标语言池共包含 66 个语言版本。Go 项目本身目前提供 65 个社区 Production 语言版本,下面每一门 Go 语言版本都直接链接到其当前公开站点。语言列表沿用项目首页现有的展示顺序。
英语(美国)仅作为跨项目目标语言纳入列表,并不是单独的 Go Production 语言版本。当前 Go 的英文源内容仍为官方 generic English,因此英语(美国)链接到 Go 官方站点,而不是不存在的本地社区部署。
- 阿姆哈拉语
- 阿拉伯语
- 孟加拉语
- 葡萄牙语(巴西)
- 保加利亚语
- 加泰罗尼亚语
- 克罗地亚语
- 捷克语
- 丹麦语
- 荷兰语
- 英语(美国)(Go 官方英文站点)
- 英语(澳大利亚)
- 英语(加拿大)
- 英语(印度)
- 英语(新加坡)
- 英语(南非)
- 英语(英国)
- 爱沙尼亚语
- 菲律宾语
- 芬兰语
- 法语(法国)
- 法语(加拿大)
- 德语(德国)
- 德语(奥地利)
- 德语(瑞士)
- 希腊语
- 古吉拉特语
- 希伯来语
- 印地语
- 匈牙利语
- 印度尼西亚语
- 意大利语
- 日语
- 卡纳达语
- 哈萨克语
- 韩语
- 西班牙语(拉丁美洲)
- 拉脱维亚语
- 立陶宛语
- 马来语
- 马拉雅拉姆语
- 马拉地语
- 书面挪威语(Bokmål)
- 波斯语
- 波兰语
- 葡萄牙语(葡萄牙)
- 旁遮普语(果鲁穆奇文,印度)
- 罗马尼亚语
- 俄语
- 塞尔维亚语(西里尔文)
- 简体中文(中国大陆)
- 简体中文(新加坡)
- 斯洛伐克语
- 斯洛文尼亚语
- 西班牙语(西班牙)
- 斯瓦希里语(坦桑尼亚)
- 瑞典语
- 泰米尔语
- 泰卢固语
- 泰语
- 繁体中文(台湾)
- 繁体中文(香港)
- 土耳其语
- 乌克兰语
- 乌尔都语
- 越南语
项目正在持续扩展新的 Go 学习与文档内容,并长期维护各语言版本的术语一致性、翻译质量、独立审核、生产发布与后续更新。
本项目属于非官方社区多语言本地化项目,与 Go 官方无隶属、授权或背书关系。

