Loading... # 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. 原始数据结构 ```go 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. 数据库设计 ```sql 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. 优化思路 保持原有的二分查找方案,优化数据结构以减少内存占用。主要优化方向: 1. 去重 Name 和 Country 字段 2. 使用更高效的 IP 地址表示方法 3. 考虑是否可以只存储起始 IP ## 2. 优化一:ASN 信息去重 ### A. 实现思路 多个 IP 段属于同一个 ASN,因此可以将 ASN 信息(Name 和 Country)集中存储,在 IPRange 结构体中只存储索引。 ### B. 优化后的数据结构 ```go 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. 总体优化效果 ```mermaid graph LR A[原始方案 117MB] -->|ASN去重| B[65MB] B -->|netip.Addr| C[46MB] C -->|总优化| D[节省71MB] ```   # 五、技术细节与注意事项 ## 1. 内存分析工具 ### A. runtime 包使用 使用 runtime.MemStats 获取当前内存分配情况: ```go 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 格式的分析报告: ```bash go tool pprof -pdf --inuse_space mem.prof > mem.pdf ``` ## 2. ASN 数据范围 - 最大 ASN 号:约 401,307 - 特殊值:4294901931(Unknown AS4294901931) - 数据类型选择:uint32 完全够用 ## 3. 性能权衡 优化后的查询性能: - 原始方案:每秒 9,000,000 次查询 - 优化后:每秒 6,000,000 次查询 - 性能下降:约 33% 性能下降的原因是增加了一层间接引用(通过 Idx 查找 ASNInfo),但这是可以接受的权衡。 # 六、经验总结 ## 1. 优化策略 1. 先用分析工具找到真正的内存瓶颈 2. 保持简单有效的算法(二分查找) 3. 优先优化数据结构而非算法 4. 考虑使用标准库中的高效实现 ## 2. 调试过程启示 实际调试过程并非线性的,而是充满反复: - 尝试 SQLite - 尝试 Trie - 重新审视 SQLite - 放弃复杂方案,回归简单方案 - 逐步优化数据结构 ## 3. 技术收获 - 深入理解 SQLite 索引机制 - 学习 netip.Addr 的优势 - 掌握 Go 内存分析工具 - 重新认识二分查找的价值 ## 4. 设计哲学 在资源受限的环境中(512MB VM),通过精心设计,可以在保持良好性能的同时大幅降低内存占用。内存优化不仅是技术问题,也是一种有趣的工程挑战。 # 七、后续优化方向 社区提出的优化建议: 1. 使用 Go 的 unique 包管理 ASNPool 2. 使用 GOARCH=386 编译以减少指针大小 3. IPv6 地址只需存储前 64 位(公网部分) 4. 尝试插值查找算法(Interpolation Search) 5. 使用 MaxMind DB 格式 6. 使用 Tailscale 的 art 路由表包 *** ## 参考资料 1. [Using less memory to look up IP addresses in Mess With DNS](https://jvns.ca/blog/2024/10/27/asn-ip-address-memory/) 最后修改:2026 年 01 月 18 日 © 允许规范转载 赞 如果觉得我的文章对你有用,请随意赞赏