
简介ip2region 地址定位库 v2.11.2.zip 是一款开源的 IP 地址到地理位置快速映射组件面向需要地域识别能力的开发者可用于广告定向、内容分发、网络安全分析等场景。压缩包共 301 个文件约 34.11MB内含 C、Java、Go、Python、PHP、Lua、Rust 等语言的源码实现同时提供数据库文件、配置文件、测试数据与说明文档方便不同技术栈直接集成或二次开发。包内示例代码与构建脚本能帮助初学者快速理解二分查找定位原理并完成本地部署。目前已有 149 人学习下载。通过本包可获得完整的多语言接口实现、IP 地址地域数据库及查询示例节省自行收集与编译时间适用于毕业设计、论文验证或系统工具中的 IP 定位模块开发。1. 先说结论ip2region 是什么以及为什么我最终放弃了在线IP查询接手一个每天几百万次请求的流量分析服务时我一开始用的是在线IP归属查询结果量一上来就翻车外部接口时不时超时账单也跟着起飞每次排查“这个IP到底在哪”还要看别人脸色。后来我把 ip2region 这个纯离线地址定位库 v2.11.2 接进来所有查询都发生在本地进程里不再依赖任何网络请求。它做的事情很简单给定一个 IPv4 地址返回“国家|省份|城市|运营商”这样的归属字符串查询耗时可做到微秒到毫秒级精度一般为城市级。适合日志归因、按地域做内容分发、风控黑名单地域分析、安全审计这类场景。如果你在做后端服务、数据分析管道或安全工具且不想为IP定位付出网络延迟和限流成本这条路线值得照着做一遍。2. 最小接入Java、Python、Go 三种语言的三行代码ip2region 的运行时核心只有一个文件ip2region.db。语言绑定只是外壳拿到这个 db 文件剩下的事就是调用一个查询方法。下面按 Java、Python、Go 各给一套最小可运行方案顺手把三个关键参数讲清楚db 路径、缓存策略、并发模型。2.1 为什么选 v2.11.2 而不是最新版ip2region 后续版本换了新的 xdb 文件格式查询性能高一些但老项目接入时往往要同步改绑定代码和文件格式。v2.11.2 的 db 文件是成熟的老格式社区里 Java、Python、Go、PHP、C 的绑定基本都是围绕这个格式展开的接到历史服务里基本不动业务代码。如果你只是想把“在线IP查询”换成“离线IP查询”v2.11.2 是一个低冒险的选择资料多、踩坑记录多、出问题容易搜到。有一个前提要记住绑定代码的版本尽量和 db 文件版本配套。老 binding 读新格式的库或者在 v2 代码里塞进 v3 的 xdb 文件结果往往不是报错就是乱码。后面避坑章会专门展开。2.2 JavanewWithFile 与缓存策略参数Java 绑定里最常见的用法是Searcher.newWithFile第二个参数传索引算法。以 v2.11.2 的 release 源码为准核心代码如下import org.lionsoul.ip2region.Searcher; public class IpLocator { private final Searcher searcher; public IpLocator(String dbPath) throws IOException { // BINARY_ALGORITHM不把整个 db 载入内存查询时在文件内做二分 this.searcher Searcher.newWithFile(dbPath, Searcher.BINARY_ALGORITHM); } public String locate(String ip) throws IOException { // search 返回 国家|省份|城市|运营商 return searcher.search(ip); } public void close() throws IOException { searcher.close(); } }这段代码的逻辑是构造一个只持有 db 文件句柄的 Searcher每次search都走文件内二分查找不把整库读进堆内存。两个参数要理解透第一个是 db 文件路径路径写错会在构造阶段直接抛 IOException第二个是算法常量BINARY_ALGORITHM意味着内存占用极低但每次查询有一次文件 IO。如果你换成MEMORY_ALGORITHM构造时就会把整个 db 读进堆内存查询更快但内存占用等于 db 体积。返回值是用|分隔的字符串解析时注意转义result.split(\\|)。这个 Searcher 不是线程安全的多个线程共用一个实例会出现串数据的问题具体解法在避坑章。2.3 Python文件缓存与一次查询Python 绑定的核心是 C 扩展常见用法是构造时直接传 db 路径默认把整个文件读进内存。最小代码如下from ip2region import Searcher # 构造时加载 db 文件到内存后续查询不走磁盘 searcher Searcher(ip2region.db) ip 114.114.114.114 result searcher.search(ip) # 返回 中国|省份|城市|运营商 print(result) searcher.close()这里只有一个参数db 文件路径。Python 绑定的默认行为是整库载入内存所以查询耗时极短。要注意的是如果绑定的 C 扩展编译版本和 db 文件格式不匹配search可能返回乱码或直接崩掉这种问题在自编译环境里尤其常见。遇到这种情况优先检查绑定版本而不是怀疑代码逻辑。2.4 Go并发安全是重点Go 绑定一般先把 db 文件读成[]byte再交给NewWithBuffer。最小代码如下package main import ( fmt os ip2region github.com/lionsoul2014/ip2region/binding/golang ) func main() { dbBytes, err : os.ReadFile(ip2region.db) if err ! nil { panic(err) } searcher, err : ip2region.NewWithBuffer(dbBytes) if err ! nil { panic(err) } defer searcher.Close() region, err : searcher.SearchByStr(114.114.114.114) if err ! nil { panic(err) } // region 为 中国|省份|城市|运营商 fmt.Println(region) }NewWithBuffer的参数是已经读入内存的 db 字节切片这样查询全程不走文件 IO适合高 QPS 场景。SearchByStr接收点分十进制字符串内部会先做进制转换。这里最大的坑是并发v2.x 的 Go Searcher 内部有状态多个 goroutine 共用同一个实例会偶发返回错误归属。高并发服务里我通常用sync.Pool维护一批 Searcher每个 goroutine 取一个独立的再用完归还避免锁竞争也避免实例复用。3. 构建自己的 ip2region.db从原始 IP 段到可查询库文件很多团队不满足于官方库的更新节奏想把自己手里的 IP 段数据比如从运营商公报或商业数据库买来的构建成 ip2region.db。这个过程不复杂但有几个失败点几乎人人都踩。3.1 原始数据长什么样ip.merge.txt 与自备数据源ip2region 的构建输入是一行一条的文本标准格式是六列用竖线分隔1.0.0.0|1.0.0.255|国家|省份|城市|运营商 1.0.1.0|1.0.3.255|国家|省份|城市|运营商每行表示一个 IP 段及其归属信息左闭右闭。如果你手里的数据是 CSV 或者第三方 JSON第一步就是归一化成这个格式。这里有一个关键动作把 IP 转成整数再排序不要拿字符串排序否则“1.2.3.4”会排在“10.0.0.0”后面构建结果直接错乱。import ipaddress # 输入 csv: start_ip,end_ip,country,province,city,isp rows [] for line in open(my_ips.csv, encodingutf-8): start, end, country, prov, city, isp line.strip().split(,) rows.append(( int(ipaddress.IPv4Address(start)), # 转整数避免字符串比较 int(ipaddress.IPv4Address(end)), country, prov, city, isp )) # 必须按 start 升序否则 maker 会拒绝对话或产生错段 rows.sort(keylambda r: r[0]) with open(ip.merge.txt, w, encodingutf-8) as out: for start, end, country, prov, city, isp in rows: out.write({}|{}|{}|{}|{}|{}\n.format( ipaddress.IPv4Address(start), ipaddress.IPv4Address(end), country, prov, city, isp ))这段代码的逻辑是先把所有 IP 段转成整数按起始地址升序排序再写回点分十进制。参数上注意两点编码必须是 UTF-8不要带 BOM否则 maker 会把 BOM 当成列内容分隔符必须是竖线不要用逗号。如果你手里的数据源本身就有重叠段先做一次重叠清理否则后面构建出来的库查询结果会不稳定。3.2 用 maker 构建 db命令行与 JVM 参数v2.11.2 发布包里通常带一个 maker 目录里面是 Java 工程。构建流程是直接运行主类把输入文本和输出路径传进去。命令大概长这样# maker 主类以实际 release 源码为准常见为 org.lionsoul.ip2region.maker.DbMaker java -Xmx512m -cp ip2region-maker.jar \ org.lionsoul.ip2region.maker.DbMaker \ ip.merge.txt ip2region.db两个参数分别对应输入文件和输出文件。-Xmx512m是给 maker 的堆内存上限maker 会把整张合并表放内存输入文件有几百万行时默认堆不够会直接 OOM。逻辑上 maker 做三件事校验行格式、检查段与段之间的重叠关系、写出数据区和索引区。如果输入文件存在前一段的结束 IP 大于后一段的起始 IP 的情况maker 会拒绝生成或者生成一个错乱的库。3.3 相邻段合并缩小体积的关键构建出来的 db 体积和段数量直接相关。如果两段 IP 归属完全一致且地址连续完全可以合并成一段。段数量越小索引区越小二分查找的层数也越低。预处理时顺手做一次合并# rows 已按 start 升序 merged [] for r in rows: if merged and merged[-1][1] 1 r[0] and merged[-1][2:] r[2:]: # 前一段结束 IP 1 等于当前段起始 IP且归属一致则合并 merged[-1] (merged[-1][0], r[1],) r[2:] else: merged.append(r) print(f合并前 {len(rows)} 段合并后 {len(merged)} 段)这里只有一个合并条件前一段结束 IP 加一等于当前段起始 IP并且从国家到运营商的五列归属完全相同。参数上要注意merged[-1][1] 1 r[0]判断的是连续段如果中间隔了哪怕 1 个 IP也不能合并。合并操作能显著缩小最终 db 体积尤其是运营商数据源里大量连续段归属相同时效果明显。3.4 构建后自校验随机抽查与边界检查构建完成的 db 不能直接上生产先用抽样脚本自检一遍。最有效的抽法是覆盖每条原始记录的三个点段首 IP、段尾 IP、段内随机 IP。import random from ip2region import Searcher searcher Searcher(ip2region.db) def expect_text(r): return {}|{}|{}|{}|{}.format(r[2], r[3], r[4], r[5]) samples [] for r in rows[:1000]: # 抽前 1000 个段做冒烟 samples.append((r[0], r)) samples.append((r[1], r)) samples.append((random.randint(r[0], r[1]), r)) for target, r in samples: ip str(ipaddress.IPv4Address(target)) got searcher.search(ip) if got ! expect_text(r): print(MISMATCH, ip, got, expect_text(r))这个脚本的逻辑是把原始段的首尾和段内随机点查一遍期望值和构建输入完全一致。只要有一条不一致说明构建过程有排序、重叠或换行符方面的问题。段首段尾是最容易暴露边界 bug 的位置如果这两个点都通过基本可以说明 maker 对边界的处理是对的。4. 查询链路拆解ip2region 的索引格式与二分边界接入和构建都跑通之后有必要理解 db 内部是怎么组织的否则遇到“差一个 IP 就查错”的边界问题会无从下手。4.1 db 内部的两层结构ip2region.db 的内部结构可以粗分为两个区域数据区和索引区。数据区按物理顺序存放若干条完整记录每条记录包含起始 IP、结束 IP 和归属字符串索引区存放指向数据块的二分查找索引。查询时先二分索引区定位到可能包含目标 IP 的数据块再在数据块内顺序匹配。这个“先索引定位、再块内扫描”的设计保证了查询时间不会随着数据总量线性增长。一个常见的验证手段是用十六进制读文件头确认索引区的偏移量import struct with open(ip2region.db, rb) as f: head f.read(16) # 粗读前 4 字节可能是文件头长度索引区起始偏移在第 8~12 字节附近 first_index_ptr, last_index_ptr struct.unpack(II, head[8:16]) print(index range:, hex(first_index_ptr), hex(last_index_ptr))这段代码只是用来验证文件不是空的、索引区偏移量能读出来别拿它去改库。不同小版本的 db 文件头部字段位置可能不同所以这个脚本只当辅助手段用。真正要理解的是两层查询模型第一层二分定位数据块第二层顺序扫描块内记录。4.2 二分边界为什么“刚好相等”的 IP 最容易翻车很多人自己写二分定位 IP 段时习惯用“段的 start 小于等于 IP 且 end 大于等于 IP”作为命中条件这个条件本身没错但二分循环的边界语义经常写错。常见错误是循环结束后拿lo去取结果结果取到的是“第一个 start 大于目标 IP”的段导致差一个段。安全的写法是找“最后一个 start 小于等于目标 IP”的索引项def find_block(blocks, target): lo, hi 0, len(blocks) - 1 while lo hi: mid (lo hi) 1 if blocks[mid].start target: lo mid 1 # 目标可能在 mid 或更后面 else: hi mid - 1 # 循环结束后 hi 指向最后一个 start target 的块 seg blocks[hi] if seg.start target seg.end: return seg return None关键在最后一句循环结束后hi和lo的语义不一样取结果用hi因为hi是最后一个满足start target的位置。如果写成lo当目标 IP 正好落在某个段首时会往后多跨一个段。这个 off-by-one 问题在段边界处最容易暴露出来。4.3 三种查询模式与内存/速度取舍v2.x 的 Searcher 提供了三种打开方式对应三种不同的资源取舍选型时直接按这张表对号入座模式内存占用单次查询耗时量级适用场景MEMORY约等于 db 文件体积微秒级高 QPS 在线服务能接受启动加载BINARY几乎为零靠文件 IO毫秒级低 QPS 定时任务内存紧张BTREE约为索引区体积亚毫秒级需要平衡内存和查询性能的中间态参数上的判断标准很简单如果 db 文件有 3MBMEMORY 模式就额外占堆 3MB每多一个 Searcher 实例就多占一份BINARY 模式几乎不占内存但每次查询都发生一次磁盘随机读BTREE 模式只把索引区载入内存数据区仍走文件 IO。我在高并发服务里一般用 MEMORY 模式并全局复用单例在批处理脚本里用 BINARY避免脚本启动时加载整库浪费时间。5. ip2region 使用避坑5 个让我翻过车的实际问题这章整理我实际遇到过的高频问题每条按现象、原因、解决三段写希望你能少走弯路。5.1 查询结果返回“0|0|0|0”或空段现象查一个确定的公网 IP返回的归属全是 0或者国家对了但省市为空。原因有两大类一是 db 构建时该段本身就缺归属信息数据源没覆盖到二是查询的 IP 落在保留段、私有段或未分配地址区间库里压根没有对应记录。解决方法是先做一次前置过滤把私有段和保留段直接挡在查询之外import ipaddress PRIVATE_NETS [ ipaddress.ip_network(10.0.0.0/8), ipaddress.ip_network(172.16.0.0/12), ipaddress.ip_network(192.168.0.0/16), ipaddress.ip_network(127.0.0.0/8), ] def normalize(ip_str): ip ipaddress.IPv4Address(ip_str) for net in PRIVATE_NETS: if ip in net: return 内网IP return None # 交给 ip2region 处理过滤之后再查返回全零时业务侧统一回退成“未知”。这个兜底必须做否则下游统计里会出现一排“0|0|0|0”排查起来很迷惑。5.2 并发查询结果相互串现象服务刚上线时单线程验证正常压测一上返回的城市张冠李戴同一个 IP 两次查询结果不同。原因是 Searcher 内部有游标或上下文变量多个线程复用同一个实例时互相覆盖。解决方法是每线程独立实例Java 里用 ThreadLocal 包一层最省事private static final ThreadLocalSearcher TL ThreadLocal.withInitial(() - { try { return Searcher.newWithFile(dbPath, Searcher.MEMORY_ALGORITHM); } catch (IOException e) { throw new RuntimeException(e); } }); public String locate(String ip) throws IOException { return TL.get().search(ip); }每个线程持有自己的 Searcher内存上会翻倍占用但换来的是不需要加锁。如果你不想每线程占一份 db 内存就用synchronized(searcher)包住 search 调用牺牲一点并发度换取内存空间。5.3 自建库查询结果差一个段现象用自己构建的 db 查询段尾 IP 经常被命中成下一段的归属。原因是输入文本没有严格按起始 IP 升序排列或者相邻段出现重叠maker 没有完全拒绝而是按错误数据生成了索引。解决方法是构建前增加重叠检测把“前一段结束 IP 1 当前段起始 IP”的行全部找出来人工处理for i in range(1, len(rows)): prev_end rows[i - 1][1] curr_start rows[i][0] if prev_end 1 curr_start: print(f重叠段: {rows[i-1]} vs {rows[i]})这里要特别提醒判断重叠用的是prev_end 1 curr_start而不是prev_end curr_start。因为左闭右闭的段定义下前一段结束 IP 正好等于当前段起始 IP 减一时是连续的合法状态。5.4 换库后绑定直接报错或返回乱码现象从某个渠道下载了最新版 db 文件旧代码没动结果 search 抛异常或返回一堆乱码。原因是 db 文件格式和绑定版本不配套最常见的是 v3.x 的 xdb 格式文件被喂给了 v2 的绑定。解决方法是保持绑定代码和 db 文件同源要么 v2.11.2 的 db 文件配 v2 绑定要么把绑定一起升级到 v3。我在生产环境里的做法是固定一个 release 版本把 db 文件和绑定 jar 一起打进构建产物避免有人单独更新其中一个。5.5 内存被 MEMORY 模式吃满现象Java 服务启动后堆内存快速上涨GC 频繁最终 OOM。原因是在高并发场景下用 ThreadLocal 包了 MEMORY 模式的 Searcher每线程一份整库副本线程池有几百个线程就意味着几百份 db 同时驻留堆内。解决方法是先算账线程数乘以 db 文件体积如果接近堆内存的五分之一就说明模式选错了。更合理的做法是全局单例加锁或者直接切到 BINARY 模式不要试图用线程数换并发性能。6. 验证与进阶从一个自测脚本开始把城市级定位用到极致6.1 写一个批量自测脚本量化准确率和耗时上线前先在测试集上跑一遍别凭感觉说“应该没问题”。测试集可以从原始数据或第三方抽样生成每行一个 IP 加期望归属脚本自动对比import time from ip2region import Searcher searcher Searcher(ip2region.db) def load_cases(path): cases [] for line in open(path, encodingutf-8): ip, expect line.strip().split(,) cases.append((ip, expect)) return cases cases load_cases(test_cases.txt) ok 0 t0 time.time() for ip, expect in cases: got searcher.search(ip) if got expect: ok 1 cost_ms (time.time() - t0) * 1000 / len(cases) print(f准确率: {ok / len(cases) * 100:.2f}% 平均耗时: {cost_ms:.4f} ms)抽样规则建议每个省份加运营商组合至少抽 100 条段首段尾各抽一部分随机中段抽一部分。如果准确率低于 95%先查数据源再动代码不要靠改查询逻辑去掩盖数据问题。6.2 让城市级结果更有用ISP 字段与高频段缓存ip2region 查询结果里最后一段是运营商信息很多人只取了省市就丢掉其实在流量分析和风控场景里“城市运营商”组合是非常好的分片键比单纯按城市分片均衡得多。另一个技巧是把高频 IP 的归属结果缓存到 Redis查询时先走缓存miss 再走 ip2region能明显降低对 db 的查询压力。缓存 key 用 IP 段而不是单个 IP例如把 /24 段的查询结果缓存起来命中率会大幅提升。我现在的习惯是每季度更新一次 db把构建校验脚本挂进 CI发版前自动跑一遍历史流量样本对比新旧库的差异。改库格式前也会先跑一遍抽样对比确认没有破坏性变更再合并。路由、缓存、风控这些下游服务部署时可以完全无感因为查询入口只有一个。希望帮到你。本文还有配套的精品资源点击获取