https://t.co/3tyMv0rf0f
TL;DR · AI 摘要
meng shao on X: " / X meng shao @shao meng $ Uber 工程团队翻译库优化实录:砍掉 sync.Map 膨胀,混合存储让内存从 2.7GB 降至 750MB 1 2 Uber 生产工程团队 @Ub...
核心要点
- 主题聚焦:未命名内容
- 来源:meng shao(@shao__meng),建议结合原文判断细节。
- AI 分析暂不可用,本条为保底评分与摘要。
meng shao on X: "https://t.co/3tyMv0rf0f" / X
meng shao
@shao__meng
$
Uber 工程团队翻译库优化实录:砍掉 sync.Map 膨胀,混合存储让内存从 2.7GB 降至 750MB
1
2
Uber 生产工程团队 @UberEng 如何把翻译服务的内存占用从约 2.7GB 压到 750MB、GC 压力大幅下降、p99 延迟控制在 1ms 以内?
Uber 工程实践:Translations at Scale
$?
/$
背景:一个“不起眼”的基础库成了全局瓶颈
Uber 的翻译量是每秒百万级,全部放在内存里服务,以换取低成本和低延迟。问题在于: 对 Uber 的 Go 服务来说,GC 是最大的计算资源消耗者之一 ,而全集群的内存 profile 显示,这个翻译库无论按内存体积还是对象数量衡量,都是头部贡献者。
这里有一个值得注意的工程方法论:Uber 没有凭感觉优化,是先做了 全集群内存画像 ,用数据定位到“谁在制造垃圾”。这是整篇文章的前提:优化的对象不是某个明星服务,是一个被无数服务隐式依赖的公共库,所以任何改动都要保持 API 不变(Get() / Put()),这是贯穿全文的约束条件。
一个具体的服务级证据:翻译服务超过 20% 的 CPU 花在 GC 上,而其中约 70% 的对象来自翻译数据。
第一个问题:对象数量爆炸,sync.Map 的隐性代价
为什么当初用 sync.Map
翻译存储的访问模式很极端: 读极其频繁、更新很少(每隔几小时整体重建一次)、从不删除 。sync.Map 正是为“读多写少”场景设计的,这个选型在当时是合理的。
问题出在哪
向 sync.Map 插入一个键值对(两个 string),会在堆上产生 4 个对象 :
1.key 装进 interface 时的指针(指向 string header)
2.*entry 指针
3.entry 内部的 atomic.Pointer
4.value 装进 interface 时的指针
也就是说,真正有用的数据只有两个字符串,却额外背上了 4 个堆对象的 GC 负担。乘以 200 万+ 条翻译(键 × 语言的组合),就是数百万个长寿对象,GC 每个周期都要扫描标记它们。
文章特别注明:这个问题针对 Go 1.24 之前的版本 (1.24 重写了 sync.Map 的底层实现)。这个细节体现了文章的严谨性:它不是在说"sync.Map 永远不好”,是“在你所用的运行时版本下,它的数据结构不适合你的数据形态”。
解法与结果
改成一个普通的 值类型的 map[string]string,用 RWMutex 保护并发 。因为从不删除、更新是整体替换,锁竞争本来就低,RWMutex 足够。仅这一项改动就带来 30% 以上的 CPU 改善 (部署前后各一周的数据对比)。
这是全文性价比最高的一步:没有改架构,只是把“错误的容器”换成“正确的容器”。核心教训是: 每多一个引用,GC 就多一份扫描工作;存值而不是存指针,本身就是优化 。
第二个问题:内存占用本身,从“全内存”到“混合存储”
对象数降下来之后,还剩两个问题:
- 内存总量高 :数据本身还在内存里,GC 周期依旧频繁;
- 刷新时的 OOM 风险 :刷新采用“后台构建新缓存、原子切换”的模式,切换前新旧两份数据并存,内存峰值翻倍,最长持续约一分钟。
关键洞察
Uber 观察到一个朴素但决定性的事实: 翻译条目的实际使用率非常低 ,几百万条里只有很小比例会被真正读到。这是整个混合方案的支点。
他们否决了两个备选方案:
• 外部翻译服务 :引入网络开销、延迟和故障面,违背“全内存换低延迟”的初衷;
• 按服务定制翻译子集 :每个服务只加载自己需要的部分,维护成本太高,翻译的使用情况也是动态的。
最终选择: 小部分留内存,其余全部落磁盘; 本质上是把操作系统的 page cache 思路在应用层重做了一遍,但用应用知识(访问模式、冷热分布)做了针对性设计。
混合方案的设计细节
- 键全部留在内存,但用截断哈希压缩
要快速判断“这个 key 存在、值在磁盘哪里”,索引必须全量在内存,但原始字符串 key 太大。方案是把 key 哈希后截断。他们对 1000 万 key 的仓库做了 碰撞概率分析 :
哈希字节数
碰撞概率
8 字节
0.00027%
7 字节
0.069%
6 字节
16%(期望约 0.177 次碰撞)
5 字节
100%(期望约 45 次碰撞)
最终选了 6 字节 = 1 字节分片路由 + 5 字节分片内查找 。6 字节在 1000 万 key 规模下期望碰撞不到 0.2 次,而内存降到原来的约 5%;碰撞时回退用原始 key 查询,将来想扩容也只需改几行代码。这是一个教科书式的工程权衡: 接受可量化的、极低概率的碰撞,换取一个数量级的内存节省,并保留升级路径 。
- 256 个分片,各持一把锁
1 字节哈希天然给出 256 个分片,每个分片独立持有 RWMutex、内存条目和磁盘文件,把锁竞争和刷新粒度都拆到极小。
- LRU 淘汰:三个方案的取舍
•全局 LRU:锁竞争大,抵消分片的意义,否;
•分片内 LRU:可能把热点分片的条目挤出去,否;
• 最终方案:基于时间的全局 LRU ,每个条目记录最后访问时间,只有超过阈值才更新全局 LRU 结构, 每秒至多更新一次 。
这里有个很真实的小插曲:读取当前时间(time.Now)本身要消耗 3–5% 的额外 CPU,对一个是“库”而非“服务”的组件来说不可接受(它在多数服务里 CPU 占比不到 2%)。解法是一个后台 goroutine 持续把当前时间写入一个原子变量,读时间变成了一次原子读。 连“看表”都要计成本,这是库作者的自觉 。
另外,LRU 的访问时间还在刷新时被二次利用: 只预加载最近 15 分钟被访问过的条目 ,消除了刷新后命中率下跌、磁盘延迟尖峰的问题。
- 磁盘布局:手工序列化,11 字节一条
每个条目在内存中的值部分只有 5 字节: 2 字节指向分片内条目索引(每分片 65,535 条上限)+ 3 字节磁盘偏移 。加上 6 字节 key,每条约 11 字节,200 万 key 的仓库含 map 开销只要约 30MB。
3 字节偏移本来只能索引每分片 16MB(共 4GiB),但通过 压缩偏移( 强制所有偏移可被 8 整除、末尾补 padding)把 3 个最低位“省”出来做移位,寻址范围扩大 8 倍,达到每分片 128MB、全库 32GB,而实际最大的仓库约 1GB。这种位级打包通常是数据库存储引擎的手法,这里被用在了业务缓存上。序列化也全部手工完成,不用 protobuf 之类的通用方案,追求极致的性能和磁盘密度。
优化得到的成效
指标
优化前
优化后
磁盘/传输(压缩 JSON)
~150MB
—
加载后内存
~800MB
元数据 ~40MB(降幅 >90%)
稳态内存(上限 5 万条常驻)
80–120MB
刷新额外内存
复制整份 ~800MB
仅 ~40MB 元数据
整体内存(实例级)
~2.7GB
~750MB
命中率
99.9%
未命中延迟 p99
<100µs
4M+ 条目中只有约 3 万条(约 1%)常驻内存,其余靠 page cache 兜底,未命中走磁盘的 p99 不到 100 微秒;对一个对外承诺 p99 <1ms 的系统来说,这个兜底延迟完全可接受。
从 Uber 这篇文章咱们能学到什么?
- “存了多少”和“用了多少”的差值就是优化空间。 大部分缓存优化盯着命中率,而这篇的起点是一个更根本的问题:为什么要把 100% 的数据放进内存去服务 1% 的访问?当你发现使用密度如此之低时,磁盘就重新回到了候选方案里;SSD + page cache 的随机读延迟,对这个量级的“未命中”完全够用。
- 对 GC 型语言,对象数量比数据体积更致命。 第一阶段优化没有释放任何数据,只是消除了指针间接层,就拿下 30% CPU。在 Go 里,“堆上有多少个需要标记的对象”往往比“总共多少字节”更能预测 GC 成本。文中那条原则可以直接抄走: 尽可能少地制造引用 。
- 好的优化不换接口。 全程 Get()/Put() 不变,调用方零改动,这才使得一个被全集群依赖的库可以平滑推送。容器启动流程(下载 → 流式解析 JSON → 逐条 Put → 原子切换)全部藏在实现内部。特别值得注意的是用流式 API 解析 JSON,避免为了解析而把整个 JSON 再读进内存;优化者对自己引入的临时分配同样苛刻。
- 用数据支撑每一个“奇怪”的决定。 6 字节 key 是 1000 万 key 的碰撞概率表;每秒更新一次 LRU 不是偷懒,是“读时间要 3–5% CPU”的实测;2+3 字节的值编码有明确的容量上限和位打包推导。文章里几乎没有出现“我们觉得”,这也是它能成为范本的原因。
- 权衡的对称性。 每个方案都诚实列出了被否决的选项和原因(外部服务、子集分发、全局/分片 LRU),而最终方案自身的弱点(碰撞、容量上限)也都给出了退化路径和扩容预案。真实系统的设计从来不是选完美的方案,是选“缺点可控、可以后续修正”的方案。
结尾作者提到,他们正在评估其他有同样模式(GC 压力大、实际使用率低)的内存重型缓存,并考虑在兴趣足够时做成通用方案,这套“内存索引 + 磁盘值 + 截断哈希 + 分片 + 时间感知 LRU”的组合拳,确实有抽象成通用库的潜力。
3:06 AM · Oct 16, 2026
·
625
Views