简介:这份资源是一份手机号码归属地查询数据库,以MySQL的SQL文件形式提供,适合从事数据分析、营销系统开发、客户服务支撑的开发者与数据库学习者使用。包内共1个文件,为phone_msg.sql,压缩包约2.22MB,导入MySQL后即可通过手机号字段关联查询对应的省份与城市信息,数据覆盖较为完整,可用于批量归属地匹配、区域用户统计等场景。已有267人学习下载。资源价值在于省去自行采集与整理号码段归属关系的成本,可直接用于SQL查询练习、JOIN与GROUP BY等聚合分析实战,也能作为数据脱敏、权限控制与个人信息合规使用的教学案例。需要提醒的是,涉及个人敏感信息时应遵守《个人信息保护法》,在合法合规前提下使用并做好加密存储与访问限制。
1. 手机号归属地查询:从一份 MySQL 数据表到可上线的接口
手上有个用户表,几十万行手机号,运营要按省份做短信分流,风控要按城市判断异常登录。你第一反应可能是调第三方 API,但量一上来,按次计费的成本和网络延迟都让人难受。这时候一份本地的手机号归属地 MySQL 数据表就成了刚需——标题里说的「非常全,淘宝50元买的」,本质就是一张覆盖号段、省份、城市、运营商、区号、邮编的映射表。它解决的是「离线、批量、零调用成本」的查询问题,适合做后台批处理、数据清洗、用户画像补全的开发者。这篇不讲虚的,从建表、导入、索引设计到查询优化,把这条链路走通,顺带把几个容易翻车的地方说清楚。
2. 手机号归属地数据的结构:号段、省份、城市怎么对应
2.1 手机号前七位才是归属地的钥匙
很多人以为手机号归属地是按前三位查的,这是个常见误解。前三位只代表运营商(比如 138 是移动),真正决定省份和城市的是前七位。中国手机号是 11 位,结构是「3 位网络识别号 + 4 位地区编码 + 4 位用户号码」。归属地库的核心就是那 4 位地区编码,它和省份、城市一一对应。
所以一张标准的归属地表,主键或唯一索引应该建在号段前七位上,而不是完整手机号。完整手机号有 11 位,前七位相同意味着归属地相同,用前七位做键能把数据量压缩到几十万行级别,查询时也只需要截取前七位去匹配,效率高得多。
常见的数据表字段设计如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT UNSIGNED AUTO_INCREMENT | 主键 |
| prefix | CHAR(7) | 号段前七位,唯一索引 |
| province | VARCHAR(20) | 省份 |
| city | VARCHAR(30) | 城市 |
| operator | VARCHAR(20) | 运营商 |
| area_code | VARCHAR(6) | 区号 |
| post_code | VARCHAR(6) | 邮编 |
这张表看起来简单,但字段长度和字符集选错,后面查询和存储都会出问题。province 和 city 用 utf8mb4 是稳妥的,虽然归属地基本都是中文,但有些城市名带生僻字,utf8 三字节可能不够。prefix 用 CHAR(7) 而不是 VARCHAR,因为长度固定,CHAR 在索引里更紧凑。
2.2 建表语句与索引策略
直接上建表 SQL,注意字符集和索引的写法:
CREATE TABLE `phone_attribution` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `prefix` CHAR(7) NOT NULL COMMENT '号段前七位', `province` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '省份', `city` VARCHAR(30) NOT NULL DEFAULT '' COMMENT '城市', `operator` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '运营商', `area_code` VARCHAR(6) NOT NULL DEFAULT '' COMMENT '区号', `post_code` VARCHAR(6) NOT NULL DEFAULT '' COMMENT '邮编', PRIMARY KEY (`id`), UNIQUE KEY `uk_prefix` (`prefix`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='手机号归属地库';这里有几个参数值得说。UNIQUE KEY uk_prefix是必须的,因为号段不能重复,重复了查询会返回多行,业务层还得去重。ENGINE=InnoDB不用犹豫,MyISAM 虽然读快,但不支持事务,导入中途失败会留下脏数据。utf8mb4_general_ci排序规则对中文够用,如果要做拼音排序再换utf8mb4_unicode_ci。
导入数据时,如果拿到的是 CSV 或 SQL 文件,用LOAD DATA INFILE比逐条 INSERT 快一个数量级:
LOAD DATA LOCAL INFILE '/path/to/phone_data.csv' INTO TABLE phone_attribution FIELDS TERMINATED BY ',' ENCLOSED BY '"' LINES TERMINATED BY '\n' IGNORE 1 ROWS (prefix, province, city, operator, area_code, post_code);LOCAL关键字允许从客户端读文件,如果服务端和客户端不在同一台机器,去掉 LOCAL 并把文件放到服务端 secure_file_priv 目录下。IGNORE 1 ROWS跳过 CSV 表头。导入前先把unique_checks关掉能再快一点,导完再打开:
SET unique_checks = 0; -- 执行 LOAD DATA SET unique_checks = 1;注意,关掉唯一性检查期间如果有重复号段,导入不会报错,但后续查询可能出问题。所以导入完成后要跑一次去重检查:
SELECT prefix, COUNT(*) AS cnt FROM phone_attribution GROUP BY prefix HAVING cnt > 1;如果返回空,说明数据干净。有重复的话,用DELETE配合子查询清理,保留 id 最小的那条。
3. 查询接口怎么写:从 SQL 到代码层的完整链路
3.1 单条查询与批量查询的 SQL 差异
单条查询很简单,截取前七位去匹配:
SELECT province, city, operator, area_code, post_code FROM phone_attribution WHERE prefix = LEFT('13812345678', 7);LEFT函数在 MySQL 里对字符串操作很快,但更好的做法是在应用层截取好再传进来,避免数据库做函数计算导致索引失效。虽然LEFT用在等值查询的右侧不影响索引,但养成习惯没坏处。
批量查询是实际业务里更常见的场景。比如一次要查 1000 个手机号的归属地,用IN比循环单查快得多:
SELECT prefix, province, city, operator FROM phone_attribution WHERE prefix IN ('1381234', '1395678', '1501234', ...);但IN列表太长会撑爆 SQL 长度限制,一般建议每批不超过 500 个。如果数据量再大,用临时表 JOIN 的方式:
CREATE TEMPORARY TABLE tmp_prefix (prefix CHAR(7) PRIMARY KEY); INSERT INTO tmp_prefix VALUES ('1381234'), ('1395678'), ...; SELECT t.prefix, p.province, p.city, p.operator FROM tmp_prefix t LEFT JOIN phone_attribution p ON t.prefix = p.prefix;临时表在会话结束时自动删除,不会污染正式表。LEFT JOIN保证即使某个号段查不到,也能返回行,业务层可以标记为「未知归属地」。
3.2 应用层封装:Python 与 Java 的查询示例
Python 用 pymysql 封装一个查询函数:
import pymysql def get_attribution(phone_number): prefix = phone_number[:7] conn = pymysql.connect( host='127.0.0.1', user='app_user', password='your_password', database='phone_db', charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor ) try: with conn.cursor() as cursor: sql = "SELECT province, city, operator, area_code, post_code FROM phone_attribution WHERE prefix = %s" cursor.execute(sql, (prefix,)) result = cursor.fetchone() return result if result else {'province': '未知', 'city': '未知', 'operator': '未知'} finally: conn.close()这里用参数化查询%s而不是字符串拼接,防止 SQL 注入。DictCursor让返回结果直接是字典,省去手动映射字段。连接用完就关,如果 QPS 高,应该换成连接池,比如 DBUtils 或 SQLAlchemy 的 pool。
Java 用 JDBC 的写法类似:
public Attribution query(String phone) throws SQLException { String prefix = phone.substring(0, 7); String sql = "SELECT province, city, operator, area_code, post_code FROM phone_attribution WHERE prefix = ?"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, prefix); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { Attribution attr = new Attribution(); attr.setProvince(rs.getString("province")); attr.setCity(rs.getString("city")); attr.setOperator(rs.getString("operator")); attr.setAreaCode(rs.getString("area_code")); attr.setPostCode(rs.getString("post_code")); return attr; } } } return Attribution.unknown(); }PreparedStatement预编译 SQL,既防注入又提升重复执行效率。dataSource用 HikariCP 或 Druid 都行,连接池大小根据并发量调,一般 10 到 20 个连接能扛住几百 QPS。
3.3 缓存层:Redis 把查询压到毫秒级
MySQL 单表几十万行,走唯一索引查询,单次大概 1 到 3 毫秒。但如果 QPS 上千,数据库连接池会成为瓶颈。加一层 Redis 缓存,把热点号段的结果缓存起来,能把响应压到 0.5 毫秒以内。
缓存键用phone:attr:{prefix},值存 JSON 字符串,过期时间设 7 天:
import json import redis r = redis.Redis(host='127.0.0.1', port=6379, db=0) def get_attribution_cached(phone_number): prefix = phone_number[:7] cache_key = f"phone:attr:{prefix}" cached = r.get(cache_key) if cached: return json.loads(cached) result = query_from_mysql(prefix) if result: r.setex(cache_key, 604800, json.dumps(result, ensure_ascii=False)) return resultsetex的 604800 是 7 天秒数。ensure_ascii=False让中文正常存储,不然会变成\uXXXX转义。缓存穿透的问题——查一个不存在的号段,每次都打到 MySQL——可以用空值缓存解决:查不到也存一个{"province": "未知"},过期时间设短一点,比如 1 小时。
4. 避坑与排查:导入、查询、性能的五个血泪教训
4.1 导入时中文乱码,查出来全是问号
现象:CSV 导入后,province 和 city 字段显示为???或乱码。
原因:CSV 文件编码是 GBK,而 MySQL 表是 utf8mb4,LOAD DATA默认按表字符集解析文件,导致中文被错误解码。
解决:导入前用iconv转码,或者在LOAD DATA里指定字符集:
LOAD DATA LOCAL INFILE '/path/to/phone_data.csv' INTO TABLE phone_attribution CHARACTER SET gbk FIELDS TERMINATED BY ',' ...如果已经导入错了,用ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4也救不回来,只能清表重导。所以导入前先用file -i phone_data.csv确认编码,别凭感觉。
4.2 号段重复导致查询返回多行
现象:同一个手机号查出来两条记录,省份还不一样。
原因:数据源本身有重复号段,导入时没做唯一性校验,或者unique_checks关掉后重复数据混进去了。
解决:先跑去重查询,确认重复后清理:
DELETE t1 FROM phone_attribution t1 INNER JOIN phone_attribution t2 WHERE t1.prefix = t2.prefix AND t1.id > t2.id;这条 SQL 保留每个号段 id 最小的记录,删掉其余的。执行前先SELECT确认影响行数,别直接在生产库上跑。
4.3 用 LIKE 模糊查询导致全表扫描
现象:查询变慢,EXPLAIN显示type=ALL。
原因:有人写WHERE prefix LIKE '138%',虽然能走索引,但如果写成LIKE '%138%'就废了。更常见的是直接拿完整手机号去查WHERE phone LIKE '1381234%',而表里根本没有 phone 字段,只能全表扫。
解决:永远用等值查询WHERE prefix = '1381234',别用 LIKE。如果业务需要按省份查,给 province 单独建索引,但注意区分度低(全国就 34 个省份),索引效果一般,不如走缓存。
4.4 连接池耗尽,报错 Too many connections
现象:应用日志里频繁出现ERROR 1040 (HY000): Too many connections。
原因:每次查询都新建连接,没关或者没复用,连接数涨到 MySQL 的max_connections上限(默认 151)。
解决:用连接池,Python 用 DBUtils.PooledDB,Java 用 HikariCP。同时检查代码里有没有conn.close()漏掉的分支,尤其是异常路径。临时可以调大max_connections,但治标不治本:
SET GLOBAL max_connections = 500;这个设置重启后失效,要永久生效得改my.cnf里的max_connections。
4.5 缓存与数据库不一致,更新号段后查不到新数据
现象:数据表里更新了某个号段的归属地,但接口返回的还是旧值。
原因:Redis 缓存没失效,7 天过期时间太长,数据变更后没主动删缓存。
解决:更新 MySQL 后立即删掉对应缓存键:
def update_attribution(prefix, province, city): # 更新 MySQL cursor.execute("UPDATE phone_attribution SET province=%s, city=%s WHERE prefix=%s", (province, city, prefix)) conn.commit() # 删除缓存 r.delete(f"phone:attr:{prefix}")删缓存而不是更新缓存,避免并发写导致脏数据。如果更新频繁,考虑把过期时间缩短到 1 小时,用时间换一致性。
5. 进阶技巧:用分区表和覆盖索引把查询再压一半
数据量到千万级(比如把物联网卡、虚拟号段都加进来),单表 B+ 树深度增加,查询会从 1 毫秒涨到 5 毫秒以上。这时候有两个优化方向:分区表和覆盖索引。
分区表按 prefix 首字母或省份做 HASH 分区,把数据打散到不同物理文件:
ALTER TABLE phone_attribution PARTITION BY HASH(CRC32(prefix)) PARTITIONS 16;16 个分区,每个分区大概几百万行,B+ 树深度降下来,查询更快。但分区表有坑:唯一索引必须包含分区键,所以uk_prefix得改成(prefix, id)或者直接去掉唯一约束,靠应用层保证。我一般不建议在归属地这种场景用分区,因为数据量还没大到那个程度,维护成本反而高。
更实用的优化是覆盖索引。如果查询只需要 province 和 city,建一个联合索引:
ALTER TABLE phone_attribution ADD INDEX idx_prefix_cover (prefix, province, city, operator);这样查询SELECT province, city, operator FROM phone_attribution WHERE prefix = '1381234'时,直接从索引里拿数据,不用回表。EXPLAIN里Extra会显示Using index,这就是覆盖索引生效的标志。代价是索引占空间,写入稍慢,但归属地库基本是读多写少,划算。
还有一个技巧是用MEMORY引擎做热数据表。把最近三个月查询频率最高的 10 万个号段放到内存表里,查询先走内存表,没有再查 InnoDB 表:
CREATE TABLE phone_attribution_hot ( prefix CHAR(7) NOT NULL, province VARCHAR(20) NOT NULL, city VARCHAR(30) NOT NULL, PRIMARY KEY (prefix) ) ENGINE=MEMORY DEFAULT CHARSET=utf8mb4;内存表重启后数据丢失,所以要用定时任务从 InnoDB 表同步。查询逻辑改成先查 hot 表,UNION ALL查主表,或者应用层做两级查询。这个方案能把热点查询压到 0.1 毫秒,但内存表不支持 TEXT/BLOB,字段长度也有限制,设计时注意。
最后说个验证方法:用BENCHMARK函数测查询性能,或者开slow_query_log抓慢查询。我习惯在导入数据后跑一轮压测,用mysqlslap模拟 100 并发查 1000 次,看 P99 延迟。如果超过 10 毫秒,就得检查索引和缓存了。
这套方案我前后搭过三次,每次踩的坑都差不多——编码、重复、索引失效。数据本身不复杂,难的是把导入、查询、缓存、更新这条链路串稳。希望帮到你。
本文还有配套的精品资源,点击获取