简介:面向需要实现国家—省—市州多层级联动查询的开发者与数据库学习者,这套三级+四级+五级联动SQL资料以行政区域为典型场景,提供建表、数据插入、关联查询、外键约束及触发器、存储过程、视图与索引等实践素材。压缩包共25个文件,包含9个PHP、4个Markdown、3个YAML、3个CSV、3个SQL以及XML、JSON等配置与说明文件,整体约22.91MB,兼顾数据库脚本、区域数据样例与PHP侧调用结构,便于直接导入数据库或对照理解分层查询逻辑。已有28人学习,适合希望从表结构设计、层级关联到查询性能优化完整走一遍的中级开发者,也可作为后台地区选择器、地址管理模块的参考实现。
1. 从一张填不满的下拉框说起:这份五级联动 SQL 文件到底装了什么
上周帮人排查一个后台表单,仓储系统录入客户收货地址,省、市、区三下拉完,第四级「街道」永远空着。翻到数据库才发现不是前端组件的问题,是那份号称省市区三级的 SQL 文件本身就只到区县,三四五级的街道、社区压根没数据。这种坑我踩过不止一次——联动数据的完整度,比联动组件写得多漂亮重要得多。这份三级+四级+五级联动的 sql 文件解决的正是这件事:把行政区划从省一路铺到街道、社区村,层级关系用 parent_id 串成自关联树,导进 MySQL 之后前端想逐级懒加载还是全量建树都能接得住。适合正在做地址簿、门店选址、物流分单、用户实名信息采集这类需要结构化地址的开发,也适合手上已经有一套两三级数据、想补齐到底层的团队。
2. 表结构拆解:parent_id 加 level 的自关联模型怎么建才不返工
拿到一份联动数据,第一件事不是急着 import,而是先确认它对表结构做了什么假设。市面上这类 SQL 文件的表设计分成两派:一派直接用行政区划代码当主键、靠代码前缀截断来表达父子关系;另一派用自增 id 当主键、另存一个 parent_id 指回上级。这两种设计在后续查询上的体验差得非常远,五级联动尤其明显,因为层级一深,前缀截断的规则就会变得很难维护。
2.1 五个层级字段怎么定,level 与 code 谁才是主心骨
自关联模型的核心就三个字段:主键 id、父级 parent_id、层级 level。顶级节点的 parent_id 固定写 0,不用 NULL,原因是WHERE parent_id = 0的写法比IS NULL更容易写进索引,也避开了 NULL 参与比较时一堆三值逻辑的麻烦。level 用 1 到 5 表示省、市、区县、街道、社区村,用 TINYINT UNSIGNED 就够,别为了"以后可能扩展"上 BIGINT。
行政区划代码 code 我的建议是存下来但不当主键。代码本身是有语义的:前两位省、前四位市、前六位区县、前九位街道、前十二位村社区,看起来很美,但现实里会遇到几个绕不开的问题。一是直辖市和省直辖县的代码结构不规范,二是开发区、高新区这类非标准行政区划经常没有官方代码,三是合并撤并之后老代码会被回收再分配。拿 code 当外键,一旦上级代码变了,下面所有子节点的 parent_id 全得跟着改,血泪经验是这种级联更新一旦中断,树就断成了两截。
所以我的常规做法是:id 自增主键管关系,code 做业务唯一索引管对账,两套体系各干各的活。下面这份建表语句是我在几个项目里反复用过的版本,字段不多但够用:
CREATE TABLE `sys_region` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键,自关联用', `parent_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '父级主键,顶级固定为0', `name` VARCHAR(64) NOT NULL COMMENT '行政区全称', `short_name` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '简称,去掉省/市/区/街道等后缀', `level` TINYINT UNSIGNED NOT NULL COMMENT '层级:1省 2市 3区县 4街道 5社区村', `code` VARCHAR(12) NOT NULL DEFAULT '' COMMENT '行政区划代码,仅用于对账', `pinyin` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '全拼,小写无分隔', `initial` CHAR(1) NOT NULL DEFAULT '' COMMENT '首字母大写', `is_leaf` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否末级,1表示没有子节点', `sort` SMALLINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '同级排序值', `status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1启用 0停用', PRIMARY KEY (`id`), UNIQUE KEY `uk_code_level` (`code`, `level`), KEY `idx_parent_level` (`parent_id`, `level`), KEY `idx_level_sort` (`level`, `sort`), KEY `idx_pinyin` (`pinyin`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五级行政区划联动表';几个字段值得单独说一下。short_name不是冗余,联动框里显示"某某省"和显示"某某"的观感差很多,前端截字符串截不干净(有的名字带"自治区"三个字),放在库里由数据决定更稳。is_leaf是给懒加载用的,前端判断"这一级选完还要不要再请求下一级",靠它比靠 level 判断准,因为第五级不一定处处都有——有的街道下面直接是社区,有的街道下面没有村级单位。sort解决的是直辖市、省直辖县这类节点的展示顺序问题,纯按 id 排会乱。
level和code联合唯一索引那里要留个心眼:如果数据源里有代码为空的节点(开发区很常见),空字符串会撞唯一键。我一般把空 code 统一写成0或者干脆在导入前补一个占位码,别让它进唯一索引。
2.2 索引怎么配:为什么 parent_id 单列索引在第四级就撑不住了
联动查询的形态非常固定,几乎全是WHERE parent_id = ? AND status = 1 ORDER BY sort。这条语句看起来只需要 parent_id 一个索引,但实际跑起来,到第四第五级会明显变慢,原因在回表次数和排序上。
如果只建KEY idx_parent(parent_id),MySQL 走索引拿到一堆主键,再回表取 level、sort、status 过滤和排序。第四级街道下面挂十几个社区还好,第三级区县下面挂几十个街道也还好,真正难受的是把level也作为过滤条件的时候——比如"只查某个市下面的所有街道"这种跨级查询,单列索引会把该市下面所有节点都捞出来再过滤。idx_parent_level(parent_id, level)把两个高频条件拼在一个索引里,联合索引的最左前缀是 parent_id,逐级查询照常命中;同时带上 level 之后,跨级过滤也能在索引里完成。
idx_level_sort(level, sort)是给"按层级批量取"准备的。全量建树的场景通常是WHERE level <= 3 ORDER BY level, sort,这个索引能直接按顺序吐出结果,省掉 filesort。数据量小的时候感觉不到,五级全量七十万行的时候差别就出来了。
还有一个容易被忽略的点:parent_id用 INT UNSIGNED,前端传参时如果框架把参数当成字符串塞进来,WHERE parent_id = '10'在 MySQL 里会做隐式类型转换,索引直接失效。这个坑在 MyBatis 的#{}和${}混用、或者 Node 那边拼接字符串时特别常见,慢查询日志里一眼就能看到。
2.3 用路径字段换掉递归:full_name 与 path 的取舍
纯自关联模型的短板是"反查完整路径"。用户选了第五级社区,你要展示"某省/某市/某区/某街道/某社区",不借助额外手段就得递归五次或者自连接五次。层级固定的时候还行,一旦哪天要加个"片区"变成六级,所有写死五次的 SQL 全得改。
我的做法是在表里加两列冗余:path存祖先 id 链(形如0,1,12,340,5678),full_name存拼好的全路径名。写入时由程序或触发器维护,读取时一次SELECT就能拿到,代价是存储多占一点、更新时要级联改。行政区划数据是典型的读多写极少,这个交易很划算。
初始化这两列的 SQL 可以一次性跑完,不用写游标:
-- 按层级从高到低逐层刷新,level=1 的先落定,后面每层都能直接引用父级结果 UPDATE sys_region SET path = CAST(id AS CHAR), full_name = name WHERE level = 1; UPDATE sys_region c JOIN sys_region p ON c.parent_id = p.id AND p.level = c.level - 1 SET c.path = CONCAT(p.path, ',', c.id), c.full_name = CONCAT(p.full_name, '/', c.name) WHERE c.level >= 2;这段的关键是"从上往下"刷新,父节点的 path 和 full_name 必须先算好,子节点才能直接拼接。五级就是跑四条 UPDATE,每条都是 join 主键,几十万行也就几秒。注意CAST(id AS CHAR)这里不能省,MySQL 里 CONCAT 遇到数字会做隐式转换,但显式写出来更清楚,也避免某些版本下的告警。
有了 full_name 之后,模糊搜索也能落到这一列上,用户输入"某某街道"能直接命中,比层层 join 快得多。缺点也直说:改了父节点名字(行政区更名),所有子节点的 full_name 都得重刷。所以我的项目里会给这一列单独留一个"重建脚本",放进数据更新的 checklist 里,别指望它自动同步。
3. 数据导入实操:从 .sql 文件到能查的库,中间隔着四道坎
SQL 文件导入听起来是最没技术含量的一步,实际上新手卡住的时间有八成耗在这里。文件编码、客户端字符集、包大小限制、库默认排序规则,四个变量里任意一个不对,导进去的都是垃圾数据,而且表面上还看不出来——SELECT COUNT(*)是能返回行数的,只有点开某一行才会发现中文全变成了问号。
3.1 命令行导入与图形化客户端的取舍
图形化客户端导入省事,但碰到大文件会卡死,而且它默认的连接字符集会让你在出问题的时候完全摸不着头脑。几十 MB 的 SQL 文件我统一走命令行,可控、可脚本化、出错了报错信息也清楚。
第一步先建库,把字符集和排序规则显式写死,别用默认的:
# 显式指定库级字符集,避免服务器默认配置把中文吞掉 mysql -h 127.0.0.1 -P 3306 -u root -p -e " CREATE DATABASE IF NOT EXISTS region_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci;"这里用utf8mb4_general_ci而不是 MySQL 8 默认的utf8mb4_0900_ai_ci,原因很实际:很多 SQL 文件里写的是DEFAULT CHARSET=utf8,导入过程中如果库和表的排序规则不一致,某些版本会报 "Illegal mix of collations",尤其是在后面做 JOIN 的时候。统一到 general_ci 能规避掉大部分跨表比较的问题,代价只是排序精度略低,对行政区名称这种场景完全够用。
第二步才是导入本体:
mysql -h 127.0.0.1 -P 3306 -u root -p \ --default-character-set=utf8mb4 \ --max_allowed_packet=64M \ -D region_db < ./region_5level.sql三个参数各自的含义:--default-character-set=utf8mb4决定客户端和服务端握手时声明的编码,必须和文件实际编码一致;--max_allowed_packet=64M是给那些把几万行合并成一条 INSERT 的文件准备的,不加大概率会撞上 "MySQL server has gone away";-D指定默认库,因为不少导出文件里没有USE语句,不指定就会报 "No database selected"。
如果文件本身是 GBK 编码(老数据里挺常见),别硬用 utf8mb4 导,先转码再导:
# 用 file 确认文件真实编码,别靠编辑器右下角猜 file -i region_5level.sql # 确认是 gbk 之后转成 utf-8,再走上面的导入流程 iconv -f GBK -t UTF-8 region_5level.sql -o region_5level_utf8.sqliconv转完一定要抽查几行,尤其是含有生僻字的地名,转码失败的字符会变成?且不报错。
3.2 字符集的三层结构:文件、连接、表,错一层就乱码
很多人把"字符集"当成一个整体概念,实际导入链路上有三层独立的设置,任一层不匹配就出乱码:文件本身的字节编码、客户端连接声明的字符集、表字段的字符集。排查问题的顺序也应该按这三层走。
第一层看文件,file -i或者hexdump -C region_5level.sql | head看头部字节,UTF-8 的中文是三个字节的 E4-E9 开头,GBK 是两个字节的 B0-F7 开头,看一眼就能区分。第二层看连接,导入结束后在同一个会话里跑SHOW VARIABLES LIKE 'character_set%',character_set_client、character_set_connection、character_set_results三个都应该是 utf8mb4。第三层看表,SHOW CREATE TABLE sys_region里如果写着CHARSET=utf8,那就是三字节的老 utf8,遇到生僻字会截断或者存成?。
已经导错了也别慌,不用重导全量,一条 UPDATE 就能救回来——前提是字节没丢,只是解释方式错了:
-- 只有当原始字节还在、只是被当成 latin1 解释时才有效 UPDATE sys_region SET name = CONVERT(BINARY(CONVERT(name USING latin1)) USING utf8mb4) WHERE name LIKE '%?%' OR HEX(name) REGEXP '^(C3|C2)';这条CONVERT嵌套的写法是典型的"乱码修复偏方",管用但有前提:数据得是先被错当成 latin1 存进去的。如果导入时字符集搞错了导致字节被真正替换成?,那这条也救不回来,只能重新导。所以我一般会在导入后立刻抽查:SELECT name FROM sys_region WHERE level = 1 LIMIT 5;,肉眼扫一眼,比事后花两小时排查划算。
3.3 导入后必做的四条校验 SQL
数据"导进去了"和"导对了"是两件事。我每接一份联动数据都会固定跑下面这组校验,任何一条返回非零结果都说明数据有问题,先修再往下写业务。
-- 1. 各层级数量分布,心里有个基准值 SELECT level, COUNT(*) AS cnt FROM sys_region GROUP BY level ORDER BY level; -- 2. 孤儿节点:parent_id 指向了一个不存在的 id SELECT COUNT(*) AS orphan_cnt FROM sys_region c LEFT JOIN sys_region p ON c.parent_id = p.id WHERE c.parent_id <> 0 AND p.id IS NULL; -- 3. 层级错位:子节点 level 不等于父节点 level + 1 SELECT COUNT(*) AS level_mismatch FROM sys_region c JOIN sys_region p ON c.parent_id = p.id WHERE c.level <> p.level + 1; -- 4. is_leaf 撒谎:标了末级却还有子节点 SELECT COUNT(*) AS leaf_lie FROM sys_region p WHERE p.is_leaf = 1 AND EXISTS (SELECT 1 FROM sys_region c WHERE c.parent_id = p.id);第一条是基线,正常分布是金字塔形,省几十、市几百、区县几千、街道几万、社区几十万,如果某一级数量突然塌陷,说明数据源那一层没导出。第二条孤儿节点最常见于手工拼合多份数据源,A 文件里的街道 parent_id 指向 B 文件里的区县,两份一合并就断链。第三条层级错位往往是省直辖县这类特殊结构导致的,出现了要人工确认是不是设计如此,别急着改。第四条is_leaf撒谎最隐蔽,前端表现就是"选了街道之后下拉框消失,但用户说这个地方明明有社区"。
4. 联动查询的几种写法:逐级懒加载、全量建树与路径回显
表建好了数据校验过了,接下来就是前端怎么取数。三种写法各有适用场景,选错了不是跑不通,是体验和性能会很难看。我的判断标准很简单:层级深度固定、数据量小、用户操作路径短,用懒加载;需要支持模糊搜索、跨级跳选、或者要做离线缓存,用全量;只需要展示已选结果的完整路径,用反查。
4.1 逐级懒加载:按 parent_id 一节一节取
这是最经典的做法,用户选了省才请求市,选了市才请求区。SQL 只有一句:
-- 取某个节点下的直接子节点,前端每选一级调一次 SELECT id, name, short_name, level, is_leaf FROM sys_region WHERE parent_id = 340 AND status = 1 ORDER BY sort, id;参数340是上一级选中节点的 id,status = 1过滤停用节点,ORDER BY sort, id保证直辖市这类需要人工排序的节点位置正确,sort相同的时候用 id 兜底,避免每次查询顺序抖动。返回的is_leaf是给前端用的,为 1 时禁用下一级下拉框,别让用户对着一个永远空白的框发呆。
这个方案的优点是首屏只查一次、每次只返回十几到几十行,网络开销极小。缺点是用户改上级选择时,下面所有级的缓存都要清空;还有"用户想直接跳到某个社区"这种场景没法支持,只能一层层点下去。如果业务里有大量"省市区三项用户都懒得点"的抱怨,就该换方案了。
懒加载还要注意一点:初始化的时候顶级是parent_id = 0,但有些数据源把顶级写成parent_id IS NULL或者parent_id = 1(指向一个虚拟根)。导之前用SELECT DISTINCT parent_id FROM sys_region WHERE level = 1确认一下,别想当然。
4.2 全量建树:一次请求拿完,前端 O(n) 组装
数据量在几千到几万行、只取到第三级或者第四级的时候,我更倾向一次性全量返回,前端在内存里建树。这样前后端各一次交互就完事,用户切来切去全是本地计算,体验明显更顺。
SQL 就是一条带层级的条件查询:
-- 只取到第四级(街道),避免第五级撑爆响应体 SELECT id, parent_id, name, short_name, level, is_leaf, sort FROM sys_region WHERE status = 1 AND level <= 4 ORDER BY level, sort, id;level <= 4这个限制是关键。五级全量七十万行,序列化成 JSON 大概几十 MB,浏览器解析都吃力,更别说移动端。真需要第五级,还是老老实实走懒加载,或者按市级维度做分片加载——用户选了市再拉这个市下面的街道社区。
前端建树的逻辑很轻,两次遍历就够:
// rows 是后端返回的扁平数组,返回树形结构 function buildTree(rows) { const map = new Map(); const roots = []; // 第一遍:挂上 children 并建索引 rows.forEach(r => { r.children = []; map.set(r.id, r); }); // 第二遍:认父 rows.forEach(r => { if (!r.parent_id) { roots.push(r); return; } const p = map.get(r.parent_id); if (p) { p.children.push(r); } else { // 父节点被 level 条件裁掉,或数据本身有孤儿,打日志别静默丢 console.warn('orphan node:', r.id, r.name); roots.push(r); } }); return roots; }这里的console.warn分支不是摆设。真跑起来的时候,孤儿节点就是这么被你发现的——如果父节点因为level <= 4被裁掉(实际上不会,因为子节点 level 一定大于父节点),或者数据本身断链,子节点会莫名消失在树上,用户看到的就是"某个市下面是空的"。日志比让线上用户来反馈便宜得多。
4.3 路径回显:从第五级反查完整上级链
订单详情页、用户地址列表这类场景,库里存的是最末级的 id,展示要"某省/某市/某区/某街道/某社区"。三种写法,按 MySQL 版本和层级是否固定来选。
MySQL 8.0 及以上用递归 CTE 最干净:
-- 从叶子节点往上爬,直到 parent_id 为 0 WITH RECURSIVE up AS ( SELECT id, parent_id, name, level FROM sys_region WHERE id = 56789 UNION ALL SELECT p.id, p.parent_id, p.name, p.level FROM sys_region p JOIN up c ON p.id = c.parent_id WHERE c.parent_id <> 0 ) SELECT GROUP_CONCAT(name ORDER BY level DESC SEPARATOR '/') AS full_path FROM up;递归的终止靠couldn't = 0这个条件,GROUP_CONCAT里的ORDER BY level DESC保证从省到社区的顺序。注意 MySQL 默认cte_max_recursion_depth是 1000,五级远远够用,但如果哪天数据被污染成环(A 的父是 B,B 的父是 A),递归会直接报错超限——这反而是件好事,能帮你发现环形数据。
MySQL 5.7 没有 CTE,就退回五次自连接:
-- 层级固定为五级时最快,每次都是主键 join SELECT CONCAT_WS('/', l1.name, l2.name, l3.name, l4.name, l5.name) AS full_path FROM sys_region l5 LEFT JOIN sys_region l4 ON l5.parent_id = l4.id LEFT JOIN sys_region l3 ON l4.parent_id = l3.id LEFT JOIN sys_region l2 ON l3.parent_id = l2.id LEFT JOIN sys_region l1 ON l2.parent_id = l1.id WHERE l5.id = 56789;CONCAT_WS而不是CONCAT,是因为CONCAT遇到任意一个 NULL 会让整个结果变成 NULL,CONCAT_WS会跳过 NULL,容错更好。LEFT JOIN也是同理,某一层断了至少还能拼出半截路径,而不是全空。
第三种就是我前面说的full_name冗余列,SELECT full_name FROM sys_region WHERE id = 56789一条搞定,代价是维护成本。高频展示、低频变更的场景,这个方案最省事。
5. 避坑与排查:五级联动数据落地时最常翻车的五个地方
这一节是从我这几年被坑出来的记录里挑的,每一条都在真实项目里出现过至少两次,按出现频率从高到低排。现象、原因、解决三段式,对着看基本能定位。
5.1 导入后中文变问号:三层字符集总有一层没对齐
现象:导入成功,COUNT(*)正常,但打开任何一行看 name 字段,中文全部是???或者一堆乱码汉字。
原因:九成是文件本身编码和--default-character-set不一致。文件是 GBK,你用 utf8mb4 声明,服务端就按 utf8mb4 去解释 GBK 的字节,结果全是乱码;反过来文件是 utf8mb4 却按 latin1 导,存进去的就是被错误解释的字节,还能通过CONVERT挽回。剩下的一成是表或字段本身建成了utf8(三字节),生僻字存不进去。
解决:先file -i确认文件编码,再检查SHOW VARIABLES LIKE 'character_set%',最后确认SHOW CREATE TABLE。三层都对齐到 utf8mb4 再导。已经导乱且字节被替换掉的,没有后悔药,只能重导,所以我的习惯是先导 100 行试水,抽查无误再跑全量。
5.2 第三级点得出来,第四级永远是空的
现象:省、市、区县三级联动正常,选完区县之后街道下拉框空白,控制台也没有报错。
原因:数据只到第三级,level最大值是 3;或者第四级数据存在,但parent_id填的是行政区划代码而不是区县的自增 id,WHERE parent_id = 340自然一条都查不到。还有一种情况是level字段被错填,第四级数据的 level 也写成了 3,按parent_id查能查到,但前端按 level 判断是否继续请求,逻辑直接跳过了。
解决:跑一遍SELECT level, COUNT(*) FROM sys_region GROUP BY level,看有没有第 4、5 级。有数据但查不到,就SELECT id, code, parent_id FROM sys_region WHERE level = 4 LIMIT 10,对比 parent_id 是不是在你第三级的 id 集合里。混用 code 和 id 是最常见的一类,导入前用SELECT COUNT(*) FROM sys_region WHERE parent_id > 0 AND parent_id NOT IN (SELECT id FROM sys_region)一次就能揪出来。
5.3 数据跑到第四级就开始卡,慢查询日志里全是全表扫描
现象:前三级秒开,点第四级转两秒,第五级直接超时。
原因:parent_id没有索引,或者有索引但参数类型不匹配导致索引失效。第四级街道数量比第三级区县多一个数量级,第五级社区又多一个数量级,数据量一上来全表扫描的代价就暴露了。另一种隐蔽情况是ORDER BY sort没有索引支撑,MySQL 把整个结果集捞出来做 filesort。
解决:EXPLAIN看一眼,type是ALL就是没走索引,Extra里有Using filesort就是排序没吃上索引。加idx_parent_level(parent_id, level)和idx_level_sort(level, sort)两个联合索引,重新跑。参数类型的问题在 MyBatis 里特别容易中招,WHERE parent_id = #{id}传 Integer 没问题,传 String 就会隐式转换,EXPLAIN的type会显示ALL但很多人只看key不为空就以为没问题。
5.4 重复执行 SQL 文件,数据翻了一倍
现象:第一次导完 30 万行,手滑又执行了一遍,变成 60 万行,前端下拉框里每个省出现两次。
原因:文件里的语句是INSERT INTO,没有唯一约束去拦,也没有TRUNCATE或者DROP TABLE前置。有的导出文件带DROP TABLE IF EXISTS,看着安全,但它会把你后来手工加的字段和索引一起干掉。
解决:短期靠UNIQUE KEY (code, level)顶住,重复插入会直接报主键冲突而不是静默写入。长期习惯是导入前先SELECT COUNT(*),导完再对一次数。真要幂等导入,把 INSERT 改造成INSERT ... ON DUPLICATE KEY UPDATE name = VALUES(name),但注意这么写的前提是 code 可靠,前面说过开发区那类没代码的节点会失效。
5.5 行政区划调整之后,数据一年没更新
现象:用户反馈某个县已经撤县设区了,系统里还是老名字;或者两个街道合并了,下拉框里还挂着已经不存在的一个。
原因:这份 SQL 文件是静态快照,导进去那一刻就固定了,没有任何版本号或者更新时间戳。行政区划调整年年有,静态数据放半年就开始失真。
解决:建一张sys_region_version记录每次导入的版本和来源日期,业务表里存的是节点 id,展示时按 id 查最新名称,这样改名不影响历史数据。定期做增量:拿新数据按code和旧数据对一遍,产出"新增 / 更名 / 撤销"三张差异表,人工确认后再执行变更。撤销的节点不要物理删除,置status = 0,否则历史订单里存的 id 就查不出东西了。
6. 二次开发:给这份数据加拼音搜索、加缓存、加增量更新
数据跑通之后,多数项目下一步会提三个需求:输入框能打拼音搜、联动接口别再每次查库、行政区划变了要能增量同步。这三件事的改造成本都不高,但顺序有讲究,先做搜索再做缓存,因为缓存 key 的设计依赖搜索的入参形态。
拼音搜索直接落在pinyin和initial两列上,前提是这两列得先被填满。数据源里没有拼音的话,可以用程序批量生成一次回写,之后就只有增量节点需要补。查询本身很简单:
-- 支持 "hz" 命中"杭州"、也支持 "hangzhou" 命中,两级过滤放在应用层更灵活 SELECT id, name, full_name, level, is_leaf FROM sys_region WHERE level = 3 AND status = 1 AND (name LIKE CONCAT(#{kw}, '%') OR pinyin LIKE CONCAT(#{kw}, '%') OR initial LIKE CONCAT(#{kw}, '%')) ORDER BY sort, id LIMIT 20;注意LIKE CONCAT(#{kw}, '%')是前缀匹配,能吃上idx_pinyin索引;写成LIKE CONCAT('%', #{kw}, '%')就是全表扫,几万行的时候能忍,几十万行就是灾难。真要支持任意位置匹配,那就上全文索引或者独立的搜索引擎,别在 MySQL 上硬扛。LIMIT 20也是必须的,搜索框不需要返回全量,用户看到前 20 条就够了。
缓存这块,我的做法是按parent_id做 key,因为联动查询的入参就是这个:
| 缓存 key | 内容 | 过期策略 |
|---|---|---|
region:children:{parentId} | 直接子节点列表 JSON | 24 小时,导入后主动清空 |
region:path:{id} | full_name 完整路径 | 7 天,随数据变更一起清 |
region:tree:l3 | 三级全量树 | 永久,只有导入才刷新 |
parent_id = 0的顶级节点访问量最大,命中率基本是 100%,这一层缓存下来能挡掉大部分请求。过期时间给长一点没关系,行政区划变更频率以年计,真正需要保证的是"导入新数据后主动清理",而不是靠 TTL 自然过期——我见过缓存没清导致新加的街道在某台机器上死活不出来的情况,排查了半天。
增量更新是最容易被忽略的一块。我的习惯是维护一份code -> id的映射表,新数据到手先按 code 对一遍,只处理差异:
-- 找出新数据里有、旧库里没有的节点(结构一致时可直接用临时表做差集) SELECT n.code, n.name, n.level FROM tmp_region_new n LEFT JOIN sys_region o ON o.code = n.code AND o.level = n.level WHERE n.code <> '' AND o.id IS NULL;tmp_region_new是按与主表相同的结构建的空表,把新 SQL 文件导进去,跑完差集再决定哪些是新增、哪些是更名(code 相同 name 不同)、哪些是撤销(旧有新无)。这条链路跑顺了之后,每次数据更新就是"导入临时表 → 跑三条差集 SQL → 人工过一遍 → 执行变更 → 清缓存",半小时能搞定。撤销的节点记得置status = 0而不是DELETE,历史订单一堆外键指着它。
从那以后我每接一份联动数据,第一件事不是建表也不是写接口,是老老实实把层级分布、孤儿节点、is_leaf一致性那几条校验 SQL 跑一遍,宁可多花五分钟,也别让前端在第四级下拉框里对着空白发呆。希望帮到你。
本文还有配套的精品资源,点击获取