Skip to content

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 退避重试两次,专治抖动导致的假「未知」;最后一段是展示分级——用户看到「美国(仅到国家)」比看到「未知」有用得多,你自己统计未知率时也不会把境外流量算成异常。

五、平台侧把「未知」降下来的五条 ​

  1. 先确认真实 IP:反代和 CDN 后面,从 X-Forwarded-For 取真实客户端地址时要校验来源,别直接信客户端传来的整串,否则攻击者随手塞个内网 IP,你的属地展示就废了。
  2. 空结果也要缓存:查不到的内网地址、超时 IP 存一个 300 秒的负缓存,避免异常流量打穿到接口层。
  3. 展示分级:市、省、国家、本地网络、未知,五档文案,和第五节一样逐级降。
  4. 监控未知率:按小时统计未知占比,超过基线两倍就告警。这个指标比接口成功率更早暴露采集链路故障。
  5. 别把属地当定位:接口给的经纬度是城市中心点,做「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。