简介:面向行政区划数据开发、后台管理系统搭建与地址清洗场景整理的四级地区表,覆盖国内省、市、县、街道乡镇四级地址,适合需要实现省市区街道联动选择、区域数据初始化或地址标准化处理的开发者与数据人员。压缩包共6个文件,以xlsx表格和sql脚本为核心,前者便于直接查看、筛选与比对,后者可导入数据库快速建表;另附png层级示意和txt说明,整体约2.48MB,体量轻便。表结构包含ID、父ID、名称、联动ID、层级及是否末级标识,既能按父子关系还原树形结构,也能借助联动ID完成路径匹配,末级字段可用于判断是否已细化到街道乡镇。已有607人学习下载,可作为级联下拉、区域编码映射和地址库搭建的基础数据参考。
1. 四级城市地区表:xlsx 与 sql 双份的省市县街道数据怎么落地
产品在地址表单上加了第四级“街道/乡镇”下拉框的那一刻,很多后端才意识到,手里那份省市区三级的地区表不够用了。这里要拆的是一份四级城市地区表资源:一份xlsx、一份sql,把国内省市县街道乡镇四级地址按「名称、联动ID、层级、是否末级」四列摊平,导入数据库就能直接驱动联动控件。它解决的是“数据从哪来、父子关系怎么存、前端怎么一级一级往下问”这三件事,不需要自己再爬一遍、再对一遍重名区县。适合做后台管理系统、小程序地址选择、订单收货地址、以及需要做地址数据清洗的从业者;如果你只是画个静态表单,这份东西对你来说偏重。
2. 拆开四列字段:名称、联动ID、层级、是否末级各自管什么
拿到一份地址表,多数人第一反应是SELECT * FROM area WHERE parent_id = ?就完事了。真正上线之后出问题的,几乎都不是 SQL 写错,而是对这四个字段的职责理解偏了:拿名称当关联键、把层级写死在代码里、把“是否末级”当成可有可无的装饰列。这一章先把字段的边界划清楚,再落到建表和父子关系补全,后面几章的导入和查询都建立在这个结构上。
2.1 四个字段的职责边界
先把每个字段谁在用、能不能改、改了会波及什么列出来,后面写代码时就不容易拍脑袋。
| 字段 | 建议类型 | 取值与约束 | 主要使用方 |
|---|---|---|---|
| 名称 | varchar(64) | 文本,同一父级下不应重复 | 前端展示、模糊搜索、导出报表 |
| 联动ID | bigint 或 varchar(12) | 全表唯一,一旦对外发布就不再变更 | 前后端传参、父子关联、缓存 key |
| 层级 | tinyint | 1 省 / 2 市 / 3 县区 / 4 街道乡镇 | 决定渲染第几个下拉、限定查询范围 |
| 是否末级 | tinyint | 1 是 / 0 否 | 前端判断还要不要继续发请求 |
「名称」只负责展示,这一点必须刻在脑子里。国内同名区县、同名街道并不罕见,用名称做关联键,短则联调时串数据,长则线上出现“选了 A 市的某街道,回填成了 B 市的同名街道”这种极难复现的问题。名称可以重复,联动ID 不行,所以任何跨表、跨接口的关联一律走 ID。
「层级」的价值不只是告诉你这是第几级。它更大的作用是把查询范围锁死——同一个parent_id在不同层级下含义不同,如果你的 ID 体系是自增式,省级记录的 ID 和某个街道记录的 ID 完全可能撞车,只按parent_id查会把别的分支捞出来。加上level = ?这个条件,等于给每次查询加了一道护栏,代价是一个联合索引。
「是否末级」是最容易被忽略、也最能省事的一列。前端渲染联动控件时,如果不知道下一级还有没有数据,常规做法是每次选择后都发一次请求,返回空数组再把下拉框置灰。用户体验上是“点完之后卡一下再变灰”,服务端则多了一堆必然返回空结果的查询。有了这一列,前端拿到当前节点就知道要不要让用户继续点,请求数能直接砍掉一截。
还有一个细节:名称里带不带“省/市/区/县/街道”后缀,同一份数据里必须统一。有的数据源省级带“省”、市级不带“市”,前端拼展示文案时就得写一堆判断。导入前统一清洗一遍,比在三个端各写一套兼容逻辑划算得多。
2.2 建表:把约束和索引一次做对
结构定下来之后,建表语句尽量一次到位,别指望后面再补索引——地址表通常是只读为主、查询频繁,索引加晚了就是一次全表扫描的事故。
CREATE TABLE `area` ( `id` BIGINT UNSIGNED NOT NULL COMMENT '联动ID,全表唯一', `parent_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '父级联动ID,省级固定为 0', `name` VARCHAR(64) NOT NULL COMMENT '名称,统一带后缀', `level` TINYINT UNSIGNED NOT NULL COMMENT '层级 1省 2市 3县区 4街道乡镇', `is_leaf` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否末级 1是 0否', `full_name` VARCHAR(255) DEFAULT NULL COMMENT '全路径,冗余字段,可选', PRIMARY KEY (`id`), KEY `idx_parent_level` (`parent_id`, `level`), KEY `idx_level_leaf` (`level`, `is_leaf`), KEY `idx_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;几个参数值得单独说一下。字符集用utf8mb4而不是utf8,是因为地名里存在生僻字和异体字,三字节的utf8存不下四字节字符,导入时会直接报错或者被截断,这类问题在测试库上往往因为数据量小而漏过去。id用BIGINT UNSIGNED,如果联动ID 是分段编码式的长数字,十二位以内完全放得下,同时避免用字符串做主键带来的索引体积膨胀;但如果你的 ID 里带字母或者有前导零语义,就老老实实改成VARCHAR(12),别硬转。
idx_parent_level这个联合索引的字段顺序不能反。查询永远是“给我某个父级下的某一级子节点”,parent_id是等值条件且区分度高,放前面;level放后面做二次过滤。反过来建索引,优化器大概率用不上。idx_level_leaf服务于另一类查询——预热缓存时按层级批量捞数据,或者统计“有多少个末级街道”。
full_name是可选冗余列,存“省/市/区/街道”的完整路径。它不参与联动查询,只在列表展示、导出、日志排查时省掉一次递归。要不要留这一列,取决于你的业务里“把一条街道还原成完整地址”的频率,频率高就留,纯联动场景可以砍掉。
2.3 只有一列联动ID时,怎么补出父子关系
资源里给的是「联动ID」,但联动查询真正需要的是parent_id。如果你的 ID 是分段编码式(前几位代表上级),父子关系可以直接由前缀推出来;如果 ID 是纯自增、彼此之间没有编码规律,那就必须靠数据本身的顺序或者额外的一列父子关系来补——这种情况下不要自己猜,先翻一遍源文件确认。
分段编码式的补全逻辑,用一段 Python 就能跑完,关键在于每一级的编码长度必须和源数据核对过再用:
import pandas as pd # 每一级的编码前缀长度,必须对照源文件实际编码规则核对后再用 PREFIX_LEN = {1: 2, 2: 4, 3: 6, 4: 9} def fill_parent_id(df: pd.DataFrame) -> pd.DataFrame: df = df.copy() df["id"] = df["联动ID"].astype(str).str.strip() # 省级没有父级,统一写 0,避免 NULL 参与后续比较 df["parent_id"] = "0" for lv in (2, 3, 4): child = df["level"] == lv # 按本级前缀长度截断,得到上一级的前缀 parent_prefix = df.loc[child, "id"].str[: PREFIX_LEN[lv - 1]] # 用上一级记录的完整 ID 做映射表,前缀 -> 真实父级 ID upper = df[df["level"] == lv - 1] mapping = dict(zip(upper["id"].str[: PREFIX_LEN[lv - 1]], upper["id"])) df.loc[child, "parent_id"] = parent_prefix.map(mapping).fillna("0") # 匹配不到父级的记录单独捞出来人工看,不要静默丢弃 orphan = df[df["parent_id"] == "0"] if len(orphan): print("未能匹配父级的记录数:", len(orphan)) print(orphan[["id", "name", "level"]].head(20)) return df这段代码的逻辑是逐级向下建映射:先把第 1 级的 ID 按前缀截成 key,再拿第 2 级记录的前缀去查,查到就把父级的完整 ID 填进去,第 3、4 级同理。PREFIX_LEN是这个函数唯一的“外部知识”,它必须来自你对源数据的核对,写错了整棵树都会错位,而且不会报错——只会安静地把街道挂到隔壁市下面。
最后那段孤儿检查不能省。凡是匹配不到父级的记录,要么是源数据本身有缺口,要么是你的前缀规则不对,无论哪种都必须人工确认,而不是fillna("0")之后当作省级记录混进去。血泪经验是:这类静默失败在数据量大的时候极难定位,等到前端反馈“某个市的区县是空的”才回头查,已经绕了很大一圈。
3. 从 xlsx 到库:两条导入链路和它们的参数
数据进了库才算真正可用。这一步看着最无聊,翻车率却最高——编码、类型推断、行尾符、local_infile开关,每一个都能让你对着“导入成功 0 行”发呆半小时。下面按“先体检、再入库”的顺序走,MySQL 和 SQLite 两条链路都给出来,按你的部署形态选一条。
3.1 导入前的三分钟体检
不要拿到 xlsx 就直接to_sql。先用 pandas 读一遍,把空值、重复 ID、层级分布、末级标记一致性这四项过一遍,五分钟能省掉后面两小时。
import pandas as pd # dtype 显式指定为字符串:避免长数字被推断成浮点变成科学计数法,也避免前导零丢失 df = pd.read_excel("四级城市地区表.xlsx", dtype={"联动ID": str}) print("总行数:", len(df)) print("字段:", list(df.columns)) print("各层级条数:\n", df["层级"].value_counts().sort_index()) print("ID 重复条数:", df["联动ID"].duplicated().sum()) print("名称空值条数:", df["名称"].isna().sum()) # 末级标记自检:有子节点的记录不应标为末级,没有子节点的记录应标为末级 has_child = set(df["parent_id"].astype(str)) df["_calc_leaf"] = (~df["联动ID"].astype(str).isin(has_child)).astype(int) mismatch = df[df["_calc_leaf"] != df["是否末级"].astype(int)] print("末级标记与子节点不一致条数:", len(mismatch)) print(mismatch[["联动ID", "名称", "层级", "是否末级"]].head(10))dtype={"联动ID": str}这一句是整段里最重要的。Excel 单元格如果被设成数值格式,十位以上的 ID 会被 pandas 读成float,再转字符串就变成1.10101e+11这种东西,导入之后全表 ID 全错,而且错得很有规律,很容易被误认为是源数据问题。
末级标记自检那段是在做交叉验证:把“出现过在别人 parent_id 里”的 ID 收集起来,凡是出现过的,理论上就不该是末级。不一致的记录不一定是错的(比如某些城市确实没有第四级,第三级本身就是末级),但必须看一眼,心里有数。如果这类记录数量很大,说明源数据的末级标记口径和你理解的不一样,先搞清楚再往下走。
3.2 MySQL:优先走 sql 文件,其次走 LOAD DATA
资源里已经带了 sql 文件,最省事的做法是直接灌进去,注意带上字符集参数:
# 指定 utf8mb4,否则连接层会把四字节字符截断 mysql -h127.0.0.1 -P3306 -uroot -p \ --default-character-set=utf8mb4 \ area_db < 四级城市地区表.sql导入之后立刻验证条数,别只看命令有没有报错:
SELECT COUNT(*) AS total FROM area; SELECT level, COUNT(*) AS cnt FROM area GROUP BY level ORDER BY level; SELECT COUNT(*) FROM area WHERE name IS NULL OR name = '';如果 sql 文件里的表结构和你要的不一样(字段名、类型、是否带full_name),或者你只想导入部分层级,那就走 xlsx 转 CSV 再LOAD DATA的路线,可控性更好:
# 导出 CSV 供 LOAD DATA 使用 df_out = df.rename(columns={ "联动ID": "id", "parent_id": "parent_id", "名称": "name", "层级": "level", "是否末级": "is_leaf", })[["id", "parent_id", "name", "level", "is_leaf"]] # utf-8-sig 带 BOM,Excel 打开不乱码;MySQL 侧用 CHARACTER SET utf8mb4 消掉 BOM 影响 df_out.to_csv("/tmp/area.csv", index=False, encoding="utf-8-sig", lineterminator="\n")LOAD DATA LOCAL INFILE '/tmp/area.csv' INTO TABLE area CHARACTER SET utf8mb4 FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' LINES TERMINATED BY '\n' IGNORE 1 LINES (id, parent_id, name, level, is_leaf);CHARACTER SET utf8mb4写在LOAD DATA里,是告诉 MySQL“这个文件是按 utf8mb4 编的”,它覆盖客户端的默认字符集,中文乱码十有八九是漏了这一行。LINES TERMINATED BY '\n'要和 pandas 侧的lineterminator="\n"对上——Windows 上默认导出的是\r\n,导入后每行最后一个字段会多一个\r,表现出来就是“is_leaf 明明填的 1,查出来是 1 加一个不可见字符”,这种坑排查起来非常费神。
LOCAL关键字要求服务端开了local_infile,同时客户端也要允许。连不上就先查这两个开关,不要怀疑文件:
SHOW VARIABLES LIKE 'local_infile'; -- 需要为 ON SHOW VARIABLES LIKE 'secure_file_priv'; -- 非 LOCAL 导入时,文件必须在这个目录下secure_file_priv是另一个高频卡点:不加LOCAL的LOAD DATA INFILE只能读该参数指定目录下的文件,你放在/tmp里会直接被拒,报错信息还不太点题。
3.3 SQLite:单文件库更适合桌面端和本地检索
如果这个地址表要跟着客户端一起分发,或者你只是想在本地快速跑查询验证,SQLite 比 MySQL 合适得多——一个文件、零依赖、可以直接只读挂载。
import sqlite3 import pandas as pd df_db = df.rename(columns={ "联动ID": "id", "parent_id": "parent_id", "名称": "name", "层级": "level", "是否末级": "is_leaf", })[["id", "parent_id", "name", "level", "is_leaf"]] conn = sqlite3.connect("area.db") # if_exists="replace" 便于反复重建,正式分发时改成一次性构建 df_db.to_sql("area", conn, if_exists="replace", index=False) # 索引在数据写入之后再建,比边写边维护快 conn.execute("CREATE INDEX IF NOT EXISTS idx_parent_level ON area(parent_id, level)") conn.execute("CREATE INDEX IF NOT EXISTS idx_level_leaf ON area(level, is_leaf)") conn.commit() # 顺手验证一下四级是否都在 for row in conn.execute("SELECT level, COUNT(*) FROM area GROUP BY level ORDER BY level"): print(row) conn.close()to_sql默认会把整张表包在一个事务里,几万行以内问题不大;条数再往上就建议分批写,或者干脆先用csv导入再建索引。索引放在写入之后创建,是因为批量插入时维护索引的开销远大于重建一次。
打包分发时把数据库以只读方式打开,能避免客户端误写:sqlite3.connect("file:area.db?mode=ro", uri=True)。地址表本质上是字典数据,只读是最合理的状态。
4. 联动查询:逐级取、全量建树、路径还原三种写法
结构清楚了、数据进库了,接下来是前端真正会打到的那几个接口。四级联动的查询形态其实只有三种:点一级查一级、一次拉全量在内存里建树、以及从最末级反查完整路径。三种写法各有适用场景,选错了不是错,是慢。
4.1 逐级查询:把 level 带上,把结果缓存住
最常见的接口形态是“传父级 ID,返回子级列表”:
SELECT id, name, level, is_leaf FROM area WHERE parent_id = ? AND level = ? ORDER BY id;level = ?这个条件不是可有可无的。如果 ID 体系是自增的,不同分支的 ID 空间是重叠的,只按parent_id查,理论上存在把其他层级记录捞出来的可能;即便当前的 ID 体系不会撞,带上level也能让优化器更稳定地走idx_parent_level。参数用占位符而不是字符串拼接,地址接口的入参来自用户输入,拼接就是给自己挖坑。
ORDER BY id而不是ORDER BY name,是因为按名称排序需要额外的拼音字段,直接按name排出来的是字符编码序,展示效果对中文不友好。要按拼音或者字母序展示,正确做法是加一列sort_key(存拼音首字母或全拼),在导入阶段一次性算好,而不是每次查询现算。
省级查询比较特殊,它的parent_id固定是 0:
SELECT id, name, is_leaf FROM area WHERE level = 1 ORDER BY id;这类查询的结果几乎不变,可以放心缓存。我一般的做法是按(parent_id, level)做 key 缓存子级列表,省级和市级这种小结果集长时间缓存,第三、四级的结果集大一些、但也基本不变,缓存时间可以略短。地址数据不像订单,它一年也未必改一次,缓存的性价比很高。
4.2 一次拉全量,在内存里建树给前端
如果前端是单页应用,用户可能反复切换省市区,逐级请求虽然简单但交互上有等待感。另一种做法是一次性把全量数据下发,前端在内存里建树、本地联动,体验最顺滑。
import json from collections import defaultdict def build_tree(rows): """rows 为 (id, parent_id, name, level, is_leaf) 元组列表,返回嵌套结构""" children = defaultdict(list) for rid, pid, name, level, is_leaf in rows: children[str(pid)].append({ "id": str(rid), "name": name, "level": level, "leaf": bool(is_leaf), "children": [], }) # 第二遍挂子节点:先按 parent_id 分组,避免在递归里反复扫描 for pid, nodes in children.items(): for node in nodes: node["children"] = children.get(node["id"], []) return children.get("0", []) with sqlite3.connect("area.db") as conn: rows = conn.execute( "SELECT id, parent_id, name, level, is_leaf FROM area ORDER BY level, id" ).fetchall() tree = build_tree(rows) print("省级节点数:", len(tree)) # ensure_ascii=False 保证中文不被转义成 \uXXXX,体积能小一大截 payload = json.dumps(tree, ensure_ascii=False, separators=(",", ":")) print("JSON 字节数:", len(payload.encode("utf-8")))这里用两遍扫描而不是递归,原因是数据本身已经是扁平的父子表,按parent_id分组建索引之后再挂引用,复杂度是线性的;递归写法在层级固定为四层时也不会错,但每层都要重新遍历一遍原始列表,白白多做几次全表扫描。
ensure_ascii=False和separators这两个参数对下发体积影响很直接。默认的json.dumps会把所有中文转成\uXXXX,一个汉字变六个字节,体积翻好几倍。压缩传输当然也能解决,但没必要先把自己撑大再压回去。
全量下发适不适合,取决于你的记录条数和首屏预算。估算方法很简单:拿上面打印出来的 JSON 字节数,除以 gzip 后的实际大小(一般能压到三到四分之一),再和你的首屏预算比一比。如果街道一级特别多导致体积超标,退一步的做法是省市区三级全量、第四级按需请求——大部分用户的地址落不到街道,这个折中通常够用。
4.3 从街道反查完整路径
订单详情、日志排查、导出台账,这些场景需要的是“反查”:手上只有一个最末级的联动ID,要还原出“省/市/区/街道”。MySQL 8.0 及以上可以用递归 CTE:
WITH RECURSIVE up AS ( SELECT id, parent_id, name, level FROM area WHERE id = ? -- 起点:末级 ID UNION ALL SELECT a.id, a.parent_id, a.name, a.level FROM area a JOIN up ON a.id = up.parent_id -- 向上找父级 WHERE up.parent_id <> 0 ) SELECT level, name FROM up ORDER BY level;递归的终止条件是parent_id = 0,也就是走到省级为止。这里要特别注意WHERE up.parent_id <> 0的位置——写在递归项里能正常停,漏写就是无限递归,MySQL 会直接报cte_max_recursion_depth超限。
低版本 MySQL 或者 SQLite 老版本不支持递归 CTE,用代码循环更稳妥:
def backfill_path(conn, leaf_id): """从末级 ID 逐级向上,返回 ['某省', '某市', '某区', '某街道']""" path, cur = [], str(leaf_id) while cur and cur != "0": row = conn.execute( "SELECT parent_id, name, level FROM area WHERE id = ?", (cur,) ).fetchone() if not row: break # ID 不存在,直接返回已拼出的部分 path.append(row[1]) cur = str(row[0]) return list(reversed(path)) # 得到的是从下往上,反转成从省到街道如果反查频率很高(比如每次列表查询都要拼地址),更好的办法是在导入阶段就把full_name算好写进表里。代价是存储多一列、导入时多一次遍历,收益是查询时零递归。用一条自连接就能批量刷出来:
-- 前提:表中已有 full_name 列,且已按层级从小到大顺序存在 UPDATE area c JOIN area p ON c.parent_id = p.id SET c.full_name = CONCAT(p.full_name, '/', c.name) WHERE c.level = 2; -- 省级单独初始化,父级为 0 的记录 full_name 就是自身名称 UPDATE area SET full_name = name WHERE level = 1;三级、四级重复上面的语句即可,注意必须按层级从小到大依次执行,否则父级的full_name还没算出来,子级拼出来就是半截路径。这个依赖顺序是这类批量更新的唯一前提,写脚本时要显式保证。
5. 避坑与排查:五条真实翻车记录
地址表这类“看起来很简单”的数据,出问题的地方往往不在业务逻辑,而在导入和字符处理这些边缘环节。下面五条是我在实际项目里踩过或者帮人排查过的,按“现象、原因、解决”记下来,能省掉不少来回。
5.1 五条高频故障的现象、原因与解决
现象一:导入后查询出来中文全是问号,但用客户端工具打开表看着又正常。原因基本锁定在连接层字符集。表是utf8mb4,文件也是 UTF-8,但客户端的character_set_client还是latin1或gbk,数据在传输过程中被转了一次。解决:连接串显式带上character_set_client=utf8mb4,命令行加--default-character-set=utf8mb4,LOAD DATA语句里补CHARACTER SET utf8mb4,三处任缺一处都可能复发。
现象二:导入提示成功,但总行数比源文件少了一截,且少的集中在第四级。原因通常不是导入丢行,而是源数据里本来就没有这部分街道——很多地区的第四级只给到部分城市,或者第三级本身就是末级。先别改代码,拿源 xlsx 的行数和库里COUNT(*)对一遍,一致就说明数据本身如此。解决:接受这个事实,在业务侧用「是否末级」兜住——末级节点前端不再请求下一级,用户看到的就是“选到区县为止”,而不是一个永远空着的街道下拉。
现象三:前端选了市之后,区县下拉一直是空白,接口返回 200 但数据是空的。原因多半是level被写死在代码里,比如区县查询固定传level = 3,而某些直辖或者特殊结构的记录层级并不是 3,或者父级 ID 传的是名称而不是 ID。解决:level不要写死在前端,由接口返回的当前节点level + 1决定;父级参数一律传联动ID,且入参做一次类型转换,防止"123"和123在类型严格的语言里匹配不上。
现象四:两个不同城市的同名区县,选了 A 的结果回填成 B。原因是用了name做关联或者做缓存 key。地址名称不具备唯一性,这一点在数据量大的时候尤其明显。解决:所有关联、缓存、埋点上报统一用联动ID;如果历史代码里已经用了名称,加一个(parent_id, name)的唯一性校验先把脏数据挡住,再逐个改成 ID 关联。
现象五:xlsx 里的联动ID 变成1.10101E+11,导入后全表对不上。原因是 Excel 把这一列存成了数值型,读出来是浮点,转字符串就带上了科学计数法。解决:读取时dtype={"联动ID": str};如果源文件已经损坏,让提供方把该列单元格格式设成文本重新导出;导入后跑一次SELECT COUNT(*) FROM area WHERE id LIKE '%E+%'兜底,能立刻发现有没有漏网的。
5.2 上线前的一张自检清单
代码写完、数据导完,别急着发。下面这张表里的项挨个打勾,能挡掉绝大部分返工。
| 检查项 | 判断标准 | 不过怎么办 |
|---|---|---|
| 总行数 | 与源 xlsx 行数一致 | 查LOAD DATA的IGNORE行数与报错日志 |
| 层级分布 | 1~4 级都有记录,且逐级递减 | 缺第四级多为源数据本身如此,确认后再放行 |
| ID 唯一 | COUNT(*) = COUNT(DISTINCT id) | 有重复先回源查,不要靠导入时去重 |
| 末级一致 | 有子节点的记录is_leaf = 0 | 按 3.1 的自检脚本重新刷一遍 |
| 孤儿记录 | 非省级记录都能找到父级 | 见 2.3 的孤儿检查,人工确认 |
| 反查连通 | 随机取 20 条末级 ID,能还原出四级路径 | 逐条比对源文件,定位断在哪一级 |
| 字符集 | 生僻地名不报错、不乱码 | 表、连接、导入三处统一 utf8mb4 |
清单里最容易跳过的是「反查连通」。正向查询只要父子关系建对了就一定能查出来,反查却依赖每一级的parent_id都正确,是发现孤儿记录最直接的手段。随机抽二十条走一遍,通常十分钟内就能暴露问题。
6. 进阶:把「是否末级」和缓存串成一条完整链路
数据落地之后,还有个容易被当成装饰的字段值得单独拿出来做点事:is_leaf。它真正的价值不在于告诉你“这是最后一级”,而在于让前端在不请求的情况下就知道该不该让用户继续点。把这个判断前置,一次地址选择能少发两到三次请求,交互上也就没有那种“点完卡一下再变灰”的顿感。
我一般会把三级接口直接做成返回结构里带leaf布尔值,前端拿到就渲染,末级节点直接禁用下一级下拉,而不是先发请求、拿到空数组再禁用。配合 4.2 里的全量建树,效果更明显——整棵树一次性下发,前端连第一次点击都不用等接口。
缓存这块,地址数据是典型的“读多写零”,缓存策略可以放得很开。按(parent_id, level)做 key 缓存子级列表,省级和市级长期缓存,三四级给个较短的过期时间兜底即可。要注意的是缓存 key 里必须带level,理由和查询里一样:光靠parent_id不足以唯一定位一次查询。上线时如果做了数据更新,记得按前缀批量失效,别让新旧两版数据在缓存里混着。
验证方法我习惯用两条:一是数量校验,各省记录数之和等于总行数,各级记录数逐级递减且不会出现某一级为 0;二是抽样走链,随机取二十条末级 ID 反查到省,逐条和源 xlsx 比对。前者能发现成片的导入问题,后者能发现零散的父子错位,两条一起跑,基本覆盖了这份数据可能出的岔子。
还有一个提速的小技巧:把full_name在导入阶段算好之后,前端展示和导出都可以直接用它,省掉每次反查。代价是表宽了一点,但地址表的行数摆在那里,多一列的开销远小于每次查询都递归一遍。前提是计算顺序必须按层级从小到大,这个依赖关系写进导入脚本的注释里,免得下次有人重跑的时候顺序搞反。
血泪教训是:第一次做四级联动时,我图省事用名称做关联,测试环境数据量小、同名区县没撞上,上线第二天客服就转来一堆“地址显示错了”的反馈,回头改了两天。从那以后我每次接地址类数据,导入前都强制走一遍 5.2 那张清单,尤其是孤儿记录和反查连通这两条,宁可多花十分钟,也不想再经历一次线上回滚。希望帮到你。
本文还有配套的精品资源,点击获取