Files

239 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: IP 属地是怎么查出来的?本地查询与在线查询方案对比
published: 2026-08-07T12:00:00
updated: 2026-08-16
description: 科普 IP 属地查询的原理:IP 段与归属地的映射、纯本地离线查询为何能做到毫秒级,对比 ip2region、在线 API 与 MaxMind 三种方案的优劣,并附登录记录场景的 Go 实战与 IPv6 盲点提醒。
image: /posts/ip-geolocation-guide/img/cover.svg
tags: ['IP', '网络', '科普']
draft: false
difficulty: 入门
---
## 写在前面
很多网站会在「登录记录」「安全中心」里显示你的登录 IP 归属地,比如「福建·福州 · 中国移动」。你可能好奇过:**服务器是怎么根据一个 IP 就判断出我在哪个城市、哪个运营商的?**
这篇科普文章讲清楚三件事:
1. IP 属地到底查的是什么;
2. 纯本地查询为什么能做到毫秒级且不联网;
3. 几种主流查询方案的优缺点对比,方便你按场景选型。
> 说明:文中用到的 IP 均为 RFC 5737 保留的文档示例段(TEST-NET-2,`198.51.100.0/24`),不会对应任何真实地址。
## 先想清楚:IP 属地查的是什么
一个常见误区是「每个 IP 对应一个具体地址」。实际上 IP 属地的本质是 **IP 段与归属地的映射**。
运营商和机构在申请 IP 时,是按**网段(CIDR)**成块分配的。比如 `198.51.100.0/24` 这一个段,一整块都归属同一地区、同一运营商。所以归属地数据天然可以**按段存储**:
```text
IP 起始 IP 结束 归属地
────────────────────────────────────────
198.51.100.0 198.51.100.255 中国|福建|福州|移动
198.51.101.0 198.51.101.255 中国|浙江|杭州|电信
```
全球公网 IP 约有 42 亿个,但**网段只有几十万条**。正因为存的是「段」而不是「每个 IP」,完整的归属地数据文件才只有几 MB(ip2region 的 `.xdb` 约 11MB)。这是「本地离线查询」可行的第一前提。
所以,**IP 属地从来不是精确到街道的定位**,它返回的是段级的「省|市|运营商」粗粒度信息。
## 纯本地查询的原理
以开源的 **ip2region** 为例,它的查询完全在本地完成,不联网、不请求第三方,几十微秒到毫秒级返回。
### 第一步:把 IP 变成整数
以 `198.51.100.42` 为例,它会转成一个 32 位整数 `0xC633642A`。IP 的四个数字本质就是 4 个字节,拼起来就是一个整数,这一步没有任何玄学。
### 第二步:二分查找
在按起始 IP 排序的段列表里做**二分查找**,找到满足 `段的起始IP ≤ 目标整数 ≤ 段的结束IP` 的那一段。
二分查找的复杂度是 O(log N),N 是几十万条,所以很快——这就是「不靠网络也能毫秒返回」的核心。
### 第三步:.xdb 文件的两级结构
ip2region 的 `.xdb` 不是简单顺序文件,内部分两个区,进一步加速:
```text
┌─────────────────────────────┐
│ 索引区 (Index) │ // 每段一条定长记录:startIP / endIP / 数据区指针 + 数据长度
│ …… 二分快速定位段 …… │
├─────────────────────────────┤
│ 数据区 (Data) │ // 真正存放各段的地点字符串
│ …… │
└─────────────────────────────┘
```
先二分索引区定位到「是哪一段」,再按指针去数据区读出地点字符串,一次磁盘读就能拿到结果。
### 为什么「离线数据」不会很快过期
IP 归属取决于**注册机构和运营商的分配**,一个网段通常长期稳定属于某地区、某运营商。所以:
- 下载一次数据文件,可以用很久;
- 归属偶尔变化时,**每月更新一次 `.xdb` 文件**即可(替换文件、不用重启服务,见下文实战);
- 数据源来自 IANA/APNIC 等机构的分配表与公开归属信息,不是实时探测。
## 主流查询方案对比
实际开发中,查询 IP 属地主要有三条路线。
### 方案一:ip2region(纯本地离线,推荐)
```go
// Go 中的用法(github.com/lionsoul2014/ip2region/binding/golang)
region, err := searcher.SearchByStr(ip)
// 返回类似 "中国|福建省|福州市|移动"
```
| 优点 | 缺点 |
|------|------|
| 免费、无网络依赖、无 QPS 限制 | 段级粒度,个别段可能显示到相邻地区 |
| 毫秒级查询,永不因外部服务挂掉 | 数据按月更新,最新分配的段可能短暂缺失 |
| 数据文件可随版本更新,不重启 | 只返回省市区+运营商,无经纬度;**仅支持 IPv4** |
**适用**:博客/后台的「登录记录显示归属地」这类展示用途。
### 方案二:在线 API(ip-api / 高德 / 腾讯位置服务)
```go
// 以 ip-api.com 为例(免费版约 45 次/分钟,非商用)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", "https://ip-api.com/json/198.51.100.42", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "未知" // 断网/超时降级:展示「未知」而不是报错
}
defer resp.Body.Close()
// 解析 JSON,status != "success" 时同样降级
```
| 优点 | 缺点 |
|------|------|
| 数据最新、可能更精确 | 有频率限制(免费版限 QPS) |
| 部分服务还返回经纬度/时区 | 依赖第三方,断网/被墙/限流时拿不到 |
| 无需维护数据文件 | 每次查询都要发一次 HTTP 请求 |
**适用**:对精度要求高、且能接受外部依赖与限流的场景。**务必加超时和降级**,否则第三方一抖,你自己的页面就卡住。
### 方案三:MaxMind GeoLite2(离线 .mmdb)
| 优点 | 缺点 |
|------|------|
| 全球数据,覆盖广、精度较好 | 免费但需注册账号并遵守 EULA;商用需 GeoIP2 商业授权 |
| 标准 `.mmdb` 格式,各语言都有库,支持 IPv6 | 国内 IP 的省市粒度有时不如 ip2region 细 |
| 离线查询 | 数据同样要定期更新 |
**适用**:面向全球用户的场景。
## IPv6:本地离线方案的一个盲点
前面聊的都是 IPv4(32 位)。但家宽和移动网络越来越多地**只下发 IPv6**(128 位)地址,而 **ip2region 目前只支持 IPv4**——拿到一个纯 IPv6 地址是查不出归属地的,本地库会返回空结果。
实际影响与常见应对:
1. **判断版本**:Go 里 `net.ParseIP(ip).To4() != nil` 就是 IPv4,先判断再查询;
2. **降级展示**:IPv6 直接显示「IPv6 地址」,不显示属地,诚实第一;
3. **优先取 IPv4**:有反向代理时,从 `X-Forwarded-For` 里挑一个 IPv4 来查;
4. **需要 v6 归属**:换支持 IPv6 的数据源(如 MaxMind GeoLite2)。
这一点在接入时很容易漏掉,上线前记得拿一个纯 IPv6 地址回归一遍。
## 实战:给登录记录加 IP 属地
subLog 博客的登录记录就用了 ip2region。完整接入一共四步。
### 1. 进程启动时加载数据文件
```go
import (
"sync/atomic"
"github.com/lionsoul2014/ip2region/binding/golang/xdb"
)
// 用 atomic.Pointer 持有查询器,方便后面热更新
var searcher atomic.Pointer[xdb.Searcher]
func initSearcher() error {
content, err := os.ReadFile("./data/ip2region.xdb") // 约 11MB,一次性读入内存
if err != nil {
return err
}
s, err := xdb.NewWithBuffer(content)
if err != nil {
return err
}
searcher.Store(s)
return nil
}
```
### 2. 查询一次
```go
func ipRegion(ip string) string {
region, err := searcher.Load().SearchByStr(ip)
if err != nil {
return "未知"
}
return region // "中国|福建省|福州市|移动"
}
```
### 3. 加一层内存缓存
登录记录里同一个 IP 可能反复出现,用 `sync.Map` 按 IP 缓存结果,一次查询终身复用:
```go
var regionCache sync.Map
func ipRegionCached(ip string) string {
if v, ok := regionCache.Load(ip); ok {
return v.(string)
}
region := ipRegion(ip)
regionCache.Store(ip, region)
return region
}
```
### 4. 数据热更新(不用重启)
ip2region 每月发布新数据。下载新文件后重新 `NewWithBuffer` 并 `Store` 覆盖即可,进程完全不用重启:
```go
func reloadSearcher(newContent []byte) error {
s, err := xdb.NewWithBuffer(newContent)
if err != nil {
return err
}
searcher.Store(s) // 原子切换,老查询继续走旧数据,新查询走新数据
return nil
}
```
## 局限与诚实提醒
无论用哪种方案,都要清楚 IP 属地的边界:
- **段级精度**:网段归属是「绝大多数」准确,个别 IP(大型企业自建段、跨省调度段)可能显示到相邻地区或只到省。
- **运营商级,不是定位**:拿到的是「省|市|运营商」,不是精确街道或基站位置——精确位置需要 GPS 或运营商三角定位,纯 IP 归属做不到。
- **动态 IP**:家宽通常是动态 IP,重拨会变,所以「上次登录在福州,这次在广州」不一定是异常,可能是 IP 变了。
- **别用于安全判定**:用 IP 段做封禁或风控判定风险自负,段级数据不适合当精确依据。
- **隐私与合规**:IP 地址属于个人信息(《个人信息保护法》)。归属地展示建议只出现在**用户本人可见**的页面(登录记录、个人中心);若在评论区等公开场合展示,建议只显示到省市并明确告知用户。
## 小结
一句话总结:
> **IP 属地 = 几十万条「IP 段 → 归属地」映射 + 一次二分查找。** 它查的是网段归属,不是精确位置。
- 想省心、免费、稳定,用 **ip2region** 本地离线查询(注意 IPv6 盲点);
- 想要最新最细、能接受外部依赖,用**在线 API**(记得超时降级);
- 面向全球用户、要 IPv6 覆盖,用 **MaxMind GeoLite2**。
对个人博客「登录记录显示归属地」这种场景,本地离线方案通常是性价比最高的选择。