简介:面向数据库初学者及需要实现多层级级联选择功能的开发者,这套三级、四级、五级联动SQL资料围绕层级数据表设计、外键关联与联合查询展开,可用于行政区划等分层信息的建模与检索场景。压缩包共25个文件,约22.91MB,以SQL脚本、PHP示例、CSV数据为主,并配有Markdown说明、YAML配置及XML/JSON等辅助文件;目录按源码、数据库迁移、配置与数据分发组织,便于按模块查阅。SQL部分侧重建表、插入与JOIN查询,CSV便于导入层级样例数据,PHP代码展示联动逻辑的调用方式。已有28人学习,适合用来对照理解父子层级字段、外键约束以及触发器、视图和索引在联动查询中的作用。读者可从中获得可参考的表结构、数据样例与查询写法,用于快速搭建省—市—区县等逐级筛选功能,并据此梳理数据完整性与查询性能的优化思路。
1. 五级联动数据不是一张表就能解决,先看清这份 SQL 里装了什么
产品经理丢过来一个几千万把行的 sql 文件,说“省市区街道村全都有,直接导进去就能用”。打开一看,五级数据塞在一张自关联表里,编码有 6 位、9 位、12 位三种长度,还夹着一批 parent 指向 0 的节点——这就是典型的三级+四级+五级联动 sql 文件:它把省、市、区县、街道乡镇、村社区五层行政区划一次性落库,用父子关系串成树,前端级联选择器按层下钻,就能从“某省”一路选到“某村”。它的价值在于层级完整、编码规范、能直接导入跑通。适合做收货地址、客户归属、表单归属地这类需求的团队,也适合手上只有三级数据、被要求“再往下钻两层”的人。直接mysql < file.sql就完事的人,多半会在字符集、层级跳跃和排序上出错。
2. 表结构与编码规则:6 位、9 位、12 位为什么要分开理解
拿到文件先别急着导入,把建表语句和几行样例数据读一遍,能省掉后面一半的排查时间。这份数据的组织方式无非三种,选错了后面每加一层都要改代码。
2.1 三种表结构:单表自关联、分层多表和扁平宽表
| 结构 | 典型字段 | 优点 | 代价 | 适用场景 |
|---|---|---|---|---|
| 单表自关联 | code / parent_code / name / level / sort | 加层不用改表,三级和五级共用一套查询逻辑 | 取全路径要递归或逐层查 | 绝大多数业务系统 |
| 分层多表 | province / city / district / street / village 五张表 | 单表数据量小、语义直白、联表看得懂 | 层级固定,加一层要动表结构和代码 | 层级多年不变的老系统 |
| 扁平宽表 | code12 / province / city / district / street / village / full_name | 一条 SQL 出全路径,导出给报表很方便 | 冗余大,改一个区名要全表更新 | 只读展示、数据导出 |
单表自关联是主流,也是改动成本最低的一种。它的核心就一句话:任何一个节点,靠parent_code找到爹,靠code前缀找到自己属于谁。层级用level字段显式标出来(1 省、2 市、3 区县、4 街道乡镇、5 村社区),前端拿这个字段决定渲染第几列,比按数组下标猜层级安全得多。
注意:
level一定要有,而且要和code长度互相印证。只靠长度判断层级,遇到补零不规范的 dump 文件会直接判断错误。
2.2 编码分段:前缀就是父级,这是整份数据的骨架
行政区划代码是分段编的,每一段往下挂一层:
- 第 1–2 位:省级
- 第 3–4 位:市级
- 第 5–6 位:县级,到这里凑齐 6 位,即三级联动能用到的最细一级
- 第 7–9 位:乡级(街道、镇),凑齐 9 位
- 第 10–12 位:村级(村、社区),凑齐 12 位
也就是说,一个 12 位的村码,前 6 位就是它所属区县的码,前 9 位就是它所属街道的码。这个性质非常关键:它意味着“由子找父”根本不需要递归,用字符串截取就能直接定位上层节点,后面第 6 章会用到。
字段映射上,常见的建表大概长这样:
| 字段 | 类型 | 说明 |
|---|---|---|
| code | CHAR(12) | 主键,左侧补零到统一长度,千万别用 INT |
| parent_code | CHAR(12) | 顶层节点写 '0',其余写父级 code |
| level | TINYINT | 1–5,必须与 code 长度对应 |
| name | VARCHAR(64) | 展示名,字符集 utf8mb4 |
| short_name | VARCHAR(32) | 去掉“省/市/区/县”后缀,用于拼接短地址 |
| pinyin / initial | VARCHAR(64) / VARCHAR(8) | 拼音与首字母,做搜索和 A–Z 索引 |
| sort | SMALLINT | 手工排序权重,没有就留 NULL |
| status | TINYINT | 1 有效、0 已撤销,用于增量更新时保留历史 |
code用 CHAR 而不用 INT,是因为存在前导零,一旦转成数字,前导零就没了,010000变成10000,前缀关系全部失效。我见过有人为了“省空间”把 code 设成 INT,结果五级查询全乱,返工重导一遍。CHAR(12) 定长还有一个好处:比较和截取都不用考虑长度差异,LEFT(code, 6)永远拿到县级码。
2.3 导入前的四道校验:花两分钟,省两小时
拿到文件后,先建个临时库导进去,跑下面四条查询。任何一条返回非空,说明文件本身有毛病,先修数据再谈联调。
-- 1) 孤儿节点:父级不存在。这类节点在前端表现为“某一列永远是空白” SELECT r.code, r.name, r.parent_code FROM sys_region r LEFT JOIN sys_region p ON p.code = r.parent_code WHERE r.level > 1 AND p.code IS NULL; -- 2) 层级与码长不匹配:level 说是村,code 却只有 6 位 SELECT code, name, level, CHAR_LENGTH(code) AS code_len FROM sys_region WHERE (level = 3 AND CHAR_LENGTH(code) <> 6) OR (level = 4 AND CHAR_LENGTH(code) <> 9) OR (level = 5 AND CHAR_LENGTH(code) <> 12); -- 3) 子节点前缀对不上父节点:说明编码被改过或补零不齐 SELECT c.code, c.name, c.parent_code, p.code AS real_parent FROM sys_region c JOIN sys_region p ON p.code = c.parent_code WHERE LEFT(c.code, CHAR_LENGTH(p.code)) <> p.code; -- 4) 主键重复:多次追加导入的典型后果 SELECT code, COUNT(*) AS cnt FROM sys_region GROUP BY code HAVING cnt > 1;第一条查询的逻辑是自连接左联,父表没匹配上就是孤儿。第二条靠CHAR_LENGTH反查,注意用CHAR_LENGTH而不是LENGTH,后者算的是字节数,utf8mb4 下中文一个字 3 字节,容易误判。第三条是最有价值的一条,它能一次性把编码体系里所有“父子关系断裂”的位置全找出来,比逐层点开页面看快得多。第四条不用解释,导入前TRUNCATE一遍就不会有。
这四条跑完都干净,再往下做联调,心里就有底了。
3. 导入与联调:把 SQL 文件落到库里跑通五级下钻
数据干净之后,剩下的就是导入、建索引、写查询、接前端。这一章按执行顺序走,每一步都给到能直接抄的命令和参数。
3.1 建库与导入:字符集和包大小是两个必调参数
建库时把字符集和排序规则显式写死,不要依赖服务端默认值。很多 dump 文件头部会写SET NAMES utf8mb4,但表定义里可能是utf8mb4_general_ci,如果服务端默认是别的排序规则,后面 JOIN 时会出现“非法混合排序规则”的报错。
# 建库:字符集与排序规则跟 dump 保持一致,别赌默认值 mysql -uroot -p -e "CREATE DATABASE region_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" # 导入:三个参数先放大,再喂文件 mysql -uroot -p \ --default-character-set=utf8mb4 \ --max-allowed-packet=512M \ region_db < region_5level.sql参数逐个说清楚:
--default-character-set=utf8mb4:客户端连接字符集。不写这一条,客户端可能按 latin1 解析文件里的中文,导进去全是问号,而且是不可逆的,只能重导。--max-allowed-packet=512M:单条 SQL 语句或单个结果集的上限。这类文件为了导入快,常常把几千行INSERT合并成一条,一条语句超过默认的 4MB 或 16MB 就直接断连。- 服务端对应参数也要放开:
SET GLOBAL max_allowed_packet = 536870912;,改完新连接才生效。
导入完成后第一件事是核对各层级数量:
SELECT level, COUNT(*) AS cnt, MIN(CHAR_LENGTH(code)) AS min_len, MAX(CHAR_LENGTH(code)) AS max_len FROM sys_region GROUP BY level ORDER BY level;这条查询同时验证了两件事:数量是否符合预期量级(省几十、市几百、区县几千、街道几万、村几十万,具体以文件为准),以及每层的码长是否统一。如果 level=5 那行的min_len出现 6,说明有村节点没补零到 12 位,前面前缀匹配的思路就用不了,得先补零:UPDATE sys_region SET code = RPAD(code,12,'0') WHERE level = 5;。
3.2 索引设计与逐层下钻查询
dump 文件里通常只带主键,业务查询要用的索引得自己补。五级联动最典型的访问模式是“给定父级,取子级列表”,所以联合索引的顺序是parent_code, level, sort:
ALTER TABLE sys_region ADD PRIMARY KEY (code), ADD KEY idx_parent_level_sort (parent_code, level, sort), ADD KEY idx_name (name);parent_code放最左,因为它是等值条件;level第二位,用来过滤掉同父级下不同层的杂项(正常情况下不会出现,但脏数据里常见);sort放最后,让排序也能吃到索引,避免Using filesort。
下钻查询就一条:
-- 取某个节点下的直接子级,用于级联选择器的下一列 SELECT code, name, level, IFNULL(sort, 9999) AS sort_key FROM sys_region WHERE parent_code = '100000' ORDER BY sort_key, code;两个细节。第一,parent_code传参必须是补零后的定长字符串,前端如果传了数字100000被转成100000还好,但传10000就查不到任何东西,所以接口层要做一次String(value).padStart(12,'0')。第二,IFNULL(sort, 9999)是为了把没有手工排序的节点压到后面——MySQL 里 NULL 在升序排序时排最前,直接用ORDER BY sort, code会让一堆没设权重的节点跑到列表顶部,看起来像“数据乱了”。
如果要一次性预热三级数据,用整表扫更划算:
-- 三级全量约几千行,一次取回,前端自己拼树 SELECT code, parent_code, name, level FROM sys_region WHERE level <= 3 ORDER BY code;几千行 JSON 大概几百 KB,走一次接口缓存起来完全没问题。
3.3 全量加载还是懒加载:按层数决定,不是按喜好决定
三级和五级是两种不同的前端策略,这个选择直接决定首屏体验。
| 方案 | 做法 | 首屏体积 | 适用层级 | 代价 |
|---|---|---|---|---|
| 全量树 | 一次取回所有层级,前端拼成嵌套结构 | 三级约几百 KB,五级可达十几 MB | 三级 | 五级下会把首屏拖慢,移动端基本不可用 |
| 分层懒加载 | 每次只取当前节点的子级,选中后再请求下一级 | 每次几 KB | 四级、五级 | 需要接口和缓存配合,多几次往返 |
| 混合 | 三级预加载,四级五级按需 | 几百 KB + 按需 | 实际最常用 | 需要处理好“回填”场景 |
三级数据一把梭没问题,五级数据千万别这么干。全国村级节点数量在几十万量级,序列化成 JSON 十几 MB,移动端解析一次就是几百毫秒起步,还不算内存占用。
懒加载的接口层加一层按父级缓存就够用了,key 里带上层级,避免同一个 code 在不同层出现时读错缓存:
// 前端侧:按「层级 + 父码」缓存,避免同名不同级读错 const cache = new Map(); async function loadChildren(parentCode, level) { const key = `${level}:${parentCode}`; if (cache.has(key)) return cache.get(key); const url = `/api/region/children?parent=${encodeURIComponent(parentCode)}&level=${level}`; const list = await fetch(url).then(r => r.json()); cache.set(key, list); return list; }level进缓存 key 是有原因的:有些数据源里同一个 code 在 3 级和 4 级都存在过(历史遗留或补零问题),不带层级的话,第二次请求会命中错误结果,表现为“选了街道,下一列出来的还是区县”。
3.4 回填:编辑页才是最容易出错的地方
新增表单的联动好做,编辑页才容易出错。用户存的是最末级100101001001,回填时你需要一次性拿到从省到村的完整路径,然后逐列展开。
笨办法是循环调 5 次接口,每次拿 parent,前端串行等待,页面会明显卡一下。更好的做法是后端开一个/api/region/path?code=xxx,内部用一条 SQL 拆码解决,这个在第 6 章展开。这里先记住一条原则:回填永远从叶子往上找,不要从根往下逐层匹配名称。用名称匹配的话,遇到“某街道”在多个区县重名,直接选错。
4. 避坑与排查:五个实际排查过的点
这一章是整篇最有价值的部分。前面按流程走,这里按“出事之后怎么查”组织。
4.1 导入阶段:连接断开与中文乱码
现象一:导入到一半报ERROR 2006 (HY000) at line 18342: MySQL server has gone away,进程直接退出。
原因是 dump 文件里存在超大 INSERT 语句,超过了max_allowed_packet,服务端主动断开。解决分两步:先在服务端执行SET GLOBAL max_allowed_packet = 536870912;,再重启客户端重导;如果文件里单条语句实在太大(超过 500MB),改用split -l 5000把文件切成小段分批导入,或者在客户端里用source逐段执行。我一般会先grep -c "INSERT INTO"看一下语句条数,条数很少但文件很大,就说明是巨型 INSERT,提前把包大小放开。
现象二:数据导进去了,SELECT name出来全是???或一堆问号方块。
原因是连接字符集不对。注意,这时候改表的字符集已经没用了——问号是编码转换时不可逆丢失的,只能删库重导。重导时检查三处:客户端命令加--default-character-set=utf8mb4;连接串里加characterEncoding=utf8(部分驱动还要useUnicode=true);建库语句显式写DEFAULT CHARACTER SET utf8mb4。另外,如果数据里有生僻字地名,utf8mb3 存不下,必须是 utf8mb4,这个在导入前用SELECT MAX(LENGTH(name)) FROM ... WHERE level=5大致估一下就能发现异常。
4.2 层级与联动:第二列空白、列表顺序乱
现象三:选了省之后,第二个下拉框是空的;或者选了直辖市的省,直接跳到区县,中间那列没有内容。
原因是数据里的层级跳跃。直辖市、省直管县级市这类结构,在部分数据源里会做成“省级节点下面直接挂区县”,没有市级节点。前端如果按固定列数渲染(第一列省、第二列市、第三列区),这一列就是空的。解决办法有两个方向:数据侧补虚拟节点,让每个区县的parent_code指向一个虚构的市级节点;前端侧不按列索引渲染,改成按level字段动态决定列。我更倾向后者,因为补虚拟节点会污染数据,导出报表时又要特殊处理。前端拿到子级列表后先看level,如果直接是 3,就把第 2 列也填上同一个批次的数据并标记为“市辖区”占位,用户点起来不会断。
现象四:下拉列表看着“乱七八糟”,有的区县跑到最前面,有的排序跟别的省不一样。
三种可能:数据里sort字段为 NULL 且用了ORDER BY sort(NULL 排最前);排序规则用了utf8mb4_general_ci而名称里带生僻字,排序结果不符合预期;或者干脆是按name字母序排的,中文按拼音和按笔画排出来的顺序完全不同。定位方法是把ORDER BY去掉,看原始顺序是否稳定,再看sort的 NULL 比例:SELECT COUNT(*) AS total, SUM(sort IS NULL) AS null_sort FROM sys_region WHERE level = 3;。解决就是用ORDER BY IFNULL(sort, 9999), code,code兜底保证顺序稳定可复现。
现象五:同一个页面刷新两次,地址选择器里的数据不一样;或者昨天能选的街道,今天没了。
原因是数据被追加导入过,或者缓存没失效。先查重复:SELECT code, COUNT(*) FROM sys_region GROUP BY code HAVING COUNT(*) > 1;,如果返回一堆,说明是INSERT而不是REPLACE,同一份数据被灌了两遍,parent_code相同的两批节点混在一起。解决是先TRUNCATE TABLE sys_region;再完整重导,别用DELETE——几十万行DELETE产生的回滚段和 binlog 比TRUNCATE大得多。缓存那一侧,全量 JSON 的缓存 key 要带一个数据版本号,版本号从库里一张region_version表读,数据更新后手动加一,前端localStorage里的旧 key 自然失效。
提示:这五条里,第一条和第二条发生在导入阶段,后三条发生在联调阶段。按这个顺序排查,能避免在错误的方向上浪费时间。
5. 数据裁剪与增量更新:区划每年都在变,别让数据留在旧版本
五级数据不是导一次管一辈子。区划调整、撤县设区、村居合并每年都有,字段名没变但内容变了,用户投诉“我们这儿早就不叫这个名了”就是这么来的。这一章解决两件事:怎么把文件裁小,怎么比对出变更。
5.1 按需裁剪:只保留用得到的层级
如果业务只用三级,就没必要把几十万条村级数据塞进生产库。裁剪有两种做法,看你的导入流程在哪一步。
如果还能重导,用mysqldump带--where直接导子集:
# 只导出三级及以上的数据,体积能小一个数量级 mysqldump -uroot -p region_db sys_region \ --where="level <= 3" \ --default-character-set=utf8mb4 \ --no-create-info --complete-insert \ > region_l3.sql--no-create-info表示只导数据不带建表,--complete-insert让每条 INSERT 都带列名,避免目标表列顺序不同导致错位。两个参数一起用,是把数据往已有表里灌时的标准姿势。
如果数据已经在库里,用CREATE TABLE AS复制一份瘦身版,顺便把不用的字段砍掉:
-- 只留三级,去掉拼音和经纬度,生产库就轻了 CREATE TABLE region_l3 AS SELECT code, parent_code, name, level, IFNULL(sort, 9999) AS sort FROM sys_region WHERE level <= 3; ALTER TABLE region_l3 ADD PRIMARY KEY (code), ADD KEY idx_parent (parent_code, level, sort);CREATE TABLE AS(CTAS)不会继承原表的索引和字符集定义,所以后面必须手动补主键和索引,字符集也要核对一遍:SHOW CREATE TABLE region_l3;。这一步如果漏了,查询会退化成全表扫描,几千行数据也能慢出几百毫秒。
5.2 年度变更比对:新增、撤销、改名三条 SQL
拿到新版数据,不要直接覆盖。先建一张region_new表导入新数据,跟旧表做三向比对,把差异落到一张变更表里,人工过一遍再合并。
-- 新增:新数据里有,旧数据里没有 SELECT n.code, n.name, n.level FROM region_new n LEFT JOIN sys_region o ON o.code = n.code WHERE o.code IS NULL; -- 撤销:旧数据里有,新数据里没有 SELECT o.code, o.name, o.level FROM sys_region o LEFT JOIN region_new n ON n.code = o.code WHERE n.code IS NULL AND o.status = 1; -- 改名:code 相同但名称变了 SELECT o.code, o.name AS old_name, n.name AS new_name FROM sys_region o JOIN region_new n ON n.code = o.code WHERE o.name <> n.name;三条查询的逻辑都是标准的“左联 + 判空”或“内联 + 不等”,成本很低。关键在结果怎么处理:新增的直接插入并置status = 1;撤销的不要删除,把status置 0,因为历史订单里还存着这些 code,删了之后回填就查不到名称,页面上会出现空白地址;改名的更新name和short_name,同时在变更表里留一条记录,方便后续追溯。
5.3 版本号与缓存失效:比“定时刷新”可靠
缓存的失效策略,定时刷新是最容易出事的一种——数据已经更新了,缓存还没到期,用户看到的是旧数据;反过来刷新太频繁,缓存又没意义。更稳的做法是显式版本号。
-- 一张单行版本表,数据合并完成后手动加一 CREATE TABLE region_version ( id TINYINT PRIMARY KEY, version INT NOT NULL, updated_at DATETIME NOT NULL ); UPDATE region_version SET version = version + 1, updated_at = NOW() WHERE id = 1;前端拿到数据时一并取回version,存进localStorage的 key 里,比如region_tree_v17。下次进页面先请求版本号,不一致就清掉本地缓存重新拉。这一套下来,数据更新和数据可见之间的延迟从“等缓存过期”变成了“下一次请求”,而且排查问题时一眼能看出用户端用的是哪一版数据。
注意:撤销的节点保留
status = 0之后,所有列表查询都要显式加WHERE status = 1,包括导出和后台管理页。漏一处就会出现“已经撤销的村还能选”的问题,这类反馈排查起来非常耗时间。
6. 前缀匹配与递归 CTE:不查五次接口,一次 SQL 还原五级路径
订单表里只存了一个 12 位村码,后台要显示“省 / 市 / 区县 / 街道 / 村”的完整地址。最直觉的写法是循环查五次,每次拿parent_code,这在批量导出时会直接拖垮数据库。用编码结构做自连接,一条 SQL 就够了:
-- 前提:code 已补齐到 12 位定长;用前缀拼出各层主键,一次自连接取全路径 SELECT p.name AS province, c.name AS city, d.name AS district, s.name AS street, v.name AS village FROM sys_region v LEFT JOIN sys_region s ON s.code = CONCAT(LEFT(v.code, 9), '000') LEFT JOIN sys_region d ON d.code = CONCAT(LEFT(v.code, 6), '000000') LEFT JOIN sys_region c ON c.code = CONCAT(LEFT(v.code, 4), '00000000') LEFT JOIN sys_region p ON p.code = CONCAT(LEFT(v.code, 2), '0000000000') WHERE v.code = '100101001001';四张表都走主键等值查找,代价极低。用LEFT JOIN而不是JOIN,是因为某些层级可能缺失,缺了也要把已有的部分显示出来,不能让整行消失。
反过来,要取某个节点下的所有后代(比如统计某市下的所有村),别用递归,用前缀匹配:
-- 取某区县下全部村级节点,走主键最左前缀,比递归快一个量级 SELECT code, name FROM sys_region WHERE code LIKE '100101%' AND level = 5 AND status = 1 ORDER BY code;LIKE '100101%'是固定前缀,能吃到主键索引;写成LIKE '%100101%'就退化成全表扫描了,这个差别在几十万行上非常明显。
递归 CTE 我一般不用来查业务数据,而是用来做数据校验——把整棵树展开成带路径的行,人工扫一遍层级是否连续:
-- 展开成「省/市/区/街道/村」的路径,仅用于校验,需 MySQL 8.0 或 PostgreSQL WITH RECURSIVE t AS ( SELECT code, name, parent_code, 1 AS lvl, CAST(name AS CHAR(512)) AS path FROM sys_region WHERE level = 1 UNION ALL SELECT r.code, r.name, r.parent_code, t.lvl + 1, CONCAT(t.path, ' / ', r.name) FROM sys_region r JOIN t ON r.parent_code = t.code WHERE t.lvl < 5 ) SELECT lvl, path FROM t WHERE code = '100101001001';CAST(name AS CHAR(512))是必须的,不显式定长,某些版本会报长度不匹配。另外 MySQL 有个cte_max_recursion_depth默认 1000,五层远不到上限,但如果哪天数据有问题形成环(A 的父是 B、B 的父是 A),递归会一直往下跑直到报错——这反而是个免费的死循环检测。
从那以后我每次拿到这类文件,都先跑一遍第 2.3 节的四条校验,再跑一条SELECT level, COUNT(*) FROM ... GROUP BY level对量级,最后才导入业务库。这三步加起来不到五分钟,能挡掉后面百分之八十的玄学问题。希望帮到你。
本文还有配套的精品资源,点击获取