省市区街道社区五级联动:行政区划SQL导入与查询实践

发布时间:2026/10/27 2:20:07
省市区街道社区五级联动:行政区划SQL导入与查询实践

简介:这套三级+四级+五级联动 SQL 文件面向数据库开发者、后端工程师与数据库管理员,围绕国家、省份、市州等多层级行政区域数据,提供建表、外键约束、层级查询与联动取数的完整示例,可用于地址选择、区域管理、分级下拉等业务场景,也适合作为 SQL 多表关联与数据库设计的学习素材。压缩包共 25 个文件,约 22.91MB,以 PHP 代码、Markdown 说明、YAML 配置、CSV 数据、SQL 脚本及 XML/JSON 配置为主;其中 SQL 负责表结构、初始数据与查询逻辑,CSV 便于批量导入区域数据,PHP 与迁移、配置模块则展示联动接口和分层数据访问的实现思路。资源还涉及 JOIN 联合查询、触发器、存储过程、视图与索引优化等知识点,可帮助读者理解外键完整性、层级查询和性能取舍。目录按源码、迁移、SQL、CSV、配置等模块组织,便于按需查阅与二次改造。目前已有 26 人学习下载。

1. 从一次「选完街道没反应」说起

去年接了个后台项目,用户地址要精确到社区。前端省、市、区县三级下拉跑得好好的,产品加了两级,选完街道之后社区框一直是空的,接口返回 200 但 data 是空数组。查了半天发现,后端拿着街道 ID 去查parent_id,而导入的 sql 文件里社区数据压根没写进来——那份「三级+四级+五级联动sql文件」是分三个文件给的,我只 source 了第一个。这件事之后我才老老实实把这类行政区划 sql 文件当成一套完整的数据工程来看,而不是当成一段建表语句随手跑一下。

这类 sql 文件解决的事情很具体:把省、市、区县、街道、社区五个层级的行政区划数据装进一张(或几张)表,配上能按父级取子级的查询结构,让前端的级联选择器有数据可取。它适合正在做地址录入、门店选址、配送范围、数据大屏区域筛选的开发者,尤其是那些不打算为了一个下拉框去接第三方开放接口的项目。下面按「表怎么设计 → 数据怎么导 → 查询怎么发 → 哪里容易踩坑 → 怎么加自定义层级」的顺序,把我拆这份 sql 文件的过程写清楚。

2. 核心模块拆解:一张自关联表怎么撑起五级联动

三级、四级、五级联动听起来像三套东西,实际落地时几乎都是同一套结构,区别只在数据的覆盖深度。省市区是三级,加上街道是四级,再加社区就是五级。数据文件拆成三份,是为了让不需要街道和社区的项目只导入前一份,少几万行噪音数据。理解这一点,后面的表设计就不用反复推倒重来。

2.1 两种主流方案:parent_id 自关联与路径枚举

自关联方案是这类 sql 文件里最常见的做法,一张表存所有层级,用parent_id指向上一级,根节点的parent_id填 0。层级不靠表结构体现,靠一个level字段标记。它的好处是增删层级只改数据不改表,前端要几级就查几级;坏处是查一棵子树要递归,MySQL 8 之前没有WITH RECURSIVE,只能靠应用层一层层拼。

路径枚举是在自关联基础上加一列path,存从根到自己的 ID 链,比如0,1,10,103。查某个节点的所有后代就变成一次LIKE 'prefix%'前缀匹配,不用递归。这类 sql 文件如果只给了parent_id没给path,导入之后可以自己补一列,一条 UPDATE 就能刷出来。我在数据量超过五万行的场景里基本都会补这一列,因为级联组件偶尔要「反查整条路径」,每次递归上去很浪费。

还有一种是闭包表,单独建一张region_path(ancestor, descendant, depth)存所有祖先-后代关系。查询最灵活,任意层级的关系一次 JOIN 就能拿到,代价是表膨胀明显——五万行数据大概会生成几十万行关系记录。除非项目里要做「查某市下所有社区」这类复杂统计,否则为一份现成的 sql 文件加闭包表属于过度设计,我不推荐。

2.2 建表 SQL 与字段约定

下面是我在这类 sql 文件基础上整理的建表语句,字段名和注释按实际项目习惯补全。这份表结构能同时承载三级、四级、五级数据,导入时靠level区分。

CREATE TABLE `sys_region` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', `parent_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '父级ID,0表示根节点', `name` VARCHAR(64) NOT NULL COMMENT '区域名称', `short_name` VARCHAR(32) DEFAULT NULL COMMENT '简称,用于列表窄列展示', `level` TINYINT UNSIGNED NOT NULL COMMENT '层级:1省 2市 3区县 4街道 5社区', `code` VARCHAR(12) NOT NULL COMMENT '行政区划编码,长度随层级变化', `path` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '从根到当前节点的ID路径,逗号分隔', `has_child` TINYINT NOT NULL DEFAULT 0 COMMENT '是否有下级:0无 1有', `sort` INT NOT NULL DEFAULT 0 COMMENT '同级排序值', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1启用 0停用', PRIMARY KEY (`id`), UNIQUE KEY `uk_code` (`code`), KEY `idx_parent_sort` (`parent_id`, `sort`), KEY `idx_level` (`level`), KEY `idx_path` (`path`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='行政区划五级联动表';

parent_id和level是联动的命脉,level建议在导入阶段就写死,不要靠查询时数父级推算,否则每次渲染下拉都要多打几轮数据库。has_child是给前端懒加载用的:级联组件判断一个节点该不该显示右侧箭头,看这个字段比再发一次「有没有子节点」的请求便宜得多。

code上的唯一索引要留意。三级、四级数据的编码通常是 6 位、9 位,五级社区编码常见 12 位,但不同来源的数据文件里偶尔会出现同一编码挂在两个父级下面的情况——这在街道层级调整时很常见。导入前先确认唯一索引会不会顶掉数据,如果数据源不保证唯一,就把uk_code降级成普通索引idx_code,把唯一性校验放到业务侧。

path这一列不在原始 sql 里,是我后加的。加完之后层级查询从递归变成前缀匹配,改动成本很低:

-- 先给根节点刷上 path UPDATE sys_region SET path = CONCAT('0,', id) WHERE parent_id = 0; -- 再按层级逐级向上拼接,level 2 到 5 各跑一次 UPDATE sys_region c JOIN sys_region p ON c.parent_id = p.id SET c.path = CONCAT(p.path, ',', c.id) WHERE c.level = 2;

这段要按level从 2 递增到 5 依次执行,因为后一级依赖前一级的path已经算好。顺序跑反了,p.path还是空串,拼出来的路径只剩自己的 ID,后面所有前缀查询都会失准。我一般会把这几条写进一个init_path.sql,导入数据后固定执行一次。

2.3 数据文件的组织方式:三份 sql 怎么选

这类资源通常按层级拆成三个文件,命名大致是region_level3.sql、region_level4.sql、region_level5.sql。三份文件不是互斥关系,而是层层追加:三级文件建表并插入省市县,四级文件追加街道,五级文件追加社区。所以导入顺序必须是 3 → 4 → 5,跳着导会出现社区记录的parent_id指向一个不存在的街道。

如果项目只需要三级,就只跑第一份,表结构和查询代码完全不用改,level最大值是 3 而已。这种「表结构统一、数据分层给」的做法比给三套不同表结构要友好得多,因为二次开发时想从三级升到四级,只需要补导数据,不用改代码里的表名。

单份文件动辄几万到十几万行 INSERT,用客户端工具打开会卡。我的习惯是全程用命令行 source,不进 GUI。下一章展开具体步骤。

3. 导入实战:把 sql 文件跑进 MySQL

拿到 sql 文件之后第一件事不是急着执行,而是先看头部的建表语句和字符集声明,再看尾部有没有SET FOREIGN_KEY_CHECKS之类的开关。这一步花两分钟,能省掉后面半小时的乱码排查。

3.1 环境准备与命令行导入

先建库,字符集直接定成utf8mb4,别用默认的latin1或utf8。区域名称里出现生僻字和少数民族文字的概率不低,三字节的utf8存不下四字节字符,导入时会直接报Incorrect string value。

# 建库时就锁死字符集,避免后面表继承到错的默认值 mysql -uroot -p -e " CREATE DATABASE IF NOT EXISTS demo_region DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;"

然后按顺序导入三份文件。把--max_allowed_packet调大是因为单条 INSERT 批量插入几百行时,默认 4M 的包大小容易被顶爆,报Packet too large之后整个导入中断,前面已经插进去的数据还得手工清。

# 依次导入三级、四级、五级数据,顺序不能乱 for f in region_level3.sql region_level4.sql region_level5.sql; do echo ">>> importing $f" mysql -h127.0.0.1 -P3306 -uroot -p'your_password' \ --default-character-set=utf8mb4 \ --max_allowed_packet=64M \ demo_region < "$f" || { echo "failed: $f"; exit 1; } done

--default-character-set=utf8mb4这个参数必须显式带上。客户端默认字符集和文件实际编码不一致时,中文会变成问号或者一串浙江这样的乱码,而且导入过程不报错,等你打开表看到一堆问号才发现,那时候只能删表重来。

导入完成后确认行数,三级数据大致在三千行量级,四级加进去接近五万行,五级会到六七十万行。数字对不上就说明某一份文件没跑完。

-- 按层级统计,确认三份文件都进了 SELECT level, COUNT(*) AS cnt FROM sys_region GROUP BY level ORDER BY level;

3.2 字符集、事务与导入速度

大文件导入慢是常态,几十万行逐条 INSERT 在默认配置下跑十几分钟不稀奇。想快一点,可以在导入前临时关掉几个检查项,跑完再打开:

-- 导入会话内关闭自动提交和唯一性检查,显著提速 SET autocommit = 0; SET unique_checks = 0; SET foreign_key_checks = 0; SOURCE /path/to/region_level3.sql; SOURCE /path/to/region_level4.sql; SOURCE /path/to/region_level5.sql; COMMIT; SET unique_checks = 1; SET foreign_key_checks = 1;

unique_checks = 0关掉的是导入期间对唯一索引的校验,代价是如果源数据本身有重复code,重复行会被静默写进去,等重新打开检查时才在后续写入时报错。所以我一般只在大批量首次导入时这么干,跑完立刻做一次重复检查(下一节的 SQL 里有)。

还有一个提速点是改表引擎的插入顺序。如果 sql 文件里的 INSERT 是乱序的(省市区混在一起),可以先把表建成 MyISAM 导完再ALTER TABLE ... ENGINE=InnoDB,能快将近一倍。不过这招在 MySQL 8 上收益已经不明显,我现在的做法是保持 InnoDB,靠调大innodb_buffer_pool_size和innodb_flush_log_at_trx_commit=2来提速,改配置比换引擎稳妥。

如果导入过程中断了,别急着重跑整份文件。先看表里已有的最大层级,从断点那份文件的对应位置继续。用tail -n +行号截取剩下的部分导入,比从头再来省时间。

3.3 导入后的自检 SQL

数据进去之后必须做三类检查,这三条 SQL 我基本是固定写在项目的check.sql里,每次换数据源都跑一遍。

-- 1. 断链检测:parent_id 指向了一个不存在的父节点 SELECT c.id, c.name, c.parent_id 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; -- 2. 同级重名:同一个父级下出现两个同名区域,前端会显示重复项 SELECT parent_id, name, COUNT(*) AS cnt FROM sys_region GROUP BY parent_id, name HAVING cnt > 1; -- 3. has_child 与实际子节点数不一致,会导致级联组件箭头显示错误 SELECT p.id, p.name, p.has_child, COUNT(c.id) AS real_children FROM sys_region p LEFT JOIN sys_region c ON c.parent_id = p.id GROUP BY p.id, p.has_child HAVING (p.has_child = 1 AND real_children = 0) OR (p.has_child = 0 AND real_children > 0);

第一条查出来的断链记录,常见原因是四级文件里出现了三级文件没覆盖到的区县,多半是数据源版本不一致,比如三级数据是旧版、四级是新版。这种只能换同一批数据源,或者手工补父节点。

第三条的结果可以用一条 UPDATE 修掉,不用逐行改:

-- 用子查询重算 has_child,修掉箭头显示不对的问题 UPDATE sys_region p SET p.has_child = EXISTS ( SELECT 1 FROM (SELECT parent_id FROM sys_region WHERE parent_id <> 0) c WHERE c.parent_id = p.id );

MySQL 不允许在 UPDATE 里直接子查询同一张表,所以中间套了一层派生表。这条跑完之后,前端懒加载的leaf判断就准了。

4. 查询链路拆解:一次省市区街道选择要发几条 SQL

表建好、数据导完,接下来是联动本身。前端级联组件有两种工作模式:一次性把所有选项加载到内存,或者点一级加载一级。对应的后端接口和 SQL 完全不同,选错了要么页面卡死,要么请求数爆炸。

4.1 懒加载模式:按 parent_id 取下一级

懒加载是默认推荐的做法,尤其数据到了五级。用户点开省,才查这个省下的市;点开市,才查区县。单次请求只返回几十行,接口响应稳定在几十毫秒。

-- 取某个节点的直接下级,parentId 为 0 时返回所有省 SELECT id, name, short_name, level, has_child FROM sys_region WHERE parent_id = ? AND status = 1 ORDER BY sort ASC, id ASC;

参数只有一个parent_id。ORDER BY sort, id这个组合不能省:sort是人工维护的展示顺序(直辖市排在前面、开发区排在后面之类的业务需求),相同sort时用id兜底保证结果稳定,否则同一次刷新两次顺序不一样,用户会觉得列表在跳。idx_parent_sort这个联合索引就是为这条查询建的,走索引之后即使表里有七十万行,取某一级的子节点也是毫秒级。

后端接口按这个思路写,用 Node + Express 举例,核心只有几行:

// GET /api/regions?parentId=0 懒加载取下一级 router.get('/api/regions', async (req, res) => { // parentId 缺省为 0,即根层级;转成数字防止注入和类型不匹配 const parentId = Number(req.query.parentId ?? 0); if (!Number.isInteger(parentId) || parentId < 0) { return res.status(400).json({ code: 400, msg: 'parentId 非法' }); } // 走 idx_parent_sort 索引,只取必要字段,别 SELECT * const [rows] = await pool.query( `SELECT id, name, level, has_child FROM sys_region WHERE parent_id = ? AND status = 1 ORDER BY sort ASC, id ASC`, [parentId] ); // 前端级联组件需要 leaf 标记,has_child 取反即可 res.json({ code: 0, data: rows.map(r => ({ ...r, leaf: r.has_child === 0 })) }); });

这里把has_child映射成前端组件通用的leaf字段,是为了让前端不用关心业务字段名。parentId做了整数校验,因为拼字符串进 SQL 是这类接口最常见的注入点,虽然用了占位符,参数类型不对一样会让索引失效——传"0"和传0在 MySQL 里走的是不同的执行计划。

懒加载的代价是用户每展开一级就发一次请求,从省选到社区一共四次。四次往返在网络差的环境下会有明显顿挫。缓解办法是选中某一级之后预取它的子级,用户还没点就已经在本地了。

4.2 全量树模式:一次拉平缓存

另一条路是启动时把所有数据查出来,在内存里拼成树,接口只做数组过滤。数据量在五万行以内(也就是到四级)时,这条路很舒服,一次查询、零次网络往返。

-- 全量拉取,只取建树必需的字段,控制单次返回体积 SELECT id, parent_id, name, level, has_child, sort FROM sys_region WHERE status = 1 ORDER BY parent_id ASC, sort ASC, id ASC;

七十万行五级数据这么拉肯定不行,返回体几百兆,Node 序列化就卡住了。所以全量树模式我只在三级、四级场景用,五级一律懒加载加缓存。判断标准很简单:全量 JSON 序列化后超过 2MB,就该换方案。

如果只用路径前缀查子树,加过path列之后一条 SQL 就够,不用在应用层递归:

-- 查某节点下所有后代,最多到指定层级 SELECT id, parent_id, name, level FROM sys_region WHERE path LIKE CONCAT((SELECT path FROM sys_region WHERE id = ?), ',%') AND level <= ? ORDER BY level ASC, sort ASC;

LIKE '前缀%'能用到idx_path索引,因为前缀是确定的。这里有个细节:拼接的时候必须加上逗号','%',只写CONCAT(path, '%')会把1,10和1,100混在一起,后者本来不是前者的后代却被查出来。这种错误在测试环境数据少的时候看不出来,上线后某个区县多出几十个不相干的社区才发现,属于典型的血泪经验。

4.3 反查路径与回显

编辑页面要回显用户已选的五级地址,得从社区 ID 一路往上找到省。用path列就是一次查询加字符串切分:

-- 根据叶子节点 ID 反查完整路径,用于编辑页回显 SELECT id, parent_id, name, level, path FROM sys_region WHERE id = ? OR FIND_IN_SET(id, (SELECT path FROM sys_region WHERE id = ?)) ORDER BY level ASC;

第二条FIND_IN_SET拿path里的 ID 串去匹配,返回的是一串按层级排好序的记录,前端直接按level顺序填进级联组件的v-model数组即可。如果没有path列,就得在应用层写循环查父级,五级地址五次查询,接口耗时翻五倍。这就是我前面坚持补path的原因。

5. 避坑与排查:导入和联动里最容易翻车的几处

前面几章的代码都是顺利路径,实际项目里真正耗时间的是下面这些。每条按现象、原因、解决写,都是我或者同事实际遇到过的。

5.1 导入后中文全是问号

现象:表能建,行数也对,但name字段打开一看全是???,前端下拉框里一排问号。

原因:sql 文件本身是 UTF-8 编码,但客户端连接用了默认的latin1,转码过程中四字节字符全部丢失,写入时就变成了问号。这种情况导入过程不报错,属于最坑的一类。

解决:先确认文件编码file -i region_level5.sql,再确认导入命令带--default-character-set=utf8mb4,最后确认表和库的字符集都是utf8mb4。三处任何一处不对都会出问题。已经导错的表没法修补,只能TRUNCATE后重导。

5.2 选完街道,社区下拉是空的

现象:接口返回 200,data是空数组,但数据库里明明有社区数据。

原因:两种可能。一是只导了三级和四级文件,五级文件没执行;二是社区的parent_id在数据源里指向的街道 ID,和实际导入后的自增 ID 对不上——如果 sql 文件里没显式指定id,靠自增生成,后面再单独导入五级文件时自增起点变了,父子关系就断了。

解决:先跑 3.3 的断链检测 SQL,能查出来就是第二种。修法是导入时保留原始 ID,不要靠自增,也就是 sql 文件里的 INSERT 必须带id列。如果文件里没带,就只能按code前缀重新关联父级,这个工作量不小,所以拿到文件第一件事就看 INSERT 有没有指定主键。

5.3 级联组件箭头一直显示,点开却是空的

现象:某个区县右侧有展开箭头,点开之后没有街道,组件卡住不响应。

原因:has_child字段标成了 1,但实际没有子节点。多半是数据源里有已撤销的区县,父级记录还在,下级数据被清理了,而has_child没同步更新。

解决:跑 3.3 第三条 SQL 找出不一致的记录,用那条 UPDATE 重算全表has_child。更省事的做法是干脆不用has_child字段,前端首次展开时无论有没有子级都发请求,返回空数组再标记leaf,多一次请求换掉一类 bug,看项目取舍。

5.4 同一父级下出现两个重名的区县

现象:下拉列表里出现两行完全一样的名字,用户不知道选哪个。

原因:数据源合并了两个版本的数据,同一条记录导了两遍,但code不同所以唯一索引没拦住;或者id是自增的,重复导入时生成了新 ID。

解决:先跑 3.3 第二条 SQL 定位。合并时优先保留code较新的一条,另一条把status置 0 而不是物理删除,因为历史订单里可能还引用着旧 ID,直接删会导致地址回显时查不到名称。这条教训是数据治理里的常规操作,别图省事DELETE。

5.5 导入到最后一行报 Packet too large

现象:导入跑到大半,突然报MySQL server has gone away或Packet too large,前面已插入的数据还在。

原因:默认max_allowed_packet是 4M,五级文件末尾如果有超大的一条批量 INSERT,会直接超过限制。

解决:导入命令加--max_allowed_packet=64M,服务端my.cnf里的同名参数也要调到一致,只改客户端不生效。已经中断的导入不用重来,用SELECT MAX(id) FROM sys_region拿到断点,再用tail -n +N截取文件剩余部分续导。

6. 二次开发:加第六级、换存储、做缓存

这份 sql 文件的数据结构是开放的,改起来不复杂,但有几个地方值得提前想清楚。最常见的需求是加第六级(网格、楼栋),表结构完全不用动,只要约定level = 6,父级指向社区 ID 就行。真正要改的是三处:前端级联组件的props配置要放开层级限制,后端接口的level上限校验要放宽,以及has_child的重算逻辑要覆盖到第 6 层。改完之后跑一次 3.3 的断链检测,确认新层级的数据父级都对得上。

第二个场景是数据量上去之后的性能。下面这张表是我在不同项目里用过的三种查询方案,按数据规模选:

方案适用数据量单次请求 SQL 数优点代价
懒加载逐级查五级,几十万行以上每展开一级 1 条内存占用低,首屏快展开有网络往返
全量树内存过滤三级、四级,五万行内1 条(启动时)零往返,实现简单数据更新要重启或刷新
Redis 缓存 + 懒加载任意规模命中缓存 0 条兼顾速度与体积需处理缓存失效

缓存这块我的做法是按parent_id做 key,值存该父级下的子节点数组 JSON,TTL 设一天。数据变更频率极低,一天一次全量刷新完全够用。预热脚本就是遍历所有has_child = 1的节点,批量查一次再写进 Redis:

# 缓存预热:按 parent_id 分组写入 Redis,key 形如 region:children:100 import json, pymysql, redis conn = pymysql.connect(host='127.0.0.1', user='root', password='pwd', database='demo_region', charset='utf8mb4') rds = redis.Redis(host='127.0.0.1', port=6379, db=0) with conn.cursor(pymysql.cursors.DictCursor) as cur: # 一次查出所有节点,在内存里分组,避免 N+1 查询 cur.execute("SELECT id, parent_id, name, level, has_child FROM sys_region WHERE status = 1") buckets = {} for row in cur.fetchall(): buckets.setdefault(row['parent_id'], []).append(row) # pipeline 批量写,减少往返;TTL 一天 pipe = rds.pipeline() for parent_id, children in buckets.items(): pipe.setex(f"region:children:{parent_id}", 86400, json.dumps(children, ensure_ascii=False)) pipe.execute()

这段的关键是先全量查一次再内存分组,而不是对每个父级发一条查询。七十万行数据全量拉一次大概几秒,如果按父级循环查,五万个父级就是五万次往返,预热脚本能跑一晚上。ensure_ascii=False必须加,否则 Redis 里存的是\uXXXX转义串,取出来还得再解一次。

第三个改造点是数据更新。区域数据不是一成不变的,撤县设区、街道合并每年都有。我的做法是保留一个version字段或者在表外单独维护一张region_version表,记录每次导入的数据版本和时间。前端接口返回时带上版本号,页面拿到的版本和当前不一致就提示刷新。这个机制听着多余,等真遇到用户在旧数据页面上选了一个已经不存在的街道、提交时报外键约束失败,就知道有多需要。

最后说个我自己的习惯。这类 sql 文件我从来不在生产库上直接导,一定先在本地建一份同名库跑通,跑完跑一遍 3.3 的三条自检 SQL,确认断链、重名、has_child都为 0 条记录,再打包成带版本号的脚本走发布流程。从那以后我每次接新的区域数据源都强制走这一遍,看着麻烦,实际比事后修数据省事得多。希望帮到你。

本文还有配套的精品资源,点击获取