◈ 夜宵吧

122 MB 的城市库

一个不到十篇文章的博客,常驻内存 194 MB。这台机器总共 1 GB,另一个常驻服务还要占六十来兆,所以这不是「可以接受」的量级。

排查过程里我猜了两次,两次都错。第三次靠改文件名才定位到:不是每请求泄漏,是一个一百多兆的地理库被整库读进了内存。

这篇讲怎么排除、怎么定位、改完之后省下了多少,以及这套按需加载方案自己的代价。

先排除泄漏

第一反应是内存泄漏:请求进来,分配了没释放。

于是重启服务,立刻看内存——194 MB。刚启动、零请求,就已经是满的。

那就不是累积型的。再验证一遍:绕开网关直连应用端口打了 400 次请求,RSS 从 194 MB 涨到 203 MB,涨的 9 MB 是正常的连接与缓存开销,打完还回落了一部分。

泄漏假设排除。

第一次猜错:GC 没跑

第二个想法是垃圾回收太懒:堆里都是死对象,但没到阈值不回收。

用 --expose-gc 重启,手动触发一次全量回收,再读 RSS。

194 MB → 191 MB。回收确实跑了,但什么也没收回来。

这说明那 190 兆是活对象,不是垃圾。

第三次:改文件名

活对象总得有人持有。把可疑的资源挨个断掉,看内存跟不跟。

地理库的路径是配置里写死的,不改代码的验证方式是把文件改名,让加载失败,然后重启看内存。

改名的瞬间,RSS 从 194 MB 掉到 66 MB。

真凶找到了:那个 127 MB 的城市级地理库,被整库读进了进程内存。

为什么会整库读进来

库是 MMDB 格式,二进制树,设计上支持内存映射、按需读页。理论上打开它只占几兆。

问题出在加载方式。第一版实现图省事,用了「打开文件、读出全部字节」的路径。128 MB 的缓冲区,加上解析过程中的中间结构,正好对上那 190 兆。

顺带说一句,这个库我本来想自己解析二进制格式。写了三轮,全部失败:IPv4 地址必须先映射成 IPv6 再走 128 位树、数据段的基址要从树字节数加一个固定偏移算起、解码函数必须同时返回「值」和「下一个游标位置」——最后这条卡得最久,因为它一旦错位,后面全部是错的地址。

三轮之后放弃,改用了一个零依赖的现成解析器:九个文件、104 KB。这个体量可以直接放进仓库,不需要联网安装。

三处改动

一、惰性加载。 首次真的需要查地址时才打开库,不是进程启动就打开。冷启动的内存曲线立刻平了。

二、空闲卸载。 五分钟没有查询就把库关掉,引用置空,然后主动回收一次。日志站的查询是突发的——看访问日志时才有,平时一条都没有。常驻一个 128 MB 的索引,为的是那种「偶尔看一眼」,不值得。

三、写入路径不再查库。 这一条影响最大。

原本每个请求结束时都要写一条访问记录,写之前顺手把来源地址翻成国家、城市、运营商。翻一次就要碰一次库——意思是每一个静态资源请求都在唤醒那 128 MB。

改成写入只存原始地址,翻地区放到读取时做。日志页一次显示 50 条,缓存命中大部分,实际只翻几条。

代价是历史记录里的地区字段会为空,需要一次性回填。写了个脚本遍历所有出现过的地址(416 个),逐个解析后批量更新,一个事务里完成。1124 条记录,只有一条查不到位置。

一个日期类型的坑

解析器返回的元数据里有个构建时间戳,我按 Unix 时间戳的惯例乘了 1000 转毫秒。

结果显示库是公元 58718 年建的。

原因很简单:那个字段已经是 Date 对象,再乘一次就溢出了。类型不是看名字能看出来的东西。

还有一处

城市名里会带城区后缀,形如 Los Angeles (Downtown Los Angeles)。直接拿去查中文对照表永远查不到,得先用正则去掉括号部分。

而中文地名本身没有现成的翻译——库里的 names 只有英文,只有国家字段带多语言。于是自建了一份对照表:省级行政区、主要城市、常见的云厂商和运营商,查不到就保留原文,不猜。

数字

指标 改前 改后
冷启动 RSS 194 MB 66 MB
400 次请求增量 9 MB 6 MB
空闲时占用地理库 128 MB 0
cgroup 上限 280 MB 未变

腾出来的一百三十兆,正好够给内核留出余量——之前每次 apt 升级都担心内存不够把数据库进程杀掉。

代价

解析器是自己写的。 一百多行、九个文件、104 KB。好处是不引依赖,坏处是库格式一改就得跟着改,而且没有上游可以报 bug。

地名翻译是对照表。 库里只给英文,中文地名靠一张自建表,查不到就保留原文。这张表会一直不完整,也不打算完整。

按需加载意味着首次访问慢一点。 五次空闲后卸载,下次要用时重新映射。实测这点代价只有几毫秒;但如果哪天访问量上来,策略就得反过来——常驻比反复挂载便宜。

腾出来的一百三十兆是这次最实际的收益:之前每次系统升级都担心内存不够,把数据库进程一起带走。