ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

全国五级行政区划数据:77万条记录的设计、查询与避坑指南

全国五级行政区划数据:77万条记录的设计、查询与避坑指南

简介:面向地理信息开发、数据分析与后端建站人员的全国五级行政区划数据库资源,覆盖省、市、区县、乡镇及村委会层级,可用于地图定位、区域统计、物流配送与本地化电商等需要精确地域归属的业务场景。压缩包内共1个文件,为sql脚本,整体约7.97MB,可直接在MySQL中执行建表并批量写入近77万条区划记录,字段涵盖各级名称与主键标识,便于与人口、经济等外部数据关联分析。目前已有1074人学习下载,适合需要快速获得完整行政区划底表、免去逐级采集整理成本的开发者与数据从业者参考使用。

1. 全国五级行政区划数据:77 万条记录到底能干什么

做地址相关的系统,绕不开一个基础问题:用户填的“某省某市某区某街道某社区”,怎么在数据库里对上号。很多团队一开始拿一份省市两级表凑合,等到要做配送范围、网格化管理、统计报表下钻到村一级时,才发现数据断档,回头补数据的成本比一开始选对数据源高得多。这份全国五级行政区划数据,覆盖省、市、区县、乡镇街道、村委会社区五个层级,总量接近 77 万条,本质是一张带层级关系的编码表,解决的就是“任意一条地址能不能落到最末级行政单元”的问题。适合做政务信息化、物流地址库、CRM 地区选择器、数据看板下钻的从业者,也适合需要做地址清洗和标准化的数据工程同学。它不是什么高深算法资源,但属于那种“平时不起眼、缺了会翻车”的底座型数据。

2. 五级区划的编码逻辑:为什么不能只存名字

拿到一份 77 万条的数据,第一反应往往是直接建一张表、加个索引就开查。真这么干,后面大概率要返工。行政区划数据的核心不在“有多少条”,而在“层级怎么表达、编码怎么设计、变更怎么追踪”。这一章先把数据结构讲透,再落到建表和导入。

2.1 行政区划代码的分段规则

国内行政区划代码常见是 6 位数字分段编码,前两位省级、中间两位市级、再两位区县级,乡镇和村一级则在 6 位基础上继续扩展(常见是 9 位、12 位)。这套分段不是随便定的,它天然支持“按前缀匹配查下级”的查询方式。比如要查某省下所有区县,用code LIKE '41%'就能筛出来,不需要递归查父子关系。

但要注意一个现实问题:乡镇和村一级的代码在不同数据源里长度不统一,有的用 9 位,有的用 12 位,还有的直接用统计用区划代码加自定义后缀。导入前必须先确认这份数据里各级代码的实际长度分布,否则前缀匹配会误伤。常见做法是先跑一遍长度统计:

-- 统计各级代码长度分布,确认编码是否规整 SELECT LENGTH(code) AS code_len, COUNT(*) AS cnt FROM region_raw GROUP BY LENGTH(code) ORDER BY code_len;

这段查询的作用是摸清数据底细。如果发现同一层级出现多种长度,说明数据里混了不同来源的编码体系,需要先做归一化,不能直接拿长度当层级判断依据。参数上没什么可调的,重点看结果:正常情况应该看到 2、4、6、9、12 这类阶梯分布,如果出现 7 位、8 位这种“夹生”长度,就要单独拎出来看是不是脏数据。

2.2 父子关系用 parent_code 还是前缀

两种建模方式各有适用场景。用parent_code显式存父级编码,查询下级时WHERE parent_code = '4101',语义清晰、不依赖编码规则,缺点是插入时要保证父级先入库,否则外键或逻辑校验会失败。用前缀匹配则不需要维护父子字段,但强依赖编码规整性,一旦某级代码长度不统一就会出错。

我的建议是两者都留:表里同时存code、parent_code、level三个字段,查询走parent_code,批量统计走前缀。建表语句大致如下:

CREATE TABLE region ( code VARCHAR(12) PRIMARY KEY, -- 行政区划代码 name VARCHAR(64) NOT NULL, -- 名称 parent_code VARCHAR(12), -- 父级代码,省级为 NULL level TINYINT NOT NULL, -- 1省 2市 3区县 4乡镇 5村社区 full_name VARCHAR(255), -- 全路径名称,冗余字段 INDEX idx_parent (parent_code), INDEX idx_level (level) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

level字段是后面所有查询的分流依据,务必在导入阶段就算好,不要等到查询时再用长度推断。full_name是冗余字段,存“某省某市某区某街道某社区”的完整路径,做地址模糊匹配和导出报表时非常省事,代价是存储翻倍——77 万条乘以平均 30 字,大概多占几十 MB,完全可接受。

2.3 批量导入的两种姿势

数据量接近 77 万条,用逐条 INSERT 会慢到怀疑人生。常见做法是转成 CSV 后用LOAD DATA导入,或者用脚本分批 executemany。以 MySQL 为例:

# 先把 CSV 清洗成统一列序:code,name,parent_code,level,full_name mysql -u root -p region_db -e " LOAD DATA LOCAL INFILE '/data/region.csv' INTO TABLE region FIELDS TERMINATED BY ',' ENCLOSED BY '\"' LINES TERMINATED BY '\n' IGNORE 1 LINES (code, name, parent_code, level, full_name);"

FIELDS TERMINATED BY和ENCLOSED BY要和实际 CSV 对齐,中文名称里如果带逗号,必须用引号包裹,否则会错列。IGNORE 1 LINES跳过表头。导入前把local_infile打开,导入后跑一次SELECT level, COUNT(*) FROM region GROUP BY level核对各级数量,省级应该是 30 多个,市级 300 多,区县 2800 左右,乡镇 4 万上下,村社区 60 多万——数量级对不上就说明中间某级漏了或重复了。

提示:导入前先备份空表结构,导入失败时直接 truncate 重来,比逐条排查快得多。

3. 地址匹配与级联查询:把 77 万条用起来

数据躺在库里只是第一步,真正体现价值的是查询和匹配。这一章讲三个高频场景:前端级联选择器怎么按需加载、用户自由文本地址怎么落到末级区划、以及统计报表怎么按层级聚合。

3.1 级联选择器的懒加载接口设计

前端地区选择器最忌讳一次性把 77 万条全推给浏览器,光 JSON 就几十 MB,页面直接卡死。正确做法是按父级懒加载,用户选完省再请求市。后端接口设计成GET /region/children?parentCode=410000,SQL 就是一句WHERE parent_code = ?,走idx_parent索引,毫秒级返回。

# Flask 示例:按父级查子级,省级传空 @app.route('/region/children') def children(): parent = request.args.get('parentCode') or None if parent is None: rows = db.query("SELECT code,name,level FROM region WHERE parent_code IS NULL ORDER BY code") else: rows = db.query("SELECT code,name,level FROM region WHERE parent_code=%s ORDER BY code", (parent,)) return jsonify([dict(r) for r in rows])

逻辑很直白:省级的parent_code为 NULL,单独走一个分支。ORDER BY code保证返回顺序稳定,前端下拉不会每次刷新都换位置。参数上唯一要注意的是parentCode的合法性校验,别让用户传个不存在的编码导致空结果被误判成“该省无数据”。熟手可以再加一层缓存,把省级和市级结果放 Redis,因为这两级几乎不变,村一级变动才需要实时查库。

3.2 自由文本地址的末级匹配

用户填的地址往往不规范,“某省某市某区某路某号”里可能缺省、缺市,甚至用简称。要把这种文本落到末级区划,常见做法是“从长到短逐级截断匹配”:先拿全文去full_name里做前缀匹配,匹配不到就砍掉最后一段再试,直到命中某一级为止。

def match_region(text): # 从完整地址开始,逐级去掉末尾片段再匹配 parts = re.split(r'[省市区县镇乡村]', text) for i in range(len(parts), 0, -1): prefix = ''.join(parts[:i]) row = db.query( "SELECT code,level FROM region WHERE full_name LIKE %s ORDER BY level DESC LIMIT 1", (prefix + '%',)) if row: return row return None

这段代码的关键在ORDER BY level DESC LIMIT 1:同一个前缀可能命中多个层级(比如“某市”既是市级全称的前缀,也可能是某个区县名的开头),取层级最深的那个更符合“尽量落到末级”的业务预期。LIKE prefix%能走索引的前提是full_name建了前缀索引,否则 77 万条全表扫会明显变慢。实际项目里我一般会给full_name加一个INDEX idx_full (full_name(20)),前 20 个字符足够区分大部分地址。

注意:这种截断匹配对“某省某市”这种只有两级、但库里村一级名称恰好也以它开头的极端情况会误判,上线前务必用真实地址样本跑一轮准确率抽检。

3.3 按层级聚合的统计写法

做数据看板时经常要“按省汇总、按市下钻”。有了level和parent_code,聚合查询很直接:

-- 统计每个省下辖的区县数量 SELECT p.name AS province, COUNT(c.code) AS county_cnt FROM region p JOIN region c ON c.parent_code = p.code AND c.level = 3 WHERE p.level = 1 GROUP BY p.code, p.name ORDER BY county_cnt DESC;

这里用自连接,p是省、c是区县,条件是c.parent_code = p.code。注意c.level = 3这个过滤不能省,否则会把乡镇也算进“区县数量”。如果要做三级下钻(省→市→区县),把自连接多套一层即可,但连接层数越多越慢,77 万条数据上三层连接大概几百毫秒,做实时接口建议预计算成汇总表,用定时任务每天刷一次。

4. 数据清洗与版本更新:77 万条里的脏数据怎么处理

行政区划数据不是静态的,乡镇撤并、村改社区、代码调整每年都有。这份数据拿到手,第一件事不是急着用,而是做一轮体检。这一章讲清洗规则和更新策略,都是踩过坑之后总结出来的。

4.1 五类高频脏数据的识别

实际拆过的区划数据里,脏数据基本集中在五类:名称带多余空格或全角字符、同一编码出现两条记录、父级编码指向不存在的记录、层级字段与代码长度矛盾、村一级名称重复但编码不同。识别方法用几条 SQL 就能覆盖:

-- 1. 名称含空格或异常字符 SELECT code,name FROM region WHERE name REGEXP '[[:space:]]' OR name LIKE '% %'; -- 2. 编码重复 SELECT code,COUNT(*) c FROM region GROUP BY code HAVING c>1; -- 3. 孤儿记录:父级不存在 SELECT r.code,r.name FROM region r LEFT JOIN region p ON r.parent_code=p.code WHERE r.parent_code IS NOT NULL AND p.code IS NULL; -- 4. 层级与长度矛盾(以 6 位为区县基准) SELECT level,LENGTH(code) len,COUNT(*) FROM region GROUP BY level,LENGTH(code);

第一条查名称里的半角空格和全角空格,中文名称里混空格是最常见的导入事故。第二条查主键重复,重复记录会导致级联查询出现两个同名选项。第三条查孤儿记录,通常是父级被删但子级没跟着清理。第四条是交叉校验,如果level=3却出现 9 位代码,说明层级标注错了。这四条跑完,脏数据基本无所遁形。

4.2 撤并数据的处理策略

区划调整有两种情况:一种是代码不变、名称变了(比如某乡改镇),另一种是代码废弃、整体并入新单元。前者直接 update 名称即可,历史数据不受影响;后者麻烦,因为旧编码下的业务数据(订单地址、用户档案)还挂着旧码,直接删会导致关联断裂。

常见做法是加一个status字段,废弃的区划标记为0而不是物理删除,查询时默认过滤status=1,历史数据仍能通过旧码查到名称。同时维护一张region_mapping表记录“旧码→新码”的映射,做数据迁移时按映射批量更新业务表。这套机制不复杂,但能省掉大量“为什么这个地址查不到区划”的售后问题。

ALTER TABLE region ADD COLUMN status TINYINT DEFAULT 1; -- 1有效 0已撤并 CREATE TABLE region_mapping ( old_code VARCHAR(12) PRIMARY KEY, new_code VARCHAR(12), effective_date DATE );

status默认给 1,导入时全部置为有效,后续按调整公告逐条改。region_mapping的effective_date用于区分“什么时候开始生效”,做跨年数据对比时能避免把新旧口径混在一起算。

4.3 增量更新的落地流程

全量重导 77 万条每次要几分钟,日常更新没必要。增量流程分三步:先拿新数据和老数据做 diff,找出新增、删除、修改三类;新增的直接 insert,删除的改status=0,修改的 update 名称并写映射表。diff 用编码做 key 即可:

old = {r['code']: r for r in db.query("SELECT code,name FROM region")} new = {r['code']: r for r in load_csv('region_new.csv')} added = new.keys() - old.keys() removed = old.keys() - new.keys() changed = [c for c in new.keys() & old.keys() if new[c]['name'] != old[c]['name']]

三个集合运算把变更类型分清楚,后面按类型走不同 SQL。changed里只比名称,因为编码不变的情况下层级和父级一般也不会变,真变了说明是数据源本身有问题,要人工核对。这套流程跑顺之后,每次更新几分钟搞定,不用再全量重来。

5. 避坑清单:五级区划数据落地时的五个翻车点

5.1 村一级重名导致级联选错

现象:用户选完“某市某区”后,乡镇下拉里出现两个同名选项,选哪个都不对。原因:不同乡镇下存在同名村社区,前端如果只用名称做 key 或展示,用户无法区分。解决:级联组件的 value 一律用code,展示时在重名项后面拼上父级名称,比如“某社区(某镇)”,从数据层面杜绝歧义。

5.2 编码长度不统一导致前缀查询漏数据

现象:按code LIKE '41%'查某省下级,结果比预期少。原因:部分乡镇代码不是标准 6 位扩展,前缀规则对不上。解决:前缀查询只用于省市区县三级,乡镇和村一级一律走parent_code精确匹配,别图省事混用。

5.3 全量推送到前端把页面拖死

现象:地区选择器首次加载转圈十几秒,内存飙升。原因:接口一次性返回全部 77 万条。解决:改成按父级懒加载,省级初始只返回 30 多条,用户每选一级请求一次,单次返回量控制在几百条以内。

5.4 物理删除废弃区划导致历史订单地址失效

现象:老订单详情页地址显示空白,因为关联的区划记录被删了。原因:更新数据时直接 delete 了撤并的区划。解决:改用status软删除,查询默认过滤有效记录,历史关联仍能取到名称。

5.5 字符集不统一导致中文名称乱码

现象:导入后部分村名显示问号或方块。原因:CSV 是 GBK 编码,库是 utf8mb4,导入时没指定字符集。解决:导入前统一转 UTF-8,LOAD DATA时加CHARACTER SET utf8mb4,建库建表也统一用 utf8mb4,别在中间环节混用。

6. 把区划数据接进业务系统:一个可复用的校验脚本

数据清洗完、接口跑通之后,真正决定这套数据能不能长期用的是“校验环节”。我一般会写一个独立脚本,在每次数据更新后自动跑一遍,输出一份体检报告,而不是等业务方反馈“地址选不出来”再去查。脚本核心做三件事:层级完整性校验、父子引用校验、抽样地址回填测试。

import csv, collections def audit(path): rows = list(csv.DictReader(open(path, encoding='utf-8'))) by_code = {r['code']: r for r in rows} report = collections.Counter() # 1. 层级分布 for r in rows: report[f"level_{r['level']}"] += 1 # 2. 父子引用完整性 for r in rows: p = r.get('parent_code') if p and p not in by_code: report['orphan'] += 1 # 3. 抽样回填:随机取 100 条村,沿 parent 链上溯到省 import random villages = [r for r in rows if r['level'] == '5'] for v in random.sample(villages, min(100, len(villages))): cur, depth = v, 0 while cur.get('parent_code'): cur = by_code.get(cur['parent_code']) depth += 1 if cur is None: report['broken_chain'] += 1 break if depth != 4: report['depth_anomaly'] += 1 return report

这段脚本没有依赖数据库,纯内存跑,77 万条大概几秒出结果。level_1到level_5的计数用来和预期数量级比对;orphan统计父级缺失;抽样回填是最有价值的一步——随机抽 100 个村,沿parent_code往上爬,正常应该正好爬 4 层到省,depth不等于 4 就说明链路断了或者层级标错了。broken_chain和depth_anomaly只要不为零,就说明这批数据不能直接上线。

参数上唯一要调的是抽样数量,100 条足够发现系统性问题,要更严格可以提到 1000 条,耗时仍在可接受范围。这个脚本我一般挂在数据导入流水线的最后一步,报告不通过就阻断发布。

血泪经验是:区划数据的坑几乎都藏在“看起来没问题”的地方,肉眼翻 CSV 翻不出孤儿记录,也翻不出层级错标,只有脚本跑一遍才踏实。从那以后我每次更新区划数据,都强制先跑这个校验脚本、看报告全绿再往业务库推,宁可多花五分钟,也不想半夜被地址选不出来的告警叫醒。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进