“中国五级行政区划体系”这几个字,第一次听到的人多半觉得它是地理课本里的东西;但只要你的系统里出现过“省/市/区”三级联动下拉框,或者被收货地址校验、门店归属统计、政务数据上报这几件事折磨过一次,你就会明白它本质上是国内最大的一张主数据表——大到没有任何一个业务系统能绕开,又复杂到几乎没有一家公司一开始就把它建对。它管的是什么?简单说就是省、地、县、乡、村这五个层级的从属关系,以及挂在每一级上的那串唯一编码。它能干的事远比想象中多:地址标准化、物流分单、区域销售看板下钻、门店覆盖圈计算、历史数据回溯对齐,全都靠它兜底。这篇文章我打算按一个后端/数据从业者的视角,把五级体系的结构、编码规则、树形建模方式、数据采集与校验、变更追踪,以及真实项目里踩过的坑,一次性讲透。刚接触的读者可以照着最后的建表和脚本直接抄作业,做过几年数据的老手,重点看第三、五、六章,那部分是我认为最容易翻车的地方。
1. 为什么我坚持把行政区划单独建一张主数据表
很多团队的早期做法是把省市区直接塞进业务表里,比如订单表存三个字段province、city、district,存的是中文名。这套方案在业务量小的时候看着挺省事,但基本撑不过第二轮迭代。我第一次被迫重构就是因为一个很朴素的问题:客户在收货地址里写“A省B市C区”,另一个客户写“A省 B市 C区”(中间多了空格),还有人写“B市C区”(省略了省)。同一个物理地址,在库里变成了三个不同的字符串,做区域统计的时候数据直接对不上。
行政区划作为主数据的第一个价值,是把“人写的东西”翻译成“机器认的东西”。人在地址栏里敲的是自由文本,机器需要的是稳定的、唯一的、可比较的标识符。这个标识符就是行政区划代码——六位数字,省级两位、地级两位、县级两位。有了它,无论用户怎么写“C区”“C 区”“C区(新)”,只要最终能映射到同一个代码上,你的统计口径就是一致的。这一步做不做,直接决定了后面所有区域维度的分析是不是可信。
第二个价值是层级结构本身承载了业务规则。举个最典型的例子:物流的配送范围划分。你要算“从某个仓出发,哪些区县能在次日达”,本质是在区划树上做子树查询——给定一个地级市,把它下辖的所有区县捞出来,再看这些区县里哪些在配送半径内。如果区划只是三个字符串字段,你连“哪些区县属于这个市”都答不上来,只能靠 LIKE 模糊匹配,性能和数据质量双输。再比如门店选址、渠道分级管理、政务数据逐级上报,全都依赖“父子关系”这个结构信息。
第三个价值,也是最多人忽略的:区划是会变的,而且变得比你想象的频繁。每年都有一批撤县设区、撤县设市、区划合并、驻地迁移。撤县设区之后,原来的县代码作废,新的区代码启用,你库里三年前存的那个代码,今天可能已经查不到了。如果区划漂在业务表里,你没有任何抓手去追溯“这个代码当年对应哪里、现在变成了什么”。把区划独立成表、并且给它加上时间维度,是唯一能在数据层面讲清楚这件事的办法。
所以我的一般原则是:区划表是所有业务表的外键目标,业务表只存代码不存中文名,中文名一律通过关联查询获得。这条原则听起来有点教条,但它能省掉后面至少三种数据不一致的问题——名字改了但你库里还是旧名、同一个代码在不同业务表里对应不同名字、以及最恶心的,历史数据因为改名而无法和当期数据对齐。
2. 五级体系的骨架拆解与编码规则
2.1 五个层级各自管什么
先说清楚这五级到底是什么。第一级是省级,包括省、自治区、直辖市;第二级是地级,包括地级市、地区、自治州、盟;第三级是县级,包括市辖区、县级市、县、自治县、旗等;第四级是乡级,包括镇、乡、民族乡、街道;第五级是村级,包括村民委员会、居民委员会(社区)。
这五级的命名差异不是文字游戏,背后是行政层级与实际管理权限的区别。做技术的人不需要深究行政学,但必须搞清楚一件事:这五级不是严格的一对多嵌套,中间存在"断档"和"跳级"的情况。最典型的就是直辖市——省级下面直接就是县级(市辖区),中间没有地级这一层。如果你按“五级必须有五层”去建树,直辖市的数据就会错乱,要么被塞进一个虚构的地级节点,要么整个子树被挂错位置。我见过一个系统把直辖市的区挂在“市辖区”这个虚构地级节点下面,结果做省级汇总的时候正常,做地级汇总的时候凭空多出一堆“地级单位”。
还有一类是省直管的县级单位,名义上归省管,实际代码上不挂在任何地级市下面。这类节点的层级判定必须靠代码规则而不是靠名字猜,具体规则下一节讲。
2.2 那串数字才是真正的身份证
六位代码的结构是固定的:第 1-2 位是省级,第 3-4 位是地级,第 5-6 位是县级。省级代码的后四位是0000,地级代码的后两位是00。比如一个虚构的省990000,它下面的某个地级单位990100,再下面的某个区990101。看代码就能反推出层级:结尾0000的是省级,结尾00的是地级,两位都不为00的是县级。这个规律在写解析函数的时候非常好用,比查表快得多。
省级往下的两级编码里还藏了一些约定俗成的区间规律,虽然不是强制标准,但在判断类型时很管用。地级编码的第 3-4 位,落在01到20区间的大多是地级市,落在21到50区间的大多是地区、自治州、盟。县级编码的第 5-6 位,01到20大体是市辖区,21到50大体是县,81到99大体是县级市。我必须强调这只是统计上的倾向,不是可以拿来做业务判断的硬规则——早年有过例外,未来也可能继续出现例外。真要判断类型,还是得靠一个显式的level_type字段,代码区间只能当作数据缺失时的兜底猜测。
真正要小心的是代码作废这件事。代码不是永久有效的,一次区划调整就可能让一批代码失效,而且失效的代码通常不会被复用——新设的单位会拿新号。这意味着你的区划表里必须同时保留“有效代码”和“历史代码”,并且能表达“这个旧代码变成了哪个新代码”的映射关系。这个设计决定,直接关系到你能不能回答“去年这个区的销售额,折算到今年的区划口径下是多少”这种问题。
2.3 十二位代码:为什么做下沉业务必须用它
六位代码只到县级。如果你的业务涉及乡镇、街道甚至村居——比如快递末端网点、社区团购的自提点、政务的网格化管理——六位就不够用了,你需要十二位统计用区划代码。它的结构是:前六位与国家标准一致,第 7-9 位是乡级,第 10-12 位是村级。比如990101001001表示某个区下面第一个街道的第一个居委会(这里的数字仅为示意)。
十二位代码里还有一个特别值得关注的东西:城乡分类代码,三位数,跟在区划代码后面单独给出。常见的取值里,111表示主城区,112表示城乡结合区,121表示镇中心区,122表示镇乡结合区,123表示特殊区域,210表示乡中心区,220表示村庄。这几个数字看起来枯燥,但在实际业务里价值极高。我做过的项目里,判断一个地址属于“城市”还是“农村”,直接影响配送时效承诺、运费模板、甚至售后政策。用城乡分类代码去判断,比用“地址里有没有‘村’字”这种土办法准确得多,也稳定得多。
提示:城乡分类代码是跟着统计用区划代码一起发布的,属于同一份数据资产,采集的时候一定要一起拿下来。很多人只抓了区划代码,等到要做城乡维度分析的时候才发现还得再跑一遍,白折腾。
2.4 三套常见代码,别混着用
实际工作中你会碰到不止一套代码,最容易搞混的是这三套:国家标准代码(县级及以上,六位)、统计用区划代码(到村居,十二位)、以及身份证前六位所依据的历史版本代码。前两套是有明确发布渠道和年度更新的,第三套最麻烦——身份证前六位反映的是发证当年的区划状态,一个人在区划调整前办的身份证,前六位可能对应一个今天已经不存在的区。
这就导致一个非常现实的问题:你用身份证前六位去反查户籍地,查不到是正常的,不是数据错了。正确的处理方式是维护一张“历史代码到现行代码”的映射表,查不到的时候先做一次映射,还查不到再走人工兜底。这个坑我在做风控画像的时候踩过一次,当时有一批用户的户籍地解析失败率异常高,排查了两天才发现是撤县设区导致的旧代码失效。
3. 树形结构怎么存:四种模型的真实取舍
3.1 邻接表:简单,但查询要递归
最直觉的做法是邻接表——每行存一个parent_code,指向上级节点。优点是写入极其简单,一次区划调整只需要改一行或者插一行;缺点是查子树要递归。现代数据库基本都支持递归查询,用WITH RECURSIVE就能解决,MySQL 8.0 以上、PostgreSQL、SQL Server 都没问题。
邻接表适合什么样的场景?写多读少,或者树很浅。行政区划这棵树深度固定是五层,递归层数可控,其实不算特别吃亏。但如果你要做“查某个省下面所有村”这种跨三层的查询,递归一次动辄几千行,配上分页和关联就有点难受了。
-- 查某个地级单位下所有后代节点(邻接表 + 递归CTE) WITH RECURSIVE sub_tree AS ( SELECT code, name, parent_code, level FROM region WHERE code = '990100' UNION ALL SELECT r.code, r.name, r.parent_code, r.level FROM region r INNER JOIN sub_tree s ON r.parent_code = s.code ) SELECT * FROM sub_tree;3.2 路径枚举:查询最快,维护要小心
路径枚举的思路是给每个节点存一条从根到自己的完整路径,比如/990000/990100/990101/。查子树就变成了一次LIKE '/990100/%'的前缀匹配,一次索引扫描搞定,不需要递归。查祖先链也能直接从路径字符串里切出来。
代价在写入侧。一旦某个节点被移动到新的父节点下面(区划调整里这叫“隶属变更”,虽然不常见但不是没有),它自己以及它所有后代的路径都得更新。五级体系下这个更新量不算恐怖,但必须用事务包住,否则中途失败就会出现路径断裂的脏数据。
还有一个坑是路径长度。用代码拼路径,每层六位加一个分隔符,五层大概 35 个字符,VARCHAR(64)完全够用。但如果哪天要支持更深的层级(比如网格化管理的扩展层级),就得提前留出余量。我一般直接给到VARCHAR(255),省得以后改表。
3.3 闭包表:查询最灵活,存储代价最高
闭包表的思路是单独建一张关系表,把所有祖先-后代对都存下来,包括自己到自己的深度 0。这样任意两点之间的层级关系都是一次普通的等值查询。查“某节点的所有后代”是WHERE ancestor = ?,查“某节点的所有祖先”是WHERE descendant = ?,都走索引,非常干脆。
代价是行数。五级体系里,一个村级节点最多有 4 个祖先,加上自己就是 5 行;全国的村居数量在百万量级,闭包表就是五百万行左右。这个量级对现代数据库不算什么,但如果你只是做一个省级看板,用闭包表就是明显的过度设计。
3.4 四种模型横向对比
| 模型 | 查子树 | 查祖先 | 写入复杂度 | 存储开销 | 适合场景 |
|---|---|---|---|---|---|
| 邻接表 | 需递归 | 需递归 | 最低 | 最低 | 树浅、写多读少、变动频繁 |
| 路径枚举 | 一次前缀匹配 | 字符串切分 | 中等(移动需级联更新) | 低 | 读多写少、层级固定 |
| 闭包表 | 一次等值查询 | 一次等值查询 | 高(需维护关系行) | 高 | 复杂层级查询、频繁做祖先判定 |
| 嵌套集 | 范围查询 | 范围查询 | 最高(插入要重排左右值) | 低 | 几乎不变的静态树 |
我的实际选择是邻接表 + 路径枚举双写。邻接表保证数据模型干净、便于维护和对接外部数据源,路径枚举作为冗余字段专门服务高频查询。写入的时候在一个事务里同时更新两列,查询的时候看场景挑用哪个。嵌套集我从来不用于行政区划——它的插入代价太高,而区划恰恰是每年都在变的,用它等于给自己挖坑。
注意:不管选哪种模型,都一定要在
code上建唯一索引,在parent_code上建普通索引。我见过一个项目两个索引都没建,做一次子树查询全表扫了三百万行,接口超时报警响了半个月没人管。
4. 从零落一份可用的区划库:建表、采集、校验
4.1 表结构设计
下面这个表结构是我这几年反复用的一套,去掉了一些业务特有的字段,保留核心部分。代码里的省市区编号都是示意的虚构值。
CREATE TABLE region ( code VARCHAR(12) NOT NULL COMMENT '区划代码,6位或12位', name VARCHAR(64) NOT NULL COMMENT '标准名称', short_name VARCHAR(32) DEFAULT NULL COMMENT '简称/别名,用于地址解析', parent_code VARCHAR(12) DEFAULT NULL COMMENT '上级代码,顶级为NULL', level TINYINT NOT NULL COMMENT '1省 2地 3县 4乡 5村', level_type VARCHAR(16) NOT NULL COMMENT '具体类型:省/地级市/市辖区/县/镇/街道...', path VARCHAR(255) NOT NULL COMMENT '路径枚举 /990000/990100/', urban_rural CHAR(3) DEFAULT NULL COMMENT '城乡分类代码', longitude DECIMAL(10,6) DEFAULT NULL, latitude DECIMAL(10,6) DEFAULT NULL, valid_from DATE NOT NULL COMMENT '生效日期', valid_to DATE DEFAULT NULL COMMENT '失效日期,NULL表示现行', PRIMARY KEY (code), KEY idx_parent (parent_code), KEY idx_path (path), KEY idx_level (level) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个字段的选择理由值得说一下。level用数字而不是字符串,是为了排序和比较方便——你想按层级从上往下展示的时候,ORDER BY level比按字符串排省事得多。level_type单独存一份中文类型,是为了解决前面说的“代码区间只能猜类型”的问题,落库时就把类型定死。valid_from/valid_to是时态字段,下一章详细展开。urban_rural只在乡村级有值,允许为空。
4.2 数据采集与清洗的三个关键动作
数据来源上,最权威的是每年发布的官方区划代码清单,县级及以上和乡村级是分开发布的,需要分别采集再合并。实际做的时候有三个动作必须做,缺一个后面都会难受。
第一个动作:统一字符集和空白。原始数据里经常混着全角空格、不间断空格、以及名称后面的多余空白。这些在肉眼看来完全一样,但在字符串比较时会导致关联失败。清洗规则很简单:去掉首尾空白,把各类不可见空白字符统一替换成半角空格,再把连续空格压成一个。这一步不做,你后面做名称匹配的时候会莫名其妙对不上。
第二个动作:补全 parent_code。官方数据通常只给代码和名称,父子关系要靠代码规则推导——六位代码去掉末两位就是地级代码,去掉末四位就是省级代码。但直辖市和直管县是例外,必须靠显式规则覆盖。我的做法是:先用代码截断推导一遍,再把推导结果里“不存在于当前数据集”的节点标记出来,人工确认这部分是不是属于特殊结构。
第三个动作:构造 path。path 不能靠递归生成完了再存,那样效率低且容易在数据量大的时候爆栈。更稳的做法是按层级分四轮批量更新:先给省级写/code/,再给地级写父path + code + /,以此类推。每轮都是一次UPDATE ... JOIN,跑起来很快。
import re WS_PATTERN = re.compile(r'[\s\u3000\u00a0]+') def clean_name(raw: str) -> str: """清洗区划名称:压空白、去首尾、全角转半角""" if not raw: return '' s = WS_PATTERN.sub(' ', raw).strip() # 全角括号统一为半角,避免后续匹配失败 s = s.replace('(', '(').replace(')', ')') return s def derive_parent(code: str, level: int) -> str | None: """按层级推导父级代码,特殊结构需另做覆盖""" if level <= 1: return None if level == 2: return code[:2] + '0000' if level == 3: return code[:4] + '00' if level == 4: return code[:6] if level == 5: return code[:9] raise ValueError(f'未知层级: {level}')4.3 入库后的四类一致性校验
数据导进去不算完,必须跑一轮校验,否则脏数据会在你毫无防备的时候咬你一口。我固定跑这四类检查。
孤儿节点检查:parent_code不为空但父节点不在表里的,全部捞出来。这类数据要么是采集时漏了父级,要么是父级代码写错了。
环路检查:正常情况下不可能出现环路,但如果手工改过数据,或者从多个来源合并,就可能出现 A 的父是 B、B 的父是 A 这种荒唐情况。查法是沿着 parent 链往上走,超过五层还没到根就判定异常。
层级一致性检查:code的长度、level的值、path的层数,三者必须互相吻合。一个六位代码的节点不应该出现在 level=4,它的 path 也不应该有三层以上。
名称重复检查:同一父级下面出现两个同名子节点,基本可以确定是重复采集或者改名余留。跨父级的同名是正常的(比如很多地方都有“城关镇”),这个不能当错误处理,但同一父级下同名必须报警。
| 校验项 | 检查逻辑 | 常见成因 | 处理方式 |
|---|---|---|---|
| 孤儿节点 | parent_code 不在 code 集合中 | 采集遗漏父级、代码录入错误 | 补采或置空并标记待确认 |
| 环路 | 沿 parent 链向上超过 5 层未终止 | 手工改数据、多源合并冲突 | 强制断开并人工核对 |
| 层级不一致 | 代码长度、level、path 层数三者互斥 | 推导规则未覆盖特殊结构 | 按显式规则表覆盖修正 |
| 同级同名 | 同一 parent_code 下 name 重复 | 重复采集、改名未删旧记录 | 保留 valid_from 较新的一条 |
这四项跑完,一套数据基本就干净了。我一般把这个校验脚本做成定时任务,每次数据更新就自动跑一遍,把异常清单推到群里——比事后被业务方问“为什么这个区查不到”要体面得多。
5. 区划是会变的:变更追踪与时态表设计
5.1 把变更分成六类,处理方式完全不同
区划调整的花样比很多人想的多,但落到数据层面无非六类,每类的处理逻辑差别很大。
新增:新设立一个单位。往表里插一行,valid_from填生效日期,valid_to留空。
撤销:整个单位消失,比如某个县被并入邻县。不能直接DELETE,要给它填上valid_to,让历史数据还能关联上。
合并:A 和 B 合并成 C。这是最容易出错的一类,因为需要同时处理三条记录:A 和 B 宣布失效,C 新建。如果只做了“新建 C”而忘了给 A、B 打失效标记,你的库里就会同时存在三份互相重叠的数据,做区域汇总的时候直接翻倍。
更名:代码不变,名字变了。这类最温和,但因为名字变了,所有按名字缓存的逻辑都会失效。如果系统里有“按名称模糊搜索区划”的功能,改名之后老名字搜不到,用户会以为数据丢了。我的做法是保留历史名称在short_name或者单独的别名表里,搜索的时候一起匹配。
隶属变更:代码不变,父节点变了。这类调整会连带影响 path 和所有后代的 path,必须级联更新。这也是为什么我在第 3 章强调 path 要放在事务里更新。
级别升降:比如撤县设区,原来是 level=3 的县,变成 level=3 的市辖区——层级没变但类型变了;也有从县级市升格为地级市的情况,那就涉及 level 变化和整棵子树的路径重建。这类调整必须人工介入,没有任何自动化规则能百分之百稳。
5.2 双时态:区分“事实发生时间”和“记录写入时间”
严格来说,区划数据需要两个时间维度:一是业务时间,也就是调整从哪天开始生效;二是系统时间,也就是这条记录是哪天被写进你的库的。前者回答“2023 年这个地址属于哪个区”,后者回答“我什么时候知道这件事的”。
对绝大多数业务系统来说,只做业务时间(valid_from/valid_to)就够了,双时态是锦上添花。但如果你的业务涉及对账、审计或者历史快照回溯,双时态就很有必要了——因为你会遇到“补录数据”的场景:2024 年才发现 2022 年的一次调整没有录入,这时候如果只有业务时间,你无法区分“这条记录是 2022 年就有的”还是“2024 年补的”,取历史快照的时候就会出错。
我的取舍是:核心区划表只做业务时间,另建一张变更日志表记录每次写入的系统时间和操作来源。变更日志表结构很简单,记录代码、变更类型、变更前后的 parent、变更前后的名称、系统写入时间、数据来源。这样既保持了主表结构清爽,又能追溯“什么时候知道的”。
CREATE TABLE region_change_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, code VARCHAR(12) NOT NULL, change_type VARCHAR(16) NOT NULL COMMENT '新增/撤销/合并/更名/隶属变更/级别变更', old_name VARCHAR(64) DEFAULT NULL, new_name VARCHAR(64) DEFAULT NULL, old_parent VARCHAR(12) DEFAULT NULL, new_parent VARCHAR(12) DEFAULT NULL, effective_date DATE NOT NULL COMMENT '业务生效日期', recorded_at DATETIME NOT NULL COMMENT '系统记录时间', source VARCHAR(64) DEFAULT NULL COMMENT '数据来源标记', KEY idx_code (code), KEY idx_effective (effective_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;5.3 版本 diff 的实操思路
每年新版数据下来,最省事的处理方式不是重新全量导入,而是跑一次 diff,只处理差异部分。全量导入的问题是:所有valid_from都会被刷成新日期,历史版本全部丢失,而且如果有业务表外键关联,还可能触发一堆莫名其妙的约束冲突。
diff 的做法很直接:把新版数据先导进一张临时表,然后和主表按code做全外连接,分出三类:只在主表里的(可能是被撤销了)、只在新表里的(新增)、两表都有的(比对名称和 parent 有没有变化)。每一类分别处理。
def diff_regions(old_map: dict, new_map: dict) -> dict: """比对两版区划数据,输出变更分类结果""" old_codes, new_codes = set(old_map), set(new_map) result = { 'removed': [], # 可能被撤销,需人工确认是否为合并 'added': [], # 新增 'renamed': [], # 更名 'moved': [], # 隶属变更 } for code in old_codes - new_codes: result['removed'].append(code) for code in new_codes - old_codes: result['added'].append(code) for code in old_codes & new_codes: o, n = old_map[code], new_map[code] if o['name'] != n['name']: result['renamed'].append({'code': code, 'old': o['name'], 'new': n['name']}) if o['parent_code'] != n['parent_code']: result['moved'].append({'code': code, 'old': o['parent_code'], 'new': n['parent_code']}) return result跑完 diff 之后,removed那一类一定要人工过一遍。因为从数据上看,“被撤销”和“被合并”长得一模一样——都是老代码没了。区别在于合并会产生一个新代码,而且新代码的管辖范围通常等于被撤销的几个单位之和。这个判断自动化做不了,只能靠人看公告。
提示:不要把 diff 结果直接写库,先输出成一份待确认清单,走一遍人工审核再执行。这一步看起来慢,但比事后回滚快得多。
6. 业务落地时的真实坑位与排查手册
6.1 地址解析:从自由文本到区划代码
所有区划应用的入口,几乎都是同一个问题:用户给了一段自由文本地址,怎么把它翻译成代码。这件事的难度被严重低估了。我见过太多人一开始觉得写个正则就能搞定,最后写了一千行规则还在打补丁。
难在哪?第一,层级词会重复出现。一段地址里可能同时出现好几个“市”字,你不确定哪个才是真的地级市边界。第二,省略和简称满天飞。有人写全称,有人写简称,有人干脆跳过中间层级。第三,存在非标准层级。各种开发区、新区、管委会,它们不是正式的行政区划,但用户就是这么写。第四,历史地址。老地址里出现的区,今天可能已经不存在了。
我用的方案是词典匹配 + 逐级收敛,而不是纯正则。具体做法:先把五级区划的名称、简称、别名全部灌进一个前缀树(Trie),然后对地址文本从左到右扫描,找出所有能匹配上的候选片段。因为名称可能有重叠(比如“A县”和“A县B镇”),匹配完之后要按位置和层级做一轮消歧——优先取层级高且位置靠前的候选,同时保证选出来的候选序列在区划树上是合法的父子关系。
这个“合法性校验”是准确率的关键。假如匹配结果里同时出现了“属于 X 省的某个区”和“属于 Y 省的某个区”,那就说明有一个匹配是错的,必须靠上下文或者更长匹配来排除。我实测下来,加了合法性校验之后,解析准确率能从七成多提到九成以上,剩下的那一成基本是地址本身信息不足,只能走人工。
def resolve_address(text: str, trie, region_map: dict) -> dict: """ 逐级收敛解析地址 返回 {'province': code, 'city': code, 'district': code, 'confidence': float} """ hits = trie.match_all(text) # 返回 [(start, end, code, level), ...] hits.sort(key=lambda x: (x[0], -x[3])) # 按位置升序、层级降序 chain, last_code = {}, None for _, _, code, level in hits: node = region_map.get(code) if node is None: continue # 校验父子合法性:已有更高层级时,当前节点必须在其子树内 if last_code and not node['path'].startswith(region_map[last_code]['path']): continue chain[level] = code last_code = code confidence = len(chain) / 3.0 return {**chain, 'confidence': min(confidence, 1.0)}6.2 六个高频问题速查
| 现象 | 大概率原因 | 排查顺序 | 解决方式 |
|---|---|---|---|
| 某些区县查不到 | 代码已作废,业务用的是历史代码 | 先查变更日志,再查映射表 | 建历史代码映射,查询时自动转换 |
| 区域汇总数据翻倍 | 合并场景下旧记录未打失效标记 | 查 valid_to 是否有重叠 | 补失效日期,加唯一约束 |
| 直辖市下钻数据错乱 | 按五级硬套,缺地级层 | 查 path 层数与 level 是否匹配 | 特殊结构建映射规则表 |
| 同名区县关联错 | 用名称做关联键 | 检查 join 条件 | 一律改用 code 关联 |
| 地址解析成功率低 | 词典缺简称和别名 | 抽样看未命中的文本 | 补别名库,加历史名称 |
| 子树查询超时 | 无索引或用了嵌套集 | 看执行计划 | 加 parent_code / path 索引 |
6.3 几个容易忽略的边界情况
开发区的处理。各类新区、开发区、管委会不是正式的行政区划,官方代码清单里通常没有它们。但业务上又必须支持——用户就是会填“某某开发区”。我的处理办法是在正式区划表之外单建一张“扩展区域表”,手动维护这些区域到正式区划的映射关系。查询的时候两套一起查,正式表优先,扩展表兜底。
飞地与飞地式的地址。有些区域在地理上和管理上不连续,一个区的管辖范围里可能嵌着别的单位。做地理围栏的时候不能只靠经纬度落点判断行政归属,必须用边界数据做真实的空间包含判断,否则会把边界另一侧的点判错。
村级数据的稳定性。村级区划的变动频率远高于上层级,每年调整量大,而且部分数据源的准确性参差不齐。如果你的业务只到县级,就不要为了“完整性”去抓村级数据,维护成本远大于收益。这是我从一个项目里学到的教训——当时为了做村级统计抓了全量数据,结果每个季度都要花大量精力做数据核对,最后发现业务方根本不用那么细的粒度。
编码边界:六位代码和十二位代码不要混存在同一张表的同一个字段里却不加区分。我见过一个系统把两种代码都塞进region_code VARCHAR(12),结果关联的时候六位的和十二位的撞在一起,出现了莫名其妙的匹配。要么分表,要么加一个code_type字段强制区分。
6.4 我在实际项目里沉淀下来的几条经验
第一,区划表一定要有“冻结快照”机制。每年新版数据发布之后,我会把当期数据导出成一份带版本号的只读快照,业务侧所有历史数据分析都基于快照跑,而不是基于会变动的主表。这样即使主表后续被修正,历史报表的数字也不会突然变化——数据口径的可复现性,比数据本身的实时性重要得多。
第二,别迷信拼音排序和字符串排序。中文名称排序在区划展示场景里是个常见需求,但按 Unicode 码位排出来的顺序完全不符合用户预期。要么显式维护一个sort_no字段按官方公布顺序排,要么用带拼音字段的方案。我现在的表里固定带一个sort_no,来源是官方清单的原始顺序,从来没出过问题。
第三,给地址解析留出“置信度”出口。不要把解析结果做成非黑即白的二值判断,一定给个置信度。低置信度的结果老老实实落到人工复核队列,比神不知鬼不觉地写进去一个错的代码要好得多。我在一个项目里给置信度设了三档:高置信直接落库,中置信落库但打标,低置信进复核池。上线三个月后,落错的比例降了一个数量级。
第四,定期做“反向抽查”。从库里随机抽一千条带区划代码的业务数据,拿地址原文重新解析一遍,比对结果是否一致。这个动作能提前发现数据源更新带来的漂移,比等业务方反馈问题要主动得多。我现在把这个抽查脚本挂在每周的定时任务上,跑完把不一致的样本直接推给负责的同事,成了一件几乎不需要额外沟通的例行工作。
这套东西做完,区划对你来说就不只是一个下拉框的选项来源,而是一层真正能支撑区域分析、地址治理、历史回溯的基础设施。真要说哪一步最值得先做,我会毫不犹豫地选第 4 章那四个校验脚本——它带来的收益,比我在这上面写过的任何优化代码都实在。