四级地址库:行政区划代码、街道编码与地址归一化实践
简介面向电商、物流配送与地理信息处理场景的淘宝4级地址库适合需要省、市、县、街道逐级匹配与地址校验的开发者、数据分析人员及后台系统维护者使用。数据以国家标准行政区划代码为组织依据覆盖四级行政结构与街道信息可用于收货地址自动填充、订单地址规范化、区域统计和本地化服务等业务。压缩包内共1个SQL文件整体约351KB体量轻便便于导入数据库后按行政区划代码、层级、名称及上级代码等字段进行查询与关联。该资源已有1072人学习下载说明其在地址数据整理场景中具有一定参考价值。对需要快速搭建地址级联选择、完善物流分区或核对行政区划名称的读者而言这份地址库能提供较完整的底层数据支撑减少自行采集与逐级校对的工作量。1. 四级地址库街道这一层并非简单多加一个下拉框四级地址库常被误认为是在省市区级联下拉框上再加一层实际接手需求后才会发现并非如此街道这一层的编码体系和前三级的国标六位行政区划代码并不相同六位码到区县就断街道要么用九位统计码要么是数据方自造的流水号。这份资源把省、市、区县、街道四级串成一条完整链路每一级都带国标行政区划代码街道能落到具体名称而不是停在区县。做收货地址表单、订单地址解析、物流分单、存量地址清洗的团队都用得上尤其是被“用户填的地址永远匹配不上码表”折磨过的那批人。2. 拆解地址库四级结构、六位码与街道字段的对应拿到一份 CSV 或 SQL 文件很少有人先看结构就直接建表结果字段对不上、父子关系断链返工两三次。更稳妥的顺序是先看层级怎么表达、code 怎么编、街道层的数据来源是什么这三件事决定后面的表怎么建、查询怎么写、匹配阈值怎么定。2.1 单表存四级还是拆成四张表先说选型。四级地址在存储上只有两种主流做法一种是一张表用level区分层级另一种是省、市、区县、街道各一张表。这个资源两种都能导但我一般会走单表原因很实际。单表的好处是级联查询只写一套 SQL省级到街道只是多 join 几次增量更新时按 code 做 diff 也简单新版本来了直接对比两份全量数据多出来的、消失的、改名的各列一列就清楚。四张表看起来更“规范”代价是任何一次跨级查询都要写四段改一次字段名要动四处而且街道表往往还要额外挂经纬度、城乡分类字段和前三级的差异越拉越大最后反而更难维护。真正需要拆表的场景只有一个街道层带 GIS 信息边界多边形、中心点坐标并且要在这上面做空间查询。那种情况下街道适合单独用一张表甚至换一个存储引擎其余三级仍然单表。这份地址库主要解决“层级 编码 名称”的映射单表足够。我导进去之后用的字段结构大致如下字段类型说明idbigint unsigned自增物理主键不放任何业务含义codevarchar(12)行政区划代码省市区 6 位街道 9 位或 12 位namevarchar(64)该级标准名称不带上级前缀short_namevarchar(32)去掉行政后缀的裸名匹配阶段用leveltinyint1 省、2 市、3 区县、4 街道parent_codevarchar(12)上一级的 code省级固定为0full_namevarchar(128)从省拼到本级前端回显直接用statustinyint1 有效、0 已撤销updated_atdate该条记录最后一次变更日期parent_code而不是parent_id这个选择值得单独说一句。用 id 做父引用导入顺序就被锁死了必须先省后市再区县中间断一批就得回滚重来用 code 做父引用只要保证同一批数据里父子 code 对得上导入顺序随便甚至可以多进程并行灌。代价是 code 要建唯一索引数据脏的时候会直接报冲突——这恰恰是我想要的效果宁愿导入阶段炸也别等到线上查询时才发现两条同名记录。2.2 六位码的三条规律与两个例外国标六位行政区划代码的编码规则本身不难前两位是省级第三四位是市级第五六位是区县。省级代码后两位固定为00市级代码后两位固定为00剩下的六位就是区县。这套规则覆盖了绝大多数情况写解析代码时可以拿来快速校验数据是否被改过。但只有三条规律不够实际数据里有两个必须单独处理的例外。第一个例外是直辖市和特别设定的市级占位。直辖市在省级之下没有真正意义上的“地级市”市级这一层填的是“市辖区”之类的占位名代码后两位同样是00。如果你按“市级 name 一定是一个城市名”去写业务逻辑直辖市用户的地址回显会变成“某某市市辖区某某区”前端看着很怪。我的处理是把这类占位节点在short_name里留空拼接full_name时跳过空的市级。第二个例外是省直辖的县级行政区划。这类县级单位直接挂在省下面中间没有地级市数据里会在市级位置放一个汇总码第三四位通常是90这种成段的值名字类似“省直辖县级行政区划”。这类节点在级联下拉里不该出现但它的子节点需要它来维持父子链完整所以不能删只能在查询时按level2 AND name LIKE %直辖%过滤掉。提示这两个例外不要写死在代码里判断字符串而是导入时打一个is_virtual标记位占位节点、汇总节点置 1查询统一加is_virtual 0比在业务代码里散落一堆字符串判断可靠得多。还有一个容易忽略的点六位码每隔一段时间会变。撤县设区、撤市设区、区县合并都会让旧码作废、新码启用而存量订单里躺着的是旧码。所以status和updated_at这两个字段不是摆设是给历史地址兜底用的后面第 5 章会具体讲这个坑怎么踩出来的。2.3 街道层九位统计码、自造码与多来源合并真正的难点在第四级。统计口径下的街道、乡镇用十二位码前六位是它所属的区县第七到九位是街道顺序码第十到十二位是下的居委会或村委会。地址库一般只保留到前九位因为再往下的居委会有几百万条业务上基本用不到体积也会翻十倍。所以你在这份数据里看到街道 code 是九位是正常的不是被截断了。问题在于九位码不是所有数据源都给得出来。街道这一层的来源通常有三个统计口径的九位码、地图 POI 抓下来的名称、以及历史订单里人工维护的别名。三者合并的时候会出现三种典型状态有九位码、有标准名称直接入库最干净。有名称没码给一个内部自造码比如区县六位 9 三位流水保证唯一但不参与对外接口。只有别名标准名要靠推理必须人工确认不能自动合并。这份地址库的街道部分属于前两种混合所以导入前要先做一次画像看每一级的条数、code 长度分布、有没有父节点对不上的孤儿记录。用 pandas 跑一遍比肉眼翻 CSV 靠谱得多import pandas as pd df pd.read_csv(region_4level.csv, dtypestr, encodingutf-8-sig) df[code] df[code].str.strip() df[level] df[level].astype(int) # 1) 每级条数省 30市 300区县 2800街道 4 万上下量级不对就是数据被截断了 print(df.groupby(level)[code].count()) # 2) code 长度分布按 level 分组看能立刻发现街道层混进了 6 位码 print(df.groupby([level, df[code].str.len()])[code].count()) # 3) 孤儿记录parent_code 在表里找不到对应节点 codes set(df[code]) orphan df[(df[parent_code] ! 0) (~df[parent_code].isin(codes))] print(len(orphan)) print(orphan.head(20)[[code, name, level, parent_code]]) # 4) 同级重名同一个 parent 下出现两条同名街道多半是后缀差异 dup df[df.duplicated(subset[parent_code, name], keepFalse)] print(dup.sort_values([parent_code, name]).head(20))这四段输出各有各的读法。第二段最有价值如果level4里出现了长度为 6 的 code说明数据方把街道和区县混在一列里了必须按 code 长度重新切层级而不是信任level字段。第三段的孤儿记录如果集中在某个省通常是那个省刚做过区划调整新街道挂到了新设的区县上而区县表还是老版本。第四段的重名八成是“某某街道”和“某某街道办事处”这种后缀差异属于可以在short_name层归一掉的。3. 建表、导入、级联查询把地址库跑通的三步结构看清楚了就可以动手。这一章给的是可以直接抄的建表语句、导入脚本和两条查询跑完这套级联下拉和订单地址回显基本就够用了。3.1 建表与索引code 做业务唯一键CREATE TABLE region ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, code VARCHAR(12) NOT NULL COMMENT 行政区划代码省市区6位街道9位, name VARCHAR(64) NOT NULL COMMENT 标准名称不带上级前缀, short_name VARCHAR(32) NOT NULL DEFAULT COMMENT 去后缀裸名匹配用, level TINYINT NOT NULL COMMENT 1省 2市 3区县 4街道, parent_code VARCHAR(12) NOT NULL DEFAULT 0 COMMENT 上级code省级为0, full_name VARCHAR(128) NOT NULL DEFAULT COMMENT 省市区街道全拼, is_virtual TINYINT NOT NULL DEFAULT 0 COMMENT 1占位/汇总节点不展示, status TINYINT NOT NULL DEFAULT 1 COMMENT 1有效 0已撤销, updated_at DATE NOT NULL DEFAULT 1970-01-01, PRIMARY KEY (id), UNIQUE KEY uk_code (code), KEY idx_parent_level (parent_code, level), KEY idx_short_name (short_name), KEY idx_full_name (full_name(32)) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_0900_ai_ci;三个索引对应用途uk_code是业务唯一键也是增量 diff 的比对基准idx_parent_level服务级联查询WHERE parent_code ? AND level ?是最热的查询形态idx_short_name服务后面的模糊匹配。full_name上的索引用了前缀 32 字节因为完整全名可能超过索引长度限制而实际查询里几乎都是前缀匹配用户从省开始打前缀索引足够。字符集这里要留个心。MySQL 8 用utf8mb4_0900_ai_ci5.7 上对应的是utf8mb4_unicode_ci两者的排序规则不同同一个名字排序结果会不一样。如果你们的地址下拉是后端排序返回的换数据库版本时列表顺序会变前端做快照测试会失败——这不是玄学是 collation 导致的真实差异。3.2 导入编码、批量与幂等导入脚本要处理四件事文件编码、后缀归一、批量提交、重复执行不出错。下面这段是能直接跑的最小实现import csv import pymysql # 从长到短先匹配长后缀避免街道办事处被街道截断后剩一个办事处 SUFFIXES (街道办事处, 街道, 自治区, 自治县, 镇, 乡, 省, 市, 区, 县) def strip_suffix(name: str) - str: for s in SUFFIXES: # 保留至少两个字防止市中区被削成市 if name.endswith(s) and len(name) - len(s) 2: return name[: -len(s)] return name conn pymysql.connect( host127.0.0.1, userapp, password***, databaseaddr, charsetutf8mb4, autocommitFalse, ) cur conn.cursor() SQL (INSERT INTO region(code,name,short_name,level,parent_code,full_name,status,updated_at) VALUES(%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE nameVALUES(name), short_nameVALUES(short_name), levelVALUES(level), parent_codeVALUES(parent_code), full_nameVALUES(full_name), statusVALUES(status), updated_atVALUES(updated_at)) BATCH 5000 buf [] with open(region_4level.csv, encodingutf-8-sig, newline) as f: for row in csv.DictReader(f): code row[code].strip() name row[name].strip() buf.append(( code, name, strip_suffix(name), int(row[level]), (row[parent_code] or 0).strip(), row.get(full_name, ).strip(), 1, row.get(updated_at) or 1970-01-01, )) if len(buf) BATCH: cur.executemany(SQL, buf) conn.commit() buf.clear() if buf: cur.executemany(SQL, buf) conn.commit()几处细节值得说。编码用utf-8-sig而不是utf-8是因为这类数据从表格软件导出时经常带 BOM不去掉的话第一条 code 前面会多一个不可见字符唯一键冲突报错但肉眼看不出问题。strip_suffix的后缀表从长到短排列如果按从短到长“某某街道办事处”会先被“街道”削成“某某办事处”再被“乡”之类的误伤。len(name) - len(s) 2这个守卫是为了防止“市中区”这种名字在某个错误的后缀表下被削空。ON DUPLICATE KEY UPDATE保证脚本可以重复跑——第一次全量导入后面每次拿到新版数据直接覆盖跑一遍就是增量同步不需要额外写 diff 逻辑。如果数据量再大一个量级比如带上居委会换LOAD DATA LOCAL INFILE会快得多但要注意local_infile参数和字段里的引号转义那种情况下我更倾向先落临时表再INSERT ... SELECT出错了好查。3.3 级联回显与反查两条 SQL 覆盖绝大多数场景前端级联下拉只需要一条按父查子的语句-- 查某个节点的直接下级省级传 parent_code 0 SELECT code, name, level FROM region WHERE parent_code ? AND status 1 AND is_virtual 0 ORDER BY code;订单地址回显要的是四个名字拼一起用自连接一次取出比在应用层查四次快一个量级-- 给定街道 code一次取出省市区街道四个名字 SELECT p.name AS province, c.name AS city, d.name AS county, s.name AS street FROM region s JOIN region d ON s.parent_code d.code JOIN region c ON d.parent_code c.code JOIN region p ON c.parent_code p.code WHERE s.code ?;反过来用户填了一串“某某省某某市某某区某某街道”要拿到对应的街道 code最简单的做法是按full_name前缀匹配SELECT code, level, full_name FROM region WHERE full_name LIKE CONCAT(?, %) AND status 1 ORDER BY level DESC LIMIT 1;这条 SQL 在数据干净的时候命中率不错但它有两个硬伤一是用户写的地址经常缺省、缺市full_name是严格拼接的缺一层就匹配不上二是LIKE xxx%只有在full_name上有索引时才走得动而前缀索引 32 字节对长地址可能截断。所以真正上线我不会只靠它第 4 章会讲怎么把这部分换成前缀树 分段匹配。注意级联查询一定要带status 1。撤销的区县如果还出现在下拉里用户选完提交订单物流分单时会找不到对应网点这类问题在测试环境几乎发现不了因为测试数据都是人工造的“干净地址”。4. 地址归一化把用户手打的脏地址落到四级码上级联下拉是理想情况现实里绝大多数地址是用户手打的一整串字符串中间夹着别名、错字、多余空格、全角括号甚至把“某某路”写在街道位置。要把这串东西落到四级码上得先归一再分段匹配最后对街道层单独做模糊。4.1 先把后缀和别名剥干净归一化这一步做的事情不多但顺序不能错全角转半角、去掉所有空白和不可见字符、统一括号、把常见别名替换成标准写法。别名表要人工维护通常几十条就够覆盖八成流量比如“自治区”的各种简写、“开发区”和它对应的正式名称。import re import unicodedata ALIAS { 高新: 高新技术产业开发区, 经开: 经济技术开发区, # 实际项目里这张表按你们订单里的脏数据补几十条量级 } def normalize(text: str) - str: # 全角转半角同时干掉零宽字符和 BOM 残留 text unicodedata.normalize(NFKC, text) text re.sub(r[\s\u200b\u200c\u200d\ufeff], , text) # 统一括号用户混用中英文括号非常常见 text text.replace(, ().replace(, )) # 别名替换放在最后避免替换出来的字符又被前面的规则处理一遍 for k, v in ALIAS.items(): if k in text and v not in text: text text.replace(k, v) return textNFKC这一步顺带解决了一个很隐蔽的问题某些输入法打出来的数字和字母是全角的肉眼和半角几乎一样做字符串相等比较时会失败。我曾经排查过一个“地址匹配率只有 60%”的问题最后发现是某个渠道的 App 用了全角数字几万条地址里区县名后面的门牌号全是全角归一化加上NFKC之后匹配率直接回到 92%。别名替换要放在最后这个顺序不是随便定的。如果先替换别名再做空白清理替换出来的长名称里可能带空格又得再清一遍如果先做括号统一再替换某些别名本身带括号就会被规则打断。顺序错了不会报错只会让匹配率莫名其妙地低几个点。4.2 用前缀树锁定省市区省市区这三级的匹配有个天然优势名称是有限集合总共三千多条而且用户的地址几乎总是从省或市开头。这种场景用前缀树Trie比正则或逐条 LIKE 都快一次扫描就能拿到最长匹配。class Node: __slots__ (children, code, level) def __init__(self): self.children {} self.code None # 命中时记录该节点的 code self.level 0 def build_trie(rows): rows: [(short_name, code, level), ...]只放 level 1/2/3 root Node() for name, code, level in rows: cur root for ch in name: cur cur.children.setdefault(ch, Node()) cur.code, cur.level code, level return root def match(text, root): 返回 [(code, level, end_index), ...]按结束位置升序 hits, i [], 0 while i len(text): cur, j root, i while j len(text) and text[j] in cur.children: cur cur.children[text[j]] j 1 if cur.code: # 沿途记录取最长靠切片判断 hits.append((cur.code, cur.level, j)) i 1 if hits and hits[-1][2] i else 1 return hits这段代码有两个设计点。一是沿途记录命中而不是只在叶子记录因为“某某市”和“某某市某某区”共享前缀只记叶子会丢掉市级二是外层循环里那个i 1 if ...的写法命中之后直接从匹配终点继续扫避免在长名称内部重复回溯——地址字符串通常只有几十个字符这个优化带来的收益不算大但能避免某些畸形输入比如一整串重复同一个字把复杂度打爆。实际情况里short_name建 Trie 比name建更合适。“某某省”剥成“某某”之后用户写“某某省某某市”和写“某某某某市”都能命中同一条。代价是要处理前缀冲突比如两个不同省有相同的两字简称这时候靠level和后续的父节点校验来消歧单靠名称做不到。4.3 街道层的模糊匹配候选集、阈值与兜底街道这一层不能用 Trie 精确匹配因为它有四万条而且用户写的街道名错字率明显高于省市区。稳妥的做法是先用省市区把候选集缩到几十条再在这个小集合里做相似度。from difflib import SequenceMatcher def match_street(raw, county_code, conn): cur conn.cursor() cur.execute( SELECT code, name, short_name FROM region WHERE parent_code %s AND level 4 AND status 1, (county_code,) ) cands cur.fetchall() best, best_score None, 0.0 for code, name, short in cands: # 全名和裸名各算一次取高的那个能救回某某街道写成某某的情况 score max(SequenceMatcher(None, raw, name).ratio(), SequenceMatcher(None, raw, short).ratio()) if score best_score: best, best_score (code, name), score if best_score 0.72: return best, best_score, fuzzy return None, best_score, unmatched阈值 0.72 不是拍脑袋。我在几批真实订单上做过统计0.72 以下的匹配结果里错误率陡增典型是把“某某路街道”匹配到“某某街道”差两个字但指向完全不同的两个地方。阈值调高到 0.85召回率会掉到七成以下用户填的街道基本都对不上。这个值跟数据质量强相关你们接之前建议先抽五百条人工标一遍把 P/R 曲线拉出来再定。匹配不上的那部分必须有兜底。我的做法是保留原始字符串写入raw_address字段code 留空同时把这条记录丢进一个待人工确认的队列而不是强行匹配一个最近的。强行匹配最可怕的地方在于它不报错错了也没人知道等到快递送错地方才发现那时候一批订单已经出去了。5. 避坑与排查五条踩出来的记录这一章是我自己在几个项目里攒下来的每条都按“现象、原因、解决”写能对上就直接用。5.1 数据侧三个看起来没问题实际会翻车的地方第一条级联下拉到最后一级选不出街道。现象是前端三级联动一切正常第四级永远是空的但直接查数据库能查到街道数据。原因八成是parent_code对不上区县用的是六位码街道的九位码是从统计口径来的它的前六位和区县码一致但数据方在生成街道表时把parent_code填成了九位码的前六位加000或者干脆填了自己的内部 id。解决方式是导入前做一次父子覆盖率统计SELECT COUNT(*) FROM region WHERE level 4 AND parent_code NOT IN (SELECT code FROM region WHERE level 3)只要不是零就先修数据再上线。更稳的做法是导入时加外键校验对不上的直接拒绝入库别让它悄悄流到线上。第二条同一个街道在库里出现两条。现象是用户选了街道提交后偶尔出现同一个地址对应两个不同的 code物流分单时分到两个网点。原因是多来源合并统计口径的叫“某某街道办事处”地图 POI 抓下来的叫“某某街道”两条记录 code 不同、名字差三个字唯一键约束没拦住。解决是建一个(parent_code, short_name)的联合唯一索引把“某某街道办事处”的裸名也归一到“某某”冲突了就在导入阶段报出来。这个索引加完可能会炸出一批历史脏数据但早炸比晚炸好。第三条区划调整后老订单的地址匹配不上了。现象是某个月开始存量订单的地址解析成功率掉了几个百分点新订单正常。原因是上游更新了地址库几个区县撤并旧 code 被标成撤销新 code 启用而老订单里存的是旧 code用新库反查就查不到。解决只能在设计阶段就留余地status字段必须有撤销的节点不删除只改状态另外维护一张code_alias表记录“旧码 → 新码”的映射反查时先查主表查不到再查别名表。这件事的后悔药很贵上线之后再补历史数据基本没法回溯。提示地址库这类数据一定要保留历史版本。每次更新前把当前全量导出一份带日期的快照出问题时能立刻对比出是哪几条记录变了比对着日志猜快得多。5.2 工程侧两个上线后才暴露的问题第四条服务跑一段时间后内存持续上涨。现象是接口 P99 从几十毫秒慢慢爬到几百毫秒重启后又恢复。原因是每次请求都重新构建前缀树四万多条街道加上三千条省市区一次构建几百毫秒对象又迟迟不被回收GC 压力堆起来就抖了。解决是把 Trie 做成单例进程启动时构建一次地址库更新时用新对象整体替换旧引用而不是原地改这样正在处理的请求不受影响。如果做了多实例部署别忘了每个实例都要有自己的构建逻辑或者干脆构建好之后序列化下发。第五条导入脚本跑第二遍报唯一键冲突。现象是第一次导入成功拿新版数据再跑一次直接抛Duplicate entry。原因是脚本用了纯INSERT没有处理已存在的记录。解决是换成INSERT ... ON DUPLICATE KEY UPDATE第 3 章的脚本里已经这么写了或者退一步用REPLACE INTO——但后者会先删后插id会变如果有别的表按id关联就会出问题。这也是我坚持用code做业务关联而不是id的原因之一。另外跑之前先看一眼源文件的 code 有没有重复我遇到过一次数据方导出的文件里同一个 code 出现两行ON DUPLICATE KEY会把后一行覆盖前一行看着没报错实际丢了一条数据。6. 上线前的自检脚本与版本快照地址库这类静态数据最怕的不是导入慢是“看起来导进去了实际缺了一块”。我给这类项目固定加一道上线前的自检四项检查跑完再发版检查项判定标准不通过的典型原因各级条数量级省 30、市 300、区县 2800、街道 4 万上下导出被截断、分页没取全父子覆盖率level1 的记录 parent_code 全部能在表里找到版本混用、区划调整code 唯一性COUNT(code) COUNT(DISTINCT code)多来源合并未去重空名称与异常长度name 非空且长度在 220 之间编码错乱、字段串列对应脚本很短跑一次不到十秒import pymysql conn pymysql.connect(host127.0.0.1, userapp, password***, databaseaddr, charsetutf8mb4) cur conn.cursor() cur.execute(SELECT level, COUNT(*) FROM region GROUP BY level) print(各级条数:, dict(cur.fetchall())) cur.execute( SELECT COUNT(*) FROM region r WHERE r.level 1 AND r.status 1 AND NOT EXISTS (SELECT 1 FROM region p WHERE p.code r.parent_code) ) print(孤儿记录:, cur.fetchone()[0]) cur.execute(SELECT COUNT(*), COUNT(DISTINCT code) FROM region) total, distinct cur.fetchone() assert total distinct, fcode 重复: {total} vs {distinct} cur.execute(SELECT COUNT(*) FROM region WHERE CHAR_LENGTH(name) 2 OR CHAR_LENGTH(name) 20) print(名称异常:, cur.fetchone()[0])四项里我最看重第二项。孤儿记录不会让任何查询报错只会让某些地址在级联里“消失”而且往往集中在某一个省靠着用户投诉才能发现。所以每次更新数据孤儿记录必须是 0是 0 才允许发版。至于版本管理我的习惯是每次更新前先导一份带日期的全量快照region_20240115.sql这种再用 code 做一次新旧 diff把新增、删除、改名三类分别落到一个变更日志表里。这样出了问题时能直接回答“这条地址是什么时候、因为哪次更新变成这样的”而不是对着一堆日志猜。这套东西平时看起来是负担真出事的那一次能省掉一整天。从那以后我每次接地址库类的数据不管对方说得多干净都强制先跑一遍画像和自检再建表——被坑过一次之后这几分钟的脚本比任何口头承诺都可靠。希望帮到你。本文还有配套的精品资源点击获取