简介:这份面向数据库开发者与后端练习者的行政区划多级联动资料,围绕三级、四级、五级层级数据的建表、外键关联与联动查询展开,可用于省市区县选择、地理信息分级检索等场景,帮助理解层级表设计与关联查询思路。压缩包共25个文件,约22.91MB,以PHP源码、SQL脚本、CSV样例数据、Markdown说明及YAML配置为主:PHP代码给出枚举、模型、命令行与服务注册等实现,SQL与CSV便于建表和导入初始行政区划数据,文档与配置则辅助理解包结构和使用方式。已有26人学习。资源还包含数据库迁移、地区服务封装和示例数据分发目录,适合按模块阅读表结构、外键约束、层级查询及数据初始化流程,也可作为中小型项目中地区联动功能的参考实现。
1. 从三级下拉框到五级地址:一个 SQL 文件能省掉多少事
后台表单里那个省市区三级联动,用久了会形成惯性,直到业务方要求地址填到「街道」和「社区」——社区矫正、卫生服务、网格归属这类字段,三级根本撑不住。这份资源就是一份五级联动的 SQL 文件,把省、市、区县、街道、社区五层行政区划整理成可直接导入的关系表,带层级编码、父级引用、拼音首字母和排序字段。它适合两类开发者:一类是做后台管理系统、CRM、工单、物流地址库,需要在表单里落地五级联动;另一类是想在本地维护一份地址数据,不想每次下拉都去调第三方接口、被限流和计费卡住。导入即用,也能当二次开发的数据底座。
2. 数据模型拆解:五级联动到底几张表、字段怎么定
五级联动在业务里是一棵树,落到关系库里就变成一个绕不开的选择题:树怎么存。见过不少项目在这一步随手拍板,等到要做「按街道反查所属区县」「按前缀搜整个片区」的时候才发现表结构不够用,回头改数据模型比改查询语句贵十倍。这一章把三种主流存法摊开对比,再给出这份 SQL 文件实际采用的字段结构。
先说结论:五级地址数据的特点是层级固定(永远 5 层,不会长成 6 层)、读远多于写(前端每选一次都是查询,数据本身一年才更新一两次),所以设计目标应该是「查单级子节点快」和「查子树方便」两头兼顾,而不是追求理论上的最优范式。
2.1 三种存法对比:邻接表、路径枚举、闭包表
邻接表是最直觉的方案,每行只记自己的父级编码,查某一级的子节点就是WHERE parent_code = ?,一条索引搞定。缺点是往上查祖先、往下查整棵子树都得递归或循环,MySQL 5.7 没有递归 CTE,只能靠应用层一层层查,五级就是五次往返。
路径枚举在邻接表基础上多存一个path字段,把从根到自己的编码用分隔符串起来,比如100000/100100/100101。查子树直接WHERE path LIKE '100000/100100/%',查祖先把 path 拆开就行,不用递归。代价是写入时要维护 path,父级变动需要级联更新——但地址数据几乎不变,这个代价可以忽略。
闭包表额外建一张关系表,把每一对「祖先-后代」都存一行。查询最灵活,任意层级的子孙、祖先都能一条 JOIN 出来,但行数是节点数的平方级增长,五级地址几十万行数据会膨胀成上千万行关系记录,对一个下拉框来说太重了。
| 方案 | 存储成本 | 查单级子节点 | 查整棵子树 | 查完整路径 | 适合场景 |
|---|---|---|---|---|---|
| 邻接表 | 最低 | 一次索引查询 | 需递归 | 需递归 | 层级深、变动频繁 |
| 路径枚举 | 低 | 一次索引查询 | 一次前缀匹配 | 字段直接读 | 层级固定、读多写少 |
| 闭包表 | 高 | 一次 JOIN | 一次 JOIN | 一次 JOIN | 图结构、任意跳转 |
我一般会选邻接表加路径枚举的混合方案:parent_code保证逐级联动够快,path兜住子树查询和完整地址拼接。这份 SQL 文件的结构也是这个路子,两个字段都留着,用不上 path 的场景完全不影响。
2.2 字段设计:code 做主键还是 id 做主键
一个常见的翻车点是拿自增id当联动值传给前端。开发环境跑得好好的,重新导一次数据id全变了,用户浏览器里缓存的旧值直接指到错误的地区,而且这种错很难复现。正确做法是让业务编码code做唯一键、做前端传参,id只做内部主键。
字段清单大致是这样:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT UNSIGNED | 自增主键,内部使用,不暴露给前端 |
| code | VARCHAR(12) | 行政区划编码,业务唯一键,联动取值 |
| parent_code | VARCHAR(12) | 父级编码,顶级固定填 '0' |
| level | TINYINT | 层级,1 省 2 市 3 区县 4 街道 5 社区 |
| name | VARCHAR(64) | 全称 |
| short_name | VARCHAR(32) | 简称,列表窄的时候用 |
| pinyin | VARCHAR(128) | 全拼,小写无分隔,给模糊搜索用 |
| first_letter | CHAR(1) | 首字母大写,做 A-Z 索引条 |
| path | VARCHAR(64) | 根到自身的编码路径,斜杠分隔 |
| sort_order | SMALLINT | 同级别排序权重 |
| status | TINYINT | 1 启用 0 停用,逻辑删除 |
level这个冗余字段值得单独说一句。理论上level可以由path里斜杠的个数算出来,但前端做校验、后端做接口分层、运维做数据体检都要频繁用它,冗余一份比每次字符串计算划算。代价是要保证它和parent_code一致,后面第 5 章会专门讲这个坑。
status不要省。行政区划会撤并,直接物理删除会让历史订单里的地址变成孤儿,正确做法是标记停用,老数据还能查出来,新下拉框里不再出现。
2.3 建表语句与索引怎么落
建表这部分直接给可抄的版本,注意几个索引的用途在后面查询章节都会用到。
CREATE TABLE `sys_region` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键,仅内部使用', `code` VARCHAR(12) NOT NULL COMMENT '行政区划编码,前端联动取值', `parent_code` VARCHAR(12) NOT NULL DEFAULT '0' COMMENT '父级编码,顶级固定为 0', `level` TINYINT UNSIGNED NOT NULL COMMENT '层级 1省 2市 3区县 4街道 5社区', `name` VARCHAR(64) NOT NULL COMMENT '全称', `short_name` VARCHAR(32) DEFAULT NULL COMMENT '简称', `pinyin` VARCHAR(128) DEFAULT NULL COMMENT '全拼,小写无分隔', `first_letter` CHAR(1) DEFAULT NULL COMMENT '首字母大写', `path` VARCHAR(64) NOT NULL COMMENT '根到自身的编码路径,斜杠分隔', `sort_order` SMALLINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '同级别排序权重', `status` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1启用 0停用', PRIMARY KEY (`id`), UNIQUE KEY `uk_code` (`code`), KEY `idx_parent` (`parent_code`, `sort_order`), KEY `idx_level` (`level`), KEY `idx_path` (`path`), KEY `idx_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='五级行政区划';uk_code是唯一约束,导入重复数据会直接报错而不是悄悄插两条,这一点比事后写去重脚本可靠得多。idx_parent带上sort_order是为了让逐级查询的排序也走索引,避免Using filesort——社区级别几十万行数据,多一次排序就是几十毫秒。idx_path是前缀索引,WHERE path LIKE '100000/100100/%'能命中,但反过来LIKE '%100100%'命不中,这个区别在第 5 章有个专门的坑。idx_name在中文名上区分度一般,量大的话改成name(16)前缀索引能省不少空间。
path字段的填充有两种做法:一是在数据生成阶段就算好,导入时直接带上;二是导完数据再刷一遍。如果是自己拼装数据,我倾向于在导入脚本里一次算好,避免多一步容易忘的 UPDATE。用 Python 拼装的逻辑大概是按 level 依次处理,每一级拿父级的 path 加上自己的 code,具体在 3.2 节的导入脚本里能看到。
提示:
code长度按 12 位预留,实际编码短于 12 位时不要用空格补齐,用VARCHAR存原值,前导零在字符串类型里不会丢。
3. 导入与校验:把几十万行地址数据灌进数据库
拿到 SQL 文件之后最忌讳的操作是双击打开、粘进客户端、点执行。几十万行的 INSERT 粘进图形化客户端,轻则卡死界面,重则因为单条语句超长直接报Packet too large,最惨的是执行到一半报错,前面已经提交的部分留在库里,行数对不上还找不到断点在哪。这一章按「确认环境 → 分批导入 → 导入后校验」三步走,每一步都给出可复现的命令。
3.1 导入前的字符集与参数确认
动数据之前先花两分钟确认三件事:文件本身是什么编码、数据库默认字符集是什么、max_allowed_packet够不够大。
# 1. 看文件编码,输出里出现 iso-8859-1 或 unknown 就要警惕 file -i region_5level.sql # 2. 建库时就指定字符集,别依赖服务器默认值 mysql -u root -p -e " CREATE DATABASE IF NOT EXISTS region_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; " # 3. 看单条语句上限,默认 4M 或 16M 在老版本上很常见 mysql -u root -p -e "SHOW VARIABLES LIKE 'max_allowed_packet';"如果文件里是「一条 INSERT 塞几千行」的写法,max_allowed_packet至少要调到 64M 才稳:SET GLOBAL max_allowed_packet = 67108864;。这个参数是全局的,改完新连接生效,老连接要重连。
字符集这里特别提醒一句:库、表、连接三处字符集要一致。见过库是utf8mb4、表也建对了,但导入命令没带--default-character-set,客户端按 latin1 发过去,中文全变成问号,而且不报错,等你发现的时候数据已经写进去了,只能清表重来。
3.2 分批导入脚本与耗时观察
数据量在几万行以内,直接管道导入最省事。关键是把提速参数放在同一个会话里,导入完再恢复。
# pre.sql 里放会话级提速参数,导入结束后再打开约束检查 cat > /tmp/pre.sql <<'EOF' SET autocommit = 0; SET unique_checks = 0; SET foreign_key_checks = 0; EOF cat > /tmp/post.sql <<'EOF' COMMIT; SET unique_checks = 1; SET foreign_key_checks = 1; EOF # 三个文件拼成一条流喂给 mysql,全程一个会话,参数才生效 cat /tmp/pre.sql /data/region_5level.sql /tmp/post.sql \ | mysql --default-character-set=utf8mb4 -u root -p region_db这里autocommit=0很关键,整批数据在一个事务里,中途任何一条语句报错都能整体回滚,不会留下一半数据。unique_checks=0只在确定数据本身没重复时开,能省掉唯一索引的逐行比对,导入速度提升明显,但导完必须改回 1,否则后续业务写入的重复数据不会被拦住。
如果拿到的是按行拆分的版本,或者需要边导边算path,用 Python 脚本控制批次更灵活:
# import_region.py 按语句切块执行,每 50 条提交一次,便于观察进度和中断续跑 import time import pymysql SQL_FILE = "/data/region_5level.sql" COMMIT_EVERY = 50 def iter_statements(path): """按分号切分 SQL 语句,跳过空行和注释行。 前提是地址名称里不含分号——中文行政区划名基本不会出现这个字符。""" buf = [] with open(path, encoding="utf-8") as f: for line in f: line = line.rstrip("\n") if not line or line.startswith("--"): continue buf.append(line) if line.endswith(";"): yield "".join(buf) buf = [] conn = pymysql.connect( host="127.0.0.1", user="root", password="******", database="region_db", charset="utf8mb4", autocommit=False, ) cur = conn.cursor() start = time.time() for idx, stmt in enumerate(iter_statements(SQL_FILE), 1): cur.execute(stmt) if idx % COMMIT_EVERY == 0: conn.commit() print(f"已执行 {idx} 条语句,耗时 {time.time() - start:.1f}s") conn.commit() cur.close() conn.close()参数说明:COMMIT_EVERY控制提交频率,设太大等于一个大事务,回滚段膨胀;设太小提交次数多,整体变慢。50 到 200 之间比较合适。charset="utf8mb4"必须显式传,pymysql 默认是utf8mb4但老版本可能不同,写死最安全。
一条经验:几万行的库在普通开发机上导完通常几秒到几十秒。如果跑了五分钟还没结束,八成是每条 INSERT 单独一个事务在刷盘,先去看文件是不是每条语句后面都跟了COMMIT。
3.3 导入后必跑的四个校验
数据灌进去只是开始,能不能用要看校验。下面四条 SQL 建议存成固定脚本,每次导入新版本都跑一遍。
-- 1. 各级行数分布,某一级为 0 或明显偏少说明数据被截断 SELECT level, COUNT(*) AS cnt FROM sys_region GROUP BY level ORDER BY level; -- 2. 孤儿节点:父级编码在表里找不到,前端点到这一支就断了 SELECT c.code, c.name, c.level, c.parent_code FROM sys_region c LEFT JOIN sys_region p ON p.code = c.parent_code WHERE c.level > 1 AND p.id IS NULL; -- 3. 同一父级下的重名,前端下拉会出现两个一模一样的选项 SELECT parent_code, name, COUNT(*) AS cnt FROM sys_region GROUP BY parent_code, name HAVING cnt > 1; -- 4. 层级跳跃:第 4 级的父级必须是第 3 级,不一致说明数据混版了 SELECT c.code, c.level, p.level AS parent_level FROM sys_region c JOIN sys_region p ON p.code = c.parent_code WHERE c.level <> p.level + 1;第 1 条是体检的第一眼,正常情况下五级的数量应该是金字塔形,越往下越多。如果第 4 级数量比第 3 级还少,基本可以确定数据文件只给了部分街道。第 2 条查孤儿节点,命中后要看是父级被停用了还是编码规则变了——前者补status,后者只能回原始数据核对。第 3 条在真实数据里偶尔会出现,比如同一区县下有两个同名社区,这种是合法的,需要人工确认后给name加后缀区分,不能无脑去重。第 4 条是最容易忽略的,混版数据(一部分来自旧版本、一部分来自新版本)会导致level和parent_code打架,前端按level过滤就查不出子节点。
注意:校验 SQL 一定要在业务接入之前跑,等前端接完再发现孤儿节点,排查成本会翻好几倍。
4. 联动查询实现:从一次上级切换看三种查法
数据进库之后,前端怎么查决定了接口形态和响应速度。五级联动有三种典型实现:逐级懒加载、一次拉全量建树、按编码反查路径。三种不是互斥的,实际项目里经常组合使用——表单用懒加载、地址选择弹窗用全量树、详情页展示用路径反查。这一章把三种都拆开讲。
4.1 逐级懒加载:每级一次请求
最简单也最常用的方式,用户选完省才去查市,选完市才去查区县。接口只需要一个,按parent_code查下一级。
# region_api.py 逐级查询接口,一次只返回一层,避免前端持有全量数据 from flask import Flask, request, jsonify from db import query app = Flask(__name__) @app.get("/api/region/children") def children(): parent = request.args.get("parentCode", "0") level = int(request.args.get("level", 1)) rows = query( "SELECT code, name FROM sys_region " "WHERE parent_code = %s AND level = %s AND status = 1 " "ORDER BY sort_order, code", (parent, level), ) return jsonify([{"code": r[0], "name": r[1]} for r in rows])参数说明:parentCode是上一级的编码,顶级传'0';level是本次要查的目标层级,由前端根据当前是第几个下拉框算出来。带上level条件不是为了性能,而是防御脏数据——万一某个节点的parent_code写错了指到跨级,没有level限制就会把第 5 级的数据塞进第 3 级的下拉框。status = 1过滤掉已停用的区划。
前端对应的逻辑要处理一个容易漏的点:切换上级时必须清空所有下游。
// cascade.js 逐级懒加载,切换任意一级都要把下游 select 重置 const LEVELS = 5; const selects = Array.from({ length: LEVELS }, (_, i) => document.getElementById(`region-${i + 1}`) ); function resetFrom(level) { // 把 level 及其之后的下拉框清空并禁用,防止用户提交「省变了市没变」的脏组合 for (let i = level; i < LEVELS; i++) { selects[i].innerHTML = '<option value="">请选择</option>'; selects[i].disabled = i > level; } } selects.forEach((el, idx) => { el.addEventListener('change', async () => { resetFrom(idx + 1); if (!el.value || idx + 1 >= LEVELS) return; const res = await fetch( `/api/region/children?parentCode=${encodeURIComponent(el.value)}&level=${idx + 2}` ); const children = await res.json(); selects[idx + 1].innerHTML = '<option value="">请选择</option>' + children.map(c => `<option value="${c.code}">${c.name}</option>`).join(''); selects[idx + 1].disabled = false; }); });resetFrom(idx + 1)这一句是整个联动逻辑的核心。漏了它的表现是:用户先选了甲省、乙市,回头改成丙省,市下拉框还显示乙市,提交上来的地址就是「丙省 + 乙市」这种不存在的组合。这种 bug 在测试环境很难撞上,因为测试人员通常按顺序选。
4.2 一次拉全量加内存建树
表单逐级查的前提是用户会按顺序选。如果是地址筛选面板、地图区域选择这种场景,用户可能直接跳到第 4 级,逐级请求就不合适了,更适合一次性把树拉下来。
数据量要心里有数:省级几十条、市级几百条、区县几千条、街道几万条、社区几十万条。全量拉到社区级别,JSON 序列化后几 MB 到十几 MB,gzip 之后也不小,移动端不划算。通常拉到处级(maxLevel=4)就够了,社区级再用懒加载补。
建树的关键是把逐个查找父节点的 O(n²) 改成哈希索引的 O(n):
# build_tree.py 一次查询全部节点,用字典索引父节点,单次遍历建树 def build_tree(rows): """rows 是按 level 升序查出来的列表,每项含 code / parent_code / level / name。""" nodes = {r["code"]: {**r, "children": []} for r in rows} roots = [] for node in nodes.values(): parent = nodes.get(node["parent_code"]) if parent is not None: parent["children"].append(node) else: roots.append(node) return roots这段代码有两个前提:rows里每个code唯一(靠uk_code保证),且父节点一定在同一批数据里(靠父级level更小保证)。如果数据有孤儿节点,它们会被当成根节点混进顶层,前端就会看到两个「省级」选项,所以 3.3 的孤儿校验必须过。
前端拿到树之后缓存起来,避免每次打开页面都请求一次:
// cache.js 带版本号的本地缓存,数据换版时改 KEY 里的版本号即可失效 const CACHE_KEY = 'region_tree_v1'; async function getRegionTree() { const cached = localStorage.getItem(CACHE_KEY); if (cached) return JSON.parse(cached); const res = await fetch('/api/region/tree?maxLevel=4'); const tree = await res.json(); localStorage.setItem(CACHE_KEY, JSON.stringify(tree)); return tree; }版本号写在 key 里,换数据的时候手动改成v2,老的自动被忽略。不建议用时间戳做 key,localStorage只有 5M 左右,几次就满了。也不建议把这棵树塞进 Pinia 或 Vuex 的持久化插件里,树太大,序列化反序列化的开销比重新请求还高。
4.3 递归 CTE 反查完整路径
详情页、订单导出这些场景拿到的是一个社区编码,要展示「省 / 市 / 区 / 街道 / 社区」的完整文字。MySQL 8 和 PostgreSQL 用递归 CTE 一条语句搞定:
-- 根据最末级编码反查五级完整路径,depth 用于防环和排序 WITH RECURSIVE chain AS ( SELECT code, parent_code, name, level, 1 AS depth FROM sys_region WHERE code = '100101001001' UNION ALL SELECT p.code, p.parent_code, p.name, p.level, c.depth + 1 FROM sys_region p JOIN chain c ON p.code = c.parent_code ) SELECT GROUP_CONCAT(name ORDER BY depth DESC SEPARATOR ' / ') AS full_path FROM chain;depth有两个作用:一是排序时从省到社区正序拼出来,二是万一数据里有环(某个节点的parent_code绕回自己),depth会一直涨直到触发cte_max_recursion_depth报错,比死循环卡死要好排查。默认上限是 1000,五级数据远远够用。
如果线上还是 MySQL 5.7 没有 CTE,别硬上,直接用第 2 章设计的path字段:
-- 5.7 的替代方案:直接读 path,应用层 split 后批量查名字 SELECT path FROM sys_region WHERE code = '100101001001'; -- 得到 100000/100100/100101/100101001/100101001001拿到 path 之后按斜杠拆成编码数组,再WHERE code IN (...)一次查出所有名字,两次查询搞定,比递归还快。这也是当初多存一个path字段的回报——设计时多一列,查询时少一层。
5. 避坑与排查:五个真实翻车记录
地址数据看着简单,真跑起来坑不少,而且大多不报错、只是结果不对,排查起来很费时间。下面五条是我自己或者身边同行真实撞过的,按「现象 → 原因 → 解决」写清楚,方便对照。
5.1 导入阶段的坑
乱码但不报错。现象:导完之后前端下拉框里全是问号或者「示」这种字符,数据库里查出来也是乱的,但导入命令退出码是 0。原因是文件编码和连接字符集不匹配,最常见的是文件本身是 UTF-8,导入命令没带--default-character-set=utf8mb4,客户端按默认的 latin1 发过去,服务端照单全收。解决:先file -i确认文件编码,导入命令显式指定字符集,建库建表统一用utf8mb4。已经写进去的乱码改不回来,只能TRUNCATE重导——这一点上别抱侥幸心理。
行数对不上,差几百条。现象:SELECT COUNT(*)的结果比文件里的记录数少,但导入过程没报错。原因通常是开了INSERT IGNORE或者ON DUPLICATE KEY UPDATE,主键或唯一键冲突的行被静默跳过;也可能是文件里有多条COMMIT,执行到一半某条语句失败,前面已经提交的部分留在库里。解决:导入前先清表,用autocommit=0让整批数据在一个事务里;不要用IGNORE;出问题后SHOW WARNINGS看具体跳过了什么。我自己现在的习惯是导入脚本执行完立刻比对源文件行数和表内行数,不等业务方发现。
5.2 查询阶段的坑
逐级查询突然变慢到几百毫秒。现象:开发环境一直很快,上线后每次切省都要转圈。原因是parent_code上没索引,或者索引顺序建反了(建成了(sort_order, parent_code)),几十万行的表每次都全表扫。解决:EXPLAIN看执行计划,type是ALL就说明没走索引,补一个(parent_code, sort_order)的联合索引。上线前用生产量级的数据压一遍,几百行的测试库永远测不出这个问题。
下拉框顺序每次刷新都不一样。现象:同一批数据,用户 A 看到的区和用户 B 看到的顺序不同,隔天再看又变了。原因是查询语句没写ORDER BY,InnoDB 返回顺序取决于执行计划和物理存储,不保证稳定。这种「玄学」问题最容易被当成前端 bug 排查半天。解决:所有列表查询强制ORDER BY sort_order, code,code做兜底保证顺序确定。同时把sort_order默认值设成 0,别设 NULL,NULL 在排序里排最前,会让默认值也乱。
5.3 数据一致性上的坑
同一条社区在两个区县下都出现。现象:用户反馈搜索某个社区名,出来两条结果,点进去路径不一样。原因是合并了不同来源的数据文件,两边的编码规则不同,同一个实体被分配了两个code,或者同一个code挂在了两个父级下。解决:靠uk_code唯一键在导入时就拦住,重复编码直接报错;跨来源合并时先写一个比对脚本,按code归一,code冲突但名字不同的挂起人工确认。这个坑的代价很高——已经产生的业务数据里带着错误编码,改起来要写迁移脚本。
层级跳跃导致前端少一级。现象:有的地址只显示到区县,街道下拉是空的。原因是某条街道记录的level字段写成了 3,前端按level = 4去筛就筛不出来。这类问题一律用 3.3 的第 4 条校验 SQL 兜,导入后跑一遍,命中就修数据或者加CHECK约束。层级和父级这两个字段本质上是同一份信息的两种表达,让它俩能打架就是设计缺陷。
提示:把这五条检查写成一个
check_region.sql,每次导入新版本都跑一遍,比出了问题再翻日志快得多。
6. 二次开发:加缓存、加搜索、加版本合并
数据入库、接口打通之后,这个项目基本能用了。真正拉开差距的是后续这三个改造方向,按投入产出比从高到低排。
第一,给逐级查询加缓存。五级地址数据一年更新不了一两次,逐级查询的结果完全可以缓存到 Redis。key 设计成region:children:{parentCode}:{level},TTL 设 7 天,数据更新时按前缀批量删。
# cache.py 读时先查缓存,未命中回源并写回,数据更新时按前缀清理 import json from redis import Redis from db import query r = Redis(host="127.0.0.1", port=6379, decode_responses=True) TTL = 7 * 24 * 3600 def get_children(parent_code, level): key = f"region:children:{parent_code}:{level}" cached = r.get(key) if cached: return json.loads(cached) rows = query( "SELECT code, name FROM sys_region " "WHERE parent_code = %s AND level = %s AND status = 1 " "ORDER BY sort_order, code", (parent_code, level), ) data = [{"code": row[0], "name": row[1]} for row in rows] r.setex(key, TTL, json.dumps(data, ensure_ascii=False)) return data def clear_region_cache(): # 数据更新后调用,scan 避免 keys 阻塞 for key in r.scan_iter(match="region:children:*", count=1000): r.delete(key)scan_iter而不是keys,是因为keys在大 key 空间下会阻塞 Redis 主线程,生产环境不要用。clear_region_cache放在数据导入脚本的末尾调用,保证缓存和数据同步。
第二,加拼音模糊搜索。用户记不住「示例社区」的全称,只记得首字母,这时pinyin和first_letter就派上用场。前缀匹配能走索引,中缀匹配走不了,量级大的时候要提前规划。
-- 前缀匹配走索引,适合输入框第一次输入 SELECT code, name, level FROM sys_region WHERE status = 1 AND (pinyin LIKE 'sfj%' OR first_letter = 'S') ORDER BY level, sort_order LIMIT 20; -- 中缀匹配走不了索引,社区级数据要控制在可接受范围 SELECT code, name FROM sys_region WHERE status = 1 AND name LIKE '%社区%' LIMIT 20;前端记得加防抖,300ms 左右,别每敲一个字发一次请求。数据量真的到了几十万还要求中缀搜索,那就上独立搜索引擎或者给name建 ngram 索引,别在 MySQL 上硬扛。
第三,做数据版本合并。行政区划每年都有调整,新版本来了不能直接TRUNCATE重导——历史订单里引用的旧编码会失效。正确做法是逻辑停用而不是物理删除:
-- 新旧两份数据比对,新数据里不存在的老编码标记停用,保留历史引用 UPDATE sys_region o LEFT JOIN region_new n ON n.code = o.code SET o.status = 0 WHERE n.code IS NULL AND o.status = 1; -- 新增的编码直接插入,变更的只更新名称和排序 INSERT INTO sys_region (code, parent_code, level, name, path, sort_order) SELECT code, parent_code, level, name, path, sort_order FROM region_new WHERE code NOT IN (SELECT code FROM sys_region);执行前把region_new建成临时表,两边的表结构对齐。改完之后一定要验证:随机抽三到五条链路,从省一路点到社区,把页面拼出来的完整地址和path拆出来的名称数组做比对,两者必须完全一致。这一步我吃过亏——停用逻辑写反了,新数据没进去、老数据被标记停用,整个下拉框全空,而UPDATE的返回行数看起来还挺正常。
从那以后我每次导入行政区划数据,都强制先跑一遍孤儿节点和层级跳跃这两条校验 SQL,再让前端接。数据本身不出声,但它错的时候,前端会替它背锅。希望帮到你。
本文还有配套的精品资源,点击获取