ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

手机号码归属地API接入实战:号段查询、缓存降级与合规边界

手机号码归属地API接入实战:号段查询、缓存降级与合规边界

做后端的朋友大概都遇到过一个需求:运营要按地域看用户分布,或者风控要做异地登录判断,再或者客服系统想把来电按省份路由到不同坐席。这些需求背后都指向同一件事——手里只有一串11位数字,得先把它翻译成“人在哪、用的哪家网络”。

手机号码归属地API接入这个活儿,看起来就是调一个HTTP接口的事,但真做起来,从选型、字段理解、缓存设计到上线后的各种边界情况,能踩的坑比想象中多得多。我这几年在几个不同项目里反复接过这类接口,从最开始的“跑通就行”到后来的“必须扛住批量导入和突发流量”,中间返工过好几次。下面就把这套东西从头到尾捋一遍,包括完整可运行的demo、字段该怎么信、上线后怎么兜底。

1. 归属地查询这件事,先搞清楚它能回答什么、不能回答什么

1.1 一串数字到行政区划的映射,本质是查表

手机号码归属地查询的原理并不神秘,它本质上是号段匹配 + 查表。国内手机号是11位,前3位是网络识别号,第4到第7位是地区编码(HLR归属位置寄存器标识),后面才是用户号码。归属地数据本质上是一张“号段前缀 → 省份/城市/运营商”的映射关系表。

所谓归属地API,就是把这张表放在服务端,对外暴露一个查询入口:你传入号码,它做前缀匹配,返回对应的行政区划和运营商信息。所以你在本地建一张表,理论上也能实现同样的功能,只是这张表是会变的——新号段会放号,虚拟运营商会增减,部分号段会在不同运营商之间调整。这也是为什么绝大多数团队选择调第三方接口而不是自己维护表:买的是那张表的更新频率。

理解这一点很关键,因为它直接决定了你对返回结果的预期。这个接口给你的是号码登记时归属的行政区域,不是设备当前的位置。这两者差别巨大,后面会细说。

demo 里我一般会把查询逻辑封装成一个独立的函数,不跟业务代码混在一起。原因是这个能力很可能在多个地方被调用(注册校验、风控、报表、客服),封装成纯函数之后,替换供应商、加缓存、加降级都只改一处。这是一个很小的设计决策,但能省掉后面大量的重构。

1.2 三个最常见的误解,先在这里掐掉

误解一:以为能定位到人在哪儿。归属地返回的是号码注册地,一个人完全可以在A省办卡然后长期在B省生活,现在跨省流动太普遍了,这个偏差非常常见。如果你的业务场景是“判断用户实际所在城市”,归属地只能作为一个极弱的先验信号,真要判断位置得靠IP、GPS或者用户主动填写。我见过有项目拿归属地做本地生活推荐,结果推的全是用户老家,效果惨不忍睹。

误解二:以为能查到机主身份。归属地接口只返回省份、城市、运营商这几类信息,不涉及任何机主姓名、身份证、地址。任何声称能通过手机号查到实名的渠道,都不属于正常的技术接口范畴,这个边界必须清楚。

误解三:以为运营商字段永远准确。携号转网推行之后,号码归属的运营商会发生变化,而号段表是静态的,这就导致一部分号码返回的运营商字段和实际在网运营商不一致。如果你的业务涉及短信通道选择、话费结算这类对运营商敏感的场景,这个字段不能作为唯一依据。

把这三个边界讲清楚,后面在跟产品对需求的时候能省掉很多来回拉扯。我一般会在需求评审阶段就把这三点写进文档,避免上线后被反馈“定位不准”。

2. 接口形态怎么选:单条查询、批量查询还是本地号段库

2.1 单条同步查询接口的请求结构

市面上的归属地接口,形态大同小异,基本都是HTTP GET + 查询参数 + JSON返回。典型结构长这样:

GET https://api.example.com/v1/phone/location?key=YOUR_KEY&phone=13800000000

返回体一般包含三层:状态码、状态描述、数据体。数据体里是省份、城市、运营商,有些还会附带区号、邮编、卡类型。

{ "code": 0, "msg": "ok", "data": { "province": "某省", "city": "某市", "carrier": "某运营商", "area_code": "0XXX", "post_code": "XXXXXX" } }

这里有个细节值得注意:不同供应商的状态码设计差别很大。有的用code=0表示成功,有的用code=200,有的用ret_code,还有的把错误信息塞在msg里而code恒为0。接入前一定要把错误码表拿全,不要只按“成功”这一条路径写代码。我见过一个项目因为把code=200当成失败、把code=0当成成功,结果上线后所有查询都走进了异常分支,日志刷得飞起。

另外要留意参数命名,常见的字段名有phone、mobile、number、tel几种,密钥参数有key、appkey、app_key、token几种,不要凭感觉写,按文档来。

2.2 什么时候该用批量接口,什么时候该自己缓存

如果业务量不大(比如每天几千次查询),单条接口完全够用。但一旦涉及到存量数据清洗或者周期性报表,情况就完全不同了——假设你要给50万用户标注归属地,一条一条调接口,按每次100毫秒算,串行跑完要将近14个小时,而且期间但凡有一次网络抖动,整个任务就得重来。

这种场景有三个思路:

方案适用场景优势代价
单条实时查询QPS低、结果要求新鲜实现最简单,无状态有网络往返开销,慢
批量查询接口存量数据一次性处理吞吐高,适合离线任务需要拼接参数,返回结构更复杂
本地号段库超高频查询、内网环境零延迟,无外部依赖需要自己更新数据,维护成本高

批量接口一般支持一次传几十到几百个号码,逗号分隔或数组形式,返回一个列表。用它的时候要注意顺序对齐:有些接口返回的数组顺序和传入顺序一致,有些不保证,需要在返回体里靠号码字段自己对齐。这个坑不踩一次很难想到。

本地号段库适合那些查询量极大但对准确性要求不那么极致的场景,比如日活很高的小区推荐。代价是要定期更新数据文件,并且要自己处理版本管理。我个人的判断标准是:日查询量超过10万次,且可以接受小时级延迟,就值得考虑本地库 + 定时同步的混合方案。

2.3 密钥和配额的隐性成本

还有个容易被忽略的点是配额和计费方式。有的接口按调用次数计费,有的按时长套餐,有的对失败请求也计费。在做容量规划的时候,一定要把重试和缓存失效算进去——如果你不做缓存,一个用户每次打开页面都触发一次查询,实际消耗可能是预期的好几倍。

我一般会先算一笔账:日活用户数 × 平均每人触发查询的次数 × 1.2(重试冗余系数),得出日调用量,再对比套餐价格。这个计算过程看起来琐碎,但能避免上线一周后收到超额告警。

3. 从零跑通一个最小可用的Demo

3.1 环境准备与依赖

Python 环境下,只需要一个requests:

python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install requests

Node.js 环境下用内置的fetch(Node 18+)就够,不需要额外装包。选哪个语言其实无所谓,接口层面完全一致,下面两个版本我都会给。

有一点提醒:不要在这个阶段引入太重的框架。demo 的意义是验证连通性和返回结构,用最短路径跑通,比搭一套完整工程更有效率。等确认字段符合预期了,再往业务代码里搬。

3.2 Python 版本的最小闭环

import os import re import requests API_HOST = "https://api.example.com" API_PATH = "/v1/phone/location" TIMEOUT = (3, 5) # (连接超时, 读取超时) def normalize_phone(phone: str) -> str: """清洗号码:去掉空格、横线、+86、0086 等前缀""" digits = re.sub(r"\D", "", phone or "") if digits.startswith("0086"): digits = digits[4:] elif digits.startswith("86") and len(digits) == 13: digits = digits[2:] return digits def query_location(phone: str, key: str = None) -> dict: key = key or os.environ.get("LOCATION_API_KEY", "") phone = normalize_phone(phone) if not re.fullmatch(r"1[3-9]\d{9}", phone): return {"ok": False, "reason": "invalid_phone"} try: resp = requests.get( API_HOST + API_PATH, params={"key": key, "phone": phone}, timeout=TIMEOUT, ) resp.raise_for_status() payload = resp.json() except requests.Timeout: return {"ok": False, "reason": "timeout"} except requests.RequestException as exc: return {"ok": False, "reason": f"network:{exc.__class__.__name__}"} except ValueError: return {"ok": False, "reason": "invalid_json"} if payload.get("code") != 0: return {"ok": False, "reason": payload.get("msg", "unknown")} data = payload.get("data") or {} return { "ok": True, "phone": phone, "province": data.get("province") or "", "city": data.get("city") or "", "carrier": data.get("carrier") or "", } if __name__ == "__main__": print(query_location("138 0000 0000"))

这段代码里有几个刻意的设计,值得说一下原因。

号码清洗放在最前面。用户输入千奇百怪:带空格的、带横线的、从通讯录导出的带+86的、带0086的。如果不做统一清洗,接口会因为格式不对直接返回错误,而这个错误看起来像“查不到”,非常容易误导排查方向。

超时设成元组而不是单个值。(3, 5)表示连接超时3秒、读取超时5秒。连接超时短一点,能快速失败;读取超时长一点,给服务端留出处理时间。只设一个数字的话,某些慢响应会拖住整个请求线程。

异常分支要分开捕获。超时、网络错误、JSON解析失败,这三类问题的处理策略完全不同,混在一起会导致排查时没有信息。raise_for_status()会把 4xx/5xx 转成异常,避免把错误页当成正常 JSON 解析。

3.3 Node.js 版本与前端联调的注意点

const API_HOST = "https://api.example.com"; const API_PATH = "/v1/phone/location"; function normalizePhone(input) { let digits = String(input || "").replace(/\D/g, ""); if (digits.startsWith("0086")) digits = digits.slice(4); else if (digits.startsWith("86") && digits.length === 13) digits = digits.slice(2); return digits; } async function queryLocation(phone, key = process.env.LOCATION_API_KEY) { const normalized = normalizePhone(phone); if (!/^1[3-9]\d{9}$/.test(normalized)) { return { ok: false, reason: "invalid_phone" }; } const url = new URL(API_HOST + API_PATH); url.searchParams.set("key", key); url.searchParams.set("phone", normalized); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 5000); try { const resp = await fetch(url, { signal: controller.signal }); if (!resp.ok) return { ok: false, reason: `http_${resp.status}` }; const payload = await resp.json(); if (payload.code !== 0) return { ok: false, reason: payload.msg || "unknown" }; const data = payload.data || {}; return { ok: true, phone: normalized, province: data.province || "", city: data.city || "", carrier: data.carrier || "", }; } catch (err) { return { ok: false, reason: err.name === "AbortError" ? "timeout" : "network" }; } finally { clearTimeout(timer); } }

前端联调这里必须强调一句:密钥绝对不能写在前端代码里。不管你怎么混淆、怎么打包,只要在浏览器里发请求,密钥就一定能被抓出来。正确做法是前端调自己的后端,由后端转发到归属地接口。这一条没有例外,我见过太多因为图省事把密钥塞进 JS 结果被人刷爆额度的案例。

3.4 把密钥从代码里挪出去

demo 阶段很多人图省事直接key = "xxxxx"写在源码里,然后顺手提交了。这个习惯一定要改。

最低要求是走环境变量:

export LOCATION_API_KEY="your_key_here"

更好一点的做法是用.env文件配合python-dotenv,同时把.env加进.gitignore。再往上,生产环境用配置中心或密钥管理服务,代码里只留一个读取入口。

还有一个细节:日志里不要打印完整密钥和完整号码。密钥脱敏成前4后4,号码脱敏成138****0000。这两条看起来是小事,但在做日志审计的时候差别很大。

4. 返回字段拆解:哪些能直接信,哪些要再加工

4.1 省份、城市、运营商三件套的可靠性分级

拿到返回结果之后,不要一视同仁地使用。这三个字段的可靠性差异挺大,我在实际项目里给它们分了个级:

字段可靠性典型偏差来源使用建议
省份高直辖市与省的命名差异、历史区划调整可直接用于统计分组,注意统一命名口径
城市中部分号段只精确到省,城市为空或返回省会为空时回退到省份,不要当作异常
运营商中低携号转网、虚拟运营商号段、物联网卡不可作为强判断依据,仅作参考

省份字段的坑主要在命名口径。有的接口返回“某省”,有的返回“某自治区”,有的直辖市返回城市名而不带“市”。如果你的下游要做地理编码或者地区树匹配,一定要在入库前做一次标准化映射,否则同一个地方会出现两三种写法,统计报表直接对不上。

城市字段的坑在空值。一部分老号段或者特殊号段只能定位到省级,city会是空字符串或者null。这时候正确的处理是回退到省份层级,而不是标记为“查询失败”。我一开始没注意这点,把空城市当异常处理,结果后台每天报几百条错误,查了半天才发现是正常情况。

4.2 区号、邮编这类附加字段,别抱太大期待

不少接口会附带area_code(区号)和post_code(邮编),看起来挺有用,实际上使用价值有限。

区号的问题在于手机号本身没有区号概念,这个字段是从归属城市推导出来的。一个城市可能有多个区号,接口通常返回主城区那个。如果你拿它去做呼叫路由,可能会打到错误的片区。

邮编的问题更明显,手机号归属地精确到市一级,邮编却是按投递区划分的,粒度对不上。返回的邮编只能理解为“该市的一个通用邮编”,不适合作为精确地址的一部分。

我的建议是:这两个字段可以展示,但不要参与业务判断。如果产品经理问能不能用来做地址自动填充,答案是不行,准确率撑不住。

4.3 空结果和“未知”的处理姿势

除了正常返回和明确的错误码,还有一类中间状态:接口调用成功,但数据为空。可能的原因包括:

  • 号段太新,数据库还没更新
  • 号码属于特殊号段(测试号、物联网号、虚拟号段)
  • 号码本身格式合法但未放号

这类情况一定要和“查询失败”区分开。前者是业务上的正常结果,后者是技术上的异常。混在一起的后果是监控指标失真——你看到错误率飙升,以为接口挂了,实际上是来了一批新号段。

我在项目里的做法是定义三种状态:ok(有数据)、empty(查询成功但无数据)、error(调用失败)。empty不计入错误率,单独打点统计。如果empty的比例突然上升,通常意味着号段库需要更新,这时候联系供应商比查代码更有用。

5. 上线之后才暴露的问题:缓存、限流与降级

5.1 为什么必须在业务层加一层本地缓存

归属地数据的特性是几乎不变。一个号码一旦放号,它的归属地基本就固定了(除非跨省转网这种极少数情况)。这意味着它是一个天然适合缓存的场景,缓存的命中率可以做得非常高。

不缓存的代价是什么?假设一个电商后台,运营每次打开用户列表页,都要给当前页的50个用户查归属地。一个人一天刷20次,就是1000次调用,而这些用户的归属地其实从来没变过。纯属浪费。

一个简单的带TTL的线程安全缓存:

import time from threading import Lock class TTLCache: def __init__(self, ttl=86400, maxsize=200000): self.ttl = ttl self.maxsize = maxsize self._store = {} self._lock = Lock() def get(self, key): with self._lock: item = self._store.get(key) if not item: return None value, expire_at = item if expire_at < time.time(): self._store.pop(key, None) return None return value def set(self, key, value): with self._lock: if len(self._store) >= self.maxsize: # 简单淘汰:清掉最早的一批,生产环境建议换 LRU for k in list(self._store.keys())[: self.maxsize // 10]: self._store.pop(k, None) self._store[key] = (value, time.time() + self.ttl)

TTL 设多长?我的经验是24小时起步。归属地变化频率极低,一天一次刷新已经足够保守。如果对准确性要求没那么高,设7天也没问题。

缓存键用什么?直接用清洗后的手机号就行。但要注意不要把完整号码当缓存键存进Redis这类共享存储,如果一定要用共享缓存,建议对号码做一次哈希,或者只存后4位加前缀的组合,降低敏感信息暴露面。

多实例部署的时候,本地缓存会有不一致,但在这个场景下完全可以接受——不同实例查同一个号,结果本来就一样。如果确实需要共享,用Redis,但要注意别把缓存变成单点。

5.2 限流和重试的边界设计

重试这个事,做得好是韧性,做不好是雪崩。

首先要区分哪些错误值得重试:

  • 超时、连接失败、5xx —— 值得重试
  • 4xx(参数错误、密钥错误、余额不足)—— 重试没有意义,只会浪费配额
  • 返回码表示“查询无结果” —— 不应该重试

其次要控制重试次数和间隔。我一般用最多2次重试,间隔采用指数退避加随机抖动(比如 200ms、600ms 加 ±20% 随机)。加抖动是为了避免多个请求同时重试造成尖峰。

import random import time def query_with_retry(phone, retries=2): last = None for attempt in range(retries + 1): result = query_location(phone) if result["ok"]: return result if result["reason"] in ("invalid_phone", "invalid_json"): return result # 不值得重试 last = result if attempt < retries: backoff = (0.2 * (3 ** attempt)) * (0.8 + random.random() * 0.4) time.sleep(backoff) return last

限流方面,如果你在服务端做聚合查询,建议加一个信号量或令牌桶,把并发压在供应商允许的范围内。突发的批量导入任务一定要做分片 + 限速,不要一次性把几万个请求全打出去。

5.3 接口不可用的时候,界面该显示什么

这是一个经常被忽略的产品问题。归属地接口挂了,页面是显示空白、显示“未知”,还是显示错误提示?

我的做法是展示层降级为“未知”,同时后端记录一条降级日志。原因是归属地信息对绝大多数业务来说是辅助信息,不是核心链路。为了一个辅助字段让整个页面报错,体验得不偿失。

但有两类场景不能这么处理:一是注册环节的号段校验(如果接口挂了,应该放行而不是拦截,避免阻断新用户注册);二是风控的异地判断(接口不可用时应该降级到其他信号,而不是默认判为风险)。

这个取舍要在设计阶段就跟业务方对齐,不要等到故障当天现场决定。

6. 踩过的坑:号码格式、虚拟号段与各种例外

6.1 前缀清洗没做全,导致整批导入失败

这是我最早期踩的一个坑。做用户表批量补全归属地,导入了一个Excel,结果发现有三成失败。排查了半天,发现Excel里的号码格式五花八门:有的是138-0000-0000,有的是+8613800000000,有的是科学计数法变成的1.38E+10,还有前面带单引号的'13800000000。

1.38E+10这个最坑,它在读取阶段就已经变成浮点数了,再转字符串会变成13800000000.0,清洗之后长度不对。正确的做法是在读取Excel时就把该列强制按字符串读取,而不是让它自动推断类型。

清洗函数我后来固化成这样一套顺序:先去除非数字字符,再去掉0086/86前缀,最后用正则做长度和号段校验。校验不通过的单独落一张异常表,而不是直接丢掉——丢掉的话你就永远不知道丢了多少、为什么丢。

6.2 虚拟运营商号段和物联网卡

虚拟运营商的号段(常见的是 170、171、162、165、167 这些开头)在归属地库里往往信息不全,可能只返回省份、城市为空,或者运营商字段返回一个笼统的值。

物联网卡的号段(比如 14 开头的一些号段、106 开头的短信通道号)更是如此,很多归属地接口对它们的支持很有限,直接返回空结果。

处理策略上,我建议在业务侧维护一个特殊号段清单,命中之后走不同的展示逻辑。比如虚拟运营商号码,城市为空时直接展示省份加“(虚拟运营商)”,比展示“未知”友好得多。

6.3 携号转网带来的运营商偏差

携号转网全面推行后,号码的运营商归属可以变更,但号段表是静态的。这就导致一个 138 开头的号码,实际可能已经在另一家运营商网络上。

如果你的业务只把运营商当展示信息,问题不大。但如果涉及短信通道选择,用错通道会导致发送失败或者到达率下降。这种场景下,正确做法是靠实时信令查询或者短信回执来判断,而不是依赖归属地接口。

我经历过一次故障:营销短信按归属地返回的运营商分组发送,结果携号转网用户那部分到达率明显偏低。后来改成不区分运营商、统一走多通道自动切换,问题才解决。

6.4 国际号码和特殊号码的兜底

如果你的产品有海外用户,或者允许输入座机号,那么归属地接口基本帮不上忙。国内号段库对+1、+44这类国际号码是查不到的,座机号也不在覆盖范围内。

兜底逻辑要提前想好:先判断是不是11位国内手机号格式,不是的话直接跳过归属地查询,不要浪费一次接口调用。这个判断放在清洗函数里最合适,一步到位。

7. 数据合规:查询链路要收敛,留存要克制

7.1 手机号属于个人信息,链路越短越好

手机号是可以识别到具体个人的信息,归属地查询虽然是“号码转地区”,但整个链路里传递的仍然是原始号码。这意味着几个原则:

能用哈希就不用明文。如果只是做统计(比如“某省用户占比”),完全可以在入库前就把号码哈希掉,只保留省份字段,原始号码不落库。

密钥权限要收敛。归属地查询的密钥只给需要它的服务用,不要做成全局共享。一旦泄露,损失的是配额和费用,更麻烦的是可能被用来做批量查询。

不要在日志里留全量号码。这一点前面提过,但值得再强调一次。日志往往是最容易被忽视的泄露渠道,因为大家默认“日志是内部看的”。

7.2 缓存数据的留存周期

缓存里存的是号码到归属地的映射,本质上还是个人信息。TTL 设太长,等于变相长期留存了号码数据。

我的建议是缓存 TTL 不超过7天,并且优先考虑只缓存“号码前缀 → 归属地”这种降级形式。比如缓存1380000这个7位前缀对应的归属地,命中率依然很高,但保留了更少的个人信息。这个思路在数据量大的场景下特别值得考虑,因为同一个号段前缀下的号码,归属地大概率是一致的。

如果需要做用户维度的差异化统计,那就把归属地字段直接写进业务表,原始号码按业务需要单独处理,不要让缓存承担这个职责。

还有一点是供应商的数据来源合规性。选型的时候除了看价格和稳定性,也要确认对方的数据来源是否合规、是否有相应的资质。这块我一般会让法务参与评估,技术侧只负责把接口能力和数据流向讲清楚。

最后分享一个我在实际项目里觉得最省事的做法:把归属地查询封装成一个独立的小服务,对外只暴露一个"传号码、返回地区"的简单接口,内部负责缓存、限流、重试、降级和日志脱敏。业务方不用关心用的是哪家供应商,将来换供应商也只改这一个服务。这个封装的成本大概是半天,但省下来的排查时间和重构成本,远超这半天。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进