最近接了个需求,要给海外业务做个用户IP归属地统计。老板一句话:“用PHP查下IP归属地就行,网上一堆库。”试了一圈才发现,国内那些口碑不错的IP库,拿到国外IP上纷纷失灵——要么查出来是空,要么定位到奇怪的地方,还有直接把8.8.8.8归到美国的但再换几个欧洲IP就原形毕露。
相关服务:柬埔寨服务器租用
折腾下来才意识到,“国外IP归属地”这个需求跟国内IP查询完全是两码事。数据源、查询方式、更新策略、字段解析,每一步都有坑。这篇就把我踩过的路和最终跑通的方案完整梳理一遍,给准备做海外IP归属地查询的PHP开发者一个可以照抄的作业。
1. 为什么“国外IP归属地”不是简单查个库就完事
1.1 需求场景与“国外”二字的真正含义
先捋一下需求。所谓的“国外IP归属地”,本质上是要对非中国大陆地区的IP地址做地理定位。典型场景有这么几类:
- 海外社交App或跨境电商网站,需要按用户所在国家做内容分区、活动投放或汇率切换
- 服务端收到异常请求,需要快速判断攻击流量来自哪些国家,以便针对性做IP封禁或速率限制
- 数据分析报表需要展示全球用户分布,按国家、城市维度做统计
- 内容合规检查,需要判断某个注册用户是否来自允许运营的国家或地区
你会发现这类场景有两个共同点:第一,它们关心的是“国家”层面,最多细化到“城市”,不需要精确到街道;第二,它们对数据的准确率和覆盖范围要求很高,一个欧洲小国的IP查不出来,报表就会缺一块。
“国外”这两个字,决定了你大概率不能直接用国内常见的IP纯真库。这类库的数据主要来自国内运营商和网民反馈,海外地址段覆盖不全、更新不及时是常态。我记得自己用某知名国内IP库查一个荷兰的IP,结果返回了“中国北京”,这种数据做海外业务统计基本没法用。
要真正解决海外IP归属地问题,核心是找到覆盖全球IP地址段的数据源。这里就涉及IP归属地的本质:它不是一个GPS坐标,而是根据IP地址段的注册信息、路由通告路径、运营商数据等综合推断出的“登记位置”。所以精度天然有限,城市级已经是比较理想的结果,指望它定位到具体街道,那就不是IP归属地查询能干的活了。
1.2 三条技术路线与选型思路
针对PHP环境,目前主流的方案就三条路:离线数据库、第三方在线API、离线加在线混合。
离线数据库方案 ,主流是MaxMind公司的GeoLite2免费库和GeoIP2商业库,格式是.mmdb二进制文件。PHP端有官方维护的geoip2/geoip2包可以直接读取。这个方案的优势是查询速度快、无外部依赖、请求量不受限制,适合有明确QPS要求的生产环境。缺点是数据库文件有几十到上百MB,需要定期更新,免费版精度不如商业版。
在线API方案 ,像ip-api.com、ipinfo.io、ipapi.co这些服务,把IP发过去,接口返回JSON格式的国家、城市、经纬度、ISP等完整信息。优点是接入极其简单,几行代码搞定,不用维护数据库,而且不用占用服务器内存。缺点是免费版都有速率限制和请求量上限,比如ip-api.com免费版每分钟只允许45次请求甚至不支持HTTPS,一旦某个接口被刷,后面所有查询都会失败。
混合方案 ,就是先用离线库做本地主查询,查不到或命中保留IP时才走在线API兜底,同时把结果缓存到Redis或文件里。这是我在生产环境实际采用的组合,兼顾了性能与覆盖率。
三条路怎么选?我的判断标准很简单:如果你只是写个脚本临时查几个IP,在线API最省事;如果你的服务要面向线上流量,必须用离线库为主、API兜底为辅的混合方案。理由后文展开讲。
2. 核心细节解析:GeoIP2 离线库的选型与使用要点
2.1 GeoLite2 下载、授权与更新策略
GeoIP2离线库有免费和付费两档。免费的是GeoLite2,从2022年起MaxMind要求注册账号并生成License Key才能下载。整个流程不复杂:注册账号后在管理后台创建一个License Key,然后在官网下载页面找到“GeoLite2 City”或“GeoLite2 Country”对应的.mmdb文件。
这里有个容易踩坑的点:下载需要带上License Key拼接URL,直接用浏览器下载没问题,但如果要在服务器上通过脚本定时更新,就得用curl或wget带上账号和Key参数。我一开始没注意,直接在服务器上wget下载地址,结果拿到的是一个JSON格式的错误提示,而不是数据库文件。
更新策略上,MaxMind官方建议每周更新一次。IP地址段的变化其实没有想象中那么频繁,但几个月不更新还是会明显感觉到新分配IP查不准。我通常用crontab挂个每周一次的定时任务,下载新文件后覆盖旧文件,然后平滑重启PHP-FPM。如果进程已经把旧DB加载进内存了,新文件不会自动生效,这一点在后文会专门讲。
值得说明的是,GeoLite2-City.mmdb这个文件是“城市级”数据库,精确到城市和经纬度,当然它也包含国家信息,体积大概几十MB到上百MB。还有GeoLite2-Country.mmdb,只有国家信息,体积小很多。如果只需要国家粒度,用Country库就够了,加载更快、内存占用更小。
.mmdb这个格式也有点讲究。它本身是MaxMind的二进制数据库格式,特点是查询快、支持IPv4和IPv6双栈,而且数据库嵌入了一些索引结构,不用把全部数据读到内存也能高效查询。当然geoip2/geoip2包在构造Reader对象时默认会载入整个文件到内存,这也是内存占用的大头,需要在部署时给PHP预留够内存。
2.2 composer 引入依赖与最小可用代码
项目里先用composer把官方包拉进来:
composer require geoip2/geoip2
然后写一个最小可用的查询脚本,验证数据库能正常读:
<?php
require 'vendor/autoload.php';
use GeoIp2\Database\Reader;
// 假设GeoLite2-City.mmdb放在data目录下
$reader = new Reader(__DIR__ . '/data/GeoLite2-City.mmdb');
// 用Google公共DNS做测试
$record = $reader->city('8.8.8.8');
echo $record->country->isoCode . PHP_EOL; // US
echo $record->country->name . PHP_EOL; // United States
echo $record->city->name . PHP_EOL; // Mountain View
echo $record->location->latitude . PHP_EOL; // 37.3860(近似值)
echo $record->location->longitude . PHP_EOL; // -122.0840(近似值)
这里有个细节:
$reader->city()
方法接受一个IP地址字符串,如果传入了保留IP地址、内网IP,或者数据库中无法匹配的地址段,就会抛出
GeoIp2\Exception\AddressNotFoundException
异常。所以实际封装时一定要用try-catch包住,不能直接裸调。
如果是Country库,就换成
$reader->country($ip)
,返回的对象结构不同,没有city和location相关的字段。写代码前先确认好你下载的是哪个库,否则会调用不存在的方法。
2.3 字段解读与中文本地化实现
GeoIP2返回的数据结构相当丰富。一个City查询返回的
$record
对象里,常用字段包括:
| 字段路径 | 含义 | 示例 |
|---|---|---|
$record->country->isoCode
| 国家ISO代码(两位字母) | US、DE、JP |
$record->country->name
| 国家英文名 | United States |
$record->mostSpecificSubdivision->name
| 州/省/大区名称 | California |
$record->city->name
| 城市名 | Mountain View |
$record->location->latitude
| 纬度 | 37.3860 |
$record->location->longitude
| 经度 | -122.0840 |
$record->location->timeZone
| 时区 | America/Los_Angeles |
$record->traits->isp
| ISP名称(部分库才提供) | Google LLC |
$record->traits->asn
| ASN编号(部分库才提供) | AS15169 |
你会发现
country->name
默认返回的是英文名。如果业务前台要展示给中国用户看,就需要自己处理中英文映射。最简单的做法是自己维护一份ISO国家代码到中文名的对照表,比如:
$countryNamesZh = [
'US' => '美国',
'GB' => '英国',
'DE' => '德国',
'JP' => '日本',
// 需要完整列表可以从公开的ISO 3166-1对照资料里整理
];
有人可能会问,GeoLite2不是支持多语言名称吗?确实,
$record->country->names
返回一个包含多种语言名称的数组,但里面并不会有简体中文。默认返回的name来自
names['en']
,其他语言选项主要是de、es、fr、ja、pt-BR、ru、zh-CN这些。等等,我印象中有的版本确实支持zh-CN。实测发现不是所有情况下都能返回中文国家名,而且拿到了也是繁体,所以最稳的做法还是自己维护ISO代码映射表,顺便还能统一香港、台湾等地区的显示名称。
2.4 四个常见认知误区:精度、IPv6、内网IP、内存
这几个坑我都在线上踩过,单独列出来:
误区一:IP归属地可以精确到街道。 这是最普遍的误解。IP地理定位充其量能到“城市”级别,很多中小国家的IP甚至只能定位到国家或地区。真正需要精确位置得靠GPS,IP定位永远只是推断。
误区二:IPv6查不了。
实际上GeoLite2同时覆盖IPv4和IPv6,
$reader->city()
方法不用做任何处理,传入IPv6地址字符串就行。IPv4和IPv6有各自独立的地址空间,数据库会分别处理。
误区三:内网IP查不到是数据库坏了。
192.168.x.x、10.x.x.x这些RFC1918保留地址段在GeoLite2里根本不存在,查询必然抛
AddressNotFoundException
。做查询服务时,对这类IP要预先拦截,不要白白浪费一次数据库查询。
误区四:数据库文件换掉了查询就自动更新。
不是的。
Reader
对象构造时就把整个数据库的内容加载进内存了,之后文件内容的变化不会影响当前进程。更新数据库后必须重启PHP-FPM进程,否则线上还在用旧数据。这也是我选择把Reader实例做成单例并在进程生命周期内复用的原因。

3. 实操过程:从0到1搭一个“IP归属地查询服务”
3.1 环境准备、项目结构与Docker部署
整套服务我自己在PHP 8.1环境上跑通,使用Composer统一管理依赖。项目结构很简单:
ip-locator/
├── composer.json
├── index.php # 对外HTTP接口入口
├── src/
│ └── IpLocator.php # 核心查询类
├── data/
│ └── GeoLite2-City.mmdb
├── cache/ # 文件缓存目录
└── vendor/
PHP版本建议用7.4以上更保险,实际上官方库要求PHP 7.2以上就能跑,但8.x对弱类型表达更严格,加上现在composer整体生态都向PHP 8看齐,直接用8.x省心得多。
部署上,我用Docker打镜像时没有把.mmdb文件打进镜像本身,而是在启动时用挂载卷把宿主机上的data目录映射进容器。这样更换数据库文件不需要重新构建镜像,直接替换宿主机文件再重启容器即可。把这步写进Dockerfile的实践里,可以让部署流程少很多折腾。
另一个部署细节是内存。一个City库文件加载后PHP进程会占用约等于文件体积的内存,几十MB的开销对单机服务还好,但如果服务器是512MB的小机器,就要考虑只用Country库或者采用流式读取方案。GeoIp2包本身在构造时就会做一次全量加载,没有流式的选项,所以真遇到内存瓶颈时,换Country库是更务实的路子。
3.2 核心查询类的封装:缓存、异常、批量
直接写一个可复用的查询类,把单IP查询、异常处理、缓存都封装进去:
<?php
declare(strict_types=1);
namespace App;
use GeoIp2\Database\Reader;
use GeoIp2\Exception\AddressNotFoundException;
use Redis;
class IpLocator
{
private Reader $reader;
private ?Redis $redis;
public function __construct(string $mmdbPath, ?Redis $redis = null)
{
$this->reader = new Reader($mmdbPath);
$this->redis = $redis;
}
public function lookup(string $ip): array
{
// 快速判断:内网、保留地址直接忽略
if (!filter_var($ip, FILTER_VALIDATE_IP) ||
preg_match('/^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)/', $ip)) {
return ['ip' => $ip, 'status' => 'private', 'country_code' => null];
}
// 优先查Redis缓存
$cacheKey = 'ip:locator:' . $ip;
if ($this->redis) {
$cached = $this->redis->get($cacheKey);
if ($cached !== false) {
return json_decode($cached, true);
}
}
try {
$record = $this->reader->city($ip);
$result = [
'ip' => $ip,
'status' => 'ok',
'country_code' => $record->country->isoCode,
'country_name' => $record->country->name,
'region' => $record->mostSpecificSubdivision->name,
'city' => $record->city->name,
'latitude' => $record->location->latitude,
'longitude' => $record->location->longitude,
'timezone' => $record->location->timeZone,
];
} catch (AddressNotFoundException $e) {
$result = ['ip' => $ip, 'status' => 'not_found', 'country_code' => null];
}
// 写入Redis缓存,TTL设置为7天
if ($this->redis && isset($result['status'])) {
$this->redis->setex($cacheKey, 604800, json_encode($result));
}
return $result;
}
}
几个设计考量说明一下。
filter_var($ip, FILTER_VALIDATE_IP)
是PHP自带IP校验函数,避免非法IP直接传给底层库。内网段的正则匹配是常用的一个简化手段,覆盖了IPv4常见的私网三段,IPv6的
fc00::/7
私网段如果要处理也可自行补充。实际场景里,来自内网段或保留段的请求通常应该直接走拒绝逻辑,而不是继续往下查询。
Redis缓存这里用处很大。同一个IP在短时间内被重复查询是很常见的,缓存7天既能降低数据库查询压力,也能减少未来数据库更新前的重复计算。如果没有Redis,用一个简单的文件缓存也可以,只是要注意并发写入时文件锁的问题。实际测试中,我处理一天几百万次查询的流量时,Redis缓存命中了90%以上的请求。
批量查询的场景也值得提一句。如果你手里有一批IP要分析,不要在循环里一个个同步调用上面的lookup方法,太慢。可以先用
array_filter
过滤掉格式非法和明显内网的IP,再并行发起查询。PHP原生没有好用的并发HTTP工具,可以借助Swoole或直接分批用curl_multi实现并发,把一批IP拆成多个并发请求发给自己的查询服务,整体吞吐能提升一个数量级。还有更彻底的做法是把批量IP丢进消息队列去异步处理,消费者逐条查、结果写回数据库。这些放到“性能优化”部分细说。
3.3 在线API兜底与切换逻辑
离线库查不到的情况主要发生在两种场合:一是IP本来就没有公开的归属信息,这种情况在线API大概率也查不到;二是GeoLite2免费库的地址段覆盖存在缺口,个别小国家或新分配的地址段确实可能空白。
我的兜底方案是用ip-api.com的免费接口补充查询。它的免费档不支持HTTPS,并且限速是每分钟45次请求,所以只能作为少量漏网之鱼的补充手段,绝对不能把主查询流量打到它上面。
兜底逻辑这样写:
public function lookupWithFallback(string $ip): array
{
$result = $this->lookup($ip);
if (($result['status'] ?? '') === 'not_found' || ($result['country_code'] ?? '') === null) {
// 调用ip-api.com做二次查询
$apiResult = $this->lookupFromApi($ip);
if ($apiResult !== null) {
return $apiResult;
}
}
return $result;
}
private function lookupFromApi(string $ip): ?array
{
$params = http_build_query([
'lang' => 'zh-CN',
'fields' => 'status,message,country,countryCode,regionName,city,lat,lon,timezone,query'
]);
$url = "http://ip-api.com/json/{$ip}?{$params}";
$response = @file_get_contents($url, false, stream_context_create(['http' => [
'timeout' => 3,
'ignore_errors' => true,
]]));
if ($response === false) {
return null;
}
$data = json_decode($response, true);
if (($data['status'] ?? '') !== 'success') {
return null;
}
return [
'ip' => $ip,
'status' => 'ok',
'country_code' => $data['countryCode'] ?? null,
'country_name' => $data['country'] ?? null,
'region' => $data['regionName'] ?? null,
'city' => $data['city'] ?? null,
'latitude' => $data['lat'] ?? null,
'longitude' => $data['lon'] ?? null,
'timezone' => $data['timezone'] ?? null,
];
}
这个方案有几层防护:第一,只在本地查询失败时才发起外部请求;第二,设置3秒超时,避免接口无响应拖垮主流程;第三,即使外部API也查询失败,返回null而不是抛异常,保证调用方拿到的永远是一个结构完整的数组。线上跑了一阵子之后发现,真正需要走API兜底的请求占比不足1%,完全在免费额度之内。
顺带一提,如果预算允许,ipinfo.io是另一个不错的备选。它支持HTTPS且免费额度是每个月五万次请求,适合那些对数据隐私要求更高的场景。但它的免费档不返回ISP和ASN信息,字段完整性不如ip-api.com。实际选型时要结合你的业务需要和预算综合判断。
3.4 接口输出与跨域调用设计
有了核心查询类,还得让它变成可以被业务方调用的服务。我直接写了一个最简单的API入口,基于原始PHP构建,不引入框架,关键是控制依赖复杂度。
<?php
// index.php 入口
require 'vendor/autoload.php';
use App\IpLocator;
// 允许跨域请求,方便前端直接调用
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, OPTIONS');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$locator = new IpLocator(__DIR__ . '/data/GeoLite2-City.mmdb', $redis);
$ip = $_GET['ip'] ?? '';
// 支持不传参数时自动取客户端IP
if ($ip === '') {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
}
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
http_response_code(400);
echo json_encode(['error' => 'invalid ip']);
exit;
}
echo json_encode($locator->lookupWithFallback($ip), JSON_UNESCAPED_UNICODE);
这里加跨域头是因为很多管理后台页面直接由前端JS调用这个接口,不加
Access-Control-Allow-Origin
浏览器会拦截响应。另外,
REMOTE_ADDR
取到的是直连客户端的IP,如果服务前面挂了Nginx反向代理,需要额外配置
real_ip
模块并把
X-Real-IP
等头传透,否则取到的永远是代理服务器的IP,这个坑特别容易在联调时暴露。
把Nginx配置贴上方便参考:
server {
listen 80;
server_name ip.locator.local;
root /var/www/ip-locator;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass 127.0.0.1:9000;
}
# 让REMOTE_ADDR取到真实客户端IP,假设Nginx是直接对外的
set_real_ip_from 0.0.0.0/0;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
}
这里
set_real_ip_from 0.0.0.0/0
的意思是信任所有来源的
X-Forwarded-For
头。生产环境不建议这样裸配置,应该只信任你自家的反向代理或负载均衡器的网段,否则伪造转发头可以绕过IP校验。可是从联调角度讲,这个配置能最简单看到真实效果。
4. 常见问题与排查技巧实录
4.1 查不到数据时怎么排查
线上反馈“这个IP怎么查不出来”的时候,先别急着改代码。按下面的顺序排查:
第一步,确认IP本身是不是保留地址或内网地址。用
filter_var($ip, FILTER_VALIDATE_IP)
校验通过后,还得排除
10.
、
192.168.
、
172.16~31.
这些私网段。这类IP在公网数据库里查不到是正常的,直接放行或者标记即可。
第二步,确认数据库文件是否完整。GeoLite2-City.mmdb在下载过程中如果中断过,文件可能损坏但不会报错。可以用
composer require maxmind/mmdb
工具或者写脚本调用
$reader->metadata()->databaseType
看能不能正常输出元信息。元信息读不出来,说明文件加载就有问题。
第三步,确认是否因为IP地址是IPv6而你使用的是旧版本库文件。老版本数据库对IPv6支持不完整,换成最新的GeoLite2就能解决。
第四步,如果代码确认无误,直接在命令行跑一个小脚本,用
$reader->city()
单独查这个IP,看看异常类型是
AddressNotFoundException
还是其他异常。其他异常比如
InvalidArgumentException
说明输入IP格式有问题;
AddressNotFoundException
才是真正的“查不到”。
我遇到过最隐蔽的一次问题,是服务器上时区设置不正确,导致日志里记录的时间全部偏移8小时。排查问题时看到“凌晨3点有一批查询失败”,下意识以为是夜间任务出故障,最后发现其实是下午3点,时区偏移的锅。IT运维里时间问题真的是最常见又最隐蔽的干扰项,建议把所有服务器统一设置成UTC时区,让各业务层自行转换显示时区,排查日志时能少掉很多认知负担。
4.2 性能优化与缓存实践
性能方面,直接给出我跑过的数据供参考。在PHP 8.1、单核2核的容器里,GeoLite2-City库单IP查询耗时大约在0.1到0.3毫秒之间,加上HTTP层和JSON序列化,整体接口响应在3到5毫秒左右。如果不加Redis缓存,这个速度已经够撑住大量请求。
引入Redis缓存后,命中缓存的请求接口响应可以压到1毫秒以内。缓存的设计要点有三个:
-
key要稳定且短,不用哈希,直接
ip:locator:8.8.8.8这种可读格式就好 - TTL设置为一周比较平衡,太短缓存失去意义,太长会导致数据库更新后新IP段仍然查不到
- 写入缓存时注意同时缓存“查不到”的结果,否则一次查不到的IP下一次还是会重复打数据库
有一类流量需要特别处理:爬虫和扫描器高频请求不存在的IP段,比如随机生成的IP对着接口一顿扫描。这种情况本地查询会频繁抛
AddressNotFoundException
,如果每次都缓存“查不到”的结果,Redis里会堆积大量无效key。我的做法是单独维护一份“无效IP”的布隆过滤器,或者直接对查不到的结果设置更短的TTL(比如1小时),避免长期占用内存。
批量分析大批量IP时,在PHP里逐条循环调用查询类其实很笨。服务端可以提前把IP列表写入Redis队列,用多个PHP CLI消费者进程并发消费,每消费一条就更新一次数据库统计结果。好处是消费者进程各自独立,不存在PHP-FPM进程数瓶颈,处理几百万IP也就是几分钟的事情。这种方法本质上是用队列思路把同步查询转换为异步批处理,配合消息队列的ACK机制还能做到失败重试。
4.3 中文名映射与多语言坑
前面提到,GeoLite2返回的国家名默认是英文。如果你期望接口直接返回中文国家名,光靠GeoIP2库是不够的。最稳妥的方式是建立ISO 3166-1 alpha-2两位国家代码到中文名的静态映射表,然后封装一个方法统一转换:
function countryCodeToZh(string $code): string
{
static $map = [
'US' => '美国', 'GB' => '英国', 'CN' => '中国', 'JP' => '日本',
'DE' => '德国', 'FR' => '法国', 'IT' => '意大利', 'ES' => '西班牙',
'PT' => '葡萄牙', 'NL' => '荷兰', 'BE' => '比利时', 'CH' => '瑞士',
'SE' => '瑞典', 'NO' => '挪威', 'DK' => '丹麦', 'FI' => '芬兰',
'RU' => '俄罗斯', 'UA' => '乌克兰', 'PL' => '波兰', 'CZ' => '捷克',
'AT' => '奥地利', 'IE' => '爱尔兰', 'HU' => '匈牙利', 'GR' => '希腊',
'RO' => '罗马尼亚', 'BG' => '保加利亚', 'HR' => '克罗地亚', 'RS' => '塞尔维亚',
'SK' => '斯洛伐克', 'SI' => '斯洛文尼亚', 'LT' => '立陶宛', 'LV' => '拉脱维亚',
'EE' => '爱沙尼亚', 'BY' => '白俄罗斯', 'MD' => '摩尔多瓦', 'AL' => '阿尔巴尼亚',
'MK' => '北马其顿', 'BA' => '波黑', 'GE' => '格鲁吉亚', 'AM' => '亚美尼亚',
'AZ' => '阿塞拜疆', 'KZ' => '哈萨克斯坦', 'UZ' => '乌兹别克斯坦', 'NP' => '尼泊尔',
'IN' => '印度', 'PK' => '巴基斯坦', 'BD' => '孟加拉国', 'LK' => '斯里兰卡',
'MM' => '缅甸', 'TH' => '泰国', 'VN' => '越南', 'KH' => '柬埔寨',
'LA' => '老挝', 'MY' => '马来西亚', 'SG' => '新加坡', 'PH' => '菲律宾',
'ID' => '印度尼西亚', 'AU' => '澳大利亚', 'NZ' => '新西兰',
'CA' => '加拿大', 'MX' => '墨西哥', 'BR' => '巴西', 'AR' => '阿根廷',
'CL' => '智利', 'PE' => '秘鲁', 'CO' => '哥伦比亚', 'VE' => '委内瑞拉',
'EG' => '埃及', 'SA' => '沙特阿拉伯', 'AE' => '阿联酋', 'IL' => '以色列',
'TR' => '土耳其', 'ZA' => '南非', 'NG' => '尼日利亚', 'KE' => '肯尼亚',
];
return $map[$code] ?? $code;
}
完整的两百多个国家代码对照可以整理成JSON或PHP数组文件,按需加载就好。注意ISO代码不光有两位字母,还有三位数字代码,比如中国是
CN
对应
156
,美国是
US
对应
840
。如果业务系统里需要按数字代码关联,记得维护一份完整的映射关系,不要只写了几十个常用国家就上线。
多语言的另一个坑是“地区”和“城市”字段。比如美国地址返回的城市名是英文,德国的城市也可能是本地拼写,这在统一展示时会造成样式不统一。最简单的处理是不展示城市名,只展示国家代码加国家名,够用就好。
4.4 部署与安全加固经验
最后把部署和安全经验汇总一下。整个服务上线后,有几个基础的安全底线一定要守住。
第一是数据库文件的权限。.mmdb文件内部包含IP段和地理数据,本身不涉及敏感信息,但如果你把它放到Web可访问目录下,别人直接下载就能拿到你的完整数据源。GeoIP2商业版数据是收费的,泄露出来等于白白送钱。正确做法是把data目录放在Web根目录之外,或者通过Nginx配置禁止访问
.mmdb
文件。我习惯把数据目录设置在项目更上层,比如
/var/lib/ip-geo/
,Web根目录只保留入口文件。
第二是接口一定要做访问频率限制。IP归属地查询接口如果裸奔,很容易被脚本刷爆。轻量做法是每IP每秒限制10次请求,超过就直接返回429。用Redis做计数器很容易实现:
$rateKey = 'rate:ip:' . $clientIp;
$count = $redis->incr($rateKey);
if ($count === 1) {
$redis->expire($rateKey, 1);
}
if ($count > 10) {
http_response_code(429);
echo json_encode(['error' => 'too many requests']);
exit;
}
第三是根据字段做输出裁剪。如果业务方只需要国家代码,接口就没必要把纬度经度、ISP、ASN都返回出去。接口层加一个
fields
参数,按需返回字段,既能减小响应体,也减少攻击面。想省事的可以让用户在请求参数里指定需要的字段,比如
fields=country_code,city
,服务端只返回这些。
第四也是极其重要的一点:日志记录一定要做,但不要记录完整IP。IP地址属于个人信息,合规要求越来越严格。我通常记录的是IP的匿名化版本,比如只保留前三个八位组
203.0.113.0
,或者对完整IP做哈希。这样在排查问题时还能定位到大致来源,又不会直接暴露个人IP。做海外业务的同学尤其要注意用户隐私相关法规,IP归属地查询本身不复杂,但数据的收集和使用边界要提前想清楚。
最后再分享一个实际经验
整套服务从我这边跑下来,最大的体会是:免费版的GeoLite2已经能覆盖绝大多数海外IP查询需求,命中率大概在95%以上,剩余那些查不到的多是云服务商动态IP和新分配段。所以真正决定业务体验的往往不是单次查询的准确率,而是整体架构的稳定性。
给还在纠结方案的同学一点参考:如果只是临时脚本,在线API就够了;如果要上生产,别偷懒,用离线库加在线兜底这套组合,再配几百行代码就能搞定。等跑一段时间发现Redis命中率稳定在90%,你会庆幸当初没有直接把所有流量打到免费API上的。
另外建议把数据库更新任务纳入日常巡检,每周手动或自动替换一次。我前几个月有一阵子忘了更新,结果拿旧库查新分配的IP段全部落空,还以为是代码坏了,排查半天才发现是数据太老。这类基础数据的保质期,真得放在心上。