
简介这是一份面向开发者的IP归属地查询数据库及多语言调用示例合集适合需要在Python、Go、Java、PHP等项目中快速集成IP定位功能的工程师使用。压缩包共6个文件包含UTF-8与GBK两个版本的qqzeng-ip-china.dat数据库以及对应四种语言Go、Java、Python、PHP的完整查询代码覆盖从数据库读取、IP解析到地区返回的完整调用流程开发者可参照示例直接嵌入自己的服务端程序。数据库已更新至2025年8月8日包体仅4.25MB体积小巧。目前已有710人学习下载。这份资源将数据库与多语言代码打包在一起省去自行转换编码和编写接口的麻烦尤其适合需要同时维护多种语言服务端的团队和个人也可作为学习IP解析原理的实战参考。1. 一份 .dat 解决全网 IP 位置查询qqzeng-ip-china 系列数据库和代码示例能做什么在做日志分析、用户风控或流量统计时经常会遇到一个需求把一个 IPv4 地址转成省市区、运营商再关联到城市维度做聚合。市面上有通过 HTTP 接口查询的方案也有需要部署服务端的方案但对于离线计算和单机部署来说最省事的还是用一份 qqzeng-ip-china-utf8.dat 或 qqzeng-ip-china-gbk.dat 直接把地理位置数据读进内网。这个系列数据库将 IP 段、归属地、运营商信息做成了紧凑的二进制格式省掉了 MySQL、Redis 或外部 API 的参与查询通过二分查找直接在内存中完成单次耗时通常在亚毫秒级正好应对多语言项目里“给我一个纯本地、无依赖的归属地查询”这类核心诉求。标题中出现 utf8.dat 和 gbk.dat 两个文件并不是两份不同数据而是同一份完整库为不同字符集场景分别编码。UTF-8 版本适合新系统GBK 版本主要面向遗留的中文 Windows 环境或老 PHP 项目。本文先把二进制结构和选型原因讲清楚再分别给出 Python、Go、Java、PHP 四套完整解析代码最后落到数据更新、结果校验和并发处理这几个容易被忽略的边界。2. qqzeng-ip-china-utf8.dat 与 gbk.dat 的二进制结构、二分查找原理和选型依据2.1 目录式结构从头部信息定位索引区和数据区拿到一个 .dat 文件先不要急着写代码。qqzeng-ip-china 系列采用“目录式”布局整体分为三部分文件头、索引区、数据区。文件头一般以固定的魔数开头记录版本号、索引区起始偏移、索引总条数等元信息。索引区是定长记录数组每条索引由起始 IP、结束 IP 和该段数据的偏移三部分组成常见布局为每项 12 字节4 字节起始 4 字节结束 4 字节偏移。数据区紧跟在索引区之后每条记录以字符串形式存放省、市、运营商等信息记录之间用分隔符切分。2.2 查询为什么快索引区有序二分查找定位 IP 段查询过程本质上是“逐段比对”把要查的 IP 转成 32 位无符号整数用二分查找在索引区中找到满足起始 IP ≤ 目标 IP ≤ 结束 IP的那一项然后根据记录偏移去数据区读出对应字符串。索引区是按起始 IP 升序排列的因此二分查找复杂度为 O(log n)。对于几十万条段来说大约十几次比较即可定位纯内存操作耗时在微秒级。这个特性决定了后续四种语言实现都可以用同一套逻辑解析文件头拿到索引区起始偏移、索引条数和数据区起点将待查询 IP 字符串转换成无符号 32 位整数IPv4在索引区上进行二分查找命中后到数据区读取归属地字符串按指定编码解码2.3 UTF-8 还是 GBK不是数据不同是应用场景不同两个文件的实际差异体现在数据区的字符串编码上。UTF-8 版本适合 logs 统一用 UTF-8、存储用 MySQL utf8mb4 的新系统解析时直接以 UTF-8 解码即可GBK 版本主要面向老牌 Windows PHP 环境或对接早期短信接口、Excel 导出等仍以 GBK 为内部编码的场景。选择标准有两条一看你的项目内部是否统一使用 UTF-8二看是否必须兼容一个“只能在 GBK 下工作”的下游模块。如果项目从零起步qqzeng-ip-china-utf8.dat是默认选择本地工具链年份较早、历史包袱较重建议两个都留一份代码里通过配置切换编码而不是硬编码。提示utf8.dat与gbk.dat对应同一份数据版本不要在同一个进程中同时加载两份到内存。按需加载其中一个节省内存并避免索引区重复占用。2.4 需要留意的边界索引项长度不是绝对固定在拿到某个具体 .dat 文件时第一条要确认的信息是“索引项长度”。老版 qqzeng.dat 的标准结构是 12 字节索引项但不同版本曾出现过 8 字节、16 字节等变体。不要硬编码一个索引项大小就以为万事大吉稳妥做法是从文件头读出版本号或索引条数字段再根据文件大小反推索引项长度索引项长度 (文件总大小 - 索引区起始偏移) / 索引总条数如果反推结果不是常见值8/12/16就要检查头部解析是否偏移。这是新手最容易踩的第一个坑直接按 12 字节硬编码读一个老版本可能前几千条能查对到后期全部错位。3. Python 与 Go 完整实现内存加载、二分查找和字符集解码3.1 Python 实现struct 解包加二分查找用 mmap 减少内存拷贝Python 实现采用mmap把文件映射到内存避免整文件读入后再复制一份减少内存占用。核心代码如下import mmap import struct class QQZengIpChina: def __init__(self, dat_path): self._f open(dat_path, rb) self._mm mmap.mmap(self._f.fileno(), 0, accessmmap.ACCESS_READ) # 文件头解析这里以常见的头部布局为例 # [0:4] 魔数或版本标记 # [4:8] 索引区起始偏移int32 # [8:12] 索引总条数int32 # 实际文件可能略有差异以你拿到的文件的头信息为准 self.index_start struct.unpack(I, self._mm[4:8])[0] self.index_count struct.unpack(I, self._mm[8:12])[0] self.index_item_size 12 # 起始IP(4) 结束IP(4) 数据偏移(4) def _ip_to_int(self, ip_str): parts ip_str.split(.) return (int(parts[0]) 24) | (int(parts[1]) 16) | (int(parts[2]) 8) | int(parts[3]) def lookup(self, ip_str, encodingutf-8): ip_int self._ip_to_int(ip_str) low, high 0, self.index_count - 1 while low high: mid (low high) // 2 off self.index_start mid * self.index_item_size start_ip, end_ip, data_off struct.unpack( III, self._mm[off:off self.index_item_size] ) if ip_int start_ip: high mid - 1 elif ip_int end_ip: low mid 1 else: # 从数据区读取字符串分隔符为 \t end_pos self._mm.find(b\t, data_off) raw self._mm[data_off:end_pos] return raw.decode(encoding, errorsreplace) return 未知逻辑说明lookup方法先做 IP 到整数的转换然后在索引区上二分查找命中后根据data_off去数据区读取字符串。数据区记录以\t作为字段终止符读取时用find定位。参数encoding控制按 utf-8 还是 gbk 解码传入对应的.dat文件时才一致。提示errorsreplace能避免个别脏数据导致UnicodeDecodeError中断查询生产环境中也可以替换为errorsignore但会丢掉异常字符信息。3.2 Go 实现slice 直接索引二进制块binary 包解数值Go 的实现思路与 Python 一致但处理二进制更直接。将文件读入[]byte后用binary.LittleEndian解析数值索引区用切片操作不需要额外的解码层package main import ( encoding/binary fmt os ) type IPDB struct { data []byte indexStart uint32 indexCount uint32 indexSize uint32 } func OpenIPDB(path string) (*IPDB, error) { data, err : os.ReadFile(path) if err ! nil { return nil, err } if len(data) 12 { return nil, fmt.Errorf(file too small) } db : IPDB{ data: data, indexStart: binary.LittleEndian.Uint32(data[4:8]), indexCount: binary.LittleEndian.Uint32(data[8:12]), indexSize: 12, } return db, nil } func ipToInt(ip string) uint32 { var a, b, c, d uint32 fmt.Sscanf(ip, %d.%d.%d.%d, a, b, c, d) return a24 | b16 | c8 | d } func (db *IPDB) Lookup(ip string, encoding string) string { target : ipToInt(ip) low, high : 0, int(db.indexCount)-1 for low high { mid : (low high) / 2 off : int(db.indexStart) mid*int(db.indexSize) startIP : binary.LittleEndian.Uint32(db.data[off : off4]) endIP : binary.LittleEndian.Uint32(db.data[off4 : off8]) dataOff : binary.LittleEndian.Uint32(db.data[off8 : off12]) if target startIP { high mid - 1 } else if target endIP { low mid 1 } else { end : dataOff for db.data[end] ! \t { end } raw : db.data[dataOff:end] return string(raw) // 若为 gbk.dat此处需用 golang.org/x/text/encoding/simplifiedchinese 转换 } } return 未知 }逻辑说明Go 版用encoding/binary.LittleEndian处理小端序定长字段与 Python 的struct.unpack(I, ...)完全对应。二分查找部分直接对[]byte切片进行数值读取避免了额外分配。需要特别注意string(raw)只适用于 utf8.dat若加载 gbk.dat需引入golang.org/x/text/encoding/simplifiedchinese做 GBK 到 UTF-8 的转码否则会输出乱码。这正体现了标题中“两个版本文件 多语言”的意义编码处理在每种语言里都要单独做。3.3 两种语言实现的关键参数和易错点对照参数或步骤Python 写法Go 写法注意点文件读取方式mmap映射os.ReadFile整读大文件用 mmap 节省内存Go 的整读在百万行数据场景下也够用IP 转整数(a24)按位或a24无符号拼接注意 Python int 无限长Go 用uint32防止溢出二分查找终止条件low highlow high边界必须包含等号否则最后一个索引项查不到字符串解码.decode(encoding, errorsreplace)utf8 直接string()gbk 需转码按 .dat 实际编码选择不可省略从运行性能看两者在纯查询阶段差异不大。Go 的优势是启动速度快、部署为单一二进制适合做 CLI 工具或高并发服务的内嵌模块。Python 的优势是代码量少、脚本改起来方便适合做离线一次性统计。4. Java 与 PHP 完整实现RandomAccessFile、Charset 指定与文件指针陷阱4.1 Java 实现NIO FileChannel 加切片MappedByteBuffer 降低 GC 压力Java 实现了类似 mmap 的MappedByteBuffer适合进程内多次查询的场景。核心代码如下import java.io.RandomAccessFile; import java.nio.MappedByteBuffer; import java.nio.channels.FileChannel; import java.nio.charset.Charset; public class QQZengIpChina { private MappedByteBuffer buf; private int indexStart; private int indexCount; private static final int INDEX_SIZE 12; public QQZengIpChina(String datPath) throws Exception { RandomAccessFile raf new RandomAccessFile(datPath, r); FileChannel channel raf.getChannel(); buf channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size()); indexStart buf.getInt(4); indexCount buf.getInt(8); } private int ipToInt(String ipStr) { String[] parts ipStr.split(\\.); return (Integer.parseInt(parts[0]) 24) | (Integer.parseInt(parts[1]) 16) | (Integer.parseInt(parts[2]) 8) | Integer.parseInt(parts[3]); } public String lookup(String ipStr, String charsetName) { int ipInt ipToInt(ipStr); int low 0, high indexCount - 1; Charset charset Charset.forName(charsetName); while (low high) { int mid (low high) 1; int off indexStart mid * INDEX_SIZE; int startIp buf.getInt(off); int endIp buf.getInt(off 4); int dataOff buf.getInt(off 8); if (ipInt startIp) { high mid - 1; } else if (ipInt endIp) { low mid 1; } else { byte[] bytes new byte[64]; int len 0; byte b; while ((b buf.get(dataOff len)) ! \t) { bytes[len] b; } return new String(bytes, 0, len, charset); } } return 未知; } }逻辑说明Java 版本用MappedByteBuffer.getInt()直接读小端序数值但这里隐藏一个细节getInt()按大端序读取。机器上的字节序通常是小端因此大多数 QQZeng DAT 文件在索引项这样的定长字段上使用小端存储直接用getInt()会读反。解决方法是改用buf.order(ByteOrder.LITTLE_ENDIAN)显式声明字节序。这一点在写跨语言实现时最容易出错建议在类初始化时加上buf.order(java.nio.ByteOrder.LITTLE_ENDIAN);4.2 PHP 实现pack/unpack 处理二进制字符串避免溢出PHP 实现用unpack从字符串中解出无符号整数适合 Web 环境快速集成。注意 PHP 的int在 32 位系统上有符号必须使用unpack(V, ...)读取无符号小端整数class QQZengIpChina { private $data; private $indexStart; private $indexCount; private $indexSize 12; public function __construct($datPath) { $this-data file_get_contents($datPath); $header unpack(VindexStart/VindexCount, substr($this-data, 4, 8)); $this-indexStart $header[indexStart]; $this-indexCount $header[indexCount]; } private function ipToInt($ipStr) { $parts explode(., $ipStr); return ($parts[0] 24) | ($parts[1] 16) | ($parts[2] 8) | $parts[3]; } public function lookup($ipStr, $encoding utf-8) { $ipInt $this-ipToInt($ipStr); $low 0; $high $this-indexCount - 1; while ($low $high) { $mid intdiv($low $high, 2); $off $this-indexStart $mid * $this-indexSize; $item unpack(VstartIp/VendIp/VdataOff, substr($this-data, $off, 12)); if ($ipInt $item[startIp]) { $high $mid - 1; } elseif ($ipInt $item[endIp]) { $low $mid 1; } else { $endPos strpos($this-data, \t, $item[dataOff]); $raw substr($this-data, $item[dataOff], $endPos - $item[dataOff]); return $encoding gbk ? mb_convert_encoding($raw, utf-8, gbk) : $raw; } } return 未知; } }逻辑说明PHP 中的unpack(V...)格式符表示 32 位无符号小端整数与前面 Java 的ByteOrder.LITTLE_ENDIAN、Python 的I保持一致。strpos查找\t分隔符substr截取原始字符串。如果加载的是 gbk.dat用mb_convert_encoding转成 UTF-8 后再输出避免在页面或 JSON 中产生乱码。提示PHP 下用substr反复截取二进制字符串会产生大量临时变量。查询频率较高的场景建议先把整个文件读入$this-data后用strpos定位避免每次查询都file_get_contents。5. 多语言版本验证与性能优化技巧更新数据、校验结果和规避 64KB 陷阱5.1 用已知 IP 段做回归校验别只测本机出口拿到新版 .dat 后第一件事是验证而不是直接扔进代码。你至少准备三组测试用例第一组是已知的地区比如114.114.114.114南京、223.5.5.5杭州、180.101.50.242南京比较四种语言查询结果是否一致第二组是边界情况例如0.0.0.0、255.255.255.255确认二分查找不会死循环第三组是随机抽取一批线上真实访问 IP对比新旧版本库之间的归属地变化情况——运营商 IP 段调整是常态新版库偶尔会把某条记录从“南京市”改为“苏州市”这不代表代码有 bug而是上游数据变化。5.2 编码切换的隐藏坑UTF-8 文件按 GBK 解码的后果用 qqzeng-ip-china-utf8.dat 时如果误以 gbk 编码解码中文会显示成乱码但更隐蔽的是数据区某些字符串里同时混有 GBK 双字节和 UTF-8 三字节字符导致解码后长度不一致个别情况下会把下一条记录的起始字节吞掉。四种语言里Python 的errorsreplace不会抛异常Java 的Charset.forName(GBK).decode遇到不合法字节会直接替换为 UFFFDGo 语言不做校验直接string()反而最“诚实”——输出乱码时你能立刻察觉。生产环境建议在初始化时统一指定encoding参数并在加载后跑一次自测用例而不是把编码参数留给调用方自由发挥。5.3 64KB 文件页边界与 JVM 内存的一个实践取舍Java 使用MappedByteBuffer时有个容易被忽略的细节映射文件大小不足页面大小通常 4KB时map方法可能报InvalidDataException而超过 2GB 的文件在 32 位 JVM 上无法映射。qqzeng-ip-china 数据量一般远小于 2GB所以主要威胁是头部读取时索引偏移计算错误导致访问越界。用map后必须对indexStart indexCount * INDEX_SIZE做一次边界校验超过buf.limit()时立刻抛出异常而不是等到查询时崩溃。5.4 数据更新策略把数据库文件与代码分离做版本化引用qqzeng-ip-china 系列的发布节奏并不是每周强制更新但运营商 IP 段调整频繁建议三个月左右拉一次新版。一个比较推荐的做法是不把 .dat 文件提交到代码仓库而是放到独立的数据目录并按文件名带上版本日期例如qqzeng-ip-china-utf8-202504.dat。这样代码可以基于文件名正则匹配加载最新版本同时保留历史版本便于回滚。如果业务对结果一致性要求高比如涉及风控和奖励发放额外在数据库文件头部写一个版本号字段查询结果中带出版本标识方便对账时定位差异。# 下载后先做一次基础校验确认文件大小和头部信息完整 ls -l qqzeng-ip-china-utf8.dat # 输出中可看到文件大小用 md5sum 记录指纹便于后续比对版本 md5sum qqzeng-ip-china-utf8.dat误读文件头偏移、忽略字节序、编码传错、索引项大小写死——这四个问题覆盖了几乎所有踩坑现场。把本文的 Python、Go、Java、PHP 四段示例跑通一遍再对照你手头的 .dat 文件确认头部字段含义剩下的就是简单到乏味的二分查找了。本文还有配套的精品资源点击获取