简介:面向需要搭建国内省、市、县、街道乡镇四级联动地址数据的开发者与数据库使用人员,这份地区表着重解决行政区划层级、父子关系与联动标识难以一次性整理的问题。压缩包共6个文件,以xlsx表格和sql数据库文件为主,并配有png示意图与txt说明,整体约2.48MB,既便于查看字段结构与记录样式,也方便按需导入数据库或做二次加工。表中提供ID、父ID、名称、联动ID、层级、是否末级等字段,从北京市、石景山区到八宝山街道的示例可看出逐级关联方式,字段含义清晰,适合下拉联动、地区检索、表单地址选择等业务场景,也能为接口联调与数据校验提供参照。已有607人学习下载,可作为基础数据底稿,帮助减少手工梳理层级、核对父级关系与校验末级节点的时间。
1. 从一次地址表单翻车说起:这份四级城市地区表能顶什么用
做收货地址表单的时候,最烦的不是写下拉框,是下拉框点到最后一级发现没数据——省、市、区县都齐了,「街道/乡镇」那一列是空的,用户填不了门牌号,订单只能卡在待完善。要么接第三方地址接口,要么自己从零维护一套四级城市地区表,后者一旦遇到区划调整就得整体重来。这份资源就是干这个的:一份国内省市县街道乡镇四级地址表,同时给了 xlsx 和 sql 两种文件形态,字段是名称、联动ID、层级、是否末级(1-是),做城市联动下拉、离线地址库、批量地址清洗都能直接拿去用。适合后端和前端的同学一起看,尤其适合那些不想再把时间耗在地址数据上的团队。
2. 数据结构拆解:名称、联动ID、层级、末级标记怎么互相咬合
文件看着只有四列,简单到让人觉得「导进去就完事了」,但真正决定后面查询好不好写的,是这四列之间的约束关系。名称负责展示,联动ID 负责父子关联,层级负责决定它出现在第几个下拉框,是否末级负责告诉前端「到这里还能不能继续往下点」。这四者一旦错位,最常见的表现就是某一级下拉框永远空白,或者用户选完了后端还判不完整。
先把字段语义和常见坑摆出来,再谈建表。
| 字段 | 典型形态 | 在系统里的作用 | 常见问题 |
|---|---|---|---|
| 名称 | 中文字符串 | 前端展示、地址拼接 | 首尾有全角空格、同名单位重名 |
| 联动ID | 定长或变长数字串 | 主键、父子关联、前缀检索 | Excel 读成数值丢精度、前导零被吃 |
| 层级 | 1 / 2 / 3 / 4 | 决定渲染第几个下拉框 | 个别地区层级断裂,只到三级 |
| 是否末级 | 1 或 0 | 判断下拉框是否还有下一级 | 与「层级=4」不等价 |
2.1 建表:主键为什么建议用变长字符串而不是 BIGINT
联动ID 最常见的形式是行政区划编码,省级取前若干位、市级再往后取、县级取满,街道乡镇级再往下补。这类编码有两个特点:第一,可能存在前导零;第二,后面要靠LIKE '前缀%'做整棵子树的检索。用 BIGINT 存,前导零会丢,前缀匹配还得靠函数转换,索引直接废掉。所以我一般会把主键定义成定长字符串。
-- 四级地址表:以联动ID 为主键,parent_id 指向上级 CREATE TABLE t_area ( id VARCHAR(12) NOT NULL COMMENT '联动ID,省2位/市4位/县6位/街道9~12位,以文件实际长度为准', parent_id VARCHAR(12) 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否', 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 COMMENT='省市县街道四级地址表';逐段说明一下。id用VARCHAR(12),长度按文件里最长的那一档定,宁长勿短;parent_id和id保持同类型同长度,避免 JOIN 时发生隐式字符集转换导致索引失效,这是血泪经验。idx_parent_level是给「查某个节点的下一级」这种最热查询准备的,parent_id在前、level在后,等值条件一次走完。idx_level_leaf用于首页初始化或者统计校验,比如一次性捞全部省级节点。idx_name是给后台模糊搜索留的,如果后面要加拼音检索,还得另建字段。
字符集统一utf8mb4,排序规则用utf8mb4_general_ci,兼容性比utf8mb4_0900_ai_ci好一些,尤其在 MySQL 5.7 和 8.0 混部的环境里。如果你的库是 PostgreSQL,把TINYINT UNSIGNED换成SMALLINT、ENGINE那一行删掉即可,其余字段照抄。
2.2 联动ID 的编码规则与父子关系还原
xlsx 里如果已经带了parent_id这一列,那这一步可以跳过。但不少四列表格只给「联动ID + 名称 + 层级 + 是否末级」,父级关系是隐含在 ID 前缀里的,得自己还原。动手前先跑一条统计,把每一级的 ID 长度分布摸清楚,别凭想象写死位数。
-- 先摸清每一级的 ID 长度分布,再决定各级截取多少位 SELECT level, CHAR_LENGTH(id) AS id_len, COUNT(*) AS cnt FROM t_area GROUP BY level, CHAR_LENGTH(id) ORDER BY level, id_len;如果输出里某一级出现了两种长度,说明数据里混了补齐和不补齐两种写法,这时候要么统一补齐,要么按最长的那档截取,二选一,不能两套混着用。下面这段 Python 是按「前缀定长 + 右侧补零」的方式反推parent_id的,适用于绝大多数按区划编码组织的表。
import pandas as pd # 每一级 ID 的定长,按上一条统计 SQL 的真实结果填,不要照抄 LEVEL_LEN = {1: 2, 2: 4, 3: 6, 4: 9} def fill_parent(df: pd.DataFrame) -> pd.DataFrame: """从联动ID 前缀反推 parent_id,缺上级的统一挂到根节点 0""" df = df.copy() df["id"] = df["id"].astype(str).str.strip() df["level"] = df["level"].astype(int) # 定长补齐:不足右侧补 0,超长的截断,保证前缀可比较 def pad(code: str, lv: int) -> str: width = LEVEL_LEN[lv] return code.ljust(width, "0")[:width] df["id"] = [pad(c, lv) for c, lv in zip(df["id"], df["level"])] known = set(df["id"]) def parent_of(code: str, lv: int) -> str: if lv == 1: return "0" # 省级没有父级 width = LEVEL_LEN[lv - 1] pid = code[:width].ljust(width, "0") return pid if pid in known else "0" # 上级缺失先挂根,交给校验 SQL 捞出来 df["parent_id"] = [parent_of(c, lv) for c, lv in zip(df["id"], df["level"])] return df参数上重点看两个地方。LEVEL_LEN必须来自真实统计结果,不同年份、不同来源的表,街道一级可能是 9 位也可能是 12 位,写死了就跑偏。parent_of里那句「上级缺失先挂根」是有意为之:直接报错会让整批导入中断,先挂到0上,再用后面第 4 章的孤儿节点校验 SQL 把这批数据捞出来单独处理,流程不会卡死。
这套前缀法的好处是不依赖额外字段,坏处是对数据规整度要求高。如果 ID 是纯自增或者 UUID 风格,前缀法直接失效,那就只能老老实实按「名称 + 层级」做模糊匹配再人工核对,成本高很多。所以拿到文件的第一件事,永远是先看 ID 长什么样。
2.3 末级标记到底该信 is_leaf 还是信 level
新手最容易写错的一行代码是if level === 4 then 禁止继续下钻。真实数据里,层级断裂很常见:有的地方是「省—市—区县」三级就到头,中间没有街道乡镇;有的地方是「省—直辖县级单位—乡镇」,中间少一级。这些情况下level不等于 4,但它确实已经是末级,前端如果按层级判断,用户就会看到一个永远点不开的空下拉框。
正确的判断依据只有一个:is_leaf = 1。level只用来决定「这是第几个下拉框」,不用来决定「还有没有下一个」。上线前跑一条一致性校验,把标记和实际子节点对不上的记录挑出来。
-- 末级标记与实际子节点数量不一致的记录,正常应该返回 0 行 SELECT p.id, p.name, p.level, p.is_leaf, COUNT(c.id) AS child_cnt FROM t_area p LEFT JOIN t_area c ON c.parent_id = p.id GROUP BY p.id, p.name, p.level, p.is_leaf HAVING (p.is_leaf = 1 AND COUNT(c.id) > 0) -- 标了末级却还有下级 OR (p.is_leaf = 0 AND COUNT(c.id) = 0); -- 没标末级却一个下级都没有这条 SQL 返回非空,就说明文件里的末级标记需要修正。修法有两种:一是以子节点为准重算(有子节点就是 0,没有就是 1),二是以层级为准(level = 4一律置 1)。我更倾向第一种,因为它是数据自洽的,不依赖对层级的假设。重算完之后再跑一遍,返回 0 行才算过关。
3. 从 xlsx 到 SQL:把静态文件灌进数据库的完整动作
拿到 xlsx 之后,很多人第一反应是打开客户端工具点「导入向导」,一路下一步,然后发现 ID 列全变成了1.10101E+11。所以这一步的动作顺序是有讲究的:先检查,再清洗,再落库,最后复核,四步不能省。下面按 MySQL 为主线写,PostgreSQL 和 SQLite 的差异在对应小节里给出来。
3.1 导入前的三件事:看表头、查长度、定类型
打开文件先做三件事,花不了五分钟,能省掉后面两小时。
第一,看表头有没有合并单元格、有没有多余的空行和说明行。很多从系统里导出的表格,第一行是大标题,第二行才是字段名,直接读进来列名全错。
第二,看 ID 列的实际长度分布,方法就是 2.2 节那条统计 SQL,只不过这一步是在 Excel 里用LEN()函数或者 pandas 的str.len().value_counts()先粗看一遍。
第三,看 ID 列在 Excel 里是什么格式。如果单元格左上角有小绿三角,说明它是以文本形式存储的数字,读的时候必须按字符串读;如果没有,那它大概率已经是数值型,长编码末尾几位可能已经被 Excel 悄悄改成 0 了,这种情况得回到原始数据源重新导出。
提示:Excel 对超过 15 位的数字会丢精度,且不可逆。文件里如果出现末几位全是 0 的记录,基本可以判定是精度丢失,不要试图用格式化修复,直接换数据源。
3.2 用 pandas 清洗并导出 CSV
清洗的目标只有一个:产出一份字段顺序固定、编码统一、没有脏字符的 CSV,让数据库端只做搬运不做判断。用 pandas 处理比在 Excel 里手工拉公式可靠得多。
import pandas as pd SRC = "四级地址表.xlsx" OUT = "area_clean.csv" # dtype=str 是关键:全部按字符串读,避免联动ID 被解析成浮点数 df = pd.read_excel(SRC, dtype=str) # 列名去空格后重命名,源文件列名不一致时改这里的映射即可 df.columns = [c.strip() for c in df.columns] df = df.rename(columns={ "名称": "name", "联动ID": "id", "层级": "level", "是否末级(1-是)": "is_leaf", }) # 去掉全角空格、不间断空格、首尾空白,这三样是导致「同名却匹配不上」的元凶 for col in ("id", "name"): df[col] = (df[col].astype(str) .str.replace("\u3000", "", regex=False) .str.replace("\xa0", "", regex=False) .str.strip()) df["level"] = df["level"].astype(int) df["is_leaf"] = df["is_leaf"].fillna("0").astype(int) # 落库前先自检:层级只能是 1~4,末级只能是 0/1 bad_level = df[~df["level"].isin([1, 2, 3, 4])] bad_leaf = df[~df["is_leaf"].isin([0, 1])] assert bad_level.empty, f"层级异常 {len(bad_level)} 行" assert bad_leaf.empty, f"末级标记异常 {len(bad_leaf)} 行" # 按 id 排序后导出,导入时顺序稳定,便于 diff 和复现 df = df.sort_values(["level", "id"]) df[["id", "parent_id", "name", "level", "is_leaf"]].to_csv( OUT, index=False, encoding="utf-8-sig", quoting=1 )几个参数值得单独说。dtype=str是整个脚本的命门,去掉它,pandas 会把纯数字列推断成int64或float64,长编码当场失真。encoding="utf-8-sig"是给 Windows 上的 Excel 和部分客户端工具准备的,带 BOM 能避免打开 CSV 看到乱码;如果目标库是 PostgreSQL 的COPY,建议改成不带 BOM 的utf-8,否则第一列列名会多出不可见字符。quoting=1表示全部字段加引号,防止名称里出现英文逗号把列切错。
3.3 三种数据库的导入写法
MySQL 用LOAD DATA LOCAL INFILE,几十万行基本是秒级。执行前要确认服务端和客户端都开了local_infile,否则会报The used command is not allowed。
# MySQL:先建库建表,再整批灌入;--local-infile=1 必须加在客户端 mysql -u root -p area_db --local-infile=1 -e " LOAD DATA LOCAL INFILE 'area_clean.csv' INTO TABLE t_area CHARACTER SET utf8mb4 FIELDS TERMINATED BY ',' ENCLOSED BY '\"' LINES TERMINATED BY '\n' IGNORE 1 LINES (id, parent_id, name, level, is_leaf);"FIELDS TERMINATED BY和ENCLOSED BY必须和导出时的quoting对应上,导出加了引号、导入不写ENCLOSED BY,字段里带逗号的行就会错位。IGNORE 1 LINES跳过表头。导入前建议先把表清空或者用REPLACE INTO,重复执行不会主键冲突。
PostgreSQL 用COPY,速度更快,但对文件路径有要求,必须是服务端可读的绝对路径。
-- PostgreSQL:文件要放在服务端可读路径,或者改用 psql 的 \copy COPY t_area (id, parent_id, name, level, is_leaf) FROM '/data/area_clean.csv' WITH (FORMAT csv, HEADER true, ENCODING 'UTF8');如果不想折腾服务端权限,就用psql的客户端命令\copy t_area FROM 'area_clean.csv' WITH (FORMAT csv, HEADER true);,效果一样,文件从本地读。SQLite 最省事,建完表直接.import。
# SQLite:先按 2.1 的结构建表(去掉 ENGINE 等 MySQL 专有子句),再导入 sqlite3 area.db <<'SQL' .mode csv .import --skip 1 area_clean.csv t_area SQLSQLite 的.import不做类型转换,全部按文本落库,正好符合我们对 ID 的要求。但它对引号的处理比较粗糙,名称里如果本身带引号,建议还是走 pandas 的to_sql更稳。
3.4 导入后的复核 SQL
导入完成不等于数据正确,跑四条 SQL 心里才有底。
-- 1. 各层级数量,省级一般几十条,市级几百条,县级几千条,街道级最多 SELECT level, COUNT(*) AS cnt FROM t_area GROUP BY level ORDER BY level; -- 2. 末级数量,应该接近总数的一大半 SELECT is_leaf, COUNT(*) AS cnt FROM t_area GROUP BY is_leaf; -- 3. 主键重复(理论上主键约束会拦住,但导入前用 REPLACE 模式时容易漏) SELECT id, COUNT(*) AS c FROM t_area GROUP BY id HAVING c > 1; -- 4. 父级指向了不存在的记录 SELECT c.id, c.name, c.parent_id FROM t_area c LEFT JOIN t_area p ON c.parent_id = p.id WHERE c.parent_id <> '0' AND p.id IS NULL;第 4 条是重点,返回非空说明前缀还原那一步有节点没匹配上,通常是某条父级记录在文件里缺失,或者长度补齐规则不一致。这批数据必须先修,否则前端点到那一层就是空白。
4. 城市联动的查询写法:逐级下钻、递归取路径、前缀取子树
表落好之后,接口层要解决的问题其实只有三类:给我某个节点的下一级、给我某个节点的完整路径、给我某个节点下面所有的末级。三种需求对应三种写法,混用会让查询慢得莫名其妙。
4.1 逐级下钻:一次只查一层
前端每点一次下拉框,后端就查一次下一级,这是最朴素的联动实现。SQL 只有一句,关键是索引要走对。
-- 查某个节点的直接下级,parent_id + level 走联合索引 SELECT id, name, is_leaf FROM t_area WHERE parent_id = '110000' AND level = 2 ORDER BY id;parent_id是等值条件,level也是等值条件,idx_parent_level正好覆盖,回表取name和is_leaf两列,代价很小。这里ORDER BY id不是必须的,但加上之后返回顺序稳定,前端渲染不会每次刷新都换位置。
一个容易忽略的点:省级节点的parent_id是'0',所以初始化第一级下拉框的查询是WHERE parent_id = '0' AND level = 1,不要写成WHERE level = 1。后者在数据有脏节点(比如孤儿节点被挂到 0)时会多返回记录。
如果前端希望「一次请求拿到整条链路的选项」,也可以做成批量接口,用IN一次查多个父级,再由后端分组返回。
-- 批量查多个父级的下级,用于一次渲染多个联动框的初始值 SELECT parent_id, id, name, is_leaf FROM t_area WHERE parent_id IN ('110000', '120000', '130000') ORDER BY parent_id, id;IN里的值不要超过几百个,再多就分批,MySQL 对超长IN列表的解析开销不小。
4.2 递归 CTE 拼出完整地址
订单详情、物流面单、发票抬头这类场景需要把「省/市/县/街道」拼成一行完整地址。用应用层循环查四次当然也行,但一次递归查询能省三次网络往返。
-- MySQL 8.0+ / PostgreSQL 通用:从叶子往上递归,拼出完整路径 WITH RECURSIVE path AS ( SELECT id, parent_id, name, level, CAST(name AS CHAR(255)) AS full_path FROM t_area WHERE id = '110101001' -- 起点:任意一级的联动ID UNION ALL SELECT p.id, p.parent_id, p.name, p.level, CONCAT(p.name, '/', path.full_path) FROM t_area p JOIN path ON path.parent_id = p.id ) SELECT full_path FROM path WHERE level = 1; -- 递归到省级结束CAST(name AS CHAR(255))这一句在 MySQL 里必须写,否则递归 CTE 的列宽会按第一行的实际长度截断,拼到后面就变成乱码或者被截掉。PostgreSQL 里可以写成name::text,也可以省略。CONCAT(p.name, '/', path.full_path)是从父到子还是从子到父,取决于你想要的顺序,这里是父在前,拼出来是「省/市/县/街道」。
递归的终止条件靠parent_id自然收敛,省级的parent_id是'0',JOIN 不上就停了。所以不需要额外写WHERE level > 1之类的判断,写了反而可能提前截断。如果数据里有环(极少数脏数据会造成),递归会一直跑下去,MySQL 默认cte_max_recursion_depth是 1000 层,会在超限时报错,这也算是一道兜底。
4.3 前缀匹配取整棵子树
「查某个省下面所有街道」「导出某个市的所有末级节点」这类需求,用递归也能做,但编码式 ID 有个更快的办法:直接前缀匹配。
-- 取某个省下所有街道乡镇级节点,LIKE 左前缀能命中主键索引 SELECT id, name FROM t_area WHERE id LIKE '11%' AND level = 4 AND is_leaf = 1; -- 取某个市下所有节点(含市县街道),用于导出或缓存预热 SELECT id, name, level, is_leaf FROM t_area WHERE id LIKE '1101%' ORDER BY level, id;LIKE '11%'是左前缀匹配,能走主键索引;写成LIKE '%11%'就退化成全表扫描,几十万行虽然也能扛,但没必要。前缀长度要和层级对应:取省用 2 位,取市用 4 位,取县用 6 位,这个位数就是 2.2 节里LEVEL_LEN定下来的那一套。
前缀法有个前提:ID 必须是定长补齐过的。如果文件里省是 2 位、市是 4 位、县是 6 位但街道是变长的 9 到 12 位混着来,前缀匹配就会漏。所以 2.2 节那一步的补齐工作不能省。
4.4 上线前必跑的四条校验 SQL
查询写完不算完,数据本身有问题的话,SQL 写得再漂亮也是白搭。这四条建议固化到发布脚本里,每次导入新版本都跑一遍。
-- 1. 孤儿节点:父级不存在 SELECT COUNT(*) AS orphan_cnt FROM t_area c LEFT JOIN t_area p ON c.parent_id = p.id WHERE c.parent_id <> '0' AND p.id IS NULL; -- 2. 跨层挂载:子节点层级不是父级 +1 SELECT COUNT(*) AS cross_level_cnt FROM t_area c JOIN t_area p ON c.parent_id = p.id WHERE c.level <> p.level + 1; -- 3. 孤立末级:is_leaf=1 但层级小于 4,属于正常的层级断裂,只做统计不做拦截 SELECT level, COUNT(*) AS leaf_cnt FROM t_area WHERE is_leaf = 1 GROUP BY level ORDER BY level; -- 4. 空名称:名称为空或只有空白字符 SELECT COUNT(*) AS empty_name_cnt FROM t_area WHERE TRIM(name) = '' OR name IS NULL;第 2 条是最容易出问题的,跨层挂载通常意味着前缀还原时截取位数错了,比如把街道级的 ID 当成县级去截,结果挂到了市级下面。第 3 条不建议拦截,层级断裂是客观存在的,统计出来心里有数就行。
5. 避坑与排查:导入和联动阶段踩过的五个坑
这一节按「现象 → 原因 → 解决」的格式写,都是实际撞过的,不是教科书上的理论风险。
5.1 导入阶段的两个坑
第一个坑:联动ID 全变成 1.10101E+11
现象是导入完成后,查出来的id全是科学计数法,或者末尾几位统一变成 0。
原因是 Excel 把纯数字列当成了数值型,超过 15 位就丢精度,pandas 默认的类型推断也会做同样的事。
解决办法是从读取那一刻就锁死类型:pd.read_excel(SRC, dtype=str),数据库端主键用VARCHAR。已经导入坏了的数据没法修复,只能重新导一遍。我现在的习惯是,凡是涉及编码、手机号、身份证这类长数字串的表格,读进来第一件事就是全列dtype=str,不管三七二十一。
第二个坑:导入成功但行数少了一截
现象是SELECT COUNT(*)出来的数和 xlsx 的行数对不上,少的那些通常集中在几个特定的省份。
原因是 CSV 里某些名称字段包含英文逗号或者换行符,导出时没加引号,导入时被当成了列分隔符或行分隔符。
解决办法是导出时加quoting=1(全部字段加引号),导入时 MySQL 加ENCLOSED BY '"'、PostgreSQL 用FORMAT csv默认就处理引号。如果已经导坏了,先用SELECT * FROM t_area WHERE name LIKE '%,%'看有没有残留,没有的话说明那些行整个丢了,必须重导。
5.2 联动与查询阶段的三个坑
第三个坑:选了市,区县下拉框一直是空的
现象是前端点了某个市,接口返回空数组,但数据库里明明有数据。
原因通常是parent_id的类型和前端传参的类型不一致。前端传的是数字110100,数据库里存的是字符串'110100',MySQL 会做隐式转换,某些情况下索引失效甚至匹配不上;PostgreSQL 更严格,直接报类型错误。
解决办法是接口层统一用字符串接收和传递,后端拼 SQL 时也按字符串绑定参数。这个坑我在两个项目里各踩过一次,后来定了个规矩:地址相关的所有 ID,从前端到数据库全程字符串,不做任何parseInt。
第四个坑:同名区县导致地址存错
现象是用户填完地址,后台一看省份对不上,明明选的是这个省,存下来的是另一个省的区县。
原因是用名称做唯一键去反查 ID。国内同名区县不少,光靠名称匹配必然出错。
解决办法是前端提交的必须是id而不是name,后端只信id。展示层要存名称的话,单独冗余一个name字段,但关联和校验一律走id。这条规则要写进接口文档,否则前端同学很容易图省事只传名称。
第五个坑:递归查路径返回的结果被截断
现象是递归 CTE 拼出来的完整地址只到市级,后面几级丢了。
原因是 MySQL 递归 CTE 的列宽由第一次 SELECT 决定,name长度是 20,拼到第三级就超了。
解决办法是显式CAST(name AS CHAR(255)),给足宽度。地址全路径一般不会超过 100 个字符,255 足够。PostgreSQL 没有这个问题,但为了跨库脚本一致,我一般两边都写。
补充一个上线后才发现的:缓存没带版本号
现象是区划调整之后,数据库更新了,前端下拉框还是老数据,清浏览器缓存也没用。
原因是地址树被整棵缓存进了 Redis,key 是固定的,数据变了但 key 没变,缓存永远不失效。
解决办法是缓存 key 里带上数据版本号,比如area:tree:v20240115,版本号跟着导入批次走,新版本导入后换个 key,旧 key 设个短过期时间自然淘汰。这条在 6.3 节会展开。
6. 二次开发:给地址表加缓存、拼音检索和增量更新
地址数据是典型的「读多写极少」,一年可能就更新一两次。这种情况下,把整棵树塞进内存或者 Redis,比每次查库划算得多。四级地址表通常几万到几十万行,序列化之后几十兆,普通应用服务器完全扛得住。我一般的做法是启动时加载成一个parent_id -> children的字典,接口层直接取,数据库只作为持久化和兜底。
写缓存之前先给数据加版本号,key 形如area:tree:{version},version用导入日期。这样新数据导入后换个 key 就完成切换,旧 key 设个 24 小时过期,回滚也方便。这个习惯是被上面那个坑逼出来的,现在只要涉及全量缓存,我第一反应就是先想版本号放哪。
拼音检索是另一个高频需求。用户在下拉框里往往不打全称,直接打首字母。做法是给表加两列pinyin和pinyin_initials,导入时用拼音库批量生成,查询时WHERE pinyin_initials LIKE 'hz%'配合前缀索引。生成脚本不复杂,但要注意多音字:地址里的「厦」「长」「重」都是重灾区,自动生成之后得抽一批人工过一遍,别全信库的输出。
增量更新比全量重导更值得投入。做法是保留上一版 CSV,新版本导入前先做 diff,分别算出新增、删除、改名三类记录,再决定怎么处理。删除要格外小心,直接物理删除会让历史订单里的地址 ID 悬空,我一般改成软删或者只做标记,历史数据保留原样,新数据走新 ID。
import pandas as pd old = pd.read_csv("area_old.csv", dtype=str).set_index("id") new = pd.read_csv("area_new.csv", dtype=str).set_index("id") added = new.index.difference(old.index) # 新增节点 removed = old.index.difference(new.index) # 消失的节点,先别急着删 common = new.index.intersection(old.index) renamed = common[new.loc[common, "name"] != old.loc[common, "name"]] # 改名 print(f"新增 {len(added)},消失 {len(removed)},改名 {len(renamed)}")跑完这一步,先人工看一眼removed列表,确认是真的撤销了而不是数据源缺页,再决定执行哪种更新策略。改名的一般直接UPDATE,新增的INSERT,消失的先标记后处理。
从那以后我每次拿到新的地址文件,都强制走一遍「长度统计 → 前缀补齐 → 末级重算 → 四条校验」这套流程,一步都不跳。地址数据看着简单,出错的地方全在细节里,而细节恰恰是最容易被「先跑起来再说」跳过的那部分。希望帮到你。
本文还有配套的精品资源,点击获取