简介:全球国家、省/州、城市三级行政区划数据以XML格式封装,同时提供中文与英文两个独立文件,使用者可根据项目语言环境直接选用。数据覆盖范围广,层级关系完整,便于程序化解析与二次加工。压缩包内共2个XML文件,整体体积仅99KB,轻量紧凑,适合嵌入各类系统或作为离线数据字典使用。已有1527人学习或下载,说明它在实际开发中具备较高参考价值。内容按国家、省/州、城市层级清晰组织,可直接生成地区选择器、行政区划下拉列表,也能用于电商配送范围校验、业务区域统计、系统初始化填充等场景。对于需要标准化全球地理数据的后端开发者、前端工程师或数据分析人员,这份资源提供了即取即用且免维护的基础数据,省去自行搜集整理的繁琐过程。
1. 全球国家、省/州、城市的数据库(中英版):先解决“地名打架”这个老大难
如果你做过跨境电商结算、国际物流运费计算或者 CRM 客户地址清洗,一定见过这种场景:同一个“加利福尼亚州”,有人填 California、有人填 CA、有人填美西、还有人直接写洛杉矶。一到做区域聚合统计,同一笔订单可能被分到三个不同的大区,报表对不上,仓库发货也不知道该走哪个口岸。我前几年接一个跨境结算项目,花了两周去治理地址数据,最后发现问题的根源不是业务逻辑,而是缺一份标准的全球国家、省/州、城市层级数据,并且要中英文双语对照。
这里说的“数据库”,并不是某个现成付费 API 的接口文档,而是一套你可以自己落地、维护、扩展的行政区划主数据:国家/地区一级、省州一级、城市一级,每条记录同时带英文名和中文名,并且保留稳定的父子关系。它的价值在于:让订单、仓库、物流、财务看到的是同一个地名编码,而不是各写各的字符串。适合谁用?适合正在搭建国际化后台、准备做全球物流路由或者需要把历史中文地址翻译成英文来对接海外系统的开发者和数据工程师。这篇文章从模型设计讲到清洗脚本,再讲到避坑,目标只有一个:让你花一两天跑通一份能投入生产环境的全球省州城市数据库。
2. 先定模型再谈数据:中英双语的层级结构怎么存
很多新手拿到全球地名数据,第一反应是建三张表:国家表、省份表、城市表,再用外键关联。这个设计看起来规范,实际用起来很别扭。全球行政区划不是严格的“国家-省-城市”三级,有的国家没有省,有的城市直属中央(比如某些直辖市、特别行政区),有的国家用“州”而不用“省”,还有的国家城市下面还有更大的都会区。如果按固定三级建模,要么留下一堆空字段,要么为了兼容特殊情况创造各种特殊记录,越改越乱。
我更推荐的做法:单表平铺,每一条记录是一个行政区域,用 parent_id 表示层级关系,再用一个冗余的 path 字段记录从根到当前节点的路径。这样表结构不限制层数,查询时也能很快拼出“某国某省某市”的完整路径。下面是一份可以直接落地的建表 SQL(以 PostgreSQL 为例,MySQL 也能兼容)。
create table region ( id bigserial primary key, parent_id bigint references region(id), level smallint not null, -- 1=国家/地区 2=省/州 3=城市 4=区县(可选) alpha2 char(2), -- 国家二字码,仅国家级记录使用 name_latn varchar(120) not null, -- 英文/拉丁字母地名 name_zh varchar(120), -- 中文简体地名 name_zh_tw varchar(120), -- 中文繁体,可先留空 path varchar(500) not null, -- 冗余路径,如 /US/CA/SF sort_order int default 0, -- 同级排序用 is_active boolean default true, created_at timestamptz default now(), updated_at timestamptz default now() ); create index idx_region_parent on region(parent_id); create index idx_region_path on region(path); create unique index idx_region_latn_parent on region(parent_id, name_latn);这里我把层级和名称全部平铺在一张表里,因为实际查询永远是“根据国家定位省、根据省定位城市”,单表 JOIN 自己一次就能拿全。parent_id 指向上一级记录的 id,level 用于区分类型,name_latn 存官方英文名,name_zh 存中文名。alpha2 只在国家级记录上有值,省份和城市为空,避免冗余存储。
2.1 一张表还是三张表:按查询场景决定层级存储
如果你的系统每天有海量订单按国家-省-城市维度做聚合分析,一张表有两个明显优势。第一,查询不需要跨表 JOIN,一条 SQL 就能拿到“国家+省+城市”三级的全部名称;第二,遇到“城市直属于国家”这种特殊情况(比如某些全球化程度高的城市型经济体),不需要在省份表里塞一条假记录。用一张表,你只需要把城市的 parent_id 指向国家即可,查询时 path 字段同样能拼出完整路径。
三张表也有它的适用场景:当你的业务系统里国家、省、城市三组数据更新频率差异极大,例如国家一年不更新、城市每周新增,分开建表可以减少写锁冲突。但绝大多数内部系统一个月的写入量也不大,锁冲突可以忽略。我的习惯是:如果这个数据只服务行政区划查询,一律单表;如果它同时承载了销售区域、仓库关联、税费规则等多个业务外键,那就拆成三张表配合视图使用,因为业务对象绑定的稳定性比查询方便更重要。
2.2 中英双字段的取舍:拼音、官方名、常用名怎么放
中英双语版本最容易翻车的地方,是“英文名”到底存在哪个字段。这里要区分三种英文名:官方罗马字名(比如 Tokyo)、拉丁转写名(比如日本城市的中文拼音版本)、以及本地常用的英文别名(比如科隆在英文里写成 Cologne,但本地拼写是 Köln)。工业界最稳妥的模式是只存官方拉丁名,加上一个别名表单独处理常用名。把别名强行塞进主表,会让“这个字符串是标准名还是曾用名”永远说不清。
我一般这样处理:name_latn 里放的是这个地名在国际通用数据源中的官方英文名,不做任何翻译;name_zh 存中文简体,来自国内公开地图数据对照;未来如果有繁体需求,把 name_zh_tw 填好即可。早期如果拿不到完整的繁体数据,宁可让字段为空也不要用简繁转换工具自动转,因为“洛杉矶”这种音译词转换之后会出现不符合港澳台习惯的结果。
2.3 建立序号与层级路径:给数据一个稳定的查询入口
path 字段是另一个关键决策。我见过很多团队只用 parent_id 递归查询,结果每次拼树都要写递归 CTE,性能一般且代码难维护。用 path 字段存储从根到自身的 ID 序列,比如 /1/23/456,或者更直观地存代码序列 /US/CA/SF,查询时直接 LIKE 'US/%' 即可拿出整个州。代价是更新时如果某个上级节点的 ID 变化,所有子节点都要跟着改,所以这个方案的前提是 ID 一旦生成绝不变更。
建议 path 字段用国家二字码、省州缩写、城市代码拼接,而不是用数字 ID,因为翻译代码比翻译 ID 更容易排查问题。比如看到 /CN/GD/SZ,即使不看对应表,也能猜到是深圳。注意,拼进去之前必须先确保上一级记录已经存在,否则会出现 /CN/GD/SZ 但 GD 记录缺失的脏数据——这一点会放到第 5 章避坑部分详细讲。
3. 数据从哪来:清洗开源数据集并补全中文翻译
行政区划数据的获取没有银弹。目前大多数开源离线地名数据,英文数据覆盖较好,但中文翻译要么缺失、要么用的是旧名称不一致的翻译。我的做法是:主数据用一份稳定的开源全球行政区划数据集,中文翻译单独维护一份映射表,在导入阶段做关联合并。顺序一定是“先英文主数据、后中文翻译、最后校验”,而不是反过来。
具体执行时,先把某个知名开放地理项目的国家、省州、城市导出包下载下来,选择合适的一级行政区层级数据,转成 CSV。需要留意的是这类导出数据的文件名、字段名随版本变化较大,建议单独写一行脚本固化字段名映射,别每次手动处理。下面是一段可复制的清洗流程。
3.1 主数据源的清洗逻辑:去重、排序、父级编码
先说数据源选择。我通常选择 shapefile 或 csv 格式的全球边界数据,重点关注包含行政区代码和当地语言名称的字段。数据量不大,国家二百多、省州大概四千、城市几十万,单机处理完全足够。导入之前先做三件事:字段裁剪、字符集转 UTF-8、删除非行政区记录(比如把“海域”“争议地区”留在表外,避免业务上误用)。
import csv import unicodedata def clean_region_row(raw: dict) -> dict: """把原始导出行洗成标准结构""" name_latn = (raw.get("name_en") or raw.get("name") or "").strip() name_zh = (raw.get("name_zh") or "").strip() if not name_latn: return None # 归一化掉变音符号,避免 Köln 和 Koln 分裂成两条 name_latn = unicodedata.normalize("NFKD", name_latn) name_latn = "".join([c for c in name_latn if not unicodedata.combining(c)]) # 去掉多余的空白字符 name_latn = " ".join(name_latn.split()) return { "id": raw.get("id"), "parent_id": raw.get("parent_id") or "", "level": int(raw.get("level") or 0), "alpha2": (raw.get("alpha2") or "").strip().upper(), "name_latn": name_latn, "name_zh": name_zh, "code": raw.get("code") or "", } def load_regions(path: str): rows = [] with open(path, encoding="utf-8") as f: for row in csv.DictReader(f): cleaned = clean_region_row(row) if cleaned: rows.append(cleaned) return rows这段代码做三件事:先把英文名和中文名取出来,然后用 NFKD 归一化去掉西文中常见的变音符号,最后统一空白。参数说明里最重要的是 level 映射,不同数据源对层级的定义不一样,你必须在加载前核对好:1 级是国家,2 级是省州或同级别行政单位,3 级是城市。如果源数据里 2 级叫“县”而 3 级叫“市”,顺序要摸清楚再导,否则后面查询全错。
3.2 中文翻译的三种常见做法:人工、批量翻译、从多语言数据对齐
拿到英文主表之后,接下来是给每条记录补上 name_zh,这是整个项目里最花时间的环节。有适合轻量落地的方式。首选做法:从公开地图服务或离线地名库拿中文名称对照。注意这些数据的许可协议一定要注意,部分来源只允许个人学习,企业商用须确认授权。以合规为准,避免项目上线上线才发现有版权风险。
第二种做法是用公共翻译接口批量翻译后人工抽检。翻译接口对地名支持得并不差,但仍可能把“Victoria”在加拿大翻译成“维多利亚”,在香港翻译成“维克托里亚”这种明显错误。所以批量翻译之后必须抽检。第三种做法,找一个已有的中文版世界行政区划表做按名对齐——用英文名精确匹配加人工修正。
我自己惯用的组合是:英文名精确匹配为主,翻译接口补漏;剩余没有任何匹配记录的,标成“未翻译”前移出正式表,人工处理。宁缺毋滥,一个名称翻译错了,造成的系统地址匹配混乱比缺失更难排查。
3.3 落地检查:如何判断翻译质量达标
翻译质量没有统一标准,但至少有几个硬性指标。第一,国家名百分之百有中文,省州名中文覆盖率不低于 95%,城市名不低于 80%。低于这个水平,前端选择器直接露馅。第二,抽样人工验证:随机抽 50 条记录,和公开地图数据对照,一条不符就继续修。第三,做一个简单的一致性校验:中文名重复或英文名重复的记录要能查出来,比如“San Jose”在美国和哥斯达黎加都存在,父级 ID 不同就是正常,父级相同还在重名就要警觉。
这套检查不用写复杂工具,一条 SQL 就能看到问题数据。把结论记成自检清单,每次更新数据源之后按清单跑一遍,项目更新效率会高很多。
4. 把省州城市数据跑起来:入库流程与验证脚本
模型定好、数据清洗完,接下去就是入库和验证。这里以 Python 加 SQLite 为例做演示,因为你不需要搭建一个大数据平台,先把流程跑通,生产环境再切换到 PostgreSQL 或者 MySQL 即可。SQLite 的好处是单个文件就能直接测试,全量数据几百兆以内都能轻松承载。
入库不是简单逐行 INSERT。按照“先父后子”的顺序写入,才能保证外键和 path 字段正确。一个常见的错误是 CSV 里父级和子级混在一起,顺序不稳定,所以入库前先按 level 排序,再重复一次“检查父级 id 是否存在”的操作。下面这段脚本会把清洗后的 CSV 装进 region 表,并自动生成 path。
4.1 初始化表结构与索引
执行建表之前,先把上一版数据清空,避免主键冲突。如果是首次导入,直接建表。下面是一个适合测试的轻量表结构,和产出同构,字段比第 2 章的少了一些业务字段。
drop table if exists region; create table region ( id integer primary key autoincrement, parent_id integer, level integer not null, alpha2 text, name_latn text not null, name_zh text, path text not null ); create index idx_region_parent on region(parent_id); create index idx_region_path on region(path);这里故意把 parent_id 设为普通索引,因为 SQLite 里外键默认不开启。正式环境建议保留第 2 章的外键约束,但这会带来一个现实问题:清洗后的数据一旦有一行父级指向一个不存在的记录,import 直接报错,所以表结构本身不是越严格越好,关键是导入前把脏数据过滤干净。
4.2 清洗导入脚本:先父后子,生成路径
import sqlite3, csv, sys def import_regions(csv_path: str, db_path: str): regions = load_regions(csv_path) # 用 3.1 节的读取函数 regions.sort(key=lambda r: r["level"]) # 先国家、再省州、最后城市 conn = sqlite3.connect(db_path) cur = conn.cursor() cur.executescript(""" drop table if exists region; create table region ( id integer primary key autoincrement, parent_id integer, level integer, alpha2 text, name_latn text, name_zh text, path text ); """) id_map = {} # 原数据 id 到数据库自增 id 的映射 for r in regions: parent_id = id_map.get(r["parent_id"]) if r["parent_id"] else None if r["parent_id"] and parent_id is None: print(f"[跳过] 父级缺失: {r['name_latn']} 的 parent_id={r['parent_id']}") continue base = "" if parent_id is None else get_path(cur, parent_id) code = r["code"] or r["name_latn"][:2].upper() path = f"{base}/{code}" cur.execute( "insert into region(parent_id, level, alpha2, name_latn, name_zh, path)" " values (?,?,?,?,?,?)", (parent_id, r["level"], r["alpha2"], r["name_latn"], r["name_zh"], path) ) id_map[r["id"]] = cur.lastrowid conn.commit() conn.close()两点值得说明。parent_id 必须经过 id_map 转换,原始数据源自己的主键和我们数据库自增 id 没有关系,直接复制过来会到造成层级错乱。path 的生成依赖父级 path,用 get_path 从库里把父级路径查出来拼上当前代码,这里有一个隐含条件是父级必须已插入,所以前面按 level 排序很关键。
get_path 的实现也很简单:
def get_path(conn, region_id: int) -> str: cur = conn.cursor() cur.execute("select path from region where id = ?", (region_id,)) row = cur.fetchone() return row[0] if row else ""如果父级路径读不出来,孩子的 path 就是空的,后续查询会出现所有城市都挂在根下的问题。实际项目中,path 为空的数据占比是衡量清洗质量的一个重要指标,应当趋近于零。
4.3 验证脚本:双语字段、层级完整性一键检查
导入完成后,我建议跑一遍系统化的验证脚本。验证脚本可以分成三个维度:完整性、一致性、规范性。完整性看记录数是否符合预期;一致性看每个子节点的父级是否存在,且层级差是否为 1;规范性看中英文是否都有值、alpha2 是否格式正确。以下是一个最小实现。
def verify_region(db_path: str): conn = sqlite3.connect(db_path) cur = conn.cursor() cur.execute("select level, count(*) from region group by level") print("层级分布:", cur.fetchall()) cur.execute(""" select count(*) from region child left join region parent on child.parent_id = parent.id where child.parent_id is not null and parent.id is null """) print("父级缺失记录数:", cur.fetchone()[0]) cur.execute(""" select count(*) from region where name_zh = '' or name_zh is null """) print("缺少中文名的记录数:", cur.fetchone()[0]) cur.execute(""" select count(*) from region where path = '' or path is null """) print("缺少路径的记录数:", cur.fetchone()[0]) conn.close() if __name__ == "__main__": verify_region(sys.argv[1] if len(sys.argv) > 1 else "region.db")这四个查询基本覆盖了最核心的校验点。父级缺失那条,如果结果不为 0,原因大概率出在源数据里存在跨层级引用,也就是某个城市的 parent_id 指向了另一个城市,这种情况要先修正数据源而不是改脚本。缺中文名的数量如果超过总量 20%,建议停下来补翻译,而不是先导入再说,否则后续业务会拿着空的中文名去渲染页面。
5. 省州城市数据库避坑:5 个容易翻车的细节
用这份数据跑业务之后,我陆续踩过一些坑,都是那些不实际用一段时间根本发现不了的问题。这里挑最有代表性的 5 个写出来,每条都按现象、原因、解决来梳理。这些经验来自我维护数据的一个模拟项目 X,项目背景是一个跨境订单系统的收件地址解析模块,和行政区划数据深度绑定。
5.1 现象:国家名正确,但省州一级中英文对不上,报关单被退回
原因:某些国家的一级行政区在源数据里存的是缩写,比如“CA”而不是“California”,而中文翻译表里存的是全称“加利福尼亚”。两边一关联,要么空白,要么把缩写直接显示出去。解决:在洗数据阶段强制增加一列全称,缩写只保留在 code 字段。不要在中英文字段里放缩写,缩写统一放进 code。
5.2 现象:同一个城市名在多个国家出现,业务匹配命中错误
原因:这套数据把“全球所有城市”不区分父级地拼在了一张表里,业务方直接用城市名匹配,没有带父级上下文。最常见的是南美某国和北美某国使用同一个城市名的案例。解决:所有对外 API 或查询接口必须要求上层传入国家 alpha2 或父级 path;只传城市名的请求直接拒绝执行。数据本身没问题,问题出在调用约定上。这一步可以通过一个简单的接口参数校验完成,避免上线后的解释成本。
5.3 现象:城市数据看起来没变,但订单按州统计时“东京都”被拆到两个大区
原因:日本的一级行政区叫“都”,它下面又有一个城市叫“东京市”,但实际行政体系里“东京都”下还有多个区。如果源数据把“都”和“市”当成了两级父子,统计口径就会分裂。解决:做层级设计时明确规则:仅把“都、道、府、县”作为 2 级,下面的区市町村统一放 3 级。同时用字段 extra_type 标记行政体例,比如 country / state / prefecture / province / municipality / city 等,给业务侧做展示判断。
5.4 现象:新城市数据导入后,“老城市”被当成无效下架,订单历史数据断链
原因:数据源更新时会把部分废弃地名删除,如果直接全量覆盖,外键引用了这些旧 id 的历史订单就全部悬空。解决:导入新版本时不要删除旧行,而是用 is_active 标记旧版本下线。同时给每条记录增加 valid_from 和 valid_to 两个时间字段。至少保留退役地名 6 个月,给下游系统一个切换缓冲期。
5.5 现象:翻译接口把“Newcastle”翻译成了“纽卡斯尔”而用户搜的是“新堡”
原因:同一个地名在不同中文语境下有不同译名。地名翻译不是加密算法,不存在唯一正确解。解决:不要试图在 name_zh 里解决所有情况,而是增加 alias 表存放别名,包括常用翻译、旧版翻译、音译版本。主表 name_zh 只存一个当前推荐翻译,搜不到时再用别名表兜底。这个 alias 表需要留一个维护入口,让运营或客服可以随时补充。
这 5 个坑之中,第 5.2 个和第 5.4 个最容易在项目初期埋雷,它们的共性问题是:把数据表当成了静态字典,没有考虑调用方如何匹配以及数据版本变更。建议放到 0.5 版需求里就提前把协议定好。
6. 把这份数据库用得更顺:字段扩展、增量更新与离线查询优化
数据入了库、校验也过了,接着要考虑的是日常使用。我一般在部署这套数据时,不会只留一张孤零零的表,而是同时提供一个只读查询接口和一份定期更新流程。这里说几个直接上手有用的技巧。
第一个技巧:按 path 前缀加速“国家-省-城市”联动查询。前端页面通常需要“选中国家后加载省份、选中省份后加载城市”的联动接口。使用 path 字段可以一次性查出某个大洲或某个国家下的全部子节点,避免反复递归查询。例如要查某个国家的所有省,SQL 是 where path like '/US/%' and level = 2。如果担心 LIKE 走不全索引,可以再增加一级冗余字段 country_code,把国家跨度单独拆出来,这样查询条件变成 where country_code = 'US' and level = 2,走普通索引更稳定。
第二个技巧:不要做逐条增量更新,做“每月全量替换加差异比对”。行政区划数据的变化非常少,但逐条增量更新脚本却容易出错,因为新增城市的父级 id 可能来自上个月还没出现的数据。我每个月跑一次全量导入到一张临时表,然后通过一条 union 查询对比新旧两版,把变化的部分生成变更清单。这个清单比增量脚本可靠得多,而且可以直接拿给业务方确认,再决定是否触发下游缓存刷新。
第三个技巧:扩展字段时保持表结构稳定。常见的扩展包括经纬度、时区、货币代码、电话号码区号。建议这些字段放到 region_ext 子表里,用 region_id 关联,而不是直接在主表上不停加列。原因是一份行政区划数据会被多个团队使用,主表结构频繁变化会导致下游 ETL 任务连环报错。我经历过一次加列带来的下游表结构同步问题,后续决定所有扩展都走子表,主表只保留核心标识字段。
create table region_ext ( region_id integer primary key references region(id), lat double precision, lng double precision, timezone text, currency_code char(3), phone_code text );字段拆出去之后,核心查询走主表,地图展示或时区换算走扩展表,互不阻塞。这套结构保持了近两年没有改动过,业务上了好几个新的场景,都没再要求返工。
关于验证,我最后养成的习惯是:每次更新完成后,把“记录总数、路径完整度、翻译覆盖率、悬空父级数”这四个指标打印出来,写进发布日志里。有这四行数字在,以后排查数据问题会很快定位到是哪一次更新引入的回归,不用黑匣子式猜来猜去。曾经因为更新时忘了清理临时表,导致一张表里新旧数据混在一起,查了一个下午才发现,这就是没有发布日志的代价。希望帮到你,少走这一段弯路就好。
本文还有配套的精品资源,点击获取