最近接了个需求,要给海外业务做个用户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实例做成单例并在进程生命周期内复用的原因。

PHP实现海外IP归属地查询:从GeoLite2离线库到在线API兜底的完整方案

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段全部落空,还以为是代码坏了,排查半天才发现是数据太老。这类基础数据的保质期,真得放在心上。