🚀 新人小白,我是这样把千万级 IP 库查询接口优化到毫秒级

79 阅读5分钟

这是一个从 数据库查询 → 缓存 → 对象列表 → 字节级内存结构 的完整优化过程。
最终实现:处理 400 万条 IP 数据,全量压入 JVM,单次查询 0.0001ms,无 GC、无 IO、无锁。
本文涵盖 SQL、算法、JVM 内存模型、GC 调优、数据结构压缩等多方面知识,是一篇全链路端到端解决方案。

🧩 背景:IP → 地理位置查询接口

业务场景很典型:

  • 用户请求过来带 IP
  • 我们需要在 400 万条 IPv4 段库中快速找到它对应的地区
  • 高并发(> 2000 QPS)
  • 需要在 单机 上跑到毫秒级以下

最初版本使用 SQL 查询:

 WITH filtered AS (
      SELECT
        remote_ip,
        CAST(INET_ATON(remote_ip) AS UNSIGNED) AS remote_ip_num,
        (CAST(INET_ATON(remote_ip) AS UNSIGNED) >> 24) AS ip_segment,
        utm_content,
        `value`
      FROM 日志表
      WHERE 时间 = #{targetTime}
        AND remote_ip LIKE '%.%' AND remote_ip NOT LIKE '%:%'
    )
    SELECT
      ...
      COUNT(DISTINCT f.remote_ip) AS count
    FROM filtered f
      JOIN ip_location_data g
    ON 
        ...
    GROUP BY
      ...

每次请求访问数据库。

结果:慢 + 压力大 + 无法扩展。

于是我们开始了长达 4 个版本的优化过程。

🧪 第一版:数据库查询

流程:

  1. 拆分 IP 字符串
  2. 转成 int 或 long
  3. 拼 SQL
  4. MySQL 查询
  5. 返回位置

问题暴露:

问题影响
网络 IO 延迟单次 4~8ms
数据库 CPU/IO 压力高并发下连接池打满
SQL 必须走范围查询,较慢大量随机 I/O
10,000 个 IP 批处理要 4~8 秒无法接受

基本不可用。

🔥 第二版:加缓存

做法:

  • 使用了ConcurrentHashMap
  • Key = IP网段,Value = 同一网段地区集合

但很快发现:

❌ IPv4 有超级多,不可能都缓存

如果几万IP覆盖所有网段,仍然要打到数据库,IO及GC压力...

实测出现了GC花费98%时间但只回收不足2%资源的问题

❌ 依然需要回源 DB,压力没降下来多少

❌ Java 中以 String 为 Key,会产生海量临时对象

年轻代频繁 GC。

最终瓶颈变成:

  • 内存占用大
  • GC 频繁
  • 缓存命中率存在隐患

⚙️ 第三版:全量加载到内存(List 对象版本)

将 400 万条数据加载到 JVM:

class IpEntity {
    ...
}
List<IpEntity> ipList;

然后用二分查找。

但是……很快又不行了

问题说明
对象头 12~16 字节 × 400 万 = 约 64MB只靠对象头就爆炸
再加上 String 引用总堆内存 1GB+
Full GC 明显变慢上百毫秒
CPU 缓存命中率差对象分散

空间占用实在可怕,性能还是不行...


🧨 终极版:内存压缩 + 数组结构 + 二分查找

我们开始进行 极致空间压缩 + 极致 CPU 优化

核心思想:

用结构换空间,用计算换时间。

最终实现:

  • 全量数据加载到 JVM
  • 不创建任何对象
  • 所有数据用 int[] 原生数组 存储
  • 字典编码
  • 解析IP使用位运算,去掉 split,消灭临时对象。
  • 二分查找

让我们逐步拆开。


🎯 1. 数据结构从 AoS → SoA(结构化数组)

传统的面向对象是:

[对象][对象][对象][对象]

我们改为:

int[] ipStarts
int[] ipEnds
int[] locationIndex
LocationInfo[] locationDict

优点:

🧠 1. 去掉 400 万个对象头(节省 ~100MB)

对象头 16 字节 × 400 万 = 64MB

⚡ 2. JVM 压缩对象指针失效问题被完全避免

全部是原生数组。

❤️ 3. 连续内存布局让 CPU Cache 命中率飙升

Array 在 L1/L2 缓存命中率极高。


🎯 2. 字典编码

大量重复字段,如:

  • 中国 北京
  • 美国 加州
  • 美国 加州
  • 美国 加州

如果每条都存一个字符串:

600 万行 × 平均 40 字节 = 240MB

我们把所有唯一位置放入:

LocationInfo[] locationDict;

主数据只存 int index:

locationIndex[i] = 123;

内存占用从 几百 MB → 数 MB


🎯 3. IP String → int(节省 70% 内存)

传统:String.split(".") = 大量临时字符串对象。

为了避免大量String对象造成GC负担,改为字符遍历 + 位运算:

int toIntIP(String ip) {
    int r = 0, p = 0;
    for (int i = 0; i < ip.length(); i++) {
        char c = ip.charAt(i);
        if (c == '.') {
            r = (r << 8) | p;
            p = 0;
        } else {
            p = p * 10 + (c - '0');
        }
    }
    return (r << 8) | p;
}

特点:

  • 不创建 String[]
  • 不创建临时对象
  • 不触发 GC

🎯 4. 二分查找(Binary Search)O(logN)

400 万条数据:

log₂(4000000) ≈ 21.93

仅需对比 ~22 次。


🎯 5. JVM 生命周期优化

  • 启动时加载数据
  • 用 HashMap 去重 location
  • 构建完数组后丢弃 HashMap → GC 回收
  • 运行时:不再创建任何对象
  • 全部是数组读取,无 IO,无锁,无 GC

📊 实际性能对比

指标初始(DB)缓存List对象最终方案
单次查询耗时5ms0.1ms0.02ms0.0001ms
并发 10000 查询50ms~80ms1ms0.3ms0.01ms
IO
GC0
内存占用几 MB几百 MB1GB+52MB

🧠 JVM / GC 视角的设计解读

1. 为什么“对象越少越好”?

对象越多:

  • GC 压力越大
  • Cache miss 越严重
  • 指针追踪越慢

2. 为什么连续数组更快?

CPU 读内存不是按对象读,而是按 Cache Line(64B)读。

连续数组 = 极高命中率。

3. String 解析为什么慢?

每个 split 都会创建:

  • new String
  • new char[]
  • 逃逸分析无法优化

你可能毫无意识却制造了成千上万个临时对象!


🥇 最终成果:系统级别的极致优化

最终效果:

  • 300 万+ 行数据压缩到不到 60MB 内存
  • CPU 查询耗时 < 0.0001ms
  • 无 GC
  • 可支持千万级 IP 库和百万 QPS
  • 内存访问 (L1/L2 Cache) 完胜任何数据库

这是“用结构换空间、用计算换时间”的典型实践。