1. 为什么要在自己的项目里养一份 IP 地理数据
做数据录入软件这行久了,会发现一个规律:真正让人头疼的从来不是“录入”这个动作本身,而是“录进来的数据准不准、用不用得上”。我这个项目的起点很朴素——标题里那套 IP 地理数据,市面上有现成的接口,也有现成的库,但每次要接进自己的系统,就得重新做一遍清洗、校验、落库、建索引,做完还得担心数据过期。于是我把这套动作固化成一个小工具:把 IP 地理数据批量录入本地库,再用这份数据去保护站点、去自定义站点。它解决的是“IP 归属地数据从哪来、怎么存、怎么查得快、怎么用得稳”这一整条链路的问题。
适合谁看?如果你在做后台管理系统、内容站、电商站、API 网关,或者你手里有一批访问日志需要做地域维度分析,这套东西基本可以直接抄。前端同学可以只看第 5 章,运维同学重点看第 3、4 章,做数据管道的同学从头看。我不会假设你有很强的网络底层知识,但我会假设你写过 SQL、跑过 Python 脚本,知道 IP 地址长什么样。
1.1 一个真实的需求场景:站点被刷量之后的补救
某公司的内容站有段时间被批量请求打得很惨,日志里同一批 IP 段几分钟内换着路径扫,注册接口被灌了几千条垃圾数据。最初的反应是加验证码,结果正常用户的转化率掉了三成;后来加频率限制,又被分布式的请求绕过去了。真正让局面缓下来的一步,是把访问日志里的 IP 拿出来做地域和网段维度的聚合——发现有相当比例的请求集中在少数几个数据中心网段,归属地显示的城市跟站点实际用户分布完全对不上。按这个特征做了一层过滤,垃圾请求的量级立刻降下来,而正常用户几乎没有感知。
这件事给我的启发是:IP 地理数据不是拿来“展示”的,是拿来“做判断”的。展示只是最表层的一步,比如页脚显示“来自某地”。真正有价值的是把它变成一条条可以执行的规则:这个网段属于机房还是家庭宽带?这个地区的访问频次是否异常?这个用户所在时区该给他看哪个版本的时间戳?这些判断的前提,都是你手里有一份结构规整、可以随手查询的 IP 地理库。
而这份库从哪来?这就是录入软件要干的事。它不是一个炫技的项目,本质是一条数据流水线:拉取、清洗、校验、入库、建索引、切换版本。听起来枯燥,但每一步都有坑,坑的位置还特别固定。
1.2 整体架构:录入软件在链路中的位置
我把整个系统拆成四层,从下往上说。
数据源层负责提供原始 IP 地理数据,常见形态有三种:按网段切分的 CSV、二进制格式的库文件、以及按单个 IP 查询的在线接口。三者的精度、更新频率、体积差别很大,后面 1.3 会细说。
录入层就是我说的这个数据录入软件,它做五件事:格式识别、字段映射、合法性校验、去重合并、分批落库。这一层是整条链路里最容易被低估的部分,因为脏数据几乎必然存在——国家名写成两种语言、城市名带不可见字符、网段起止写反、IPv6 混进 IPv4 表里。不在这里拦住,后面查询层就得天天背锅。
存储层是自建的数据库,我选了“整数区间 + 二分查找”的模型,理由在 2.1 展开。这个模型的好处是查询极快、改造灵活,坏处是需要自己维护索引和版本。
应用层分两条支线:一条通往“保护站点”,也就是风控规则引擎;另一条通往“自定义站点”,也就是多语言、多币种、时区、内容差异化。两条支线共用同一个查询接口,但缓存策略完全不同——风控要的是实时和准确,个性化要的是低延迟和高命中。
四层之间我用一个约定隔开:录入层只负责把数据变干净,不做任何业务判断;应用层只消费查询接口,不直接碰库。这个约定听起来像教条,但它救过我一次——某次数据源格式大改,我只改了录入层的字段映射,应用层一行代码没动就平滑过渡了。反过来如果业务逻辑里到处散落着“读 CSV 第 7 列”,那次改版至少要拖两周。
1.3 在线接口与离线库的取舍,我为什么两个都要
这是被问得最多的一个问题:既然有在线接口,为什么还要费劲自己建库?
先看在线接口。它的优势很明确:单次查询就能拿到结构化结果,字段通常比较全,包含国家、省州、城市、经纬度、时区、运营商、是否是数据中心网段等;维护成本几乎为零,不用操心数据更新。缺点同样突出:有网络依赖、有配额限制、有延迟成本。站点每天的访问量到十万级的时候,如果每个请求都同步调一次外部接口,页面首字节时间会被硬生生拉长几十毫秒,接口一抖动,整个站点跟着抖。这还没算上配额——批量回补历史日志的时候,几十万条 IP 一次跑完,很容易把当天额度打空。
离线库反过来:查询是本地内存操作,微秒级;没有配额概念,想查多少查多少;数据落在自己手里,隐私上更可控。代价是要自己维护更新,而且原始数据的精度和字段完整度通常不如商业接口。
所以我的做法是分层使用:主链路(每个请求都要判定的场景)走离线库,保证延迟可控;旁路(离线分析、日志回补、疑难 IP 复核)走在线接口,拿更细的字段做补充。两边通过一个统一的查询函数封装起来,业务侧只看到lookup(ip),不关心底层走的是哪条路。这个设计还有一个隐性好处:在线接口返回的结果可以做抽样比对,用来监控离线库的准确率。我设了一个每天 200 条的抽样比对任务,偏差率超过阈值就告警——这个机制后来帮我发现过一次数据源更新滞后的问题。
有一点要提醒:不要把在线接口当成“绝对正确”的参照物。地理定位本质上是概率推断,同一个人用手机在两地之间通勤,上午查是一个城市,下午查是另一个城市,这不是谁的错。做规则的时候一定要给“不确定”留出位置,具体在 4.3 讲。
2. 数据录入软件的设计与字段规范
2.1 表结构怎么定:把 IP 段存成整数区间
先说结论:不要用字符串存 IP,也不要用字符串比较做范围查询。
IPv4 地址本质是一个 32 位无符号整数,192.168.1.1转成整数就是3232235777。把网段存成(start_int, end_int)这样的闭区间,查询就退化成一次“给定数字,找包含它的区间”的问题,用二分查找在有序数组上做,复杂度 O(log n)。字符串方案的问题在于:'9.1.1.1' > '10.1.1.1'是成立的(按字典序),但数值上 9 开头的地址小于 10 开头的地址,排序全乱,范围查询直接失效。
表结构我用了两张表,一张存区间,一张存地理实体,靠 ID 关联:
CREATE TABLE geo_entity ( geo_id INTEGER PRIMARY KEY, country_code CHAR(2) NOT NULL, country_name VARCHAR(64), region_name VARCHAR(128), city_name VARCHAR(128), timezone VARCHAR(64), latitude REAL, longitude REAL, is_datacenter SMALLINT DEFAULT 0 ); CREATE TABLE ip_range_v4 ( start_int INTEGER NOT NULL, end_int INTEGER NOT NULL, geo_id INTEGER NOT NULL, confidence SMALLINT DEFAULT 0, version INTEGER NOT NULL ); CREATE INDEX idx_range_v4 ON ip_range_v4(version, start_int, end_int);为什么拆两张表?因为地理实体的重复度极高——同一个城市可能对应几千个网段,如果每行都存一遍城市名和时区,表体积会膨胀好几倍,更新一个城市名还得扫全表。拆开之后,区间表只存三个整数,紧凑得很。实测下来,200 万条区间记录在 SQLite 里占的空间大约 90MB,拆表之前是 340MB,差了将近四倍。
confidence字段是给自己留的后路。不同数据源对“这个网段定位到城市”的置信度不一样,我统一映射成 0-100 的分数:国家级别 30,省州级别 60,城市级别 85,有运营商或建筑级信息的 95。风控规则里会用到这个分数——低置信度的记录只用来做粗筛,不参与封禁类决策。
version是版本号,配合 3.3 的滚动切换。这一列看着多余,实际上是整个录入软件能“不停服更新”的关键。
IPv6 单独建表,结构一样但用 TEXT 存 128 位地址的前缀。这块数据量小得多,我暂时没做深度优化,够用就行。
2.2 数据源清洗:把五花八门的格式拉齐
原始数据到手的第一个小时,基本都在跟格式搏斗。我踩过的典型情况整理成一张表,方便你对照:
| 现象 | 典型表现 | 处理方式 |
|---|---|---|
| 网段起止写反 | start > end | 交换两值并记一条告警日志 |
| 网段重叠 | 两条记录区间有交集 | 保留 confidence 高的,另一条裁剪 |
| CIDR 混入区间表 | 出现1.2.3.0/24 | 解析成起止整数再入表 |
| 城市名带不可见字符 | 尾部有\u200b或多余空格 | 统一 strip + 正则过滤控制字符 |
| 国家名中英混杂 | 同一国家两种写法 | 建映射字典,落到 ISO 两位码 |
| 时区写成偏移量 | +08:00 | 转成 IANA 时区名,如Asia/Shanghai |
| IPv6 混进 v4 表 | 点分十进制解析报错 | 按冒号数量分流到不同解析分支 |
| 空值与占位符 | -、N/A、null | 统一转 NULL,不参与城市级判定 |
这张表里最坑的是“网段重叠”。理论上 IP 段不应该重叠,现实中不同批次的数据合并时经常出现,因为数据源更新时旧段没删干净。如果不处理,二分查找会命中哪个区间完全看运气,同一个 IP 两次查询结果不一样,排查起来能让人怀疑人生。我的处理是入表前做一次全局排序 + 扫描合并,遇到重叠就按置信度取舍,同时把冲突记录写进一张conflict_log表,方便回溯是哪一批数据引入的。
另一个容易被忽略的是编码问题。某些数据源的城市名用本地语言写,文件头却声明 UTF-8,读进来是一堆问号。我的做法是读取时先按字节嗅探,UTF-8 失败就退到 GBK 尝试,两个都失败就整行跳过并计数。跳过率超过 0.5% 就停止导入——这通常意味着文件整体不是你以为的那个格式,硬着头皮录进去只会污染库。
提示:清洗阶段一定要保留“原始行号”。出了问题时,你能靠行号快速定位到源文件的哪一行,比只知道“有 3 条数据异常”有用得多。
2.3 录入流程:分批、事务、断点续跑
录入这件事,小数据量怎么都好说,上了百万行就必须考虑三件事:内存、事务、中断。
内存:不要一次性把整个文件读进列表再处理。我一开始就是这么干的,40 万行的 CSV 读进内存后,Python 进程占了 1.2GB,机器直接开始换页。改成流式迭代后,峰值内存压到 200MB 以内。具体做法是生成器逐行 yield,每攒够一批就落库并清空缓冲。
事务:每行一个事务是最慢的做法,磁盘 fsync 次数太多。我按批提交,一批 5000 行。这个数字不是拍脑袋来的——我试过 500、1000、5000、20000 四档,在本地 SSD 上录入 40 万行的耗时分别是 62s、38s、21s、23s。5000 行是拐点,再往上收益递减,而单次事务失败要回滚的数据量却变大,得不偿失。
断点续跑:录入过程中机器重启、脚本被杀、数据库连接超时,都是常事。我加了一个import_progress表,记录“哪个文件、处理到第几行、本批次是否已提交”。重跑时从断点继续,不会重复插入。这里有个细节:进度记录必须在事务提交之后写,顺序反了会出现“进度记了但数据没进去”的空洞,比重复插入更难查。
整个流水线的伪代码大概长这样:
def import_file(path, batch_size=5000): buf = [] for lineno, row in enumerate(stream_rows(path), start=1): rec = normalize(row, lineno) # 清洗 + 校验 if rec is None: skipped += 1 continue buf.append(rec) if len(buf) >= batch_size: commit_batch(buf) # 单事务写入 save_progress(path, lineno) # 事务提交后再记进度 buf.clear() if buf: commit_batch(buf) save_progress(path, lineno)commit_batch里我用的是executemany而不是拼 SQL 字符串,除了防注入,还有个现实原因:拼出来的超长 SQL 在某些驱动上会撞到语句长度上限,报错信息还特别模糊。
3. 核心实现:录入与查询的代码骨架
3.1 批量录入脚本:5000 行一批的理由
接着上面的批次大小说。除了耗时,还有一个因素决定批次大小:锁的粒度。如果录入和查询共用同一个数据库,一批写入时间太长会阻塞查询。5000 行在 SQLite 上的写入耗时大概 0.25 秒,这个量级的锁等待对线上查询基本无感;换成 20000 行一批,单次写入 0.9 秒,查询侧就会出现明显的毛刺。
SQLite 我还调了几个参数,效果比想象中大:
PRAGMA journal_mode = WAL; -- 读写不互斥 PRAGMA synchronous = NORMAL; -- 牺牲极端断电安全换速度 PRAGMA cache_size = -64000; -- 64MB 页缓存 PRAGMA temp_store = MEMORY; -- 临时表放内存journal_mode=WAL是最关键的一条,它让读操作不被写操作阻塞。改之前录入期间查询延迟能飙到 800ms,改之后基本维持在 20ms 以内。synchronous=NORMAL意味着极端断电情况下可能丢最后一个事务,对 IP 地理数据这种可以重跑的场景完全可以接受——但如果是订单类数据,请老老实实保持 FULL。
如果你用的是 PostgreSQL,思路类似但手段不同:COPY命令比INSERT快一个数量级,40 万行用COPY FROM STDIN大概 6 秒;索引建议先删后建,录完再CREATE INDEX,比边插边维护索引快两三倍。这个技巧在大批量回补历史数据时特别好用。
3.2 查询层:二分查找与缓存
查询的核心是“给定 IP 整数,找出包含它的区间”。数据按start_int升序排列后,用二分找到最后一个start_int <= ip的位置,再检查该位置的end_int >= ip即可。
import bisect class RangeIndex: def __init__(self, rows): rows.sort(key=lambda r: r[0]) self.starts = [r[0] for r in rows] self.ends = [r[1] for r in rows] self.geo = [r[2] for r in rows] self.conf = [r[3] for r in rows] def lookup(self, ip_int): i = bisect.bisect_right(self.starts, ip_int) - 1 if i >= 0 and self.ends[i] >= ip_int: return self.geo[i], self.conf[i] return None, 0把starts、ends、geo拆成三个平行数组(列式存储),比存对象列表快很多。原因是 Python 对象有额外开销,遍历时缓存不友好。我实测过:100 万条记录,对象列表每次查询约 8 微秒,平行数组约 1.6 微秒,差了五倍。内存占用差距更大——对象列表 300MB 以上,三个整数数组用array模块存是 12MB 左右。
缓存分两层。第一层是进程内 LRU,缓存最近查询的 10 万个 IP,命中率在真实流量下大概 60%-75%。第二层是 IP 段聚合:把 IP 的前 24 位(IPv4 的 /24 网段)作为缓存 key,而不是完整 IP。这个改动把缓存命中率从 70% 提到了 92%,因为同一网段的用户大概率落在同一个地理区域。代价是精度损失——一个 /24 里的 256 个地址被当成同一个位置,实际误差最多到区县级,用于个性化完全够用,用于风控则需要谨慎(4.3 会讲怎么处理)。
缓存还有一个坑:不要缓存“查不到”的结果。未收录的 IP 查出来是 None,如果这个 None 进了缓存,等数据更新后依然返回 None,看起来像数据没生效。我的做法是未命中结果只缓存 5 分钟,命中结果缓存 1 小时。
3.3 增量更新与版本切换,不锁表
IP 地理数据每月都在变,更新必须有,但不能停服。我用的是“影子表 + 版本号”方案。
新建一张ip_range_v4_new,把新数据全部录进去,建好索引。校验通过(行数、覆盖率、抽查比对都过)之后,在一个事务里做三件事:更新一个配置表里的active_version,把旧版本标记为待清理,提交。查询层每次查询时读取active_version(这个值缓存在内存里,5 秒刷新一次),带上版本号去查。
这个方案的妙处在于没有任何锁表操作,旧数据在新版本生效的那一刻起才不可见,之前的所有查询都正常返回。清理旧版本可以延迟到低峰期做,甚至保留两个版本以支持快速回滚。
BEGIN; UPDATE config SET value = '202406' WHERE key = 'active_version'; COMMIT; -- 低峰期执行 DELETE FROM ip_range_v4 WHERE version < 202405;要注意的是active_version的读取频率。如果每个请求都去查一次配置表,数据库压力不小。我把它放在进程内存里,5 秒同步一次,切换的延迟就是 5 秒。对 IP 地理数据这种更新频率来说,5 秒的生效延迟完全无所谓。
还有一个细节:新版本上线前必须做覆盖率对比。我用同一批 1 万个抽样 IP,分别用新旧版本查询,统计“结果一致率”和“从有结果变成无结果”的比例。后者如果超过 1%,说明新数据源有问题,宁可不上也不要上。这个检查帮我拦住过一次数据源裁剪失误——新版本漏了整整一个大洲的网段。
4. 用地理位置数据保护站点:把风险挡在业务之前
4.1 分层风控:先把流量的“来路”分级
“保护站点”这个说法容易被理解成上一套防火墙,其实不是。IP 地理数据在防护里的作用,是给每个请求补充一个“来路画像”,让后续规则有依据。
我的分级是这样的:
| 等级 | 判定依据 | 处理策略 |
|---|---|---|
| L0 可信 | 家庭宽带网段 + 高置信度城市 + 历史行为正常 | 全量放行,不做额外校验 |
| L1 正常 | 普通网段,置信度中等 | 正常处理,只记录 |
| L2 存疑 | 数据中心网段,或归属地与声明地区严重不符 | 加轻量校验,如限频、要求完成一次交互 |
| L3 高危 | 数据中心网段 + 高频 + 短时间多账号行为 | 拦截或转人工审核 |
这里要强调:等级不是判决,是优先级。L3 不等于“一定是坏人”,只是“值得多看一眼”。把 L3 直接等同于封禁,是误杀的主要来源,具体见 4.3。
分级依据里,“数据中心网段”这个字段特别有用。它来自数据源的标注,标识某个网段属于云服务商或机房而非住宅宽带。正常用户的家庭网络几乎不会落在这类网段里,而批量请求工具往往跑在云主机上。这个特征单独用误判率不低(有些企业办公网也走机房出口),但叠加上频次和行为特征之后,判别力就很强了。
4.2 规则落地的三种形态:边缘、应用、异步
同一个判断,放在不同的位置执行,效果差别很大。
边缘层(反向代理或 CDN 边缘)适合做粗粒度的拦截:整段网段拒绝、按国家或地区做访问范围控制、简单的频率阈值。优势是请求根本进不到应用服务器,成本最低;劣势是规则变更需要重载配置,而且边缘能拿到的上下文有限。Nginx 的geo模块可以加载网段列表做映射,配置大概是这样:
geo $risk_level { default 1; include /etc/nginx/geo/datacenter_ranges.txt; # 值映射到 3 10.0.0.0/8 0; # 内网段直接放行 } map $risk_level $limit_key { 3 $binary_remote_addr; default ""; } limit_req_zone $limit_key zone=risky:10m rate=5r/s;limit_req_zone的 key 为空时不生效,这个技巧让我只对高风险等级做限流,正常流量完全不受影响。这种“按条件启用限流”的写法在文档里不显眼,但实战中极其有用。
应用层适合做带业务上下文的判断:登录接口按地区做二次验证、注册接口限制同一网段的账号创建速率、支付接口校验账单地区与访问地区的一致性。应用层能拿到用户 ID、设备指纹、历史行为,判断更准,代价是消耗服务器资源。
异步层适合做回溯分析:把请求日志带上地理字段落盘,离线跑聚合任务,发现异常模式后再反哺前两层。异步层不影响实时性能,是最安全的试验田。我的很多规则都是先在异步层跑一周,看误报率,确认没问题再往边缘层搬。
三层之间要有清晰的职责边界,否则会出现“同一个 IP 在边缘被拦、在应用层被放行”的混乱。我的约定是:边缘层只做“明显不该来”的拦截,应用层做“可能需要确认”的校验,异步层只观察不干预。
4.3 误杀控制:白名单、灰度与申诉通道
这一段是整章最重要的,因为防护系统最大的风险不是漏放,是误杀。
第一,不确定就降级。查询返回的confidence低于 60 时,不要用它做拦截决策,最多用来调整限频阈值。数据库里查不到的 IP,一律按 L1 处理,不因为“未知”就判为高危。未知 IP 的量在真实流量里通常占 3%-8%,全部按高危处理,误杀率会高得离谱。
第二,白名单要分层。我设了三层:全局白名单(内部服务、合作方固定出口)、业务白名单(某个业务线自己的合作渠道)、临时白名单(运营手工加的,带过期时间)。第三层最容易被滥用,所以强制要求填过期时间,最长 7 天,过期自动失效。
第三,灰度放量。任何新规则上线,先只对 5% 的流量生效,观察 24 小时。观察指标不是“拦截了多少”,而是“误杀申诉量”和“正常业务指标有没有波动”。这两个指标里,后者更重要——很多误杀用户根本不会申诉,直接走了。
第四,给不确定的流量一个出口。对 L2 存疑流量,我不用直接拦截,而是要求完成一次轻量交互(比如页面停留超过 2 秒、完成一个简单的滑块)。这类交互对真人几乎无感,对批量脚本是硬门槛。
注意:任何基于地理位置数据的拦截规则,都必须能解释“为什么拦”。我在规则配置里强制要求填
reason字段,出问题时能一眼看出是哪条规则命中。没有 reason 的规则不允许上线,这条规矩救过我好几次。
4.4 一套可以直接抄的判定流程
把上面的东西串起来,单个请求的判定大概是这样走的:
def assess(ip, ua, path, recent): geo, conf = index.lookup(ip_to_int(ip)) if geo is None: return "L1", "geo_miss" if ip in global_whitelist: return "L0", "whitelist" if geo.is_datacenter and conf >= 85: if recent.hits_1min > 30 and recent.distinct_paths > 10: return "L3", "dc_scan" return "L2", "dc_low_conf" if path.startswith("/api/") and conf < 60: return "L2", "low_conf_api" return "L1", "default"这段代码没什么花哨的地方,但每条分支背后都对应一次线上事故的教训。distinct_paths这个条件就是后来加的——只看请求数会把正常的高频轮询接口误判,加上“路径分散度”才准确。
5. 用地理位置数据自定义站点:体验层面的收益
5.1 语言、货币、时区三件套
“自定义站点”听着像是要做一套复杂的推荐系统,其实收益最大的三件事特别朴素:语言、货币、时区。
语言:按 IP 归属地给一个默认语言,但一定要允许用户手动切换,并且切换后记住选择。用户的手动选择优先级永远高于 IP 推断。我见过太多站点犯这个错:用户切到英文,刷新页面又变回中文,只因为 IP 在国内。这个体验损伤比不自动切换还严重。
货币:电商站按地区展示默认货币,价格换算要小心。不要用实时汇率直接乘,把换算后的价格缓存一段时间(我用 15 分钟),否则同一件商品两次刷新价格不一样,用户会觉得你不靠谱。另外,某些地区的价格敏感度差异很大,直接按汇率换算常常不合适,这块需要业务侧自己定,技术侧只提供“地区”这个输入。
时区:这个最容易做,收益也最直接。订单时间、活动开始时间、日志时间戳,全部按用户所在时区渲染。实现上不需要 IP 数据也能做——浏览器能拿到时区——但当用户还没执行 JS 时(比如邮件、服务端渲染的首屏),IP 数据就是唯一的来源。所以我的做法是:服务端用 IP 推断给初始值,客户端 JS 拿到真实时区后覆盖并上报。两者不一致时,以客户端为准,同时记一条日志用于监控 IP 库的准确率。
这三件事的投入产出比极高:加起来不到一天工作量,用户感知却很明显。相比之下,花两周做的“个性化推荐”在流量不大的站点上往往看不到效果。
5.2 缓存与 Vary 头:最容易翻车的地方
按 IP 判断地区来做内容差异化,最容易踩的坑是缓存串味。
假设你的页面按地区展示不同的推荐内容,CDN 把第一个访问者的页面缓存下来,后面所有人都看到同一个版本。这在测试环境完全发现不了,上线后就是一场灾难。
解决思路有两条。第一条是把地区编码进缓存 key:CDN 层面按国家或地区分缓存,例如/page?region=cn。这条路的优点是简单直接,缺点是缓存命中率下降——分成 20 个地区就是 20 份缓存,每份都要单独回源。我的做法是只对国家维度分缓存(通常不超过 10 个有效国家),更细的城市级差异化放到客户端做。
第二条是用 Vary 头。如果你按Accept-Language做内容协商,必须加上Vary: Accept-Language,否则缓存会把中文页面返回给英文用户。但要注意:Vary加得越多,缓存命中率越低,因为每个维度组合都是独立的缓存条目。
Vary: Accept-Language, Accept-Encoding Cache-Control: public, max-age=300, s-maxage=600第二条路看起来更“标准”,但实测下来我倾向于第一条——显式编码在 URL 里,排查问题的时候一眼就能看出这个缓存属于哪个地区,比翻 Vary 头快得多。
还有一个细节:不要在服务端根据 IP 做重定向来切换语言。比如检测到日本 IP 就 302 到/ja/。这个方案对搜索引擎极不友好,爬虫的 IP 可能在任何地方,抓到的内容和用户看到的对不上。正确做法是在同一 URL 下返回不同语言的内容,用hreflang标注,或者在客户端做切换。
5.3 内容差异化与 A/B 的边界
再往上一层就是内容差异化:不同地区展示不同的活动、不同的商品排序、不同的推荐位。
这块我的建议是先用数据说话,再动手做。拿历史访问日志跑一遍地理维度的聚合,看不同地区的用户行为到底差多少。我做过一次分析,发现两个地区的用户点击分布相似度高达 90%,那这种情况下做地区差异化纯属浪费人力。反过来另一个案例里,某两个地区的支付方式偏好差异巨大,那这个差异化就非常值得做。
A/B 实验和地理定位结合的时候有个陷阱:分流必须按用户 ID 哈希,不能按 IP。按 IP 分流会导致同一个用户换个网络就看到另一个版本,实验数据全废。IP 数据在这里的正确用法是“作为分层维度”——比如你想知道新方案在某个地区效果如何,就在实验分析时按地区切分数据,而不是按地区分配实验组。
另外一个必须说的边界:差异化不等于歧视。同一个服务,不同地区的用户应该享受到同等的核心功能,差异化的只能是表现形式(语言、货币、内容排序)和运营策略。这条线要划清楚,否则技术上的“优化”很容易变成业务上的问题。
6. 常见问题与排查技巧实录
6.1 定位不准的六种典型表现
“这个 IP 查出来的位置不对”——这是我收到最多的反馈。实际情况千差万别,我整理成一张速查表:
| 现象 | 根因 | 处置方式 |
|---|---|---|
| 城市对了,区县错了 | 数据源本身只到城市级 | 降低该字段置信度,不用于精细判定 |
| 移动网络定位漂移 | 运营商出口节点与用户实际位置不同 | 移动网段只信到省州级 |
| 企业专线定位到总部 | 出口 IP 属于总部机房 | 结合 ASN 字段判断,企业网段放宽 |
| 同一 IP 结果前后不一 | 缓存穿透或版本切换中 | 检查 active_version 同步间隔 |
| 新分配 IP 查不到 | 数据源更新滞后 | 未命中走在线接口兜底 |
| IPv6 全部命中不到 | 表没建或前缀长度不匹配 | 单独校验 IPv6 分支 |
这里面最值得展开的是移动网络定位漂移。移动用户的 IP 常常是运营商核心网出口,一个出口后面挂着几十万用户,地理位置可能对应到省会城市而不是用户所在的三线城市。如果你的业务需要城市级精度(比如本地生活服务),必须接受“移动网络只能给到省级”这个现实,用其他信号(GPS、用户填写地址)来补充。硬要拿 IP 做城市级判定,错误率会让你怀疑数据质量。
企业专线的情况类似。某公司的出口 IP 注册在总部所在地,但员工可能分布在十几个城市。这类网段的地理字段对“人在哪”判断力很弱,但对“这人属于哪个组织”判断力很强——如果能拿到 ASN(自治系统号)或组织名称字段,用组织维度做判断会准得多。
6.2 性能与内存:几条实测数据
性能这块我做过一轮压测,数据放在这里供参考。测试机是 4 核 8G 的普通云主机,数据集 200 万条 IPv4 区间。
| 方案 | 内存占用 | 单次查询 | 100 万次查询总耗时 |
|---|---|---|---|
| Python 对象列表 + 线性扫描 | 320MB | 约 40ms | 不可用 |
| Python 对象列表 + 二分 | 310MB | 8µs | 9.2s |
| 平行整数数组 + 二分 | 12MB | 1.6µs | 1.8s |
| 加进程内 LRU 缓存 | 45MB | 0.3µs(命中) | 0.4s |
| 落 Redis,/24 聚合 | 独立进程 | 0.15ms | 网络往返为主 |
几个结论值得记一下。第一,二分查找是必需品,线性扫描在任何规模下都不可行。第二,数据结构的影响比算法更大,从对象列表换到平行数组,性能提升五倍、内存降到 1/25,改造成本却只有半天。第三,缓存能再提一个数量级,但要注意缓存穿透和失效策略。第四,Redis 方案的延迟主要花在网络往返上,如果你的查询量不大(每秒几千次以内),进程内缓存完全够用,不必引入额外组件。
还有一个容易被忽视的点:启动加载时间。200 万条记录从 SQLite 读出来构建索引,大约需要 8 秒。如果你的服务频繁重启(比如容器化部署),这 8 秒会很碍事。我的做法是把索引序列化成二进制文件(用array.tobytes()),启动时直接 mmap 读进来,加载时间降到 0.6 秒。代价是数据更新后要重新生成这个文件,纳入录入软件的收尾步骤就行。
6.3 数据最小化与留存策略
IP 地址在多数地区的实践中被视作个人相关信息,这件事在设计和运营阶段就要考虑,不要等出了问题再补救。
我的做法是三条。第一,能不存就不存完整 IP。风控和统计分析用 /24 前缀足够,我在日志表里存的是前缀加一个加盐哈希,需要精确定位原始 IP 时只能通过哈希比对,不能反推。第二,明确留存期限。访问日志里的 IP 字段保留 30 天,超过就自动清空该列,只留聚合后的地区统计。这个策略写进建表语句的注释里,谁改表谁看见。第三,流向透明。站点上有一页简短的说明,讲清楚收集了什么、用来做什么、保留多久。这不是为了应付什么,而是我自己作为用户也希望知道这些。
技术上有个小技巧:分区表 + 自动清理。日志表按月分区,删除过期分区是一个元数据操作,比DELETE FROM ... WHERE快几个数量级,也不会产生大量碎片。
CREATE TABLE access_log_202406 PARTITION OF access_log FOR VALUES FROM ('2024-06-01') TO ('2024-07-01'); -- 到期直接 DROP,秒级完成 DROP TABLE access_log_202406;这套东西配合录入软件的版本管理一起用,整个链路就闭环了:数据按月更新,日志按月归档,任何时候线上只有一份活跃数据加一份可回滚的备份。
我在实际项目里最大的体会是,IP 地理数据的价值不在于“准”,而在于“可解释”。任何一条基于它做出的判断,你都得能说清楚依据是什么、置信度多少、错了怎么回滚。我见过太多项目把地理位置当成一个黑盒开关,命中就拦、不命中就放,出问题的时候完全没法复盘。反而是一些看起来保守的做法——分级、降级、灰度、留日志——在长期运行中省下的时间远超它们增加的复杂度。最后分享一个小技巧:给每一条地理规则都加一个“影子模式”,规则照常计算但不执行,只记录“如果执行会拦掉多少请求”。挂一周看看这个数字,再决定要不要真的开。这个习惯让我避开了至少三次可能的大规模误杀。