简介:面向开发者、数据分析人员及后台系统建设者的全国五级行政区域数据包,更新至2023年3月,覆盖省、市、县、乡、村完整层级,重点解决地址级联、区域代码维护与基础数据初始化问题,可用于地址选择、统计汇总、电商下单、政务信息录入及历史数据校对等场景。压缩包共3个文件,以2个SQL脚本和1个CSV表格为主,SQL便于直接导入数据库建表查询,CSV适合表格查看、比对与二次整理,整体约18.8MB。数据包含各级行政区域名称及对应代码,层级完整、字段直观,能减少手工收集与逐级核对成本,也便于按业务需要进一步做筛选、关联和格式转换。已有5018人学习下载,适合需要快速落地行政区划基础数据、进行系统联调或数据清洗的中初级开发人员学习参考使用。
1. 五级行政区划数据包:为什么我宁愿自己导库也不用在线接口
上个月给一个本地生活类小程序做后台,用户下单地址要精确到「某街道某社区」,前端省市区三级联动选完就断了,剩下的让用户手打,后台攒了一堆「某某路旁边那个小区」的脏数据。要补上乡镇街道和村社区两级,在线接口按次计费,还得处理限流、超时、缓存穿透。最后我直接把一份 2023 年更新的全国五级行政区域数据库 SQL 文件导进了自己的 MySQL:省、市、区县、乡镇街道、村社区五层一次到位,一次导入,之后所有查询都走本地。这份资源适合做后台管理、CRM、报表分组、地址清洗、物流分区的开发者,只要你业务里出现过「区县」以下的地址粒度,它就是兜底的那一层。下面按表结构、导入、查询、避坑、二次开发逐层拆开。
2. 表结构拆解:一张自关联表如何装下六十万条村居数据
拿到 SQL 文件先别急着 source,先看建表语句。这类数据包常见两种形态:一种是五张独立表(省表、市表、区县表、乡镇表、村居表),一种是一张自关联表靠parent_code和level串起五层。前者字段少、单表查询快,但做「根据村级反查完整地址」时要 JOIN 四次;后者一次查询就能拿到父子关系,代价是表大、索引要设计好。我一般选单表,因为业务侧百分之八十的诉求是「给我某个节点的下一级」和「给我某个节点的完整路径」,单表两种情况都好处理。
2.1 建表语句与字段设计
先看我在实际项目里用的建表语句,字段是在原始 SQL 基础上补了几个业务常用列:
CREATE TABLE `sys_region` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键', `code` VARCHAR(12) NOT NULL COMMENT '行政区划代码:省级6位/乡级9位/村级12位', `parent_code` VARCHAR(12) NOT NULL DEFAULT '' COMMENT '父级代码,顶级节点为空串', `level` TINYINT UNSIGNED NOT NULL COMMENT '层级:1省 2市 3区县 4乡镇街道 5村社区', `name` VARCHAR(64) NOT NULL COMMENT '节点名称', `short_name` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '简称,下拉框展示用', `pinyin` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '拼音首字母,用于快速检索', `full_path` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '全路径冗余,如 某省/某市/某区/某街道/某社区', `longitude` DECIMAL(10,6) DEFAULT NULL COMMENT '经度,部分数据包自带', `latitude` DECIMAL(10,6) DEFAULT NULL COMMENT '纬度', `version` SMALLINT UNSIGNED NOT NULL DEFAULT 2023 COMMENT '数据版本年份', `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '软删除标记,区划撤销时置1', PRIMARY KEY (`id`), UNIQUE KEY `uk_code` (`code`), KEY `idx_parent` (`parent_code`), KEY `idx_level_name` (`level`, `name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='五级行政区划';code上必须建唯一键,这是整个表的业务主键,比自增id更稳定,做数据同步和外部对接时也只认它。parent_code单独建索引,因为「查下一级」是全表最高频的查询。idx_level_name这个联合索引是给村级模糊搜索准备的,先按level=5过滤再按名称匹配,比单独在name上建索引省很多回表。
full_path是典型的空间换时间。原始数据包通常不带这个字段,导入后跑一条 UPDATE 批量刷出来,之后列表页展示地址就不用递归了。代价是区划调整后必须重刷,这一点后面避坑章节会讲。
2.2 编码规则决定了你能不能偷懒
行政区划代码是分段有含义的:前 2 位是省级,前 4 位是地级,前 6 位是县级,前 9 位是乡级,前 12 位是村级。省级和地级都是 6 位定长,所以省级节点的后 4 位补零,地级节点的后 2 位补零。这个规则带来一个很实用的副作用:查某个省下面所有节点,不用递归,直接code LIKE '10%'前缀匹配就够了,配合code上的索引是走 range 扫描的。
-- 查某个省级节点下的全部地级节点(前缀匹配,走 uk_code 索引) SELECT code, name FROM sys_region WHERE code LIKE '1000%' AND level = 2 AND is_deleted = 0;这里把省级代码写成100000这类占位值演示,实际使用时换成你库里真实的代码前缀即可。要注意前缀匹配的位数:省级取前 2 位、地级取前 4 位,取多了会漏数据,取少了会串到别的省。我在第一次写的时候把省级取成前 3 位,结果全省的地级节点一条都查不出来,排查了半小时才发现是自己写错了位数,这种低级错误在新项目里特别常见。
2.3 数据体量与存储引擎的取舍
这版数据的量级大致是:省级三十多个,地级三百多个,区县两千八百多个,乡镇街道四万个上下,村社区六十万上下,整表七十万行以内。这个体量对 MySQL 来说属于「小表」,单机完全扛得住,不需要上分库分表。
引擎选 InnoDB,不要选 MyISAM。原因有三个:一是要做软删除,需要事务保证批量更新的原子性;二是要频繁按parent_code做点查,InnoDB 的行锁在并发写入时不会锁全表;三是现代 MySQL 版本对 MyISAM 的支持在逐步弱化,新项目没有理由再用。字符集必须 utf8mb4,村社区名称里偶尔会出现生僻字和异体字,utf8 三字节存不下,导入时会直接报错或者截断成问号。
字段长度也留了余量:name给到 64 字符,因为个别乡镇街道的名称加上后缀会超过 30 个字符;full_path给到 255,五级名称拼起来最长的也就一百多字符,留足空间做后续扩展。这些细节原始 SQL 里往往给得比较紧,导入前统一放宽比导入后再 ALTER 省事得多。
3. 导入实战:从 utf8mb4 建库到大文件 source 的完整流程
数据包到手,第一步不是打开 SQL 文件看内容,而是把目标库的字符集和 SQL 模式先定好。这一步做错,后面所有中文都是问号,而且改起来非常麻烦——得清库重导。
3.1 建库与字符集、SQL 模式的准备
# 1. 建库,字符集必须和表保持一致 mysql -uroot -p -e " CREATE DATABASE IF NOT EXISTS region_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci;" # 2. 会话级放宽限制,大文件导入时避免中途报错 mysql -uroot -p region_db -e " SET GLOBAL max_allowed_packet = 256*1024*1024; SET GLOBAL net_read_timeout = 600; SET GLOBAL net_write_timeout = 600; SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION';"max_allowed_packet默认值在不少发行版里只有 4MB 或 16MB,而这类数据包的单个 INSERT 语句经常是几百行批量拼在一起的,很容易超限,表现是导入到某个位置突然断开,日志里提示 packet too large。把它调到 256MB 基本一劳永逸。
sql_mode这里特意只保留严格事务模式,去掉了ONLY_FULL_GROUP_BY之类的限制。原因是原始 SQL 文件里可能有GROUP BY写法不符合新版本规范,导入阶段不需要校验这些,先让数据进去,业务查询再自己规范。
3.2 三种导入方式的对比与选择
| 方式 | 命令形态 | 适用场景 | 注意事项 |
|---|---|---|---|
| 客户端 source | source /path/region.sql | 交互式排查,能看每条语句报错 | 必须在 mysql 客户端内执行,路径不能带引号 |
| 重定向导入 | mysql -uroot -p db < region.sql | 一次性导入,最快 | 出错即中断,不好定位行号 |
| LOAD DATA | LOAD DATA INFILE ... | 数据是 CSV/TSV 而非 SQL | 需要secure_file_priv放行目录 |
日常我优先用重定向导入,命令短、速度快:
# 后台执行并记录日志,避免终端断开导致导入中断 nohup mysql -uroot -p'你的密码' region_db \ < /data/region_2023.sql > /data/import.log 2>&1 & # 实时看进度 tail -f /data/import.log把命令放后台跑是有血泪经验的:七十万行的 INSERT 加上索引维护,跑几分钟很正常,中途 SSH 断线就前功尽弃。用nohup挂住,日志落盘,出问题还能回溯是哪条语句炸的。
如果确定要提速,导入前可以先ALTER TABLE sys_region DISABLE KEYS,导完再ENABLE KEYS,能把索引重建从逐行维护变成一次性批量构建。不过单表七十万行提升有限,我一般懒得折腾这一步,除非是百万级以上的数据。
3.3 导入后的四项校验
导完不等于导对。下面四条校验查询建议每次都跑一遍:
-- 校验 1:各层级数量是否在合理区间 SELECT level, COUNT(*) AS cnt FROM sys_region GROUP BY level ORDER BY level; -- 校验 2:是否存在孤儿节点(父级代码找不到对应记录) SELECT c.code, c.name, c.parent_code FROM sys_region c LEFT JOIN sys_region p ON p.code = c.parent_code WHERE c.level > 1 AND p.id IS NULL LIMIT 20; -- 校验 3:代码是否重复(唯一键理论上拦住,但历史数据可能带重复行) SELECT code, COUNT(*) AS c FROM sys_region GROUP BY code HAVING c > 1; -- 校验 4:抽一个村级节点,看全路径能否拼出来 SELECT code, name, parent_code FROM sys_region WHERE code = '100101001001';校验 1 看的是整体量级,如果 level=5 只有几万条,说明村居数据没导全,或者 SQL 文件被截断了。校验 2 是重点,孤儿节点的成因通常是父级被合并撤销但子级没跟着调整,这种数据在前端联动里会表现为「选了区县之后下一级空白」。校验 3 在加了唯一键之后一般不会命中,但如果原始文件里存在同一代码的更新行和旧行,导入时可能被忽略或覆盖,得心里有数。
校验通过后,把full_path批量刷出来。这一步用一条自连接 UPDATE 就能完成,比写脚本循环快得多:
-- 自下而上刷两级路径(省+市),再补区县,按需扩展到五级 UPDATE sys_region c JOIN sys_region p ON c.parent_code = p.code SET c.full_path = CONCAT(p.full_path, '/', c.name) WHERE c.level = 2; UPDATE sys_region c JOIN sys_region p ON c.parent_code = p.code SET c.full_path = CONCAT(p.full_path, '/', c.name) WHERE c.level = 3;必须严格按 level 从小到大刷,因为每一级依赖上一级的full_path已经算好。反过来从村级往上刷,父级的路径还是空的,拼出来就是/某社区这种残缺值。这个顺序问题我在两个项目里各踩过一次。
4. 查询与接口:省市区三级联动到村级模糊搜索的落地写法
数据进库之后,真正的活是把它变成前端能用的接口。行政区划查询的形态很固定:初始化拉省级列表、选完省拉市、选完市拉区县,再往下两级按需加载。难点不在 SQL,在缓存和搜索。
4.1 查下一级与完整路径的两种写法
按parent_code查下一级是最基础的查询,走idx_parent索引:
-- 查某个节点的直接下级,按代码排序保证展示顺序稳定 SELECT code, name, level FROM sys_region WHERE parent_code = '100100' AND is_deleted = 0 ORDER BY code ASC;反查完整路径有两种做法。一是直接读冗余的full_path字段,一次查询搞定,适合列表展示和导出;二是用 MySQL 8 的递归 CTE 自下而上拼,适合数据还没刷冗余字段的阶段,或者需要做数据校对的时候:
-- MySQL 8.0+:从村级节点向上递归到省级 WITH RECURSIVE path AS ( SELECT code, name, parent_code, level, 1 AS depth FROM sys_region WHERE code = '100101001001' UNION ALL SELECT p.code, p.name, p.parent_code, p.level, c.depth + 1 FROM sys_region p JOIN path c ON c.parent_code = p.code ) SELECT GROUP_CONCAT(name ORDER BY level SEPARATOR '/') AS full_name FROM path;递归 CTE 的写法在 MySQL 5.7 上跑不了,如果你的库还是 5.7,只能靠冗余字段或者应用层循环。depth字段是防御性的,加了它可以在WHERE里限定最多递归五层,避免数据异常时死循环。
4.2 五级联动接口的分层缓存策略
联动接口的特点是「读多写极少」,一份数据可能被调用几百万次。我的做法是三层缓存:进程内本地缓存(Guava Cache 或类似实现)挡掉绝大部分热点请求,本地未命中查 Redis,Redis 也没有才落库。
# 按 parent_code 缓存下级列表,key 带版本号便于整体失效 import json def list_children(parent_code: str) -> list: cache_key = f"region:children:{parent_code}:v2023" cached = redis_client.get(cache_key) if cached: return json.loads(cached) rows = db.query( "SELECT code, name, level FROM sys_region " "WHERE parent_code = %s AND is_deleted = 0 ORDER BY code", (parent_code,) ) result = [dict(r) for r in rows] # 区划数据基本不变,缓存 7 天足够;有更新时按 key 前缀批量删 redis_client.setex(cache_key, 7 * 24 * 3600, json.dumps(result, ensure_ascii=False)) return result这段逻辑的关键在缓存 key 里带了版本号v2023。区划数据一年更新一次,更新后旧 key 自然过期,不需要写复杂的失效逻辑。如果你们的数据是季度更新,把版本号换成v2023q4这种格式即可,改一个常量就能全量切换。
前端那句「选完省拉市」对应的是传省级 code 调这个接口,返回的就是地级列表。到村社区那一级,有些前端会一次性把全省的村都拉下来,那是自找麻烦——六十万条数据压缩后也有十几 MB,移动端直接卡死。
4.3 村级模糊搜索的索引与分页
真正难的是村级搜索。用户输入「幸福」两个字,要在地级市范围内找出所有名字里带「幸福」的村社区。这种查询用name LIKE '%幸福%'是全表扫描,六十万行扫一次几百毫秒,并发一上来直接打满 CPU。
第一步是限定范围。让用户先选到区县,搜索时带上区县前缀,把扫描范围从六十万压到几百:
-- 限定在某区县下做模糊匹配,前缀条件走索引,名称条件做过滤 SELECT code, name, full_path FROM sys_region WHERE level = 5 AND is_deleted = 0 AND code LIKE '100101%' AND name LIKE CONCAT('%', '幸福', '%') LIMIT 20;code LIKE '100101%'是前缀匹配,能走索引把范围收窄;name LIKE虽然还是无法走索引,但作用在几百行的结果集上,代价可以忽略。这个组合是我试过的性价比最高的写法。
如果确实要做跨区县的全局搜索,就得引入拼音首字母字段。数据包如果没带拼音,导入后自己跑一遍转换脚本补上,把村社区名称转成首字母串存进pinyin列,搜索时同时匹配名称和拼音,用户输入xf也能命中「幸福」。这一步做完,用 ES 的必要性就不大了——除非你要做分词、纠错、高亮,否则 MySQL 加一张拼音映射表完全够用。
5. 避坑实录:导入与查询阶段最容易翻车的五个地方
这一节列的是我在不同项目里真实踩过的坑,每条按「现象、原因、解决」三段写。看的时候可以对号入座,别等上线了才发现。
5.1 导入后中文全是问号
现象:导入成功,SELECT出来所有名称都是????,或者只有部分村社区名称乱码。
原因:SQL 文件的编码和数据库连接的编码不一致。数据文件可能是 GBK 导出,而客户端连接用的是 utf8mb4,中间的转换直接丢了信息。另一种情况是表建成了utf8(三字节),遇到生僻字被截断成问号。
解决:先确认文件编码(file -i region.sql),再确认客户端连接编码。导入时显式指定:
mysql --default-character-set=utf8mb4 -uroot -p region_db < region.sql如果文件本身是 GBK,先转码再导,不要在 MySQL 里硬扛:iconv -f GBK -t UTF-8 region.sql -o region_utf8.sql。转完再导,一次干净。
5.2 大文件导入跑到一半断开
现象:执行mysql < region.sql后,跑到几十万行突然ERROR 2006 (HY000): MySQL server has gone away,前面的数据进了一半。
原因:max_allowed_packet太小,单条 INSERT 语句的体积超过上限,服务端主动断开连接。也有一小部分情况是net_write_timeout到了,服务端认为客户端已经卡死。
解决:调大max_allowed_packet到 256MB 或更高,同时把net_read_timeout、net_write_timeout都调到 600 秒以上。导入前先TRUNCATE TABLE清掉半截数据,否则会撞上唯一键冲突报错,看起来像是数据本身有问题,其实是脏数据没清干净。
5.3 唯一键冲突:同一代码出现两行
现象:建了uk_code之后导入失败,提示Duplicate entry。
原因:2023 版数据在整理过程中,某些区划调整(撤县设区、合并乡镇)会留下新旧两条记录,代码相同但名称不同。也可能是上一版数据没清干净,新旧混导。
解决:导入前先DROP TABLE重建,或者TRUNCATE。如果确实需要保留历史,把唯一键改成UNIQUE KEY (code, version),用version区分年份,查询时统一加version = 2023条件。这个改法在需要跨年对比区划变化时特别有用。
5.4 查不到村居:层级值不统一
现象:前端选到区县后下一级空白,后端查level = 4也查不到乡镇街道。
原因:不同来源的数据包对level的编号习惯不一样,有的从 0 开始(0 省、1 市),有的从 1 开始(1 省、2 市)。直接拿别人写的查询语句套自己的数据,层级全错位。
解决:导入后立刻跑一次SELECT level, COUNT(*) FROM sys_region GROUP BY level,对照数量确认每一级的编号。三十多个对应的应该是省级,六十万左右对应村级。数字对上了,编号规则就确认了。别信文档,信数据。
5.5 前端缓存了旧版数据,区划调整后对不上
现象:后台已经更新了数据,用户端选出来的还是旧的区县列表,甚至出现「选了某区,下一级街道是另一个区的」这种串位。
原因:联动数据被缓存在浏览器本地(localStorage 或 IndexedDB),或者 CDN 上挂了旧版本。区划数据一年才变一次,很容易被当成静态资源长期缓存。
解决:接口响应里带上version字段,前端本地缓存的 key 拼上这个版本号,版本变了自动换 key。同时给接口加Cache-Control: max-age=86400而不是一年的长缓存。这类数据更新频率低,但真变了就是错位,缓存策略保守一点没有坏处。
6. 二次开发:把死数据表改造成可增量维护的区划服务
拿到这份 SQL 文件,多数人的用法是导进去、写几个查询就完了。但我更推荐多花半天,把它包成一个小服务,因为行政区划数据的特点就是「平时不动,一动就是全量」——每年更新一版,如果是散落在各个业务代码里的硬编码查询,改起来非常难受。
第一步是加一张版本表,把每次导入的数据变成可追溯的版本:
CREATE TABLE `sys_region_version` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `version` SMALLINT UNSIGNED NOT NULL COMMENT '数据年份,如 2023', `total_rows` INT UNSIGNED NOT NULL COMMENT '本次导入总行数', `source_file` VARCHAR(255) NOT NULL COMMENT '来源文件名,便于回溯', `imported_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_version` (`version`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;有了这张表,sys_region里的version字段才有意义。新数据导入时先写临时表,比对差异后再INSERT ... SELECT增量合并,把消失的节点标is_deleted = 1而不是物理删除,这样历史订单里引用的旧地址还能查到名称。
第二步是把查询封装成服务层,对外只暴露三个方法:children(parent_code)、path(code)、search(keyword, scope)。业务代码不直接写 SQL,改数据结构时只动服务层。我一般还会在服务启动时把全部省级节点和地级节点预热进本地缓存,这两级加起来才三百多条,内存占用可以忽略,但能把首屏联动的响应从几十毫秒压到个位数。
第三步是写一个季度校验脚本,比对线上数据和官方发布的最新版本,输出新增、删除、改名的节点清单。这个脚本不需要自动化执行,人工看一眼清单,确认没问题再走导入流程。区划调整涉及业务口径,全自动反而危险。
最后说个我自己的教训:早些年做类似项目,图省事直接在业务库里建了张区域表,业务表和区划表混在一个库,后来整库迁移时区划数据被一起清掉了,恢复花了大半天。从那以后我每次接这类需求,都强制把区划数据单独建库、单独备份、单独一个导入脚本,哪怕服务器资源紧张也宁可多开一个库。这份数据看着简单,真出问题时影响面比想象中大得多。希望帮到你。
本文还有配套的精品资源,点击获取