Appearance
IP 属地显示「未知」是怎么回事?三种成因和平台侧兜底写法(PHP 实现)
先把答案说了:「未知」九成不是平台坏了,而是三种情况——服务端拿到的是内网地址、这个 IP 在数据源里只到国家、或者查询当场失败了。 前两种是数据本身的样子,第三种是工程问题。分别看。
一、成因一:平台拿到的是内网地址
这是最容易被忽略的一种。用户的浏览器明明在公网,但你的服务端记录到的却是 192.168.x.x、10.x.x.x 或者 100.64.x.x。这类地址在归属地数据库里根本没有省市信息,平台读不到省份,就只能显示「未知」,做得细一点的会显示「本地」。
实测几个地址,返回长这样:
192.168.1.1、10.0.0.1→country是「保留」,isp是「内网地址」,省市字段全空;100.64.0.1→isp是「共享地址空间」(运营商级 NAT 段,很多校园网、4G 网络内部都在用);::1→ 「回环地址」,fe80::1→ 「链路本地地址」。
问题出在采集环节。Nginx 直接跑在公网、没挂 CDN 的时候,$remote_addr 就是真用户;一旦前面有 CDN、SLB 或者公司自己的反代,$remote_addr 变成上一跳的地址,有些架构里这一跳还是内网回源,于是记进日志的就是内网 IP。属地显示为「未知」,先去看采集到的是不是内网地址,比怀疑接口准得多。
二、成因二:境外 IP 只到国家
国内 IP 的省市数据比较全,境外 IP 常常只到国家一级。查 8.8.8.8 返回「美国」,prov、city 两个字段是空的;查 1.1.1.1 返回「澳大利亚」,省市同样为空,经纬度落到的是该国地理中点。
而绝大多数平台的属地文案是按「省 → 市」拼的,拼不出东西就直接落到默认值「未知」。这解释了一个常见现象:同一个用户,在国内时属地显示「广东」,出境或者挂了境外节点以后就变成「未知」,不是他藏起来了,是数据只有国家这一层。所以展示逻辑应该按「市 → 省 → 国家」逐级降级,把国家用起来,「未知」的比例能立刻降一截。
三、成因三:查询失败被降级
第三种最好修:接口超时、限流返回 429、或者网络抖了一下,代码里没写重试就把结果写成空,页面渲染出来也是「未知」。这类「未知」有个特点——刷新一下就正常了。如果你的后台能看到「未知率」突然从 3% 跳到 20% 又落回去,基本就是这一种,往前查网络和限流。
顺带说一句 IPv6。现在接口对 IPv6 是正常支持的,2400:3200::1 能查到「浙江 杭州」。但如果你的采集链路里某一段(老版本 CDN 日志、某些反代配置)只记 IPv4,IPv6 用户就整批变成「未知」,这个坑在双栈站点上很常见。
四、PHP 实现:把「未知」压下去的兜底函数
思路三句话:先校验再请求(非法 IP 不浪费配额)、超时要重试、展示分级降级。完整代码:
php
<?php
/**
* region_label.php —— 访客 IP 属地展示的「不显示未知」兜底
* 用法: php region_label.php 114.114.114.114 8.8.8.8 192.168.1.1
*/
const API = 'https://ip9.com.cn/get';
function query_region(string $ip, int $retry = 2): array
{
// 非法 IP 不查,直接返回错误,别浪费配额(接口对非法 IP 返回 ret=400)
if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
return ['ret' => 400, 'data' => []];
}
$url = API . '?ip=' . urlencode($ip);
$ctx = stream_context_create([
'http' => ['timeout' => 2, 'header' => "User-Agent: region-label/1.0\r\n"],
]);
for ($i = 0; $i <= $retry; $i++) {
$raw = @file_get_contents($url, false, $ctx);
if ($raw !== false) {
$j = json_decode($raw, true);
if (is_array($j)) {
return $j; // 含 ret=429/400,交给调用方判断
}
}
usleep(300000); // 失败退避 300ms 再试
}
return ['ret' => -1, 'data' => []]; // 三次都失败
}
/** 把接口结果翻译成「能给人看的属地文案」 */
function region_label(string $ip): string
{
$j = query_region($ip);
$ret = $j['ret'] ?? -1;
$d = $j['data'] ?? [];
if ($ret === 429) return '查询频繁,稍后再试';
if ($ret !== 200 || !$d) return '归属地未知'; // 400/401/超时统一降级
$isp = $d['isp'] ?? '';
$country = $d['country'] ?? '';
// 内网/保留地址:接口会返回 country=保留 + isp=内网地址|回环地址|共享地址空间
if ($country === '保留' || $isp !== '' && str_contains($isp, '地址')) {
return '本地网络(' . ($isp ?: '非公网 IP') . ')';
}
$prov = $d['prov'] ?? '';
$city = $d['city'] ?? '';
if ($city !== '') return $prov . ' ' . $city; // 国内:省市齐全
if ($prov !== '') return $prov; // 只到省
if ($country !== '') return $country . '(仅到国家)'; // 境外:省市为空,别显示未知
return '归属地未知';
}
$ips = array_slice($argv, 1) ?: ['114.114.114.114'];
foreach ($ips as $ip) {
printf("%-18s => %s\n", $ip, region_label($ip));
usleep(150000);
}跑一遍的真实输出:
114.114.114.114 => 江苏 南京
8.8.8.8 => 美国(仅到国家)
192.168.1.1 => 本地网络(内网地址)
100.64.0.1 => 本地网络(共享地址空间)
2400:3200::1 => 浙江 杭州
1.2.3.999 => 归属地未知
202.96.128.86 => 广东 广州几个关键点:filter_var 挡住手输的错 IP,接口对非法 IP 是 ret=400(0.0.0.0 这类返回 ret=401),本地能拦就拦,省下的是你自己每分钟 60 次的额度;2 秒超时 + 300ms 退避重试两次,专治抖动导致的假「未知」;最后一段是展示分级——用户看到「美国(仅到国家)」比看到「未知」有用得多,你自己统计未知率时也不会把境外流量算成异常。
五、平台侧把「未知」降下来的五条
- 先确认真实 IP:反代和 CDN 后面,从
X-Forwarded-For取真实客户端地址时要校验来源,别直接信客户端传来的整串,否则攻击者随手塞个内网 IP,你的属地展示就废了。 - 空结果也要缓存:查不到的内网地址、超时 IP 存一个 300 秒的负缓存,避免异常流量打穿到接口层。
- 展示分级:市、省、国家、本地网络、未知,五档文案,和第五节一样逐级降。
- 监控未知率:按小时统计未知占比,超过基线两倍就告警。这个指标比接口成功率更早暴露采集链路故障。
- 别把属地当定位:接口给的经纬度是城市中心点,做「3 公里内推荐」一定是错的;属地只适合做城市级的展示和统计。
总结
「未知」是数据链路的体检报告,不是玄学。先看采集到的是不是内网 IP,再看这个 IP 有没有省市数据(境外大概率只有国家),最后看有没有超时降级——按这个顺序查,十分钟就能定位。上面那段 PHP 直接丢进项目里就能用,把 usleep 换成 Redis 缓存的实现即可上生产。想先手动确认某个 IP 到底有什么字段,直接调 https://ip9.com.cn/get?ip=xxx 看一眼返回的 country、prov、city、isp 就行,免费版不需要注册;要区县粒度或者 ip_type(判断是家庭宽带、企业宽带还是机房出口)得用 VIP 版,功能清单在官网 https://www.ip9.com.cn。