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. 优化思路

保持原有的二分查找方案,优化数据结构以减少内存占用。主要优化方向:

  1. 去重 Name 和 Country 字段
  2. 使用更高效的 IP 地址表示方法
  3. 考虑是否可以只存储起始 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]

mermaid

内存优化效果

五、技术细节与注意事项

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.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
最后修改:2026 年 01 月 18 日
如果觉得我的文章对你有用,请随意赞赏