四级城市地区表:省市区街道联动设计与SQL导入避坑指南 简介这份资源提供中国省市县街道乡镇四级地址数据包含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物理主键自增100001codevarchar(20)联动 ID业务唯一110101namevarchar(64)行政区划名称某区parent_codevarchar(20)上级联动 ID110100leveltinyint层级 1-43is_leaftinyint是否末级 1/002.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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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_onlyTrue) ws wb.active rows [] cur {province: , city: , county: } for i, row in enumerate(ws.iter_rows(min_row2, values_onlyTrue)): 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第{i2}行字段缺失跳过) continue rows.append((name, str(code), str(parent or ), int(level), int(is_leaf or 0))) print(f共解析 {len(rows)} 行)这段代码的关键在cur游标和空值继承。read_onlyTrue让大文件读取更省内存几万行的 xlsx 用普通模式也能跑但养成习惯没坏处。values_onlyTrue直接拿值不拿单元格对象省去.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(keylambda 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 设计最后一个习惯每次更新地址数据先跑校验脚本再导测试库抽查无误后再上生产。地址数据是很多系统的地基地基歪了上面全歪。我见过太多团队因为地址数据没校验上线后用户填错地址、物流发错区域回头排查花的时间是校验的几十倍。宁可导入前多花半小时也别上线后花三天救火。希望帮到你。本文还有配套的精品资源点击获取