做业务系统的这些年,我有个很深的感触:越是看起来不起眼的基础数据,越容易在关键时刻给你一记闷棍。行政区划数据就是典型例子——电商下单要省市县三级联动,物流分单要按区域做路由,统计报表要按省市区汇总,连填个注册地址都要校验一遍省市区是否匹配得上。这个项目提供的正是这样一份刚需数据:覆盖省级、地级、县级、乡级、村级五个层级,同时给出 SQL 和 CSV 两套文件,无论你是做后端开发、数据分析,还是搭建可视化大屏,都能直接用起来,不用再到处找残缺不全的旧数据。
这篇文章我会从数据结构设计讲起,把 12 位区划代码的规律、表字段怎么定、SQL 和 CSV 各自适合什么场景、级联查询怎么做、行政区划变动时怎么维护,以及我在实际项目中踩过的几个坑,一次性说清楚。适合理想上需要一份离线行政区划数据的开发者、数据产品和运维同学参考。
1. 行政区划数据到底难在哪:先说说这个项目的立足点
1.1 一个再常见不过的业务痛点
我和不少做系统的朋友聊过,大家手里多多少少都有一份“祖传”的行政区划数据:要么是前端项目里写死的一个 JSON 文件,要么是从某个技术博客复制来的 SQL 脚本,注释里写着“更新于某年”,之后就一直没动过。平时开发环境里跑着没毛病,一上线就露馅——用户选的区和街道对不上,物流系统解析地址失败,统计报表里出现“未知区域”。
这种问题本质上不是因为大家不认真,而是行政区划数据确实难维护。它的变化频率远比一般人想象的高:撤县设区、撤地设市、乡镇合并、村居更名,每年都会有一批变动。而且网络上流传的数据来源杂乱,有的只有省市区三级,没有乡镇和村级,有的字段命名五花八门,有的还用拼音缩写,整合起来非常费劲。
所以当我看到这个项目把五级数据整理成规范的两套文件时,第一反应是“终于有人把这事干利索了”。它解决的不是某个具体功能问题,而是一类基础设施问题:让业务系统在起步阶段就有一份可靠的、可追溯的行政区划底表。
1.2 五级覆盖带来的新可能性
市面上常见的行政区划数据集大多是三级,也就是省、市、区县。三级数据应付电商收货地址、注册表单这类场景基本够了,但一旦业务触角往下探,问题就出来了。
- 物流末端配送:快递分拨到区县之后,还得知道街道乡镇的归属,才能安排具体网点。
- 乡村业务:做农村电商、农资配送、冷链调度,必须精确到乡镇甚至行政村。
- 政府服务类系统:社区治理、网格化管理、基层普查,数据粒度天然就是村和社区。
- 数据报表与可视化:大屏想展示某个区下面的乡镇分布,没有乡级、村级数据,图表就只能停在区县这一层。
这个项目补上的正是这一段链条。省级、地级对应宏观管理,县级承接常规业务,乡级和村级支撑末端场景。五级齐了,从省到村我就能用同一套编码体系做完整的下钻和聚合,不需要再为末端数据去单独采购或爬取。
1.3 双格式设计为什么实用
只看数据内容还不够,交付格式往往决定了一个数据集能不能被顺利用起来。这个项目同时提供 SQL 和 CSV 两种文件,这个细节很加分。
SQL 文件是给要落库的人准备的,建表语句、插入语句一气呵成,MySQL 或 PostgreSQL 导进去就能跑;CSV 文件是给数据分析场景准备的,Python 里 pandas 直接读,Excel 里也能打开查看和筛选。两种格式不是重复造轮子,而是对应了两类完全不同的使用者:后端工程师要的是“开箱即用”,数据分析师要的是“灵活可读”。下面我会分别把两套文件的使用方式拆开讲。
2. 五级模型与区划代码:表结构这样设计才不乱
2.1 先搞懂 12 位区划代码的规律
拿到数据之前,我强烈建议先花十分钟理解区划代码本身的编码规则。这是整个数据的骨架,理解了它,后面所有查询、统计、维护都顺理成章。
标准区划代码通常是 12 位数字,可以拆成三段来看:
- 前 6 位是县级及以上区域的编码,本身又可以拆成三段,每段 2 位:前 2 位代表省级,中间 2 位代表地级,后 2 位代表县级。这也正是身份证号码前 6 位的含义。
- 第 7 到第 9 位对应乡级(街道、镇、乡)。
- 第 10 到第 12 位对应村级(村委会、居委会)。
举例来说,假如我用“接着编一个不存在的 A 省”做演示:A 省的省级代码是990000000000,它下面的 B 市(地级)代码是990100000000,B 市下的 C 县(县级)代码是990102000000,C 县下面的示范镇代码是990102001000,示范镇下面的示例村代码是990102001001。
注意一个规律:每一级代码末尾补零,补齐 12 位。所以从代码本身就能直接判断它的层级,也能通过前缀快速找到父子关系。这个特性在实际查询时非常有用。
2.2 单表搞定五级:建表 SQL 长这样
这个项目把五级数据放在同一张表里,用level字段区分层级、用parent_code维护父级关系。个人认为这是很合理的设计,五级数据的量级在几十万条左右,单表完全可以承载,而且比拆成五张表更灵活。
下面是参考建表语句,保留了最核心的字段:
CREATE TABLE `region` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键', `code` CHAR(12) NOT NULL COMMENT '12位行政区划代码', `name` VARCHAR(50) NOT NULL COMMENT '行政区划名称', `level` TINYINT NOT NULL COMMENT '层级:1省级 2地级 3县级 4乡级 5村级', `parent_code` CHAR(12) DEFAULT NULL COMMENT '父级区划代码,省级为NULL', `short_name` VARCHAR(50) DEFAULT NULL COMMENT '简称', `pinyin` VARCHAR(100) DEFAULT NULL COMMENT '拼音,用于搜索联想', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1启用 0停用', `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序权重', PRIMARY KEY (`id`), UNIQUE KEY `uk_code` (`code`), KEY `idx_parent_code` (`parent_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='行政区划五级表';这里有几个设计点值得展开说一下。
- 主键用自增 id,业务查询用 code 唯一键。行政区划代码才是真正的业务主键,但它可能随区划调整而变化,所以保留一个自增 id 做数据库内部标识更稳妥。
- level 字段必须加。虽然按 code 前缀可以判断层级,但直接存一个 level 字段,查询和索引都更直观。
- parent_code 建索引。级联查询是高频操作,父级编码的索引直接影响性能。
- status 字段强烈建议保留。行政区划会有“停用”的情况,用 status 标记比直接删除安全得多,这一点我在第 5 节会详细说。
CSV 文件的结构和这张表是镜像的,每条记录对应一行,表头包括code,name,level,parent_code,pinyin,status。这样你在 Excel 里查一个城市的时间,和直接在数据库里查的时间成本差不多。
2.3 为什么编码比名称更适合做主键
有些人刚接手时会想:直接用名称做关联不就行了,为什么非要搞一整套代码?我拿实际踩过的坑来回答你。
第一,名称不稳定。同一个县级市可能从“县”改成“区”,乡镇合并后名字直接消失;而代码一旦分配,在同一轮区划调整中通常尽量保持不变,名称变了但 code 还能对上,这对历史数据的追溯意义重大。
第二,名称不唯一。全国叫“城关镇”“中心街道”的地方数不胜数,单靠名称做关联,十有八九会串数据。加上“省份+城市+区县”的全路径去匹配,文本匹配的复杂度立刻上去了,而用代码做关联,一次精确匹配就结束。
第三,代码天然带层级。LEFT(code, 2)就是省级,LEFT(code, 4)就是地级,LEFT(code, 6)就是县级。这种结构让跨级统计变得极其简单,名称字段做不到这一点。理解了这三点,你就明白为什么所有规范的行政区划数据集都把 code 作为核心字段了。
3. SQL和CSV两套文件的定位差异与实际导入操作
3.1 SQL 文件:适合快速落库
SQL 文件里面通常包含两部分:建表语句和数据插入语句。拿到之后,你只需要创建一个数据库,然后直接导入即可。
以 MySQL 为例,基本流程是这样:
mysql -uroot -p CREATE DATABASE IF NOT EXISTS region DEFAULT CHARACTER SET utf8mb4; USE region; SOURCE /path/to/region.sql;PostgreSQL 这边我用的是 psql 的\i命令:
psql -U postgres -d region -f /path/to/region.sql导入完成后,先跑两条验证 SQL:
SELECT level, COUNT(*) FROM region GROUP BY level; SELECT * FROM region WHERE parent_code IS NULL;第一条看每个层级的数据量是否符合预期,第二条抽查省级记录是否完整。验证这一步千万别省,早发现问题早处理,等业务跑起来才发现数据少了才叫难受。
导入 SQL 文件时最容易遇到两个问题。一是字符集不对,建库时没有指定utf8mb4,导入后中文变乱码;二是max_allowed_packet设置太小,插入记录多的 SQL 语句可能被 MySQL 拒绝,报Packet for query is too large。解决方法是在 MySQL 配置文件里调大参数,或者把大 SQL 拆开分批执行。
3.2 CSV 文件:适合数据分析与跨语言使用
CSV 的价值在于通用性。你没法保证每个项目都恰好用 MySQL,但 CSV 在任何语言里都能按标准方式读取。
Python 这边最常用的姿势是 pandas:
import pandas as pd df = pd.read_csv('region.csv', dtype={'code': str, 'parent_code': str}) print(df.head()) print(df['level'].value_counts())注意dtype里强制把 code 和 parent_code 转成字符串。如果不加这一步,pandas 会把001这种前导零的代码当成整数,前导零丢失后代码就废了。这个问题我见过不止一次,凡是用非字符串类型读区划代码的,迟早要吃大亏。
如果 CSV 在 Excel 里打开出现乱码,大概率是文件编码与 Excel 默认编码不一致。处理办法有两个:一个是用带 BOM 的 UTF-8 格式,Excel 能自动识别;另一个是用utf-8-sig读取:
df = pd.read_csv('region.csv', dtype={'code': str}, encoding='utf-8-sig')数据量不大时,用 Excel 直接筛选和手工核对也完全可行,CSV 在纯手工场景下比 SQL 友好太多。
3.3 两种格式怎么选,我给一个参考表
很多朋友会纠结到底用哪个文件,我把两者的差异整理成一个对照表:
| 对比维度 | SQL 文件 | CSV 文件 |
|---|---|---|
| 核心用途 | 快速建库、后端落库 | 数据分析、跨语言读取、人工核对 |
| 依赖条件 | 需要 MySQL/PostgreSQL 等数据库 | 任意文本工具即可打开 |
| 导入成本 | 命令行导入,适合批量 | pandas/Excel 读取,灵活轻量 |
| 类型风险 | 建表时需注意字符集 | 读取时需注意编码与前导零 |
| 适用人群 | 后端开发、DBA | 数据分析师、测试、产品 |
我的建议是:正式环境一定以 SQL 落库为准,CSV 作为分发和校验的副产物。两者内容一致,但 SQL 能保证数据类型和约束在数据库层面被强制执行,CSV 更自由,也就更容易被“自由地”读错。
4. 从省到村的下拉联动与关联查询落地实操
4.1 级联菜单接口可以这样设计
拿到了五级数据,最常见的落地场景就是行政区划级联选择。以前的做法是在前端写死一个 JSON,后端只存一个完整编码。这种做法在五级数据面前会非常臃肿,更合理的做法是后端提供下级查询接口:
SELECT code, name, level FROM region WHERE parent_code = :parentCode AND status = 1 ORDER BY sort_order, code;对应的接口语义是GET /regions/children?parent=990000000000,查询“A 省”下的地级单位,返回列表。前端拿到第一级后,每次用户选中一项,就再携带该项的 code 查询下一级,直到第 5 级。
这套方案的关键是给parent_code建索引。五级数据量虽然不算大,但级联接口是高频调用,没有索引很容易在并发稍高时拖垮数据库。上面建表语句里已经包含了idx_parent_code,注意别在建表时顺手删了。
如果某些场景希望一次请求拿到省到村的完整路径,可以用递归 CTE:
WITH RECURSIVE region_path AS ( SELECT code, name, level, parent_code FROM region WHERE code = :targetCode UNION ALL SELECT r.code, r.name, r.level, r.parent_code FROM region r INNER JOIN region_path rp ON r.code = rp.parent_code ) SELECT * FROM region_path ORDER BY level;这个写法在 PostgreSQL 和 MySQL 8.0 里都支持,适合给“地址回显”场景使用——用户保存过的地址只有一个村级编码,打开详情页时需要反推出省、市、区的完整名称。
4.2 用区划代码做跨级聚合统计
展开一个区域维度做统计报表,区划代码的前缀规律能派上大用场。
假设业务表order里存了收货地址的 12 位区划代码region_code,我只想统计各省的订单量:
SELECT LEFT(region_code, 2) AS province_code, COUNT(*) FROM `order` WHERE region_code IS NOT NULL GROUP BY LEFT(region_code, 2);想下钻到地级,改成LEFT(region_code, 4);下钻到县级,改成LEFT(region_code, 6)。不用 JOIN 任何区域表,就能先拿到一个编码维度的聚合结果。要和名称关联时再 JOINregion表:
SELECT r.name, COUNT(*) FROM `order` o INNER JOIN region r ON LEFT(o.region_code, 6) = r.code WHERE r.level = 3 GROUP BY r.code, r.name;这套写法的核心思想是:区划代码本身就是一个携带层级信息的维度字段,不要在业务表里只存名称不存代码,否则后续所有统计分析都会变成一场文本匹配的灾难。
4.3 历史数据没有代码怎么办:兜底匹配的通用思路
现实项目里没有那么理想。存量用户数据很多只存了文本地址,没有区划代码,这时候就需要做一次地址补全,给老数据“回填”代码。
我的做法是优先做三级匹配:
- 把地址文本按“省”“市”“区/县”等关键词做切分,先尝试提取省级和地级。
- 用
region表按名称做匹配,先匹配县级,再根据parent_code反查地级和省级。 - 匹配不上时,进入人工核对队列,宁可标记为“待确认”,也不要随便给一个错误代码。
有一个细节特别值得提醒:名称里带“市”不代表它一定是地级。县级市的名字里也带“市”,直辖市下属的区里也可能出现“市辖区”这类特殊名称。绝对不要按“名称里是否包含某个字”来推断层级,一切以 level 字段和 parent_code 为准。这是新手最容易错的一环。
5. 数据维护与更新:行政区划变更时的处理思路
5.1 行政区划为什么老在变
我最早也天真地以为,行政区划是“一次维护、终身使用”的数据。后来发现完全不是。现实中触发数据变更的情形很多,比如:
- 县改为区、县级市升级为地级市;
- 新设立开发区、新区;
- 乡镇合并、街道拆分;
- 村居合并、更名,城中村改造后居委会撤销。
这些变动对做业务的人来说直接意味着:昨天还在用的区划代码,明天可能就查不到对应的名称了。所以在设计阶段就把“数据会变”作为一个基本假设,而不是等到线上碰壁才补救。
5.2 一套可落地的更新流程
我不建议拿到新数据后直接清空旧表重新导入,那样会把所有历史关联关系一并“清空掉”。更稳妥的更新流程是三步走。
第一步,比对。把新数据与库内现有数据按 code 做对比,找出三类差异:新出现的 code、消失的 code、名称或层级发生变化的 code。
-- 以导入一张新表 region_new 为例,找出新增的 code SELECT n.code, n.name FROM region_new n LEFT JOIN region r ON n.code = r.code WHERE r.code IS NULL;第二步,停用旧数据。对“消失”的旧 code,不要 DELETE,而是更新 status 字段:
UPDATE region r LEFT JOIN region_new n ON r.code = n.code SET r.status = 0, r.remark = '区划调整停用' WHERE n.code IS NULL AND r.status = 1;第三步,写入新增。将新增的 code 一次性插入正式表,并保留parent_code关系。
这套流程的关键价值在于:业务表里的历史数据不会因为区划代码停用而失效,你只是给旧代码打了“失效标记”,查询时默认status = 1,需要追溯历史时可以放开条件看到旧名称。
5.3 让历史数据可追溯的更远一步
如果你的系统对区划有效性有强诉求,比如要做审计、对账、历史快照,我建议在区域表扩展两个字段:valid_from和valid_to,分别标识该记录的有效起始和结束时间。
日常查询统一加上WHERE valid_from <= NOW() AND (valid_to IS NULL OR valid_to > NOW()),就能随时看到“当前应该生效的区划”。要做历史还原时,把时间参数带进去即可。
再进一步,可以建一张region_mapping表,记录“旧 code -> 新 code”的迁移关系:
CREATE TABLE region_mapping ( old_code CHAR(12) NOT NULL, new_code CHAR(12) NOT NULL, change_type VARCHAR(20) COMMENT '变更类型:撤并/升格/更名等', changed_at DATE NULL, PRIMARY KEY (old_code) );这样就算某个乡镇被合并到隔壁镇,原有的村级数据也能通过 mapping 表映射到新区域,不至于在统计时永久“失联”。这套设计在交易系统、账务系统里非常实用。
6. 我在实际使用中踩过的坑和几条避坑建议
6.1 编码问题比想象中频繁
一组数据文件,最容易被忽略的就是编码。我见过同事在 Windows 下用记事本打开 CSV,另存为 ANSI 编码后直接扔给后端读取,结果所有中文全部变“锟斤拷”。解决办法就两条:数据库侧统一utf8mb4,CSV 文件保持 UTF-8,并且读取时优先用带utf-8-sig的处理方式兼容 BOM 头。
另外导入前先看几行原始数据,确认中文显示正常。这个动作十秒钟,能帮你省掉一次“全表乱码重导”的返工。
6.2 “市”这个字最容易坑人
前面提过一次,这里再单独说透。当你从接口或数据中拿到一个叫“某某市”的名称时,先别急着认定它的层级:
| 名称示例 | 实际层级 | 判断依据 |
|---|---|---|
| 某直辖市 | 省级 | level 字段为 1 |
| 某地级市 | 地级 | level 字段为 2 |
| 某县级市(省直辖) | 县级 | level 字段为 3,parent_code 直接挂省级 |
| 某县级市(属地级市代管) | 县级 | level 字段为 3,parent_code 挂地级 |
判断层级唯一可靠的手段是 level 字段和 parent_code 关系,名称只是一个展示维度。我曾经在一个项目里为了省事,用“名称以市结尾”推断层级,结果省直辖县级市的统计全部错挂到地级,排查了两天才找到根因。从那以后,凡是涉及层级判断,我规定代码里只能查字段,不能猜名称。
6.3 村级数据质量要提前打预防针
五级数据里,村级的数据量最大,变动也最频繁。很多村在乡镇合并后名称随之调整,城中村改造后原来的村委会改成居委会,编码也随之变化。
这意味着两件事。第一,不要假设村级代码永恒不变,做缓存时要有失效策略,定期触发全量刷新;第二,历史订单、历史地址不要在前端展示时硬查村级名称,查不到就降级显示上一级名称,这样用户体验不会断。
如果业务允许,我建议村级数据做成“次日生效”的异步更新任务,而不是在线上交易链路里同步读取并解析。村级数据的实时性要求通常没高到需要在毫秒级链路里处理。
6.4 长期维护的两条建议
第一,确立唯一数据源。数据库里只有一张region表是权威数据,CSV、Excel、JSON 都只是它的导出产物。所有对区划数据的修改必须走这张表,不允许谁手里攥着一份自己的副本各改各的。
第二,每次更新前先做备份。导入新数据或执行批量 UPDATE 前,导出当前表的完整快照。这个习惯救过我很多次,尤其是批量更新语句里漏了 WHERE 条件的时候。更新完成后,再跑一次五级数据量的汇总对比,确认没有异常丢失。
数据维护这种事,多一分谨慎,线上就少一分事故。区划数据看着简单,但它牵连的系统面太广了,前期把结构和流程想清楚,后面会轻松很多。