☰
四级城市地区表:省市区街道联动设计与SQL导入避坑指南
2026/10/9 21:16:34 网站建设 项目流程

简介:这份资源提供中国省市县街道乡镇四级地址数据,包含xlsx表格与sql数据库文件,面向需要实现城市联动选择、地址级联录入或行政区划数据初始化的开发者与数据管理人员。数据字段涵盖ID、父ID、名称、联动ID、层级及是否末级标识,可清晰还原从省级到街道乡镇的完整层级关系,便于直接导入数据库或在前端联动组件中调用。压缩包共6个文件,以1个xlsx数据表、1个sql脚本和1个txt说明为主,另附3张png示意图,整体约2.48MB,体积轻便易于传输与部署。目前已有604人学习下载,适合用于电商收货地址、政务表单、物流系统等需要四级联动选择的场景,能帮助读者省去逐级整理行政区划的繁琐工作,快速获得结构规范、层级完整的地址基础数据。

1. 四级城市地区表:从省市县到街道乡镇,一张表把地址联动做干净

做后台系统的人迟早会撞上地址选择这件事。用户注册要选地区,订单要填收货地址,门店管理要绑定行政区划,物流要按街道乡镇做分单。一开始大家都觉得简单,不就是个下拉框联动吗?真做起来才发现,省市区三级数据网上一搜一大把,可一旦要精确到街道乡镇,数据要么缺、要么乱、要么层级对不上。更麻烦的是,很多老数据的 ID 是自增数字,换个数据源就全错位,前端缓存的联动关系直接失效。

四级城市地区表要解决的就是这个问题:把国内省、市、县、街道乡镇四级行政区划,整理成一份带稳定联动 ID、带层级字段、带末级标记的结构化数据,同时提供 xlsx 和 sql 两种落地形态。xlsx 给运营和产品看,方便核对和补录;sql 给后端直接导入,建表、建索引、写查询。名称、联动 ID、层级、是否末级这四个字段,基本覆盖了地址联动 90% 的需求。这篇就按我实际落过的方案,把表结构、导入、查询、缓存和踩过的坑讲清楚,新手能照着跑,熟手能直接拿去改。

2. 四级地址表的结构设计与字段取舍

2.1 为什么是这四个字段:名称、联动 ID、层级、是否末级

先想清楚地址联动到底在干什么。前端一个四级联动,用户选省,市列表要变;选市,县列表要变;选县,街道列表要变。这个「变」的本质,是拿着上一级的 ID 去查下一级的所有子节点。所以每个节点必须有一个唯一标识,而且这个标识要能表达父子关系,这就是联动 ID 的价值。

常见做法有两种。一种是纯自增主键加 parent_id,查询时where parent_id = ?。另一种是把层级路径编码进 ID,比如110000、110100、110101,前两位省、中间两位市、后面县,街道再往后接。前者灵活,后者直观。我一般会两者都留:一个自增主键做物理主键,一个业务编码做联动 ID,前端只认业务编码,后端换库换源都不影响。

层级字段用整数存,1 到 4 分别代表省、市、县、街道乡镇。别用字符串存「省」「市」这种中文,排序和比较都麻烦。是否末级用 0/1 存,1 表示这是最末一级、没有下级。这个字段看着多余,其实很关键:前端渲染时,末级节点不该再出现「请选择下一级」的空下拉,后端校验时也要靠它判断地址是否填完整。

字段名类型说明示例
idbigint物理主键,自增100001
codevarchar(20)联动 ID,业务唯一110101
namevarchar(64)行政区划名称某区
parent_codevarchar(20)上级联动 ID110100
leveltinyint层级 1-43
is_leaftinyint是否末级 1/00

2.2 建表 SQL 与索引:让四级查询走索引而不是全表扫

表结构定下来,建表语句要顺手把索引加上。地址表数据量不大,省市区加街道乡镇,全国也就几万行,但查询频率极高,几乎每个页面加载都要查。没有索引,几万行全表扫也能忍,但并发一上来就是灾难。

CREATE TABLE `region_four_level` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '物理主键', `code` varchar(20) NOT NULL COMMENT '联动ID,业务唯一', `name` varchar(64) NOT NULL COMMENT '行政区划名称', `parent_code` varchar(20) NOT NULL DEFAULT '' COMMENT '上级联动ID,省级为空', `level` tinyint NOT NULL COMMENT '层级:1省 2市 3县 4街道乡镇', `is_leaf` tinyint NOT NULL DEFAULT '0' COMMENT '是否末级:1是 0否', PRIMARY KEY (`id`), UNIQUE KEY `uk_code` (`code`), KEY `idx_parent` (`parent_code`), KEY `idx_level` (`level`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='国内四级行政区划表';

uk_code保证联动 ID 不重复,这是数据质量的底线。idx_parent是联动查询的主力索引,前端每次切换上级都靠它。idx_level在按层级统计或批量导出时有用。字符集用 utf8mb4,别用 utf8,后者在某些生僻字和特殊符号上会翻车,地址里的生僻字比你想象的多。

提示:如果数据源里省级的 parent_code 是空字符串而不是 NULL,建表时默认值就写空字符串,别写 NULL,否则查询时where parent_code = ''和is null混用会漏数据。

2.3 从 xlsx 到 sql:导入流程与字段映射

拿到 xlsx 之后,别急着写代码,先打开看三件事:表头顺序、有没有合并单元格、末级标记是不是每行都填了。合并单元格是导入脚本的头号杀手,省名经常只在第一行出现,下面几行是空的。这种数据直接读会得到一堆空省名。

我一般用 Python 的 openpyxl 读 xlsx,逐行处理,遇到空值就向上继承。核心逻辑是维护一个「当前省」「当前市」「当前县」的游标,读到新值就更新游标,读到空值就用游标补。这样即使 xlsx 是合并单元格导出的,也能还原成完整四级。

import openpyxl wb = openpyxl.load_workbook('region.xlsx', read_only=True) ws = wb.active rows = [] cur = {'province': '', 'city': '', 'county': ''} for i, row in enumerate(ws.iter_rows(min_row=2, values_only=True)): name, code, parent, level, is_leaf = row[0], row[1], row[2], row[3], row[4] # 空值向上继承,处理合并单元格 if level == 1: cur['province'] = name elif level == 2: cur['city'] = name elif level == 3: cur['county'] = name # 校验联动ID和层级是否匹配 if not code or not level: print(f'第{i+2}行字段缺失,跳过') continue rows.append((name, str(code), str(parent or ''), int(level), int(is_leaf or 0))) print(f'共解析 {len(rows)} 行')

这段代码的关键在cur游标和空值继承。read_only=True让大文件读取更省内存,几万行的 xlsx 用普通模式也能跑,但养成习惯没坏处。values_only=True直接拿值不拿单元格对象,省去.value的调用。字段缺失的行直接跳过并打印行号,方便回头核对,别默默吞掉,否则数据少了你都不知道。

解析完写回 sql 时,用批量 insert,别一行一条。几万行一条条插,光网络往返就够你等。拼成INSERT INTO ... VALUES (...),(...),(...)每批 500 到 1000 行,速度和稳定性都合适。

3. 联动查询与缓存:把四级下拉做到秒开

3.1 按 parent_code 查子级:最常用的那条 SQL

前端联动的每一次切换,落到后端就是一条按 parent_code 查子级的 SQL。这条语句必须走索引,必须快。

-- 查某个省下的所有市 SELECT code, name, level, is_leaf FROM region_four_level WHERE parent_code = '110000' ORDER BY code;

ORDER BY code是为了让下拉列表顺序稳定。行政区划编码本身有顺序含义,按编码排基本就是官方顺序,比按名称拼音排更符合用户预期。如果数据源编码不规范,那就按 id 排,至少保证每次返回顺序一致,别让用户每次刷新看到的下拉顺序都在变,那是玄学体验。

查末级判断也很简单,前端拿到子级列表后,如果某条记录is_leaf = 1,就禁用它的下一级下拉。后端在保存地址时,也要校验用户选的最后一级is_leaf是否为 1,不是就说明地址没填完。

3.2 一次性加载整表做内存缓存:几万行的正确姿势

如果系统并发高,每次都查库也扛不住。地址数据的特点是读多写极少,几个月才更新一次,天生适合缓存。我一般会在服务启动时把整张表加载进内存,按 parent_code 建一个 map,查询直接走内存。

from collections import defaultdict # 启动时加载一次 region_map = defaultdict(list) all_regions = {} # code -> 记录 def load_regions(db): rows = db.query('SELECT code, name, parent_code, level, is_leaf FROM region_four_level') for r in rows: region_map[r['parent_code']].append(r) all_regions[r['code']] = r # 每个子列表按 code 排序,保证顺序稳定 for k in region_map: region_map[k].sort(key=lambda x: x['code']) def get_children(parent_code): return region_map.get(parent_code, [])

defaultdict(list)省去判断 key 是否存在的样板代码。all_regions留着做单点校验,比如校验用户传上来的 code 是否真实存在。几万条记录,每条几个字段,内存占用也就几 MB,对现代服务来说可以忽略。更新数据时,重新加载一次 map 即可,或者做个管理接口触发 reload。

注意:内存缓存和数据库要保证同源。如果运营在后台改了某条地址,缓存没刷新,用户看到的就是旧数据。常见做法是改完发个消息或调个 reload 接口,别让两份数据各说各话。

3.3 前端联动的数据结构:一次返回还是逐级请求

前端联动有两种流派。一种是一次性把整棵树返回给前端,前端自己维护联动关系,切换时不请求后端。另一种是逐级请求,选一级查一级。前者首屏慢但后续快,后者首屏快但每次切换都有网络延迟。

我的经验是:如果地址数据在几千行以内,直接一次性返回,前端用 map 建索引,体验最顺。如果数据到几万行,一次性返回的 JSON 可能几百 KB,首屏压力大,那就逐级请求,但后端必须走内存缓存,保证单次查询在毫秒级。别做那种既逐级请求又每次查库的方案,用户点一下等半秒,四级点完两秒过去了,体验直接崩。

返回给前端的结构,建议扁平化,别嵌套。嵌套结构前端处理起来要递归,扁平结构直接按 parent_code 过滤就行。

[ {"code": "110000", "name": "某省", "parent_code": "", "level": 1, "is_leaf": 0}, {"code": "110100", "name": "某市", "parent_code": "110000", "level": 2, "is_leaf": 0} ]

4. 数据清洗与校验:四级地址表最容易翻车的地方

4.1 层级错位与父子断裂:三种典型脏数据

从各种渠道拿到的地址数据,几乎没有一份是干净的。最常见的脏数据有三种。第一种是层级错位,某个街道乡镇的 level 标成了 3,实际应该是 4,导致前端渲染时把它当县处理,下一级下拉出不来。第二种是父子断裂,某个市的 parent_code 指向一个不存在的省,查子级时永远查不到。第三种是末级标记错误,明明还有下级的县被标成了 is_leaf = 1,用户选到这里就卡住,填不了街道。

这三种问题的根源都是数据在多次转手、合并、补录过程中丢了约束。解决办法是在导入后跑一遍校验脚本,把异常行全部揪出来。

def validate(rows): codes = {r[1] for r in rows} errors = [] for name, code, parent, level, is_leaf in rows: # 省级不该有父级 if level == 1 and parent: errors.append((code, '省级却有父级')) # 非省级必须有父级且父级存在 if level > 1: if not parent: errors.append((code, '缺少父级')) elif parent not in codes: errors.append((code, f'父级 {parent} 不存在')) # 层级应比父级大 1 if parent in codes: parent_level = next(r[3] for r in rows if r[1] == parent) if level != parent_level + 1: errors.append((code, f'层级跳变 {parent_level}->{level}')) return errors

这段校验覆盖了父子存在性和层级连续性。跑完把 errors 打印出来,逐条核对。别嫌麻烦,地址数据的错误一旦进了生产,用户填错地址、物流发错地方,排查成本比校验高得多。

4.2 名称重复与编码冲突:合并数据源时的坑

多个数据源合并时,名称重复和编码冲突几乎必然出现。比如两个来源里都有「某区」,但编码不同,或者编码相同但名称一个是「某区」一个是「某新区」。这种冲突不能靠程序自动决定,必须人工介入。

我的做法是先把冲突项导出来,按编码分组,同编码不同名称的、同名称不同编码的都列出来,让业务方确认以哪个为准。程序层面只做一件事:导入时如果 code 已存在,就报错并跳过,绝不覆盖。覆盖是最危险的操作,你以为在更新,实际可能把正确的数据冲掉。

-- 找出编码重复的记录 SELECT code, COUNT(*) AS cnt FROM region_four_level GROUP BY code HAVING cnt > 1; -- 找出同省同市同名但编码不同的记录 SELECT name, parent_code, COUNT(DISTINCT code) AS cnt FROM region_four_level GROUP BY name, parent_code HAVING cnt > 1;

这两条查询能揪出大部分冲突。第一条查编码重复,第二条查同名不同码。跑完人工过一遍,该合并的合并,该废弃的废弃。

4.3 末级标记的批量修正:一条 SQL 搞定

末级标记错误,很多时候是因为数据源根本没这个字段,需要我们根据「有没有下级」反推。反推逻辑很简单:如果一个节点没有任何子节点,它就是末级;有子节点,就不是末级。

-- 先把所有节点标记为非末级 UPDATE region_four_level SET is_leaf = 0; -- 再把没有子节点的节点标记为末级 UPDATE region_four_level r SET r.is_leaf = 1 WHERE NOT EXISTS ( SELECT 1 FROM region_four_level c WHERE c.parent_code = r.code );

这两条 SQL 顺序不能反。先全置 0,再按「无子级」置 1,逻辑才自洽。如果反过来先置 1 再改,中间状态会乱。跑完之后抽查几个已知有下级的县,确认它们的 is_leaf 是 0,再抽查几个街道乡镇,确认是 1。

提示:这个反推逻辑假设数据是完整的。如果某个县的下级数据缺失,它会被误标成末级。所以反推之前,先确认四级数据都齐了,别拿一份只有三级的残缺数据来跑。

5. 避坑与排查:四级地址表落地时的血泪经验

5.1 坑一:联动 ID 用了自增主键,换数据源全错位

现象:系统上线半年,运营换了一份更新的地址数据,导入后前端联动全乱,选省出来的市对不上。

原因:联动 ID 用的是数据库自增主键,换数据源后自增顺序变了,前端缓存的旧 ID 和新数据对不上。

解决:联动 ID 必须用业务编码,不能用自增主键。自增主键只做物理主键,对外一律暴露业务编码。如果历史数据已经用了自增 ID,做一次映射迁移,把前端和接口里的 ID 全换成业务编码。

5.2 坑二:xlsx 合并单元格导致省名丢失

现象:导入后发现有几百条记录的省级字段是空的,查子级时这些市永远出不来。

原因:xlsx 里省名是合并单元格,只有第一行有值,下面几行是空的,读取时直接拿到空字符串。

解决:读取时维护游标,空值向上继承。或者导入前先在 Excel 里取消合并并填充,但手工操作容易漏,还是脚本处理靠谱。

5.3 坑三:末级标记没更新,用户选到县就卡住

现象:用户反馈地址选到县之后,街道下拉是空的,但明明这个县有街道。

原因:该县的 is_leaf 被错误标成了 1,前端看到末级标记就禁用了下一级下拉。

解决:跑一遍末级反推 SQL,把所有无子级的节点标为末级,有子级的标为非末级。同时在前端加个兜底:如果 is_leaf = 1 但实际有子级,仍然允许请求下一级。

5.4 坑四:字符集用了 utf8,生僻字变问号

现象:某些地址名称里的生僻字在数据库里显示成问号,前端展示乱码。

原因:建表时字符集用了 utf8,它最多存三字节,部分生僻字需要四字节。

解决:建表时用 utf8mb4,连接字符集也设成 utf8mb4。已经建好的表用ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4转换,但转换前备份,转换后逐条核对生僻字。

5.5 坑五:缓存没刷新,运营改了地址用户看不到

现象:运营在后台把某个街道改名,用户端刷新后还是旧名字。

原因:服务启动时加载了内存缓存,运营改的是数据库,缓存没同步。

解决:改地址的接口里加一步缓存刷新,或者发个内部消息触发 reload。别指望定时刷新,地址改动频率低,定时刷新要么太频繁浪费,要么太慢用户投诉。

6. 进阶:把四级地址表用出花来的几个技巧

地址表落地之后,能做的事比想象的多。第一个技巧是做地址解析。用户粘贴一段完整地址,比如「某省某市某区某街道某号」,你可以用四级表做前缀匹配,逐级切分出省市区街道,剩下的当详细地址。这个在导入历史订单、批量录入门店时特别有用。实现思路是把所有省名、市名、县名、街道名建一个前缀树,从长到短匹配,匹配到就切掉继续匹配下一级。

第二个技巧是做地址校验。用户填的地址,逐级校验 code 是否存在、父子关系是否成立、末级是否真的是末级。校验通过再入库,能挡掉大部分脏地址。校验逻辑就是前面那套 validate,搬到接口层。

第三个技巧是做区域聚合统计。订单表里存了街道 code,你想按市统计,不需要 join 四级表,直接用 code 的前缀匹配就行。前提是你的联动 ID 是层级编码,比如110101前四位1101就是市。这个技巧在报表场景里能省掉大量 join。

技巧适用场景关键点
地址解析历史数据导入、批量录入前缀树从长到短匹配
地址校验用户填写、接口入库逐级校验父子与末级
区域聚合报表统计、按级汇总依赖层级编码的 ID 设计

最后一个习惯:每次更新地址数据,先跑校验脚本,再导测试库,抽查无误后再上生产。地址数据是很多系统的地基,地基歪了,上面全歪。我见过太多团队因为地址数据没校验,上线后用户填错地址、物流发错区域,回头排查花的时间是校验的几十倍。宁可导入前多花半小时,也别上线后花三天救火。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询