手机归属地查询按前 7 位号段做缓存:实测粒度与 Redis 实现

约 13 分钟 高级手机号码归属地查询缓存策略RedisLRU
# 手机归属地查询按前 7 位号段做缓存:实测粒度与 Redis 实现 接口:手机归属地查询(apiCode=6,接入点 6-1)|是否免费:是,注册后默认可调用,设使用档次限制|请求方式:POST / GET|返回格式:JSON|适用人群:调用量接近档位上限、需要降低请求数的开发者|阅读时间:约 9 分钟|最后实测核对:2026-09-18 缓存键用手机号会浪费大量命中机会,因为归属地结果只跟前 7 位号段有关。缓存键用前 7 位,命中率会明显不同。这个粒度不是推断出来的,下面给出对照实验数据,再给内存和 Redis 两套实现。 ## 核心要点 - 缓存键是前 7 位号段,不是完整手机号。实测同一号段的不同号码返回完全一致。 - 数据半年同步一次新增号段归属信息,缓存 TTL 可以设得比较长,不必按天清理。 - 命中缓存返回 `ret_code` 为 0 的记录,失败结果不建议长期缓存,避免负结果被反复沿用。 ## 粒度怎么定的:对照实验 用同一前缀、不同后缀的输入各调一次,看结果是否随后缀变化。 | 输入 | 号段 | 返回的 `prov` / `city` | 返回的 `num` | |------|------|------------------------|--------------| | `13800130000` | 1380013 | 北京 / 北京 | 1380013 | | `13800139999` | 1380013 | 北京 / 北京 | 1380013 | | `13800140000` | 1380014 | 北京 / 北京 | 1380014 | | `13800240000` | 1380024 | 辽宁 / 沈阳 | 1380024 | 第一行和第二行的前 7 位相同、后 4 位不同,返回结果完全一致,说明后 4 位不参与匹配。第四行的第 7 位不同,返回的城市从北京变成沈阳,说明第 7 位参与匹配。 结论是缓存键取前 7 位,后 4 位是无效位。同一批实测里 `138-0013-8000` 和 `13800138000` 返回也一样,说明输入里的连字符在匹配前已被清理,缓存键用清理后的号段。 ## 内存缓存:单实例用 LRU ```python import re from functools import lru_cache import requests API_URL = "https://route.showapi.com/6-1" APP_KEY = "YOUR_APPKEY" # 正式环境从配置或密钥管理服务读取 def to_segment(raw) -> str | None: cleaned = re.sub(r"[-\s]", "", str(raw or "").strip()) if not re.fullmatch(r"\d{7,}", cleaned) or cleaned[0] == "0": return None return cleaned[:7] @lru_cache(maxsize=20000) def locate_segment(seg: str) -> dict: """按号段查询,结果进进程内 LRU。maxsize 按可用内存调整。""" resp = requests.get( API_URL, params={"appKey": APP_KEY, "num": seg}, timeout=10, # 对齐官方 x-read-timeout ) data = resp.json() if data.get("showapi_res_code") != 0: raise RuntimeError(data.get("showapi_res_error")) body = data.get("showapi_res_body", {}) if body.get("ret_code") != 0: raise LookupError(f"ret_code={body.get('ret_code')} {body.get('remark', '')}") return { "prov": body.get("prov"), "city": body.get("city"), "name": body.get("name"), "type": body.get("type"), "num": body.get("num"), "areaCode": body.get("areaCode"), "postCode": body.get("postCode"), "provCode": body.get("provCode"), "cityCode": body.get("cityCode"), } def locate(phone: str) -> dict | None: seg = to_segment(phone) if not seg: return None try: return locate_segment(seg) except LookupError: return None # 号段查不到,不缓存,下次重查 ``` `lru_cache` 把结果存在进程内存里,多实例部署时每个实例各存一份,命中率会打折,而且失败结果不会进缓存(异常不会被 `lru_cache` 记录),这一点正好符合「失败结果不长期缓存」的要求。 ## Redis 缓存:多实例共用 ```python import json import re import redis import requests r = redis.Redis(host="127.0.0.1", port=6379, db=0, decode_responses=True) TTL_SUCCESS = 30 * 24 * 3600 # 成功结果 30 天,数据半年更新一次,留足余量 TTL_FAILURE = 3600 # 失败结果只留 1 小时,避免负结果被反复沿用 LOCK_TTL = 10 def locate_with_redis(phone: str) -> dict | None: seg = to_segment(phone) if not seg: return None key = f"phone:seg:{seg}" # 键就是前 7 位号段 cached = r.get(key) if cached: rec = json.loads(cached) return rec.get("data") # 简单防击穿:拿不到锁就等一小会儿再读缓存 lock = r.lock(f"{key}:lock", timeout=LOCK_TTL, blocking_timeout=5) if not lock.acquire(): cached = r.get(key) return json.loads(cached).get("data") if cached else None try: cached = r.get(key) if cached: return json.loads(cached).get("data") resp = requests.get( API_URL, params={"appKey": APP_KEY, "num": seg}, timeout=10, ) body = resp.json().get("showapi_res_body", {}) if body.get("ret_code") != 0: r.setex(key, TTL_FAILURE, json.dumps( {"ok": False, "ret_code": body.get("ret_code")}, ensure_ascii=False)) return None data = {k: body.get(k) for k in ("prov", "city", "name", "type", "num", "areaCode", "postCode", "provCode", "cityCode")} r.setex(key, TTL_SUCCESS, json.dumps({"ok": True, "data": data}, ensure_ascii=False)) return data finally: lock.release() ``` 多实例部署时用这套,缓存命中落在 Redis 上,各实例共享同一份数据。 ## TTL 为什么可以设 30 天 产品说明写明每半年同步一次新增号段归属信息。已收录号段在两次同步之间不会变化,所以成功结果的 TTL 设 30 天不会带来数据偏差。新增号段第一次查询会返回 `-5`,失败结果用短 TTL,等号段同步进库后重查就能拿到数据。 把成功结果和失败结果分成两档 TTL,是这套缓存里最值得保留的一点:成功的可以放长,失败的必须放短。 ## 并发保护与档位 缓存之外的请求量最终还是要落在档位上。免费档位的每日调用量与并发上限见[免费接口与档位说明](https://www.showapi.com/free-api),L0 档是 100 次/天、1 QPS。 两个做法能压住请求量。第一,进程内先查 LRU,再查 Redis,最后才发请求,形成两级缓存。第二,对同一号段的并发请求做合并,Redis 的分布式锁就是干这件事的,拿到锁的那个请求去查,其余请求等它写回后读缓存。 ## FAQ **Q1:缓存键用完整手机号行不行?** 能用,但命中率低。实测归属地结果只跟前 7 位号段有关,同一号段的不同号码返回完全一致。用前 7 位做键,命中率更高。 **Q2:为什么可以用前 7 位,不会有例外吗?** 实测对照过:前 7 位相同、后 4 位不同的两个号码,返回结果完全一样;第 7 位不同的号码,返回可能落到另一个城市。匹配键就是前 7 位。 **Q3:缓存过期时间设多久合适?** 成功结果可以设 30 天。数据每半年同步一次新增号段归属信息,两次同步之间已收录号段不变。失败结果建议只留 1 小时,等新号段入库后重查。 **Q4:查询失败的结果要不要缓存?** 建议短时间缓存,不要长期缓存。长期缓存会让一个暂时的失败结果被反复沿用,即使号段后来入库了也拿不到数据。 **Q5:多实例部署缓存怎么共用?** 用 Redis,键是前 7 位号段,各实例读写同一份数据。进程内再加一层 LRU 做本地热点缓存,减少 Redis 往返。 **Q6:缓存能减少多少调用?** 取决于业务里号段的集中度,不同业务差别很大。可以先用脚本统计一段时间内不同号段数与总请求数的比值,用这个比值评估效果,不要预设一个固定数字。 ## 下一步阅读 - [手机归属地查询:用 Python 给 Excel 号码表批量补归属地](https://www.showapi.com/guides/phone-attribution-excel-batch-6) —— 去重统计和缓存键口径一致 - [手机归属地查询返回字段全解:11 个字段的类型、取值与粒度](https://www.showapi.com/guides/phone-attribution-response-fields-6) —— 缓存里存哪几个字段 - [手机归属地查询:在注册表单里做实时校验与运营商识别](https://www.showapi.com/guides/phone-attribution-form-validation-6) —— 表单场景的两级缓存 - **本系列共 12 篇**:查看[手机归属地查询指南总目录](https://www.showapi.com/guides/phone-attribution-guides-6)
加载中...