这是一个从 数据库查询 → 缓存 → 对象列表 → 字节级内存结构 的完整优化过程。
最终实现:处理 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 个版本的优化过程。
🧪 第一版:数据库查询
流程:
- 拆分 IP 字符串
- 转成 int 或 long
- 拼 SQL
- MySQL 查询
- 返回位置
问题暴露:
| 问题 | 影响 |
|---|---|
| 网络 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对象 | 最终方案 |
|---|---|---|---|---|
| 单次查询耗时 | 5ms | 0.1ms | 0.02ms | 0.0001ms |
| 并发 10000 查询 | 50ms~80ms | 1ms | 0.3ms | 0.01ms |
| IO | 高 | 中 | 无 | 无 |
| GC | 中 | 高 | 高 | 0 |
| 内存占用 | 几 MB | 几百 MB | 1GB+ | 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) 完胜任何数据库
这是“用结构换空间、用计算换时间”的典型实践。