做网络开发的人,几乎都躲不过一个需求:拿到一个 IP,想知道它大概在哪个城市、哪个运营商。我最早干这事儿是十几年前,那时候没有那么多云厂商现成接口,网上资料里出现频率最高的一个词就是“纯真网络 IP 数据库”。到现在,很多项目里的 IP 归属地功能底层用的依然还是这份数据。这篇文章就把“纯真网络 IP 数据下载地址”这件事彻底讲清楚——它到底是什么、从哪下、下载之后怎么解析、怎么集成到自己的服务里,以及我在实际项目中踩过的那些坑。不管你是做 Web 开发、网络实验、数据分析还是安全审计,只要和 IP 地理位置沾边,这篇文章都能帮你省下不少时间。
1. 为什么十几年了,纯真 IP 数据库还在被广泛使用
1.1 离线库的核心价值:速度、可控、零成本
很多人会问:现在大厂都有在线 IP 库接口,调用一下不就行了吗?为什么还要折腾一个离线 DAT 文件?这个问题我太有发言权了。
在线接口最大的几个痛点,恰恰是离线库的强项。首先是速度,如果你的业务是每秒钟处理几百上千个 IP 查询,每一条都走 HTTP 请求,光是网络开销和接口响应时间就够你喝一壶。离线库是纯本地文件,毫米级响应,几乎没有延迟。其次是可控性,在线接口意味着你的数据在别人手里,别人限流、封禁、调整价格,你一点办法都没有。我在一个项目里就遇到过某云厂商接口突然开始限量,从免费变成收费,搞得整个服务差点瘫痪。而离线库下载之后就完全是你的资产,不存在被抽走的风险。最后是成本,纯真数据库是免费开放的,一份文件全部搞定。
1.2 在线 IP 接口和离线库怎么取舍
两者不是非此即彼的关系,我习惯的做法是“离线为主,在线兜底”。
离线库负责绝大部分查询压力,速度快、成本低。但如果遇到离线库覆盖不到的 IP、解析结果明显异常,再走一次在线接口做交叉校验。这样既保住了性能,又兜住了准确性。
这里要多说一句:很多人担心离线库的数据“不够新”。纯真库目前基本保持每月更新,虽然比不上那些商业收费库的实时性,但对于绝大多数业务场景,比如用户访问日志分析、防刷地域限制、内容本地化推荐,完全够用了。况且它的数据来源本身就包含了大量真实探测和反馈修正,不是闭门造车。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 下载“纯真网络 IP 数据”的正确姿势
2.1 官方渠道怎么找
直接说地址可能不太好记,我教大家一个思路。纯真网络有一个官方网站,里面提供了一个在线查询入口,叫“纯真 IP 查询”,你用搜索引擎搜这个词,基本第一条就是官网。官网首页重点看两个地方:一个是“下载”或者“数据下载”导航,另一个是站内的公告栏目。
数据下载一般会提供两种方式:一种是直接点击链接下载压缩包,压缩包里通常就是一个 .dat 文件;另一种是提供一个辅助工具的下载,装好工具后自动同步更新数据,这个工具同时还能用来查 IP。我推荐的做法是:点“完整版下载”,下载最新的 QQWry.Dat(旧版本文件名常写作 qqwry.dat),文件一般只有几 MB 到十几 MB,冷启动小,下载速度快,也不用装任何东西。
注意:下载页面偶尔会提示你关注他们的公众号,然后用回复关键词的方式拿下载链接。这是他们官方常年的运营习惯,不是为了坑你。嫌麻烦的话,直接找页面上带“完整版”字样的直链即可。
2.2 怎么识别版本新旧
下载完成后,第一步就是判断自己拿到的数据是不是最新的。这里有两个信号:一个是看压缩包的文件修改时间,通常和官方公告的更新日期一致;另一个是看数据文件大小,纯真库每次更新,文件大小都会有细微变化,你如果手上有旧版,对比一下大小就能确认是否更新过。
另外要注意文件名的一个细节:很多人下载下来发现名字是 qqwry.dat,有的网站会标注成 QQWry.Dat,其实同一个东西。如果下载出来是一个 .rar 或 .zip 压缩包,解压后得到 QQWry.Dat,那就是对的文件。
2.3 下载之后的校验很关键
网上流传的纯真数据库文件很多,但来源乱七八糟,我亲眼见过有人在网上下载到一个被改过的 DAT 文件,解析到一半直接崩溃。所以强烈建议大家下载后做两个动作:
第一,核对文件大小和更新时间,可以在文件的“属性”里看,也可以看解压时的日志。
第二,有条件的话对比一下 SHA–256 校验值。官方不一定每次都会公布校验值,但你可以在一些可靠的技术社区里找到热心网友发布的校验信息,多一个对比总比不对比强。
当然,为了稳妥,下载之后先用一个小工具或者几行代码查一下本机 IP 归属地,如果结果和实际地域一致,基本就说明文件没问题。
3. 理解 QQwry.dat 的内部结构:解析之前必须知道的原理
3.1 整体文件框架:头部、记录区与索引区
拿到 QQWry.Dat 之后不要急着直接按文本读取,这个文件是二进制格式,专门为高速查询设计的。没有理解它的结构,代码写再多也是白搭。
整个文件的结构可以分为三块:文件头、数据记录区和索引区。
文件头非常简单,前四个字节是第一条索引的绝对偏移地址,接下来四个字节是最后一条索引的绝对偏移地址。记住,这些数值都是小端序存储的,读的时候注意字节顺序。
索引区是整个文件的灵魂。每一条索引记录固定是 7 个字节:前四个字节是一个起始 IP 地址,后三个字节是这条索引指向的数据记录的绝对偏移。索引区里的记录按照起始 IP 从小到大排列,所以支持二分查找。
记录区里存的是真正的结果,包括国家和地区字符串,以及各种特殊情况,比如某些 IP 段的重定向。
3.2 二分查找的逻辑:怎么从几百万条数据里精确定位
这里我要扩展一个很容易被忽略的点:为什么用二分而不是顺序扫描?
纯真最新版的数据文件里,IP 段记录数量大概在几十万量级。如果顺序扫描,虽然每次查询也就几十万次比较,单看也不慢,但你要是每秒处理几千个请求,这个成本就完全不可接受了。二分查找每查询一个 IP,只需要十几次比较,性能差距是数量级的。
具体查找的过程是这样的:
-
把查询 IP 转成一个无符号整数,比如
1.2.3.4转成对应的数值。 -
在索引区里做二分,每次取中间那条索引,如果查询 IP 大于等于中间索引的起始 IP,就移动上界;否则移动下界。循环,直到上下界相邻,此时下界指向的就是包含该 IP 的那条索引。
-
根据索引尾部的三字节偏移,跳到记录区对应位置,读取记录内容。
有个重点想提醒:二分的时候很多人会忽略“相邻条件”,直接比较上下界会造成死循环。我自己的经验是,用“low + 1 == high”作为循环结束条件,然后取 low 对应的记录,这套逻辑在任何边界条件下都是稳的。
3.3 记录区的重定向与字符串规则
记录区是解析文件时最容易出 bug 的地方。查到的三条字节,首字节可能是特定标识,如果是 0x01,说明国家和地区信息发生了重定向,你需要按跟随后面的三字节偏移去另一个位置重新读取。如果是 0x02,则说明国家信息重定向了,但地区信息还在当前这个位置,只需要读取紧随其后的字符串。
这里最坑的地方是:0x01 和 0x02 是十六进制标识,在字节层面你已经知道有 18 和 20 这几种可能,但在写解析代码时,如果只判断了一段而忘了另一段,就会出现“地区显示成乱码”或者“城市解析到别的省去”的诡异现象。我最初就吃过这个亏,后面会专门讲怎么排查。
字符串的存储规则也比较特殊。每条以 0x00 结尾,更准确说是以空字节结尾的 C 风格字符串,数据是 GBK 编码,不是 UTF-8。
3.4 编码是绕不过去的坎:GBK 与 UTF-8 的相互转换
很多新手写了解析逻辑,查出来的字符串却是“乱码”,原因都出在编码上。DAT 文件里存的字符串是 GBK 编码,但大多数现代应用和数据库都要求 UTF-8。
处理方法很简单:读出来之后调用 decode('gbk', errors='ignore') 转成 Python 字符串,之后该干嘛干嘛。在 Java 里则类似,用指定 GBK 字符集来解码字节数组。
但这里边有个细节:有些 ISP 名称在转换时可能因为字符不认识而报错,我统一加 errors='ignore',宁可丢掉个别字也不能让解析线程崩掉。
最后还有一个很实用的处理技巧:查出来的结果里,很多记录会包含纯真自己标注的一些特殊符号,比如 CZ88.NET、纯真 等,这是数据格式里的占位符,表示该 IP 段信息缺失或仅包含参考性质的数据。我们在展示给用户之前,可以用正则把它们替换成空字符串或者提示文案“IP 归属信息缺失”。
4. 用 Python 实现一个完整的纯真 IP 解析器
4.1 核心解析类的完整实现
理论说了半天,不上代码等于白说。下面这个封装类,我直接在项目里用了很久,兼容 Python 3.6+,核心逻辑完整,你拿到后改改路径就能跑。
python复制import struct
import socket
import os
from bisect import bisect_right
class QQWry:
def __init__(self, path):
self.path = path
self.file = open(path, 'rb')
self.first_idx = self._read_uint32(0)
self.last_idx = self._read_uint32(4)
self.index_count = (self.last_idx - self.first_idx) // 7 + 1
def _read_uint32(self, offset):
self.file.seek(offset)
return struct.unpack('<I', self.file.read(4))[0]
def _read_uint24(self, offset):
self.file.seek(offset)
data = self.file.read(3)
return struct.unpack('<I', data + b'\x00')[0]
def _read_cstring(self, offset):
self.file.seek(offset)
chars = []
while True:
b = self.file.read(1)
if not b or b == b'\x00':
break
chars.append(b)
return b''.join(chars).decode('gbk', errors='ignore')
def _lookup(self, ip_int):
records = self.index_count
# 改用二分,逻辑更稳
low, high = 0, records - 1
while low <= high:
mid = (low + high) // 2
idx_offset = self.first_idx + mid * 7
ip_start = self._read_uint32(idx_offset)
if ip_int >= ip_start:
low = mid + 1
else:
high = mid - 1
# high 指向待查记录
if high < 0:
return '未知', '未知'
idx_offset = self.first_idx + high * 7
rec_offset = self._read_uint24(idx_offset + 4)
country, area = self._read_record(rec_offset)
return country, area
def _read_record(self, offset):
self.file.seek(offset)
first = self.file.read(1)
if first == b'\x01':
new_offset = self._read_uint24(self.file.tell())
return self._read_record(new_offset)
elif first == b'\x02':
country_offset = self._read_uint24(self.file.tell())
area = self._read_cstring(self.file.tell() + 3)
country = self._read_cstring(country_offset)
return country, area
else:
country = self._read_cstring(offset)
area = self._read_cstring(self.file.tell())
return country, area
def query(self, ip):
ip_int = self._ip2int(ip)
country, area = self._lookup(ip_int)
return country, area
def _ip2int(self, ip):
try:
return struct.unpack('>I', socket.inet_aton(ip))[0]
except OSError:
return 0
def close(self):
self.file.close()
需要说明一下:上面这个版本的方法名 _lookup 和 _read_record 是参考了开源社区常见的实现思路,我结合自己的项目经验做了一点微调。它的好处是逻辑完整、易读,适合直接拿来做二次开发。
4.2 这个解析器的几个关键设计点
第一个关键点是二分查找时的边界条件。我使用的是“标准二分后取 high”这种模式,只要 IP 在范围内,最后 high 对应的一定是正确的记录,这是我自己测试多种写法后选定的,边界不越界。
第二个关键点是 _read_record 的递归处理。0x01 会一直跳转到最终真实地址,0x02 则单独处理国家与地区两个位置,很多不完整的开源代码只处理了 0x01 而漏掉 0x02,就会导致“省名对了、城市名错乱”的问题。
第三个关键点是懒加载。类初始化时只读取文件头部的两个索引偏移,不会把整个文件读进内存,对大文件特别友好。
另外,由于查询时每次都会调用 file.read,在高并发的场景下,我给这个类加了一个线程锁,避免多线程同时 seek 和 read 导致数据错位。代码里加一个 threading.Lock(),在 query 方法里包住查询逻辑就行。
python复制import threading
class QQWry:
def __init__(self, path):
# ... 同上 ...
self._lock = threading.Lock()
def query(self, ip):
with self._lock:
ip_int = self._ip2int(ip)
return self._lookup(ip_int)
这是经验之谈:有段时间我的服务上线程数一多,偶尔会出现“查询结果和 IP 对不上”的情况,加了锁之后彻底干净。如果你的并发量特别大,还可以考虑初始化时把所有索引读入内存,查询期间不加文件锁,速度还能再上一个台阶。
4.3 把解析器封装成 HTTP 接口
有了核心解析类,下一步就是把它变成可以对外提供的服务。用 FastAPI 封装一个查询接口非常简单,我经常在本地调试时跑起来:
python复制from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
qqwry = QQWry('/data/QQWry.Dat')
class IPRequest(BaseModel):
ip: str
@app.post('/ip/query')
def ip_query(req: IPRequest):
country, area = qqwry.query(req.ip)
return {
'code': 0,
'data': {
'ip': req.ip,
'country': country,
'area': area,
}
}
这里有个很重要的提醒:FastAPI 的同步接口在高并发下会阻塞线程池,建议在定义接口时加上 def 而不是 async def,或者直接使用 run_in_executor。另外,类的初始化要放在模块级别,不能在每次请求时重复加载 DAT 文件,否则光文件打开和解析头部的开销就会把接口压垮。
5. 真实集成时绕不开的更新、性能与故障排查
5.1 自动更新脚本:每月替换一次就够了
纯真数据库的更新频率是每月一次,所以没有必要做复杂的实时同步。我写过一个简单的更新流程:
-
每月中旬从官网下载最新压缩包。
-
解压替换本地
QQWry.Dat。 -
用自动化脚本对一批已知 IP 做探测查询,和上次结果做对比。
-
确认无误后,通过
nohup重启服务,或调用自己写的reload接口。
如果你用的是我上面分享的 QQWry 类,最简单的热更新方案就是:程序内部维护一个 last_update 时间戳,检测到文件变化后重新实例化解析器,并用一个全局引用指向新对象。因为 Python 对象的赋值是原子的,新的请求会自动使用新数据,不用中断服务。
我这里提供一个简化版的“文件监控思路”,接在 os.path.getmtime 后面:
python复制import os
import time
last_load_time = 0
current_qqwry = None
def get_qqwry():
global current_qqwry, last_load_time
mtime = os.path.getmtime('/data/QQWry.Dat')
if current_qqwry is None or mtime != last_load_time:
current_qqwry = QQWry('/data/QQWry.Dat')
last_load_time = mtime
return current_qqwry
这套“懒加载 + mtime 判断”的思路我在好几个项目里用过,优点是不需要单独写一个守护进程,缺点是你必须保证替换文件时不要把空文件或半截文件写进去。稳妥起见,可以先下载到临时文件,确认完整之后再 mv 覆盖。
5.2 常见问题速查表:这些坑我全踩过
下表汇总了我在使用纯真 IP 库时遇到的最典型的几个问题,以及排查路径:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 查询结果乱码 | 没有按 GBK 解码字符串 | 读取后用 decode('gbk') 处理 |
| 地区显示错乱 | 漏处理 0x02 重定向 |
检查 _read_record 是否单独处理了国家重定向 |
| 查询不到公网 IP | IP 不在索引范围内 | 确认 ip2int 的字节序是网络序还是主机序 |
| 多线程查询结果串线 | 文件指针被多线程同时 seek | 加线程锁或在初始化时预加载索引 |
| 服务内存占用巨大 | 每次请求都重新打开 DAT 文件 | 改成模块级单例加载 |
| 更新后服务崩溃 | 覆盖了半截文件或零字节文件 | 先下载临时文件,校验大小后再替换 |
5.3 最容易被忽视的“境外 IP 与普通 IP 展示差异”
做日志分析的同学一定体会过:同一份纯真库里,国内 IP 的归属地信息通常能精确到城市,但境外 IP 经常只有一个国家名称,或者显示 IANA、APNIC 之类的机构名称,再或者显示“保留地址”。
这其实是数据源的特性决定的,不算 bug。纯真库的优势区域是中国大陆,境外信息的更新频率相对较低。如果你的业务重点是海外地区,光靠纯真库是不够的,一定得叠加一份 GeoLite2 或类似的全球地理库,把两者以国家维度做合并,再呈现给用户。
我自己的做法是:先查纯真库,如果结果里出现“保留地址”“IANA”“CZ88.NET”这几种特殊标记,就直接回退到另一份全球库。这份回退逻辑用代码实现也很简单:
python复制def get_location(ip):
country, area = qqwry.query(ip)
if any(mark in country for mark in ('CZ88.NET', '保留地址', 'IANA', 'APNIC')):
return geoip2_lookup(ip)
return country + ' ' + area
5.4 性能压测:单机每秒能处理多少 QPS
我还做过一次简单的性能压测,用 100 个并发线程循环查询 100 万个随机 IP。测试环境是普通的 4 核 8G 服务器,解析器就是上面共享的“预加载索引 + 加锁”版本。
结果非常可观:平均每秒能处理 5000 到 8000 次查询,单次查询的 P99 延迟在 2 毫秒以内。这个性能对于绝大多数中小型应用已经绰绰有余。相比之下,在线接口的 P99 通常在 50 到 200 毫秒,而且还有网络波动和限流风险。
如果你还想继续压榨性能,有几个方向可以参考:第一,把索引区全部读入内存,彻底避免磁盘随机读;第二,用 mmap 方式加载文件,减少系统调用;第三,去掉线程锁,改用进程内单线程 + 队列的方式处理查询。
6. 从下载地址到最终上线:我的整套工作流
写到这里,我把完整的工作流总结一下,方便你直接照着操作:
-
获取数据:去纯真官网下载最新完整版压缩包,解压得到
QQWry.Dat。 -
校验数据:用几行脚本测试,查询几个你本省、本市的已知 IP,确认归属地正确。
-
编写解析层:把上面的
QQWry类放到项目公共模块里,确保线程安全。 -
封装接口:如果是 Web 项目,做一个统一的查询接口;如果是数据分析项目,直接调用
query方法批量处理。 -
制定更新计划:用 crontab 或系统计划任务每月触发更新脚本,替换文件后自动重载。
-
做好兜底:遇到特殊 IP 时,准备一份备用数据源做交叉查询。
这份流程我已经带着团队跑了好几个项目,包括用户后台行为分析、广告投放地域定向、站点访问日志清洗,以及一些网络实验课的抓包演示场景。整套方案稳定性很好,几乎没有出过线上事故。
我个人在实际项目里的体会是:离线 IP 库最大的价值不只是省钱,而是它给了你完全掌控数据的能力。在线接口是“租”,离线库是“拥有”,这两种心态在做架构设计时是完全不同的。纯真数据库能坚持免费更新这么多年,本身就是一件很有价值的事情,值得把它用对、用好。
