简介:面向需要处理中国行政区划数据的开发者,这份 MySQL 省市区数据表以单个 SQL 脚本形式提供,可直接导入数据库使用。脚本按 province、city、district 等层级组织,涵盖省份、城市、区县的名称、电话区号、拼音以及经纬度坐标等字段,适合地址填写与校验、按拼音检索排序、地图定位和距离估算等常见业务场景,对刚接触地理信息存储的初中级开发者也比较友好。rar 压缩包内共 1 个文件,为 SQL 脚本,体积约 126KB;脚本中包含建表语句和行政区划数据插入语句,导入后即可得到结构完整的省市区表,省去逐条收集、清洗基础数据的麻烦。目前已有 2939 人学习,说明其在同类数据中具备一定参考价值。读者可据此快速搭建地址库原型,或在此基础上扩展街道层级、自定义编码规则,减少前期数据准备成本。
1. 一份能直接导入的 MySQL 省市区表,省掉三天手工整理
做后台系统的都遇到过这种需求:用户下单要选收货地址、门店要按行政区统计、物流要按区分单。前端三级联动一拉,后端就得有一张能查的省市区表。自己动手整理?三个层级、几千条记录,还要带行政区划代码、层级关系、上下级关联,稍不留神就出现“同一个市两个拼写”“某个区挂错省”这种玄学问题。这份 MySQL 省市区表完整版文件解决的正是这个场景:把整理好的省、市、区县三级数据以 SQL 文件形式给到,建表语句和 INSERT 数据都在里面,导入即用,字段结构按常见业务需求设计。适合正在做电商、CRM、本地生活类项目,或者单纯不想再为一份地址数据折腾的后端开发者。
2. 省市区表的字段设计与导入流程:从 SQL 文件到可查询的三级联动
拿到一个 .sql 文件,先别急着 source 进去。地址表看着简单,但字段类型、主键策略、层级关系怎么表达,直接决定了后面查询是写一行就完事,还是要套三层子查询。这个资源的核心价值不在“数据有多少条”,而在于它给了一套能落地到业务里的表结构。下面先把设计逻辑讲清楚,再走一遍完整的导入和验证流程。
2.1 为什么是 id / name / pid 这套三元结构
省市区本质是一棵深度为三的树。常见做法有两种:一种是存province_id、city_id、district_id三个字段冗余在业务表里;另一种是地址表自己维护pid指向父级,形成自关联。这份资源的表采用后者,即每条记录有id、name、pid三个核心字段,省级记录的pid为 0。这个设计的好处是扩展性强——将来要加“街道/乡镇”第四级,只需要继续挂pid,不用改表结构;坏处是查“广东省-深圳市-南山区”这种完整路径时需要自关联两次。
我一般会在导入后额外加一列level,直接把层级固化下来,避免每次靠pid判断层级时写一堆CASE WHEN。这个改动成本很低,但查询可读性提升明显。下面建表语句里我会保留原始结构,同时给出加level的写法。
-- 原始结构,通常资源文件里就是这样建表的 CREATE TABLE `region` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', `name` varchar(64) NOT NULL COMMENT '行政区名称', `pid` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '父级ID,省级为0', PRIMARY KEY (`id`), KEY `idx_pid` (`pid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='省市区数据表';这段语句里有两个点值得说。第一,pid上必须建索引,因为三级联动第一层查询是WHERE pid = 0,第二层是WHERE pid = 省id,没有索引数据量上来之后每次都全表扫。第二,字符集用utf8mb4而不是utf8,地名里出现生僻字的情况不算罕见,utf8存四字节字符会截断甚至报错,这是血泪经验。
2.2 导入 SQL 文件的完整步骤
资源里通常包含一个建表 + 数据的.sql文件,几千到上万行 INSERT。直接命令行导入是最稳的方式,不要指望图形化工具在大文件上不卡。
# 1. 先建库(如果还没有) mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS demo_region DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci;" # 2. 导入 SQL 文件,注意用 --default-character-set 指定编码 mysql -u root -p --default-character-set=utf8mb4 demo_region < region_full.sql # 3. 验证导入结果 mysql -u root -p demo_region -e "SELECT COUNT(*) AS total FROM region;" mysql -u root -p demo_region -e "SELECT COUNT(*) AS prov FROM region WHERE pid = 0;"第一条命令建库时显式指定utf8mb4,避免库默认字符集和表不一致导致乱码。第二条命令里的--default-character-set=utf8mb4是关键,很多人导入后中文变问号,就是客户端连接编码和服务端不一致。第三条验证要查两个数:总记录数和省级记录数。省级数量应该在一个固定区间内(三十多个),如果查出来是 0 或者几百,说明pid字段的数据不符合预期,可能资源文件的层级规则和你的假设不一样,得先看清楚再往下做。
导入完成后,建议再补一个层级字段,把树的深度固化下来:
-- 新增 level 字段并回填 ALTER TABLE `region` ADD COLUMN `level` tinyint(1) NOT NULL DEFAULT '1' COMMENT '层级:1省 2市 3区县'; -- 省级 UPDATE region SET level = 1 WHERE pid = 0; -- 市级:父级是省 UPDATE region r JOIN region p ON r.pid = p.id SET r.level = 2 WHERE p.pid = 0; -- 区县级:父级是市 UPDATE region r JOIN region p ON r.pid = p.id SET r.level = 3 WHERE p.level = 2;这三个 UPDATE 的顺序不能乱,必须从省级往下逐层回填,因为每一层都依赖上一层的level已经是正确值。如果先跑区县那条,此时市的level还是默认的 1,条件p.level = 2匹配不到任何行,结果就是区县全是 1,这种错误不报异常,只能靠 count 分组查出来。
2.3 三级联动的三种查询写法与性能差异
数据进库之后,真正的考验是查询。前端三级联动每次选择触发一次请求,对应三种典型查法。
第一种,按父级查子级,最常用:
-- 查所有省 SELECT id, name FROM region WHERE pid = 0 ORDER BY id; -- 查某省下的市(假设省 id = 19) SELECT id, name FROM region WHERE pid = 19 ORDER BY id;这条走idx_pid索引,几千条数据量下毫秒级返回,没有任何优化空间也不需要优化。
第二种,根据区县 id 反查完整地址路径。做订单详情页时经常遇到,用户只存了district_id,要展示“省 市 区”全称:
-- 三次查询拼路径(应用层组装,最直观) -- 1) 查区县 SELECT id, name, pid FROM region WHERE id = 440305; -- 2) 用上一步的 pid 查市 SELECT id, name, pid FROM region WHERE id = 440300; -- 3) 用市的 pid 查省 SELECT id, name FROM region WHERE id = 440000;三次主键查询,总耗时也在毫秒级,比写一个复杂自关联 SQL 更好维护。熟手可能会想用一次 JOIN 搞定,但实际项目里这种“查三次拼起来”的写法反而更常见,因为缓存和代码逻辑都更清晰。
第三种,模糊搜索地名。用户输入“南山”要匹配到南山区:
SELECT id, name, pid, level FROM region WHERE name LIKE '南山%' AND level = 3 LIMIT 20;这里加了level = 3限定只在区县层搜,避免“南山”同时命中省市区不同层级造成结果混乱。LIKE '南山%'是前缀匹配,能走索引;如果写成LIKE '%南山%',索引失效全表扫,数据量小的时候感觉不出来,一旦并发上来就是慢查询。这是个很典型的踩坑点,后面还会展开。
2.4 用一张表验证数据完整性
导入完成后不要只看 count,得抽查层级关系是否自洽。下面这条 SQL 能一次性找出“孤儿记录”——即pid指向的父级 id 在表里不存在:
-- 查找父级不存在的脏数据 SELECT r.id, r.name, r.pid FROM region r LEFT JOIN region p ON r.pid = p.id WHERE r.pid != 0 AND p.id IS NULL;正常情况这条查询应该返回空集。如果查出记录,说明 SQL 文件里的 id 有断层或者被截断过,比如导入中途报错退出、只导了一半。另一个检查是看有没有重名同级记录:
-- 同一父级下是否有重名 SELECT pid, name, COUNT(*) AS cnt FROM region GROUP BY pid, name HAVING cnt > 1;同层级重名在真实行政区划里很少见,但数据整理过程中因为拼接、去重不彻底可能出现。这两条检查加起来不到十秒,能挡掉后面地址选择时“两个一样的选项”这种让人抓狂的问题。
3. 地址表在业务里的落地方式:缓存、索引与前后端对接
数据能查通只是第一步。真实项目里省市区数据的特点是“读多写极少、变更频率低”,这个特征决定了它几乎必然要被缓存。但缓存怎么加、加在哪一层、失效怎么办,不同团队做法差别很大。这一章讲的是把这份表接进业务系统时,几个绕不开的实际问题。
3.1 缓存策略:什么时候该查库,什么时候该进内存
省市区数据一年可能就变动一两次(撤县设区之类),每次前端拉三级联动都查库完全是浪费。常见做法是应用启动时全量加载进内存,用Map<pid, List<Region>>的结构存,查询直接走内存。
// Node.js 侧的全量缓存示例 const regionMap = new Map(); // key: pid, value: 子级数组 async function loadRegionCache(db) { const rows = await db.query('SELECT id, name, pid, level FROM region ORDER BY id'); regionMap.clear(); for (const row of rows) { if (!regionMap.has(row.pid)) regionMap.set(row.pid, []); regionMap.get(row.pid).push({ id: row.id, name: row.name, level: row.level }); } } // 查省 function getProvinces() { return regionMap.get(0) || []; } // 查市 function getCities(provinceId) { return regionMap.get(provinceId) || []; }这段代码的关键在regionMap的 key 用pid,把“按父级查子级”这个最高频操作变成一次哈希查找,复杂度 O(1)。启动时加载一次,内存占用对几千条数据来说可以忽略。注意regionMap.clear()那行,是给热更新留的口子——数据变更后重新调一次loadRegionCache就行,不用重启服务。
如果项目是多实例部署,每个实例各自加载一份内存缓存即可,没必要上 Redis。省市区数据量小、变更少,Redis 反而多一跳网络开销。但如果你的业务里有“运营后台修改地区名称”这种需求,就得考虑缓存一致性问题,简单做法是改完直接调各实例的刷新接口,或者加个版本号轮询。我一般倾向于后者,实现简单且不会漏。
3.2 业务表里该存 id 还是存文本
这是个经常被争论的点。订单表里存province_id、city_id、district_id还是直接存“广东省深圳市南山区”字符串?
存 id 的好处是省空间、便于统计(按地区 group by)、地址变更后历史订单展示的是最新名称;坏处是每次展示要回查,且如果区划调整导致某个 id 被删除,历史订单就查不到名字了。存文本的好处是自包含、展示零查询;坏处是冗余大、统计麻烦、名称可能不规范。
我的实际选择通常是“两者都存”:id 用于关联查询和统计,文本快照用于展示兜底。多占几十字节,但省掉了大量“地址怎么显示不对”的排查时间。下面是一个折中的建表示例:
CREATE TABLE `user_address` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `user_id` bigint unsigned NOT NULL, `province_id` int unsigned NOT NULL, `city_id` int unsigned NOT NULL, `district_id` int unsigned NOT NULL, `detail` varchar(255) NOT NULL COMMENT '详细地址', -- 名称快照,下单时写入,避免区划变更后展示异常 `region_text` varchar(128) NOT NULL COMMENT '省市区文本快照', PRIMARY KEY (`id`), KEY `idx_user` (`user_id`), KEY `idx_district` (`district_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;region_text这列在保存地址时由后端拼接好写入,展示时直接读,不用回表。idx_district索引是给“按区县统计订单量”这类运营需求准备的,如果没有统计需求可以不建,索引不是越多越好。
3.3 前后端接口的两种返回结构
三级联动接口设计上有个分歧:是每次只返回下一级,还是一次性返回整棵树。
只返回下一级的方式接口简单,GET /api/region?pid=0,前端每次选择再请求。缺点是用户选完三级要发三次请求,网络差的时候体验不好。一次性返回整棵树的方式是启动时拉一个几 MB 的 JSON,前端本地做联动,之后零请求。
我一般会根据端来分:PC 端管理后台用一次性加载,反正内网快;移动端 H5 用按级请求,省流量。下面是一次性接口的数据结构示例:
{ "provinces": [ { "id": 19, "name": "广东省", "cities": [ { "id": 1901, "name": "深圳市", "districts": [ { "id": 190101, "name": "南山区" } ] } ] } ] }这个嵌套结构前端遍历最方便,但要注意如果直接返回可能达到几 MB,记得开 gzip。另外接口里不要带pid、level这些前端用不上的字段,能省不少体积。真正需要这些字段的场景(比如反查路径)放在另一个接口里。
3.4 数据更新与版本管理
行政区划不是一成不变的。撤县设区、合并乡镇,每隔一段时间就有调整。资源文件是静态的,导入之后如果你不做版本管理,下次想更新就只能整表删了重导,业务表里那些 id 引用全部失效。
稳妥的做法是给 region 表加个is_deleted软删标记,更新时比对新旧数据,新增的插入、消失的标记删除而不是物理删除,名称变更的更新 name 字段。这样历史订单引用的 id 依然能查到(虽然可能标记了删除),展示上做个兼容即可。这个思路和前面region_text快照是互补的,双保险。
4. 导入与使用中的常见坑:五个真实翻车场景排查
这份资源本身是个 SQL 文件,看起来没什么技术含量,但导入和使用过程中翻车的概率不低。下面这几个是我和身边人实际踩过的,按“现象 → 原因 → 解决”整理,照着排能省不少时间。
4.1 导入后中文全是问号
现象:SELECT name FROM region LIMIT 10出来是????或者乱码。
原因:客户端连接字符集和服务端表字符集不一致。命令行 mysql 客户端默认可能是 latin1,导入时按 latin1 解释 UTF-8 字节,自然乱码。
解决:导入和查询时都显式加--default-character-set=utf8mb4,并且确认建库建表用的也是 utf8mb4。如果已经导入了乱码数据,只能删表重导,乱码不可逆。另外检查 my.cnf 里character-set-server的值,从根上统一。
4.2 省级查询返回为空
现象:SELECT * FROM region WHERE pid = 0一条都没有。
原因:资源文件的层级标记方式和你的假设不同。有的数据用pid = 0表示省,有的用pid = -1,还有的用parent_id IS NULL。不看数据直接套查询条件就会踩。
解决:先SELECT pid, COUNT(*) FROM region GROUP BY pid ORDER BY COUNT(*) DESC LIMIT 5看看哪些 pid 值记录最多,最大的那组大概率是省级(因为省最少、下面的市区多,所以省的 id 出现次数少,反而是区县的 pid 分布最散)。更直接的办法是SELECT * FROM region LIMIT 5肉眼看几条,确认层级规则再写查询。
4.3 三级联动漏了“直辖市”
现象:北京、上海、天津、重庆四个直辖市,选了“北京市”之后下一级出不来,或者出来的是“北京市-北京市-朝阳区”这种重复。
原因:直辖市在行政区划里是省级,但下面直接就是区,没有市级这一层。不同数据源处理方式不同:有的补齐一个虚拟的“市辖区”作为市级,有的直接让区挂在省下。如果你的代码写死了“省→市→区”三层,遇到直辖市就断链。
解决:导入后单独查一下直辖市的子级结构,SELECT * FROM region WHERE pid = (SELECT id FROM region WHERE name='北京市')。如果下面直接是区,前端要做特殊处理——市级下拉自动填充同值或者禁用。这是省市区数据里最容易被忽略的边界,长江以北的项目尤其要注意。
4.4 LIKE 模糊搜索慢查询
现象:地名搜索接口上线后,并发一高就超时,慢日志里全是LIKE '%xx%'。
原因:前面提过,前置通配符让 B+ 树索引完全失效,每查一次全表扫一遍。几千条数据单次可能只要几毫秒,但 QPS 上百就积压了。
解决:改成前缀匹配LIKE 'xx%',或者在应用层把地名数据加载进内存用前缀树匹配。如果业务确实需要任意位置匹配(比如用户输入“山”想匹配“南山区”),那就上专门的搜索引擎或者建一张拼音/首字母辅助索引表,不要在主表上硬扫。
4.5 更新数据后业务表地址显示错乱
现象:重新导入新版省市区数据后,用户地址里显示的地区名对不上了,有的甚至显示成别的城市。
原因:整表删除重导,自增 id 重新分配,旧业务表里存的district_id指向了新数据里完全不同的记录。这种错误极其隐蔽,因为查询不报错,只是数据错了。
解决:更新时绝不能物理删除重导。用前面说的软删 + 比对更新方案,保持 id 稳定。如果数据结构差异太大必须重来,那就只能写迁移脚本,按名称匹配新旧 id 做映射,再批量更新业务表。这个迁移脚本写起来不复杂但一定要先在测试库跑一遍核对数量。
5. 进阶玩法:把静态表变成可维护的地址服务
前面讲的都是“把数据用起来”。但如果你的项目长期迭代,早晚会遇到“省市区数据要更新”“多个系统共用一份地址数据”这类需求,这时候单纯一个 SQL 文件就不够了,得把它包装成一个内部服务。这一章讲两个具体技巧,一个是把表导出成前端可直接用的 JSON 包,另一个是用定时任务做数据版本比对。
先说导出。很多前端团队希望后端提供一个静态 JSON 文件,放进 CDN 或者打包进 App,这样联动完全不依赖接口。用一条 SQL 配合脚本就能生成:
# 从 MySQL 导出三级嵌套 JSON(需安装 pymysql) import json import pymysql conn = pymysql.connect(host='127.0.0.1', user='root', password='xxx', database='demo_region', charset='utf8mb4') cur = conn.cursor(pymysql.cursors.DictCursor) cur.execute("SELECT id, name, pid FROM region ORDER BY id") rows = cur.fetchall() # 先按 pid 分组 children = {} for r in rows: children.setdefault(r['pid'], []).append(r) # 递归组装三层 def build(pid): return [{'id': x['id'], 'name': x['name'], 'children': build(x['id'])} for x in children.get(pid, [])] tree = build(0) with open('region_tree.json', 'w', encoding='utf-8') as f: json.dump(tree, f, ensure_ascii=False, separators=(',', ':')) print('节点数:', len(rows)) # 打印总量做核对这段脚本的核心是children这个字典——先一次查询把父子关系全部读进内存分组,再递归组装,避免在数据库里做 N 次查询。ensure_ascii=False保证中文不被转义成\uXXXX,体积能小一半;separators去掉多余空格。最后那个print打印节点数是个小习惯,每次导出后对一下总数,防止某次查询被截断导出了残缺数据。
再说版本比对。省市区数据源如果定期更新,可以在库里建一张region_version表记录每次导入的批次和哈希值,导入前先算新数据 md5,和上一条比,相同就跳过。这个习惯来自一次教训——有次定时任务配置错了,每天凌晨把同一份数据重复导入,业务表 id 被反复重排,用户地址错乱了小半天才发现。从那以后我每次做数据导入都强制走一遍“批次记录 + 哈希比对 + 导入后 count 校验”的流程,多花两分钟,换的是不用半夜爬起来查地址。
如果你手上正好缺一份干净的省市区基础数据,这份 SQL 文件导入后配合上面的层级回填和缓存写法,基本能直接撑起一个中小型项目的地址模块。希望帮到你。
本文还有配套的精品资源,点击获取