省市区行政区划数据包解析:表结构、SQL导入与多端复用实践
简介省份城市地区完整数据库是一份面向数据分析、GIS开发、市场研究、物流规划等领域从业者的实用数据资源涵盖全国34个省级行政区、下辖城市与区县的行政代码、经纬度、人口、面积、经济指标、交通及文教等多维信息可有效支撑区域分析、地图应用和配送优化等场景。压缩包共4个文件含3个xml数据文件与1个sql脚本xml用于分层存储省份、城市、地区详情sql便于直接导入数据库进行查询与管理整体仅73KB轻量易用。当前已有3041人学习下载。借助这份数据库读者可快速获得完整的行政区划数据骨架用于搭建基础数据层、制作可视化图表或开展人口与经济的统计分析尤其适合需要标准化省市县数据的中小型项目与教学演示兼顾实用性与合规性。1. 一份省份数据库为什么值得在开工前先拆一遍任何正在做订单、物流、报销或运营系统的开发者某天都会被同一个需求堵住要一份能直接落地的省份数据库。表面上这只是省市区三级联动的一张表真正上手才知道区划数据要经得起历史订单查询、代码变更和多端复用这三重折腾。这套数据包做得比较完整的地方在于把省、市、区三级的官方区划代码整理成统一结构的表同时提供了 SQL、JSON、CSV 三种形态每一条记录都带父级编码和状态标记。适合直接灌进 MySQL 或 PostgreSQL 当基础表也适合前端拿去做级联数据源对刚接触区划编码的人它还是一份结构清晰的编码映射参考。2. 拆开这套省市区数据表结构、字段语义与编码血缘拿到数据的第一时间别急着导入。先花十分钟把表结构、字段语义和数据更新的血缘关系弄清楚后面省下的时间远远不止十分钟。这一章会把包内 SQL 文件里的核心表拆开讲顺便说清楚六位行政区划代码为什么值得当作业务主键来用。2.1 树形结构设计一张表还是三张表区划数据在业务代码里有过多种存放姿势。早期项目喜欢三列平铺把省级、市级、区级放在一行里一省多市靠名称去重查询时一张表全部解决。但这种方式碰到区划调整后的历史数据修订时会非常痛苦所有旧订单里“某区”已经被并入“某新区”必须全量做字符串替换稍不留神就把不相干的地名也一起改了。另一个常见做法是三张独立表省表、市表、区表各存各的层级直观坏处是生成树的时候要在代码里做三层嵌套判断每次查询多一张表多一次 JOINSQL 越来越长。这套数据包用的是单表自引用结构靠parent_code串起层级既保留树形查询能力又让表的数量保持最少。将来如果要下钻到乡镇街道在同一张表里继续加记录就行不用改表结构。每一行就是一条区划记录省级的父级统一填000000把根节点固定住。三级分隔明确、父子关系清晰。业务表想联查区域名一次 JOIN 就够不用写复杂的多层嵌套子查询。这种设计不是我独创而是区划数据在绝大多数生产环境里最成熟的做法。2.2 核心表字段逐个说code、name、level、status 缺一不可仓库里的 SQL 建表文件核心逻辑和下面这段基本一致。字段是多一个嫌多、少一个嫌少。CREATE TABLE region ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, code CHAR(6) NOT NULL COMMENT 六位行政区划代码前两位省前四位市后两位区县, name VARCHAR(64) NOT NULL COMMENT 区划名称, level TINYINT NOT NULL COMMENT 层级1省级2市级3区县, parent_code CHAR(6) NOT NULL DEFAULT 000000 COMMENT 父级编码省级节点的父级固定为000000, sort_order SMALLINT NOT NULL DEFAULT 0 COMMENT 同层级排序按官方公布顺序填充, status TINYINT NOT NULL DEFAULT 1 COMMENT 1在用0已撤销撤销保留不删除, updated_at DATE NOT NULL COMMENT 该街道状态对应的官方公告生效日期, PRIMARY KEY (id), UNIQUE KEY uk_code (code), KEY idx_parent_code (parent_code, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT省市区三级行政区划表;逻辑说明code用CHAR(6)而不是VARCHAR(6)因为定长字符串在 InnoDB 索引里更紧凑等值查询稳定UNIQUE KEY uk_code保证同一个六位编码绝对不重复这是程序里去重的最终依据。idx_parent_code (parent_code, status)是最关键的组合索引因为绝大多数查询都带“某个父级下的在用节点”这个条件走这个索引基本都在毫秒级。name给到 64 位提前给“自治县”“特别区”这类长名称留足空间避免上线第一天就撞上字符串截断。字段参数上看这套表比较克制没有任何业务相关字段。sort_order用来保证级联下拉框的展示顺序和官方公布顺序一致有些数据源按拼音排在省级层容易乱这个字段值得保留。updated_at是给人看也给程序看的每次区划调整都需要它来判断哪一条生效、哪一条作废不能省。补一段虚拟数据帮助理解结构。以下编码是演示用的实际数据包替换为官方完整内容。INSERT INTO region (code, name, level, parent_code, sort_order, status, updated_at) VALUES (990000, 示例省, 1, 000000, 1, 1, 2025-01-01), (990100, 示例市, 2, 990000, 1, 1, 2025-01-01), (990101, 示例区, 3, 990100, 1, 1, 2025-01-01);这段数据点出了编码的核心规律990100前四位是9901正好是市级编码990101前四位同样是9901说明它属于这个市。区划数据很多场景可以不递归直接靠编码前缀判断归属。2.3 数据血缘从官方公告到业务表的四个处理步骤这套数据包里的每一条记录最终来源是官方每年发布的行政区划代码公告。公告样式通常是零散的调整列表而业务系统需要的是完整快照这四个步骤就是数据包的加工逻辑。第一步把官方公告里的变更条目解析成新增、修改、撤销三类操作。第二步把每一条变更映射为标准字段包括code、name、level、parent_code同时查出父级编码填进去。第三步执行状态翻转被撤销的编码不删除而是把status改成0发生改名的保留旧记录并新增一条新记录。第四步打上updated_at时间戳形成一版可直接导入的完整数据。很多人问为什么不能每次更新直接把旧数据删掉重来因为业务系统里的历史订单、历史报表已经引用了旧的code。如果物理删除所有历史数据的外键关系瞬间断裂问题在月底对账时才会集中爆发。status字段的价值就在于此它让同一张表同时承载了“当前版本”和“历史版本”查当前数据时加WHERE status 1查历史数据时按时间过滤。这套表结构经过多次业务验证无论是查询还是排障都足够稳。2.4 SQL、JSON、CSV 三种格式怎么选数据包提供三种格式不是简单堆文件而是对应三个落地场景。我自己在实际项目里是这样分的。格式典型使用场景注意要点SQL后端建库、数据初始化、联表查询先导 schema 再导 data注意字符集JSON前端级联组件、接口透传、离线包构造树后体积约百 KB 级可内置CSV人工核对、Excel 透视、临时统计无类型约束导入前需转码检查SQL 是最稳的交付形态字段类型和约束都已经定义好。JSON 适合前端直接消费省去后端再转一次的环节。CSV 则主要用于非开发人员核对数据内容。实际生产环境里我一般建议后端以 SQL 为基准前端以 JSON 为快照两边由同一份源数据生成防止两套数据长期运行后出现口径漂移。3. 让数据真正可查可用导入、三种查询路径与增量更新拿到压缩包之后最实际的问题是“我怎么把它跑起来”。这一章以 MySQL 为例把导入、查询、更新的完整过程串一遍。用 PostgreSQL 或 SQLite 的思路一致建表语句微调类型即可。3.1 导入三步走建库、按顺序导入、快速验证导入顺序不能乱。先把库建好再导入表结构最后灌数据。中途不要跳步否则会出现找不到表或字符集错乱的问题。# 第一步建立数据库指定 utf8mb4 字符集 mysql -uroot -p -e CREATE DATABASE IF NOT EXISTS demo DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; # 第二步按顺序导入表结构和数据 mysql -uroot -p --default-character-setutf8mb4 demo sql/region_schema.sql mysql -uroot -p --default-character-setutf8mb4 demo sql/region_data.sql # 第三步做一个快速计数和抽样确认数据行数符合预期 mysql -uroot -p demo -e SELECT level, COUNT(*) FROM region GROUP BY level; SELECT * FROM region LIMIT 5;这里的核心参数是连接侧的--default-character-setutf8mb4。如果数据库建的时候用了 utf8mb4连接时不指定客户端会按默认字符集传输中文很容易变成乱码第三步抽样一看就露馅。第一步里的COLLATE utf8mb4_general_ci是通用排序规则对中文名称排序足够不需要为了个别生僻字去换更强的 collation。第三步的验证很关键分组统计level能看到省级、市级、区县级的记录数大致合理再LIMIT 5抽查几条名称确认中文正常显示说明整条链路打通了。这一步多花十秒钟能挡掉一大半后续排查工作。3.2 三种查询路径JOIN、递归 CTE、编码截断数据导入后业务里跑得最多的就是三类查询。第一种查行政区划树第二种查某省下面所有市或某市下面所有区第三种确认一个编码的归属。三种写法各有边界。-- 方式 A自关联 JOIN查某市对应的省名 SELECT c.name AS city_name, p.name AS province_name FROM region c JOIN region p ON c.parent_code p.code WHERE c.level 2 AND c.code LIKE 99% ORDER BY p.sort_order, c.sort_order LIMIT 20;这是最直观的理解方式把区级节点和它的上级做关联一次取两列名称。逻辑说明c是子表p是父表c.parent_code p.code是自关联条件WHERE c.code LIKE 99%则利用编码前缀限定到某个省级范围。参数上注意ORDER BY用了p.sort_order和c.sort_order两级排序保证跨省不会乱。-- 方式 B递归 CTE得到某省下面完整子树 WITH RECURSIVE sub_tree AS ( SELECT code, name, level, parent_code FROM region WHERE code 990000 AND status 1 UNION ALL SELECT r.code, r.name, r.level, r.parent_code FROM region r JOIN sub_tree st ON r.parent_code st.code WHERE r.status 1 ) SELECT * FROM sub_tree ORDER BY level, sort_order;递归 CTE 是 MySQL 8.0 之后才有的能力。逻辑说明前半段查根节点后半段反复把子节点联进来直到没有新的下一级为止。它的价值在于不用知道树有几层拿到一个code就能把下面的所有节点捞全。参数上注意每一层都带status 1否则会把已经撤销的旧节点也带出来级联组件里就容易出现历史残留项。-- 方式 C编码截断直接按前缀查询 SELECT name FROM region WHERE code LIKE CONCAT(LEFT(990100, 4), %) AND level 3;这第三种写法利用六位编码的前缀语义取市级编码前四位匹配所有以这四位开头的六位码就能直接筛出这个市下面所有区县。逻辑说明LEFT(990100, 4)得到9901LIKE 9901%命中所有以9901开头的六位编码。它比递归 CTE 更快但只适用于标准三级城市遇到层级跳变的特殊城市会漏数据所以我把编码截断定位为“辅助筛选”不当作唯一路径。三种方式怎么选业务只展示两级或三级级联用方式 A 的 JOIN 最稳要导出整棵子树用方式 B做快速统计和粗筛用方式 C。实际项目里我通常三种并存接口根据参数决定用哪一种。3.3 增量更新临时表比对别重建生产表一年一次全量替换看着省事对生产系统却是高风险操作。老订单里的code可能已经撤销新数据里根本查不到。我采用的方式是把新版本先导进临时表然后做差异比对生成三类变更。import pymysql conn pymysql.connect( host127.0.0.1, userroot, password***, databasedemo, charsetutf8mb4 ) cur conn.cursor() # 旧表当前生产在用的 region 快照 # 临时表新版本数据导入的 region_2026 CUR_TABLE region NEW_TABLE region_2026 # 第一步找被撤销的编码旧表有、新表没有 cur.execute(f SELECT o.code, o.name FROM {CUR_TABLE} o LEFT JOIN {NEW_TABLE} n ON o.code n.code WHERE n.code IS NULL ) removed cur.fetchall() # 第二步找新增的编码新表有、旧表没有 cur.execute(f SELECT n.code, n.name FROM {NEW_TABLE} n LEFT JOIN {CUR_TABLE} o ON n.code o.code WHERE o.code IS NULL ) added cur.fetchall() # 第三步找名称发生变化的编码 cur.execute(f SELECT o.code, o.name, n.name FROM {CUR_TABLE} o JOIN {NEW_TABLE} n ON o.code n.code WHERE o.name n.name ) renamed cur.fetchall() print(removed:, removed) print(added:, added) print(renamed:, renamed)逻辑说明三个 SQL 分别对应撤销、新增、改名三种变更类型。第一条用LEFT JOIN找出新表里不存在的旧编码第二条反向找出旧表里没有的新编码第三条用等值 JOIN 加名称比较找出改名记录。这里没有用EXCEPT因为 MySQL 8.0 之前的版本兼容性更好LEFT JOIN是最保守的写法。拿到差异后生产库执行的不是全量覆盖而是三条受控操作新增的直接INSERT改名的先UPDATE status0再INSERT新记录撤销的只UPDATE status0不删除。整个流程包在事务里执行先备份当前表再提交。参数上特别注意执行前把数据库会话的autocommit关掉全部操作成功后再COMMIT中途出错直接ROLLBACK生产表不会留下半新半旧的中间状态。3.4 索引与查询性能一个联合索引解决八成问题这张表的数据量只有几千行大部分场景不碰性能瓶颈。但真正上线后联查、级联组件反复请求、报表按区域统计查询频率会迅速拉高这时候索引设计就重要了。前面建表 SQL 里已经埋了KEY idx_parent_code (parent_code, status)。这个联合索引覆盖了最常见的过滤条件“找某个父级下所有在用节点”。执行计划里能明显看到索引命中而不是全表扫描。有几个常见的反例需要避不要对name建唯一索引区划名称本身不唯一不要用LIKE %9901这类后置通配符前缀索引立即失效不要用JSON_EXTRACT去查存储在 JSON 字段里的编码会让索引完全失效。编码截断查询必须保证通配符在右侧例如LIKE 9901%这是走索引的前提。4. 避坑实录区划数据落地最容易翻车的五个现场我从多次把区划数据接入业务系统的经历里挑出五个高频问题按“现象、原因、解决”三段式梳理一遍。前两个问题上手就遇到后三个通常要等到系统跑上一阵才暴露。4.1 现象导入直接报 Data too long表结构没建起来执行region_schema.sql时报Data too long for column name导入立刻中断。原因是部分版本的表结构把name定义为VARCHAR(16)或VARCHAR(32)而官方区划名称里有类似“某某各族自治县”的长名称超出预设长度。解决方式建表前先看一眼 DDL把name字段放宽到VARCHAR(64)如果要稳妥可以直接用VARCHAR(128)。这一步越早做越好等数据导了一半再改表结构还得先清空重来。4.2 现象导入成功但中文全部变成问号表建好、数据也导完了SELECT * FROM region LIMIT 10一看中文全变成???。原因不是数据本身坏了而是连接层的字符集不一致。MySQL 客户端默认连接字符集可能是latin1数据从 utf8mb4 传到客户端时被转码成乱码显示。解决方式导入时固定加--default-character-setutf8mb4代码里用连接池时连接串显式指定characterEncodingutf8建库时顺手写上DEFAULT CHARACTER SET utf8mb4。这三处保持一致乱码问题基本绝迹。4.3 现象市级节点下面直接挂区层级跳变导致树构建失败做级联组件时程序遍历树并判断level状态应该是省级下面市级、市级下面区级。但部分城市采用“直筒子市”模式市级直接管到区中间没有地级市这一跳。于是校验逻辑报错树构建中断。原因是在这些编码里区级节点的parent_code指向了市级level差了两级。解决方式程序里不要硬性要求层级差必须等于一一律以parent_code为准做关联如果要做层级校验只校验父级存在且status1不校验相邻层级差。4.4 现象更新区划数据后历史订单的区域编码全部失效系统上线半年后做了一次数据刷新把所有旧数据删掉导入官方最新编码。结果月底对账时发现一批历史订单里的region_code在新表里查不到对应名称报表里直接显示空。原因就是覆盖式更新把旧编码物理删除了历史数据失去映射。解决方式更新一律改成状态翻转保留旧记录把status置为0业务侧查询当前数据时过滤status1查历史数据时直接按编码关联即使状态是0也能拿到名称。这是数据包设计status字段的核心动机。4.5 现象同名“市”跨层级重复按名称去重误删数据省级里有“某市”它下属的县级市也叫“某市”。如果代码里用name做去重或建唯一索引后者会被误判为重复记录。原因很简单区划名称不唯一跨层级重名是常态。解决方式一切唯一性判断以code为准展示才用name如果历史表里已经用名称存了区域尽早迁移到编码字段否则后面每次区划调整都会踩这个坑。5. 多端复用同一份区划数据级联组件、后端校验与离线包区划数据最常被复用的三个位置前端的三级联动组件、后端的地址合法性校验、以及离线环境下的地址选填。这套数据包的多格式文件正好能覆盖这三个端。5.1 前端级联把扁平数组构造成一棵可直接消费的树绝大多数 UI 框架的级联组件接收的是嵌套树结构而接口返回的往往是扁平的区划列表。前端拿到数据后第一件事就是建树。下面这段 JavaScript 是通用方案。// 入参扁平 regionList出参级联组件可用的树 function buildRegionTree(list) { const map {}; list.forEach((item) { map[item.code] { ...item, children: [] }; }); const roots []; list.forEach((item) { if (item.level 1) { roots.push(map[item.code]); } else { const parent map[item.parent_code]; if (parent) { parent.children.push(map[item.code]); } } }); return roots; }逻辑说明第一遍遍历把每个元素按code放进map让引用可被快速查找第二遍遍历把每个非省级节点挂到父级的children数组下面。这样做是 O(n) 复杂度数据量在几千条时几乎无感。参数上建议只保留code、name、children三个字段传给组件层级和父编码在构建之后就不再需要减少组件内部不必要的计算。拿到树之后可以进一步映射成 UI 组件要求的格式value用code、label用name。注意value一定不要用名称否则后端拿到的是中文存储和回显都会很被动。5.2 后端校验编码存在只是及格链路完整才算合格前端级联能挡住大部分误操作但接口层必须再做一次校验。后端不能只查“这个编码是否存在”还要验证整条父级链路是完整的这一步能拦掉不少篡改请求。def validate_region_chain(code: str, region_map: dict) - bool: 校验一个区划编码是否完整存在于合法链路中。 region_map: {code: {level: int, parent_code: str, status: int}} node region_map.get(code) if not node or node[status] ! 1: return False current node while current[level] 1: parent region_map.get(current[parent_code]) if not parent: return False if parent[status] ! 1: return False current parent return True逻辑说明先把目标节点从region_map里取出来校验存在性和状态然后循环向上找父级每层都必须存在且状态为1一直追到省级节点。只要中间任何一环断裂就判定非法。参数上特别注意region_map必须以code作为键取值包含level、parent_code、status三个字段缺一个都无法完成链路校验。这个函数适合放进服务端的基础校验层和参数校验放在一起。它比单纯的存在性校验多了一层防护即使有人绕过前端手动构造一个不存在的父子关系也会在这里被拦下。5.3 静态文件、接口下发、离线包三种落地姿势怎么选同一个数据源到底怎么往各端分发是落地时经常纠结的问题。我根据自己的实践列一张对比表。分发方式优点缺点适用场景静态 JSON 打包进前端零接口依赖、首屏快更新要重新发版管理后台、官网活动页后端接口按需下发每次取最新、可加权限依赖网络、有延迟强一致要求的交易场景离线包附带版本号断网可用、启动快需要额外做版本管理APP、物流手持终端我的默认推荐是前端把 JSON 作为静态资产打包后端把 SQL 作为数据库基表两边的数据从同一份源文件生成发布时固定字符串版本号。区划数据一年变不了几次没必要每次打开页面都请求一次接口。等到确实发生调整再发一版新客户端或发布一个离线包更新通知成本远低于为它单独维护一套动态接口。6. 把数据发布成生产级三条校验和版本约定数据到了能查能用的程度离生产可用还差最后一关质量校验和版本管理。这一章给出我每次上线前都会执行的检查项以及一套稳定的发版流程。6.1 上线前必跑的三条校验孤儿节点、重复编码、层级连续直接把下面这段 Python 跑在导入后的数据上任何一条错误都意味着数据源或导入过程有问题。def quality_check(rows): errors [] # 第一条孤儿节点子节点找不到父级 codes {r[code] for r in rows} for r in rows: if r[level] 1 and r[parent_code] not in codes: errors.append(f孤儿节点: {r[code]} {r[name]}) # 第二条编码重复同一个 code 出现多行 by_code {} for r in rows: by_code.setdefault(r[code], []).append(r[name]) for code, names in by_code.items(): if len(names) 1: errors.append(f重复编码: {code} - {,.join(names)}) # 第三条省级节点必须挂在 000000 根节点下 for r in rows: if r[level] 1 and r[parent_code] ! 000000: errors.append(f省级节点父级错误: {r[code]} {r[name]}) return errors逻辑说明第一条用codes集合做 O(1) 查找逐个检查level 1的记录是否存在父级第二条把相同code的记录归组数量大于一说明数据里有重复导出第三条检查省级节点是否都指向虚拟根节点。参数上注意level的值必须是数字如果数据源导出成字符串需要先转换再跑否则判断会失效。这三条全过数据才算具备发布条件。6.2 发布流程与版本约定让更新可回溯我的发版流程固定为六步简单直接新一批官方公告发布后先解析并导入临时表对上节三个校验脚本错误必须清零把生产表备份为region_backup_日期用事务方式执行差异 UPDATE只翻转status和更新名称在版本表里插入一行记录本次公告生效日期和变更摘要重新导出 JSON 并更新前端离线包版本号。版本号我习惯用“公告年份 公告批次”组合例如region_2025_01。后端region表里不加版本号字段因为同一张表只保留最新状态历史状态靠status区分。前端 JSON 文件名带上版本号避免浏览器缓存造成新旧数据混用。写到这里想讲一个我自己的翻车经历。早几年做某物流项目的模拟项目X时为了赶上线我把新版区划数据直接覆盖进了生产表结果第二个月结算时发现一批历史订单的区域编码全部查不到名称。后来我在系统里强制规定任何区划数据更新都必须先跑完三条校验、保留旧状态、最后才切换版本。从那以后每次动这张表我都老老实实走一遍这套流程。希望帮到你。本文还有配套的精品资源点击获取