Appearance
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 记下来,属地随便编。正确的思路是分层信任:
- 有 CDN 专用头(Cloudflare 的
CF-Connecting-IP、阿里云/腾讯云 CDN 的X-Real-IP之类),优先用——这些头是 CDN 回源时写的,访客碰不到。 - 只有你自己的 Nginx 反代:先确认
REMOTE_ADDR确实是你反代服务器的 IP,再信X-Forwarded-For,并且从右往左取——右边是你反代刚追加的,左边才是访客(或访客伪造的,但已被你反代覆盖逻辑处理掉)。 - 都不是,才用
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 都是假的,属地查得再准也没用。