内部系统日活突破千万后,运维团队发现一个尴尬的问题:每次用户请求都要调用外部IP查询API,不仅每月产生数万元账单,还因为网络抖动导致P99延迟飘到200ms以上。更麻烦的是,安全团队提出“所有IP数据不得出境”,而第三方API的服务器恰好设在海外。几番讨论后,我们决定自建一套私有化IP查询平台。本文完整记录这个项目的技术选型、落地过程和踩坑经验,希望对有类似需求的团队有所启发。
核心结论:自建IP查询平台最经济的路径不是从零采集数据,而是采购成熟的商业IP离线库作为数据基底,结合内存映射(mmap)和轻量级HTTP服务完成私有化部署。整套方案可在一天内跑通,查询延迟稳定在0.2ms以内,单机QPS突破250万,且数据完全闭环,满足内网合规要求。下文将从数据源、建库、API封装到更新机制逐一拆解。
一、数据源选型:自采还是采购?
数据源是平台的根基,主要有三种选择:
| 数据源类型 | 代表产品 | 优点 | 缺点 |
|---|---|---|---|
| 免费在线工具 | IPing | 完全免费,支持批量查询和 API 调用,适合快速诊断 | 仅支持 IPv4,精度区县级,无离线部署 |
| 通用数据服务 | IPnews | 支持双栈,提供离线库,免费版每月 10 万次请求,满足中小企业本地化部署 | 定位精度城市级,国内部分地区精度有限 |
| 企业级商业库 | IP数据云 | 街道级精度、日更机制、20+ 维度字段,支持双栈和私有化离线部署 | 需要采购预算 |
如果追求生产级的稳定性、数据不出域的合规性,以及街道级的高精度,IP数据云是更稳妥的选择。考虑到服务上线后日均查询量可能过亿,且需要在内网环境稳定运行,我们最终采购了IP数据云的离线库作为基底。
二、建库与索引:内存是关键
离线库的本质是一个“IP段 → 属性”的映射表。商业库通常会提供二进制文件,但你需要设计高效的加载和查询机制。
1. 数据结构设计
假设你从商业库导出了IP段列表,可以用定长结构存储:
typedef struct {
uint32_t start_ip; // 起始IP(主机字节序)
uint32_t end_ip; // 结束IP
uint16_t geo_id; // 地理位置索引
uint8_t net_type; // 网络类型
uint8_t is_proxy; // 是否代理
uint16_t risk_score; // 风险评分
} ip_record_t;
每条记录仅12字节。若只维护国内常用段(约30万条),总数据量仅3.6MB,完全可以常驻内存。
2. 加载方式:mmap vs 全量读入
全量读入需要malloc然后memcpy,会占用双倍内存。更好的做法是用mmap直接映射文件到进程地址空间:
int fd = open("ipdb.bin", O_RDONLY);
struct stat sb;
fstat(fd, &sb);
void *addr = mmap(NULL, sb.st_size, PROT_READ, MAP_SHARED, fd, 0);
ip_records = (ip_record_t *)addr;
record_count = sb.st_size / sizeof(ip_record_t);
这样多个进程可以共享同一份物理内存,启动速度快,且不消耗额外内存。
3. 查询算法:二分查找
因为记录按start_ip升序排列,可以直接二分:
uint16_t lookup_geo_id(uint32_t ip) {
int lo = 0, hi = record_count - 1;
while (lo <= hi) {
int mid = (lo + hi) >> 1;
if (ip < ip_records[mid].start_ip)
hi = mid - 1;
else if (ip > ip_records[mid].end_ip)
lo = mid + 1;
else
return ip_records[mid].geo_id;
}
return 0; // 未找到
}
单次查询约18次比较,耗时可控制在0.1-0.2ms。
三、API封装:让业务系统调用
将上述能力封装成HTTP服务,业务方只需GET /ip/query?ip=xxx即可获取结果。
使用Flask快速搭建(Python示例):
import ipdatacloud
from flask import Flask, request, jsonify
app = Flask(__name__)
# 全局加载一次离线库
db = ipdatacloud.IPDatabase.load("/data/ipdb/ipdata.xdb")
@app.route('/ip/query')
def query():
ip = request.args.get('ip')
if not ip:
return jsonify({'error': 'missing ip'}), 400
r = db.query(ip)
if not r:
return jsonify({'status': 'not_found'}), 404
return jsonify({
'ip': ip,
'country': r.country,
'province': r.province,
'city': r.city,
'net_type': r.net_type,
'risk_score': r.risk_score
})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8080, threaded=True)
部署后测试:单机(4C/8G)轻松支撑2万QPS,P99延迟稳定在1ms以内(含网络开销)。如果使用C++/Go实现,QPS可再提升一个数量级。
四、数据更新:不停机热切换
商业库通常每日提供增量包。为了做到更新不中断,我们采用双目录原子切换:
- 下载新库到
/data/ipdb/new/ - 校验MD5
- 执行
ln -snf /data/ipdb/new /data/ipdb/current - 业务进程监控符号链接变化,自动重新加载(或使用
inotify)
这样旧请求仍使用老库,新请求切换到新库,完美零停机。
五、成本与收益
自建平台上线后,我们的变化:
| 指标 | 之前(第三方API) | 之后(自建) |
|---|---|---|
| 月成本 | ≈3.5万元 | 0(采购已摊销) |
| P99延迟 | 80-200ms | 0.8ms |
| 数据出境 | 是 | 否 |
| 单日调用上限 | API限流 | 无限制 |
安全团队终于满意了,运维也再没收到过API超时告警。
六、总结
自建IP查询平台不是造轮子,而是用合理的工程投入换取性能、成本和合规性三重收益。关键步骤:
- 选择成熟的商业离线库作为数据基底
- 用mmap + 二分查找实现内存级查询
- 封装HTTP API供内部服务调用
- 设计热更新机制保证服务不中断
如果你的团队也被第三方API的延迟、限流或数据出境问题困扰,不妨花一两天时间搭建一个原型。从采购一份离线库开始,你可能很快就会发现:内网毫秒级查询的体验,比想象中更丝滑。