简介淘宝4级地址库是一份面向电商开发、物流配送与地理信息处理场景的行政区划数据包适合需要做地址匹配、区域统计或收货地址校验的开发者与数据分析人员使用。压缩包内共1个文件为sql格式的数据库脚本体积约351KB可直接导入数据库后按省、市、县区、乡镇街道四级结构查询行政区划代码与名称。数据采用国家标准行政区划编码前六位对应省市区县并附加街道层级信息便于精确到乡镇街道的地址补全与校验。资源已有1074人学习下载可作为地址库建设的轻量参考。读者可获得完整的四级区划表结构、层级关联字段以及街道名称数据用于订单地址自动填充、区域维度统计、本地化服务划分等任务也能据此理解行政区划代码的组织方式与查询思路减少自行采集整理基础地理数据的时间成本。1. 四级地址库补的是哪一层缺口省市区之后还差一个街道做收货地址表单的人都清楚省、市、区三级下拉只能算及格线。用户选完某个区之后剩下的全靠一个自由输入框兜底包裹能不能送到取决于那行字里街道写没写对。四级地址库把这一层补上省、市、区县、街道乡镇每一级都挂国标行政区划代码前端能做四级联动后端能把脏地址归一成编码再落库。这份资源适合三类人做电商、物流、本地生活后端的需要一份能直接导入数据库的四级数据做数据清洗的手里几十万条地址要反查行政编码做项目交付的客户明确要求地址严格按国标走。它的价值不在条数多而在于街道层没被砍掉、编码没乱填、父子关系能对得上。下面从表结构怎么定、编码位数怎么读、CSV 怎么灌、地址怎么反查一路拆到半年后怎么更新。2. 结构拆解四级地址库的层级模型与行政区划代码位数拿到一份地址库先别急着建表导入。结构没想清楚后面每一次查询都在补窟窿。四级地址库的核心矛盾只有一个你是想让写入简单还是想让查询简单。这两件事在地址这种强层级数据上几乎必然对立选型错了后期改表比重新导入还贵。2.1 邻接表还是宽表按查询模式定一份四级地址数据落到关系型数据库里只有两种主流存法。邻接表一行一个节点用parent_code指向父节点。省级节点父级为空区县指向市街道指向区县。写入时每个节点只出现一次改名只改一行代价是查一条完整路径要做递归。宽表一行一条完整路径把省市区街道四个名字和四个编码平铺成八列。查一条路径一次SELECT就出来导出给业务方直接能用代价是冗余大街道改名要刷全表而且一旦某条路径的上级改名你很难保证八列同步更新。维度邻接表宽表单行含义一个行政节点一条四级路径存储冗余低每个节点一份高上级名重复 N 次级联下拉查询一次查parent_code要DISTINCT去重慢完整路径查询递归或应用层循环一次查询改名成本改一行刷全表适合场景源数据入库、在线表单离线导出、报表、BI我一般的做法是两张都留以邻接表作为唯一数据源再物化一张宽表给导出和报表用。物化表不进业务写路径只在数据更新后重建一次脏了也无所谓。很多项目一开始图省事只用宽表等到要按parent_code做四级联动时就抓瞎了只能靠名字LIKE硬拼同名区县一多就串数据这是血泪经验。还有一个折中做法邻接表加一个path字段存省码/市码/区码/街道码这样的斜杠串。既能一次LIKE xx0000/%捞出某省全部下级也能ORDER BY path保证下拉顺序稳定。代价是父级变更时要级联刷子节点的path但对地址这种几乎只增不迁的数据来说可以接受。2.2 六位、九位、十二位编码长度决定层级国标行政区划代码是分段编码长度本身就是层级信号。省、市、区县用六位前两位是省中间两位是市后两位是区县街道乡镇在六位后面补三位凑成九位村和社区再补三位凑成十二位。这意味着你不能只用一张表加一个level字段就完事还必须处理一种情况同一个库里混着六位码和九位码parent_code补齐规则不统一JOIN直接空结果。常见做法是内部统一成六位存省市区九位存街道十二位存村社区父级推导规则固定下来。def parent_of(code: str): 按编码长度推导父级编码内部统一 6/9/12 位存储 c code.strip() if not c.isdigit(): raise ValueError(f非数字编码: {code}) n len(c) if n 12: # 村/社区 - 街道 return c[:9] if n 9: # 街道 - 区县 return c[:6] if n 6: if c[2:6] 0000: # 省级无父级 return None if c[4:6] 00: # 市级 - 省 return c[:2] 0000 return c[:4] 00 # 区县 - 市 raise ValueError(f非法编码长度: {len(c)}) def level_of(code: str) - int: 层级编号1省 2市 3区县 4街道 5村社区 return {6: None, 9: 4, 12: 5}.get(len(code)) or ( 1 if code[2:6] 0000 else 2 if code[4:6] 00 else 3 )这段代码有两个地方值得说明。第一parent_of对六位码做了三次判断顺序不能反先判省级再判市级最后才当区县否则110000会被当成市级推出一个不存在的父级。第二level_of里六位码的层级不是靠长度判断的而是靠尾段是否为零因为省、市、区县都是六位长度本身不携带信息。还有一个容易忽略的边界省直辖的县级行政区划六位码的中间两位通常是90这类占位段它的父级是省而不是市。如果你的推导逻辑硬按前四位 00去找父级这类节点会全部断链。稳妥的做法是先按parent_code是否存在于表中做一次全量校验把断链的节点单独捞出来人工确认而不是让程序猜。2.3 街道层的数据校验四条硬规则街道层是这份资源最容易出问题的地方也是最值钱的地方。省级数据全国就三十多条谁都不会错街道层动辄几万条来源杂、更新快、重名多导入前不做校验后面查询就是在垃圾上建索引。我一般会跑四条硬规则命中任何一条都不让进主表先落到临时表观察。校验项规则命中后处理断链parent_code必须存在于表中补父节点或整条剔除长度只允许 6、9、12 三种修正或隔离到临时表同级重名同一parent_code下name唯一加区分后缀如XX街道新空码段街道码三位不能是000直接丢弃属于占位脏数据import pandas as pd df pd.read_csv(region_4level.csv, dtypestr, encodingutf-8-sig, keep_default_naFalse) # 1. 长度合法性 bad_len df[~df[code].str.len().isin([6, 9, 12])] # 2. 父级断链父码非空但不在 code 集合里 codes set(df[code]) bad_link df[df[parent_code].ne() ~df[parent_code].isin(codes)] # 3. 同级重名按父级分组后名字不能重复 dup df[df.duplicated(subset[parent_code, name], keepFalse)] # 4. 街道层占位码 bad_street df[(df[code].str.len() 9) df[code].str.endswith(000)] print(len(bad_len), len(bad_link), len(dup), len(bad_street))encodingutf-8-sig是为了吃掉文件头的 BOM不加这一项第一条省级编码前面会多出一个不可见字符导入后code长度变成 7主键冲突或者查不到排查起来非常费劲。keep_default_naFalse是为了防止名字里恰好是NA、NULL这类字符串被 pandas 自动转成空值地址库里真出现过这种名字。同级重名这条规则要留个口子确实存在同一区县下有两个同名街道的情况加后缀是最省事的处理但后缀会污染展示。更干净的做法是保留原名另加一列display_name存带后缀的版本展示和匹配分开用。3. 落地导入建表、批量灌数与四级联动查询结构定了接下来是把这份库真正跑起来。这一段的目标很明确给它一张能撑住级联查询的表用最短时间把几万行灌进去然后验证四级联动和逆序解析两条主链路都通。3.1 表结构与索引怎么定表结构不用花哨字段够用就行重点是索引要跟查询模式对齐。地址库的查询只有三种按父级查子级级联下拉、按编码查自身回显、按名字模糊查搜索框。索引就照这三种建。CREATE TABLE region ( code CHAR(12) NOT NULL COMMENT 行政区划代码6/9/12位, name VARCHAR(64) NOT NULL COMMENT 标准名称, short_name VARCHAR(32) DEFAULT NULL COMMENT 简称用于展示, parent_code CHAR(12) DEFAULT NULL COMMENT 父级编码省级为NULL, level TINYINT NOT NULL COMMENT 1省 2市 3区县 4街道 5村社区, sort_no INT NOT NULL DEFAULT 0 COMMENT 同级排序号, path VARCHAR(64) DEFAULT NULL COMMENT 省/市/区/街道 编码路径, PRIMARY KEY (code), KEY idx_parent (parent_code, sort_no), KEY idx_name (name), KEY idx_path (path) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci;主键直接用code不用自增 ID。地址库的特点是编码天然唯一且稳定用业务编码做主键能省掉一次回表也让导数据变得可重复执行重灌直接REPLACE INTO就行不用先清表。idx_parent是联合索引顺序不能反parent_code在前用于等值过滤sort_no在后直接覆盖排序级联下拉那条WHERE parent_code ? ORDER BY sort_no走的就是这个索引不会产生额外排序。把sort_no放前面就废了。idx_name用于搜索框name上有前缀匹配需求普通 B-Tree 索引对LIKE xx%有效对LIKE %xx%无效。如果前端要做全模糊搜索别硬靠这个索引上全文索引或者把名称搜索放到内存结构里做后面第 4 章会讲。path字段是可选优化项存110000/110100/110105/110105001这样的串好处是查某省全部下级只要WHERE path LIKE 110000/%一次搞定不用递归。代价是父级改名时要级联刷新只在地址数据基本不变的前提下划算。3.2 CSV 批量导入的三个参数几万行数据用INSERT一条条灌是最傻的做法慢不说还容易把连接池占满。用 MySQL 的LOAD DATA LOCAL INFILE几秒钟完事。# 先建表 mysql --default-character-setutf8mb4 -u root -p addr_db schema.sql # 客户端和和服务端都要开 local_infile mysql --local-infile1 --default-character-setutf8mb4 -u root -p addr_dbLOAD DATA LOCAL INFILE /data/region_4level.csv INTO TABLE region CHARACTER SET utf8mb4 FIELDS TERMINATED BY , OPTIONALLY ENCLOSED BY LINES TERMINATED BY \n IGNORE 1 LINES (code, name, parent_code, level, sort_no) SET code TRIM(BOTH \r FROM code), name TRIM(name), parent_code NULLIF(TRIM(parent_code), ), level CAST(level AS UNSIGNED), sort_no CAST(sort_no AS UNSIGNED);三个参数值得单独说。CHARACTER SET utf8mb4是给文件内容指定的不写这一行服务端会按表的默认字符集解析中文很容易变问号OPTIONALLY ENCLOSED BY 是因为地址名里偶尔带逗号比如某些复合名称不加引号包裹会整行错位LINES TERMINATED BY \n针对的是 Windows 换行如果文件是\r\n末尾的\r会粘在最后一列上所以SET里对code做了TRIM(BOTH \r FROM ...)。NULLIF(TRIM(parent_code), )这一句是为了把空字符串转成真正的NULL。省级节点的父级必须是NULL而不是空串否则递归查询的终止条件失效会一直往上找直接死循环。如果不想碰 MySQL 的命令行工具用 pandas 也能灌适合数据量不大或者需要先做清洗的场景from sqlalchemy import create_engine import pandas as pd engine create_engine( mysqlpymysql://root:pass127.0.0.1:3306/addr_db?charsetutf8mb4 ) df pd.read_csv(region_4level.csv, dtypestr, encodingutf-8-sig, keep_default_naFalse) df[parent_code] df[parent_code].replace(, None) df[level] df[level].astype(int8) # chunksize 控制单批行数太大容易超过 max_allowed_packet df.to_sql(region, engine, if_existsappend, indexFalse, chunksize2000, methodmulti)charsetutf8mb4写在连接串里别指望数据库默认值。chunksize2000是个经验值再大容易触发max_allowed_packet报错报错信息还特别不直观只会告诉你连接断了。3.3 四级联动与逆序解析级联下拉是正向查询给定父级查子级一条 SQL 就够-- 查某市下所有区县按 sort_no 排 SELECT code, name FROM region WHERE parent_code 110100 ORDER BY sort_no, code;逆序解析是反过来的给定一个街道编码要还原出省/市/区/街道完整路径。MySQL 8.0 以后用递归 CTE 最省事WITH RECURSIVE up AS ( SELECT code, name, parent_code, level FROM region WHERE code 110105001 UNION ALL SELECT r.code, r.name, r.parent_code, r.level FROM region r JOIN up ON r.code up.parent_code ) SELECT * FROM up ORDER BY level;UNION ALL不能换成UNION后者会做去重排序白搭一次性能。终止条件是r.code up.parent_code匹配不到行所以父级必须是NULL而不是空串这和上面导入时的NULLIF是配套的。如果线上是 5.7 或者更早的版本没有 CTE两条路一是在应用层写循环一次查一级最多四次查询配合本地缓存其实很快二是用前面说的path字段把路径物化下来WHERE code ?查一次拿到path再按斜杠切分回表查名字。后者更适合高并发场景因为只有一次查询。def resolve_path(conn, code: str): 不依赖 CTE 的逆序解析逐级上溯最多 4 次查询 chain, cur [], code while cur: row conn.execute( SELECT code, name, parent_code FROM region WHERE code %s, (cur,) ).fetchone() if not row: break chain.append({code: row[0], name: row[1]}) cur row[2] return list(reversed(chain)) # 反转成 省-市-区-街道这段的关键在while cur的循环条件parent_code为NULL时cur变成None循环自然退出不需要额外的层级计数。如果担心脏数据造成死循环加一个len(chain) 5的保险超过就抛异常比默默转圈强。4. 地址解析把用户手填的一行字反查成行政编码真实业务里用户不会乖乖走四级下拉大部分订单的地址是一行自由文本某某路某某小区 3 栋。这一章解决的是怎么把这行字尽量准确地落回编码落到区县一级算合格落到街道一级算优秀。4.1 先切分再匹配别一上来就上模型很多人第一反应是上 NLP、上地址解析模型。对四级地址库这个场景属于杀鸡用牛刀而且效果未必好模型对生僻的街道名、新设的开发区几乎无能为力而你要的恰恰是精确命中一个编码。可行的路线是逐级收敛 最大匹配。先按行政级别从高到低匹配每匹配到一级就把候选集收缩到该节点的子节点里再匹配下一级。这样做的核心收益是消歧全国叫朝阳的区不止一个但一旦锁定了省和市候选就唯一了。切分的锚点是行政后缀省、市、区、县、旗、街道、镇、乡、开发区。先扫描这些后缀把文本切成若干段再从第一段开始往编码树里对。切不出来就直接走全串模糊匹配作为兜底。4.2 前缀树最大匹配的 Python 实现把地址库里的名字全部挂到一棵树上每个节点记录对应的编码和层级匹配时从根往下走能走多远走多远。from collections import defaultdict class TrieNode: __slots__ (children, code, level) def __init__(self): self.children defaultdict(TrieNode) self.code None # 该节点对应的行政区划编码 self.level 0 # 层级用于逐级收敛 def build_trie(rows): rows: [(code, name, level, parent_code), ...] root TrieNode() for code, name, level, _ in rows: node root for ch in name: node node.children[ch] node.code, node.level code, int(level) return root def match(text: str, root: TrieNode, max_level4): 正向最大匹配返回命中的编码列表按优先级 hits, i, n [], 0, len(text) while i n: node, j, best root, i, None # 从 i 开始尽量往后吃字符 while j n and text[j] in node.children: node node.children[text[j]] j 1 if node.code and node.level max_level: best (node.code, node.level, j) if best: hits.append(best) i best[2] # 跳到匹配结尾避免重叠命中 else: i 1 return hits__slots__是为了压内存地址库街道层几万个名字每个字一个节点用普通对象会吃掉不少内存。best记录的是当前起点能匹配到的最深节点也就是最长匹配这比最短匹配更准因为经济技术开发区和开发区同时在树里时前者应该优先。i best[2]这一句是最大匹配的关键命中之后直接把游标跳到匹配串的末尾避免某某街道里又匹配出某某这种重叠结果。真正的逐级收敛要在外面再包一层def parse_address(text, trie, conn): hits match(text, trie, max_level4) if not hits: return None # 按层级排序先省后市后区县再街道 hits.sort(keylambda x: x[1]) parent None for code, level, _ in hits: if parent is None: parent code if level 1 else None continue # 后一级的父编码必须等于前一级否则丢弃这次命中 row conn.execute( SELECT parent_code FROM region WHERE code %s, (code,) ).fetchone() if row and row[0] parent: parent code return parent那个row[0] parent的判断是整套逻辑的地基。没有它文本里随便出现的XX镇和前面匹配到的区县不在一个父级下就会被错误拼接最后给出一个根本不存在的路径。这种错误比完全匹配不上更可怕因为它看起来是对的。4.3 命中率不够时的兜底策略最大匹配跑完通常会有一批地址落在区县一级就下不去了或者干脆一条都没命中。这时候别急着上模型先看兜底手段够不够。第一种是别名表。街道改名、开发区合并、俗称替换这些靠算法推不出来只能靠人工维护一张alias表映射旧名/俗称 → 标准编码。这张表是长期资产每次线上出现解析失败就往里加一条几个月后命中率会明显爬升。第二种是编辑距离。针对长度较短的候选名做模糊匹配长度小于等于四的允许一次编辑大于四的允许两次。阈值别再放宽再宽就会出现XX街道匹配到XX街这种跨级别的错误结果。import difflib def fuzzy_match(name, candidates, cutoff0.8): candidates: 同一父级下的兄弟节点名字列表 return difflib.get_close_matches(name, candidates, n3, cutoffcutoff)cutoff建议从 0.8 起调往下调到 0.7 时会明显出现误匹配。get_close_matches内部用的是 Ratcliff-Obershelp 相似度对中文短串的表现尚可胜在零依赖。第三种是置信度分级。解析结果别只给一个编码同时给一个confidence精确命中记exact经过别名映射记normalized走编辑距离的记fuzzy。后续业务按置信度分流fuzzy的结果只用于统计不用于发货地址避免误改。5. 避坑排查地址库落地最常见的五类翻车这部分是踩过的坑汇总每一条都对应过一次线上排查。现象写得具体一点方便你对号入座。5.1 导入后中文全是问号现象SELECT出来中文正常但前端展示成???或者反过来数据库里存的就是问号应用层读出来也是问号。原因字符集在三层不一致。CSV 文件本身是 UTF-8客户端连接字符集是latin1表是utf8mb4中间任何一层对不上中文就会在转换时被替换成问号而且是不可逆的替换存进去就回不来了。解决三处都要显式指定。命令行加--default-character-setutf8mb4LOAD DATA语句里写CHARACTER SET utf8mb4JDBC 或 SQLAlchemy 的连接串里带charsetutf8mb4。建库时也要DEFAULT CHARSETutf8mb4别用utf8那个是三字节的遇到四字节字符部分生僻字在地址名里真有会直接报错。5.2 直辖市层级断裂下拉框选不出区县现象省选完之后市的下拉是空的或者市和区县重复出现两次。原因直辖市的行政层级和普通省份不一样省下面是区县中间没有地级市这一层。如果你的数据按省-市-区县三级硬铺直辖市的市级节点要么是空的要么被填成和省同名前端按固定三级去取自然取不到。解决两条路。一是在数据里给直辖市补一个虚拟市级节点名字和省级同名编码用省级前两位加0100这种规则补出来保证层级连续二是在前端做特判判断当前省级节点下有没有市级子节点没有就直接跳到区县。前者改动小后者数据干净。我一般选前者因为前端特判这种东西换个页面就得重写一遍。5.3 同名区县串数据地址跑到别的省现象用户明明选的是南边的区后台存下来的编码属于北边的同名区。原因查询时只按name匹配没带父级条件。全国同名的区县、同名的街道非常多只靠名字查必然串。解决所有按名字的查询强制带parent_code条件前端级联的时候把已选的上级编码一起传下去。匹配逻辑里也一样先锁定父级再匹配子级别在全表范围内按名字LIKE。这条规则要在代码评审里当红线卡因为它的错误在测试环境基本发现不了只有在数据量大的时候才暴露。5.4 六位码和九位码混存JOIN 出来是空现象街道查区县JOIN结果是空但两边的数据明明都在表里。原因一边存的是六位区县码另一边存的是九位街道码截断后的六位看着一样实际上因为补齐规则不同对不上。常见的是市级码存成了四位1101而区县的父级存的是六位110100字符串比较直接失败。解决入库前统一补齐到固定长度省市区一律六位街道九位。在表上加CHECK (CHAR_LENGTH(code) IN (6,9,12))做约束脏数据根本进不来。已经混了的写个脚本按第 2 章的parent_of重算一遍parent_code再回写。5.5 用户写的街道其实是社区名现象解析结果一直停在区县一级匹配不到街道但人工看地址明明写了街道。原因用户写的是社区或村委会的名字那属于十二位码不在四级范围内。或者写的是某个小区名压根不是行政区划。解决这种事没法靠四级库解决得接受街道不一定能落这个现实。做法是解析到哪一级算哪一级剩下的部分原样保留在详细地址字段里别硬凑。真要往下落得再引入一份社区级数据或者 POI 库那是另一个量级的工作。6. 增量更新与对账让地址库半年后还对得上地址数据不是一次性资产。行政区划会调整街道会合并开发区会新设一份导进去就不管的库半年后就会出现新地址查不到、旧编码对不上的情况。这一章讲怎么让更新这件事变得可控。6.1 版本快照与 diff最朴素也最有效的办法每次拿到的全量数据都按批次存一份快照表里加batch_no字段主表只保留最新批次。新旧两批做 diff产出一张变更表。-- 新增新批次有、旧批次没有 SELECT n.code, n.name, ADD AS change_type FROM region n LEFT JOIN region_old o ON n.code o.code WHERE o.code IS NULL; -- 删除旧批次有、新批次没有 SELECT o.code, o.name, DEL AS change_type FROM region_old o LEFT JOIN region n ON n.code o.code WHERE n.code IS NULL; -- 改名编码相同但名称不同 SELECT n.code, o.name AS old_name, n.name AS new_name, RENAME AS change_type FROM region n JOIN region_old o ON n.code o.code WHERE n.name o.name;三条查询覆盖了绝大多数变更。RENAME这一类最需要人工确认因为系统里的历史订单存的可能是旧名字改名之后按名字反查就会失败得靠别名表把旧名映射到新编码上去。这也是别名表除了纠错之外的第二个用途。DEL这一类要谨慎处理。编码消失通常意味着合并或者撤销直接删主表的行会让历史数据的外键失效。稳妥做法是加一个is_active标记逻辑删除物理行永久保留查询时统一过滤is_active 1。6.2 回归用例每次更新完跑一遍固定的回归用例比人眼核对靠谱得多。用例不用多覆盖几条关键路径就行。用例类型输入期望正向级联某省级编码返回的市级节点数大于 0逆序解析某街道编码路径深度为 4且每级父码连续别名映射已知旧街道名命中新编码置信度为 normalized边界直辖市的区县编码路径深度为 3不报错第一行那条用例是我一定会加的。曾经出现过一次数据包里某个省的市级节点整体缺失级联下拉直接空白而单看行数和总条数完全正常人眼根本发现不了。有了这条用例导入完跑一遍就知道。从那以后我每次拿到新批次的地址数据都强制走一遍快照 → 校验 → diff → 回归用例这四步哪怕只是多了一个街道也不跳过。地址这种数据的特点是错得很安静等到用户投诉包裹发错省成本已经出去了。如果你手上正好在做地址表单、订单归集或者物流分单需要一份带街道层的四级库打底这份数据可以直接拿去用字段和编码都是现成的按上面的表结构导进去就能跑。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?