PHP 读取访客 IP 实现 GEO 内容分发有哪些注意事项
PHP做GEO分发应仅信任REMOTE_ADDR,XFF按可信代理从右向左解析;地区判断用本地mmdb/ip2region而非字符串前缀,并用inet_pton标准化,避免把原始IP作为CDN缓存键或SEO跳转唯一依据。
结论:PHP 做 GEO 内容分发,唯一可信的访客地址是 $_SERVER['REMOTE_ADDR'],X-Forwarded-For 只能按"可信代理层数"从右往左解析;地区判断要用本地 mmdb/ip2region 库而不是字符串前缀匹配,并且不要把原始 IP 当成 CDN 缓存键或 SEO 跳转的唯一依据。
PHP 拿访客 IP 该读哪个变量?
结论:只有 REMOTE_ADDR 来自 TCP 连接本身,客户端无法伪造;所有 HTTP_* 变量都是从请求头复制的,可以随意伪造。
站点前面挂了 Nginx、SLB 或 CDN 时,REMOTE_ADDR 拿到的是代理 IP,要在 Nginx 层先还原:
set_real_ip_from 10.0.0.0/8; # 可信代理网段,逐个列
real_ip_header X-Forwarded-For;
real_ip_recursive on;
用 Cloudflare 时真实 IP 在 CF-Connecting-IP,国家码在 CF-IPCountry(ISO 3166-1 alpha-2,如 CN、US),这两个头由边缘节点写入,比源站再查一次库更快。
X-Forwarded-For 为什么不能直接取第一个?
结论:XFF 是 客户端, 代理1, 代理2 的逗号分隔串,最左边那段是客户端自己塞进来的,取 explode(',')[0] 等于把 GEO、限流、风控的判断权交给攻击者。
正确做法是从右往左走链条,第一个不在可信列表里的地址才是真实客户端:
function realIp(array $trusted): ?string {
$remote = $_SERVER['REMOTE_ADDR'] ?? '';
if (!filter_var($remote, FILTER_VALIDATE_IP)) return null;
if (!in_array($remote, $trusted, true)) return $remote;
$chain = array_filter(array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '')));
$chain[] = $remote;
for ($i = count($chain) - 1; $i >= 0; $i--) {
if (!in_array($chain[$i], $trusted, true)) return $chain[$i];
}
return $remote;
}
三个细节:有的代理会写入 unknown;有的带端口写成 1.2.3.4:5678;双栈环境下客户端可能是 IPv6,也可能是 ::ffff:1.2.3.4 这种 IPv4 映射格式。入库前统一用 filter_var($ip, FILTER_VALIDATE_IP) 或 inet_pton() 标准化。
判断 IP 属于哪个网段,能不能用字符串比较?
结论:不能用 substr($ip, 0, 7) === '192.168.' 这类前缀比较,192.168.1.100 和 192.168.10.1 会互相误判,必须用 inet_pton() 转二进制后按掩码比较。
function ipInCidr(string $ip, string $cidr): bool {
[$net, $mask] = explode('/', $cidr) + [1 => 32];
$a = inet_pton($ip); $b = inet_pton($net);
if ($a === false || $b === false || strlen($a) !== strlen($b)) return false;
$bytes = intdiv((int)$mask, 8); $bits = (int)$mask % 8;
return substr($a, 0, $bytes) === substr($b, 0, $bytes)
&& ($bits === 0 || (ord($a[$bytes]) >> (8 - $bits)) === (ord($b[$bytes]) >> (8 - $bits)));
}
至少要剔除 10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、127.0.0.0/8、169.254.0.0/16、100.64.0.0/10(运营商 CGNAT,移动网络很常见),IPv6 侧是 ::1、fc00::/7。这些地址查 GEO 库只会返回空,直接归入"默认区域"。
地区数据用本地库还是在线 API?
结论:有一定流量的站点用本地库,不要在请求链路里调第三方 HTTP API。
可选方案有两种:MaxMind GeoLite2(免费,需注册拿 license key,GeoLite2-City.mmdb 约 60 MB,Composer 装 maxmind-db/reader,Reader 支持内存模式但要算好 PHP-FPM 进程数 × 内存);ip2region 的 xdb 格式,国内城市粒度,单次查询微秒级,完全离线。
注意两点:国家级别判断的准确率明显高于城市级别,移动网络出口 IP 常年被 NAT 到省会,把"精确到市"当硬需求一定会翻车;另外别每个请求都查库,把 IP 段 → 地区 写进 Redis 或 APCu,TTL 设 1 到 7 天,同一 /24 段聚合成一条记录,命中率会明显提升。
GEO 分流在缓存和 SEO 上有什么坑?
结论:不要把访客 IP 写进 CDN 缓存键,也不要用 301 自动跳转到语言版本。
把 IP 当 Vary 或缓存键,每个用户一个缓存条目,CDN 命中率直接归零。正确做法是在 CDN 边缘按国家分桶(Cloudflare 读 CF-IPCountry,国内 CDN 一般也有类似头部),源站按国家维度输出内容并设较短 TTL。由 IP 决定语言时加 Vary: Accept-Language 是无效的,因为客户端请求头并不参与判断。
SEO 上,Google 的建议是 hreflang 标注加手动语言选择,自动 301 会让爬虫只抓到一个语言版本。要跳就跳 302,把用户选择写进 cookie,之后不再跳,并在页面上保留语言切换入口。合规上,IP 在 GDPR 下被欧盟法院认定为可识别个人身份的数据,在国内《个人信息保护法》下也属于个人信息,日志留存要有明确目的和期限。
小结
PHP 做 GEO 分发就四件事:IP 只信 REMOTE_ADDR,XFF 按可信代理从右往左解析;网段判断走 inet_pton 加掩码;地区查询用本地库并缓存聚合结果;分发策略放在 CDN 边缘或做短 TTL 分桶,别碰 IP 级缓存键和 301 自动跳转。
转载请注明出处,版权归原作者所有。