凌晨两点多,某数据录入系统的值班同事发来截图:录入队列里突然多出四百多条记录,提交时间集中在十几分钟内,字段格式合规得过分,连错别字都没有。人工复核发现,这些记录来自同一个出口 IP 段,分布在三个相隔上万公里的地区,而账号注册时间全在当天下午。这不是第一次了。后来我们把整个录入链路重新梳理了一遍,把 IP 地理位置数据嵌进风控和界面渲染两个环节,问题才算压住。
这篇内容聊的就是这件事:IP 地理位置数据怎么用在站点的安全防护和内容自定义上,以及像 ipdata 这类地理位置数据服务在真实系统里应该摆在什么位置。适合正在做后台系统、数据采集平台、跨境业务站点的开发者和产品同学看,也适合已经把接口调通、但还没想清楚"拿到这些字段之后到底干什么"的人。全文不讲空概念,只讲我们踩过的路。
1. 标题里那个"数据录入软件"的真实诉求
先把场景说清楚,不然讨论地理位置数据很容易飘。所谓"数据录入软件",在多数团队里不是一个孤立的小工具,而是一套带权限、带审核、带统计的后台系统:录入员分区域作业,审核员按批次处理,运营看报表。这类系统天然对"人从哪来"敏感,因为它的核心资产就是数据本身的干净程度。
1.1 录入系统最怕的三类脏数据来源
第一类是脚本灌入。有人写个循环,把伪造或半伪造的记录批量塞进来,目的是刷量、刷任务、套激励。这类请求的特征是节奏均匀、字段高度规整、出口 IP 集中。
第二类是账号共享。一个录入账号被几个人甚至跨地区轮流使用,工时统计全乱,绩效算不准,出问题还追不到人。这类行为在 IP 维度上表现为"同一账号在极短时间内出现在地理上不可能同时到达的两个位置"。
第三类是区域越权。某些录入任务按市场划分,A 区的录入员不该看到 B 区客户的信息。传统做法靠账号权限表管,但权限表管不住"人把自己的账号借出去"。
这三类问题有一个共同点:它们都不是靠字段校验能解决的。字段可以伪造,节奏可以模仿,只有"请求来自哪里"这个信号相对难以低成本伪造。这就是地理位置数据在录入系统里最朴素的切入点。
1.2 地理位置数据在链路上的四个落点
我们把地理位置判断放在了四个位置,每个位置的目标完全不同:
| 落点 | 触发时机 | 主要用途 | 失败时的处理 |
|---|---|---|---|
| 登录鉴权 | 账号登录时 | 判断是否为异常地域登录,触发二次验证 | 放行但标记风险分 |
| 写入接口 | 提交记录时 | 机房出口 IP、跨区提交拦截 | 进入人工审核队列 |
| 界面渲染 | 首屏输出时 | 语言、时区、日期与数字格式、货币单位 | 回落到站点默认区域 |
| 报表统计 | 离线批处理 | 按来源地区聚合,看异常分布 | 归入"未知来源"桶 |
四个落点对实时性的要求不一样。登录和写入是同步链路,多几十毫秒用户能感知,所以必须带缓存和超时兜底;渲染是半同步,可以接受一次带缓存的外呼;报表是离线,怎么查都行,甚至可以批量补历史。
提醒一句:不要一上来就在所有落点都调实时接口。地理位置数据的价值密度在"少数关键判断"上,铺得太开会把接口费用和延迟一起拉高,收益却很有限。
1.3 为什么不在前端做这件事
有人会问,浏览器里不是能拿到时区、语言吗,前端算一下不就行了。不行,原因有三个。
一是前端拿到的都可以被改。时区、语言、navigator系列字段在调试工具里随便改,拿它做安全判断等于没有判断。
二是前端拿到的是"设备偏好",不是"网络位置"。用户在系统设置里把语言设成英文,不代表他在英文市场;用户挂着公司内网出口,时区可能是总部时区。
三是前端算出来的结论要传回服务端,服务端还得再信一次,中间多了一个可以被篡改的环节。
地理位置判断必须落在服务端,前端只负责根据服务端下发的"区域标识"切换文案和格式。这个分工要一开始就定死,否则后面会陷入"到底以谁为准"的扯皮。
2. IP 地理位置数据能给出什么,以及给不了什么
第二个要讲清楚的是能力边界。我见过不少团队把地理位置接口当成"精准定位",写进需求文档时写的是"获取用户所在城市",上线后发现城市命中率远低于预期,然后开始怀疑服务商不行。问题往往不在服务商,而在需求本身写错了。
2.1 字段清单与各自的业务含义
以 ipdata 这类服务为例,一次查询通常能返回下列几组信息。我把它们按"可信度"和"业务用途"分开列:
| 字段分组 | 典型内容 | 可信度 | 主要用途 |
|---|---|---|---|
| 国家/地区 | 两位国家码、国家名 | 高 | 合规提示、站点默认区域、语言兜底 |
| 省/州、城市 | 行政区名称与编码 | 中 | 报表聚合、区域任务分配 |
| 经纬度 | 浮点坐标 | 低到中 | 距离估算、地图展示 |
| 时区 | IANA 时区名 | 中高 | 时间显示、营业时间判断 |
| 网络属性 | ASN、运营商、连接类型 | 高 | 机房 IP 识别、风控打分 |
| 类型标记 | 是否为数据中心、是否为企业网络 | 中高 | 风控打分 |
| 货币与语言 | 货币码、语言码 | 中 | 界面本地化兜底 |
注意最后两行的定位:它们是兜底值,不是权威值。用户所在国家的默认货币不等于用户想用的货币,跨境业务尤其如此。我们最终的做法是"地理位置给默认值,用户设置覆盖默认值,两者都记下来",报表里能看出偏差率。
真正高价值的其实是网络属性那一组。判断一个 IP 是不是数据中心出口,比判断它在哪个城市有用得多。因为绝大多数批量灌数据的请求都来自机房,而正常录入员的请求来自家庭宽带或移动网络。这个信号的区分度,比经纬度高出几个量级。
2.2 精度是分层的,别用一个指标概括全部
行业内常见的说法是"国家级准确率很高,城市级中等,坐标级最低"。这句话是对的,但落到工程上要拆得更细:
- 国家级:绝大多数场景可用。跨境判断、合规提示、语言兜底都可以基于它。
- 省级:可用性尚可,适合做报表聚合,不适合做"是否越权"的硬判断。
- 城市级:在人口密集、运营商基础设施建设完善的地区表现较好;在网络结构分散的地区会明显下降。
- 坐标级:只能当参考。常见偏差来源包括运营商把整个省份的出口集中在省会、移动网络的出口池跨市共享、企业专线统一从总部出口。
我们把"城市"字段用在两个地方:报表的地理分布图,和"距离上一次登录位置超过 N 公里"的软提示。绝不用它做封禁依据。
2.3 三条必须写进设计文档的误差假设
上线前我们把下面三条写进了技术方案,后面每次争论都回来看这三条:
- 移动网络不可靠假设。移动网络的出口 IP 会在一个较大的池子里漂移,同一个用户在通勤路上可能被判断在相邻两三个城市之间跳。任何基于城市的硬规则,都必须对移动网络类型放宽阈值。
- 企业出口集中假设。一个几十人的团队共用一条专线,所有人在 IP 维度上长得一模一样。如果规则是"同 IP 多账号判定为异常",第一天就会把整个团队误伤。所以规则必须是"同 IP 多账号 + 时间窗口 + 字段相似度"的复合条件。
- IPv6 覆盖不全假设。IPv6 的地理数据覆盖度通常不如 IPv4,某些段甚至只能给到国家。系统必须能接受"只有国家、没有城市"这种稀疏结果,而不是当成错误处理。
这三条假设后来救了我们两次。一次是移动录入员集体被误判,一次是 IPv6 用户大面积"城市未知"。如果没有提前约定,这两次都会被当成线上故障处理,实际只是数据本身的特性。
3. 站点防护:把地理信号变成可执行的风控规则
拿到字段只是开始,真正难的是"怎么用"。我见过两种极端:一种是拿到数据什么都不敢做,怕误伤;另一种是上来就写死规则,country != 'XX'直接拒绝,第二天客服被打爆。中间那条路是打分,不是判断。
3.1 从布尔判断到风险打分
布尔判断的问题在于它只有一个比特的信息量,无法表达"有点可疑"和"非常可疑"的区别。改成打分之后,每条规则贡献一个分数,总分超过阈值才触发动作,动作本身也分等级:记录、二次验证、进人工队列、临时限制。
我们用的权重表大致如下(分数是相对值,不是绝对值,各团队应该按自己的误判成本调):
| 信号 | 分值 | 说明 |
|---|---|---|
| 出口 IP 被标记为数据中心 | +30 | 录入场景下这是最强的单一信号 |
| 同一账号 24 小时内跨越多个国家 | +25 | 需要排除移动网络与企业专线 |
| 同 IP 关联账号数超过 5 | +20 | 单独看容易误伤,配合其他信号 |
| IP 时区与浏览器时区相差超过 3 小时 | +15 | 常见于手工修改时区 |
| IP 国家与账号注册国家不一致 | +10 | 跨境办公很常见,权重必须低 |
| 提交频率处于历史高位区间 | +15 | 与 IP 无直接关系,但需要一起看 |
阈值我们设了两档:低于 40 分只记录不打扰;40 到 70 分之间触发二次验证(短信或邮箱确认);高于 70 分进人工审核队列,同时对该账号的写入做限速。三个月跑下来,人工审核队列里真正有问题的比例从最初的不到两成提升到了六成以上,误伤投诉下降了绝大部分。
3.2 五条可以直接抄的规则写法
下面这几条是我们实际在用、并且调过参数的规则,写法上尽量做到"条件明确、可解释、能回滚":
规则一:机房出口写入敏感表。如果请求的出口 IP 被标记为数据中心,且目标接口属于核心数据表,直接进人工队列,不做拦截。理由是不拦截就不会误伤真实用户,而人工队列的成本远低于数据被污染的成本。
规则二:不可能的地理跳跃。同一账号两次相邻请求,地理位置距离超过 800 公里且时间间隔小于 2 小时,标记为高风险。这里的 800 公里是拍出来的,实测下来能过滤掉跨区共享账号的大部分情况,同时不误伤同城和邻近城市通勤。
规则三:时区与地理位置错位。IP 所属时区与浏览器上报时区相差超过 3 小时,记一次轻量风险分。这一条单独看不强,但和"账号注册不满 7 天"叠加时命中率很高。
规则四:同 IP 并发写入。同一出口 IP 在 5 分钟内对同一张表写入超过 30 条,触发限速,返回可重试的状态码而不是拒绝。限速比封禁温和,脚本会自己卡住,真人几乎无感。
规则五:注册地与常用地分离。账号长期在 A 地区使用,突然从 B 地区提交核心数据,且 B 地区从无历史记录,触发二次验证。这条规则需要维护一个"常用地区"的滚动统计,实现上不复杂,一张按账号聚合的日表就够。
3.3 误判的成本必须有出口
任何基于地理位置的风控都会误判,区别只在于你有没有为误判准备出口。我们踩过的误判场景包括:跨国公司员工出差、海外留学生暑期回国、运营商临时调整出口路由、用户自己开了加速类工具导致出口变化。
对应的处理是:所有触发风控的动作都必须给出可解释的提示和可自助解除的路径。二次验证是最低成本的方式;人工审核要给出 24 小时内响应的承诺;被限制的账号要有明确的申诉入口,而不是只显示一个 403。
有个细节值得单独提:提示文案不要写"检测到您所在地区异常",这句话会让人一头雾水。写成"您的账号近期在多个地区登录,为保障数据安全,请完成一次验证",完成率会高很多。风控的成败有一半在文案上。
4. 内容自定义:让同一个页面在不同市场长出不同样子
防护是防守面的用法,自定义是进攻面的用法。同一套地理位置数据,换个用法就能明显提升录入效率,因为它减少的是"每次都要手动改"的摩擦。
4.1 本地化不是翻译,是格式对齐
很多团队理解的本地化是把文案翻成另一种语言,实际用起来最影响体验的往往是格式。我们按优先级排了一下,效果从高到低:
- 日期与时间格式。
2024/03/05和03/05/2024混在一起,录入员每天都要多花几秒确认。统一按 IP 所属地区输出,出错率下降非常明显。 - 数字分隔符与小数点。逗号和小数点的用法在不同地区是反的,录入金额字段时这是真实的错误来源。
- 货币单位与符号位置。符号在前在后、要不要空格,这些细节在跨境结算场景里会引发对账纠纷。
- 计量单位。长度、重量、温度,能换算的尽量换算,不能换算的在字段旁边标注原单位。
- 语言。放在最后不是因为它不重要,而是因为它最容易被用户设置覆盖,地理位置只是兜底。
实现上,我们把"区域标识"(一个短字符串,比如zh-CN、en-US)作为唯一入参传给前端,前端用标准的国际化库格式化,绝不在业务代码里手写格式化逻辑。服务端只负责决定这个标识是什么,以及用户手动选择时怎么覆盖。
4.2 首屏渲染里做地理判断的代价
最直接的做法是在服务端渲染首屏之前调一次地理位置接口,拿到结果再输出。问题是这个调用是同步的,接口慢 300 毫秒,首屏就慢 300 毫秒,用户感知非常明显。
我们后来改成了三层策略:
- 第一层:会话内固定。用户登录后第一次请求算出区域标识,写进会话,后续请求直接读,不再调接口。这样绝大多数请求是零成本。
- 第二层:IP 段缓存。以
/24(IPv4)或/48(IPv6)为粒度缓存查询结果,TTL 设 24 小时。同一个办公网络里的几十个录入员共享一条缓存,命中率很高。 - 第三层:异步预热。登录成功后异步触发一次查询,把结果写入会话,首屏不等它。如果首屏时缓存未命中,先用浏览器的
Accept-Language兜底渲染,等异步结果回来再补一次局部刷新。
三层叠加之后,首屏因为地理位置判断增加的平均耗时降到了个位数毫秒。
4.3 缓存与个性化的冲突怎么解
这里有个经典矛盾:内容个性化要求每个用户看到的可能不同,CDN 缓存要求同一 URL 返回同一份内容。两者直接冲突,处理不好会出现"北京用户看到英文界面"这种事故。
我们试过Vary头,效果不好,因为Vary的维度是请求头,而地理位置不是请求头,边缘节点无法据此分桶。最终方案是用区域标识进缓存键:把区域标识作为 URL 的一部分或者作为缓存键的一维,让不同区域落到不同的缓存对象上。
代价是缓存对象数量乘以区域数,命中率会下降。所以区域粒度不能太细,我们只用了"大区"级别(几个合并后的区域),而不是城市级别。语言、货币这些细节仍然在前端按用户设置覆盖,两套机制并存。
有个坑要提前说:做缓存分桶之后,务必在测试环境验证"同一用户切换区域后是否拿到了正确的内容"。我们曾经因为缓存键里漏了一个字段,导致切换区域的用户在半小时内看到旧区域的内容,排查了很久才定位到缓存层。
5. 接入实现:从一次调用到长期运行
接口调通只要十分钟,稳定运行一年是另一回事。这一节讲的是工程侧真正花时间的部分。
5.1 请求层的超时、重试与并发
地理位置查询是外部依赖,必须假设它随时会慢、会挂、会返回格式不对的数据。我们的封装做了这几件事:
- 超时:连接超时 100 毫秒,读取超时 200 毫秒。超过就走降级,绝不阻塞主流程。
- 重试:只对网络错误和 5xx 重试一次,不重试 4xx。重试要带抖动,避免同一时刻大量请求同时打过去。
- 并发限制:给这个依赖单独一个信号量,限制最大并发,防止它拖垮整个线程池。
- 结果校验:返回的字段做类型和范围校验,国家码必须是两位字母,经纬度必须在合法区间,校验不过当失败处理。
import time import random import requests GEO_TIMEOUT = (0.1, 0.2) # 连接 100ms,读取 200ms GEO_MAX_RETRY = 1 def fetch_geo(ip: str, cache): hit = cache.get(ip) if hit is not None: return hit last_err = None for attempt in range(GEO_MAX_RETRY + 1): try: resp = requests.get( f"https://api.ipdata.co/{ip}", params={"api-key": GEO_KEY}, timeout=GEO_TIMEOUT, ) if resp.status_code >= 500: raise RuntimeError(f"upstream {resp.status_code}") if resp.status_code != 200: return None # 4xx 不重试,直接降级 data = resp.json() result = { "country": data.get("country_code"), "region": data.get("region"), "city": data.get("city"), "timezone": data.get("time_zone", {}).get("name"), "asn": data.get("asn", {}).get("asn"), "is_dc": bool(data.get("threat", {}).get("is_datacenter")), } if not _valid(result): return None cache.set(ip, result, ttl=86400) return result except Exception as exc: last_err = exc if attempt < GEO_MAX_RETRY: time.sleep(random.uniform(0.02, 0.08)) # 抖动 return None上面这段是骨架,真正上线还要加监控埋点:调用耗时分布、失败率、降级触发次数。这三个指标任何一项异常,都意味着你的风控和本地化正在退化成"默认值"。
5.2 缓存层怎么选:本地还是共享
本地缓存(进程内 LRU)的特点是快、零网络开销,缺点是每个实例各存一份,命中率被实例数稀释,而且实例重启后缓存全空。
共享缓存(集中式键值存储)的特点是命中率高、结果一致,缺点是多一次网络往返,大概几毫秒。
我们的选择是两级都用:本地 LRU 挡在前面,容量设几千条,TTL 设 1 小时;共享缓存放在后面,TTL 设 24 小时。查询顺序是本地、共享、接口。这样常见 IP 几乎不产生外部调用,同时保证不同实例看到的结果一致。
需要提醒的是缓存粒度。我们最初按完整 IP 缓存,结果发现同一办公网络的几十个 IP 各存一份,浪费严重。改成按网段聚合后,缓存对象数量下降了一个数量级,而精度损失在业务上完全可接受——同一个/24网段落在同一个城市的概率非常高。
5.3 降级:拿不到数据时系统怎么办
降级方案要在写代码之前定,不能等出事再想。我们的规则是:
- 防护链路降级为只记录不判定。风险分缺少地理维度,就按其他信号算,宁可漏,不可误伤。
- 渲染链路降级为站点默认区域,同时前端读
Accept-Language做二次兜底。 - 统计链路降级为归入未知桶,离线任务在数据恢复后重新跑一遍补上。
还有一条容易被忽略:降级必须可观测。如果接口静默失败了三天,业务方却完全不知道,那和没做监控没区别。我们给降级次数设了告警阈值,日降级率超过 1% 就发告警。
6. 踩过才知道的六个坑
这一节全是教训,没有理论。每一条都对应一次真实的排查过程。
6.1 取到的 IP 不是用户的 IP
最早的实现里,我们直接从请求对象读客户端地址,上线后发现所有用户都被判断成同一个位置。原因很简单:请求前面有转发层和 CDN 边缘节点,代码读到的是边缘节点的地址。
解决办法是按X-Forwarded-For从左到右找第一个可信的地址,或者依赖边缘节点注入的专用头。关键是信任链要明确:只有你自己控制的那几层注入的头才能信,用户自己传进来的头一律不信。我们后来固定成"从右往左跳过已知的可信层,取第一个非可信地址",这个逻辑写成了一个公共函数,全站统一调用。
排查那次问题的方法也值得记一下:把判断结果和用户反馈的位置做对比,发现全部集中在几个数据中心所在的城市,基本就能确定是取错了 IP 而不是数据源不准。
6.2 机房 IP 标记不等于恶意
我们把"数据中心 IP"当作强信号用了两周,然后收到一批投诉:某公司的整个团队无法提交数据。查下来,他们用的是云桌面办公,出口 IP 天然就是机房地址。
这件事的教训是:网络类型标记描述的是网络,不是人。它只能作为一个加分项,不能作为独立的拦截条件。调整之后的规则变成了"机房 IP + 新账号 + 高频提交"三者同时满足才触发限制,误伤立刻消失。
6.3 时区字段和地理位置字段会打架
同一个 IP,返回的国家可能是 A,时区可能是 B。这在地理边界附近、或者运营商路由特殊配置的情况下很常见。我们最初的代码里,两个字段被用在了不同的判断里,导致同一个用户在两个功能里的行为不一致,排查了半天才发现是数据本身内部不一致。
处理原则是定一个主字段,其他字段只做补充。我们定的是国家码为主,时区只在"渲染时间格式"这一件事上使用,不参与风控。凡是参与风控的字段,都要在文档里标清楚优先级和冲突时的取舍。
6.4 坐标不能直接算距离
"不可能的地理跳跃"规则用的是距离,而距离是从经纬度算的。最初我们用直线距离,结果在跨越特定地理形态的场景里误差很大。后来改用了考虑地球曲率的公式(Haversine),再后来干脆把距离阈值放宽了一倍,因为这条规则的目的不是精确测量,而是"粗筛"。
顺带说一句,经纬度在城市级精度下的偏差可能有几十公里,用它算出来的距离本身就不精确。所以阈值不要卡太死,800 公里和 900 公里在实际效果上没什么区别。
6.5 数据新鲜度会被忽略
IP 归属会变,运营商重组网段、企业更换专线、地址池重新分配,这些都会让缓存里的旧数据失效。我们最初的缓存 TTL 设了 7 天,结果某次运营商调整之后,一批用户连续几天被判断在错误的位置。
现在按用途分开设 TTL:用于渲染的区域标识可以长一些(24 小时到 7 天都行),用于风控的网络类型标记短一些(建议不超过 24 小时)。如果数据源提供变更通知或者支持批量刷新,那就更好,定期把高频 IP 段的缓存主动失效一遍。
6.6 合规提示不能靠猜
有些市场对数据的存储位置、展示内容有明确要求。我们最初的实现里,合规提示是按国家码硬编码的,新增一个市场就要改一次代码,而且很容易漏。后来改成配置驱动:区域标识到提示内容的映射放在配置中心,运营可以自己维护,代码只负责查表。
还有一个细节:合规提示的展示时机。放在首屏会打断录入流程,放在提交前又会显得突兀。我们的做法是放在"进入某个特定功能区"的时候,一次性展示,用户确认后记住选择,不再重复弹。
7. 一点点个人体会
这套东西从最开始的"调个接口试试",到最后变成录入链路里绕不开的一环,中间改了大概四五版。回头看,真正有价值的经验不在于用了哪个数据源、字段有多全,而在于三件事:
一是明确边界。地理位置数据回答的是"这个请求大概从哪来",不是"这个人是谁"。所有超出这个边界的期待,最后都会变成线上问题。
二是永远给误判留出口。风控规则写得再精细,也会有真实用户被拦下来。有没有二次验证、有没有申诉入口、文案写得清不清楚,这些"非技术"的部分决定了这套系统能不能长期跑下去。
三是降级方案要比主流程先写。外部依赖不可用是常态,不是异常。先想清楚"拿不到数据时系统怎么活",再想"拿到数据时怎么用得更好",顺序反了会返工。
最后再分享一个我觉得挺实用的小做法:给地理位置相关的判断都打上统一的日志标签,字段包括原始 IP、解析结果、命中规则、最终得分、采取的动作。线上出现任何争议,把这条日志捞出来就能复盘,不用靠猜。这套日志后来还意外成了产品侧做区域分析的数据源,算是顺手捡的。