雑談:追一个内存泄漏,四个「合理的解释」骗了我一路
上一篇写「六个数字骗了我」,讲的是默认值、自报规格、别人的转述怎么各自撒谎。这一篇是它的续集,只不过这次骗我的不是数字,是四个看起来非常合理的解释。
起因很小:一个消费队列消息、往 Redis 里写数据的后台服务,变慢了,内存也一直下不去,还 OOM 过几次。同事在群里问是不是数据量突然变大了。
不是。查到最后,是 Quarkus 底层那个容错库(SmallRye Fault Tolerance)的一个内存泄漏——用重试/熔断的每个服务都在漏。但这篇不想写「泄漏怎么修」,那部分最后只有两行。值钱的是中间:我先后得出过四个结论,每一个都很有道理,每一个都是错的。
泄漏长什么样
先给个背景。把一个跑了半天的进程的堆拉出来数类的实例:
DelegatingMeteredOperation 314,034 ← 只增不减
MetricsCollector 314,035 ← 配对增长
FaultToleranceOperation 7 ← 一直是 7
被容错保护的方法只有 7 个,可跟“计量”相关的包装对象却有 31 万个,还在涨。典型的泄漏形状:该有界的东西无界了。
方向锁定在这个容错库的 metrics 上。接下来就是找为什么。然后我开始一个接一个地骗自己。
谎言一:「这个 6.9.0 已经修了」
翻 release note,眼睛一亮:6.9.0 明明白白写着 “fix memory leak in metrics”。再一查我们用的版本,比 6.9.0 新。
那就结案了呗?官方都说修了,我们版本更高,那泄漏肯定是别的原因。
我差点就这么写进报告。但那台机器上明明在漏。于是我做了件后来救了整个调查的事:我不信 release note,我自己在本地把那个版本拉下来,写了个最小复现。
结果:在我们用的版本上,照样漏。
6.9.0 修的是另一种泄漏(不设描述时用随机 UUID 当标签的那种)。和我们撞上的完全是两码事。“官方修过”这句话,字面上没错,但它让我差点停在错误的地方。
Release note 会告诉你「修过一个叫这个名字的东西」,不会告诉你「你那个也叫这个名字的东西修没修」。
谎言二:「元凶是那个随机 UUID」
继续读代码,很快发现一处刺眼的写法:框架给每个容错单元设描述的时候,拼了个 UUID.randomUUID() 进去。
这太像元凶了。metrics 系统最怕的就是高基数标签——每次生成一个新 UUID,监控后端就多存一份,永不释放。教科书级别的泄漏原因。我几乎已经在脑子里写好了「去掉 UUID 即可」。
但我这次学乖了,没有直接下结论,而是在复现里把它做成开关:UUID 一版、固定字符串一版、干脆不设描述一版,三种都跑。
带 UUID → 漏
固定字符串 → 一样漏
不设描述 → 还是漏
三种漏得一模一样。UUID 跟这个泄漏毫无关系。
它只是个长得很像凶手的路人。如果我当时信了直觉去改 UUID,改完发现还在漏,那才是真正的噩梦——因为我会以为「我已经修过了」。
谎言三:「单例的东西不可能每次调用都漏」
这条是最狡猾的,因为它有代码证据支持。
那个容错单元在框架里是单例——启动时创建一次,之后所有调用共用。单例怎么可能每次调用漏一个?我的第一版最小复现也印证了这一点:我直接调用那个单例,数出来的计量对象永远是 1。不漏。
看,代码逻辑、我的复现,都在说「单例不漏」。我一度真的以为是不是哪里看错了。
问题是——那台真机在漏。两个都是事实,只能是我复现的路径不对。
真实服务不是直接调那个单例的,而是通过注解(@ApplyGuard)让容器的拦截器去调。这条路和我图省事写的「直接调」是两码事。于是我把复现推倒重来,老老实实起了个真正的 CDI 容器,用注解触发,再数:
调用 1000 次 → 泄漏对象 1000 个
调用 2000 次 → 2000 个
调用 4000 次 → 4000 个
严丝合缝的 1:1。 这才是真机上那条路。
而我第一版「直接调用」的复现之所以显示不漏,恰恰是因为它绕过了出问题的那段。一个错误的复现,比没有复现更危险——它会给你一个很有底气的错误结论。
真正的原因(两行)
到这一步反编译进去看,根因干净得让人想笑:
那个拦截器,每次调用都新建一个描述当次调用的小对象,拿它去一个缓存里 computeIfAbsent。而这个小对象没有实现 equals/hashCode——于是每次都是一个全新的身份,缓存永远命中不了,每调用一次就往里塞一个,再也不拿出来。
而同一个库里另一处缓存,用的是这个对象的稳定名字做键,所以那边有界、这边无界。同一个库,两处缓存,一处对一处错。
修复就是把出错那处也改成用稳定名字做键。两行。
谎言四:「都跑了好几年了,不可能是它」
这是所有人的第一反应,也包括我:如果几十个服务都在漏,那它们跑了好几年,为什么只有一个 OOM?为什么不是天天炸?
这条谎言我用两个动作拆穿:
一,查这段代码多老。 git blame 一拉——泄漏那套写法是去年底一次重构引进来的,提交信息写着「把重试改成用注解」。重构之前用的是普通声明式重试,那条路根本不漏。所以不是「跑了几年才漏」,是**「漏才八个月」**。服务跑了几年,不代表这段代码存在几年。
二,为什么只炸一个。 因为这是场赛跑:泄漏累积的速度 vs. 重启把它清零的速度。 大多数服务被日常发版、自动扩缩、节点轮换频繁重启,漏还没攒到顶就被清零了。只有那个吞吐最高的服务,在两次重启之间就能把堆填满——它跑输了。别人不是不漏,是被重启一次次救回来了。
最后还有一个反直觉的尾巴。我去看生产环境,本以为它比测试环境更稳(没在崩)。结果生产的内存常年贴在上限的 80%,而测试环境只有 35%。为什么?因为生产那个 pod 活得久、不怎么被重启——于是漏一路攒到了天花板底下。测试环境反而因为被自动扩缩不停重建,「意外地」压住了。
「看起来最稳的那个,其实最危险」——它稳,只是因为还没轮到它撞墙。
结局
到这我手里已经有:一个能一键复现的最小工程、一个把出错缓存键改掉的两行补丁、以及「最新版本仍未修复」的证据。于是给上游 SmallRye 提了 PR,附上复现和补丁。
维护者(Red Hat 的 Ladislav Thon)几个小时后回复:确认修复正确,补了个测试,合并进了主干。一个从「某个服务有点慢」开始的报警,最后变成了别人容错库里的一次提交。
那到底谁没骗我
复盘一下这四个骗过我的东西,它们的共同点很有意思:
- Release note 说「修过」——真的修过,只是不是我这个。
- 代码里的 UUID 长得像元凶——只是长得像。
- 我自己的第一版复现 说「不漏」——因为它走错了路。
- 「跑了几年没事」 听起来是铁证——只是那段代码没跑几年。
每一个都部分为真,每一个都足够权威到让人想停下。它们不是谎言,是没说全的真话,而没说全的真话最会骗人。
这一路上唯一一次没骗我的,是那张随调用次数严格 1:1 增长的表。1000 次一千个,2000 次两千个,改一个字就归零。它不讲道理,它只是长在那儿,涨给你看。
所以和上一篇是同一个结论,只是更硬了一点:
别推理,去复现。 能自己跑一遍、能亲手让它归零的东西,才是唯一不会骗你的。其余所有「听起来很对」的解释,先当它在撒谎。