简介:这份资源是2024年最新整理的MySQL全国省市区三级联动数据表,面向后端开发、数据库设计人员以及需要地址级联选择功能的开发者,用于解决地理信息查询与行政区域联动维护的问题。压缩包共2个文件,以1个SQL脚本和1个zip压缩包为主,SQL文件承载省市区三级表结构定义与初始化数据,zip则便于整体传输与备份,资源包整体约162.23MB。目前已有571人学习下载。数据采用省份、城市、区域三层结构,通过上级编码字段建立外键关联,实现选择省份后自动过滤城市、选定城市后展示对应区县的联动效果,并附有INSERT语句示例,便于理解字段含义与层级标识。对于需要实时准确地理信息的应用而言,这份最新数据可省去自行采集与清洗的成本,同时为索引优化、数据完整性与行政区域变更维护提供参考思路。
1. 全国省市区三级联动表:一份能直接塞进 MySQL 的行政区划底表
做过后台的人都碰过这个场景:用户注册要选籍贯,收货地址要选省市区,运营后台要按区域筛数据。前端组件库给了一堆 JSON,看着能用,真往数据库里灌的时候才发现字段对不上、层级乱、港澳台缺、直辖市重复。我见过最离谱的一份数据,广东省下面挂了“广州市”两次,一次是省直辖,一次是市辖区,前端选完存进订单表,对账时直接对不上。
这份 2024 年最新的 MySQL 全国省市区三级联动表,解决的就是这件事。它把省、市、区县三级行政区划整理成一张可以直接导入 MySQL 的 SQL 表,字段结构清晰,层级关系用 parent_id 串起来,前端拿到数据后按 parent_id 过滤就能实现三级联动。适合正在做电商收货地址、用户资料、区域统计后台的开发者,也适合需要一份干净底表做数据清洗的从业者。下面我从表结构设计讲到导入、查询、前端对接,再到几个我踩过的坑,一步步拆开。
2. 表结构怎么设计:parent_id 还是 code 前缀
拿到一份三级联动表,第一件事不是急着导入,而是看它的表结构设计。设计方式直接决定了你后面查询顺不顺手、前端对接要不要二次加工。市面上常见的方案有两种:一种是 parent_id 自关联,一种是 code 编码前缀。这份资源用的是前者,我先把两种方案的差别讲清楚,再说为什么这个场景下 parent_id 更稳。
2.1 两种主流表结构对比
parent_id 方案的核心是每行记录存一个父级 id,省级的 parent_id 为 0 或 NULL,市级指向省的 id,区县级指向市的 id。查询时先查 parent_id = 0 拿省份,再拿省份 id 查市,再拿市 id 查区县。code 前缀方案则是给每个行政区划编一个固定长度的编码,比如省级 2 位、市级 4 位、区县级 6 位,通过 LIKE '44%' 这种前缀匹配来拿下级。
| 对比项 | parent_id 自关联 | code 前缀 |
|---|---|---|
| 查询下级 | WHERE parent_id = ? | WHERE code LIKE '44%' |
| 索引效率 | 等值查询,走索引快 | 前缀匹配,数据量大时偏慢 |
| 层级扩展 | 加层级要改数据 | 编码长度天然分层 |
| 前端对接 | 直接按 parent_id 过滤 | 需要截取编码判断层级 |
| 数据维护 | 父级 id 变了要级联改 | 编码稳定,改层级不动编码 |
我一般会选 parent_id,原因是等值查询在 MySQL 里走 B+ 树索引最直接,几万条数据下响应稳定。code 前缀方案看着优雅,但 LIKE 前缀匹配在数据量上来后容易退化成范围扫描,而且前端拿编码去截取判断层级,逻辑容易写错。
2.2 建表语句与字段说明
这份资源的建表语句核心字段就几个,我按自己的习惯补了注释和索引。实际导入前建议先看一眼字段类型,id 用 int 还是 bigint 取决于你的数据量,行政区划表撑死几万条,int 完全够用。
CREATE TABLE `region` ( `id` int NOT NULL AUTO_INCREMENT COMMENT '主键,自增', `parent_id` int NOT NULL DEFAULT '0' COMMENT '父级id,省级为0', `name` varchar(50) NOT NULL COMMENT '行政区划名称', `level` tinyint NOT NULL COMMENT '层级:1省 2市 3区县', `code` varchar(12) DEFAULT NULL COMMENT '行政区划编码,可选', PRIMARY KEY (`id`), KEY `idx_parent` (`parent_id`) COMMENT '按父级查下级,必须建索引', KEY `idx_level` (`level`) COMMENT '按层级筛选时用' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='全国省市区三级联动表';字段里 level 是我强烈建议保留的。有些表只靠 parent_id 串层级,查询时得递归或者多次查库才能知道某条记录是省还是区县。有了 level,前端要拿“所有省份”直接 WHERE level = 1,要拿“所有区县”直接 WHERE level = 3,省掉一次判断。code 字段看情况,如果你的业务要对接物流或者统计口径,保留编码有用;纯前端联动的话,code 可以不要,少一个字段少一份维护成本。
注意:字符集一定用 utf8mb4,别用 utf8。有些区县名字带生僻字,utf8 存进去会变问号,这个坑我踩过,排查了半天才发现是字符集问题。
2.3 数据导入的两种方式
数据导入分两种场景:一种是资源里直接给了 INSERT 语句,你 source 一下就行;另一种是给了 CSV 或 SQL 文件,需要先建表再导入。我一般用命令行操作,比图形化工具稳,出错了日志也清楚。
# 方式一:直接执行资源里的 sql 文件 mysql -u root -p your_database < region.sql # 方式二:先建表,再用 LOAD DATA 导入 csv mysql -u root -p your_database -e "source /path/to/create_table.sql" mysql -u root -p your_database -e " LOAD DATA LOCAL INFILE '/path/to/region.csv' INTO TABLE region FIELDS TERMINATED BY ',' LINES TERMINATED BY '\n' IGNORE 1 ROWS (parent_id, name, level, code);"第一种方式最省事,资源里的 SQL 文件通常已经包含了建表和插入语句,直接 source 进去就行。第二种适合你手里是 CSV 的情况,LOAD DATA 比一条条 INSERT 快得多,几万条数据几秒钟搞定。注意 IGNORE 1 ROWS 是跳过 CSV 的表头行,如果你的文件没有表头,这行要去掉。导入完成后跑一句 SELECT COUNT() 确认条数,再跑一句 SELECT level, COUNT() FROM region GROUP BY level 看各级数量对不对,省级应该是 34 左右,市级 300 多,区县 3000 上下,数量级不对说明导入漏了。
3. 三级联动查询怎么写:从递归到一次查完
表建好、数据灌进去,接下来就是怎么查。三级联动看着简单,写起来有几种路子,性能差别不小。我见过有人用递归查库,前端每选一级就发一次请求,也见过有人一次性把全表拉出来在前端过滤。两种都能用,但场景不同,选错了要么慢要么占内存。
3.1 按需查询:三次等值查询
最直接的做法是前端每选一级,后端查一次下级。省级列表查一次,用户选了省之后再查市,选了市再查区县。这种方式的优点是每次返回的数据量小,首屏快;缺点是用户操作过程中有网络延迟,选完省要等一下才出市。
-- 查所有省份 SELECT id, name FROM region WHERE parent_id = 0 AND level = 1 ORDER BY id; -- 用户选了某个省后,查该省下的市 SELECT id, name FROM region WHERE parent_id = ? AND level = 2 ORDER BY id; -- 用户选了某个市后,查该市下的区县 SELECT id, name FROM region WHERE parent_id = ? AND level = 3 ORDER BY id;这三条语句都走 idx_parent 索引,等值查询,响应在毫秒级。参数就是上一级选中的 id,前端传过来直接拼进去。注意 level 条件加上去更保险,防止数据里有脏数据导致层级错乱。我一般会在后端加一层缓存,省级列表几乎不变,缓存起来能省掉大量重复查询。
3.2 一次查完:全量拉取前端过滤
如果前端组件支持本地过滤,或者你的应用是单页应用、希望减少请求次数,可以一次性把全表拉出来,前端按 parent_id 自己组装树。这种方式适合数据量不大、对首屏加载时间不敏感的场景。
-- 一次性拉取全量数据,前端组装 SELECT id, parent_id, name, level FROM region ORDER BY parent_id, id;这条语句返回所有记录,三万多条数据大概几百 KB,走一次网络传输。前端拿到后按 parent_id 分组,选省时过滤 level=1,选市时过滤 parent_id 等于选中省 id,选区县同理。优点是选完省市区零延迟,缺点是首次加载稍慢,而且数据更新后前端缓存要处理。我一般会在接口层加 ETag 或版本号,数据没变就返回 304,省流量。
3.3 递归查询:MySQL 8.0 的 CTE 写法
MySQL 8.0 支持 CTE 递归,可以一条 SQL 查出某个省下面的所有市和区县,适合做区域统计或者导出。不过三级联动场景下用得少,因为前端是逐级选择的,不需要一次拿全。但如果你要做“某省所有区县”的报表,这个写法很有用。
WITH RECURSIVE region_tree AS ( -- 锚点:选中的省 SELECT id, parent_id, name, level FROM region WHERE id = ? UNION ALL -- 递归:查下级 SELECT r.id, r.parent_id, r.name, r.level FROM region r INNER JOIN region_tree rt ON r.parent_id = rt.id ) SELECT * FROM region_tree WHERE level = 3;锚点部分是你要查的省,递归部分不断找 parent_id 等于上一级 id 的记录,直到没有下级为止。最后过滤 level = 3 拿所有区县。这个写法在数据量不大时没问题,但递归深度和中间结果集要注意,别在超大表上直接跑。我一般只在后台报表用,前端联动还是走等值查询。
4. 前端对接与数据缓存:别每次选都查库
后端查询写好了,前端怎么接也有讲究。我见过不少项目,用户每选一级就发一次请求,选完省市区发了三次,网络稍微差一点就卡顿。其实省级数据几乎不变,市级也很少动,完全可以在前端做缓存,减少请求次数。
4.1 前端缓存策略
最简单的做法是首次加载时把省级列表缓存到内存或 localStorage,用户选省时如果缓存里有该省的市列表就直接用,没有再去请求。区县数据量大,可以按需请求,但也可以在市列表返回时一并带上该市下的区县,减少一次往返。
// 前端缓存示例:省级列表缓存到 localStorage const CACHE_KEY = 'region_provinces'; async function getProvinces() { const cached = localStorage.getItem(CACHE_KEY); if (cached) { return JSON.parse(cached); } const res = await fetch('/api/region?parent_id=0&level=1'); const data = await res.json(); localStorage.setItem(CACHE_KEY, JSON.stringify(data)); return data; } // 选省后查市,市数据也可以按省 id 缓存 async function getCities(provinceId) { const key = `region_cities_${provinceId}`; const cached = sessionStorage.getItem(key); if (cached) return JSON.parse(cached); const res = await fetch(`/api/region?parent_id=${provinceId}&level=2`); const data = await res.json(); sessionStorage.setItem(key, JSON.stringify(data)); return data; }省级用 localStorage 长期缓存,市级用 sessionStorage 会话级缓存,区县按需请求不缓存。这样用户第一次选完省市区后,再选第二次几乎零延迟。注意缓存要有失效机制,行政区划调整虽然少,但一年总有几次,可以在接口返回里带一个版本号,版本变了就清缓存。
4.2 接口设计:一次返回还是分次返回
接口设计上,我倾向于分次返回,但把市和区县合并成一次。具体来说,前端初始化只拉省级列表;用户选省后,接口返回该省下的市列表,同时把每个市下的区县也带上,前端拿到后自己按 parent_id 分组。这样用户选市时区县已经在本地了,不用再请求。
// 接口返回结构示例:市和区县一起返回 { "cities": [ { "id": 4401, "name": "某市", "children": [ { "id": 440103, "name": "某区" }, { "id": 440104, "name": "某区" } ]} ] }这种结构前端处理起来方便,选市时直接从 children 里拿区县。缺点是如果某个省下面市很多、区县更多,单次返回数据量会大一些。我实测过,最大的省下面所有市区县加起来也就几百 KB,一次返回完全可接受。如果你的应用对流量特别敏感,可以再拆细,但大多数场景没必要。
5. 避坑与排查:导入和查询时最容易翻车的几个点
这份表看着简单,真用起来坑不少。我把自己和同事踩过的几个典型问题整理出来,每条按现象、原因、解决写,你遇到类似情况可以直接对照。
5.1 导入后中文变问号
现象:导入 SQL 文件后,SELECT 出来的行政区划名称全是问号或者乱码。原因:数据库、表、连接三处的字符集不一致,最常见的是表用了 utf8 而数据里有生僻字。解决:建表时指定 utf8mb4,导入时连接也指定 utf8mb4,命令是 mysql --default-character-set=utf8mb4 -u root -p。如果已经导入错了,ALTER TABLE region CONVERT TO CHARACTER SET utf8mb4 可以补救,但最好重新导入。
5.2 直辖市数据重复
现象:北京市下面既有“北京市”又有“市辖区”,前端选完省之后市列表里出现重复项。原因:直辖市的行政区划结构和普通省不同,省级和市级名称可能相同,数据源处理时没去重。解决:查询市级列表时加 DISTINCT 或者在后端做一次去重,更彻底的做法是导入前清洗数据,把直辖市的市级记录合并。我一般会在接口层过滤掉 name 和父级 name 相同的记录。
5.3 parent_id 索引没建导致查询慢
现象:数据导入后前端联动卡顿,查一次市列表要等一两秒。原因:parent_id 字段没建索引,每次查询全表扫描。解决:ALTER TABLE region ADD INDEX idx_parent (parent_id)。几万条数据下,有没有索引差别是几十毫秒和一两秒的区别。建完索引用 EXPLAIN 确认一下,type 应该是 ref 而不是 ALL。
5.4 港澳台数据缺失或层级不对
现象:前端选省时找不到香港、澳门、台湾,或者选了之后没有下级。原因:部分数据源只收录了内地 31 个省级行政区,港澳台要么没有要么层级处理不一致。解决:导入前先 SELECT COUNT(*) FROM region WHERE level = 1 确认省级数量,正常应该是 34。如果缺,需要手动补或者换一份数据源。港澳台的市级和区县结构跟内地不同,前端展示时可能要特殊处理,这个要在需求阶段就确认好。
5.5 区县数量对不上
现象:导入后统计区县数量,发现比预期少了几百条。原因:CSV 导入时字段分隔符或换行符处理不当,某些带逗号的名称被截断。解决:导入后跑 SELECT COUNT(*) FROM region WHERE level = 3 核对数量,再抽查几个名称里可能带特殊字符的记录。如果确实少了,检查 CSV 的引号转义,或者改用 SQL 文件直接导入,避开 CSV 解析问题。
6. 进阶用法:用这份表做区域统计和地址解析
表用顺了之后,除了三级联动,还能干点别的。我平时用得最多的两个进阶场景,一个是按区域做数据统计,一个是把用户填的地址反向解析成行政区划 id。这两个场景都依赖这份表的层级关系,写起来不复杂,但能省掉不少重复劳动。
6.1 按省市区聚合统计
后台经常要看“每个省有多少订单”“每个市有多少用户”,这时候用 region 表跟业务表 JOIN 一下就行。假设订单表里有 region_id 字段,存的是区县级 id,要按省统计,需要先通过区县找到市再找到省。用递归 CTE 或者多次 JOIN 都能实现。
-- 按省统计订单量:区县 -> 市 -> 省 SELECT p.name AS province, COUNT(*) AS order_count FROM orders o JOIN region c ON o.region_id = c.id AND c.level = 3 JOIN region ci ON c.parent_id = ci.id AND ci.level = 2 JOIN region p ON ci.parent_id = p.id AND p.level = 1 GROUP BY p.id, p.name ORDER BY order_count DESC;这条语句通过两次 JOIN 把区县关联到省,然后按省分组统计。注意每个 JOIN 都带了 level 条件,防止脏数据导致关联错层级。如果订单表里存的是市级 id,少一次 JOIN 就行。这种统计在数据量大时要注意索引,orders 表的 region_id 和 region 表的 parent_id 都要有索引,不然 JOIN 起来会慢。
6.2 地址文本反向解析
用户填地址时经常是一整串文本,比如“某省某市某区某街道”,要把它拆成省市区 id 存起来。做法是用 region 表里的名称去匹配地址文本,从省级开始逐级往下找。我一般会写一个简单的解析函数,按名称长度从长到短匹配,避免“某市”匹配到“某市某区”里的“某市”。
def parse_address(addr, region_map): # region_map: {level: {name: id}} result = {'province_id': None, 'city_id': None, 'district_id': None} # 先匹配省 for name, rid in region_map[1].items(): if name in addr: result['province_id'] = rid addr = addr.replace(name, '', 1) break # 再匹配市 if result['province_id']: for name, rid in region_map[2].items(): if name in addr and region_map[2].get(name): result['city_id'] = rid addr = addr.replace(name, '', 1) break # 最后匹配区县 if result['city_id']: for name, rid in region_map[3].items(): if name in addr: result['district_id'] = rid break return result这个函数的核心思路是按层级顺序匹配,每匹配到一级就把名称从地址里去掉,避免下一级重复匹配。region_map 是提前从数据库加载到内存的字典,按 level 分组,key 是名称 value 是 id。实际用的时候要注意名称重复问题,比如有些市和区县同名,匹配时要结合上一级 id 过滤。我一般会在加载 region_map 时按 parent_id 再分一层,匹配市级时只在该省的市级列表里找,准确率高很多。
从那以后我每次拿到新的行政区划数据,都会先跑一遍数量核对和层级校验,再导入业务库。这份表本身质量不错,但数据源更新、字符集、索引这些环节该走的检查一步都不能省。希望帮到你。
本文还有配套的精品资源,点击获取