简介:面向电商开发、物流系统对接与地理信息应用的开发者,这份资源提供一份覆盖全国省、市、区县、乡镇街道四级的行政区划数据,采用国家标准行政区划代码,字段包含代码、层级、名称与上级区划代码,便于按层级查询、地址填充与地区匹配。压缩包为 zip 格式,共 1 个文件,即单个 sql 脚本,体积约 351KB,导入 MySQL 等关系型数据库后即可直接建表查询,无需额外清洗。已有 1072 人学习下载。数据价值集中在街道一级的颗粒度:可用于订单收货地址自动补全与校验、配送范围划分、按地域做用户与市场分析,也能为地址模糊匹配、字典表维护提供基础参照。使用前建议结合本地行政区划变动作必要核对。
1. 一份地址库救不了烂地址:先把四级结构和国标代码说清
订单地址写着「××市××区××街道××社区 3 栋」,系统里最多只能匹配到区县,街道字段永远是空的——这种问题,换一份数据源往往比改十行匹配代码更有效。业内常说的淘宝地址库,本质是一份把省、市、区县、街道四级拉平、并挂上国家标准行政区划代码的对照表,街道这一级是它和普通省市库最大的差别。它适合做收货地址下拉、面单解析、运费模板分区、网点分单,也适合做数据清洗时的地名归一化;不适合拿来做 GIS 坐标计算,也不适合当成实时权威源——它是一份快照,会过期。下面按「表怎么建 → 文本怎么解 → 业务怎么接 → 哪儿会翻车 → 怎么长期维护」的顺序拆。
2. 建表与导入:把省市区街道四张表塞进 MySQL
拿到手的资源通常是 CSV 或 SQL dump,字段各家不太一样,但骨架就那几列。直接LOAD DATA灌进去能跑,跑两个月就会遇到父级找不到、代码前导零丢失、同名街道串台。这一章先把表结构和导入校验做扎实,后面解析才有地基。
2.1 字段设计:code、name、parent_code、level 四个不能省
四级地址库最容易被简化坏的地方,是把code建成INT。国标行政区划代码是 6 位数字,街道级普遍用 12 位统计用区划代码,省级代码形如110000,前两位有效、后四位补零。一旦存成整型,110000变110000还好,但区县级的110101没问题,真正的坑在街道级的110101001001这种 12 位串——超过INT范围,也丢前导零。所以code必须是CHAR(12),导入时统一zfill(12)。
第二容易被省掉的是parent_code。有人用自增id做父子关联,单次导入没问题,等到第二年拿到新版区划要合并时,自增 id 全漂移,历史订单里的关联就断成一地鸡毛。用code做逻辑父键,id只做物理主键,是更稳的做法。
level用来区分层级,1 省、2 市、3 区县、4 街道。它的价值不在于展示,而在于查询时能快速切断递归:想拿「某省下所有街道」,不用递归 CTE,level=4 AND code LIKE '11%'就够,配合索引很快。
其余字段按需加:short_name存「北京」「朝阳」这类简称,别名匹配时用得上;pinyin和initial给前端下拉做首字母检索;status标记现行/已撤销,这是后面处理历史地址的关键;version记录这批数据属于哪次导入。建表语句大致如下:
CREATE TABLE region ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, code CHAR(12) NOT NULL COMMENT '国标/统计用区划代码,左补零到12位', name VARCHAR(64) NOT NULL COMMENT '标准名称,如 朝阳区', short_name VARCHAR(32) DEFAULT NULL COMMENT '简称,如 朝阳', parent_code CHAR(12) NOT NULL DEFAULT '000000000000' COMMENT '父级code,省级为全0', level TINYINT NOT NULL COMMENT '1省 2市 3区县 4街道', status TINYINT NOT NULL DEFAULT 1 COMMENT '1现行 0已撤销', version VARCHAR(16) NOT NULL COMMENT '数据版本,如 2024a', PRIMARY KEY (id), UNIQUE KEY uk_code_ver (code, version), KEY idx_parent (parent_code, level), KEY idx_name (name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='四级行政区划对照表';提示:
uk_code_ver用(code, version)而不是code单列,是为了让多版本共存。想只保留一份现行数据,就把version从唯一键里去掉,改成单列唯一。
2.2 导入脚本:从 CSV 到库里的批处理与校验
原始文件常见两种:一种是四级拉平的一张大表,每行一个节点;另一种是省市区县一张、街道一张。不管哪种,导入前先做三件事——补零、去空白、校验父子关系。用pandas读、pymysql批量写,单机导几十万行街道数据几分钟能完。
import pandas as pd import pymysql # 源文件列名按实际调整,这里假设已是拉平结构 COLS = ["code", "name", "parent_code", "level"] def load(path): df = pd.read_csv(path, dtype=str, keep_default_na=False) df = df[COLS].copy() # 统一补零到 12 位:6 位国标码补成 110000000000 这种形式 df["code"] = df["code"].str.strip().str.zfill(12) df["parent_code"] = df["parent_code"].str.strip().str.zfill(12) df["level"] = df["level"].astype(int) # 名称去全角空格与首尾空白,避免 "北京市 " 和 "北京市" 变成两条 df["name"] = df["name"].str.replace("\u3000", "", regex=False).str.strip() return df def validate(df): errs = [] # 1) code 重复 dup = df[df.duplicated("code", keep=False)] if len(dup): errs.append(f"重复code {len(dup)} 条,示例:{dup['code'].head(3).tolist()}") # 2) 父级必须存在(省级 parent_code 为全 0) codes = set(df["code"]) orphan = df[(df["level"] > 1) & (~df["parent_code"].isin(codes))] if len(orphan): errs.append(f"孤儿节点 {len(orphan)} 条,示例:{orphan['name'].head(3).tolist()}") # 3) level 与 code 有效位一致性:省级后 10 位、市级后 6 位、区县后 6 位应为 0 bad = df[((df["level"] == 1) & (df["code"].str[2:] != "0" * 10)) | ((df["level"] == 2) & (df["code"].str[6:] != "0" * 6))] if len(bad): errs.append(f"层级与代码不匹配 {len(bad)} 条") return errs def write(df, conn, batch=5000): sql = ("INSERT INTO region(code,name,parent_code,level,version) " "VALUES(%s,%s,%s,%s,%s) " "ON DUPLICATE KEY UPDATE name=VALUES(name), parent_code=VALUES(parent_code)") rows = [(r.code, r.name, r.parent_code, r.level, "2024a") for r in df.itertuples()] with conn.cursor() as cur: for i in range(0, len(rows), batch): cur.executemany(sql, rows[i:i + batch]) conn.commit() if __name__ == "__main__": df = load("./region_4level.csv") problems = validate(df) if problems: raise SystemExit("数据校验未通过:\n" + "\n".join(problems)) conn = pymysql.connect(host="127.0.0.1", user="root", password="***", database="addr", charset="utf8mb4") write(df, conn) print(f"导入完成,共 {len(df)} 行")几个参数值得单独说。batch=5000是经验值,太小网络往返开销大,太大容易撞max_allowed_packet,五千到一万之间比较稳。ON DUPLICATE KEY UPDATE让脚本可以重跑,配合唯一键做幂等,比每次TRUNCATE再灌要安全——中间出问题不至于把线上表清空。validate里第 3 条规则是简易版,只校验省、市两级,区县和街道的代码规则各地不完全统一,硬校验容易误杀,把它降级成「告警」而不是「阻断」更合理。
注意:导入前务必在事务里跑,或者先灌临时表
region_tmp,校验通过再RENAME TABLE切换。直接往生产表灌,一旦中途报错,表就处于半截状态。
2.3 索引与查询:为什么 parent_code 上必须有索引
地址库的查询几乎全是「按父级取子集」:前端下拉先取省,选完省取市,选完市取区县,选完区县取街道。这类查询的固定形态是WHERE parent_code = ? AND level = ?,所以(parent_code, level)联合索引是必须的,单列索引在数据量大时会被优化器放弃。
第二个高频场景是按名称模糊搜。LIKE '%朝阳%'走不了索引,全表扫几十万行也还行,但并发一上来就顶不住。常见做法是建name前缀索引,把%放右边,配合前端输入首字提示;实在要做任意位置匹配,就上ngram全文索引,或者在应用层维护一份拼音/首字母的内存字典,用 Trie 做前缀检索,把压力从数据库挪到进程内。
还有一个容易忽略的点:查「某省全部街道」时,code LIKE '11%'这种前缀匹配,只有在code是CHAR且排序规则是utf8mb4_bin或者ascii_bin时才稳定走索引。默认的utf8mb4_general_ci在某些版本下会把CHAR当字符串比较,前缀匹配的效率会打折。数据量百万级以上时,这个差别能看出来。
3. 地址解析:从一段自由文本还原到街道级
库建好了,真正难的是用户填的那串东西。有人写「北京市朝阳区望京街道」,有人写「北京朝阳望京」,有人写「朝阳区望京SOHO T1」,还有人把省市区全塞在一个输入框里。解析器的目标不是 100% 命中,而是在可控的时间复杂度内,给出「匹配到哪一级 + 置信度多少」,让业务决定是直接采用还是转人工。
3.1 前缀树(Trie)匹配:为什么不用 LIKE '%街道%'
最直观的做法是对每个地名跑一次LIKE,四级就是四次全表扫。一条地址四次,一万条订单四万次,基本没法用。更糟的是LIKE '%朝阳%'会同时命中「朝阳区」和「朝阳街道」「朝阳镇」,谁先返回取决于执行计划,结果不可复现。
把四级名称按层级顺序插进 Trie,匹配时从文本左端滑动窗口做最长匹配,复杂度只和文本长度、最长词长有关,和库里有几百万条无关。构建一次,常驻内存,单次解析在毫秒级。
class RegionTrie: """按层级组织的简易前缀树,key 是地名,value 是 code 与子节点""" def __init__(self): self.root = {} def add(self, names, code): # names 是从省到当前级的名称列表,如 ["北京市","朝阳区","望京街道"] node = self.root for n in names: node = node.setdefault(n, {"__children__": {}})["__children__"] node["__code__"] = code def longest_match(self, text, start, max_len=8): node, best, i = self.root, None, start while i < len(text) and (i - start) < max_len: ch = text[i] # 逐字下探,命中则记录,未命中提前退出 if ch not in node: break node = node[ch]["__children__"] if "__children__" in node[ch] else node[ch] i += 1 if "__code__" in node: best = (text[start:i], node["__code__"], i) return best这段代码里max_len=8是防止「新疆维吾尔自治区」这类长名称被截断,实际可放到 12。longest_match返回三元组:匹配到的词、对应 code、结束位置。解析时先从头找省,命中后从结束位置继续找市,逐级推进;某一级失败就跳过去找下一级,最后按命中层级给分。Trie 的节点用 dict 存,几万条地名占用的内存大概几十兆,单机完全扛得住。
3.2 降级匹配与置信度:匹配不上时的三级回退
真实地址里缺级太常见。有人不写省只写市,有人直辖市写「北京朝阳」把「市」和「区」都省了,有人街道写成「望京街道办」。解析器要有回退策略,不能一级失败就整体判死。
我一般的做法是三级回退。第一级,全量顺序匹配,四级全中就记 100 分。第二级,省失败时跳过省,从市级开始匹配,命中后按「缺省级」扣 15 分;市失败同理。第三级,区县或街道失败时,把已匹配的前缀从文本里剔掉,剩下的部分交给关键词匹配,在已确定父级的子集里做一次模糊查找,命中记 60 分,没命中记 0 分并标记「待人工」。
置信度不是摆设,它决定了后续动作。90 分以上直接写库;70 到 90 之间写入但打「待复核」标记,让客服抽检;70 以下不进地址表,只留原文进原始表。这套阈值不是拍脑袋,是拿几百条真实订单跑出来的,不同业务线的数据质量差别很大,阈值要按自己的样本重新调。
这里还有个血泪经验:不要用「匹配到的字符数占总长度的比例」当置信度。地址里往往带小区名、楼栋号、手机号,长度噪声太大,「北京市朝阳区」六个字命中,后面跟三十个字的小区描述,比例就掉到 20%,但实际是完全正确的匹配。按层级命中数给分,比按字符比例稳得多。
3.3 归一化预处理:全角、别名、缺省直辖市前缀
匹配之前先做归一化,这一步做不好,后面全是玄学。
第一类是全角半角与空白。用户从微信复制的地址常带全角空格\u3000、全角括号、不间断空格,strip()处理不掉。统一替换成半角,再压缩连续空白。
第二类是别名。行政区划名称在口语里有大量变体:「北京市」和「北京」,「朝阳区」和「朝阳」,「内蒙古自治区」和「内蒙」,「广西壮族自治区」和「广西」。做法是维护一张alias表,alias_name → code,归一化时先把长名称替换成标准名称,再进 Trie。别名表要人工维护一部分,剩下靠日志挖——把匹配失败的原文捞出来聚类,前十里总有几个高频别名。
第三类是直辖市和省直辖县级市。直辖市的二级节点在统计口径里常常是「市辖区」这种虚拟层,用户不会写「北京市市辖区朝阳区」。处理办法是把「市辖区」「县」这类虚拟层在解析时跳过,不参与匹配,但保留在库里维护树结构完整。省直辖的县级市(比如某些省直接管的市)在树里挂省下、层级算市级,解析时不能按「省-市-区」三级硬套。
ALIAS = { "北京市": "北京市", "北京": "北京市", "内蒙": "内蒙古自治区", "内蒙古": "内蒙古自治区", "广西": "广西壮族自治区", "宁夏": "宁夏回族自治区", } VIRTUAL = {"市辖区", "县", "省直辖县级行政区划"} def normalize(text): text = text.replace("\u3000", " ").replace("(", "(").replace(")", ")") text = "".join(text.split()) for k, v in sorted(ALIAS.items(), key=lambda x: -len(x[0])): text = text.replace(k, v) return textsorted(..., key=lambda x: -len(x[0]))这一句别省,否则「内蒙古」先被替换,「内蒙古自治区」就再也匹配不上了,长词优先是别名替换的铁律。VIRTUAL集合在逐级推进时用来跳过虚拟层,不做匹配但也不阻断流程。
4. 业务衔接:运费模板、面单与地址快照
地址库本身只是数据,价值在它接上业务之后。这一章讲三个真实衔接点:街道级地址怎么影响物流分单,订单地址为什么必须存快照,以及区划版本升级时怎么不把历史数据搞崩。
4.1 街道级地址对物流分单的意义
只到区县级的地址库,做运费模板只能按区县划片,同区县里城区和偏远乡镇一个价,成本会失真。做到街道级之后,可以按街道绑定网点、时效、是否偏远、是否加价。某公司的实际做法是维护一张street_ext扩展表,用街道 code 关联网点 ID 和偏远标记,运费计算时先用地址解析出街道 code,再查扩展表,查不到就回退到区县规则。
-- 街道级扩展:一个街道可能对应多个网点,按优先级取第一个 SELECT s.code, s.name, e.network_id, e.is_remote, e.extra_fee FROM region s LEFT JOIN street_ext e ON e.street_code = s.code AND e.enabled = 1 WHERE s.code = '110105026000' AND s.level = 4;is_remote这类标记不是地址库自带的,得自己补。补的办法是拿历史签收时效数据反推:同一区县内,某些街道的平均签收时长明显高于中位数,基本就是偏远或山区。这条规则比拍脑袋划片区靠谱,也经得起复盘。
4.2 订单地址快照:为什么不建议外键关联
新手最容易犯的错,是订单表里存一个region_code,展示时JOIN region取名称。上线第一年没事,第二年区划调整,某个县改成区,代码作废,region表里那条记录被更新或删除,历史订单的收货地址就变了——用户明明寄到「××县」,订单详情里显示「××区」。这在客诉和对账里是灾难。
正确做法是订单里存快照:province_name、city_name、district_name、street_name四个文本字段,外加四个 code 字段,全部冗余。展示只读快照,不 JOIN。region表只服务于「下单时的下拉选择」和「解析时的对照」,不参与历史数据展示。
CREATE TABLE order_address ( order_id BIGINT NOT NULL, province_code CHAR(12), province_name VARCHAR(64), city_code CHAR(12), city_name VARCHAR(64), district_code CHAR(12), district_name VARCHAR(64), street_code CHAR(12), street_name VARCHAR(64), detail VARCHAR(255) COMMENT '小区、楼栋、门牌', region_version VARCHAR(16) COMMENT '下单时使用的地址库版本', PRIMARY KEY (order_id) );region_version这列很多团队不加,出问题时才知道有用。它能回答「这单当时是按哪版区划下的」,配合版本表就能还原当时的匹配上下文。
4.3 与国标代码对账:版本升级时的兼容处理
新版地址库到手,第一件事不是导入,是对账。拿新旧两版的 code 集合做差集,分出三类:新增节点、删除节点、名称变更节点。删除节点最危险,往往对应撤县设区、合并乡镇。
处理原则是「只追加、不物理删除」。旧 code 保留,status置 0,同时在新 code 上打一个replaced_by字段指向后继。解析时如果命中旧 code,自动跳到replaced_by,展示名称用旧的(尊重历史),但路由和运费计算用新的。这样历史订单不受影响,新订单走新规则。
对账脚本的核心就三段 SQL:新增、消失、同名不同 code。跑完之后人工过一遍消失列表,一般几十条,能肉眼确认哪些是真正的撤销、哪些是数据源漏抓。这一步不做,等到线上出现「地址匹配不到」再回头查,成本高十倍。
5. 避坑清单:五个让地址库翻车的现场
这一章全是踩过的坑,按「现象 → 原因 → 解决」写。每一条都能在你导入第一版数据之后的三个月内遇到。
5.1 撤县设区与代码作废
现象:老订单详情页里,收货地址从「××县」变成了空白,或者匹配日志里突然出现一批「区县未命中」。
原因:新版本数据源只保留了现行区划,撤销的县被整条删除,历史订单里存的 code 在库里查不到了。
解决:导入时永不物理删除,用status=0标记失效,加replaced_by指向新 code。解析器命中失效节点时自动沿replaced_by走一跳,展示仍用原名。同时把「消失的 code 列表」作为每次升级的必查项,人工确认后再发布。
5.2 同名街道与「××街道××社区」
现象:用户明明在广州,解析出来的街道却在另一个省,运费算错,面单打错网点。
原因:街道名重复率极高,「城关街道」「新华街道」这类名字全国几十个,只按名称匹配必然串台。
解决:匹配必须带父级约束。逐级推进时,市级确定后,区县匹配只在「该市子集」里找,街道匹配只在「该区县子集」里找。Trie 的节点也要按父级分片建,不能全局一张平表。另外「××街道××社区」这种串写要单独处理,社区名不进地址库,匹配时把「社区」后缀截掉再找街道。
5.3 直辖市与省直辖县级市
现象:北京的地址解析出来,市级是「市辖区」,前端下拉显示成「北京市 / 市辖区 / 朝阳区」,用户一脸问号。
原因:统计口径里直辖市二级是虚拟层,直接照搬库结构就会暴露给用户。
解决:库里保留虚拟层保证树完整,展示和解析时过滤。给region加一个is_virtual标记,前端下拉查询加AND is_virtual = 0。省直辖县级市同理,树里挂省下、层级按市级处理,不要硬凑「省-市-区」三级。
5.4 开发区、管委会这类「非标准」层级
现象:地址写「××新区」「××开发区」「××管委会」,库里完全匹配不到,解析直接掉到 0 分。
原因:这些不是标准行政区划,很多是经济功能区,代码体系里根本没有对应条目。
解决:建一张alias_region映射表,把功能区名称映射到所属行政区县的 code,解析时先查别名表再进 Trie。映射关系需要人工维护,但量不大,一个地级市通常也就十几个。不要试图把开发区塞进region表当第四级,那会破坏层级一致性,后面统计口径全乱。
5.5 数据质量:空街道、乱填街道
现象:落库数据里街道字段大量为空,或者全是同一个占位值,统计分单时完全没法用。
原因:前端下拉没做四级联动,街道选项没加载出来用户就提交了;或者为了省事给了「其他」选项,用户全选它。
解决:采集端强制联动,区县未选时街道下拉禁用;街道允许为空但必须显式勾选「暂不填写」,落库时打标记。落库前做一次 code 有效性校验,level=4的 code 必须能在表里查到且status=1,否则拒绝写入并记日志。这一步加上之后,脏数据比例能从两位数掉到个位数。
6. 增量更新与自检:把地址库当成有版本的数据资产
地址库不是导一次就完事的东西,每年都有区划调整,一年不更新就会开始出现匹配失败。把它当成有版本的数据资产来管,比每次出问题临时找新数据要省事得多。
6.1 版本表与变更留痕
单独建一张region_version表,记录每次导入的来源、发布时间、行数、校验和、导入时间。每次导入走「临时表 + 校验 + 影子表切换」三步,切换前把旧版本整体归档一份,别只留 diff。存储成本很低,几十万行压缩完就几十兆,出事时能救命。
CREATE TABLE region_version ( version VARCHAR(16) PRIMARY KEY, source_note VARCHAR(255) COMMENT '数据来源说明,不写具体链接', publish_date DATE, row_count INT, checksum CHAR(32) COMMENT '按 code+name 排序后拼接的 md5', imported_at DATETIME DEFAULT CURRENT_TIMESTAMP );checksum用code+name+parent_code排序后拼串算 md5,两次导入如果 checksum 一样,说明数据没变,可以跳过后续的对账和回归,省不少事。
6.2 自检脚本:四类必查异常
每次导入后跑一遍自检,四类查询固定不变,我一般直接存成.sql文件,用命令行批处理。
| 检查项 | 判定条件 | 处理方式 |
|---|---|---|
| 孤儿节点 | level>1且parent_code不存在 | 阻断,回查数据源 |
| 层级断裂 | 省下无市、市下无区县 | 告警,人工确认 |
| 叶子为空 | level<4且无子节点 | 告警,可能是数据漏抓 |
| 同名同父 | 同parent_code下name重复 | 阻断,去重后再导 |
-- 孤儿节点 SELECT c.code, c.name, c.parent_code FROM region c LEFT JOIN region p ON p.code = c.parent_code AND p.version = c.version WHERE c.level > 1 AND p.id IS NULL; -- 同名同父 SELECT parent_code, name, COUNT(*) n FROM region WHERE version = '2024a' GROUP BY parent_code, name HAVING n > 1;第一段 SQL 我用得最多,因为数据源合并时最容易漏父级。第二段能揪出「同一个市下面两个同名区」这种源数据错误,遇到过两次,都是抓取时把两个不同来源拼一起了。跑完这两条,再抽查十个随机 code 在业务系统里走一遍解析,基本就能确认这版数据可用。
从那以后我每次接地址相关的需求,都先把版本表和这四条自检跑一遍再谈业务逻辑,宁可多花半小时,也不想在客诉群里解释为什么地址突然对不上。希望帮到你。
本文还有配套的精品资源,点击获取