地址库里那条"XX市XX区XX街道"的记录,前后端联调时愣是对不上号的事,我遇到过不止一次。前端级联选出来的是三级,后端库里存的是五级,中间少了两级,最后拼出来的字符串发出去,物流那边的系统直接拒收。那之后我花了小半年时间,把五级行政区划体系这套东西从数据源、表结构、编码规则到查询缓存完整梳理了一遍,中间踩的坑比想象中多得多。这篇文章聊的就是这套五级行政区划体系在工程上怎么落地——它本质上是一棵五层的树,省级、地级、县级、乡级、村级依次向下,但真拿它当普通树来写代码,很快就会撞墙。内容面向做电商地址、政务数据、物流面单、地图检索、CRM 客户地址管理的开发者和数据分析同学,也适合产品经理了解这块的数据边界在哪里。下面讲的每一条,基本都是我在真实项目里被数据"教育"过之后总结的,代码块里的编码和名称全是示意值,不要当真。
1. 五级体系落到数据模型里,先得把每一级的边界划清楚
很多人第一次接触这套体系,直觉反应是"不就是五个 parent_id 串起来的树吗"。真做下去会发现,这五层里有好几层的性质并不一致:有的是完整的地方行政层级,有的是派出机构,有的在统计口径里存在但在行政建制上并不独立。不把这些差异先拎出来,后面所有的关联查询都会飘。
1.1 五层不是均匀的五层,层级之间有"跳过"和"代管"
最典型的是直辖市。省一级下面直接就是区县,没有地级市这一层,如果你用一个固定的"省→市→区"三级联动逻辑去套,直辖市下面第二级就空了。还有一类是省直接管辖的县级市,它在编码上第三四位是00,意味着它上面没有地级市挂靠,但业务系统里往往又需要一个"市"字段来填,于是有人硬塞一个同名的地级节点进去,数据从此就脏了。
再往下一层,"代管"关系也很容易出错。县级市被地级市代管,在很多业务口径里算作地级市的下级,但它本身仍然是县级建制。如果你的表里只存一个parent_id,这个"代管但不隶属"的语义就被抹平了,等到你需要按行政级别统计或者按隶属关系出报表的时候,只能靠人工补规则。
乡级这一层更微妙。街道是区县政府的派出机关,镇和乡是一级政府,两者的行政性质不同,但在五级体系里它们并列在同一层。村级严格来说不属于行政建制,是基层群众性自治组织,但在统计用区划代码里它确实占据最末三位。
提示:如果你的业务只需要"省市区"三级,别图省事只建三张表。建完整的五级表,然后在查询层做投影,比反过来补两层要轻松得多。
1.2 六位代码和十二位代码,是两套不同用途的东西
做这块数据一定会遇到两套编码。一套是六位的行政区划代码,前两位是省级,中间两位是地级,后两位是县级;另一套是统计用区划代码,十二位,在六位基础上再拼三位乡级、三位村级。这两套编码的用途完全不同,混用是新手最容易犯的错。
六位代码是"身份标识",相对稳定,一个县改成区,代码通常会变,但变完之后新旧代码之间存在明确的对应关系。十二位代码是"统计口径标识",它每年都可能调整,乡级和村级那六位的变动频率相当高——一个村合并、一个社区拆分,代码就变了。
我在第二个项目里就吃过混用的亏:订单表存的是十二位统计代码,商品库存的是六位行政区划代码,做地区维度汇总的时候直接按字符串截取前六位去 join,结果发现有些条目根本 join 不上。原因是有少量乡级节点的前六位在当年的统计口径里对应的是一个已经撤销的县级单位。这种数据在总量里占比很小,可能千分之几,但排查起来极其费劲。
| 编码类型 | 位数 | 分段含义 | 稳定性 | 典型用途 |
|---|---|---|---|---|
| 行政区划代码 | 6 | 2 位省 + 2 位地 + 2 位县 | 较稳定,变更可追溯 | 主体标识、归属关系 |
| 统计用区划代码 | 12 | 6 位区划 + 3 位乡 + 3 位村 | 年度调整,变动频繁 | 统计汇总、抽样框 |
| 城乡划分代码 | 3 | 独立于上述两套 | 随统计口径变 | 判断城乡属性 |
第三行那个城乡划分代码值得单独提一句:它用来标记一个村级单位属于城区、镇区还是乡村,做用户地域画像、配送范围划分的时候很有用,但它跟十二位代码是并列关系,不是拼在一起的。
1.3 三类必须先打标记的"非标准"节点
第一类是各类开发区、管理区、新区。它们有管理委员会,有实际管辖范围,能开证明能盖章,但在正式的行政区划序列里往往不独立存在,而是挂在某个区县下面。业务方填地址的时候会填这些名字,你的标准库里没有,匹配就失败。
第二类是撤县设区之后的过渡期。名义上已经改了,实际业务系统里老名字还在用,快递面单、营业执照、老客户的收货地址全是旧称。这个过渡期可能长达好几年。
第三类是名称后缀不统一。同样是村级,"XX村民委员会""XX村委会""XX村"三种写法在不同数据源里同时存在,做模糊匹配之前必须先做一次后缀归一。
我的做法是在表里加一个node_type字段,把标准行政节点、开发区类节点、历史遗留节点区分开,再配一个is_active和valid_from/valid_to。这三个字段看起来简单,但后面做历史地址回溯的时候能省掉大量硬编码。
2. 存储结构:邻接表够用,但路径字段能救你一命
表结构这块我前后改过三版。第一版是纯邻接表,一个parent_id走天下;第二版加了路径枚举字段;第三版是两者混合,加上一份冗余的层级名称。每一版都是被具体的查询性能问题逼出来的。
2.1 邻接表为什么是默认答案,又为什么不够
邻接表的优点很明确:写入简单,任意层级的增删改都只影响一行,天然支持无限层级扩展。五级体系虽然是固定五层,但用邻接表来表达没有任何障碍,反而比固定五列(省列、市列、区列……)要灵活得多——后者一旦遇到直辖市这种跳层的情况就要填冗余值。
问题出在读取侧。最典型的需求是"给我这个村往上所有的祖先节点",用邻接表就得递归。MySQL 8 之前没有 CTE,只能靠应用层循环查五次,一次请求打五次库,级联下拉还好说,批量地址解析的时候就是灾难。即使上了 CTE,深递归在数据量大、索引没打好的情况下依然慢。
-- 递归查询某个节点的全部祖先(示意) WITH RECURSIVE ancestors AS ( SELECT id, parent_id, name, level, 1 AS depth FROM region WHERE id = 100302001001 UNION ALL SELECT r.id, r.parent_id, r.name, r.level, a.depth + 1 FROM region r JOIN ancestors a ON r.id = a.parent_id WHERE a.depth < 6 ) SELECT * FROM ancestors ORDER BY depth;这段 SQL 能跑,但它的前提是parent_id上有索引,而且数据量控制在几十万级。到了村级全量数据(几十万到上百万行),再加上并发,单靠递归 CTE 支撑高频查询是不现实的。
2.2 路径枚举字段在地址检索上的意外收益
第二版我加了一个path字段,存的是从根到当前节点的 id 路径,形如/1/10/1001/100101/100101001/。这个字段最初只是为了减少递归,后来发现它在地址检索上的价值更大。
有了路径,判断祖先关系变成一次字符串前缀匹配:WHERE path LIKE '/1/10/1001/%'就能拿到某个区县下的所有节点,不用任何递归。前缀匹配能走索引(前提是用前缀索引或者倒序存储,普通 B-Tree 对LIKE 'xxx%'是友好的),性能比递归高一个数量级。统计某个区县下面有多少个乡级节点、村级节点,一句 count 就出来了。
代价是写入变复杂了:新增一个节点要拼路径,移动一个节点要级联更新所有子孙的路径。不过在行政区划这个场景里,节点的"移动"非常罕见——一个村从 A 镇划到 B 镇这种事一年也就零星几例,用一次批处理脚本刷一遍完全可接受。相比之下读多写少的比例可能是几万比一,这个取舍很划算。
我的路径里还额外带了一段层级名称的冗余,形如某省/某市/某区/某街道/某村,专门用来做展示和模糊搜索。这个字段有冗余,但省掉了每次查询都要 join 五张表(或者说五次自连接)的麻烦。
2.3 我实际在用的字段清单与索引设计
下面是我最终定下来的字段结构,去掉业务相关的部分,只留通用字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 内部主键,自增或雪花 |
| code | varchar(12) | 官方编码,六位或十二位,唯一 |
| name | varchar(64) | 标准名称 |
| short_name | varchar(32) | 去掉后缀的简称,用于匹配 |
| level | tinyint | 1 省 2 地 3 县 4 乡 5 村 |
| parent_id | bigint | 上级内部 id |
| path | varchar(255) | 祖先 id 路径 |
| name_path | varchar(255) | 祖先名称路径 |
| node_type | tinyint | 标准 / 开发区 / 历史 |
| is_active | tinyint | 是否现行有效 |
| valid_from / valid_to | date | 生效区间 |
索引上,code唯一索引是必须的,parent_id普通索引用于取直接下级,path上加前缀索引(长度 64 足够覆盖前四级)用于子树查询,short_name上加一个普通索引配合前缀匹配做模糊搜索。另外我单独建了一张别名表,存同一个节点的各种历史名称和俗称,匹配的时候先查别名表再查主表,命中率高不少。
注意:
path字段一旦用了字符串前缀匹配,就要保证所有路径的分隔符和起止格式严格统一,前后都不能有空格或者多余斜杠,否则索引直接失效,这个坑我调了大半天才发现。
3. 数据清洗才是最耗时的部分:那些对不上的条目
写代码的时间大概占三成,剩下七成全在跟数据较劲。五级体系的数据看起来是官方发布的、应该很规整,实际上导入之后能对上的比例往往只有九成多,剩下那百分之几足够让你加班一周。
3.1 代码漂移:同一个地方换过好几次号码
行政建制调整会带来代码变更。一个县改成区,六位代码的后两位通常会变;两个区合并,其中一个的代码直接作废;新设一个区,代码是新的。如果系统里存的是三年前导入的数据,而业务方给的是今年的编码,两边就对不上。
处理这件事我用的是一张映射表,字段是old_code、new_code、change_type、effective_date。清洗阶段先做一次全量比对,凡是只在旧数据里出现的编码,查映射表,能映射过去的就替换,映射不过去的标成待人工确认。这张表的价值在于它把"历史"这件事显式化了,而不是靠代码里的 if-else 打补丁。
映射关系有一个容易忽略的细节:合并场景是一对多还是多对一。两个区合并成一个,旧的两个代码都指向新的一个,这是一对一(多对一方向);一个区拆成两个,旧代码可能要根据具体位置指向不同的新代码,这就没法自动映射了,只能靠地址明细做二次判断。我在项目里遇到过一次拆分,最后是让业务方按乡镇级别提供了一份对照清单才解决。
3.2 开发区、管理区这类"影子节点"的归属处理
这类节点是最让数据对不齐的。它们在业务上真实存在,地址里天天出现,但在标准区划序列里查不到。有两种处理思路:
一种是硬挂——把它挂到实际管辖它的区县下面,作为乡级节点的一个变体,node_type标成开发区。好处是业务方填的地址能直接匹配到,坏处是它跟真正的乡级节点混在一起,做统计的时候容易重复计数。
另一种是软挂——单独建一张"扩展节点表",跟标准表分开,查询的时候 UNION 起来。好处是标准数据保持纯净,坏处是所有查询都要多一层合并逻辑。
我倾向第一种,但在表里加了一个is_standard标记,默认所有统计查询都过滤is_standard = 1。这样既能匹配,又不会污染统计口径。开发区下面往往还有自己的社区划分,比标准乡级细,这个层级我统一按村级处理,名字后缀加个标注。
3.3 从"对不上号"到定位根因的排查链路
数据对不上的时候,最忌讳直接改数据。我摸索出一套固定的排查顺序,基本能覆盖九成问题:
| 步骤 | 检查项 | 常见结论 |
|---|---|---|
| 1 | 两侧编码位数是否一致 | 六位对十二位,前六位相同但全串不等 |
| 2 | 编码是否为已撤销的旧码 | 需要查映射表 |
| 3 | 名称是否只是后缀差异 | "XX村"对"XX村民委员会" |
| 4 | 是否开发区类节点 | 标准库无此节点 |
| 5 | 是否跨级填写 | 业务方直接填了村级,跳过了乡级 |
| 6 | 是否存在同名不同地 | 需要靠上级路径消歧 |
第 5 条特别常见。很多表单设计成"省市区"三级下拉,但用户手动填详细地址的时候把乡镇和村都写进了详细地址字段,结构化字段是空的。这种数据在清洗时需要做地址解析,把自由文本拆回五级,第 4 节会展开讲。
第 6 条也值得说。同名乡镇、同名村在全国范围内大量存在,单看名称根本无法定位。这时候路径字段就派上用场了:先用名称在前缀树里匹配出候选集,再用用户提供的省市信息做路径前缀过滤,命中率能提到九成以上。如果用户连省市都没给,那就只能靠 IP 或者手机号归属地猜,猜错了要留人工修正入口。
4. 查询侧:级联下拉、地址解析与缓存粒度
数据理顺之后,剩下的就是查询怎么做得快、做得准。这块的难点不在于单条 SQL 的写法,而在于不同场景下需求差异太大——前端级联下拉要的是快,地址解析要的是准,报表统计要的是全,一套方案吃不下三种需求。
4.1 三级联动的懒加载与预加载,选错了就是白屏
最常见的需求就是省市区三级联动。方案有两种:一次性把全国数据(省+地+县,大概几千条,压缩后几十 KB)全量下发到前端,或者每选一级请求一次。
全量下发的优点是没有网络等待,切换极快,缺点是有更新就得重新发版或者加版本号控制。我现在的做法是全量下发到县级,但做两层压缩:字段名用短名(i、n、p),结构用嵌套数组而不是对象,再开 gzip,实际传输量能压到很小。乡级和村级不下发,因为量级太大,改成按需查询。
按需查询的关键是接口要支持批量。用户选了"某市",前端一次性请求这个市下面所有的区县,而不是一个个请求。再往下的乡级,可以在用户选定区县后用一次异步请求预取,用户还没点开下拉的时候数据已经到了。这个预取的时机要卡准,太早浪费流量,太晚就白屏。
提示:懒加载接口一定要做防抖和请求取消。用户快速划过几个省的时候,如果每个省都发一次请求,回来顺序乱了,下拉框里就会显示上一个省的区县,这个 bug 我在三个项目里都见过。
4.2 把一段自由文本地址拆回五级
地址解析是五级体系里技术含量最高的一块。输入是"某某省某某市某某区某某路某某号某某小区几栋",输出是五级结构 + 剩余详细地址。难点在于用户的写法千奇百怪:省略省名、用简称、乡镇和村混写、把小区名当成村名。
我的解析流程分四步走,顺序不能乱:
第一步是分词和命名实体识别,把可能的行政层级词性标出来。这一步用规则比用模型稳,因为行政区划名称的后缀有强规律(省、市、区、县、镇、乡、街道、村、社区),正则能覆盖大部分。
第二步是自顶向下逐级匹配。先匹配省级,拿到候选后限定范围再匹配地级,依次往下。绝对不能反过来从村级往上匹配,因为村级名称重复率极高,从下往上会产生海量候选。
第三步是处理跳过和跳级。用户没写地级市是常事,尤其是直辖市或者省直管的情况。这时候要在候选集里做一次"允许跳级"的尝试,看能不能在下一级直接命中。
第四步是剩余部分归一。匹配掉行政层级之后剩下的字符串就是详细地址,要把"某某路""某某号""某某小区"这些保留原样,只做格式清理。
# 逐级收窄的匹配思路(示意,非完整实现) def parse_address(text, region_tree): candidates = region_tree.roots() # 省级候选 matched = [] rest = text for level in range(1, 6): hit = None for node in candidates: alias = node.short_name + node.suffix if alias in rest: hit = node rest = rest.replace(alias, "", 1) break if hit is None: continue # 允许跳级 matched.append(hit) candidates = hit.children() return matched, rest.strip()这段代码的关键在continue那一行——匹配不到就跳过当前层级继续往下试,而不是直接失败。实际生产里还要加上别名匹配、同音字容错和最长匹配优先,否则"某某新区"里的"新区"会被误判成区级。
4.3 缓存按哪一级失效,决定了你半夜会不会被叫起来
五级数据的读取频率极高、变更频率极低,天然适合缓存。但缓存粒度选错了,更新的麻烦会很大。
我的策略是按层级分三档:省级和地级几乎不变,缓存时间可以按天算,甚至直接打进代码里,一年更新一次;县级变化稍多,缓存按小时;乡级和村级变化最频繁,缓存按分钟或者干脆走短 TTL 加主动刷新。
主动刷新的触发点是数据更新任务。每天定时跑一次同步,比对官方数据的新旧版本,发现有变更就精准清除对应节点的缓存,而不是全量清空。全量清空的代价是缓存雪崩,凌晨跑任务的时候可能把线上打出一个尖峰,我就这么干过一次,监控告警响了一晚上。
另外,缓存 key 的设计要带版本号。region:{level}:{parent_code}:{version}这种结构,版本号一变,旧 key 自然失效,不用逐个删。旧 key 靠 TTL 自己过期,内存占用有个上限,比逐个 del 安全。
5. 乡级和村级:数据量最大、变动最频繁的两层
很多人做到县级就收手了,觉得"省市区"够用。真往下做乡级和村级,会发现这是完全不同的两个世界——量级从几千跳到几十万,变更频率从一年一次变成一年数次,名称的规范性也断崖式下降。
5.1 真实的量级差异与更新节奏
省级三十多个,地级三百多个,县级两千多个,乡级四万左右,村级六十万上下。这个数量级差异决定了你不能用同一套加载策略:前三级可以全量塞进内存,后两级必须靠数据库和索引。
变更节奏也不一样。县级以上的调整是新闻级别的,一年也就那么几次;乡级一年可能有几十上百处调整,包括撤乡并镇、街道析置;村级调整更密,合并、拆分、更名、农转居,一年上千处的量级。而且村级变动往往就在你导入数据之后的几周内发生,等你发现的时候业务方已经在用新名字了。
我做同步任务的时候,前三级用全量覆盖,后两级必须用增量比对——拿新旧两份数据做 diff,只处理变化的部分。全量覆盖村级数据会导致主键全变,所有外键引用全部要重建,那是一次几小时的下线。
5.2 乡级数据缺失时的降级策略
现实情况是,很多数据源到县级就断了,乡级和村级拿不到或者不完整。这时候如果业务上必须要乡级,就得有降级方案。
我的做法是三级降级:第一优先用标准数据源;拿不到的话,用地址解析从用户填写的详细地址里反向提取乡级名称,提取到的存进一张"用户贡献"表,经过一定数量的重复验证后升级为可信别名;再拿不到,就把乡级字段留空,用一个虚拟节点占位,避免整个链路的空指针。
这个虚拟节点的设计有讲究。它必须有稳定的 id(用负数或者高位保留段),有明确的名称(比如"未填写"或者"其他"),但不能参与统计汇总,否则数据会失真。前端展示的时候也要做特殊处理,不然用户看到"其他街道"会一脸问号。
注意:虚拟节点千万不要复用真实的编码段。我见过有人用
000000当占位,结果跟某些历史数据里的空值编码撞上了,清洗的时候被当成真实节点处理,造出一堆脏数据。
5.3 我在村级数据上踩过的几个具体坑
第一个坑是村级名称的长度。有些村的官方全称很长,加上"村民委员会"之后超过 32 个字符,如果表里name字段定的是 varchar(32),导入的时候会被静默截断,截断之后名字重复率飙升,去重逻辑直接失效。后来我把name放宽到 64,short_name单独存去掉后缀的版本。
第二个坑是同名村的消歧。全国范围内同名村太多了,只靠名称去重会误合并。必须用完整的路径做唯一性判断,或者用十二位编码——十二位编码是全国唯一的,这一点比名称可靠得多。
第三个坑是用户填写的"村"其实不是村。很多小区、楼盘、商业综合体的名字里带"村"字,比如某某新村、某某花园村,地址解析时容易被误判成村级节点。我的处理方式是在村级候选匹配时加一层校验:如果匹配到的名称前面紧邻着道路名称或者门牌号,降权处理;如果这个名称在标准库里根本不存在,直接不匹配,归入详细地址。
第四个坑是农转居的过渡。一个村变成社区,名称从"某某村"变成"某某社区",编码可能不变也可能变。用户的老地址里还是"村",新数据里只有"社区",匹配失败。这时候别名表就派上用场了,把新旧名称都挂在同一个节点下,两个都能命中。别名表的数据来源有两个:一是历史数据的名称变化自动生成,二是从用户的失败记录里人工确认后补录。
做完这一整套,最深的体会是:行政区划这套数据,技术难度不在于算法有多复杂,而在于它是一份"活的"数据——它在变,而你的系统里存的是某一刻的快照。真正需要设计的不是查询逻辑,而是版本管理和变更传播机制。凡是涉及地址的地方,都要问一句"这条数据是什么时候的",问清楚之后,很多莫名其妙的 bug 就有了答案。
另外一个经验是关于测试数据的。别拿"某省某市某区"这种规整的样本做测试,那只能验证 happy path。测试集里必须塞进直辖市、省直管县、开发区、撤县设区的旧名称、同名村、超长名称、缺失乡级这七八种边界情况,每一种都对应一个真实踩过的坑。我现在的测试集是从线上失败记录里扒出来的,跑一遍能覆盖九成历史问题,比拍脑袋构造样本靠谱得多。