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。
地名翻译是对照表。 库里只给英文,中文地名靠一张自建表,查不到就保留原文。这张表会一直不完整,也不打算完整。
按需加载意味着首次访问慢一点。 五次空闲后卸载,下次要用时重新映射。实测这点代价只有几毫秒;但如果哪天访问量上来,策略就得反过来——常驻比反复挂载便宜。
腾出来的一百三十兆是这次最实际的收益:之前每次系统升级都担心内存不够,把数据库进程一起带走。