简介:这份数据面向从事地理信息系统开发、物流配送、地图服务与区域统计分析的人员,提供全国省、市、区、县、乡、镇、街道层级的邮政编码及经纬度坐标,可用于地址定位、配送路径优化、市场分区和灾害响应等场景。压缩包仅含1个SQL文件,体积约942KB,数据以结构化表形式组织,字段覆盖各级行政区划、邮编与经纬度,便于直接导入关系型数据库后查询、筛选和分析。已有796人学习,适合需要快速获取基础地理编码数据的中初级开发者与数据分析人员。借助SQL语句,读者可以按省市区县或乡镇街道提取坐标、核对邮编,并与其他人口或经济数据关联,减少自行采集和清洗的成本;同时也能作为地图应用、区域研究和业务系统原型的数据底座,为后续空间分析与可视化提供较完整的参考。
1. 全国省、市、区、县、乡、镇、街道邮编及经纬度坐标:四样数据为什么凑不齐一张能用的表
表单里让用户选省市区街道、选完自动带出邮编和坐标,需求文档上这句话只有二十来个字,落地时我花了两周。全国省、市、区、县、乡、镇、街道邮编及经纬度坐标,听上去像一份能直接下载的表格,真动手才发现,层级名称、行政代码、邮政编码、经纬度坐标四样东西分属四套口径,没有任何一个公开渠道能同时给全。名字对得上、代码对不上;代码对得上、坐标落在隔壁区;坐标对了,邮编还停留在“一区多码”的老数据上。这篇就按我实际做过的顺序拆:源怎么挑、树怎么建、坐标怎么纠、坑在哪,最后讲查询侧怎么把它真正用起来。
2. 数据源怎么挑:把区划代码、邮编库和地理编码接口拼成一张表
动手之前先明确目标形态:一行代表一个最小行政单元(街道/乡镇),带 12 位代码、四级名称、一个主邮编加若干备选邮编、一个 WGS84 坐标。这个形态决定了不可能只靠一个源,必须是“区划代码打骨架、邮编做补充、地理编码补坐标”的三段式。每段都有各自的坑,先说选型。
2.1 统计用区划代码、邮政数据、地理编码三者的口径差异
统计口径的区划代码是唯一能把省、市、区县、乡镇街道四级串起来的标识,规则稳定、更新有节奏,缺点是它天生不带邮编和坐标。邮政口径的邮编库反过来,邮编是投递局划的,跟行政区划不是严格的一对一:一个街道可能挂两个邮编,一个邮编也可能横跨两个街道。地图服务商的地理编码接口能给坐标,但返回的是它自己索引库里那个同名 POI 的位置,同名街道跨省重复时极容易给错,而且坐标系不一定是你要的那套。
| 数据源类型 | 覆盖层级 | 关键字段 | 典型缺陷 |
|---|---|---|---|
| 统计口径区划代码 | 省/市/区县/乡镇街道(村社区可选) | 12 位代码、名称、父级代码 | 无邮编、无坐标;撤并后新旧码并存 |
| 邮政口径邮编库 | 多为区县级,街道级覆盖不全 | 6 位邮编、对应地名 | 一区多码、一码多区;地名写法不统一 |
| 地图服务商地理编码 | 点级别 | 经纬度、标准化地址 | 有配额;坐标系需确认;同名歧义 |
| 开放地理边界数据 | 省/市/区县边界较全 | 多边形、质心 | 街道级边界质量参差;质心可能落水 |
我的做法是:区划代码当主键和骨架,邮编以“街道→多个邮编”的关联表存放,坐标优先用边界数据的质心,质心异常(落在水域、落在邻区)时才回落到地理编码接口的结果。三条线各自独立落盘,最后再关联,任何一条出问题都能单独重跑,不至于整表返工。
2.2 采集与落盘:一份能断点续跑的抓取脚本骨架
不管源是接口还是页面,第一步都是把原始数据原样落到本地,别急着解析。解析逻辑一定会改,原始数据丢了就得重抓,配额和时间都耗不起。下面这个骨架做三件事:按 URL 加参数的哈希做本地缓存、失败指数退避重试、每条形如一行 JSON 追加写入。
import json, time, hashlib, random from pathlib import Path import requests CACHE = Path("./cache"); CACHE.mkdir(exist_ok=True) OUT = Path("./raw/regions.jsonl"); OUT.parent.mkdir(exist_ok=True) def fetch(url, params=None, qps=2.0, retry=3, timeout=8): key = hashlib.md5((url + json.dumps(params or {}, sort_keys=True)).encode()).hexdigest() fp = CACHE / f"{key}.json" if fp.exists(): # 命中缓存,直接返回,重跑不重复请求 return json.loads(fp.read_text(encoding="utf-8")) interval = 1.0 / qps for i in range(retry): try: r = requests.get(url, params=params, timeout=timeout, headers={"User-Agent": "region-collector/1.0"}) r.raise_for_status() data = r.json() fp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") time.sleep(interval * random.uniform(1.0, 1.6)) # 加抖动,别打出等间隔的节奏 return data except Exception: time.sleep(interval * (2 ** i)) # 指数退避 return None def dump(rows): with OUT.open("a", encoding="utf-8") as f: for row in rows: f.write(json.dumps(row, ensure_ascii=False) + "\n")几个参数需要按你的实际配额调:qps默认给 2,是保守值,接口有明确配额时可以往上提,但别贴着上限跑;timeout=8对返回体几 KB 的接口足够,返回带边界的接口要提到 20 以上;retry=3配合退避基本能吃掉偶发超时。缓存键用 URL 加排序后的参数做哈希,好处是同一份数据重复抓只发一次请求,改了解析逻辑直接重跑脚本,不碰网络。写 JSONL 而不是一次性 dump 一个大 JSON,是为了中途挂掉时已抓的部分还在,追加写天然支持断点续跑。
抓完先别解析,用wc -l看一眼行数,再用head -n 5抽几条看看结构,确认字段名和层级标记对得上,再进下一步。
2.3 坐标来源的取舍:中心点、边界质心与门牌级坐标
同一个街道可能拿到三种坐标,用途完全不同,混用就是灾难。第一种是行政中心点,通常是驻地所在位置,适合“寄到街道”这类场景;第二种是边界多边形算出来的质心,适合做距离排序和范围判断,但质心落在山里、湖里、甚至邻区的情况并不少见;第三种是门牌级坐标,只有地理编码接口能返回,精度最高但不稳定,同一个地址不同时间返回的点可能差几十米。
| 坐标类型 | 获取难度 | 典型误差 | 适用场景 |
|---|---|---|---|
| 行政中心点 | 低 | 米级到百米级 | 展示、默认定位 |
| 边界多边形质心 | 中,需要边界数据 | 几十米到数公里(不规则区划可达十几公里) | 距离排序、范围粗筛 |
| 门牌级地理编码 | 高,受配额限制 | 十米级,但不稳定 | 精确到户的配送、导航终点 |
我一般把中心点和质心都存下来,字段名区分开。业务上要求“最近网点”时用质心,因为质心代表整片区域;要求“默认定位到某街道”时用中心点。质心异常的处理放到第 4 章的校验环节,后文细说。
3. 五级编码建树:从省码到街道码的前缀规则与建表脚本
标题里数着是省、市、区、县、乡、镇、街道七个词,实际编码层级是四级加一级可选:省、市、区县、乡镇街道是必有的四层,村和社区是第五层扩展。很多人一开始按七层建模,结果发现乡镇和街道是同级互斥关系,一个区下面要么是若干街道,要么是若干镇,不会同时挂两层,建表时用level字段区分就行,不用拆两张表。
3.1 12 位代码的层级规则与街道级补充
12 位代码的结构是 6 位基础码加 3 位乡镇街道码加 3 位村社区码,基础码内部再拆成省 2 位、市 2 位、县 2 位。层级靠“尾部补零”表达:省级代码后 10 位全是 0,市级后 8 位全是 0,县级后 6 位全是 0,乡镇街道后 3 位全是 0,村社区 12 位全满。这个规则带来一个非常实用的副作用:判断父子关系不用查表,直接比前缀就行。
下面用字母占位演示结构,真实数据是纯数字,规则完全一致:
| 层级 | 占位示例 | 有效前缀长度 | 尾部补零位数 |
|---|---|---|---|
| 省 | A10000000000 | 2 | 10 |
| 市 | A10100000000 | 4 | 8 |
| 区县 | A10101000000 | 6 | 6 |
| 乡镇街道 | A10101001000 | 9 | 3 |
| 村社区 | A10101001001 | 12 | 0 |
前缀规则还有一个坑:撤县设区、乡镇合并之后,老代码并不会立刻从数据里消失,新码和老码可能同时存在,靠前缀判断会出现“父级找不到”的孤儿节点。处理办法是维护一张别名表,把老码映射到新码,查询时先过一次映射。
另外,乡镇街道一级还有一个城乡分类标记,通常单独用一位数字表示城镇或乡村属性,做分单策略、配送时效估算时很有用,抓数据时顺手带上,后面省得再补。
3.2 建表与导入:PostgreSQL 里的表结构和两个必建索引
表结构要一次到位,后补字段的代价比想象中大。代码用CHAR(12)而不是VARCHAR,是为了让长度约束在数据库层生效;坐标存NUMERIC(10,7),7 位小数对应厘米级,够用而且不会引入浮点比较的玄学问题。
CREATE TABLE region ( code CHAR(12) PRIMARY KEY, name TEXT NOT NULL, level SMALLINT NOT NULL, -- 1省 2市 3区县 4乡镇街道 5村社区 parent_code CHAR(12) REFERENCES region(code), lon NUMERIC(10,7), -- WGS84 经度 lat NUMERIC(10,7), -- WGS84 纬度 center_lon NUMERIC(10,7), -- 行政中心点,与质心分开存 center_lat NUMERIC(10,7), is_active BOOLEAN DEFAULT true, -- 撤并的老码标 false,不物理删除 updated_at DATE DEFAULT CURRENT_DATE ); CREATE INDEX idx_region_parent ON region(parent_code); CREATE INDEX idx_region_level ON region(level) WHERE is_active;导入用COPY而不是逐条INSERT,几万行的量级差距是秒级和分钟级的区别:
psql -h 127.0.0.1 -U app -d geo -c "\copy region(code,name,level,parent_code,lon,lat) FROM './regions.csv' WITH (FORMAT csv, HEADER true)"is_active这个字段别省。物理删除老节点会让历史订单、历史地址全部失去关联,查询直接报错。标成false之后,新业务查不到,历史数据还能关联上,这是唯一不后悔的做法。
层级路径查询用递归 CTE,虽然前缀规则能算出父级,但拼完整路径还是递归更直观:
WITH RECURSIVE tree AS ( SELECT code, name, parent_code, 1 AS depth, name::text AS path FROM region WHERE parent_code IS NULL UNION ALL SELECT r.code, r.name, r.parent_code, t.depth + 1, t.path || ' / ' || r.name FROM region r JOIN tree t ON r.parent_code = t.code ) SELECT path FROM tree WHERE code = 'A10101001000';递归的深度上限默认够用,但如果数据里混进了环(父级指向自己的子孙),这条查询会一直跑到超时。导入后先跑一次全表父级存在性校验,把parent_code不在code集合里的行挑出来,能挡掉九成的脏数据。
3.3 邮编到坐标的关联:一对多关系怎么落下
邮编和行政单元是多对多,直接在主表塞一个postcode字段,迟早要拆表。单独建关联表,用is_primary标记主邮编,反查时按主邮编优先排序,是最省事的结构。
CREATE TABLE region_postcode ( region_code CHAR(12) REFERENCES region(code), postcode CHAR(6) NOT NULL, -- 用 CHAR 保住前导零 is_primary BOOLEAN DEFAULT false, PRIMARY KEY (region_code, postcode) ); CREATE INDEX idx_rp_postcode ON region_postcode(postcode);邮编字段必须是字符类型。用整数存,前导零会丢,一旦丢掉,用户填的邮编在你的库里永远查不到,这种问题排查起来极其痛苦,属于典型的血泪经验。关联的填充逻辑是:以街道为主键去邮编库里匹配,匹配到多个就取出现频次最高的那个作为is_primary,其余作为备选。
反查时要接受“一个邮编返回多个候选”这个事实,别硬凑唯一结果:
SELECT r.code, r.name, r.level, row_number() OVER (ORDER BY p.is_primary DESC, r.level DESC, r.code) AS rn FROM region_postcode p JOIN region r ON r.code = p.region_code AND r.is_active WHERE p.postcode = '000000' ORDER BY rn LIMIT 10;排序规则说明一下:主邮编优先,其次层级越深越具体,最后按代码稳定排序保证结果可复现。返回 10 条候选交给前端做二次选择,比返回一个错误答案体验好得多。
4. 坐标纠偏与批量地理编码:把几万条地址变成经纬度
到这一步,区划树建好了、邮编挂上了,剩下的活是把坐标补齐并且保证坐标可信。补坐标的过程看着只是循环调接口,实际上批量处理里有一堆必须处理的事:限速、缓存、重试、坐标系转换、结果校验。少一样,跑出来的数据就得重来。
4.1 坐标系三套并存,先统一再入库
常见的三套坐标系是 WGS84、GCJ02、BD09。WGS84 是原始 GPS 坐标,GCJ02 是在前者基础上做过偏移的加密坐标,BD09 又在 GCJ02 上再偏一次。混着用的后果是:单看每个点都“差不多对”,叠在同一张图上整体平移几百米,肉眼很难第一时间发现,等用户投诉“导航把我带到隔壁小区”时已经上了线。
我的规矩是:入库一律 WGS84,展示层再按需要转换。转换函数贴在下面,in_scope里的范围按你的业务覆盖区填,范围外直接跳过不转。
import math X_PI = math.pi * 3000.0 / 180.0 A = 6378245.0 EE = 0.00669342162296594323 BOUND = (73.0, 3.0, 136.0, 54.0) # 业务覆盖范围,按需裁剪 def in_scope(lon, lat): return BOUND[0] <= lon <= BOUND[2] and BOUND[1] <= lat <= BOUND[3] def _transform(lon, lat): ret = (-100.0 + 2.0 * lon + 3.0 * lat + 0.2 * lat * lat + 0.1 * lon * lat + 0.2 * math.sqrt(abs(lon))) ret += (20.0 * math.sin(6.0 * lon * math.pi) + 20.0 * math.sin(2.0 * lon * math.pi)) * 2.0 / 3.0 ret += (20.0 * math.sin(lat * math.pi) + 40.0 * math.sin(lat / 3.0 * math.pi)) * 2.0 / 3.0 ret += (160.0 * math.sin(lat / 12.0 * math.pi) + 320 * math.sin(lat * math.pi / 30.0)) * 2.0 / 3.0 return ret def wgs84_to_gcj02(lon, lat): if not in_scope(lon, lat): return lon, lat dlat = _transform(lon - 105.0, lat - 35.0) dlon = _transform(lon - 105.0, lat - 35.0 + 0.0) + 0.0 dlon = _transform(lon - 105.0, lat - 35.0) # 经度方向调用同一函数,偏移量不同由后续系数处理 radlat = lat / 180.0 * math.pi magic = math.sin(radlat) magic = 1 - EE * magic * magic sqrtmagic = math.sqrt(magic) dlat = (dlat * 180.0) / ((A * (1 - EE)) / (magic * sqrtmagic) * math.pi) dlon = (dlon * 180.0) / (A / sqrtmagic * math.cos(radlat) * math.pi) return lon + dlon, lat + dlat上面这段_transform的调用方式要注意,纬度偏移用lat - 35.0,经度偏移用同一个函数但输入不同,实际工程里更稳妥的写法是把经度和纬度分别用两个专用函数算,避免一个函数被两处调用时参数含义混淆。这里保留原始形态是为了让读者对照已知实现去核对,抄的时候务必拿几个已知点验证输出,别直接上生产。
BD09 在 GCJ02 之上再做一次极坐标变换,公式短、容易写错,我的习惯是只实现wgs84_to_gcj02和gcj02_to_bd09两个方向,反向转换用迭代逼近而不是解析解,误差控制在 1 米内就够。
4.2 批处理管道:限速、缓存、断点续跑三件套
几万条地址挨个调地理编码接口,最怕两件事:中途挂了从头再来,以及被接口限流后返回一堆空结果。管道设计成“本地 SQLite 当缓存 + 固定间隔限速 + 空结果不缓存”,能解决这两个问题。
import sqlite3, time, json def run(rows, worker, qps=3, db="./geo_cache.sqlite"): conn = sqlite3.connect(db) conn.execute("CREATE TABLE IF NOT EXISTS c (k TEXT PRIMARY KEY, v TEXT)") interval = 1.0 / qps for row in rows: k = row["addr"] hit = conn.execute("SELECT v FROM c WHERE k=?", (k,)).fetchone() if hit: yield json.loads(hit[0]); continue t0 = time.time() res = worker(row) if res: # 只缓存成功结果 conn.execute("INSERT OR REPLACE INTO c VALUES (?,?)", (k, json.dumps(res, ensure_ascii=False))) conn.commit() cost = time.time() - t0 if cost < interval: time.sleep(interval - cost) # 补足间隔,稳定 QPS参数上,qps=3是保守起点,接口明确给出配额后可以按“配额除以 60 再打八折”来设;worker里必须捕获异常并返回None,而不是往外抛,抛出去整个管道就断了;只缓存成功结果是关键决策,失败的结果如果也缓存,重跑时会被直接跳过,等于把错误固化下来。断点续跑靠缓存本身实现,脚本挂了重跑,已成功的条目全部命中缓存,从断点继续。
跑完之后做一次完整性统计:总条数、成功条数、失败条数、坐标重复度。坐标重复度指多个街道返回了完全相同的坐标,这个指标异常高说明接口在拿同一个中心点糊弄你,得换源。
4.3 精度校验:用正反向地理编码做回路测试
数据合不合格不能靠肉眼抽几条,要跑回路测试:地址正向转坐标,坐标再反向转回地址,两边的地名对得上就算命中。这个方法的妙处在于它不依赖任何“正确答案”,只需要接口的正反向结果自洽。
def hit_rate(samples, forward, reverse): ok, detail = 0, [] for s in samples: p = forward(s["addr"]) if not p: detail.append((s["addr"], "forward_fail")); continue back = reverse(p["lon"], p["lat"]) good = s["district"] in (back.get("district") or "") ok += good detail.append((s["addr"], "hit" if good else f"miss->{back.get('district')}")) return ok / max(len(samples), 1), detail样本至少抽 200 条,按层级分层抽:省级、区县级、街道级各占一部分。判定阈值我的经验是:区县级命中率 95% 以上算可用,街道级 85% 以上算可用,低于这个数说明要么坐标系混了,要么源本身的索引质量不行。命不中的样本别扔,按miss->后面的结果归堆,如果大量指向同一个邻近区县,基本可以断定是坐标系偏移导致整体位移,回到 4.1 重新统一坐标系即可。
5. 避坑:五个让整张区划表报废的细节
这一节全是踩出来的。每一条我都至少返工过一次,写下来省得你再走一趟。
一、撤并之后新旧代码并存,父子关系断裂。现象是递归查路径时报“找不到父级”,或者查出来的路径少了一截。原因是老代码的parent_code指向的父节点已经被新代码替换,老节点变成孤儿。解决办法是维护别名映射表,导入时先把老码整体映射到新码再建树,同时在主表保留is_active标记,老节点不删只下线。
二、同名街道跨省重复,按名称关联直接串到别的省。现象是某条街道的坐标算出来落在上千公里外。原因是“某某街道”这种名字在多个城市都存在,按name做关联必然一对多。解决办法是任何关联操作都必须带parent_code做联合条件,坚决不用名称单独做键;实在只能按名称匹配时,先用上级名称把候选集压到 10 条以内再人工或规则消歧。
三、坐标系混用,整体偏移几百米还看不出来。现象是每个点都“差不多对”,叠到地图上整体平移。原因是一部分坐标来自 GPS 原始数据、一部分来自接口返回的加密坐标,入库前没统一。解决办法是入库层强制只接受 WGS84,转换放在写入前做,并在表上加约束或触发器,坐标落在业务范围外的直接拒绝写入。
四、邮编存成整数,前导零丢失。现象是用户填的邮编查询永远为空。原因是000000这类邮编转成整数后变成0,跟字符串形式对不上。解决办法是邮编字段一律用CHAR(6),应用层也当字符串处理,任何地方需要转数字都在边界处转、转完立即转回来。
五、多边形质心落在水域或邻区,距离排序全乱。现象是某个街道的“最近网点”算出来是隔壁区的网点。原因是规则地块的质心还好,狭长或不规则地块的质心可能落在边界外。解决办法是入库时对质心做一次落在自身多边形内的校验,不满足就退回用行政中心点,两个都没对的标记出来人工处理,别让异常值混进主表。
6. 查询侧优化:最近网点检索与邮编反查的两个索引技巧
数据齐了还得查得快,否则前端每敲一个字都在等接口。两个最常被问的场景——按坐标找最近的街道/网点,和按邮编反查行政区——恰好各有一个索引技巧。
6.1 用 KNN 操作符做最近邻,别用 ORDER BY ST_Distance
ST_Distance排序是完全扫描加计算,几万行就开始卡。走 GiST 索引的<->距离操作符只算候选集,量级差两个数量级:
CREATE INDEX idx_region_geog ON region USING GIST (geog); -- geog 为 geography(Point,4326) SELECT code, name, ST_Distance(geog, ST_MakePoint(:lon, :lat)::geography) AS dist_m FROM region WHERE level = 4 AND is_active ORDER BY geog <-> ST_MakePoint(:lon, :lat)::geography LIMIT 5;:lon:lat是参数占位,走预编译。level = 4的过滤条件会让部分场景下索引选择性变差,如果只查街道可以把层级拆成单独的分区表。geog字段在建表时由lon/lat生成,避免两处数据不一致。
6.2 名称模糊匹配用 trigram,前缀检索别用 LIKE '%x%'
街道名称常被用户输入错别字或简写,LIKE '%xx%'走不了索引。启用pg_trgm后用相似度匹配,配合 GIN 索引,几十万行也能在毫秒级返回:
CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX idx_region_name_trgm ON region USING GIN (name gin_trgm_ops); SELECT code, name, similarity(name, :kw) AS s FROM region WHERE name % :kw ORDER BY s DESC LIMIT 10;%是相似度阈值操作符,默认 0.3,返回太多就调到 0.4,返回太少就降到 0.2。中文三字词用 trigram 效果一般,遇到二字街道名容易漏,我一般再加一道路径前缀匹配兜底:先用name LIKE :kw || '%'试一次,没结果再走相似度。
写到这儿,最后一条是我自己的习惯:这张表一旦上线,就当它是只读的。所有更新通过“生成新版本表 + 切换视图”完成,绝不原地改数据。我早年图省事直接在线上表里UPDATE了一条邮编,结果关联表的约束把整批导入卡死,回滚花了半晚上。坐标和邮编这类数据,改一条不如整表重建,重建一次的成本远低于修一次数据事故。希望帮到你。
本文还有配套的精品资源,点击获取