简介:面向开发者、数据分析师和信息化建设人员的全国五级行政区划数据包,覆盖省、地、县、乡、村五级行政区域及对应代码,适合地址库建设、区域筛选、数据清洗、统计汇总、报表分析和系统级联选择等场景,也可服务于政务、电商、物流与地理信息类项目,版本更新至2023年3月。压缩包共3个文件,含2个SQL脚本和1个CSV表格,整体约18.8MB;SQL便于按需导入数据库,CSV适合查看、比对与二次处理,字段以行政区域名称和代码为主,可省去逐级梳理区划关系的时间。已有5018人学习下载。借助完整的层级与代码对应关系,读者能快速搭建五级联动数据结构,完成历史数据匹配、区域维度统计和基础地理信息校验,也可作为转换为JSON等格式的可靠底表,减少重复采集与校对成本,尤其适合需要批量维护地址主数据或构建区域维度表的团队。
1. 五级行政区域数据库表到底长什么样:一张宽表还是五张表
拿到一份标着 2023 年更新的全国五级行政区域数据库表和配套 SQL 文件时,很多人的第一动作是直接执行导入命令,然后打开自己的三级联动下拉框,发现乡镇一级是空的。出错点几乎从来不在数据质量上,而是 6 位行政区划码和 12 位村级码之间那段补齐规则的处理方式不对。
这份资源覆盖省、市、区县、乡镇街道、村社区五级,记录里带上级 parent 关系、城乡分类代码、拼音与首字母,适合自己做地址库、门店选址、物流四级分单,或者给 CRM 补一份本地行政区划底表。下面按「表怎么建 → 数据怎么导 → 查询怎么写 → 数据怎么校验 → 哪里最容易翻车」的顺序拆一遍,每一步都能直接抄。
2. 表结构与编码规则:12 位行政区划码怎么拆成五级
先别急着建表。打开 SQL 文件,先看它是五张独立表还是一张宽表。这两种在市面上都常见,选错了后面每加一个层级都要改表结构,代价不小。
2.1 单表邻接模型与五表范式模型的取舍
五表范式模型的写法是 province、city、district、town、village 各一张,字段按层级定制:市一级多一个行政级别字段,区县多一个是否市辖区的标识,乡镇带城乡分类代码。好处是语义清晰,坏处是「按村反查省市区」要 join 四次,查询计划很难看,而且一旦要加一级网格,五张表全部要动。
单表邻接模型是一张sys_region,用level区分层级,parent_code指向上级。三级联动就是一次parent_code索引扫描,加层级只加一个level值。我一般选这个,再旁挂一张扩展表存村级专有属性,避免主表宽到三十列还全是 NULL。
代价也要说清楚:单表模型下删除一个区县,它的乡镇和村不会自动消失,得靠parent_code手工递归。所以这类库基本只做逻辑删除,用status标记,物理删除留给下一版重建表的时候一起做。
编码规则要先吃透,它是后面所有校验的基准:
| 层级 | 代码位数 | 说明 |
|---|---|---|
| 省级 | 6 位 | 前两位为省级标识,后四位补 0 |
| 地级 | 6 位 | 前两位同省,中间两位为顺序码 |
| 区县 | 6 位 | 前四位同市,后两位为顺序码 |
| 乡镇街道 | 9 位 | 前 6 位同区县,后 3 位为乡级顺序码 |
| 村社区 | 12 位 | 前 9 位同乡镇,后 3 位为村级顺序码 |
关键点:不要把六级都用 6 位铺平,也不要用 12 位存所有层级。统一右补零到 12 位存,是最省心的做法,既能保证定长索引效率,也避免「6 位和 9 位混存、字符串比较时前面少位」这类问题。
2.2 建表语句与字段含义
下面这份 DDL 我用了几年,PostgreSQL 微调一下就能用(AUTO_INCREMENT换GENERATED BY DEFAULT AS IDENTITY,TINYINT换SMALLINT):
CREATE TABLE `sys_region` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键', `code` CHAR(12) NOT NULL COMMENT '行政区划代码,右补零至12位', `name` VARCHAR(64) NOT NULL COMMENT '标准名称,与统计用区划代码对应', `short_name` VARCHAR(32) DEFAULT NULL COMMENT '简称,如去掉“市/区/县”后缀', `level` TINYINT NOT NULL COMMENT '1省 2市 3区县 4乡镇街道 5村社区', `parent_code` CHAR(12) NOT NULL DEFAULT '000000000000' COMMENT '上级代码,顶级填全0', `pinyin` VARCHAR(128) DEFAULT NULL COMMENT '全拼,小写无分隔', `initials` VARCHAR(16) DEFAULT NULL COMMENT '首字母缩写,大写', `lng` DECIMAL(10,7) DEFAULT NULL COMMENT '经度,GCJ-02 或 WGS-84 需统一', `lat` DECIMAL(10,7) DEFAULT NULL COMMENT '纬度', `urban_rural` CHAR(3) DEFAULT NULL COMMENT '城乡分类代码:111/112/121/122/123/210/220', `node_type` TINYINT NOT NULL DEFAULT 0 COMMENT '0正式区划 1开发区/管委会 2虚拟村居', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1有效 0已撤销', `version` SMALLINT NOT NULL COMMENT '数据版本号,如 2023', PRIMARY KEY (`id`), UNIQUE KEY `uk_code_ver` (`code`, `version`), KEY `idx_ver_parent` (`version`, `parent_code`, `level`), KEY `idx_ver_level_name` (`version`, `level`, `name`), KEY `idx_ver_pinyin` (`version`, `pinyin`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='五级行政区划主表';几个必须解释的参数:
code用CHAR(12)而不是BIGINT。行政代码存在前导零,转成整数再存会把省级代码100000变成100000看着没事,但乡镇层出现0开头的顺序码时就丢位了。而且用字符串存才能做前缀匹配查询。
uk_code_ver是(code, version)而不是单列code。这是给多版本共存的场景留的口子,2022 和 2023 两版数据能同时在一张表里存着做对比,切版本只改查询条件,不重建表。
idx_ver_parent的第一个字段是version。这是踩过坑才改的:原来写成(parent_code, level),查询时带上版本条件,优化器只能用上parent_code,version回表过滤,村级数据量一上来延迟立刻可见。
node_type是自定义字段,不在国标里。它用来区分正式的行政区划、开发区管委会这类「挂名在乡镇级但不是一级政府」的单位,以及村里用来挂集体户的虚拟村居。不加这个字段,前端下拉里就会混进一堆空选项。
2.3 从 SQL 文件导入:MySQL、PostgreSQL、SQLite 三条路径
导入之前先确认文件是纯 SQL 还是带 mysqldump 头。带/*!40101 SET ...*/这类注释的直接按 MySQL 走,纯 INSERT 的三家都能吃。
MySQL 客户端导入:
# 先建空库并锁定字符集,避免继承服务端默认的 latin1 mysql -h 127.0.0.1 -u root -p -e " CREATE DATABASE IF NOT EXISTS region_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" # 导入:显式指定字符集,放宽单包上限,跳过逐条提交 mysql -h 127.0.0.1 -u root -p \ --default-character-set=utf8mb4 \ --max-allowed-packet=256M \ region_db < region_2023.sql--default-character-set=utf8mb4不能省。客户端默认字符集和服务端不一致时,生僻字会在传输阶段就被截断成问号,而且是静默的,导入完成不报错。
--max-allowed-packet=256M针对的是某些 SQL 文件把几万条村级记录塞进单条INSERT INTO ... VALUES (...),(...)的写法。默认 64M 装不下时,报错信息是MySQL server has gone away,看起来像连接断了,其实是包超限。
PostgreSQL 路线:
# -v ON_ERROR_STOP=1 让任意一条语句失败即中断,避免导到一半留下半表数据 psql -h 127.0.0.1 -U postgres -d region_db \ -v ON_ERROR_STOP=1 \ -c "SET client_encoding = 'UTF8';" \ -f region_2023.sqlON_ERROR_STOP这个参数建议无脑加上。不带它的时候 psql 遇错继续往下跑,最后你拿到的是一个缺了几千条村级记录、但表面完整的库,排查起来是很麻烦的事。
SQLite 路线适合轻量场景,比如桌面工具或移动端内置地址库:
# SQLite 不做严格类型检查,CHAR(12) 实际按 TEXT 存,反而省了迁移麻烦 sqlite3 region.db <<'EOF' PRAGMA journal_mode = WAL; PRAGMA synchronous = OFF; .read region_2023.sql PRAGMA synchronous = NORMAL; EOF导入期间把synchronous关掉、journal_mode设成 WAL,几十万行的导入耗时会降一个数量级,导完记得改回来。注意 SQLite 没有AUTO_INCREMENT和ENGINE=InnoDB,MySQL 语法的文件要先做一次语法替换。
3. 查询落地:从三级联动到按村反查全路径
表建好、数据进去了,接下来是真正决定这个库好不好用的部分。业务方提的需求无非两类:从上往下逐级展开,和从下往上反查归属。
3.1 邻接表转路径:递归 CTE 的写法与终止条件
市级以上反查用递归 CTE 最直接。MySQL 8.0 起原生支持:
-- 输入任意一个村级代码,向上回溯出完整五级路径 WITH RECURSIVE up AS ( SELECT code, name, parent_code, level, 0 AS depth FROM sys_region WHERE code = '100101001001' AND version = 2023 UNION ALL SELECT r.code, r.name, r.parent_code, r.level, up.depth + 1 FROM sys_region r JOIN up ON r.code = up.parent_code AND r.version = 2023 WHERE up.depth < 4 -- 硬性截断,最多向上 4 层 ) SELECT code, name, level FROM up ORDER BY level;depth < 4这个条件看着多余,其实是保命的。源数据里一旦出现parent_code指向自己、或者两个节点互指的脏记录,递归会一路跑下去,直到撞上cte_max_recursion_depth(默认 1000)才报错,日志里就是一句Recursive query aborted,根本看不出是哪条数据的问题。加硬截断后最多返回 5 行,异常数据一眼可见。
version = 2023在每个分支里都要带。只在最外层加是无效的,递归的每一轮都会重新扫全表。
PostgreSQL 写法基本一致,把反引号去掉即可。如果 PG 装了ltree扩展,也可以给每个节点预先算好物化路径,查询时用@>做前缀匹配,代价是每次数据更新要重算路径。
3.2 三级联动接口的 SQL 写法与索引命中
前端最常见的调用是「给定父级代码,返回下一级列表」:
-- 查某省下的所有地级市 EXPLAIN SELECT code, name, short_name FROM sys_region WHERE version = 2023 AND parent_code = '100000' AND level = 2 AND status = 1 ORDER BY code;看EXPLAIN的key列,应该命中idx_ver_parent,type是ref,rows在几十的量级。如果显示ALL全表扫描,八成是因为version和parent_code两个条件的顺序和索引定义不一致,或者parent_code传进来时被隐式转换成了数字。
ORDER BY code而不是ORDER BY name。行政区划代码本身就是按行政顺序编的,按代码排出来的下拉框顺序天然符合用户预期;按名称排会出现「按拼音首字母跳来跳去」的观感问题,而且没法用索引消掉排序。
再说一个实操层面的取舍。省份和地级市两级加起来通常不到 400 行,区县一级也只有几千行,这三层完全可以一次性全量拉出来塞进进程内缓存或者直接打进前端包。真正需要走接口的只有乡镇和村两级。我一般会在服务启动时把level <= 3全量加载成树,接口只处理level IN (4,5),这样九成以上的联动请求都不碰数据库,那个所谓的「缓存一致性」问题也就不用操心了。
3.3 常用业务查询模板
按前缀拿整棵子树,比递归快得多,前提是代码严格右补零:
-- 取某市下所有区县及其乡镇,前缀匹配走 code 上的索引 SELECT code, name, level FROM sys_region WHERE version = 2023 AND code LIKE '1001%' AND level IN (3, 4) ORDER BY code;LIKE '1001%'是常量前缀,能用上索引;写成LIKE '%1001%'就退化成全表扫描了。前缀长度按层级算:省级取前 2 位,市级取前 4 位,区县取前 6 位,乡镇取前 9 位。
批量取名称时注意别写成循环:
-- 一次拿回一批村级代码对应的完整名称,避免 N+1 SELECT code, name, CONCAT_WS('', NULLIF(p1.name,''), NULLIF(p2.name,''), NULLIF(p3.name,''), NULLIF(p4.name,'')) AS full_path FROM sys_region v LEFT JOIN sys_region p4 ON p4.code = LEFT(v.code, 9) AND p4.version = v.version LEFT JOIN sys_region p3 ON p3.code = LEFT(v.code, 6) AND p3.version = v.version LEFT JOIN sys_region p2 ON p2.code = LEFT(v.code, 4) AND p2.version = v.version LEFT JOIN sys_region p1 ON p1.code = LEFT(v.code, 2) AND p1.version = v.version WHERE v.version = 2023 AND v.code IN ('100101001001','100101001002','100102001001');这里用LEFT(code, n)拼上级代码,前提是代码严格右补零且层级规范。一旦数据里混进了不规范的省级直辖县级单位,这个拼法就会拼出一个不存在的父节点,LEFT JOIN返回 NULL,full_path少一段。稳妥做法是老老实实按parent_codejoin 四次,慢一点但不会错。
拼音和首字母搜索是地址输入的刚需:
-- 支持“全拼前缀”和“首字母前缀”两种输入 SELECT code, name, level FROM sys_region WHERE version = 2023 AND level = 3 AND (pinyin LIKE 'nanjing%' OR initials LIKE 'NJ%') LIMIT 20;pinyin和initials两个字段在导入阶段就要算好,别在查询时调函数。中文转拼音在 SQL 层没有原生支持,只能靠应用层预处理。
4. 数据校验与清洗:把 SQL 文件跑成可上线的数据
导入成功不等于数据可用。下面三条断言是我每次导完必跑的,任何一条不过就不往下走。
4.1 三级完整性校验:孤儿节点、层级分布、同级重名
孤儿节点是最常见的问题,表现为某个乡镇的parent_code在表里找不到对应记录:
-- 断言1:不存在找不到父节点的记录(顶级节点除外) SELECT c.code, c.name, c.parent_code FROM sys_region c LEFT JOIN sys_region p ON p.code = c.parent_code AND p.version = c.version WHERE c.version = 2023 AND c.level > 1 AND p.code IS NULL;返回非空就说明数据断层。常见原因是源文件把某一级单独拆成了附录,导入时漏跑了,或者父节点被标成了status = 0但子节点没跟着处理。
层级分布看数量级是否符合常识:
-- 断言2:五级数量和位数分布 SELECT level, COUNT(*) AS cnt, MIN(CHAR_LENGTH(TRIM(TRAILING '0' FROM code))) AS min_len, MAX(CHAR_LENGTH(code)) AS max_len FROM sys_region WHERE version = 2023 GROUP BY level ORDER BY level;正常的分布是:省级几十条,地级几百条,区县几千条,乡镇几万条,村社区几十万条。如果某一级数量明显偏少一个数量级,基本可以断定是导入时被max_allowed_packet截断或者中途报了唯一键冲突。
同级重名要单独查一遍:
-- 断言3:同一个父节点下不允许出现同名节点 SELECT parent_code, name, COUNT(*) AS c FROM sys_region WHERE version = 2023 GROUP BY parent_code, name HAVING c > 1;这条在村级数据上经常有惊喜。两个「新村」挂在同一个镇下面,可能是真的两个行政村,也可能是同一份数据被重复插入。区分办法是看code后三位是否不同、urban_rural是否一致,实在判断不了就当成真实数据保留,但要在业务层做去重展示。
4.2 编码异常:前导零丢失、位数不齐与代码复用
从 Excel 或 CSV 中转出来的数据,前导零丢失是高频事故。省级代码010000会变成10000,区县代码010101会变成10101。检查方法:
-- 找出长度不足 12 位的记录(假设统一右补零存储) SELECT code, name, level, CHAR_LENGTH(code) AS len FROM sys_region WHERE version = 2023 AND CHAR_LENGTH(code) <> 12 LIMIT 100;修复用LPAD,但补之前必须确认补的是左还是右。省级是右补零(100000→100000000000),乡镇是 9 位右补零,村级本来就 12 位。一律LPAD(code, 12, '0')会把100000补成000000100000,直接错位。
-- 正确做法:按层级分别补零,且只补一次 UPDATE sys_region SET code = RPAD(code, 12, '0') WHERE version = 2023 AND level <= 4 AND CHAR_LENGTH(code) < 12 AND code <> LPAD(code, 12, '0'); -- 排除已经被左补零污染的行第二个坑是代码复用。行政区划调整中,某个乡镇被撤销后,它原来的代码可能在后续年份被分配给一个新设立的单位。如果只有code一个业务键,新旧两条记录的name完全不同,你的历史订单归属就会莫名其妙地变成另一个地方。处理办法是status加version做联合判断:查历史数据必须带当年的version,查当前归属用最大version。
4.3 版本对比:用 SQL 跑出一份变更清单
两版数据都导入同一张表之后,做一次全量对比,产出一份「新增、撤销、更名」清单交给业务方确认。MySQL 没有FULL OUTER JOIN,用两次LEFT JOIN加UNION ALL模拟:
-- 对比 2022 与 2023 两版,输出四类变更 SELECT COALESCE(n.code, o.code) AS code, o.name AS old_name, n.name AS new_name, CASE WHEN o.code IS NULL THEN '新增' WHEN n.code IS NULL THEN '撤销' WHEN o.name <> n.name THEN '更名' ELSE '未变' END AS change_type FROM (SELECT code, name FROM sys_region WHERE version = 2022 AND level = 3) o LEFT JOIN (SELECT code, name FROM sys_region WHERE version = 2023 AND level = 3) n ON o.code = n.code WHERE n.code IS NULL OR o.name <> n.name UNION ALL SELECT n.code, o.name, n.name, '新增' FROM (SELECT code, name FROM sys_region WHERE version = 2023 AND level = 3) n LEFT JOIN (SELECT code, name FROM sys_region WHERE version = 2022 AND level = 3) o ON o.code = n.code WHERE o.code IS NULL;第二条UNION ALL专门捞新增节点,因为第一条的LEFT JOIN以旧版为驱动表,捞不到旧版里不存在的代码。level = 3只对比区县,实际用的时候建议把乡镇和村也各跑一遍,村级变更量往往比区县大一个数量级。
拿到变更清单后,最值钱的动作是把它跟业务表做一次交集:哪些门店地址的区县被撤销了、哪些订单的乡镇归属需要重算。这件事越早做越好,等用户投诉地址选不出来了再回头查,成本翻好几倍。
5. 避坑:五级行政区划数据落地时最容易翻车的 5 件事
以下都是我或者身边同行真实撞过的,按「现象 → 原因 → 解决」写清楚。
5.1 直辖市和省级直辖单位把 parent 关系打断
现象:三级联动里点了某个直辖市,市级下拉是空的;或者某个县级市怎么都找不到它的上级。
原因:直辖市的层级结构和普通省份不同,市级节点在部分数据源里缺失或者用省级代码本身占位,parent_code不是规矩的「截掉后两位」。省级直辖的县级单位,父节点直接挂在省上,层号是 3 但父级层号是 1,跳了一级。
解决:永远不要用LEFT(code, 4)这种截断方式反推父节点,一律读parent_code字段。同时在完整性校验里加一条:level = 3 且父节点 level = 1的记录数如果为零,说明这个数据源没有处理直辖单位,要单独补。
5.2 开发区、管委会和虚拟村居把五级撑成六级
现象:乡镇级下拉里出现大量「某某开发区管委会」「某某新区」,村级里出现一整片同名条目,点进去没有任何下级。
原因:统计口径里的「乡级单位」和「村级单位」包含非行政区划实体。开发区、高新区、保税区在统计上挂在乡镇级,但不设村委会;虚拟村居是为了挂集体户口临时编的,代码尾号常常是000或999。
解决:用node_type字段区分(0 正式区划 / 1 开发区 / 2 虚拟村居),前端联动默认只查node_type = 0,后台管理页面才放开全部。另外可以加一条规则:村级代码后三位为000的,标记为虚拟节点。
5.3 村级数据一次性导入把事务撑爆
现象:MySQL 客户端跑到村级 INSERT 时卡住不动,几分钟后报MySQL server has gone away或者The table is full。
原因:一是单条INSERT塞了几万行,超过max_allowed_packet默认的 64M;二是autocommit = 0时整个文件是一个大事务,undo log 一直膨胀,InnoDB 日志空间被吃光。
解决:先用split把 SQL 文件按 5 万行切块,逐块导入;导之前临时SET autocommit = 1,或者每 2000 条显式COMMIT;max_allowed_packet调到 256M,innodb_log_file_size至少给到 512M。导入完成后再改回原配置。
5.4 字符集与生僻字导致同名记录合并
现象:导入后部分乡村名变成问号;或者两条看起来一模一样的记录,GROUP BY name能查出两行。
原因:utf8只支持三字节,部分生僻字需要四字节,MySQL 的utf8编码在写入时会静默截断。另一种情况是源数据里存在异体字,肉眼看着一样,实际码点不同,utf8mb4_general_ci下比较结果不确定。
解决:库、表、连接三处全部统一utf8mb4;比对去重时用BINARY强制按字节比:GROUP BY parent_code, BINARY name,这样异体字不会被误合并,能真正暴露数据问题。
5.5 多版本同表,查询忘了带 version
现象:上线一段时间后,联动下拉里出现重复城市,或者已经撤销的区县又冒出来了。
原因:新旧两版数据同表共存,某几个查询接口漏了version条件;或者本地缓存没跟着版本号失效,旧 key 一直命中。
解决:version必须进唯一索引,也必须进每一条查询的 WHERE;缓存 key 统一拼上版本号,切版本时换 key 前缀而不是清空缓存。再加一层兜底:应用启动时跑一次SELECT version, COUNT(*) FROM sys_region GROUP BY version,日志里打出来,哪个版本的数据量不对一眼就能看见。
6. 版本升级做成可回滚流程:影子表、校验、一把切换
每年数据更新一次,直接DELETE旧版再INSERT新版是最省事也最危险的做法。中间任何一步失败,线上就是一个空的或者半残的地址库,用户在下单页选不出区县。我现在的做法是影子表配合原子改名:
-- 1) 建一张结构完全一致的空表 CREATE TABLE sys_region_new LIKE sys_region; -- 2) 把新版本 SQL 全部导进影子表(改文件里的表名,或者在导入会话里做映射) -- 导入期间线上读的仍然是 sys_region,零影响 -- 3) 在影子表上跑完整的三条校验(孤儿、层级分布、同级重名) SELECT COUNT(*) FROM sys_region_new WHERE level = 5; -- 4) 校验通过后原子切换,MySQL 的 RENAME 支持一条语句改多表 RENAME TABLE sys_region TO sys_region_bak_2022, sys_region_new TO sys_region;RENAME TABLE是原子的,切换瞬间不会有请求读到半张表。旧表保留至少一个迭代周期,一旦发现新版本某个区县的归属有问题,再RENAME回去只要一秒。
切换之后还有两件事要补。一是把版本号下发给业务方:应用配置里的REGION_VERSION从 2022 改成 2023,全量重启或者热刷新缓存。二是跑一次变更清单的交集查询,把受影响的业务数据捞出来,比如:
-- 找出地址表里归属到「已撤销区县」的记录,交给业务方确认 SELECT o.id, o.addr_detail, o.region_code FROM biz_order o LEFT JOIN sys_region r ON r.code = o.region_code AND r.version = 2023 AND r.status = 1 WHERE r.code IS NULL LIMIT 500;返回的这批数据就是新版本上线后第一批会出问题的订单。提前跑出来,比等客服工单堆上来再查要轻松太多。
血泪经验是有一年图快,直接在原表上TRUNCATE然后重导,导到村级时网络抖了一下,客户端断开,线上地址库空了将近四十分钟,那天的订单全部积压在待处理队列里。从那以后我每次升级都强制走一遍影子表加原子改名,哪怕只是加一个乡镇。希望帮到你。
本文还有配套的精品资源,点击获取