手机号码归属地离线数据库:号段查询引擎与避坑实践
简介手机号码归属地数据库是一份面向开发者和数据分析人员的实用数据资源收录超过38万条国内手机号码记录涵盖省份、城市、运营商及号段归属等关键字段能够用于号码校验、用户画像、风控分析等场景。压缩包体积小巧仅含2个文件——1个SQL脚本与1个MDB数据库文件合计8.27MBSQL脚本便于在MySQL中一键建表导入MDB文件则适合Access等桌面数据库工具直接打开两种格式互补方便不同技术栈的读者使用。数据内容覆盖全国主要号段包含号码、省份、城市、运营商等字段并附有按号码查询归属地的SQL示例读者可据此快速建立查询接口也可进一步设计省市区与运营商维度的扩展表、添加索引或对接定时更新流程。目前已有730人学习下载适合刚接触号码归属地查询或需要快速集成相关数据的开发者参考。1. 手机号码归属地数据库离线查询为什么比在线接口更靠谱做风控、做用户画像、做消息推送触达几乎每个项目都绕不开一个看似不起眼的需求拿到一个手机号判断它属于哪个城市、哪家运营商。第一次做这个功能的人十有八九会直接调在线接口结果线上流量一起来第三方接口超时、限流、费用飙升账单比服务器还贵。手机号码归属地数据库本质上是把这份数据以号段为索引、以离线文件形式落到本地让查询从网络 IO 变成内存或磁盘查找。这篇文章写给被在线接口折磨过、或者正准备自建这个服务的后端开发者。我们会从数据怎么组织讲起再把查询引擎、API 封装、更新策略完整走一遍最后把虚拟运营商、携号转网这些真正的坑一次性排掉。全部方案都基于常见做法代码可以直接改改落地。2. 数据从哪里来号段表、运营商记录与更新源头2.1 归属地数据的本质号段前缀与三要素映射手机号码归属地查询的数据模型其实非常简单核心是三要素号段前缀、归属地、运营商。一个 11 位手机号前 3 位是网络识别号中间 4 位是地区编码后 4 位是用户号码。所谓“号段”在数据文件里通常体现为前 7 位比如 1381234 这个前缀可以精确到城市。前 3 位只能精确到运营商和大致区域7 位号段才是归属地查询的最小可用粒度。数据文件的常见格式是 CSV 或按行分隔的文本每行一条记录字段顺序一般是号段前缀、省份、城市、运营商类型、区号、邮编。运营商类型一般是字符串比如“中国移动”“中国联通”“中国电信”也有细分到“移动 GSM”“联通 WCDMA”的版本但对绝大多数业务来说粗粒度就够了。数据来源主要有三条路。第一条是运营商公开的号段分配公告这最权威但分散在各个时期需要自己整理。第二条是网上流传的“号段大全”文件这类文件时效性参差不齐下载之后必须抽样验证。第三条是从实时查询接口反推跑一批随机号码把返回结果累积成本地表但这样做有合规风险和频率限制我一般只用来做小规模交叉验证。2.2 原始 CSV 长什么样先看清数据再谈建库我建议你不管最终用什么存储第一步先把原始数据统一转成这份标准结构。以下是典型的号段数据预览用来做后面的建库操作。$ head -n 5 phone_segments.csv prefix,province,city,carrier,area_code,postcode 1381234,广东省,广州市,中国移动,020,510000 1381235,广东省,广州市,中国移动,020,510000 1381236,广东省,深圳市,中国移动,0755,518000 1381237,广东省,深圳市,中国移动,0755,518000这份 CSV 的每一行代表一个 7 位号段前缀而不是单个手机号。这意味着全量数据量级在几十万行左右解压后纯文本大概几 MB 到十几 MB完全装得进内存。正因为这个规模特征手机号码归属地数据库的查询引擎根本不需要引入 MySQL、Redis 这类重型组件一个设计良好的进程内索引就足够了。2.3 数据校验脚本过滤脏行与重复前缀网络上下载的号段文件最常见的脏数据有三类一是带 BOM 头二是前缀混入空格或全角字符三是同一前缀出现多行但归属地不一致。我习惯先写一个 Python 脚本做预处理把数据洗成内部标准格式。import csv import re from collections import defaultdict def normalize_row(row): prefix str(row[prefix]).strip().replace(\ufeff, ) if not re.fullmatch(r1\d{6}, prefix): return None return { prefix: prefix, province: row[province].strip(), city: row[city].strip(), carrier: row[carrier].strip(), area_code: row[area_code].strip(), postcode: row[postcode].strip(), } def build_clean_file(src_path, dst_path): seen defaultdict(list) with open(src_path, encodingutf-8-sig) as f: for row in csv.DictReader(f): item normalize_row(row) if item: seen[item[prefix]].append(item) with open(dst_path, w, encodingutf-8) as f: writer csv.DictWriter(f, fieldnameslist(row.keys())) writer.writeheader() for prefix, items in seen.items(): provinces {i[province] for i in items} if len(provinces) 1: continue writer.writerow(items[0]) build_clean_file(raw_phone.csv, clean_phone.csv)这里有几个参数值得说明。utf-8-sig编码能自动吃掉 BOM避免第一条记录的prefix字段混入隐藏字符。正则1\d{6}校验 7 位前缀以 1 开头号码段规则决定了手机号不可能以其他数字开头。seen字典按前缀聚合如果同一前缀出现两个不同省份说明源文件本身有错误直接丢弃该前缀宁缺毋滥。3. 把查询跑起来三种进程内索引方案对比与选型3.1 方案一Python 字典直查简单但费内存最直接的做法是把全部号段加载成 Python dict键是 7 位前缀值是三元组。查询时取电话号码前 7 位一次 dict 查找完成。这套实现在代码量上最省但内存占用要心里有数几十万条记录存成 Python 对象每条记录的字符串、元组、字典开销叠加整体内存会膨胀到 200MB 以上。对小项目这不算问题但如果是常驻服务、并且和业务进程共享内存这 200MB 就有点肉疼了。更关键的是Python 的字符串比较和哈希计算在 QPS 上万时会有明显的 CPU 开销。所以我把这个方案定位为“原型验证”生产环境我会往下看。3.2 方案二排序数组加二分查找内存和速度的平衡点这是我最常用的方案。把全部记录按前缀排序后存入数组查询时用二分查找定位前缀。Python 标准库的bisect模块提供了现成实现代码非常短。import bisect import csv class SegmentIndex: def __init__(self, csv_path): self.prefixes [] self.metadata [] with open(csv_path, encodingutf-8) as f: reader csv.DictReader(f) for row in reader: self.prefixes.append(row[prefix]) self.metadata.append((row[province], row[city], row[carrier])) self._sorted sorted(zip(self.prefixes, self.metadata)) self.prefixes [p for p, _ in self._sorted] self.metadata [m for _, m in self._sorted] def lookup(self, phone): prefix phone[:7] pos bisect.bisect_left(self.prefixes, prefix) if pos len(self.prefixes) and self.prefixes[pos] prefix: return self.metadata[pos] return None初始化时把前缀和元数据打包排序再拆回两个平行数组是为了让bisect只对字符串列表操作命中的下标直接对应元数据列表。bisect_left找到插入点如果插入点位置的值恰好等于目标前缀就返回命中否则说明该号段在表中不存在。这个方案在几十万条数据规模下内存大约只有字典方案的四分之一单次查询耗时稳定在微秒级。3.3 方案三前缀树 / Trie适合需要前缀模糊匹配的场景如果你的业务不止查归属地还希望支持“某个前缀下所有号码段”这类聚合查询Trie 结构更好用。比如运营商的号段分配分析、按城市统计号段数量Trie 可以天然按前缀层级做遍历。但 Trie 的节点对象在 Python 里开销不小构建也慢单纯为了单点查询它没有优势。我给出的实践建议是只做单号归属地查询无脑选二分数组需要前缀聚合统计再考虑 Trie如果号码量极小比如就几千条内部白名单直接用 dict 反而是最省事的。选型没有银弹数据规模和查询形态决定了方案差异。3.4 基准测试先量化再上线上线前我会用下面的脚本做一个简单压测确认单次查询耗时和启动加载时间。测试机配置不用太好普通虚拟机就能看出问题。import random import time from segment_index import SegmentIndex idx SegmentIndex(clean_phone.csv) phones [1381234 str(random.randint(1000, 9999)) for _ in range(100000)] start time.perf_counter() for p in phones: idx.lookup(p) cost time.perf_counter() - start print(f100k queries cost {cost:.3f}s, avg {cost / len(phones) * 1e6:.2f}us/query)random.randint(1000, 9999)生成的轨迹号码可以验证查询是否命中也可以混入一些不存在的号码段测试未命中路径。压测至少要覆盖两种情况全命中前缀都在表里和全未命中比如1999999这种实际不存在的段)。二分查找在未命中时要多走几次比较耗时差异通常不超过 20%但如果你的实现里未命中走了异常捕获性能会差出一个数量级。4. 封装成 HTTP 接口参数设计、缓存策略与并发控制4.1 接口路径与返回结构约定本地查询引擎做好之后下一步是把它暴露成服务。常见做法是用 FastAPI 或 Flask 包一层 HTTP 接口推荐 FastAPI因为它的异步能力和数据校验能直接复用。接口路径我一般设计成GET /v1/location/{phone}返回统一 JSON方便前端和后端联调。{ code: 0, data: { phone: 13812345678, prefix: 1381234, province: 广东省, city: 广州市, carrier: 中国移动, area_code: 020 } }这里的code字段故意保留未命中号段返回404或code: 10001而不是直接 HTTP 500。因为调用方经常需要区分“号码段不存在”和“服务内部错误”两种场景。4.2 用 LRU 缓存把热门号码段挡住实际流量分布极不均匀少数热门号段会占据大部分请求量比如某个营销活动的地域集中在几个城市。这正好适合 LRU 缓存。functools.lru_cache可以直接装饰查询函数最大缓存条数通常设置在 4096 到 65536 之间太小命中率不够太大又消耗内存。from functools import lru_cache lru_cache(maxsize16384) def lookup_with_cache(prefix): return segment_index.lookup(prefix)注意这里把查询参数从完整 11 位手机号改成 7 位前缀。因为同一个前缀的所有号码查询结果相同按前缀缓存等于把重复查询压缩了 10 的 4 次方倍。maxsize16384意思是只缓存最近查询的 16384 个前缀对冷数据自动淘汰不需要额外写淘汰逻辑。4.3 并发与超时线程池隔离还是协程直跑FastAPI 的异步接口里直接调用同步的二分查找没有大问题因为单次查询在微秒级不会阻塞事件循环太久。但如果你在查询前后加了日志、埋点、数据库记录等 IO 操作建议把查询和 IO 分开查询走内存IO 走异步任务。对外暴露服务时还有两个参数要调。一个是接口超时时间建议设置 500ms 到 1s因为本地查询理论上不可能超过 1ms超过 10ms 基本可以认定出了 bug。另一个是并发限制可以用slowapi这类库做单 IP 限流归属地查询服务最常见的滥用是被人拿来批量爬全量号段限流策略按 IP 维度每分钟 600 次是比较常见的经验值。5. 归属地查询必踩的 6 个坑虚拟运营商、携号转网与数据时效5.1 虚拟运营商号段查出来是空或者运营商不对现象输入 170、171、162、165、167 开头的号码返回结果要么是 null要么运营商字段显示成“未知”。原因虚拟运营商租用基础运营商的网络号段独立分配但很多网上下载的号段表根本没收录这部分数据。部分表收录了虚拟运营商但归属地只标到“全国”或“北京”因为虚拟运营商的放号范围本身就不受传统地区限制。解决单独维护一张虚拟运营商号段表至少保证carrier字段能识别为“虚商”不要把 null 直接透传给前端。业务侧如果只是做地域统计建议把虚商号码归入“未知地区”桶避免污染城市维度数据。5.2 携号转网让归属地城市变成“历史数据”现象用户手机号是 138 开头的广州号码携号转网到联通之后归属地数据库仍然返回“广东移动”运营商标记错误用户投诉“号码不变运营商变了”。原因手机号码本身不换但运营商变了。号段表只能反映初始放号时的运营商归属无法感知转网后的真实运营商。解决归属地库定位为“号段注册地”而非“当前运营商”。在接口文档里明确写清返回字段是segment_carrier还是current_carrier前者来自号段表后者不在离线库能力范围内。如果业务流程必须知道当前运营商只能对接运营商侧校验能力不要把离线库当成万能数据。5.3 7 位前缀查得到但 3 位前缀查不到现象用户传了13812345678库返回正常但传一个不存在的号段如1990000返回 null。这时候有人会想加一层“前 3 位”兜底让前 3 位有记录就返回省份甚至运营商。原因7 位号段表覆盖的是已放号段而 3 位前缀覆盖的是所有潜在的号码空间两者表结构不同。解决不要做 3 位兜底。归属地查询的价值在于精确到城市3 位前缀只能模糊到省级而且很可能把未放号的区间误判成某省。如果产品上确实需要一个默认值返回“未知”并让业务方决定降级文案比返回一个误导性的省份合理得多。5.4 文件更新后内存还是旧版本现象运维替换了 CSV 文件但服务长时间不重启查询结果一直不更新。等发现时已经过去几周期间新增号段全部查询失败。原因查询引擎在初始化时把数据一次性加载进内存文件更新对运行中的进程不可见。没有实现热加载机制。解决常见做法是双缓冲切换。初始化时持有 A 版本对象后台线程周期性检查文件变化检测到变更后构建 B 版本对象构建完成后原子替换指针。查询线程全程只读旧版或新版不存在部分更新的中间态。5.5 号段数据文件编码不一致导致乱码现象在 Linux 上跑cat看数据没问题但程序加载后城市名出现æµå·或者一类乱码。原因网上流传的号段文件可能是 GBK、GB18030 编码也可能是 UTF-8 带 BOM混用编码是常态。Python 默认按 UTF-8 读取时会直接解码失败或读到乱码字符。解决预处理阶段统一检测编码。用charset-normalizer库自动识别源文件编码转成 UTF-8 后再进清洗流程。这一步放在最开始别等建库之后才排查。5.6 定时任务更新号段表结果把数据弄丢了现象服务器上配置了每周五凌晨自动下载号段文件并更新某次执行完发现查询命中率暴跌大量号码返回未知。原因下载源服务器返回了 302 跳转或反爬页面脚本没有校验内容把只有几百字节的 HTML 错误页当成新号段表写入了库。解决更新流程必须加校验关卡。文件大小低于阈值、行数波动超过 30%、必备字段缺失任一条件不满足就拒绝执行替换并发送告警。宁可让服务用旧数据继续跑也不要在凌晨把好端端的索引变成废表。6. 查询引擎的下一步增量更新与更快的哈希索引当数据规模从几十万涨到几百万比如带上了历史号段回溯和多个运营商维度二分数组虽然还能扛但有两个改进方向值得做一是把二分查找换成布隆过滤器加哈希表的组合二是把冷热数据分层热段走哈希冷段走二分。布隆过滤器的价值体现在未命中场景。查询不存在的号段时二分查找要走log2(N)次字符串比较而布隆过滤器只做几次哈希位检测能直接告诉你“这个前缀大概率不存在”省掉后续的二分查找。代价是极小概率的误判误判了再走一次哈希表兜底即可精度不会受影响。哈希表方向的改进更简单把 7 位前缀直接转成整数键映射到预分配的数组槽位。7 位号段即1开头加 6 位数字最多 100 万种可能一个布尔数组加一个数据数组就能做到 O(1) 查询内存占用比字典低得多。我自己常用这个方案替代纯二分代码量多一点但压测下来吞吐基本是翻倍的。最后聊一个生产习惯无论查询引擎做得多快我上线前永远会准备一份“手工核验号码清单”里面放 20 个真实在用的号段和 10 个不存在的号段每次发布完拿起手机号码对着查一遍。这个动作没有任何技术含量但防住了无数次把数据文件路径配错的低级翻车。归属地库这类基础设施稳定性不是靠复杂架构堆出来的而是靠每次更新都认真做回归验证。希望这套思路对你做号段查询项目有实际帮助。本文还有配套的精品资源点击获取