前段时间做一个需求,需要用到一个本地词典文件。该词典原始文件超过2G,在服务启动的时候加载到内存中,并且保持词典数据的热加载,也就是不停服更新词典数据到服务进程的内存中。
之前有同事在其他项目中有热更新词典的代码,我就直接拿来用了。这是典型的双Buffer词典。也就是程序运行期间,内存中会同时维持两份词典:一份前台词典供运行时各处理逻辑检索,另一份是后台词典,在检测到目标文件修改时(通过检查文件mtime判断的是否更新)。在词典数据更新时,重新解析加载,最新的数据储存到后台词典中。最后两个词典做0 - 1 切换,也就是前台词典变后台词典,后台词典变前台词典。
词典类在服务中采用的核心数据结构是unordered_map。前后台词典也就是会存在两个unordered_map。key是某某ID,value是词典原始文件逐行解析后重组出来的protobuf Message对象。
在线下环境(非线上生产环境)测试的时候,自测完代码逻辑无问题。喵了一眼机器基础指标,发现内存会多次上涨。
内存占用在 5-10G之间那次是第一次启动完成的时间,后面又连续涨了两次。怀疑是有内存泄露,在把流量停掉以后,重启服务。观测到内存仍旧会规律上涨,且一个小时会涨一次。如此规律,让人不得不怀疑是词典更新导致。词典文件是ceph挂载的,会自动更新,所以我几乎没关注过。确认了一下词典的更新时间和更新频率。确实也是一小时更新一次,且其每次更新的时间和内存每次上涨时间相match。
想尽快验证一下是否真的是词典更新导致的内存上涨,等着词典一次一次例行更新就太慢了。不过由于这个词典API判断词典是否更新是检测的文件修改时间(mtime),所以通过touch该词典文件,可以提前触发词典的加载。
按理说双buffer的词典,在正常启动后暴涨一次内存是合理的。因为启动的时候内存中加载了词典的一个版本。一个小时之后词典更新,第二个版本的词典数据也会加入到内存。而彼时原先的前台词典虽然变成了后台词典,但是内存并不会立即delete(持有旧词典数据的unordered_map)。因为可能运行的请求处理逻辑仍然会用到旧词典。
重新阅读这个词典API的实现。当内存中存在两个版本的词典后,等到词典第二次更新到时候(也就是第三个版本词典出现的时候),该实现逻辑是先创建一个词典对象存储第三个版本词典的数据。若其加载解析成功则原先的后台词典对象就会被delete(第一个版本的词典占用的内存被释放)。然后后台词典的指针指向刚新建的对象(第三个版本的词典正式成为后台词典),最后做前后台词典的切换(第三个版本词典成为前台词典,第二个版本的词典变成后台词典)。
也就是说按照这个词典API的实现逻辑,内存中确实存在某个时刻存储着三份词典的数据,涨两次内存也说得通,但是当新的词典加载完成,上上个版本的词典对象是会被delete的。所以内存应该回落才对!难道是delete没有被触发吗?
尝试了touch了几次词典文件发现,确实词典文件更新会导致内存连续上涨。但诡异的是后来我尝试缩减词典到一个特别小的大小,却观察到机器内存并不会下降!哦?这是词典API本身存在内存泄露的风险吗?和刚才看代码时的疑惑一样,上上版本的词典没有触发delete?然而通过多次测试又发现这样一个事实:
词典内存不会永远上涨,启动完成之后,最多涨两次,第三次也会涨但比较少,第四次五次更新词典文件,则几乎不会导致内存的变化!如果说存在词典对象没有被正常delete,那么内存占用应该会继续上涨,而不是趋于稳定。
头疼。一方面内存不会无限上涨,不像是内存泄露;但另一方面词典缩小却不会导致内存占用减少。
这……让我在十月的深夜凌乱了。问题又兜回来了吗?这到底是不是内存泄露?或者到底是不是词典更新导致的呢?
尝试了用一些工具来辅助定位是否有内存泄露的风险,但一无所获。后来注释掉了每行词典数据重组成pb对象之后insert进unordered_map的代码,经测试词典更新确实不会再导致内存上涨。说白了实锤了内存上涨就是这两个前后台的unordered_map引起的。然而通过加日志也能证实每次旧map对象的delete每次都有被调用到,也就是不存在第三个map对象没被delete的情况,那么为什么delete掉对象后,其占用的内存无法释放呢?
遽然陷入绝境,坐困愁城。
突然我灵光一现:会不会是glibc导致的持呢?我们都知道内存分配器,比如glibc的ptmalloc,有时候内存分配器的内存管理策略并不一定如我们所愿。
经证实确实glibc有这样的内存分配策略:为了避免大对象频繁的内存分配和释放,glibc并不一定会把delete的对象内存立即归还给操作系统,有时候可能继续让进程持有该内存。当后续再有大对象需要分配的时候,可以直接使用,而不再需要再去向操作系统申请内存。glibc这个策略其实是为了提高内存分配效率的,并且也不会无限占用内存,而是在达到某个平衡点之后内存便不再增长,这也和我所观察到的现象一致。
说到底这其实不算是一次『内存泄露』。然而这个现象既然不会持续占用内存,那么到底需不需要解决呢?在我的场景下,答案是肯定的。因为我们的词典比较大,且不可控,当线上正常服务的时候,内存也会正常上涨,其实是存在OOM风险的。在运行效率和服务稳定性之间相比较,自然要让步于稳定性。
那么怎么解决呢?虽然没有直接搜索到答案,但是直觉告诉我一个更好的内存分配器或许可以解决。死马当活马医,于是我尝试了让程序链接tcmalloc或jemalloc。最终jemalloc表现良好,可以慢慢释放掉多余占用的内存。
那些凸起的线是加载和解析词表的过程中,突然飙上来的内存,但随机又很快回落,接着慢慢继续回落。其实jemalloc在针对大对象存储时,其性能表现也并不差,甚至使用了jemalloc之后服务一次请求响应的耗时还有不少缩减。