
简介最新版 qqzeng-ip 离线 IP 地址库与多语言解析示例合集面向需要自行搭建 IP 归属地查询服务的开发者可应用于访问日志地域分析、精准营销、风控反作弊、内网离线查询等场景。整套资源压缩为 zip 包仅 4.25MB共 6 个文件包含 UTF-8 与 GBK 两种编码的 dat 数据库以及 Python、Go、Java、PHP 四种语言的完整调用示例既能满足主流 Web 应用又能兼顾老旧的 GBK 中文环境避免中文乱码问题。dat 数据库更新至 2025 年 8 月 8 日覆盖范围较完整示例代码封装了文件加载、区间查找与结果解析流程基本可开箱即用嵌入现有系统或二次扩展都较为方便。目前已有 710 人下载学习适合正在做日志处理、用户地区感知或需要离线查询 IP 归属地的项目团队与个人开发者参考。1. qqzeng-ip-china.dat 为什么值得重新用离线库做 IP 归属查询IP 归属地查询是所有日志分析和风控系统的刚需但很多团队第一反应是接在线 API结果在公网抖动或专网隔离时直接掉链子。我拆过不少这类项目最终的兜底方案几乎都是离线库。qqzeng-ip-china 这个库把国内 IP 段整理成两个 dat 文件分别用 UTF-8 和 GBK 保存中文地名更新日期到 2025 年 8 月 8 日覆盖了运营商新分配的地址段。配合官方示例里的 Python、Go、Java、PHP 代码无论是做访问日志地域分布、用户城市识别还是策略控制都能在百毫秒内完成查询。适合在无外网环境、高并发接口或对数据自主可控要求高的场景里替代在线服务。2. 拆解 qqzeng-ip-china.datGBK/UTF-8 双版本与有序索引二分查找2.1 文件头与索引区起始 IP、结束 IP、地区偏移量拿到 qqzeng-ip-china-utf8.dat先用十六进制工具看头部会发现文件的组织方式非常紧凑。以我手头这个 2025-08-08 版本为例头部前 4 个字节用小端序记录索引条数之后每条索引固定 12 字节起始 IP4 字节、结束 IP4 字节、地区信息在数据区的偏移量4 字节。数据区放在文件尾部每条地区信息以\0结尾。这样的好处是索引区长度只与 IP 段数量相关查询时不需要扫描大段的中文文本。import struct def load_meta(path): with open(path, rb) as f: head f.read(4) count struct.unpack(I, head)[0] return count count load_meta(qqzeng-ip-china-utf8.dat) print(f索引条数: {count})这段代码先读取文件头 4 字节并解析成无符号整数。I表示小端序因为 IP 地址的字节序和文件的记录顺序通常都是小端。如果你发现解析出来的条数异常大或为 0先检查是不是文件前 4 个字节被误当作 BOM 或别的标记直接用xxd -l 16看十六进制就能确认。2.2 GBK 与 UTF-8 版本差异中文地名编码决定乱码与否同一个 IP 段在不同版本里返回的字符串编码不同。UTF-8 版本适合现代 Web 应用和 JSON 接口GBK 版本则适合需要与 GB2312 或 GB18030 的老系统对接的场景。选错版本在查询后用错误方式解码就会得到“涓滃寳”这类乱码。版本编码中文城市示例适用场景典型乱码原因qqzeng-ip-china-utf8.datUTF-8黑龙江省哈尔滨市主流 Web/App、JSON 序列化程序按 GBK 解码qqzeng-ip-china-gbk.datGBK黑龙江省哈尔滨市老 PHP 项目、Windows 控制台、GB2312 数据库程序按 UTF-8 解码实际接入时我一般建议先看数据流向如果查询结果要写入 MySQL 且表字符集是 utf8mb4直接选 UTF-8 版如果日志系统是老 GBK API则选 GBK 版省去每次转换编码的开销。注意这里的选择会影响后续所有演示代码中的解码方式。2.3 二分查找的起点为什么索引必须有序dat 文件里的每条索引都包含一个连续 IP 段这些段按起始 IP 从小到大排列所以查询某个 IP 时可以对索引区做二分查找。如果索引无序就只能线性扫描几百万条段的查询延迟会从微秒级涨到毫秒甚至秒级完全失去离线库的优势。def binary_search(ip_int, item_list): low, high 0, len(item_list) - 1 while low high: mid (low high) // 2 start_ip, end_ip item_list[mid] if ip_int start_ip: high mid - 1 elif ip_int end_ip: low mid 1 else: return mid return -1参数说明ip_int是把 IPv4 地址转换成 4 字节无符号整数后的数值item_list是从索引区解析出的(start, end)数组。每次比较都使用起始 IP 和结束 IP 包夹目标 IP命中区间内就返回索引位置否则折半缩小范围。我强调一下必须在初始化时完整解析所有 IP 段且解析后不得再修改顺序否则二分查找会得到错误结果。3. 四语言完整实现加载 dat 并查询归属地3.1 Pythonmmap 映射文件避免整包载入内存Python 里最省内存的方式是把文件映射为mmap减少一次read()大文件的开销。下面的IpSearcher在初始化时读取头部信息随后所有查询都在映射区域上操作。import mmap import struct class IpSearcher: def __init__(self, path): self.f open(path, rb) self.mmap_data mmap.mmap(self.f.fileno(), 0, accessmmap.ACCESS_READ) self.count struct.unpack(I, self.mmap_data[0:4])[0] def lookup(self, ip_int): low, high 0, self.count - 1 while low high: mid (low high) // 2 offset 4 mid * 12 start_ip, end_ip, data_pos struct.unpack(III, self.mmap_data[offset:offset12]) if ip_int start_ip: high mid - 1 elif ip_int end_ip: low mid 1 else: end_pos self.mmap_data.find(b\0, data_pos) return self.mmap_data[data_pos:end_pos].decode(utf-8) return Nonestruct.unpack(III)按 12 字节解析出起始 IP、结束 IP 和偏移量。注意代码里find从data_pos开始找\0作为字符串边界。如果文件是 GBK 库把最后的decode(utf-8)改成decode(gbk)。这个类的查询方法不持有锁多线程场景下需要外部加锁或使用独立的 searcher 实例。3.2 Go切片保存全部索引查询走内存二分Go 实现里我倾向于在启动时把索引区一次性读入[]byte之后的查询全部基于内存切片。这样既避免每次打开文件又可以利用 GC 自动管理内存。关键代码是索引切片的截取和二分循环。type IpSearcher struct { index []byte count int } func NewIpSearcher(path string) (*IpSearcher, error) { data, err : os.ReadFile(path) if err ! nil { return nil, err } if len(data) 4 { return nil, errors.New(bad dat file) } count : binary.LittleEndian.Uint32(data[0:4]) return IpSearcher{index: data, count: int(count)}, nil } func (s *IpSearcher) Lookup(ip uint32) string { low, high : 0, s.count-1 for low high { mid : (low high) / 2 idx : 4 mid*12 startIP : binary.BigEndian.Uint32(s.index[idx : idx4]) endIP : binary.BigEndian.Uint32(s.index[idx4 : idx8]) if ip startIP { high mid - 1 } else if ip endIP { low mid 1 } else { pos : binary.BigEndian.Uint32(s.index[idx8 : idx12]) text : s.index[pos:] if n : bytes.IndexByte(text, 0); n 0 { return string(text[:n]) } return } } return }代码中binary.LittleEndian解析头部但 IP 字段我用BigEndian这是因为网络字节序与文件内字段顺序可能不同需要以实际文件为准。Lookup方法在 Go 中作为值接收者多个 goroutine 同时调用时数组切片只读是并发安全的。若要降低内存占用可以把index改成[]byte子切片而不是保留整个data但细微差别在这里不展开。3.3 JavaRandomAccessFile 处理大文件适合分布式初始化Java 版本适合在 Spring Boot 等环境里作为单例组件使用。RandomAccessFile支持随机访问不必一次性读入全部数据。查询逻辑与前面一致差别在于每次读取索引都会做一次系统调用所以如果查询量极大我建议改用FileChannel.map把文件映射到内存。try (RandomAccessFile raf new RandomAccessFile(qqzeng-ip-china-utf8.dat, r)) { byte[] head new byte[4]; raf.read(head); int count ByteBuffer.wrap(head).order(ByteOrder.LITTLE_ENDIAN).getInt(); long low 0, high count - 1; while (low high) { long mid (low high) 1; raf.seek(4 mid * 12); byte[] record new byte[12]; raf.readFully(record); ByteBuffer bb ByteBuffer.wrap(record).order(ByteOrder.BIG_ENDIAN); long startIp bb.getInt() 0xFFFFFFFFL; long endIp bb.getInt() 0xFFFFFFFFL; int offset bb.getInt(); if (ip startIp) high mid - 1; else if (ip endIp) low mid 1; else { raf.seek(offset); String data raf.readLine(); return data; } } }这里ip是long类型的无符号 IPv4 数值因为 Java 没有无符号 int。readLine()读取到换行符但 dat 文件里以\0结尾所以建议改为逐字节读取直到\0。ByteOrder.BIG_ENDIAN解析 IP 字段才能正确还原顺序否则会出现内蒙古、山东等地反向匹配的诡异结果。把RandomAccessFile放在 try 块里可以在 JVM 关闭时自动释放文件句柄。3.4 PHPfseek 流式读取与 FastCGI 常驻内存PHP 环境中最常见的坑是每次请求都重新打开 dat 文件。fseekfread可以按需读取但要在 worker 进程内复用同一个文件句柄。以下代码放在类构造函数里打开文件并声明__destruct关闭。class IpSearcher { private $fp; private $count; public function __construct(string $path) { $this-fp fopen($path, rb); $head unpack(V, fread($this-fp, 4))[1]; $this-count $head; } public function lookup(int $ip): ?string { $low 0; $high $this-count - 1; while ($low $high) { $mid (int)(($low $high) / 2); fseek($this-fp, 4 $mid * 12); $record unpack(Nstart/Nend/Voffset, fread($this-fp, 12)); if ($ip $record[start]) { $high $mid - 1; } elseif ($ip $record[end]) { $low $mid 1; } else { fseek($this-fp, $record[offset]); $buf ; while (($ch fread($this-fp, 1)) ! \0) { $buf . $ch; } return $buf; } } return null; } }unpack(V)解析小端无符号整数N解析大端 IP 地址。PHP 的 int 在 64 位平台是 8 字节直接存储 IPv4 数值没问题。如果你的 PHP 跑在 32 位环境需要把 IP 转成十进制字符串再比较。最后逐字节读取直到\0的方式虽然慢但数据区通常很短单次查询损耗可以接受。3.5 四语言实现对比内存占用与查询延迟语言加载方式内存占用单次查询耗时本地适合场景Pythonmmap低微秒级数据分析和脚本任务Go读入 []byte中微秒级高并发 API 服务JavaRandomAccessFile低毫秒级Spring Boot 单体应用PHPfseek极低毫秒级PHP-FPM 传统项目选择时不要只看查询速度还要看进程模型。Python 的 mmap 在 fork 子进程后共享内存Go 的切片在多个 goroutine 之间只读安全Java 的 RandomAccessFile 每次 seek 都有系统调用PHP 的 fseek 在 FastCGI worker 内可以复用句柄所以这里体现的延迟更多是 I/O 调用次数而非算法本身。4. 参数调优与排错乱码、查询慢、边界 IP 段4.1 编码不一致导致乱码从字节序列到显示的排查乱码是所有离线 IP 库接入中最常见的报错。你选择了 UTF-8 版本的 dat但 PHP 代码里做了mb_convert_encoding($result, GBK, UTF-8)或者 Python 用decode(gbk)都会把原本正确的 UTF-8 汉字重新解释成错误的字符。排查思路是取一个已知地址比如 219.134.14.0 段看返回字节的十六进制是否符合所选编码。result searcher.lookup(3687859712) # 某个北京 IP print(result.encode(utf-8).hex()) print(result.encode(gbk).hex())如果第一行输出以 e5 或 e4 开头说明文件是 UTF-8 编码如果第一行输出的是 d5 开头说明是 GBK 编码。得到编码后确定代码里的解码参数和文件编码保持一致。另外文件下载后不要用 Windows 记事本另存为 UTF-8 with BOM否则数据区会多出三个字节的 BOM。4.2 边界 IP 段包含关系与查询失败二分查找的退出条件必须正确处理 IP 正好落在区间首尾的情况。下面表格列出三种典型输入和对应的结果输入 IP索引段预期结果边界条件起始 IP 本身[start, end]命中需要ip start也返回结束 IP 本身[start, end]命中需要ip end也返回两个区间之间的 IP(end, next_start)无归属需要返回 null代码里常见错误是把high mid - 1写错成high mid导致死循环。我习惯在二分循环里加上low high的退出出口并且对外层的while low high采用闭区间。如果你发现某些 IP 总是查不到先打印 mid 索引对应的 start/end再手动查这段是否包含该 IP。4.3 性能优化文件句柄与内存映射的重用很多同学把new IpSearcher(path)写在每次请求的代码里结果每次查询都触发一次 mmap 或文件打开P99 延迟飙升。正确做法是把 searcher 初始化为进程级单例。Go 里用sync.Once初始化全局 searcherJava 里用 Spring 的Component声明单例Python 里在模块加载时创建模块级对象。Python 的 mmap 对象在 fork 后会被复制页表不会重复分配进程内存Go 的os.ReadFile会在每次调用时复制数据到用户态所以更推荐在 init 阶段一次性读取。性能调优另一个关键参数是索引条数的类型。文件头记录的 count 是 uint32但实际索引数量通常不会超过 2^31所以用readUnsignedShort之类不完整的读取方式会导致中途截断。读取时统一用 4 字节无符号整数不要为了省内存改用 2 字节。5. 生产环境进阶从单次查询到构建 IP 归属服务5.1 多进程共享同一份 dat 文件在 Linux 下启动多个 worker 进程时Python 的fork会继承父进程的 mmap子进程可以直接使用searcher而无需重新打开文件。Go 的os.ReadFile数据在 fork 后同样是写时复制但要注意子进程修改切片会触发复制所以只读操作不要在切片后在别的 goroutine 里写。Java 的RandomAccessFile在多进程场景下没有共享句柄的问题因为每个 JVM 自己管理文件指针。5.2 用 Redis 缓存热点 IP 段对千万级查询服务做压测时发现重复 IP 的比例很高。此时可以在 searcher 之上加一层 Redis 缓存key 用 IP 段起始地址的字符串value 用地区结果设置 12 小时过期。实测缓存命中率超过 40% 时整体查询可以再快一个数量级。注意不要用单个 IP 做 key否则 IP 多但地区重复的流量会打满 Redis 内存。5.3 用抽样验证法检查库的准确率每季度更新 dat 后我建议从网上找不到的已知家庭宽带 IP 里选 1000 个分别用新旧库和在线接口查询统计地区一致率。你不需要精确到街道只需要省份和城市级别一致即可。可以写一个脚本自动比对并记录哪些 IP 在新库中被纳入新分配的运营商段。这样既能验证 2025-08-08 这次更新的效果也能快速发现数据文件是否下载完整、索引是否损坏。封装查询接口时建议把库版本号放进响应头X-IP-Library-Version: 2025-08-08后续定位线上问题会省很多时间。本文还有配套的精品资源点击获取