Skip to content

PHP 怎么获取访客真实 IP 和归属地?Nginx 反代、CDN 后面最容易拿错

一个做废品回收站的老哥问我:留言板里记下来的 IP,查出来全是 127.0.0.1 和一个云厂商的节点地址,气得想砸键盘。他的站就是典型的 PHP 部署:前面挂着 Nginx 反代,有的页面还套了 CDN。$_SERVER['REMOTE_ADDR'] 拿到的永远是"最后一个跟你服务器握手的人"——反代服务器的内网地址,或者 CDN 节点,而不是访客本人。

这篇把 PHP 里取真实 IP 的完整链路讲清楚:怎么区分反代和 CDN、哪个头能信哪个头是用户随便填的、取到 IP 之后怎么查归属地并落库,最后给一段留言/表单场景能直接用的代码。

一、先搞明白 IP 是在哪一步丢的

访客 → Nginx → PHP-FPM,这是最常见的结构。PHP 里所有 IP 信息都来自 $_SERVER,但一层代理套一层,REMOTE_ADDR 就逐级变成"上一跳"的地址:

  • 没反代、PHP 直连公网:REMOTE_ADDR 就是访客 IP,最简单。
  • 有 Nginx 反代(监听 80/443,转发给 9000 端口的 PHP-FPM):PHP 看到的 REMOTE_ADDR 是 127.0.0.1 或内网地址。真实 IP 在 Nginx 转发的 X-Forwarded-For 头里。
  • 前面再套 CDN:REMOTE_ADDR 变成 CDN 节点 IP,X-Forwarded-For 可能被 CDN 改写,也可能多个代理层层追加,变成一串逗号分隔的地址。

网上一搜全是"取 X-Forwarded-For 第一个值"的代码,这是最坑的写法:那个头客户端自己就能伪造。访客拿 curl 加一个 X-Forwarded-For: 1.2.3.4,你的留言板就把 1.2.3.4 当成他的 IP 记下来,属地随便编。正确的思路是分层信任:

  1. 有 CDN 专用头(Cloudflare 的 CF-Connecting-IP、阿里云/腾讯云 CDN 的 X-Real-IP 之类),优先用——这些头是 CDN 回源时写的,访客碰不到。
  2. 只有你自己的 Nginx 反代:先确认 REMOTE_ADDR 确实是你反代服务器的 IP,再信 X-Forwarded-For,并且从右往左取——右边是你反代刚追加的,左边才是访客(或访客伪造的,但已被你反代覆盖逻辑处理掉)。
  3. 都不是,才用 REMOTE_ADDR

二、查归属地:接口选型与缓存

IP 拿到手,下一步是查省市运营商。老教程里的淘宝接口 ip.taobao.com 早就停服了,别抄。现在用在线接口:GET https://ip9.com.cn/get?ip=<ip>,免注册直接调,IPv4/IPv6 都支持,返回 JSON 里有 prov(省)、city(市)、isp(运营商)等字段,免费版 60 次/分钟/IP。

留言板一天几百条留言,量不大,但同一 IP 反复提交(一个人回好几条)会重复消耗配额。所以查询必须带缓存:按 IP 缓存 24 小时,同一个 IP 一天只查一次。PHP 里最简单的是文件缓存,一个 JSON 文件搞定;流量大了换 Redis 或数据库表。

三、完整 PHP 实现

第一步:取真实 IP。 下面函数处理了直连、自建 Nginx 反代、常见 CDN 三种情况,核心是"只信可信来源":

php
<?php
// real_ip.php —— 取访客真实 IP
// 按实际情况改:你 Nginx 反代服务器的 IP(公网 IP 或 127.0.0.1)
$TRUSTED_PROXIES = ['127.0.0.1', '10.0.0.5'];

function isPublicIp(string $ip): bool
{
    return filter_var($ip, FILTER_VALIDATE_IP,
        FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}

function getClientIp(): string
{
    global $TRUSTED_PROXIES;
    $remote = $_SERVER['REMOTE_ADDR'] ?? '';

    // 1) CDN 专用头:CDN 回源时写入,访客伪造不了,优先用
    foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'HTTP_ALI_CDN_REAL_IP'] as $h) {
        if (!empty($_SERVER[$h]) && isPublicIp($_SERVER[$h])) {
            return $_SERVER[$h];
        }
    }

    // 2) 只有 REMOTE_ADDR 是自己反代时才信 X-Forwarded-For
    if (in_array($remote, $TRUSTED_PROXIES, true) && !empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
        // 从右往左:右边是反代刚追加的,跳过私网/保留地址,第一个公网 IP 才是访客
        $parts = array_reverse(array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])));
        foreach ($parts as $p) {
            if (isPublicIp($p)) {
                return $p;
            }
        }
    }

    // 3) 都没有反代,直接就是访客
    return $remote;
}

几点说明:HTTP_ALI_CDN_REAL_IP 是阿里云 CDN 的头,用腾讯云/又拍云就把对应的头加进数组;FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE 一次过滤掉内网、回环和保留地址。这套逻辑在虚拟主机上也能跑——你不用改 Nginx 配置,纯应用层解决,这是 PHP 场景比改 real_ip 模块省事的地方。

第二步:查归属地 + 缓存 + 落库。 以留言提交为例,入库前打上属地标签:

php
<?php
require 'real_ip.php';

// 查询归属地,文件缓存 24 小时;查不到返回 null,绝不让接口故障拖垮业务
function queryLocation(string $ip): ?array
{
    $cacheFile = __DIR__ . '/ipgeo_cache.json';
    $cache = is_file($cacheFile) ? json_decode(file_get_contents($cacheFile), true) : [];

    if (isset($cache[$ip]) && $cache[$ip]['ts'] > time() - 86400) {
        return $cache[$ip]['data'];
    }

    // 注意:接口对无 UA 的请求会拦(返回 403),必须带上 User-Agent
    $ctx = stream_context_create(['http' => [
        'timeout'       => 5,
        'ignore_errors' => true,
        'header'        => "User-Agent: Mozilla/5.0 (compatible; IP9Demo/1.0)\r\n",
    ]]);
    $resp = @file_get_contents('https://ip9.com.cn/get?ip=' . rawurlencode($ip), false, $ctx);
    if ($resp === false) {
        return null;
    }
    $json = json_decode($resp, true);
    if (!isset($json['ret']) || $json['ret'] !== 200) {
        return null;
    }
    $d = $json['data'];
    $data = [
        'prov' => $d['prov'] ?? '',
        'city' => $d['city'] ?? '',
        'isp'  => $d['isp']  ?? '',
    ];
    $cache[$ip] = ['ts' => time(), 'data' => $data];
    file_put_contents($cacheFile, json_encode($cache, JSON_UNESCAPED_UNICODE));
    return $data;
}

// 留言提交处调用
$ip  = getClientIp();
$loc = queryLocation($ip);

// 用 PDO 预编译入库,属地查不到就记空,不影响留言本身
$stmt = $pdo->prepare('INSERT INTO messages (content, ip, prov, city, isp, created_at)
                       VALUES (?, ?, ?, ?, ?, NOW())');
$stmt->execute([
    $_POST['content'],
    $ip,
    $loc['prov'] ?? '',
    $loc['city'] ?? '',
    $loc['isp']  ?? '',
]);

文件缓存写多进程会打架,留言量上来后把 $cache 换成 Redis 或者一张 ip_geo 表就行,函数签名不用动。

四、落地注意事项

  • 信任边界是安全设计,不是性能优化$TRUSTED_PROXIES 一定要写死你自己的反代 IP。写成"REMOTE_ADDR 是内网就信 XFF",等于告诉攻击者:随便填。上线前用 curl -H "X-Forwarded-For: 8.8.8.8" 打一下,属地没变才算对。
  • 移动大内网用户拿到的可能是 100.64.x.x 共享地址,不是私网但也不是个人独享,查出来的属地是运营商出口的位置,别当成精确位置用。
  • 接口失败要静默降级file_get_contents 加了 5 秒超时,查不到就记空字符串,留言照常收。千万别在留言主流程里 die()
  • 隐私合规:IP 属个人信息。留言板要记录访客 IP 和属地,隐私政策里写明用途(防垃圾留言、安全审计),后台查询权限收一收,别给每个管理员都开。
  • 配额不够再升级:免费版 60 次/分钟对留言场景绰绰有余;真有批量回填历史数据的需求,要么脚本里限速慢慢跑(每次间隔 1.1 秒),要么上按次计费的 VIP 版(0.05 元/千次起,18 万次/分钟),VIP 还多给 area 区县、ip_type(家庭/企业/机房)、ip_asn 字段——判断机房刷留言时 ip_type 比属地更管用。

总结

PHP 拿访客归属地,坑不在"调接口",在前面三步:分清有没有反代和 CDN、只信可信来源的头、从右往左解析 X-Forwarded-For。这三步理顺,一个 getClientIp() 加一个带缓存的 queryLocation(),留言反垃圾、注册风控、登录审计都能复用同一套数据。

在线接口 https://ip9.com.cn/get?ip=<ip> 免注册直接调,字段和免费额度说明见官网 https://www.ip9.com.cn 。先把取值链写对,再谈别的——IP 都是假的,属地查得再准也没用。