Mess With DNS 内存优化实战:IP 地址查询内存占用分析与优化
一、问题背景
1. 系统环境
Mess With DNS 运行在一台只有 465MB RAM 的虚拟机上,内存分配情况如下:
- PowerDNS:100MB
- Mess With DNS:200MB
- Hallpass:40MB
- 剩余可用内存:约 110MB
2. 问题现象
系统运行约 3 年来,周期性地出现内存不足(OOM)问题。虽然之前影响不大(每天最多发生一次,重启几分钟即可恢复),但最近开始造成实际问题。特别是备份脚本使用 restic 进行数据库备份时,经常因内存不足被 OOM Kill。
3. 问题影响
- 备份文件可能损坏
- restic 获取锁后需要手动解锁
- 增加了运维手动工作负担
二、内存占用分析
1. 核心问题定位
通过内存分析工具发现,内存主要被 IP 地址数据库占用。Mess With DNS 启动时会加载一个 IP 到 ASN(自治系统号)的映射数据库,用于根据源 IP 地址查询其所属的自治系统。
原始数据文件大小:
- ip2asn-v4.tsv:26MB
- ip2asn-v6.tsv:11MB
- 总计:37MB
但加载到内存后占用约 117MB,是原始文件大小的 3 倍多。
2. 原始数据结构
type IPRange struct {
StartIP net.IP
EndIP net.IP
Num int
Name string
Country string
}使用二分查找进行 IP 地址查询,性能非常高效,每秒可完成约 900 万次查询。
3. 内存问题根因
- net.IP 底层是 []byte,涉及不必要的指针开销
- Name 和 Country 字段大量重复(多个 IP 段属于同一 ASN)
- 每个结构体包含完整的 IP 地址对象和字符串
三、优化方案探索
1. 方案一:使用 SQLite 数据库
A. 实现思路
将 IP 地址段数据存储在 SQLite 数据库中,创建索引以加快查询速度,数据存储在磁盘上而非内存中。
B. 数据库设计
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);C. 遇到的问题
问题 1:IPv6 地址存储
SQLite 不支持 128 位整数,最初选择将 IPv6 地址存储为 TEXT。使用 Python 的 ipaddress 模块将 IPv6 地址展开为完整格式,确保字符串比较正确。
问题 2:性能大幅下降
- SQLite 方案:每秒约 17,000 次查询
- 原始二分查找:每秒约 9,000,000 次查询
- 性能下降约 500 倍
问题 3:索引使用问题
使用 EXPLAIN QUERY PLAN 分析发现,SQLite 只使用了 end_ip 索引,没有同时使用 start_ip 和 end_ip 索引。尝试使用复合索引、ANALYZE、INTERSECT 等方法,但效果不佳或性能更差。
D. 方案评估
- 内存使用:显著降低(数据存储在磁盘)
- 性能:大幅下降
- 复杂度:增加
- 结论:不适合对性能要求较高的场景
2. 方案二:使用 Trie 数据结构
A. 实现思路
使用 Trie(前缀树)数据结构存储 IP 地址,尝试减少内存占用。
B. 测试结果
使用 ipaddress-go 库进行测试,结果如下:
- 内存占用:800MB(仅 IPv4 地址)
- 查询性能:每秒约 100,000 次
- 结论:内存占用更大,性能更差
C. 问题分析
可能存在使用不当的问题,但考虑到简单二分查找方案已经足够高效,决定放弃 Trie 方案。
四、最终优化方案
1. 优化思路
保持原有的二分查找方案,优化数据结构以减少内存占用。主要优化方向:
- 去重 Name 和 Country 字段
- 使用更高效的 IP 地址表示方法
- 考虑是否可以只存储起始 IP
2. 优化一:ASN 信息去重
A. 实现思路
多个 IP 段属于同一个 ASN,因此可以将 ASN 信息(Name 和 Country)集中存储,在 IPRange 结构体中只存储索引。
B. 优化后的数据结构
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
}C. 优化效果
- 原始内存占用:117MB
- 优化后内存占用:65MB
- 节省内存:52MB(约 44%)
3. 优化二:使用 netip.Addr
A. 问题分析
net.IP 底层使用 []byte 实现,存在额外的指针开销。Tailscale 团队在 2021 年发布了新的 IP 地址库,专门解决这一问题。
B. netip.Addr 优势
- 更小的内存占用
- 值类型,非指针
- 不可变性,更安全
- 已纳入 Go 标准库
C. 实现方式
将 net.IP 替换为 netip.Addr,修改非常简单。
D. 优化效果
- 优化前内存占用:65MB
- 优化后内存占用:46MB
- 节省内存:19MB(约 29%)
4. 总体优化效果
graph LR
A[原始方案 117MB] -->|ASN去重| B[65MB]
B -->|netip.Addr| C[46MB]
C -->|总优化| D[节省71MB]五、技术细节与注意事项
1. 内存分析工具
A. runtime 包使用
使用 runtime.MemStats 获取当前内存分配情况:
func memusage() {
runtime.GC()
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Alloc = %v MiB\n", m.Alloc/1024/1024)
f, err := os.Create("mem.prof")
if err != nil {
log.Fatal(err)
}
pprof.WriteHeapProfile(f)
f.Close()
}B. pprof 分析选项
- --alloc-space:显示所有已分配的内存
- --inuse-space:仅显示当前使用的内存
生成 PDF 格式的分析报告:
go tool pprof -pdf --inuse_space mem.prof > mem.pdf2. ASN 数据范围
- 最大 ASN 号:约 401,307
- 特殊值:4294901931(Unknown AS4294901931)
- 数据类型选择:uint32 完全够用
3. 性能权衡
优化后的查询性能:
- 原始方案:每秒 9,000,000 次查询
- 优化后:每秒 6,000,000 次查询
- 性能下降:约 33%
性能下降的原因是增加了一层间接引用(通过 Idx 查找 ASNInfo),但这是可以接受的权衡。
六、经验总结
1. 优化策略
- 先用分析工具找到真正的内存瓶颈
- 保持简单有效的算法(二分查找)
- 优先优化数据结构而非算法
- 考虑使用标准库中的高效实现
2. 调试过程启示
实际调试过程并非线性的,而是充满反复:
- 尝试 SQLite
- 尝试 Trie
- 重新审视 SQLite
- 放弃复杂方案,回归简单方案
- 逐步优化数据结构
3. 技术收获
- 深入理解 SQLite 索引机制
- 学习 netip.Addr 的优势
- 掌握 Go 内存分析工具
- 重新认识二分查找的价值
4. 设计哲学
在资源受限的环境中(512MB VM),通过精心设计,可以在保持良好性能的同时大幅降低内存占用。内存优化不仅是技术问题,也是一种有趣的工程挑战。
七、后续优化方向
社区提出的优化建议:
- 使用 Go 的 unique 包管理 ASNPool
- 使用 GOARCH=386 编译以减少指针大小
- IPv6 地址只需存储前 64 位(公网部分)
- 尝试插值查找算法(Interpolation Search)
- 使用 MaxMind DB 格式
- 使用 Tailscale 的 art 路由表包