本文翻译自 Uber 官方博客 Translations at Scale: Memory Optimization Techniques That Kept Uber’s P99 Under 1ms ,作者 Cristian Velazquez(Sr Staff Engineer),原文发布于 2026 年 10 月 15 日。为便于阅读,译文对部分长句做了拆分,技术术语保留英文原词,文中的插图直接引用原文外链。
一句话总结:先在 Go 层把 sync.Map 换成 map[string]string 消掉每条翻译 4 个指针对象,再把 key 压缩成 6 字节哈希、value 压缩成 5 字节偏移量,只把热点条目留在内存、其余落盘。
引言
在 Uber,我们每秒要服务数百万次翻译请求。为了节省成本并降低延迟,这些翻译全部从内存中提供。保持资源效率是我们长期的目标。为此,我们重点关注 GC(Garbage Collection)——它一直是我们 Go 服务中最大的算力消耗方之一。过去我们用过 GoGCTuner 之类的工具来降低 GC 成本,但 tuner 只能靠把应用可用的内存全部用上来帮忙,当空闲内存本来就低的时候它就无能为力了。
在对整个机群做了一轮内存 profile 分析之后,我们发现翻译库在对象体积和对象数量上都是排名靠前的贡献者。本文介绍我们这些年来针对该库与 GC 交互方式所做的两项主要优化。
第一个问题:创建的对象数量过多
最初的问题是翻译服务的 GC 频率非常高,服务 20% 以上的 CPU 被 GC 吃掉,而其中大部分被创建的对象都来自翻译数据。
图 1. 内存 profile 显示 70% 的对象来自翻译。
下面的说明针对 Go 1.24 之前的版本(sync.Map 在 Go 1.24 有重大改动)。在做任何优化之前,翻译存储用的是 sync.Map。这个实现对我们的场景性能最好,原因是:
- 更新极少:大多数服务根本不使用 live updates 功能,即便用了,更新频率也极低
- 没有删除:这个 map 每隔几小时就被整体重建一次
- 读操作数量巨大
图 2 展示了 sync.Map 的结构。
图 2. sync.Map 结构。
sync.Map 的问题在于它创建的对象太多了。
我们以插入一个条目(简单的字符串 key-value 对)为例走一遍。假设你要插入 key "hello-es" 和 value "hola"。dirty 和 readOnly 基本一样,这里先只看 readOnly 结构。
为了把字符串存进 interface,Go runtime 会为字符串创建一个指针,因为 interface 只存指针(第 1 个对象)。
对于 value,有一个 *entry 指针(第 2 个对象)。entry 内部又有一个 atomic.Pointer(第 3 个对象)。要把字符串值存进 interface,还需要再一个指针(第 4 个对象)。
合计下来,存一条翻译需要创建 4 个对象。而上面这个例子里,翻译服务有超过 200 万条翻译(一条翻译是一个 <key>-<language> 组合)。
为了解决这个问题,我们改成了用值存储的简单 map 实现(map[string]string),配一个 RWMutex 来支持 live updates。
图 3. 用简单结构减少指针。
这样每次新增条目都不会再创建新对象。对这个服务来说,改动带来的效果是 CPU 使用率改善超过 30%。
图 4. 部署前后一周的 CPU 使用率对比。
第二个问题:内存占用过高
上面的优化让很多服务的 CPU 使用率显著下降,但并没有解决其他问题:
- 内存占用高:翻译数据不再创建大量对象(也就没有那么多需要 GC 追踪的引用),但内存消耗依然可观——我们减少的是指针,不是实际存储的数据。这会导致 GC 触发更频繁;而如果服务里还有很多非翻译对象,GC 的 CPU 时间可能依然很高。
- OOM:前面提到,刷新每隔几小时发生一次,其做法是先构建一个后台缓存,再与当前缓存做原子切换。这会造成最长一分钟的双倍内存占用,在某些服务上直接表现为 OOM。
我们意识到一件重要的事:真正被用到的翻译比例非常低。我们探索了几种方案。外部服务成本更高——网络调用有额外开销、延迟更高、失败概率也更大。我们也试过给每个服务生成更小的翻译子集,让它们只装自己需要的部分,但这种做法维护成本很高,比如一个 key 要在很多仓库里同步更新。最终我们采用了一个混合方案:一小部分放内存,其余全部放磁盘。
混合方案
图 5. 磁盘存储方案的高层概览。
为了尽量减少对翻译库的改动,我们沿用了同样的 API:Get() 和 Put()。
服务容器启动时,先从外部 blob storage 下载翻译数据。下载完成后解压,用流式 API 读取 JSON,避免把整个大 JSON 一次性读进内存。然后对每条新翻译调用 Put(),把 key-value 对写入。处理完 JSON 中的全部翻译后,再做原子切换,开始对外提供翻译请求。
我们唯一改动的部分是缓存的存储机制,其他一切照旧。最需要盯住的一点是延迟不能明显上升。
我们对存储的要求是:
- 所有 key 必须留在内存中以支持快速查找
- 分片(sharding)以降低锁竞争
- 用 LRU 做内存淘汰
所有 key 都放内存
大多数 key 都很小,但它们仍会吃掉总内存的 25%–30%。为了进一步压缩内存,我们决定改用整数 key,本质上就是这些 key 的哈希值。
我们最大的翻译仓库大约有 200–300 万个 key,即便按 1000 万条的仓库来算,碰撞率是:
| 字节数 | 碰撞概率 | 期望碰撞数 |
|---|---|---|
| 8 | 0.00027% | ~0 |
| 7 | 0.069% | ~0 |
| 6 | 16% | ~0.177 |
| 5 | 100% | ~45 |
结论是可以用 6 字节:第一个字节用于分片(256 个分片),剩下 5 字节用于分片内的 map。用 6 字节能把碰撞数压得很低,而内存降到原来大小的约 5%。
为了处理碰撞,我们保留了一套 fallback 机制,用原始格式的 key 兜底。实际上只有极少数 key 会走到这条路径;如果将来需要扩展字节数,改动也只有几行。
分片降低锁竞争
如上所述,哈希码的 1 个字节用于分片。这对应 256 个分片,每个分片有自己的 RWMutex、内存条目和磁盘文件。
用 LRU 做内存淘汰
我们决定实现一个简单的 LRU 淘汰策略,但有两个方案要选:
- 全局 LRU:那分片还有什么意义?
- 分片内 LRU:不太准确,因为可能从一个热点分片里淘汰数据
最终方案是全局 LRU,但基于时间。我们不在每次访问时更新 LRU,而是记录条目最后一次被访问的时间;一旦超过阈值(我们的实现里至少每秒一次),就更新全局 LRU。到目前为止,这个方案在单实例的负载下表现良好。
实现时遇到的一个挑战是:获取当前时间带来了很高的 CPU 开销。在我们的 benchmark 中,取时间额外增加了 3%–5% 的 CPU,这对这个库来说贵得离谱,因为大多数服务当前的 CPU 占用还不到 2%。因此我们需要一个 workaround。最终我们加了一个后台 goroutine,只是不断地用一个原子变量刷新最后一次上报的时间。
这还带来了一个额外的优化:我们用 LRU 的访问时间来判断哪些条目最近被访问过。执行刷新周期时,我们据此只预加载最近 15 分钟内被用过的条目。这么做的原因是:每次进入刷新周期,命中率都会下降(磁盘延迟随之上升)。
磁盘存储
主要需求讲完了,接下来讨论磁盘读写怎么做。前面说了 key 是 6 字节,那 value 存什么?我们决定存 5 字节:
- 2 字节:内存条目的索引,每个分片支持 65,000 个条目
- 3 字节:条目所在的磁盘偏移量
最终实现下来是每条 11 字节。所以一个 200 万条的仓库大约占 30MB(还要算上 map 的额外开销)。
再说磁盘偏移量。用 3 字节意味着每个分片最多索引 16MB(总共 4GiB)。我们最大的仓库大约 1GB,3 字节绰绰有余;但也要考虑仓库会持续增长,而且分片可能不均衡。
因此为了提升可索引范围,我们用了 CompressedOffsets:强制任何偏移量都能被 8 整除(不能整除就补 padding)。这样最低 3 个 bit 恒为空,于是可以把偏移量右移 3 bit,从而支持到 16MB << 3,即每个分片 128MB(总共 32GB)。
为了追求最高性能和最低磁盘占用,我们用手工序列化,因为每个条目在磁盘上只存很少几个字段。
效果示例
以前面内存 profile 的那个例子来说,压缩后的 JSON 大约 150MB,解压并加载进内存后,内存占用会涨到 800MB。
改用混合方案后,初始大小约为 40MB(只有元数据,改善超过 90%)。我们把内存条目上限配置为 50,000,通常内存占用落在 80–120MB。
发生 reload 时,我们只需要额外 40MB 的元数据,而不是再复制一份 800MB。
指标
最后看看方案的实际表现。
图 6. 每个分片 18,000 个磁盘条目。
图 7. 示例中单实例的内存条目总数。
图 8. 示例的全局命中率。
图 9. 示例每分钟全局命中总数。
图 10. 示例每分钟全局未命中总数。
图 11. 未命中的延迟。
图 12. 内存从约 2.7GB 降到约 750MB。
命中率非常高(超过 99.9%)。400 多万条(18K × 256)里我们只加载了约 30,000 条,也就是约 1% 的条目。对于那 0.1% 的未命中,我们的 p99 延迟不到 100µs。
后续计划
我们正在调研其他吃内存、且可能影响 GC 的缓存方案。如果有足够的需求,我们希望做一个能覆盖更多场景的通用方案。
结论
内存缓存是降低服务延迟的好办法。但在我们的规模下,成千上万个实例上跑着未经优化的内存缓存,成本会非常高。
为了优化成本,重要的是:
- 创建尽可能少的引用(减轻 GC 负担),这一点通过把
sync.Map换成基于值的简单 map 实现了 - 关注总内存消耗,以及这些对象真正被使用的频率。如果实际使用量只占总量的很小一部分,那就存在做混合方案的大机会
致谢
特别感谢 Internationalization 团队,他们帮助完成了评审与审批,让这些改动得以在全 Uber 铺开。
题图:原文标注为 Generated with ChatGPT。