最近在维护 Go 多语言翻译项目时,我越来越明显地遇到一个性能问题:
单独看每一个命令,它似乎都有自己的工作;但把整条工作流连起来看,却会发现很多命令正在反复重建几乎相同的运行环境。
这和我之前遇到的性能问题不太一样。
以前一个命令慢,我通常会直接分析这个命令:
哪里遍历太多?
哪里重复解析?
哪里可以在单次执行中复用?
有没有明显的算法问题?
这种优化方式当然仍然有效。
但当整个工作流发展到现在的规模以后,我开始发现:
真正昂贵的东西,越来越不属于某一个具体命令,而属于这些命令共同依赖的“当前环境”。
这意味着,继续逐个优化命令,收益开始变小。
下一阶段更值得考虑的问题变成了:
能不能把一次已经可靠构建并验证过的执行环境,在多个连续命令之间安全复用?
问题不是某一个命令慢,而是一整组命令都在重新准备
最近处理 Learn / Docs 的翻译流程时,有几条正式命令的耗时都已经进入数分钟级别。
例如曾经出现过:
- 当前性检查约 475 秒;
- 结果包生成约 480 秒;
- 导入约 422 秒;
- 审核包导出和检查甚至一度达到约 11 分钟;
- 整合站点相关命令也曾超过 8 分钟。
如果只看这些数字,很容易得到一个直觉:
那就分别优化这几条命令。
但真正分析以后,会发现这些命令虽然最终目的不同,前面却会执行很多相似工作。
例如它们都可能需要重新确认:
- 当前内容包成员;
- 当前翻译单元;
- 历史审核链;
- 当前候选状态;
- 表示方式的继承关系;
- 术语表和兼容状态;
- 正式回执;
- 当前指针;
- SHA 与身份标识;
- 哪些结果仍然有效;
- 哪些历史状态已经失效。
这些检查单独看都很合理。
问题在于:
命令 A 刚刚花几分钟建立完这些信息,命令 B 启动以后,又从头建立一次。
命令 C 再来一次。
命令 D 还会再来一次。
于是整条工作流的实际耗时,并不只是:
每个命令真正要完成的业务工作。
还包含了:
多次重复构建同一个可信上下文。
这就是我现在认为更值得优化的地方。
单命令优化的收益开始接近上限
前一阶段我已经做过不少单命令内部的优化。
最典型的方法就是:
同一次命令执行过程中,已经计算过的结果不要重复计算。
例如某个历史状态已经成功解析并验证过一次,那么同一次调用后面的多个步骤就可以复用它,而不是每次都重新从磁盘读取、重新遍历、重新计算。
这种优化有一个很大的优点:
它的边界很清楚。
命令退出以后,所有临时状态一起消失。
下一次执行仍然从真实仓库重新建立当前状态。
所以它比较容易证明:
- 不会把旧状态误认为当前状态;
- 不会因为缓存残留跳过失效检查;
- 不会跨进程污染;
- 不会改变正式身份;
- 不会让一次执行依赖另一次执行留下来的隐含状态。
这种“单次调用内复用”,一直是我比较喜欢的优化方式。
但它也有一个天然限制:
它只能解决一个进程内部的重复工作。
一旦命令结束,下一个正式命令重新启动,前面几分钟建立起来的所有环境就全部丢失。
于是同样的准备工作又重新执行。
当一条完整工作流包含很多连续命令时,这部分成本就开始变得非常明显。
我开始怀疑:真正应该复用的是“环境”,而不是某个结果
最开始想到跨命令优化时,很容易想到“缓存”。
比如:
把某些结果写到磁盘,下一个命令直接读缓存。
但我现在越来越觉得,“缓存”这个词太容易让人走向错误方向。
因为正式翻译工作流最重要的并不是:
有没有一个旧结果可以拿来用?
而是:
这个结果为什么现在仍然可以用?
例如一个历史审核结果,即使文件本身完全没变,也不能只因为磁盘里存在一份缓存,就直接认定它仍然有效。
还需要确认它所依赖的:
- 翻译单元身份;
- 内容包成员;
- 表示方式;
- 上游源内容;
- 审核链;
- 当前指针;
- 相关 SHA;
是不是仍然和当前状态一致。
所以我现在更倾向于把这个问题描述成:
共享已经验证过的执行环境。
而不是:
共享旧计算结果。
两者看起来很像,但设计重点完全不同。
普通缓存关注:
这个值之前算过没有?
共享可信环境更关心:
这个值之前已经在什么前提下被证明有效?
这些前提现在是否仍然成立?
性能优化不能把“当前性”一起缓存掉
这类系统最危险的优化方式,是为了速度把当前性检查本身绕过去。
例如某个命令刚刚确认:
当前内容包是 51 个页面。
下一个命令如果只是因为:
前一个命令五分钟前检查过了。
就完全跳过当前状态确认,那么中间一旦发生正式状态变化,后面的结果就可能建立在已经失效的环境上。
所以跨命令复用真正困难的地方,并不是如何把对象留在内存里。
真正困难的是:
如何证明复用发生时,原来的环境仍然适用于当前命令。
这也是为什么我不会直接把所有昂贵对象做成长期全局缓存。
性能优化必须保留几个基本性质:
当前性仍然要成立。
失效传播仍然要成立。
身份标识仍然要一致。
SHA 校验仍然要可信。
历史来源仍然可以追溯。
无法确认时仍然必须停止。
如果为了节省几分钟,把这些性质中的任何一个变成:
大概没问题。
那这个优化就没有意义了。
冷启动必须永远能够独立工作
这也是我现在非常看重的一条边界。
即使未来实现跨命令环境复用,我仍然希望:
完全没有任何复用状态时,任意正式命令都能独立从仓库建立正确环境。
也就是说,冷启动必须始终是完整、正确的执行路径。
共享环境只能是:
在可以证明安全的情况下减少重复计算。
而不能变成:
没有前一个命令留下来的状态,当前命令就无法正确工作。
这是一个非常重要的区别。
如果系统必须依赖“先跑过命令 A”,命令 B 才能获得正确结果,那么这已经不只是性能优化了。
它实际上引入了新的隐式状态。
而隐式状态正是正式工作流里最难排查的问题之一。
理想状态应该是:
冷启动和复用启动得到完全相同的正式结论。
区别只有一个:
复用启动少做了一些已经证明不需要重复执行的计算。
跨命令复用比进程内缓存难很多
单次命令内部做缓存,相对容易。
因为所有对象都属于同一个进程。
你可以明确知道:
- 输入没有变化;
- 当前工作目录没有变化;
- 已经加载的数据就是本次调用的数据;
- 命令退出后状态自动销毁。
跨命令以后,这些条件都不能再默认成立。
例如命令 A 执行完成以后,到命令 B 开始之前,可能发生:
- 仓库文件被修改;
- 新的正式回执被写入;
- 当前指针发生变化;
- 内容包成员发生变化;
- 某个表示方式产生了后继状态;
- 某个候选快照被重新建立;
- 用户切换分支;
- 另一个进程产生并发修改。
因此共享环境必须回答一个更复杂的问题:
从上一次构建环境到现在,哪些东西发生过变化?
如果不能回答,就只能丢弃环境重新构建。
这也是“无法确认时按失败处理”在性能优化里的一个具体体现:
不能证明环境仍然有效,就不复用。
真正值得复用的,可能是一组有边界的已验证对象
从目前的经验看,我觉得未来比较合理的方向,并不是维护一个巨大的永久全局状态。
更可能是:
建立一个有明确生命周期、明确输入身份、明确失效条件的执行环境。
这个环境里可以包含一组已经完成验证的对象,例如:
- 当前内容包成员解析结果;
- 已经验证过的历史审核链;
- 已经加载并校验的正式回执;
- 当前表示方式关系;
- 已经计算的身份映射;
- 不会在同一阶段内变化的只读索引。
然后把它交给多个连续消费者。
例如:
第一个命令建立环境。
第二个命令确认环境输入身份仍然一致,于是复用。
第三个命令如果发现正式状态已经发生变化,就让旧环境失效,重新建立。
这样做真正节省的不是某一个函数的几十毫秒。
而是:
数个连续命令每次都重新读取、解析、遍历、验证整套历史状态的成本。
为什么这比继续优化某一条 8 分钟的命令更值得研究?
假设现在有三条命令:
第一条 8 分钟。
第二条 7 分钟。
第三条 7 分钟。
如果分别投入大量时间,把它们优化成:
7 分钟、6 分钟、6 分钟。
总耗时从 22 分钟变成 19 分钟。
当然有收益。
但如果分析以后发现,它们每一条都花了 4 分钟重新构建同一类环境,那么更有价值的问题就变成:
能不能把这 4 分钟只做一次?
如果可以安全实现,那么收益就可能不是:
某一条命令快 10%。
而是:
整条链路少掉多次重复的基础成本。
这就是架构级优化和局部优化之间的区别。
局部优化通常问:
这个函数还能不能更快?
架构级优化问的是:
这件事情为什么要重复做三次?
但共享环境也不能为了性能变成新的复杂系统
这里还有一个我非常警惕的问题。
跨命令复用很容易一路演变成:
- 常驻服务;
- 全局缓存;
- 多级缓存;
- LRU;
- 磁盘索引;
- 自动失效;
- 后台刷新;
- 并发更新。
这些方案理论上都能继续优化性能。
但对于一个低频、强调可恢复和可证明性的正式翻译工作流来说,它们不一定值得。
因为每增加一层缓存,就会增加新的问题:
谁负责失效?
如何证明缓存没有过期?
并发修改怎么办?
崩溃以后如何恢复?
缓存损坏怎么办?
是否会影响正式身份?
Debug 时如何判断拿到的是实时结果还是旧结果?
如果最后为了节省几分钟,构建了一套比原工作流还复杂的缓存一致性系统,那很可能已经偏离了目标。
所以我现在仍然倾向于:
优先选择简单、有边界、生命周期明确的复用方式。
例如让一组紧密相邻、共同依赖同一只读环境的命令共享一次环境构建。
而不是一开始就设计一个永久存在的全局缓存层。
性能测试也应该越来越接近真实工作流
这次还有一个变化是:
我越来越不愿意为了性能优化单独跑很多长时间的“热缓存 benchmark”。
原因很简单。
如果一个命令本身就要运行 5~10 分钟,连续执行四五次只为了比较冷启动和热启动,会产生很高的测试成本。
而且这类测试很容易优化出一个漂亮但不真实的数字:
第二次运行非常快。
但正式工作流真实情况可能是:
每次都是一个新进程。
每次执行前正式状态已经发生变化。
下一条命令根本不会命中同一份缓存。
那么这个“热启动数字”对真实生命周期的价值就很有限。
所以现在我更希望:
让真实的下一条工作流命令承担性能验收。
优化某个共同环境以后,不额外制造大量重复 benchmark。
而是继续走真实流程。
如果接下来的:
- 当前性检查;
- 结果包;
- 导入;
- 审核包;
- 整合站点;
都自然获得收益,那么这个优化才真正有意义。
这样得到的不是实验室数字。
而是:
整条真实生命周期确实变快了。
这也改变了我判断性能问题的方式
以前看到一条命令跑 8 分钟,我的第一反应可能是:
为什么这条命令这么慢?
现在我更容易先问:
它慢的这 8 分钟里,有多少是在做真正属于自己的工作?
又有多少是在重新建立前一条命令刚刚建立过的环境?
如果大部分时间来自后者,那么继续钻进这条命令内部优化,可能方向就错了。
这时候应该把性能分析范围扩大到:
整条工作流。
甚至从:
command profile
转向:
lifecycle profile
因为真正影响维护体验的,从来不是某一个命令的数字。
而是:
从“我开始处理这个翻译单元”到“它可以进入下一个正式阶段”,总共需要多久。
我现在对这类优化的判断标准
经过前面几轮性能优化以后,我现在更愿意用下面几个问题判断一个跨命令复用方案是否值得做。
第一:
它复用的是计算,还是偷偷复用了已经可能失效的正式结论?
第二:
没有共享环境时,冷启动是否仍然能够得到完全正确的结果?
第三:
环境什么时候失效,能不能被机器明确判断?
第四:
复用以后,当前性检查、身份标识、SHA 和历史来源是否仍然完整?
第五:
失败以后能不能安全回到完全不复用的路径?
第六:
它优化的是一条命令,还是整条生命周期中会重复出现的共同成本?
第七:
实现和验证成本,是否明显低于未来重复节省的时间?
只要其中几个问题回答不清楚,我宁可继续接受几分钟的慢速执行,也不会急着引入一个长期存在的复杂缓存系统。
从“让命令更快”到“让工作流少做重复的事”
我觉得这可能是这几个月性能优化里一个比较明显的思路变化。
最开始是:
这条命令为什么慢?
后来变成:
这条命令里面为什么重复算?
现在进一步变成:
为什么上一条命令已经算过的环境,下一条命令还要完整重建?
这三个问题代表的是不同层次的性能优化。
第一层是代码级优化。
第二层是单次调用内的计算复用。
第三层则是工作流级的执行环境复用。
越往后,潜在收益越大。
但同时,对正确性的要求也越高。
尤其在我的多语言翻译项目里,正式工作流已经越来越强调:
- 历史不可变;
- 当前状态明确;
- 身份可验证;
- 独立审核;
- 失效传播;
- 可恢复;
- 无法确认时停止。
性能优化不能成为这些原则的例外。
反而应该建立在它们之上。
最后
现在回头看,我越来越觉得:
性能优化最有价值的阶段,不是把某一个已经很快的函数再缩短几十毫秒。
而是发现:
整个系统正在反复做一件本来可以安全共享的昂贵工作。
对于现在这套翻译工作流来说,下一阶段值得研究的就是这种问题:
如何让连续的正式命令共享已经验证过的执行环境,同时又不让“复用”变成新的隐式状态。
如果最终能够做到这一点,真正得到的就不只是:
某一条命令快了一点。
而是:
整条翻译生命周期减少了大量重复准备工作。
这也是我现在理解的性能架构优化:
不是让机器更快地重复同一件事,而是先问清楚,这件事为什么还需要重复做。
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 官方无隶属、授权或背书关系。

