做海外业务的人,对“同一套接口,不同地区要给出不同表现”这种事应该不陌生。前阵子我接到一个很典型的活:上游某个 API 服务商要求调用方根据用户所在国家或地区,动态传入不同的 version 参数,否则接口要么返回旧版数据结构,要么直接拒绝服务。项目里已经有一套 PHP 写的接口转发层,于是这个需求就落在了“在 PHP 代理脚本里按国家代码改写 version 参数”上。
这里说的代理脚本,是业务侧的 API 请求转发与参数改写服务,放在应用层,跟网络链路的通信代理完全是两码事,别搞混了。这篇文章把整个方案拆开讲:先说清楚为什么会出现“按国家代码选 version”的需求,再讲国家代码怎么拿最靠谱,然后给出一份能直接改改就用的 PHP 代理脚本实现,最后把线上踩过的坑——比如 IP 字段伪造、参数覆盖策略、超时重试、缓存击穿这些——一并列出来。适合正在做跨境业务、需要做 API 适配层的后端同学参考,也适合想了解“网关转发层如何动态改写请求参数”的 PHP 开发者。
1. 项目概述与需求拆解
1.1 需求的本质:在转发层统一决策版本
这个需求的本质是:在反向代理层根据“请求者的国家代码”这个条件,决定请求中 version 参数的值。为什么不能在前端或客户端直接传?因为客户端传的 version 不可信,而且如果每个人都能自己指定版本,上游 API 的灰度节奏和兼容性管理就会非常混乱。放在代理层统一改写,对上游服务商是透明的,对调用方也是透明的——调用方只需要按正常方式调用,版本选择由这个适配脚本决定。
我项目里遇到的实际情况是:上游 API 把全球市场分成多批次灰度,某个区域先用老版本,另一个区域已经切到新版本,再有一个区域正在做 A/B 对比。如果让每个业务方自己去维护“哪个国家对应哪个版本”的配置,迟早会出现不一致,所以必须在统一入口做这件事。这个统一入口,就是标题里说的 PHP 代理脚本。
1.2 为什么用“国家代码”而不是其他维度
国家代码(ISO 3166-1 alpha-2 标准,比如 US、JP、KR、DE、BR、IN 这种两位字母代码)在这个需求里是最合适的判断维度,理由有三点。
第一,拿到成本低。无论是 IP 归属地、CDN 注入的请求头,还是账号资料里的国家字段,都能拿到,不用额外设计复杂的判定逻辑。第二,区域语义清晰。产品、运营、商务在谈版本策略时,天然用的是“北美用新版本、欧洲用旧版本”这种表述,不会说“ASN 1000 用新版本”这种技术黑话。第三,映射关系稳定。国家代码和 API 版本的对应关系变化频率,远低于每个 IP 单独配置。IP 段可能三天两头漂移,国家代码则几乎不变。
1.3 影响范围:不只是多传一个参数
这个改动影响的不只是“传一个参数”这么简单。首先,所有经过这个代理脚本的请求,返回的数据结构可能因为 version 不同而不同,这意味着下游解析方要有足够的兼容性。其次,日志、监控、分账、风控这些系统如果按 version 统计,也要跟着调整。最后,版本切换本身可能是突发的——上游临时要求某地区切版本,所以代理脚本里必须留出“动态改映射配置”的开关,不能每次改版本都重新部署服务。
2. 技术选型与整体设计
2.1 代理层放在哪里:三个方案的取舍
方案上有三种选择。
第一种是改业务代码。在每个调用 API 的 service 层加判断,代码侵入性大,多个业务方调用同一个接口就会重复实现逻辑,漏改一个地方就可能出线上事故。
第二种是独立网关。上 Nginx/OpenResty 或者 APISIX 那种成熟网关,性能和扩展性都好,但需要维护额外组件,配置复杂度也高,小团队不一定愿意为这个需求专门养一套网关。
第三种是应用层 PHP 转发脚本。在现有 PHP 项目里加一层轻量转发,不引入新组件,适合内部系统、中小流量。
这个例子里我选的是第三种。原因很直接:项目本身就是 PHP 技术栈,已经有一个现成的接口转发服务,流量属于中等水平,加一层 PHP 转发完全扛得住;而且 PHP 脚本改映射关系、加缓存、打日志都非常方便,迭代快。如果你的项目流量很大,或者对延迟极其敏感,那建议还是考虑 OpenResty 这一类方案,原理是一样的,只是承载层换了。
2.2 国家代码的三种来源与取舍
国家代码的获取,常见有三条路径。
CDN 注入请求头。云厂商或者 CDN 会在回源请求里加上国家代码字段。这种方式的优点是零额外查询成本、准确率高、性能最好;缺点是依赖 CDN 配置,回源时才会注入,如果请求没经过 CDN 就取不到,而且请求头理论上可以被伪造,所以只能信任来自可信 CDN 回源网段的请求头。
IP 归属地数据库解析。用 GeoIP 库(比如 GeoLite2、ip2region 这类)本地解析客户端 IP。优点是不依赖第三方服务,离线可用;缺点是数据需要定期更新,IP 段归属有延迟。另外如果服务器本身在多层代理后面,要拿到真实客户端 IP 还得处理 X-Forwarded-For。
账号体系里的国家字段。如果请求能带上用户身份,直接从账号信息里取国家最准。可惜很多场景是无登录状态的开放接口,拿不到这个信息。
实际项目里我用了“CDN 请求头优先 + IP 归属地兜底”的组合策略:先读 CDN 注入的国家头,没有就本地查 GeoIP,最后给一个 DEFAULT 兜底。这样既保证了大多数请求的低延迟高准确率,又覆盖了未经过 CDN 的场景。
2.3 version 映射规则:一张配置表搞定
映射规则核心是一个“国家代码 -> 版本号”的字典。我推荐用普通 PHP 配置文件来维护,结构类似下面这样:
<?php // config/version_map.php return [ // 北美:新版本 'US' => 'v2.4', 'CA' => 'v2.4', // 东亚部分区域:次新版本 'JP' => 'v2.3', 'KR' => 'v2.3', // 欧洲部分区域:过渡版本 'DE' => 'v2.2', 'GB' => 'v2.2', 'FR' => 'v2.2', // 拉美、澳洲:较老版本 'BR' => 'v2.1', 'AU' => 'v2.1', // 其余区域:默认版本 'DEFAULT' => 'v2.0', ];这里有个值得注意的细节:version 参数在示例 API 里的写法是带前缀“v2.4”这种语义化版本,而另一类 API 是整数版本号比如“20240501”或者“ver=3”,不管哪种,映射原理都一样,只是存储要统一成字符串。然后映射规则一定要有 DEFAULT 兜底,否则新增地区、解析失败的数据都会拿不到版本号,直接 500。
3. 核心代码实现与关键细节
3.1 前置模块:判断国家代码的完整实现
代码部分从“拿国家代码”开始。我把它拆成独立函数,方便测试和复用。这个模块是整个方案的地基,地基不稳后面全白搭。
<?php declare(strict_types=1); /** * 获取本次请求对应的国家代码(ISO 3166-1 alpha-2) * 优先级:CDN 注入头 -> GeoIP 解析 -> 默认 */ function getCountryCode(): string { // 1. CDN 回源注入的国家代码,常见字段: // Cloudflare: CF-IPCountry // 其他云 CDN: X-Country-Code / X-Geo-Country $cdnHeaders = [ 'HTTP_CF_IPCOUNTRY', 'HTTP_X_COUNTRY_CODE', 'HTTP_X_GEO_COUNTRY', ]; foreach ($cdnHeaders as $header) { if (isset($_SERVER[$header])) { $code = strtoupper(trim($_SERVER[$header])); // 只接受标准的两位字母国家代码,防止脏数据进入映射逻辑 if (preg_match('/^[A-Z]{2}$/', $code)) { return $code; } } } // 2. GeoIP 兜底:拿到客户端 IP 后解析 $ip = getClientIp(); $code = geoIpLookup($ip); if ($code !== '') { return $code; } // 3. 兜底默认值,保证后续逻辑始终能拿到一个合法值 return 'DEFAULT'; } /** * 尽力获取真实客户端 IP。 * 注意:X-Forwarded-For 可以被伪造,只有在请求只来自可信代理时才信任。 */ function getClientIp(): string { // 这里假设前置是一层可信的 Nginx 或者云负载均衡, // 它会主动覆盖 X-Real-IP 请求头,所以可以放心读取。 if (isset($_SERVER['HTTP_X_REAL_IP'])) { return trim($_SERVER['HTTP_X_REAL_IP']); } if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) { $parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']); return trim($parts[0]); } return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; } /** * 使用本地 GeoIP 文件查询国家代码,读取失败时返回空字符串。 */ function geoIpLookup(string $ip): string { static $reader = null; // 这里用静态变量复用 Reader 对象,避免同一进程内重复初始化 $dbPath = __DIR__ . '/../data/GeoLite2-Country.mmdb'; if (!is_file($dbPath)) { return ''; } try { if ($reader === null) { $reader = new \GeoIp2\Database\Reader($dbPath); } $record = $reader->country($ip); $code = $record->country->isoCode; return $code === null ? '' : strtoupper($code); } catch (\Throwable $e) { // GeoIP 查询失败不能影响主流程,记录日志后返回空字符串即可 error_log('[geo] lookup failed: ' . $e->getMessage()); return ''; } }getClientIp 这里有个容易被忽略的点:如果在多层反向代理后面,REMOTE_ADDR 是上一层的 IP,而不是真实用户 IP。我项目里的做法是在前置 Nginx 上配置好 X-Real-IP,PHP 层优先读取它。如果在公网裸奔环境下信任 X-Forwarded-For,等于把国家判定的主动权交给攻击者,他随便填一个头部就能伪装地区,这个问题在第 4 节还会展开。
3.2 version 参数改写与请求转发:核心动作
拿到国家代码之后,就需要把它映射成 version,然后带着新参数去请求上游接口。这里是整个脚本的心脏,我直接把核心函数放出来。
<?php /** * 根据国家代码解析出最终 version。 */ function resolveVersion(string $country, array $queryParams): string { $map = require __DIR__ . '/../config/version_map.php'; $version = $map[$country] ?? $map['DEFAULT'] ?? 'v1.0'; // 内部调试通道:允许携带 _force_version 显式覆盖。 // 正式环境里建议把这个开关放到独立的鉴权配置中,避免被外部刷。 if (!empty($queryParams['_force_version']) && isInternalRequest()) { // 对版本号做白名单清洗,只保留安全字符 $version = preg_replace('/[^A-Za-z0-9._-]/', '', $queryParams['_force_version']); } return $version; } /** * 简单判断是否为内部请求,例如来源 IP 在内部网段。 */ function isInternalRequest(): bool { $ip = getClientIp(); $internalRanges = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16']; foreach ($internalRanges as $range) { if (ipInRange($ip, $range)) { return true; } } return false; } /** * IP 是否在某个 CIDR 网段内。 */ function ipInRange(string $ip, string $cidr): bool { if (!str_contains($cidr, '/')) { return false; } [$subnet, $mask] = explode('/', $cidr, 2); if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { $ipLong = ip2long($ip); $subnetLong = ip2long($subnet); $maskLong = -1 << (32 - (int)$mask); return ($ipLong & $maskLong) === ($subnetLong & $maskLong); } return false; } /** * 代理转发入口:读取当前请求,改写参数,转发到上游。 */ function proxyRequest(): void { $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?? '/'; $queryParams = $_GET; $country = getCountryCode(); $version = resolveVersion($country, $queryParams); // 核心动作:把解析好的 version 写回 query 参数。 // 这里使用“覆盖策略”,也就是说不管客户端有没有传 version, // 最终以代理层根据国家解析出的版本为准。 $queryParams['version'] = $version; // 顺手去掉内部调试参数,避免泄露到上游。 unset($queryParams['_force_version']); $targetBase = 'https://api.example-service.com'; $targetUrl = $targetBase . $path; $targetUrl .= '?' . http_build_query($queryParams); $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $targetUrl); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HEADER, false); curl_setopt($ch, CURLOPT_TIMEOUT, 15); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3); $headers = []; if ($method === 'POST' || $method === 'PUT' || $method === 'PATCH') { $body = file_get_contents('php://input'); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); if (isset($_SERVER['CONTENT_TYPE'])) { $headers[] = 'Content-Type: ' . $_SERVER['CONTENT_TYPE']; } } // 把上游服务可能需要的鉴权头透传过去,注意只透传自己认识的, // 不要无脑转发所有请求头,否则容易把敏感信息带出去。 foreach (['HTTP_AUTHORIZATION', 'HTTP_X_API_KEY'] as $authHeader) { if (isset($_SERVER[$authHeader])) { $name = str_replace('HTTP_', '', $authHeader); $name = str_replace('_', '-', $name); $headers[] = $name . ': ' . $_SERVER[$authHeader]; } } if ($headers) { curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); } curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); $response = curl_exec($ch); $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($response === false) { // 上游完全不可达时,代理层返回 502 并记录详细错误 http_response_code(502); header('Content-Type: application/json'); echo json_encode([ 'code' => 50201, 'message' => 'upstream request failed', ]); error_log('[proxy] upstream error: ' . $error); return; } http_response_code((int)$statusCode); header('Content-Type: application/json; charset=utf-8'); echo $response; }几个关键点需要额外说明。
http_build_query会把参数数组拼成 URL 查询串,如果客户端传了同名参数,$_GET 里后者覆盖前者,所以不用担心重复参数把上游搞懵。CURLOPT_CUSTOMREQUEST加POSTFIELDS的组合,在转发 PUT/PATCH 时也没问题。要注意的是 php://input 只能读一次,所以不管转给谁都要一次读完。
错误处理我做了两层:网络层失败返回 502,HTTP 状态码非 2xx 时不改状态码原样透传,让下游自己判断。不过实际项目里可以根据需求调整,比如记录日志后仍然返回 200 并在 body 里带错误码——但不建议默认这么做,因为下游容易把业务错误和调用成功混淆,排查问题会很难受。
3.3 响应处理与异常兜底:透传不等于放任
响应处理的关键就是“透传”。但透传之前我建议统一做三件事。
第一,内容类型检查。如果上游返回的不是合法 JSON,而下游习惯了 JSON 解析,最好在这里做一次校验,而不是把垃圾字符串直接抛给下游。轻量做法是json_decode一次,失败就记录日志并返回 50205。
第二,增加必要的响应头。比如 CORS 头、缓存控制头,避免下游浏览器乱报错。跨境业务经常有网页端直接调用代理层的情况,不加 CORS 头前端会一片红。
第三,超时兜底。整个脚本的执行时间上限应该在入口处设置,常规用 Nginx 的 fastcgi 超时配合set_time_limit(20),防止某个慢请求长时间占着 PHP-FPM 进程。
还有一个容易被忽略的问题:上游返回的编码可能不是 UTF-8,尤其某些老接口返回 GBK,如果不做处理,下游拿到的字符串就会乱码。我在项目里写了一个小函数,专门在透传前检测 Content-Type 里的 charset,不是 UTF-8 就做一次转换。这个处理对海外业务特别重要,因为不同地区的上游系统用的编码标准可能完全不同。
3.4 缓存设计:让代理层扛住流量
PHP 是短生命周期进程,每个请求都会重新解析国家代码、重新查地理库、重新读映射文件。要注意,PHP-FPM 的 opcache 对静态 require 文件有优化,但是 mmdb 文件每次请求都去读,文件大的时候性能会很难看。
实际方案里我做了两层缓存。
第一层是文件变化时间缓存。把 version_map.php 的filemtime缓存到 APCu 里,版本文件没变化就不重新 require。PHP 的 require 本身有 opcache,性能问题不大,但文件 mtime 检查很便宜,算是习惯性防御。
第二层是 GeoIP 查询结果缓存。同一个 IP 在一天内的国家归属基本不会变,所以用“IP -> 国家代码”的映射缓存,缓存键就是 IP,过期时间 24 小时,存储放在 Redis。这样即使某个 IP 高频请求,也不会反复去读取 mmdb 文件。
实现起来不复杂:
<?php /** * 带缓存的获取国家代码 */ function getCountryCodeWithCache(): string { $ip = getClientIp(); // 先从 Redis 缓存拿,缓存键用 md5 避免 IP 明文出现在键名中 $cacheKey = 'geo:country:' . md5($ip); $cached = redisGet($cacheKey); if ($cached !== null) { return $cached; } $country = getCountryCode(); // 内部走 CDN 头 + GeoIP 逻辑 redisSet($cacheKey, $country, 86400); return $country; }注意缓存键用 md5 是因为 Redis 键最好是不含特殊字符的字符串,IP 本身也可以直接做键,但 IP 属于敏感信息,日志和监控系统可能不允许明文存储,所以做一层哈希更稳妥。
另外一个容易被流量打爆的点:如果某个 IP 是攻击者伪造的,CDN 头每次都不一样,缓存形同虚设。这种情况我在 Nginx 层直接限流,根据真实 IP 每分钟最多 120 次,超过就丢进黑名单。这里说的黑名单是业务参数防护,属于正常的接口防刷手段。
4. 常见问题与排查实录
4.1 国家代码判定不准怎么办
上线第一天就遇到一个问题:某地区用户反映拿到的是默认版本。查了半天,发现他们办公室出口 IP 属于云服务商机房,地理库把这个 IP 判到了数据中心所在地区,而不是真实地区。
这类问题的排查思路:
- 先在代理层打日志,输出“最终国家代码 + 命中来源”。我写的日志格式是
[geo] ip=... cdn_country=US geo_country=US final=US source=cdn。 - 如果 CDN 头存在但值不对,去 CDN 控制台确认是否开启了“国家识别”功能,有些 CDN 默认不注入,需要手动开启。
- 如果 GeoIP 库判错,基本只能换库或者等数据库更新,没有一劳永逸的办法。我项目里做的补偿机制是:允许在映射表里针对“IP 网段”做手动覆盖,权重高于国家代码,主要应付“某国全部流量都从某机房出去”的极端场景。
4.2 客户端自己传了 version,到底以谁为准
这个问题在需求确认阶段就要明确。我在 3.2 节代码里用的策略是“服务端覆盖”,理由是 version 属于网关控制参数,不应该信任客户端。但这个策略不是绝对的。
如果客户端是自家 App,且必须使用“设置里缓存的版本号”,那就要改成“客户端优先 + 白名单校验”。如果上游 API 在未来要支持客户端自定义版本,映射逻辑可能就要反过来。
我建议不要把“覆盖/保留”的开关写死在代码里,而是做成配置项:
// config/proxy_config.php return [ 'client_version_overridable' => false, ];这样日后策略变化,改配置就可以,不用动代码。同样的道理,凡是可能变化的策略,都应该配置化,比如“DEFAULT 用哪个版本”“是否允许调试参数”这些。
4.3 上游接口超时与重试策略
代理脚本转发的请求,如果上游很慢,PHP-FPM 进程会被拖死。我踩过的坑是:上游某个接口偶尔出现 10 秒以上的抖动,PHP 默认没有超时限制(或者说比较宽松),结果整个实例的 PHP-FPM 被占满,其他正常请求全部排队。
后来做的改进:
CURLOPT_CONNECTTIMEOUT设为 3 秒,CURLOPT_TIMEOUT设为 15 秒,这两个值可以根据上游 P99 延迟来调整,但绝对不能省。- 转发逻辑里不做“无条件重试”。幂等 GET 可以重试一次,但 POST/PUT 重试有风险,上游可能已经执行了写操作,再发一次可能造成重复订单。如果确实需要重试,只有在上游响应头里明确给了
Retry-After,或者业务上确认了幂等键的情况下才做。 - PHP 侧设
set_time_limit(20),比 curl 超时稍微宽松,确保 curl 返回后还有时间写处理逻辑。
4.4 线上问题排查清单
我把实际排查过的现象整理成一张速查表,供参考:
| 现象 | 可能原因 | 排查/解决 |
|---|---|---|
| 所有请求都拿 DEFAULT 版本 | CDN 头没注入,GeoIP 库文件缺失,映射表缺少对应国家 | 先看 [geo] 日志确认来源;确认 mmdb 文件存在;补映射 |
| 偶尔部分地区版本不对 | GeoIP 库更新慢,IP 段漂移 | 手动 IP 网段覆盖,定期更新数据库 |
| 接口返回 502 | 上游 DNS 解析失败、SSL 握手失败、域名配置错误 | curl_error 记录了具体错误,检查上游地址与证书 |
| 接口返回 200 但数据缺字段 | version 被客户端覆盖或没覆盖导致结构变化 | 检查透传日志里的 URL 与 version 参数值 |
| 代理层响应极慢 | 无超时配置、上游慢 | 设置 connecttimeout/timeout,排查上游 P99 |
| 高频 IP 反复读地理库 | 没有加缓存 | 增加 Redis 缓存,缓存键 md5(ip) |
| 日志里 IP 全是内网地址 | 被多层代理转发,取到了前置代理 IP | 用 X-Real-IP 求真实 IP,或在前置层覆盖该头 |
4.5 关于安全边界多说一句
这个代理脚本本质是把请求转发到上游,所以它天然是一个入口。我只透传固定几个请求头,绝不转发User-Agent、Referer之类的东西;输入输出都限制为 JSON 格式;对_force_version这种调试参数做了内部网段校验。还有一个细节:转发之前用http_build_query重新拼参数,等于同时做了 URL 编码,可以避免一些注入尝试。
日志方面,转发层会记录完整的请求 URL(去掉敏感 query)和响应状态码。为了防止敏感信息泄露,日志里不会记录 Authorization 头的完整值,只记录是否存在。
5. 实操体会与后续扩展建议
最后聊点个人体会和可以继续做的方向。
先说体会。这种“按国家代码动态调参数”的代理脚本,看起来只是改一个 version,但真正难的不是代码,而是边界条件的确认:客户端传了 version 怎么办?国家代码判定错了怎么办?上游版本切换时要不要灰度?这些如果不提前定义好,上线第一天就会被各种异常数据淹没。我自己的习惯是,先把所有 logger 打全,把“最后一次判定结果”和“依据来源”记录清楚,排查时会省掉大量时间。
代码层面还有一个容易被忽略的点:PHP 短生命周期进程不适合放全局状态,所以映射表、GeoIP Reader 这些资源都要做成静态复用或者放在容器外。用长驻常驻的 Swoole 来跑会更舒服,不过那是另一个话题。
后续扩展方向,我目前觉得比较有价值的是这几个:
- 把 version 映射表接入配置中心或远程文件,这样改版本策略不用重新发布服务,改完配置等缓存过期即可。
- 增加流量镜像比较。新版本切换前,可以把一部分请求同时打到新旧两个版本,对比返回结果差异,确认无误再切正式流量。
- 把代理脚本做成可观测的中间件:记录每个国家的请求量、版本占比、失败率、耗时分布,这些数据对上游的灰度决策很有帮助。
- 如果后面流量涨起来,可以在这个 PHP 代理层后面再加上一层 Nginx 静态缓存,对 GET 接口做短时间缓存,能显著降低上游压力。
碰到过的一个真实例子是:某个市场切新版本当天,下游有个数据同步任务还在按旧版字段解析,结果新版本返回里少了一个字段,任务全线报错。当时的教训就是:代理层虽然只管改写参数,但版本变化的影响是全链路的,建议所有消费方都准备“字段缺失的降级策略”,不要默认字段永远齐全。
这个方案最终在我这边落地后,代码量不算大,但把“不同国家不同版本”的配置从各个业务代码里收拢到了一个入口,后续调整版本就像改配置一样简单。如果你也遇到类似需求,按照这个思路去实现,基本不会走弯路。最后再强调一次:一定要把来源日志打清楚,没有日志的转发层,一旦出问题,排查成本会非常大。