让 Mess With DNS 查询 IP 地址时少占一点内存

原作者:Julia Evans|2024 年 10 月 27 日|原文。中文翻译与技术审校:未完纪,2026 年 10 月 5 日。

编者说明:本文完整翻译原文正文,保留尝试失败的过程、全部代码与性能数字。数字来自作者的特定数据集及机器,并非本次实测或通用保证。文末补充静态审查发现。

过去大约三年,Mess With DNS 一直有个问题:它不时耗尽内存,然后被 OOM 杀掉。

我一直没有优先处理这件事。通常它只会在重启期间停几分钟,最多一天发生一次,所以我就忽略了。但上周,它开始真正影响其他事情,我决定调查一下。

这一路有些曲折,不过学到了不少东西。下面依次说说可用内存、备份脚本被杀、SQLite 和字典树的尝试,以及最终如何给原来的数组瘦身。

可用内存大约只有 100 MB

Mess With DNS 跑在一台大约有 465 MB 可用 RAM 的虚拟机上。根据 ps aux 的 RSS 列,内存大致这样分配:

  • PowerDNS:100 MB。
  • Mess With DNS:200 MB。
  • hallpass:40 MB。

于是大约只剩 110 MB 空闲内存。前一阵,我把 GOMEMLIMIT 设成 250 MB,想让 Mess With DNS 超过 250 MB 时垃圾收集器就开始工作。我觉得它有所帮助,但没有彻底解决问题。

译者注:此处是作者对目标的概括;GOMEMLIMIT 是 Go 运行时的软内存限制,不是 RSS 硬上限,也不是“到这个数才触发 GC”的单一开关。总量和剩余量都是作者给出的近似值。

真正的问题:备份脚本被 OOM 杀掉

几周前,我第一次开始用 restic 备份 Mess With DNS 的数据库。

备份原本运行得还可以,但系统余量很小。我猜 restic 有时需要的内存超过了可用量,于是备份脚本偶尔会被 OOM 杀掉。

这带来两个问题:一是我担心备份可能受损;更重要的是,restic 运行时会加锁。如果进程被杀,我就得手动解锁,后续备份才能继续。对我的所有 Web 服务来说,需要人工介入是我最想避免的事——谁有时间总干这个!所以我很想解决它。

办法应该不止一种,但我决定先减少 Mess With DNS 的内存占用,给系统腾出余量,主要因为这听起来是个有意思的问题。

到底什么在吃内存:IP 地址

以前我已经给 Mess With DNS 做过很多次内存剖析,所以很清楚大部分内存花在了哪里:IP 地址。

启动时,它会把可查询每个 IP 地址对应 ASN 的数据库载入内存。这样收到 DNS 查询时,就能根据来源地址,例如 74.125.16.248,告诉你该 IP 属于 GOOGLE。

单是这个数据库就占了约 117 MB。简单跑一下 du 就知道这太多了:原始文本文件加起来才 37 MB!

$ du -sh *.tsv
26M	ip2asn-v4.tsv
11M	ip2asn-v6.tsv

原来的数据结构是一个数组,元素长这样:

type IPRange struct {
	StartIP net.IP
	EndIP   net.IP
	Num     int
	Name    string
	Country string
}

查询时对数组做二分查找,判断目标 IP 是否落在某个区间里。这基本是最简单的做法,而且非常快:我的机器每秒大约能查 900 万次。

尝试一:用 SQLite

最近我常用 SQLite,所以第一个想法是:能不能把数据放进磁盘上的 SQLite 数据库,给表加索引,从而少用一点内存?

于是,我用 sqlite-utils 写了一个简短的 Python 脚本,把 TSV 导入 SQLite;然后修改程序,改为从数据库查询。

这确实达到了最初的内存目标。GC 之后,它几乎不占什么内存了,因为表在磁盘上!但如果同时执行大量查询,我不确定会产生多大的垃圾收集压力。简单做了一下内存剖析,似乎每次查询会分配大约 1 KB。

不过,使用 SQLite 时也遇到了一些问题。

问题一:IPv6 地址怎么存

SQLite 不支持任意精度的大整数,而 IPv6 地址有 128 位,所以我决定存成文本。也许 BLOB 更好:最初我以为 BLOB 不能比较,但 SQLite 文档说可以。

最终表结构如下:

CREATE TABLE ipv4_ranges (
   start_ip INTEGER NOT NULL,
   end_ip INTEGER NOT NULL,
   asn INTEGER NOT NULL,
   country TEXT NOT NULL,
   name TEXT NOT NULL
);
CREATE TABLE ipv6_ranges (
   start_ip TEXT NOT NULL,
   end_ip TEXT NOT NULL,
   asn INTEGER,
   country TEXT,
   name TEXT
);
CREATE INDEX idx_ipv4_ranges_start_ip ON ipv4_ranges (start_ip);
CREATE INDEX idx_ipv6_ranges_start_ip ON ipv6_ranges (start_ip);
CREATE INDEX idx_ipv4_ranges_end_ip ON ipv4_ranges (end_ip);
CREATE INDEX idx_ipv6_ranges_end_ip ON ipv6_ranges (end_ip);

我还发现 Python 自带 ipaddress 模块,可以用 ipaddress.ip_address(s).exploded 把 IPv6 地址展开,确保字符串比较能够得到正确顺序。

译者注:这里依赖固定宽度、统一格式以及合适的排序规则;不能直接拿任意压缩写法的 IPv6 字符串比较。若改成 BLOB,应统一为同一地址族、相同长度的大端字节序。

问题二:慢了约 500 倍

我跑了一个大致如下的微基准。结果是每秒能查 17,000 个 IPv6 地址,IPv4 也差不多。

这有些令人泄气。每秒 1.7 万次其实也能接受,毕竟 Mess With DNS 流量不大;但原来的二分查找每秒能查 900 万次。

	ips := []net.IP{}
	count := 20000
	for i := 0; i < count; i++ {
		// create a random IPv6 address
		bytes := randomBytes()
		ip := net.IP(bytes[:])
		ips = append(ips, ip)
	}
	now := time.Now()
	success := 0
	for _, ip := range ips {
		_, err := ranges.FindASN(ip)
		if err == nil {
			success++
		}
	}
	fmt.Println(success)
	elapsed := time.Since(now)
	fmt.Println("number per second", float64(count)/elapsed.Seconds())

该看看 EXPLAIN QUERY PLAN 了

以前我几乎没有在 SQLite 中用过 EXPLAIN,这正好是个有趣的机会,看看查询计划究竟在做什么。

sqlite> explain query plan select * from ipv6_ranges where '2607:f8b0:4006:0824:0000:0000:0000:200e' BETWEEN start_ip and end_ip;
QUERY PLAN
`--SEARCH ipv6_ranges USING INDEX idx_ipv6_ranges_end_ip (end_ip>?)

看起来它只用了 end_ip 索引,没有用 start_ip 索引。这样一来,比二分查找慢也许说得通。

我试着寻找让 SQLite 同时使用两个索引的方法,但没有找到;也可能它自己知道怎样最好。

到这里,我放弃了 SQLite 方案。它更慢,而且比简单的二分查找复杂得多。我更想保留接近原来方案的实现。

我还试过下面这些:

  • 把两个独立索引改成复合索引,仍没有达到预期。
  • 执行 ANALYZE,也没有达到预期。
  • 用 INTERSECT 对 start_ip < ? 与 ? < end_ip 的结果取交集。这确实用到了两个索引,但查询似乎又慢了整整 1000 倍,可能因为它需要在内存中生成两个子查询的结果再求交集。

译者注:最后一条的严格不等号与前面的 BETWEEN 闭区间语义不同;要处理区间端点,应使用对应的包含边界条件。原文没有给出完整 SQL,因此这里不补造查询。

尝试二:用字典树

接下来,我想到试试 trie(字典树)。我模糊地觉得它也许能更省内存,并找到了支持用 trie 查询 IP 的 ipaddress-go 库。

我试了一下,尝试代码在这里。但我觉得自己大概哪里用错了,因为和朴素数组加二分查找相比:

  • 内存多得离谱:仅 IPv4 地址就用了 800 MB。
  • 查询慢得多:每秒只有 10 万次,而不是 900 万次。

我不太确定到底出了什么问题,于是放弃这个方向,决定直接让数组少占点内存,继续使用简单的二分查找。

关于内存剖析的几个收获

这次我学到,可以用 runtime 包查看程序当前分配了多少内存。文中这些内存数字就是这样得到的:

func memusage() {
	runtime.GC()
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	fmt.Printf("Alloc = %v MiB\n", m.Alloc/1024/1024)
	// write mem.prof
	f, err := os.Create("mem.prof")
	if err != nil {
		log.Fatal(err)
	}
	pprof.WriteHeapProfile(f)
	f.Close()
}

我还了解到,用 pprof 分析堆剖析文件时,有两种观察方式:累计分配的空间和当前仍在使用的空间。前者会告诉你曾经分配过的所有内存,后者只包括当前在用的部分。

总之,我反复运行了这个命令:

go tool pprof -pdf --inuse_space mem.prof > mem.pdf

每次用 pprof,我都会翻回自己写的入门文章——那可能是我用得最多的一篇博客。我也应该把这两种观察方式补进去。

译者注:原文说明文字写成了 --alloc-space 和 --inuse-space,实际命令采用下划线。使用时应核对当前 pprof 帮助中的 -alloc_space、-inuse_space。输出代码除以 1024 两次,单位实际为 MiB;不要把这个堆指标与进程 RSS 或 SQLite 所占的系统页缓存直接等同。

尝试三:让数组更省内存

我当时保存 ip2asn 条目的方式还是这样:

type IPRange struct {
	StartIP net.IP
	EndIP   net.IP
	Num     int
	Name    string
	Country string
}

我想到了三个改进方向:

  1. Name 和 Country 大量重复,因为很多 IP 区间属于同一个 ASN。
  2. net.IP 底层是 []byte,感觉有一层不必要的指针。能不能直接嵌在结构体里?
  3. 也许不需要同时保存起始和结束 IP。很多区间是连续的,能否调整表示,只存起点?

想法 3.1:给 Name 和 Country 去重

我想把 ASN 信息存入另一个数组,让 IPRange 只保存该数组的索引。看这几个结构体就能明白:

type IPRange struct {
	StartIP netip.Addr
	EndIP   netip.Addr
	ASN     uint32
	Idx     uint32
}

type ASNInfo struct {
	Country string
	Name    string
}

type ASNPool struct {
	asns   []ASNInfo
	lookup map[ASNInfo]uint32
}

成功了!内存从 117 MB 降到 65 MB,省下大约 50 MB。这让我很满意。相关完整代码在固定提交中。

译者注:这里展示的是原文给出的最终结构体,已经包含下一节的 netip.Addr,不要据此把两项优化的测量步骤混为一谈。

ASN 到底有多大

顺便说一句:用 uint32 保存 ASN 合适吗?我检查了 ip2asn 文件,最大的似乎是 401307,不过还有几行写着 4294901931,大得多,但仍刚好落在 uint32 的范围内。因此 uint32 确实够用。

59.101.179.0	59.101.179.255	4294901931	Unknown	AS4294901931

想法 3.2:用 netip.Addr 替换 net.IP

原来,不止我觉得 net.IP 多占了内存。2021 年,Tailscale 的工程师发布了一个新的 Go IP 地址库,解决了这个问题和许多其他问题。他们还写了一篇很棒的介绍文章。

令我高兴的是,这个库不仅存在、刚好符合需求,而且已经进入 Go 标准库,成为 netip.Addr。替换很容易,又省了大约 20 MB,使内存占用降到 46 MB。

我没有尝试第三个想法,也就是从结构体删除结束 IP。因为周六上午已经写了很久代码,我对当前进展很满意。

每当我觉得“这东西不太对,肯定有更好的办法”,然后马上发现别人已经做出了我需要的东西,而且考虑得更深、实现得比我自己会写出的版本更好,那种感觉总是特别棒。

真实过程其实乱得多

虽然我尽量把过程讲成一条简单的线——“先试 X,再试 Y,最后试 Z”——但这多少有点不诚实。我总想把真实的调试过程,也就是彻底的混乱,整理得更线性、更容易理解,因为真实情况实在太难写了。它更像这样:

  • 试 SQLite。
  • 试 trie。
  • 怀疑自己对 SQLite 的所有结论,再回头看结果。
  • 等等,索引呢?
  • 很晚才终于意识到,可以用 runtime 检查各部分内存用量,于是开始测量。
  • 再看一次 trie,也许一开始全理解错了。
  • 放弃,回到二分查找。
  • 再检查所有 trie/SQLite 的数字,确认自己没有搞错。

为什么坚持用 512 MB

有人问,为什么不直接给虚拟机多加点内存?我完全负担得起 1 GB 的虚拟机,但我觉得 512 MB 真的应该够用——甚至 256 MB 也应该够!所以我更想留在这个约束里。它有点像一道有趣的谜题。

回复中提到的其他思路

大家还提出了很多我没想到的好主意。先记下来,哪天再过一个“性能优化快乐日”时,可以拿来找灵感:

  • 给 ASNPool 试试 Go 的 unique 包。有人试过,内存反而增加了,可能因为 Go 指针是 64 位。
  • 试着用 GOARCH=386 编译,让指针变成 32 位以节省空间,也许可以配合 unique 使用。
  • 有人认为只用 64 位就能保存这些 IPv6 数据,理由是地址的前 64 位才是“公开部分”。
  • IP 地址是数值,也许插值查找比二分查找更快。
  • 尝试 MaxMind 数据库格式,以及 mmdbwriter 或 mmdbctl。
  • 试试 Tailscale 的 art 路由表包。

译者纠错:“IPv6 只有前 64 位是公开部分”不能当作通用规则。IPv6 地址有 128 位,路由前缀可以长于 /64;RFC 7608明确要求支持到 /128 的前缀。只有在验证特定数据集及查询语义允许丢弃低 64 位后,才可以考虑这种压缩。

结果:省下了大约 70 MB

我部署了新版本,Mess With DNS 现在用的内存更少了,太好了!

作者记录的内存变化:原数组117 MB,重复信息去重后65 MB,换用netip.Addr后46 MB;原始文本37 MB。
根据原文数字绘制,非本次实测。文中 MB 沿用作者写法;示例测量代码按 MiB 输出。

还有两点要说:查询速度稍微慢了一点,我的微基准从每秒 900 万次降到 600 万次,可能因为多了一层间接访问。用少一点内存、多一点 CPU,对我来说是不错的交换。它仍比原始文本占的空间大:46 MB 对 37 MB;指针总要占空间,这也没关系。

说实话,我不知道这能否解决所有内存问题,大概不能!不过我玩得很开心,学到了一些 SQLite 知识,还是不知道该怎样评价 trie,而且比以前更喜欢二分查找了。

代码静态审校

本次没有运行 Go、SQL、pprof 或基准。正文样例保持原样;以下是对源代码的实际发现及建议。

  • 数据文件与错误处理:固定提交的 ReadASNs 在检查字段数之前访问 fields[4],坏行会越界;结束后未检查 scanner.Err(),可能把截断读取当成功。应验证五列、检查扫描错误并验证排序、区间不重叠与起止地址关系。
  • 32 位编译会暴露 ASN 解析问题:原代码先 strconv.Atoi 转为 int,失败时返回 0,再转 uint32。因此原文建议的 386 架构会让 4294901931 这类值溢出并静默变成 0。应直接使用 strconv.ParseUint(s, 10, 32),并返回错误,而不是把 0 当错误占位。
  • 地址规范化:当前实现用 Is4() 分流。Go 官方文档说明 IPv4 映射的 IPv6 地址需要按业务语义决定是否 Unmap();否则相同 IPv4 可能被送入 IPv6 表。无效地址也应先拒绝。
  • trie 尝试并非完整查询实现:Gist 的 LookupIP 命中节点后返回 nil, nil,并未返回节点中保存的 ASN 值。AddRecord 的解析错误分支检查 err2 却打印 err;strings.Fields 还可能截断带空格的名称。修复这些正确性问题后,再进行同等工作量的比较。本文不据此推断 800 MB 的具体成因。
  • 测量写入错误被忽略:pprof.WriteHeapProfile 和 f.Close 的错误均应处理;os.Create("mem.prof") 会覆盖现有同名文件。示例还会主动执行 GC,不能放进热路径衡量正常延迟。
  • 内存口径:MemStats.Alloc 表示堆对象分配量,不等于系统整体内存或进程 RSS。微基准未给出完整环境、冷热缓存及查询分布,不能把本文数字当数据库或数据结构的普遍优劣结论。

版权与来源:原文与代码归 Julia Evans 及相应权利人所有;原文未单列开放转载许可证。图表为未完纪原创,来源数字已标注。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容