ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

网站判断国内还是国外的工程实践:IP归属、CDN真实IP与多信号校验

网站判断国内还是国外的工程实践:IP归属、CDN真实IP与多信号校验 1. 先弄清楚判断国内还是国外这件事的边界很多做网站的人第一次接这个需求脑子里想的是一句话给我一个函数输入客户端IP输出国内或国外。听起来简单真动手才发现网站判断客户端地理位置这件事从来不是一个布尔值而是一条从网络层到应用层的证据链你得决定在哪一层做判断、用哪些信号、允许多大误差。先说清楚它常见的使用场景你才能判断自己的需求属于哪一类。第一种是内容分发类首页展示不同语言的版本、货币单位、推荐内容境内用户看到简体中文和人民币报价境外用户看到英文和美元。第二种是资源调度类静态资源从哪个边缘节点回源、视频走哪条链路这个决策通常在CDN或前置网关完成源站根本看不到。第三种是展示层适配客服入口、联系方式、配送范围提示。第四种是风控与统计登录地异常提醒、地区维度的数据报表。这四类场景对精度的要求完全不同。内容分发和统计误判率控制在百分之几以内就够用了因为用户能自己切换语言。而涉及账号安全和资金相关的判断单纯靠IP地理归属是远远不够的必须叠加设备指纹、行为特征、二次验证等手段。所以第一件要做的事是先跟需求方对齐这个判断结果错了最坏会发生什么。注意我见过太多项目在需求阶段就被写成精确识别用户所在国家最后被一个用了机房出口IP的用户打脸。先把这句话改成按网络出口归属做出合理推断后面的技术选型会顺畅很多。1.1 判断的是人、设备还是那条网络路径这里有个概念必须先掰开。网站能直接观测到的只有一条到服务器的网络路径具体来说就是TCP握手时那个来源IP地址。IP地址归属于某个运营商、某个网段这个网段被分配给了某个地区或某个机构——这是IP地理库给出的信息。所以严格讲网站判断的是这个客户端的流量出口在哪里而不是这个人站在哪里。一个人可以带着笔记本电脑跨国旅行可以用公司专线出口可以用手机蜂窝网络漫游。这些情况下出口IP的地理归属和他本人的物理位置会出现偏差。我通常把这三种口径分开表述网络出口归属IP库能答的、语言与区域偏好浏览器和系统设置能答的、账号注册地业务系统里存的。工程设计上最靠谱的做法是三者结合而不是指望任何一个单独给出正确答案。1.2 为什么这件事注定做不到百分之百原因有三层每一层都会吃掉一点精度。第一层是数据本身的滞后。IP地址段是不断被重新分配、拆分、合并的。一个新分配的网段从运营商投入使用到进入各大IP库中间可能有几周甚至几个月的窗口期。在这段时间里这个网段在你的库里可能是未知也可能是过期的旧归属。第二层是网络架构带来的模糊。同一个企业出口、同一栋写字楼、同一所学校几千上万人共享几个出口IP他们的实际位置和需求可能各不相同。云主机、容器平台、无头浏览器批量访问出口IP更是集中在少数几个机房段上看起来全在同一个城市。第三层是客户端侧的不确定性。浏览器语言可以手动改时区可以改甚至系统区域设置也可以改。这些信号单独看都不可信只有和IP归属交叉印证时才有价值。把这些讲透之后你会发现真正专业的做法不是追求一个正确答案而是设计一套多信号加权 允许用户覆盖 全程可回溯的判断机制。后面几节我会按这个思路从最核心的IP反查讲起一路讲到落地配置和踩坑排查。2. 主路一条拿来源IP去反查地理归属不管前面加了多少辅助信号IP反查永远是主力其他信号只是修正项。原因很实在IP是服务端在TCP握手阶段天然拿到的不需要客户端配合不能通过前端脚本被绕过成本也最低。下面就把这条主路的每个环节拆开讲。2.1 IP库的四种形态先想清楚你要哪一种市面上的IP地理数据源大致可以归成四类选型的时候按你的调用量、精度要求和维护能力来定。类型典型代表形态精度单次查询开销适合场景免费离线库二进制数据库文件mmdb格式国家级别够用城市级偏粗微秒级内存映射绝大多数中小网站首选免费文本库CSV格式的起止IP对照表国家级别尚可更新较慢加载内存后二分查找需要自己二次加工、做定制的场景商业离线库细分到城市、运营商、ASN明显更细微秒级精准调度、广告投放、风控在线查询接口HTTP接口返回JSON取决于服务商几毫秒到几十毫秒后台批处理、离线对账不适合每请求对绝大多数网站来说免费的二进制离线库就已经够用了。判断国内还是国外本质上只需要知道国家编码这个级别的需求用免费库完全能扛住。我做过一个日均千万级请求的服务用离线库全量走内存查询P99的额外耗时几乎测不出来。真要说免费库的短板主要是两类一是部分新分配或小运营商网段的覆盖不全会返回未知二是某些跨境使用频繁的网段归属标注可能和实际使用情况不符。这两类问题都可以通过未知时走兜底策略来化解而不是硬扛。2.2 一次查询在服务端真实发生的过程很多人以为查IP库是一次网络请求其实离线库的工作方式是纯本地计算。加载的时候把数据库文件映射进内存查询时把IP地址转换成一个整数然后在内部的树形结构里做一次前缀匹配。拿IPv4举例203.0.113.42会被转成32位整数库内部维护的其实是一棵前缀树或者说一组有序区间查找过程是对这32位逐段收敛。整个过程没有任何IO没有磁盘读取没有网络往返所以耗时在微秒量级。这就是为什么我一直推荐在应用启动时把库文件加载一次常驻内存而不是每次请求都去读文件。如果你用的是文本格式的库加载后自己建索引效果是一样的只是启动阶段会多花几百毫秒。import geoip2.database # 应用启动时初始化一次全局复用 reader geoip2.database.Reader(/data/geoip/GeoLite2-Country.mmdb) def lookup_country(ip: str) - str: try: resp reader.country(ip) # 未收录的网段会返回 None这里必须显式兜底 return resp.country.iso_code or UNKNOWN except Exception: return UNKNOWN注意country.iso_code返回None是完全正常的情况不是异常。库对未收录网段就是这么表达的。如果你的代码没处理None线上会零星出现类型错误而且很难复现。另外提醒一点不要忘记ASN查询。国家编码告诉你在哪个国家ASN告诉你这个IP属于哪家运营商或哪个云厂商。当你发现某个IP的国家判定异常时ASN往往能立刻解释原因——比如看到某云厂商的ASN就知道这是一个机房出口不能代表真实用户位置。2.3 IPv6批量上线之后新增的变量IPv6带来的坑比很多人预想的要多。首先是库里必须同时加载IPv6数据否则来自IPv6的客户端会全部落到未知如果兜底策略写得激进这些用户会被统一判成某一类量还不小。其次是双栈环境下的判定不一致。同一个用户走IPv4和走IPv6可能拿到两个不同的归属结果因为运营商的IPv6地址分配策略和IPv4并不完全对应。这在日志里表现为同一个账号的地区标记来回跳。处理办法是记录两个结果并标注来源业务侧只在两者一致时才做强制决策。第三是地址表示的规范问题。同一个IPv6地址有多种写法大小写、零压缩形式都可能不同。查询前必须做标准化否则缓存键会分散命中率掉得很难看。我一般统一转成小写并用标准库做一次压缩解析再拿去查询。3. 站前有CDN或网关时先拿到真身IP再谈判断现在几乎没有网站是裸奔在公网上的前面基本都会有一层CDN或者反向网关。这时候源站看到的来源IP是CDN节点的IP不是真实客户端。拿这个IP去查归属得到的是CDN节点所在地区整个判断从根上就错了。所以这一步必须先解决。3.1 X-Forwarded-For 那串IP该取哪一个标准做法是中间层在转发时把客户端IP追加到X-Forwarded-For头部形成一条链路记录。麻烦在于这个头部是客户端可以自己伪造的。如果用户手动构造一个X-Forwarded-For: 1.2.3.4而你的网关又无脑信任那判定结果就被完全控制了。正确的处理原则只有一句话只信任你自己配置的那几个中间层的IP段其他一律不信。具体操作上在网关层配置可信来源段让网关自己从右侧往左剥离可信IP剩下第一个不可信的地址就是真实客户端。有些团队图省事直接取最左边的地址这在有伪造头部的情况下会直接把风控打穿。我用Nginx的realip模块做过这个配置核心是明确声明哪些地址段是可信中间层。配置好之后$remote_addr变量会直接变成真实客户端IP后面的日志和判定逻辑都能统一用这一个变量不用在每个业务点重复解析。3.2 主流CDN直接给的国家标记头省事的做法是直接读CDN帮你算好的国家编码。主流CDN在边缘节点就已经完成了地理判定会通过响应头或者回源请求头把结果传下来常见的有几个某境外CDN服务会带上CF-IPCountry这样的两位国家码。某云厂商的CDN会带上自己命名的国家码头部。部分平台的边缘运行时会把国家码注入到请求上下文里供你在边缘脚本中直接读取。这类头部的好处是省掉了源站自己查库的开销而且判定发生在离用户最近的节点精度通常更好。代价是你被绑在了这家的生态里换CDN的时候判定逻辑要跟着改。我的习惯是双轨并行优先读CDN给的国家码读不到再用本地IP库兜底同时把两个结果都记到日志里。跑一段时间之后对比两者的差异率如果差异很小就继续用差异大就说明某一方的数据有问题这时候再去查具体是哪些网段在打架。3.3 Nginx 上的落地配置直接抄下面这段是我在多个项目里用过的配置骨架基于支持二进制数据库的模块配合map做一次结果归一化。# 加载国家级别数据库 geoip2 /data/geoip/GeoLite2-Country.mmdb { auto_reload 60m; $geoip2_country_code country iso_code; } # 把国家码归一到是否境内这个业务维度 map $geoip2_country_code $is_domestic { default 0; CN 1; 0; # 库未收录时统一走非境内分支再由业务侧兜底 } server { listen 443 ssl; server_name example.com; # 可信中间层地址段这段必须按你自己的网络拓扑填 set_real_ip_from 10.0.0.0/8; real_ip_header X-Forwarded-For; real_ip_recursive on; location / { add_header X-Region-Flag $is_domestic always; proxy_pass http://backend; } }这段配置里有三个细节值得单独说。auto_reload 60m让Nginx定期检查库文件是否更新配合后面讲的定时替换任务可以做到不重启服务就更新数据。map里显式写了空字符串的分支是为了防止库未收录时变量变成空值导致后续判断出错。real_ip_recursive on是让可信段剥离从右往左进行这一点在多层转发时非常关键。注意set_real_ip_from千万不要写成0.0.0.0/0。这等于告诉Nginx信任所有来源的转发头任何人都能伪造。我接手过一个项目就是这个配置日志里一半以上的IP是假的排查了两天才发现问题出在这一行。4. 辅助信号语言、时区、解析与延迟IP反查是主力但它有明确的失效场景。这时候就需要辅助信号来修正或者给一个置信度。下面这三个信号成本低、收益高值得都接上。4.1 浏览器语言与服务端 Accept-Language客户端每次请求都会带上Accept-Language头部这是最容易被利用的信号之一。它的优势是零成本服务端直接读就有。劣势也很明显用户可以手动改企业统一装机的浏览器可能被批量设置成同一语言。我的用法不是拿它做最终判定而是做一致性校验。如果IP归属显示境内但语言首选项是某个境外语言这个请求的置信度就打一个折扣可以走展示通用版本 提供切换入口的中间态而不是硬判。解析这个头部的时候注意两点。一是它可能带权重值形如zh-CN;q0.9要按权重排序后再取首位不能简单取第一个逗号前的内容。二是它有长度上限某些客户端会塞进非常长的列表解析前建议做长度截断避免无害的头部撑爆日志。4.2 时区是最容易被忽略的高价值信号浏览器提供的时区信息准确性远高于语言设置因为大多数人装完系统后根本不会去改时区。前端一行代码就能拿到const tz Intl.DateTimeFormat().resolvedOptions().timeZone; // 例如 Asia/Shanghai、Europe/Berlin const offsetMinutes new Date().getTimezoneOffset();拿到时区标识后和IP归属做交叉验证。我一般的处理逻辑是两者一致时提高置信度两者冲突时降级到中间态而不是互相覆盖。这样做的实际效果是误判率能从单纯靠IP的几个百分点降到千分位级别。时区还有个额外好处它能识别出一些IP判定不了的情况。比如用户用的是公司统一出口出口IP在某个机房但时区能反映出他实际所在的区域。这在做内容推荐时比IP更有参考价值。需要注意时区上报必须走服务端接口或者随请求头部传递不能只存在前端变量里。因为你的页面很可能是被CDN缓存的缓存住的HTML不能携带用户专属信息。4.3 DNS解析视角与RTT粗测再往深一层还有两个偏工程化的信号。一个是权威解析的视角判定。如果你的域名解析服务支持按来源网段返回不同结果可以准备两个不同的子域分别解析到不同的地址然后看客户端最终连上了哪一个。这个方法不需要客户端任何配合但它需要你有解析层的控制权而且判断结果需要回传链路较长。另一个是响应延迟粗测。在页面上记录一个请求的首字节耗时上报到服务端和服务端配置的多个探测点数据做比对能粗略推算出客户端到各地机房的网络距离。这个方法的精度取决于探测点密度成本较高一般只在调度系统里用普通的语言切换场景没必要上。这两个信号我都只在特定项目里用过日常的内容分发选前三个信号就够了。技术选型的原则始终是先用最便宜的信号把绝大多数请求分对再给剩下的模糊请求设计好兜底路径。5. 工程落地把判断结果变成可切换、可回溯的能力信号选好了配置写完了接下来是把这套东西变成一个真正能上线跑的系统。这一节讲三个必须解决的问题。5.1 结果缓存与用户手动选择权每次请求都跑一遍完整的多信号判断虽然单次开销不大但叠加起来并不划算而且结果是稳定的——同一个用户短时间内不太可能换国家。我的做法是判定一次用Cookie记住同时允许手动覆盖。首次访问时跑完整判断把结果写进一个带签名或者带校验位的Cookie有效期设成一到七天。后续请求直接读Cookie不再重复计算。手动切换时更新这个Cookie并在服务端记一个标记表示这个结果来自用户选择而非自动推断。这个设计最关键的收益不是省算力而是避免了判定结果的抖动。同一个用户在不同时间用不同网络出口IP可能变如果不记住手动选择用户会看到页面语言反复横跳体验很差。// 前端读取判定结果并渲染未命中时回退到通用版本 fetch(/api/region) .then(r r.json()) .then(data { render(data.locale, data.currency, data.source); }) .catch(() render(default, USD, fallback));注意data.source这个字段要返回标明结果来自自动判断还是用户手动选择。这个信息在排查投诉的时候非常有用能一下子区分是判断错了还是用户自己切过。5.2 缓存分层与缓存键设计如果你的页面走CDN缓存地区差异会带来一个典型问题缓存把某个地区的版本发给了所有人。这是实际项目里最常见的判定正确但展示错误的原因。解决办法有两个方向。一是把地区相关的内容从HTML里剥离出来改成前端异步接口获取HTML本身保持地区无关这类接口不缓存或者按Cookie分区缓存。二是如果非要缓存带地区差异的页面就必须把地区维度加进缓存键同时加上Vary头部声明。我倾向于第一种。把地区差异放到接口里页面缓存策略可以做得非常简单粗暴缓存命中率高运维复杂度低。代价是多一次请求但这部分数据可以随页面一起内联在首屏或者做成极小的JSON实际影响很小。5.3 打点与灰度验证上线之前必须准备好打点否则出了问题你连从哪查都不知道。我一般会记录这几个字段来源IP脱敏后、判定结果、判定来源CDN头部还是本地库、辅助信号值、最终展示版本。有了这些数据你可以做两件很有价值的事。一是抽样比对随机抽一部分请求同时用两个不同的库跑一遍看差异率。差异率突然变大就是某个库的数据出了批次问题。二是灰度切换新的判断逻辑先放10%的流量观察各项指标稳定后再逐步放量。注意打点数据里的IP地址属于需要谨慎处理的用户信息存之前做脱敏只保留判断所需的前缀部分同时设定明确的留存期限别让日志表无限膨胀。6. 常见误判与排查技巧实录6.1 高频误判场景速查表下面这张表是我这几年一条条攒下来的都是线上真实出现过的问题。现象常见原因快速验证方法处理思路判定结果与用户实际所在地区不符客户端走的是企业或机房出口查该IP的ASN看是否属于云厂商或IDC机房段降级处理走中间态版本大批用户集中判成同一个地区判定取到了CDN节点IP而非真实IP打印原始来源IP和解析后的IP对比修正可信段配置开启递归剥离部分用户判成未知IP库未收录该网段或只加载了IPv4数据统计未知结果里IPv6的占比补全IPv6库完善兜底策略同一用户地区频繁跳变双栈环境下IPv4与IPv6归属不一致分别记录两种协议的判定结果不一致时固定为上次结果并加长Cookie页面语言和判定结果不一致缓存命中了其他地区的版本检查缓存键和Vary头部地区内容改走接口或补充缓存键维度判定结果被轻易伪造无条件信任了客户端传来的转发头手动构造转发头测试只信任自有中间层网段新购入的IP段识别异常库更新滞后查库文件的生成日期缩短更新周期或临时加白名单服务启动后内存占用陡增同时加载了城市级和ASN级的大库看进程内存曲线和加载的库文件大小按需加载只加载实际用到的维度6.2 一套可复用的排查动作遇到误判投诉我一般按这个顺序走基本能在几分钟内定位。第一步拿到用户的出口IP直接去库里查一次。这一步能区分是库的数据错了还是代码逻辑错了。如果单独查库的结果是对的说明问题出在取IP或者判定流程上。第二步看日志里的原始头部和最终解析出的IP。这一步专门抓取IP环节的问题。如果原始头部里客户端IP在第一位但被解析成了别的东西就是可信段配置的问题。第三步核对CDN给的国家码和本地库的结果。两者冲突时先看CDN给的头部是不是被上游改写过再看本地库是不是过期了。第四步检查缓存。如果前面三步都正常但用户看到的还是错的那基本就是缓存键没有覆盖地区维度命中了别人的版本。第五步用curl带上不同的请求头复现这是最后的手段也是最有效的验证方式。# 直接指定来源IP测试判定逻辑 curl -H X-Forwarded-For: 203.0.113.42 -I https://example.com/ # 查看响应头里的地区标记 curl -sI https://example.com/ | grep -i X-Region # 对比不同IP段的判定结果 for ip in 203.0.113.42 198.51.100.7 2001:db8::1; do echo -n $ip - curl -s -H X-Forwarded-For: $ip https://example.com/api/region echo done这套流程走下来绝大多数问题都能定位到具体环节。真正难查的是那种只出现在少量用户身上、无法复现的问题这类通常和特定运营商的网段策略有关我的经验是多收集样本再找共性别盯着单个案例死磕。7. 性能、成本与更新策略上的取舍7.1 离线库和在线API怎么分工这两个东西不是二选一的关系而是分工关系。离线库负责线上实时判定因为它没有网络开销不会因为对方的接口抖动而影响你的可用性。这一点很重要判定逻辑一旦依赖外部接口你的接口可用性就等于两者相乘。在线API负责离线场景比如历史日志的批量回填、数据报表的地区维度补全、对账。这类任务对延迟不敏感对精度要求相对高用在线服务反而更合适因为它们在数据更新上通常更及时。如果你确实需要在线上用在线API务必加两层保护本地缓存加超时降级。缓存把热点IP的结果记住超时降级保证对方接口挂了的时候你的判定流程不会跟着挂而是落到本地库或者未知分支。7.2 更新与文件替换的细节IP库需要定期更新这是绕不过去的运维工作。免费库一般每周更新我的做法是用定时任务拉取新文件落到一个临时目录校验文件完整性和格式然后原子替换。原子替换的意思是先把文件放在同目录的其他名字下再用一次重命名操作切过去这样不会出现读到半个文件的窗口期。配合Nginx的auto_reload替换之后不需要重启服务运行中的进程会自己重新加载。应用层如果也加载了库需要自己在代码里做定期重载或者干脆把查询集中到网关层让应用只读网关传下来的结果。注意替换之后一定要验证。我踩过一次坑拉取到的文件其实是错误页面而不是数据库内容程序加载时报错但被异常捕获吞掉了结果是所有查询都走兜底分支判定了三天才被发现。现在我的流程里加了强制校验加载后立刻查一个固定IP比对预期结果不一致就回滚。最后再说一点经验。这套机制上线之后别指望一次就调到位。真实的流量构成比你想象的复杂得多机房出口、移动网络、专线、公共网络各种情况在线上都会冒出来。建议一开始就把判定结果和最终展示版本都记下来跑上两周再看看哪些分支的流量占比超出预期你会发现不少需要调整的地方。我做过的一个项目上线第一周就发现未知分支扛了接近八个百分点的流量追下去是运营商新分配的一批网段还没进库把更新周期从每周缩到每三天之后这个比例掉到了千分位以下。
返回列表