
简介面向开发、数据分析与地理信息相关从业者的全国行政区划代码数据集依据GB/T 2260标准整理覆盖省、市、县、乡镇四级区划共约4.6万条记录可用于数据库建表、地区字典维护、统计分析及GIS项目的基础数据准备。压缩包共4个文件约5.01MB分别提供SQL脚本、Access数据库、TXT文本和XLSX表格SQL可直接导入MySQL快速建表mdb便于无SQL环境查看TXT与XLSX则适合Excel读取、筛选和二次加工。已有1105人学习下载说明其在行政区划数据整理场景中具有一定参考价值。数据为2017年版本使用者可据此获得较完整的六位行政区划编码、层级关系与名称对照同时也需留意区划调整带来的时效性限制适合做历史数据复现或基础字典搭建。1. 拿到一份 SQL 版全国行政区划代码先想清楚它替你省了哪一步上个月帮朋友的公司做一套后台注册页要选地区产品经理甩过来一句「三级联动数据要准」。真动手才发现麻烦不在前端而在数据本身全国行政区划代码这套东西每年都在动撤县设区、改名、编码合并随手从网上抄一份 GB/T 2260 的 CSV导入之后大概率是一半父子关系对不上。这次拿到手的是打包成 SQL 脚本的全国行政区划代码库直接source就能出表省掉的正是「找数据 对齐层级 补父级」这一段最枯燥也最容易出错的活。它适合三类人做后台表单联动的、做数据清洗要按地区聚合的、以及需要在离线环境里维护一份地区字典的。下面按「数据结构怎么设计的、脚本怎么跑、查询怎么写、哪里会翻车、二次开发怎么接」顺着拆一遍。2. 数据模型拆解GB/T 2260 编码规则与表结构设计先把编码这件事说透否则后面所有查询都是猜。GB/T 2260 是行政区划代码的国家标准主体是 6 位数字按「省 2 位 地 2 位 县 2 位」分段递进。很多 SQL 脚本在这个基础上还会额外提供 9 位或 12 位的扩展码统计用区划代码扩展段对应乡级、村级。你手上这份资源到底给到哪一级导入前先head看一眼源文件最稳妥。2.1 六位编码的分段含义与层级判定理解分段规则之后层级其实是「算出来的」而不是「查出来的」。前 2 位非零、后 4 位全零就是省级单位前 4 位非零、后 2 位为零就是地级单位6 位全非零就是县级单位。所以level字段本质是一个冗余字段用来换查询性能。-- 从编码本身推导层级用于校验脚本里的 level 字段有没有写错 SELECT code, name, CASE WHEN RIGHT(code, 4) 0000 THEN 1 -- 省级后四位全零 WHEN RIGHT(code, 2) 00 THEN 2 -- 地级后两位为零 ELSE 3 -- 县级六位占满 END AS derived_level, level AS stored_level FROM region_dict WHERE level CASE WHEN RIGHT(code, 4) 0000 THEN 1 WHEN RIGHT(code, 2) 00 THEN 2 ELSE 3 END;这段 SQL 的核心是拿「推导层级」和「存储层级」做对比返回的就是脏数据。RIGHT(code, 4)取后四位判断是否全零比用LIKE %0000更直白也不会被排序规则影响。参数上唯一要注意的是code必须是定长的CHAR(6)或至少保证没有前后空格——源文件里带\r或全角空格是这类数据集的经典毛病导入时用TRIM()或LOAD DATA的字段处理兜住。如果脚本提供的是 12 位扩展码判定逻辑就往后顺延后 6 位全零是省级后 4 位全零是地级后 2 位全零是县级其余是乡级。这个分支建议单独写一个视图别在主表上堆一堆CASE WHEN。2.2 表结构一棵自关联树还是一张扁平字典市面上的做法基本两种。第一种是自关联树只存code / name / parent_code层级靠递归查第二种是扁平字典把province / city / county三列直接拍平在一行里查询极快但更新很痛。这份资源我倾向按「主表存树 视图拍平」组合着用。CREATE TABLE region_dict ( code CHAR(6) NOT NULL COMMENT GB/T 2260 六位行政区划代码, name VARCHAR(64) NOT NULL COMMENT 区划名称, short_name VARCHAR(32) DEFAULT NULL COMMENT 简称用于前端展示, parent_code CHAR(6) DEFAULT NULL COMMENT 父级编码省级为 NULL 或 000000, level TINYINT NOT NULL COMMENT 1省 2地 3县, full_path VARCHAR(255) DEFAULT NULL COMMENT 冗余全路径形如 省/市/县, is_active TINYINT NOT NULL DEFAULT 1 COMMENT 1在用 0已撤销, version_tag VARCHAR(16) DEFAULT NULL COMMENT 数据版本标记如 2024A, PRIMARY KEY (code), KEY idx_parent (parent_code), KEY idx_level_active (level, is_active), KEY idx_name (name) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci;逐列说一下取舍。code用CHAR(6)而不是INT因为编码有前导零语义且永远不会有算术运算定长字符在索引里也更省。parent_code允许 NULL 是为了兼容省级节点——有些脚本给省级父级填000000有些直接留空导入前统一成一种别两种混着来否则LEFT JOIN会漏。full_path是明显的冗余但前端做「省 / 市 / 县」面包屑、后端做报表分组时它值这个存储成本。is_active这一列是我强烈建议保留的。行政区划的「撤销」在业务上不等于「删除」历史订单里的地址还引着旧编码物理删掉就等着报表缺行吧。常见的做法是把is_active 0的节点留着前端下拉框过滤历史数据查询不过滤。提示如果脚本原本没带is_active、version_tag这类运维字段导入后自己ALTER TABLE补上比改脚本安全后续换版本时这两列是 diff 的基础。2.3 主键、索引与字符集几个容易忽略的默认值字符集这块翻车的人不少。源文件如果是 GBK 编码老数据包很常见直接往 utf8mb4 的表里灌名称列会变成一堆问号或者乱码而且不报错。稳妥的流程是先SHOW VARIABLES LIKE character_set_%确认连接字符集再在LOAD DATA里显式写CHARACTER SET。表一级用utf8mb4排序规则选utf8mb4_general_ci就够区划名称里出现生僻字的概率不高但如果用的是 12 位扩展码且包含乡镇名utf8mb4_0900_ai_ci在 MySQL 8.0 上对多音字排序更合理一点。索引方面主键在code上是必须的idx_parent支撑「拿某省下面所有市」这类查询idx_level_active服务「一次性拉全部县级」的字典预热。我一般不会在full_path上建索引除非有明确的模糊搜索需求——真要做名称搜索用name上的前缀索引或者干脆接一个全文/搜索引擎更合适。还有一个隐性坑code是CHAR(6)时MySQL 的比较默认会忽略尾部空格但如果某个脚本把编码存成了VARCHAR且混进了空格1101 和1101在LEFT JOIN时可能匹配上也可能匹配不上取决于版本和排序规则。所以导入后的第一件事就是跑一遍WHERE code TRIM(code)把带空格的捞出来。3. 导入落地从脚本到可查询数据库的完整过程数据模型清楚之后跑起来这件事其实不复杂难的是「跑完之后你怎么确信它是对的」。这一章按建表、导数据、体检、查询四步走每一步都给可复制的命令。3.1 建表与脚本执行顺序脚本包里通常不止一个.sql文件常见组合是「建表语句 数据插入语句」或者「建表 CSV 数据文件」。先看清单再动手ls -lh看大小几万行以内的INSERT语句可以直接source几十万行的就该走LOAD DATA了。# 1) 建库字符集一定要显式指定别依赖服务器默认值 mysql -uroot -p -e CREATE DATABASE IF NOT EXISTS region_db \ DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; # 2) 先只跑结构不跑数据确认表建对了 mysql -uroot -p region_db schema.sql # 3) 再灌数据如果是大文件加 --max_allowed_packet 防截断 mysql -uroot -p --max_allowed_packet64M region_db data.sql # 4) 立刻看行数和层级分布别等写完业务代码才发现少了一半 mysql -uroot -p region_db -e SELECT level, COUNT(*) FROM region_dict GROUP BY level;命令本身没什么玄学值得说的是顺序。先跑结构再灌数据好处是灌数据失败时表结构还在可以反复重试如果结构和数据混在一个文件里中途报错你很难判断是 DDL 的问题还是某一行数据的问题。--max_allowed_packet这个参数在导入大INSERT语句时经常是罪魁祸首默认 4M/16M 很容易被一条几万 value 的INSERT撑爆报错信息是Packet too large看到这个先加参数再说。如果资源给的是 CSV 而不是 SQL 插入语句用下面这段LOAD DATA LOCAL INFILE /path/to/region.csv INTO TABLE region_dict CHARACTER SET utf8mb4 FIELDS TERMINATED BY , OPTIONALLY ENCLOSED BY LINES TERMINATED BY \n IGNORE 1 LINES (code, name, parent_code, level) SET is_active 1;FIELDS TERMINATED BY和OPTIONALLY ENCLOSED BY要按源文件实际格式调Windows 导出的文件行尾是\r\n这时LINES TERMINATED BY \n会让每行末尾多一个\r表现为最后一个字段带不可见字符——名称看不出来code一比较就出问题。改成LINES TERMINATED BY \r\n或者在SET里对字段做TRIM(TRAILING \r FROM ...)。LOCAL关键字要求客户端开启local_infile服务端没开的话会直接报错换成服务端路径导入即可。3.2 导入后的三步体检行数对不上是最轻的病父子关系断了才是要命的。我固定跑三组检查加起来不到十秒。第一组看编码长度分布和重复-- 长度分布正常应该只有一个长度值6 或 12 SELECT CHAR_LENGTH(code) AS len, COUNT(*) AS cnt FROM region_dict GROUP BY CHAR_LENGTH(code); -- 重复编码主键约束下不该有结果但如果导入时做过 IGNORE可能静默丢行 SELECT code, COUNT(*) FROM region_dict GROUP BY code HAVING COUNT(*) 1;第二组找孤儿节点——父级编码在表里不存在SELECT c.code, c.name, c.parent_code FROM region_dict c LEFT JOIN region_dict p ON c.parent_code p.code WHERE c.parent_code IS NOT NULL AND c.parent_code 000000 AND p.code IS NULL;这段是标准的自连接反查。LEFT JOIN之后p.code IS NULL说明右表没匹配上也就是父级缺失。注意要排除省级的哨兵值否则每个省都会被当成孤儿报出来白白吓自己一跳。第三组看层级分布是否合理SELECT level, COUNT(*) AS cnt, MIN(code) AS min_code, MAX(code) AS max_code FROM region_dict WHERE is_active 1 GROUP BY level;正常情况下省级几十个、地级三百多、县级两千多量级一眼就能看出对不对。如果县级数量和地级数量差不多多半是源数据只到地级或者层级字段整体错位了。3.3 常用查询按前缀取子树、按层级取平铺数据干净之后日常查询其实就那么几种。第一种给一个省级前缀取它下面所有地级-- 参数prov_prefix 传两位省级前缀如 11 SET prov_prefix 11; SELECT code, name FROM region_dict WHERE code LIKE CONCAT(prov_prefix, %) AND level 2 AND is_active 1 ORDER BY code;LIKE CONCAT(prefix, %)这种「常量前缀 通配符」的写法能吃到code上的索引因为前缀是确定的。但要注意CONCAT在部分版本里会让优化器放弃索引稳妥写法是把拼接放到应用层直接传11%进去或者用范围条件code 110000 AND code 120000——范围写法对索引最友好代价是要自己算上界。第二种一次取三级拍平给前端做静态 JSONSELECT p.code AS prov_code, p.name AS prov_name, c.code AS city_code, c.name AS city_name, d.code AS dist_code, d.name AS dist_name FROM region_dict p JOIN region_dict c ON c.parent_code p.code AND c.level 2 JOIN region_dict d ON d.parent_code c.code AND d.level 3 WHERE p.level 1 AND p.is_active 1 ORDER BY p.code, c.code, d.code;三个自连接数据量在万级以内毫无压力。如果嫌这个查询每次都要跑把它物化成一张region_flat表换版本时重建一次前端接口直接SELECT *就行。4. 核心脚本逐段讲层级查询、前缀匹配与版本 diff前面都是能用就行这一章讲的是「用久了会疼」的地方。递归、索引失效、版本对比这三块写不好后面维护成本会翻倍。4.1 递归 CTE 拼出完整路径full_path这个字段如果脚本没给得自己算。MySQL 8.0 的WITH RECURSIVE是标准解法WITH RECURSIVE tree AS ( -- 锚点所有省级节点路径从自己开始 SELECT code, name, parent_code, 1 AS lvl, CAST(name AS CHAR(255)) AS path FROM region_dict WHERE level 1 UNION ALL -- 递归子节点把自己的名字接到父节点路径后面 SELECT c.code, c.name, c.parent_code, t.lvl 1, CONCAT(t.path, /, c.name) FROM region_dict c JOIN tree t ON c.parent_code t.code WHERE t.lvl 4 -- 防御性上限避免脏数据造成无限递归 ) SELECT code, path FROM tree ORDER BY code;锚点部分是递归的起点必须能筛出没有父级的那些行否则整棵树无从展开。递归部分每轮把子节点挂到上一轮结果上lvl做深度计数path做字符串累加。CAST(name AS CHAR(255))这步容易被忽略——CTE 的列类型由锚点决定如果不显式加宽MySQL 可能按第一行的实际长度推断深层路径拼到一半被截断。t.lvl 4是我自己的习惯脏数据里偶尔出现父子互相指向的行没有这个上限查询会一直跑到cte_max_recursion_depth默认 1000才报错日志里一片红。这段查完拿结果去UPDATE region_dict SET full_path ...回填。回填时按code关联别按名称关联重名的区划不在少数按名字更新是经典的翻车姿势。4.2 前缀匹配别写成函数包字段WHERE LEFT(code, 4) 1101和WHERE code LIKE 1101%结果一样性能差一个数量级。前者把字段包在函数里索引直接失效几十万行看似能忍一旦这张表跟别的表JOIN起来全表扫描会被放大成灾难。-- 反例函数包字段索引失效 SELECT * FROM region_dict WHERE LEFT(code, 4) prefix4; -- 正例一常量前缀 LIKE可走索引 SELECT * FROM region_dict WHERE code LIKE CONCAT(prefix4, %); -- 正例二范围查询索引利用率最高 SELECT * FROM region_dict WHERE code prefix4 AND code next_prefix4;第二个正例需要一个「上界」参数next_prefix4计算方式是对前缀末位做进位比如四位前缀的下一个区间起点就是末位加一。这个计算放在应用层做比放在 SQL 里做清晰得多Java 里几行字符串处理Python 里str(int(prefix) 1).zfill(4)就完事。真要在 SQL 里算用CONCAT(LEFT(prefix4, 3), CHAR(ASCII(RIGHT(prefix4, 1)) 1))可读性极差不推荐。另一个需要留心的点如果code列在源脚本里是VARCHAR而不是CHARLIKE 11%比的是字符串前缀语义正确但如果有历史遗留的空格 110000不会匹配。导入阶段的那次TRIM检查在这里体现价值。4.3 版本 diff新增、撤销、更名怎么捞出来行政区划一年调一次业务系统不可能每次全量重灌——历史订单关联的旧编码一旦被删报表直接缺数据。正确的姿势是每年把新版数据导入一张临时表跟老表对比出差异只做增量更新。-- region_old / region_new 结构相同均为 code name is_active -- 三类差异一次查出新增、撤销新版中不存在、更名 SELECT n.code, n.name AS new_name, o.name AS old_name, CASE WHEN o.code IS NULL THEN add WHEN n.code IS NULL THEN remove WHEN n.name o.name THEN rename END AS diff_type FROM region_new n LEFT JOIN region_old o ON n.code o.code WHERE o.code IS NULL OR n.name o.name UNION ALL SELECT o.code, NULL, o.name, remove FROM region_old o LEFT JOIN region_new n ON n.code o.code WHERE n.code IS NULL;前半段捞「新增」和「更名」后半段捞「删除」。CASE里先判o.code IS NULL是因为这一支里新增行一定存在判断顺序错了会把新增误标成更名。UNION ALL而不是UNION是为了避免对大结果集做去重排序。拿到 diff 之后新增的直接INSERT更名的UPDATE name删除的不要DELETE改成UPDATE is_active 0。这一条是血泪经验某次做数据同步把撤销的区划物理删了半个月后财务对账发现有一批历史订单的地区字段关联不上只能从备份里捞。从那以后is_active成了强行要求删除操作在区划表上是禁止的。注意更名的情况要区分「编码不变只改名」和「编码变更撤并」。后者在 diff 里表现为一删一增需要人工确认映射关系脚本没法自动推断别指望自动搞定。5. 避坑与排查导入和查询里最容易翻车的几处这一章是我踩过的坑的合集按「现象 → 原因 → 解决」记遇到问题先来这里对号入座。5.1 导入阶段的两个高频翻车点现象一导入提示成功但SELECT COUNT(*)比源文件行数少一截。原因通常是INSERT IGNORE或ON DUPLICATE KEY UPDATE在默默吞掉冲突行。区划数据集里偶尔出现同一编码对应两条记录历史遗留或合并时的脏数据主键约束一拦那条就没了而且不报错。另一种可能是sql_mode里开了STRICT_TRANS_TABLES某个字段超长或类型不符直接整行拒收。解决方式导入前先关掉IGNORE让它报错SHOW WARNINGS看具体哪一行导入后拿源文件行数和COUNT(*)做一次硬比对不相等就按code做 left join 找出缺哪些。类型超长的问题比如名称列给了VARCHAR(20)但有个长名字提前把字段放宽到VARCHAR(64)更省事。现象二中文名全是问号或者排序结果不对。前者是字符集链路断在了某一环客户端连接是 latin1、源文件是 GBK、表是 utf8mb4三者任意一个不匹配都会出乱码而且 MySQL 常常不报错。后者多半是排序规则选成了utf8mb4_bin按字节比较结果跟人的预期不一致。解决方式SHOW VARIABLES LIKE character_set_%确认client、connection、results三项LOAD DATA里显式写CHARACTER SET gbk或utf8mb4覆盖自动推断导入后跑一遍SELECT name FROM region_dict WHERE name LIKE %?%有结果就是乱码重灌。语句前面加SET NAMES utf8mb4;也是个便宜保险。5.2 查询阶段的三个隐蔽问题现象三LEFT JOIN查子级时本省的数据跑到了别的省下面。原因几乎都是parent_code的类型或值不一致父表code是CHAR(6)子表parent_code是VARCHAR(6)某些行末尾带空格或者省级父级有人填NULL、有人填000000、有人填三种写法在JOIN时的行为各不相同。解决方式建表时两张关联列保持完全一致的类型和字符集导入后统一父级哨兵值建议省级统一为NULL并在应用层判空或者统一为000000并在表上加 CHECK 约束。别两种并存。现象四递归查询跑着跑着报Recursive query aborted after 1001 iterations。原因是数据里存在环A 的父级是 B、B 的父级又是 A或者某行parent_code code自指。这种脏数据在手工维护的 Excel 转 SQL 里挺常见。解决方式跑之前先用一句 SQL 排查自指——SELECT * FROM region_dict WHERE code parent_code环状引用用递归 CTE 加深度上限兜住前一章那个t.lvl 4同时在应用层把cte_max_recursion_depth调小到 10 以内让问题尽早暴露而不是跑满 1000 轮。现象五前端下拉框里出现了已经撤销的区划用户选了一个不存在的地址。原因是查询语句忘了加is_active 1或者脚本在导入新版本时把旧数据混进了同一张表却没有标记状态。解决方式给「前台展示用」和「历史数据回查用」分别建视图前台视图固定带is_active 1和version_tag 当前版本业务代码只允许查视图不允许查基表。这条约束听着麻烦但能避免 90% 的「为什么列表里多了个不存在的县」这类问题。6. 二次开发把静态字典接进业务系统脚本跑通只是第一步真正的工作量在于让它变成业务系统里一个「不用操心」的组件。这一章讲三个改造点都是接了十几个项目之后沉淀下来的固定套路。6.1 封装成只读字典服务区划数据是典型的读多写少最适合做成只读服务。我的做法是不让业务代码直接碰数据库中间垫一层RegionService对外只暴露四个方法按编码取单条、按父级取子列表、按层级取全量、按编码取完整路径。# 极简版字典服务缓存层用进程内字典适合单机部署 class RegionService: def __init__(self, conn): self._conn conn self._cache {} # code - row self._children {} # parent_code - [rows] self.reload() def reload(self): with self._conn.cursor() as cur: cur.execute( SELECT code, name, parent_code, level, full_path FROM region_dict WHERE is_active 1 ) rows cur.fetchall() self._cache {r[0]: r for r in rows} self._children {} for r in rows: self._children.setdefault(r[2], []).append(r) def children_of(self, parent_code): # 未命中返回空列表而不是 None调用方少一层判空 return self._children.get(parent_code, []) def path_of(self, code): row self._cache.get(code) return row[4] if row else Nonereload()一次性把全量数据拉进内存万级数据占几十 MB对单机服务完全可以接受。_children这个反查字典是关键把「按父级取子级」从一次 SQL 变成一次哈希查找。children_of返回空列表而不是None是为了让调用方少写一层判空——这种小约定在多人协作里能省不少事。如果数据量再大或者多实例部署把这层换成 Redis Hash键设计成region:children:{parent_code}reload时整体重建。6.2 版本化与缓存刷新每年新版数据发布服务不能停机重灌。我一般用「双表 版本标记」的做法region_dict上带version_tag新版本导入时先写version_tag 2025A跑完 diff 和体检确认无误再一次性把服务的缓存指向新版本老版本的记录保留一段时间供历史查询。改造点具体做法注意版本标记每行带version_tag查询默认过滤当前版本老版本不要立刻删留一个季度缓存刷新提供/admin/region/reload内部接口手动触发加个鉴权别裸奔灰度切换新旧版本并存配置项控制读哪张表出问题能秒回滚变更通知diff 结果落一张region_change_log表下游系统可订阅刷新接口这块我吃过一次亏直接在业务高峰期调了reload()全量查询把数据库连接池打满接口超时一片。后来改成先查到一个临时字典对象构建完成后再做一次原子替换引用数据库压力就从「瞬时高峰」摊平成了「后台慢慢查」。6.3 校验规则落到写入侧最后一步也是最容易被跳过的一步在业务写入口做区划合法性校验。用户注册填的地址、订单里的收货地区都应该在落库前检查编码是否存在于当前版本的字典里、层级是否正确比如县级编码不能直接挂在省级下面。-- 落库前的校验编码存在、在用、且父子层级连续 SELECT 1 FROM region_dict c JOIN region_dict p ON c.parent_code p.code WHERE c.code ? AND c.is_active 1 AND p.is_active 1 AND c.level p.level 1;这条语句返回空就拒绝写入。加上p.is_active 1是为了挡住「子级还在用但父级已撤销」的脏情况实际数据里确实出现过。参数就一个待校验的编码成本极低但能挡掉大部分前端传错值、或者用户提交了过期页面缓存的情况。我现在的习惯是任何一张字典类表接入业务之前先写三样东西——只读服务封装、版本切换开关、写入侧校验 SQL。三样齐全了才允许业务方调用缺一样后面必出问题。行政区划这种一年一动、动起来还带着历史数据牵连的数据尤其如此图省事直接SELECT *糊上去等某天发现订单表里挂着一批查不到名字的地区编码返工的成本远高于当初多写的这几十行。希望帮到你。本文还有配套的精品资源点击获取