上周又有同行在群里发截图,说线上注册页的手机号归属地显示“未知”,怀疑接口挂了。我扫了一眼他的代码,发现请求里少了鉴权头,服务端直接返回了空结构,而他的代码只判断了if (result),空对象在有些语言里判真,于是“未知”就被静默吞掉了。这类问题在手机号码归属地 API 接入里太常见了。归属地查询看着只是“调一个接口拿两个字段”,但真把它放到生产环境里跑,鉴权、缓存、限流、号段异常、携号转网、合规边界,每一项都能把人绊一跤。这篇内容就是把我这几年在多个项目里接归属地接口的经验摊开讲:从方案选型讲到接口规则拆解,再给一份能直接跑起来的 demo,最后配上排查速查表。不管你是刚接手的后端新人,还是想给业务加个归属地展示的产品同学,照着做基本能少走一大半弯路。
1. 需求拆解与方案选型:为什么大多数人最后都选了 API
1.1 归属地查询到底在解决什么问题
先把需求说清楚。手机号码归属地查询,本质是通过号码本身携带的号段信息,推断出这个号码最初登记的省份、城市以及所属运营商。注意这里的措辞——“最初登记”,这四个字非常关键,后面讲携号转网的时候会反复用到。它不涉及任何个人身份信息,也拿不到机主姓名、证件号码、实时位置这些东西,输出只有两个维度:地理归属和运营商归属。
实际业务里用到它的场景比想象中多。用户注册时展示“号码归属:某省某市”,可以辅助用户确认自己没输错号码,降低提交后的反悔率;客服系统里把归属地打到工单头部,坐席在沟通时能大致判断用户的作息和方言习惯,服务体验会自然一些;风控环节里,一个注册 IP 在某地、号码归属地在另一地、收货地址又在第三地,这种三角不一致就是很典型的异常信号;数据报表里按归属地做用户地域分布统计,比依赖用户自己填写的地区字段要可靠得多,因为填写字段可以随便选,号段不行。
还有一个容易被忽略的用途是数据清洗。老系统里沉淀了大量号码,格式五花八门,有带 +86 的,有带空格的,有中间带横杠的,甚至还有把座机号填进手机号字段的。跑一遍归属地校验,能快速把这些脏数据挑出来,比正则校验准得多——正则只能判断“长得像不像手机号”,归属地接口能判断“这个号段到底分没分配出去”。
1.2 三条技术路线的横向对比
真要做这件事,摆在面前的无非三条路:调第三方 API、本地维护离线号段库、两者混用做兜底降级。我做过两个版本的方案,也帮同行救过场,把对比结论整理成表更直观。
| 对比维度 | 第三方 API | 离线号段库 | 混合方案 |
|---|---|---|---|
| 接入成本 | 低,拿到密钥当天能跑通 | 中,要处理数据导入和更新 | 中高,两套逻辑都要写 |
| 数据新鲜度 | 高,服务方持续维护 | 取决于你多久更新一次 | 高,API 为主 |
| 携号转网支持 | 部分服务方支持 | 基本不支持 | 视服务方能力 |
| 离线可用 | 否,强依赖网络 | 是 | 是,降级后可用 |
| 单次查询延迟 | 20ms 到 200ms 不等 | 亚毫秒级 | API 正常时同 API |
| 长期成本 | 按量计费,量大有议价空间 | 一次性采购,更新另算 | 两者叠加 |
| 运维负担 | 低 | 高,更新是长期活 | 中 |
纯离线号段库的优势是延迟和成本,一个哈希表查下去就是微秒级,量大也不心疼。但它有两个硬伤:一是号段数据会变,新号段投放、老号段回收,你的库不更新就会把新号码判成“未知”;二是携号转网铺开之后,号码的当前运营商和号段原始运营商可能完全不是一回事,离线库给出的运营商字段会失真。
纯 API 的优势是省心,服务方帮你维护数据。但它引入了网络依赖,对方抖动一下你的页面就转圈,而且按量计费在量大的时候是一笔持续支出。
我的结论是:做主链路用 API,做兜底用离线库。正常情况下走 API 拿最新数据,API 超时或者额度耗尽时切到本地号段库,只保证“省份”这个粗粒度字段可用,运营商字段在降级状态下标记为“待确认”。这样既保证了数据质量,又不会因为一个查询把整条注册链路拖死。
1.3 选型时最容易被忽略的四个隐性成本
第一个是鉴权复杂度。有些服务商的签名算法做得很绕,要在 URL 上拼参数、再算 HMAC、再对结果做一次编码,稍有不慎就是签名错误。而签名错误返回的错误码往往是通用的“参数错误”,排查起来非常费时间。选型时优先挑支持简单 Header 鉴权的,能省掉大量联调时间。
第二个是返回结构的稳定性。我遇到过一次服务方升级,把province字段从字符串改成了对象,导致下游解析直接崩掉。后来我们定了个规矩:所有外部接口的返回,一律先过一层适配层,把外部字段映射成内部统一结构,外部怎么变都只改适配层这一处。
第三个是计费口径。有的按请求次数计费,不管号码合法与否都算一次;有的按成功返回计费。如果你的业务里存在大量格式校验失败但仍会发请求的情况,前者会白白烧钱。所以参数校验一定要放在调用之前,这是省钱也是省心。
第四个是合规边界。归属地本身不涉及个人身份,但手机号是敏感信息,传输、存储、日志打印都要有约束。后面第 2.4 节会专门讲,这里先提一句:日志里绝对不要原样打印完整号码,中间四位必须打码。
2. 号码结构与接口规则拆解:先把输入输出讲透
2.1 11 位数字背后的三段划分
国内手机号是 11 位,看起来是一串,实际上可以切成三段:前 3 位是网络识别号,中间 4 位是地区编码(HLR 归属位置寄存器标识),后 4 位才是用户号码。真正决定归属地的,是前 7 位这个组合,业内叫“号段”。
举几个例子帮助理解:138开头属于早期投放的号段,覆盖面广;199、198这类是后来陆续开放的;170、171、162、165、167这一批是虚拟运营商号段,归属地字段往往只能给到省份,给不到地市;140到144段以及1064开头的,多是物联网卡、行业卡,个人业务里碰到要格外小心,这类号码通常不具备正常的短信接收能力。
这解释了为什么参数校验的正则要写成^1[3-9]\d{9}$而不是^1\d{10}$。第二种写法会把12开头、10开头的无效号也放进去,白白消耗一次接口额度。更严谨一点,可以在正则之外再叠一层号段白名单校验,把已知的物联网卡段排除掉,尤其是做营销短信的业务,这一步能显著降低发送失败率。
还有一个细节:带国际区号的情况。用户从通讯录复制过来的号码经常是+8613800138000或者008613800138000。这两串先要做归一化,去掉+86、0086、86前缀,去掉所有空格、横杠、括号,只保留纯数字,再做长度和正则校验。归一化函数必须幂等,同一串输入跑两遍结果一致,否则重试逻辑会把数据搞乱。
2.2 通用请求规则:一个典型接口长什么样
不同服务商的接口形态大同小异,基本都是 HTTP GET 或 POST,带上鉴权信息和号码参数,返回 JSON。下面这套结构是行业中比较常见的一种,我用example.com做占位,实际接入时把域名和路径换成服务方文档里的值。
GET https://api.example.com/v1/phone/attribution?phone=13800138000 Headers: X-Api-Key: <你的密钥> X-Api-Sign: <按规则计算出的签名> Content-Type: application/json返回大致是:
{ "code": 0, "message": "ok", "data": { "phone": "13800138000", "province": "某省", "city": "某市", "operator": "运营商A", "area_code": "0XXX", "number_type": "mobile", "is_virtual": false }, "request_id": "req_20240101_abcdef" }这里要盯住三个点。第一是code,一定要用服务方文档里明确的成功码去比对,不要写if code == 200,很多服务方的成功码是0或者"000000",字符串和数字混用的情况也很常见,做比较时统一转成字符串更稳。第二是request_id,出问题时把它甩给服务方技术支持,对方能直接定位到这次调用,比自己描述半天强。第三是data里的字段可空性,尤其是city,虚拟运营商号段和部分新号段经常只给省不给市,前端展示要有兜底文案,不能直接显示null。
2.3 返回字段怎么读:省市区与运营商的取值陷阱
province和city这两个字段,看着简单,用起来坑不少。第一个坑是直辖市和特别行政区的结构。这类地方的省级和市级名称往往重合,有些服务方返回的province和city是同一个字符串,有些会把city置空。前端如果写死“省 + 市”拼接,就会出现“某市某市”这种重复展示。稳妥做法是先判断两者是否相等,相等就只显示一个。
第二个坑是省直辖县级行政区。这类地方在行政区划上直接归省管,city字段有的服务方给的是县级单位名,有的是空。如果你的业务需要做地区分组统计,建议直接按province聚合,别在city上做严格假设。
第三个坑是运营商字段的时效性。前面提过携号转网,用户把号码从 A 运营商转到 B 运营商之后,号码本身没变,号段归属也没变,但当前实际服务的运营商已经变了。绝大多数归属地接口返回的是“号段归属运营商”,不是“当前运营商”。这个差异在普通展示场景无所谓,但在短信通道选路这种场景里是致命的——你按号段选了 A 运营商的通道,用户其实已经转网到 B,短信送达率会掉得很难看。这类业务必须额外接入运营商实时检测能力,归属地接口解决不了。
还有一个number_type字段值得关注,它区分普通手机号、虚拟运营商号、物联网卡等类型。做营销触达的业务应该按这个字段做分流,虚拟号段和物联网卡段的短信到达率普遍偏低,提前过滤掉能省下不少通道费。
2.4 合规边界:能查什么、绝对不能碰什么
这部分必须单独拎出来讲,因为它决定了你的方案能不能长期跑下去。归属地查询的输出只有地理和运营商,属于对号码属性的推断,不涉及个人身份识别,这是它和“手机号实名核验”这类能力的根本区别。核验类能力会返回“姓名与号码是否一致”这种结论,那才涉及个人信息处理,需要严格的授权链路。归属地不涉及,所以门槛低很多。
但低门槛不等于没门槛。我在实际项目里坚持几条底线:传输必须走 HTTPS,明文 HTTP 里带着完整号码,等于把用户信息写在明信片上寄出去;日志不能打全号,统一用中间四位打码的函数处理,比如138****0000;数据库不存原始号码,如果业务确实需要留存用于排查,存打码后的结果加上一个不可逆的哈希值即可;调用要有明确用途,别为了“以后可能用得上”就把归属地结果全量落库,这属于超范围收集。
还有一条容易被忽略:第三方接口的日志归谁管。你调服务方接口,号码会出现在对方的访问日志里。选型时看一眼对方文档里关于数据留存的说明,正规服务方会明确写日志保留周期。如果你的业务对这块特别敏感,就考虑用离线号段库把敏感号码留在本地,只把非敏感的批量统计需求走 API。
3. 从零写一个能跑起来的接入 Demo
3.1 环境准备与目录结构
我习惯用 Python 做这类联调 demo,因为改一行就能跑,不用编译。依赖只有两个:requests发请求,python-dotenv读环境变量。密钥这种东西绝对不能写死在代码里,也不能提交到版本库,这是铁律。
pip install requests python-dotenv目录结构我一般这么摆:
phone-demo/ ├── .env # 密钥配置,加入 .gitignore ├── .env.example # 配置模板,提交到仓库 ├── config.py # 配置读取与校验 ├── phone_utils.py # 号码归一化与校验 ├── attribution.py # 归属地查询核心逻辑 ├── cache.py # 本地 TTL 缓存 ├── main.py # 演示入口 └── offline_segments.py # 离线号段兜底.env.example里只放占位符:
ATTRIBUTION_API_BASE=https://api.example.com ATTRIBUTION_API_KEY=your_key_here ATTRIBUTION_API_SECRET=your_secret_here ATTRIBUTION_TIMEOUT=3.env里填真实值,同时第一时间写进.gitignore。我见过不止一次密钥被推到公开仓库的事故,补救成本很高,密钥要作废、要重新签发、要排查有没有被刷量,一个疏忽够折腾一整天。
3.2 第一版:最小可跑通的请求
先不考虑任何工程化,把请求跑通再说。这一版的目的是验证鉴权规则和返回结构,确认密钥有效、网络通、字段符合预期。
# attribution.py import os import requests from dotenv import load_dotenv load_dotenv() BASE = os.getenv("ATTRIBUTION_API_BASE") KEY = os.getenv("ATTRIBUTION_API_KEY") def query_raw(phone: str) -> dict: """最原始的一次调用,不做任何校验和兜底""" url = f"{BASE}/v1/phone/attribution" headers = {"X-Api-Key": KEY} params = {"phone": phone} resp = requests.get(url, headers=headers, params=params, timeout=3) resp.raise_for_status() return resp.json() if __name__ == "__main__": print(query_raw("13800138000"))跑这一版的时候,我建议把resp.text也打出来看一眼。有些服务方在签名错误时返回的是 HTML 错误页而不是 JSON,直接调resp.json()会抛解析异常,把真正的原因盖住了。先看原文,再解析,能省掉一轮瞎子摸象。
提示:示例里的号码
13800138000只是用来演示格式,并非真实可用的业务号码,实际测试请用自己控制的号码。
这一步能跑通,说明密钥和网络没问题。跑不通,先看 HTTP 状态码:401 和 403 基本是密钥或签名问题,404 是路径写错,429 是触发限流,5xx 是服务方侧故障,先别怀疑自己的代码。
3.3 第二版:加参数校验、异常兜底与返回适配
第二版把工程化的骨架搭起来。核心是三件事:进接口之前把号码洗干净并校验,出接口之后把各种异常收敛成统一结构,把外部字段映射成内部字段。
# phone_utils.py import re _PREFIX_PATTERN = re.compile(r"^(\+?86|0086)") _CLEAN_PATTERN = re.compile(r"[\s\-()()]") _PHONE_PATTERN = re.compile(r"^1[3-9]\d{9}$") # 已知的物联网卡、行业卡号段,个人业务里直接拒绝 _SPECIAL_SEGMENTS = ("140", "141", "142", "143", "144", "1064") def normalize(phone: str) -> str: if not phone: return "" s = str(phone).strip() s = _PREFIX_PATTERN.sub("", s) s = _CLEAN_PATTERN.sub("", s) return s def validate(phone: str) -> tuple[bool, str]: s = normalize(phone) if not s: return False, "号码为空" if len(s) != 11: return False, f"长度应为 11 位,实际 {len(s)} 位" if not _PHONE_PATTERN.match(s): return False, "号段格式不合法" if s.startswith(_SPECIAL_SEGMENTS): return False, "物联网卡或行业卡号段,不支持查询" return True, svalidate返回元组而不是抛异常,是刻意的。校验失败是高频预期内的情况,用异常控制流程会拖慢性能,也让调用方必须写 try。返回值里带上失败原因,前端可以直接展示,不用再猜。
接下来是核心查询函数,把网络异常、业务错误码、字段缺失全部收进一个统一结构:
# attribution.py import hashlib import hmac import time import requests SUCCESS_CODES = {"0", "200", "000000"} def _sign(params: dict, secret: str) -> str: """按服务方约定拼接参数后做 HMAC-SHA256""" items = sorted((k, str(v)) for k, v in params.items() if k != "sign") raw = "&".join(f"{k}={v}" for k, v in items) return hmac.new( secret.encode("utf-8"), raw.encode("utf-8"), hashlib.sha256 ).hexdigest() def query(phone: str) -> dict: """ 统一返回结构: {"ok": bool, "province": str, "city": str, "operator": str, "virtual": bool, "source": str, "error": str} """ ok, payload = validate(phone) if not ok: return {"ok": False, "error": payload, "source": "local"} number = payload params = {"phone": number, "ts": int(time.time())} headers = { "X-Api-Key": KEY, "X-Api-Sign": _sign(params, SECRET), } last_err = "" for attempt in range(2): # 最多重试一次,避免放大故障 try: resp = requests.get( f"{BASE}/v1/phone/attribution", headers=headers, params=params, timeout=3, ) resp.raise_for_status() body = resp.json() except requests.Timeout: last_err = "上游超时" continue except requests.RequestException as e: last_err = f"网络异常:{type(e).__name__}" continue except ValueError: last_err = "返回体不是合法 JSON" continue if str(body.get("code")) not in SUCCESS_CODES: return { "ok": False, "error": body.get("message", "上游返回失败"), "source": "api", } data = body.get("data") or {} province = (data.get("province") or "").strip() city = (data.get("city") or "").strip() if not province: return {"ok": False, "error": "上游未返回省份", "source": "api"} return { "ok": True, "province": province, "city": city if city != province else "", "operator": data.get("operator") or "未知", "virtual": bool(data.get("is_virtual")), "source": "api", } return {"ok": False, "error": last_err, "source": "api"}这段代码里有几个设计取舍值得说。重试只做一次,是因为归属地查询不属于关键交易链路,失败了大可以降级展示“未知”,没必要为了一个展示字段把请求堆积起来。如果上游整体故障,重试反而会把故障放大成雪崩。超时设 3 秒也偏保守,实测大多数服务方正常返回在 100ms 以内,3 秒已经是给足余量了,再长就该考虑降级。
city != province这个判断,就是为了解决前面说的直辖市重复展示问题。在这里统一处理掉,前端就不用关心行政区划的特殊性了。
3.4 第三版:本地 TTL 缓存与批量查询
到了第三版,开始考虑成本。归属地的数据特性非常适合缓存:同一个号码的归属地在相当长的周期内不会变,号段分配是稳定的,所以缓存命中率会很高。业务里同一个用户反复提交、反复查询的场景非常普遍,缓存能把上游调用量压掉一大半。
我一般用一个带过期时间的字典实现,不引入额外依赖。量大就换 Redis,接口设计保持一致。
# cache.py import threading import time class TTLCache: def __init__(self, ttl: int = 86400, max_size: int = 10000): self._data = {} self._ttl = ttl self._max = max_size self._lock = threading.Lock() def get(self, key): with self._lock: item = self._data.get(key) if not item: return None value, expire_at = item if time.time() > expire_at: self._data.pop(key, None) return None return value def set(self, key, value): with self._lock: if len(self._data) >= self._max: # 简单策略:清掉最早过期的一批 now = time.time() expired = [k for k, (_, e) in self._data.items() if e < now] for k in expired[: self._max // 4]: self._data.pop(k, None) if len(self._data) >= self._max: self._data.clear() self._data[key] = (value, time.time() + self._ttl)缓存 TTL 定 24 小时是一个折中。定太短没意义,定太长会掩盖号段变更。有人会问,号段不是基本不变吗?大部分不变,但新号段投放后,旧数据里对它的结论可能是“未知”,如果这个“未知”被缓存一整天,用户一整天都看到未知。所以我的做法是失败结果也缓存,但 TTL 缩短到 5 分钟,成功结果缓存 24 小时。这样既能防止坏结果被长期缓存,又能挡住针对同一号码的重复攻击式调用。
批量查询接口通常有数量上限,常见是单次 50 到 100 个。批量接口的价值不只是省请求数,更重要的是省往返延迟。100 个号码逐个查,光网络往返就是 100 次,批量一次搞定。但批量要处理好部分失败:一批里可能有几个号码格式不合法,接口可能整体返回成功但某项为空,代码必须逐项对齐,不能假设返回数组的顺序和入参完全一致,要靠号码字段回填。
def query_batch(phones: list[str], chunk: int = 50) -> dict[str, dict]: result = {} valid = [] for p in phones: ok, v = validate(p) if ok: valid.append(v) else: result[normalize(p)] = {"ok": False, "error": v, "source": "local"} for i in range(0, len(valid), chunk): part = valid[i : i + chunk] body = _post_batch(part) # 内部封装 POST,略 for item in body.get("data", []): num = item.get("phone") if num: result[num] = { "ok": True, "province": item.get("province", ""), "city": item.get("city", ""), "operator": item.get("operator", "未知"), "source": "api", } return result3.5 密钥管理与签名规则的两个实操要点
密钥管理上,我的建议是把密钥当作数据库密码同等对待。开发、测试、生产三套环境用不同的密钥,各自独立限额,这样测试环境跑飞了也不会影响生产额度。密钥定期轮换,轮换期间新旧密钥并行一段时间,避免切换瞬间全部请求失败。
签名这块,最容易出错的是参数排序和空值处理。大多数规则要求参数名按字典序排列后拼接,值为空的参数要么完全剔除、要么保留成key=,这两种处理方式算出来的签名完全不同。联调时先用服务方文档里的在线签名示例对一遍,确认自己算出来的字符串和示例完全一致,再去排查网络问题。我一般的做法是把参与签名的原始字符串打到 debug 日志里(注意里面不能有密钥),第一次比对一次就够,之后就稳了。
还有一个细节:时间戳参与签名时,注意时区和服务端允许的偏移。有些服务方要求时间戳与服务器时间偏差不超过 5 分钟,本地时钟不准就会一直签名失败,报错还是“签名错误”,特别迷惑。遇到这种情况先对一下时间。
4. 性能、成本与稳定性:把查询做成一个能长期跑的服务
4.1 分层缓存:本地内存 + 共享缓存
单机内存缓存在多实例部署下有个明显短板:同一个号码在 A 实例上缓存了,请求打到 B 实例还是要回源。量小无所谓,量大就是白白多花钱。解决办法是加一层共享缓存,通常是 Redis,键用号码的哈希值而不是明文,避免缓存里躺着完整号码。
分层之后读取顺序是:本地内存、共享缓存、上游接口,任何一层命中就返回。写入顺序反过来,上游返回后同时写两层。这里有个小陷阱:缓存击穿。某个热门号码的缓存刚过期,瞬间几十个请求同时回源,上游会被打出一个尖峰。解决办法是加一个短期的互斥锁,只让一个请求回源,其他请求短暂等待或者直接返回旧值。业务上完全能接受“晚 50 毫秒看到归属地”。
缓存键用哈希还有个额外好处:如果哪天缓存被误导出,哈希值无法反推出号码,等于多了一层保险。
4.2 限流与重试的正确写法
限流要分两头做。对上游要做客户端限流,也就是自己控制自己的 QPS,别等对方返回 429 才知道超了。一个简单的令牌桶就够用,每秒放固定数量的令牌,取不到就排队或者直接降级。这样能把压力平滑掉,而不是瞬间打满。
重试的要点是只用指数退避,不用固定间隔。固定间隔重试在对方故障时等于定时炸弹,所有客户端同一秒重试,把刚从故障中恢复的服务再打挂一次。退避加随机抖动,能让重试请求散开。另外,4xx 类错误基本不该重试,参数错了重试一百次还是错,只有超时和 5xx 才值得重试。
4.3 离线号段兜底与降级开关
兜底方案的价值在于“最坏情况下业务还能跑”。我的做法是维护一份精简的离线号段表,只保留省份这一级,够粗但够用。上游连续失败超过阈值(比如 10 秒内失败率超过 50%),自动切到离线模式,同时打点报警。恢复后平滑切回,不要一次性全量切,按比例灰度切更稳。
离线表不需要覆盖全部号段,覆盖头部常用号段就能扛住大部分流量,冷门号段返回“未知”完全可以接受。更新频率按季度做一次就行,别搞成负担。
5. 常见问题与排查速查表
5.1 返回空、返回错、返回慢的三类症状
返回空是最常见的。第一反应应该是看参数:号码有没有带上前缀没清掉、长度对不对、是不是虚拟号段。第二反应看鉴权:密钥是否过期、签名有没有算错、请求头名字是否拼错(大小写敏感)。第三反应看账户状态:额度是否用完、账户是否被风控。这三步走完,八成问题能定位。
返回错要看错误码分布。集中在 401 和 403,就是鉴权;集中在 429,就是限流;集中在 5xx,就是服务方侧问题,这时候该做的是降级而不是继续调。如果错误码是通用的“参数错误”,而参数看起来没问题,就重点查签名规则里的排序和空值处理。
返回慢要分段计时。DNS 解析、TCP 连接、TLS 握手、首字节时间、内容传输,五段分开看。如果卡在 TLS 握手,多半是证书链或者网络链路问题;如果卡在首字节,就是服务端处理慢。分段计时靠requests的钩子或者直接上抓包工具,别凭感觉猜。
5.2 号段异常与携号转网的三个坑
第一个坑是新号段未收录。新投放的号段在某些服务方那里会返回“未知”,尤其是一些小服务方更新滞后。遇到这种情况,先确认号码确实有效,再联系服务方确认收录计划。别急着换供应商,多数是时间差问题。
第二个坑是虚拟运营商号段信息不全。这类号段经常只给省不给市,甚至运营商字段返回的是“虚拟运营商”而不细分。如果业务强依赖地市字段,就得在展示层做兜底。
第三个坑是携号转网导致运营商失真。前面讲过,归属地接口给的是号段原始运营商。做短信通道选路这类业务,必须叠加实时运营商检测,不能只靠归属地接口。这个坑我踩过一次,短信到达率掉了将近两位数百分点,排查了半天才发现是转网用户占比比预期高。
5.3 排查速查表
| 症状 | 优先排查项 | 快速验证方法 | 处理建议 |
|---|---|---|---|
| 返回 code 非成功码 | 密钥、签名规则 | 用文档示例参数重算签名比对 | 修正排序或空值处理 |
| 返回成功但 data 为空 | 号码格式、号段是否收录 | 换一个已知正常的号码试 | 加强前置校验,联系服务方 |
| 批量接口部分为空 | 返回顺序假设错误 | 打印原始返回逐项核对 | 按号码字段回填,不按索引 |
| 偶发超时 | 网络链路、上游抖动 | 分段计时定位卡点 | 加超时、加重试退避、加降级 |
| 频繁 429 | 本地 QPS 是否超限 | 统计每秒请求数 | 客户端令牌桶限流 |
| 前端显示重复地名 | province 与 city 相等 | 打印原始字段 | 相等时只展示一个 |
| 签名正确但偶尔失败 | 本地时钟偏移 | 与标准时间比对 | 校时或放宽时间窗口 |
| 缓存后仍是旧值 | TTL 设置过长 | 检查缓存键与过期逻辑 | 失败结果用短 TTL |
注意:排查时打日志务必对号码打码,中间四位用星号替代,别为了图快把完整号码打到日志里,事后清理成本很高。
6. 上线前必须做的几件事与我踩过的坑
上线前的检查清单我固定有这么几项。超时必须显式设置,requests默认不设超时,上游卡住就会把工作线程全部占满,这是最隐蔽也最致命的故障。降级开关必须存在,一个配置项就能切到离线模式,故障时能立刻止血。失败必须可观测,把失败率、P99 延迟、降级次数做成指标,配好阈值告警,别等用户投诉才知道出了问题。缓存必须能一键清空,遇到数据异常时能快速恢复。
我踩过的第一个坑是只看 HTTP 状态码不看业务码。早期写代码觉得raise_for_status()过了就是成功,结果服务方在参数错误时也返回 200,业务码里写着失败,我们的代码直接把空 data 当成功往下传,前端就显示“未知”。后来所有外部接口一律双层判断,HTTP 层和业务层都要过。
第二个坑是重试没加退避。有次上游抖动,我们的重试逻辑是立刻重试三次,结果把对方的限流阈值瞬间打满,本来几秒钟能恢复的抖动被我们放大成了几分钟的故障。加了指数退避和随机抖动之后,同样的情况只体现为少量请求变慢,用户几乎无感。
第三个坑是缓存了失败的“未知”结果。当时没区分成功和失败,统一缓存 24 小时,结果一个新号段投放后,头一天查过的用户全部被缓存成“未知”,整整一天都是错的。改成失败结果短 TTL 之后,这个问题再没出现过。
最后一个体会:归属地查询这种事,最怕的不是技术难,而是想当然。想当然认为返回一定有 city,想当然认为运营商字段就是当前运营商,想当然认为号码格式一定标准。把每一个“想当然”都换成一次显式校验和兜底,这套东西就能安安稳稳跑很久。至于后续扩展,我目前在做的是把归属地结果和用户注册地、常用登录 IP 做交叉比对,输出一个“地域一致性”评分给风控用,这块还在调阈值,等稳定了再单独整理一篇。