简介:面向物流配送、地图服务、市场分析与政府规划等场景整理的中国行政区划地理编码数据集,覆盖省、市、区、县、乡、镇、街道各级,共约46090条记录,每条包含行政区划名称、邮政编码及对应的经纬度,可直接作为地理信息系统的基础数据表使用。压缩包内只有1个SQL脚本文件,包体约942KB,导入主流关系型数据库后即可通过SQL完成按区域检索、坐标匹配与分级统计,省去自行采集与清洗的环节。该资源已有796人学习下载,适合GIS开发、位置服务后端、数据分析与物流路径规划方向的初、中级开发者参考。数据以表格形式组织,字段划分清晰,便于与人口、经济等业务指标关联扩展,也能支撑地址解析、配送范围划定与受灾区域快速定位等具体需求,为二次开发和空间分析提供结构化、可查询的起点。
1. 给一个地址补邮编和坐标,为什么最后变成了三套数据的缝合怪
上周有人问我,能不能把一条「某省某市某区某街道某路某号」的地址,一次性查出邮政编码和经纬度。听起来就是调个接口的事,真动手才发现:邮编、行政区划、经纬度坐标,这三样东西压根不是一套体系里长出来的。邮编按投递区划走,一个街道能对应好几个邮编段;行政编码按管辖关系走,撤并之后年年变;坐标点按地理位置走,还分好几套坐标系。反直觉的结论是——邮编不是行政区的属性,谁把它当字段塞进区划表,后面一定得返工。这篇讲的就是从零把省、市、区、县、乡、镇、街道五级数据连同邮编段和坐标点拼成一张能用、能查、能维护的表,适合做地址清洗、物流分区、地理围栏和门店选址的人照着复现。
2. 数据模型先定死:区划树、邮编段、坐标点三张表怎么拆
动手写采集脚本之前,先把表结构定下来,这一步省下来的时间后面能翻倍赚回来。我见过太多人一上来就写爬虫,抓到一半发现「街道」这个词既可能是乡镇级单位也可能是村级单位,字段长度不够、层级判断全靠 if-else,最后整份数据推倒重来。这属于典型的血泪经验:结构没想清楚,数据量一大就是玄学。
2.1 区划树为什么用父指针加层级列,而不是嵌套集
五级区划本质是一棵深度固定为 5 的树,用父指针(parent_id)加层级(level)是最省事也最好维护的做法。嵌套集、物化路径这些方案在深度固定、变更不频繁的场景里收益不大,反而让插入和移动节点变得难懂。真正要花心思的是业务编码——它才是跨系统关联的唯一钥匙,内部自增 id 只是给数据库自己用的。
-- 行政区划主表:内部主键与业务码分离,带上生效区间 CREATE TABLE adm_div ( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, parent_id BIGINT NOT NULL DEFAULT 0, -- 根节点为 0 ad_code VARCHAR(12) NOT NULL, -- 业务码,按层级对齐长度 level TINYINT NOT NULL, -- 1省 2市 3区县 4乡镇街道 5村社区 name VARCHAR(64) NOT NULL, -- 规范短名,如「某街道」 full_name VARCHAR(255) NOT NULL, -- 全路径名,便于模糊搜索 valid_from DATE NOT NULL, valid_to DATE NULL, -- NULL 表示当前有效 UNIQUE KEY uk_code_valid (ad_code, valid_from), KEY idx_parent (parent_id), KEY idx_level (level) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;字段里有三个地方值得掰开说。ad_code长度给到 12,是因为乡镇街道这一级在省级码基础上还要续位,不同来源的位数并不统一,留冗余比后期改表便宜。valid_from/valid_to这对组合是给撤并留的后悔药,某地撤县设区之后老编码不能直接删,否则历史订单里的地址就断了链。full_name单独冗余一列,是为了避开每次查询都递归拼路径,代价是名称变更时要批量刷一次,用触发器或者定时任务都行。
建完表先别急着灌数据,跑三条校验 SQL,把结构性问题挡在入库之前:
-- 校验一:孤立节点,父节点不存在 SELECT c.id, c.ad_code, c.name FROM adm_div c LEFT JOIN adm_div p ON c.parent_id = p.id WHERE c.parent_id <> 0 AND p.id IS NULL; -- 校验二:层级断裂,子节点层级不等于父节点加一 SELECT c.id, c.name, c.level, p.level AS parent_level FROM adm_div c JOIN adm_div p ON c.parent_id = p.id WHERE c.level <> p.level + 1; -- 校验三:用递归 CTE 找深度超过 5 的异常链 WITH RECURSIVE chain AS ( SELECT id, parent_id, 1 AS depth FROM adm_div WHERE parent_id = 0 UNION ALL SELECT c.id, c.parent_id, chain.depth + 1 FROM adm_div c JOIN chain ON c.parent_id = chain.id ) SELECT id, depth FROM chain WHERE depth > 5;第一条查孤立节点,采集时最常见的翻车点是分页拉到一半父级还没入库,子级先落地了。第二条查层级断裂,有些来源会把「市辖区」这种虚级也当成一层,导致层级整体偏移。第三条防环,理论上不会出现,但合并多份来源时真遇到过 A 的父指向 B、B 的父又指回 A 的情况,递归查询直接跑死。
2.2 邮编挂不到区划表上:用中间表接住多对多关系
如果只能记住一句话,那就是邮编和行政区划是多对多。一个乡镇街道可能被三个邮编段覆盖,一个邮编段也可能横跨两个街道。把 zip 当成 adm_div 的一个字段,迟早会遇到一个单元格塞不下两个值。正确做法是拆一张关系表,用权重列表达「这个邮编主要属于哪个区划」。
-- 邮编主表:只存邮编本身和它的元信息 CREATE TABLE zipcode ( zip VARCHAR(6) NOT NULL PRIMARY KEY, zip_type TINYINT NOT NULL DEFAULT 1, -- 1 投递区 2 专用 3 历史遗留 note VARCHAR(128) NULL ); -- 邮编与区划的关系表:多对多,weight 表示覆盖占比 CREATE TABLE zipcode_ad ( zip VARCHAR(6) NOT NULL, ad_code VARCHAR(12) NOT NULL, weight DECIMAL(5,4) NOT NULL DEFAULT 1.0000, -- 0~1,主要归属给 1 is_primary TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (zip, ad_code), KEY idx_ad (ad_code) );weight这一列的来历值得讲讲。邮编段是按投递范围划的,边界往往沿着马路、河流走,跟行政区划边界对不齐。采集来的原始数据里,同一个邮编可能同时出现在好几个街道的记录中,直接去重会丢掉信息,直接保留又没法回答「这个街道的主邮编是啥」。用出现次数除以总次数算个粗权重,人工抽查几条之后把主要归属标成is_primary = 1,下游查单个邮编就能拿到唯一答案,查一个街道也能按权重排出主次。
查询时分两种情况写 SQL,别混用:
-- 场景一:已知邮编,反查它覆盖哪些区划(按权重降序) SELECT z.zip, za.ad_code, d.full_name, za.weight FROM zipcode z JOIN zipcode_ad za ON za.zip = z.zip JOIN adm_div d ON d.ad_code = za.ad_code AND d.valid_to IS NULL WHERE z.zip = '000000' ORDER BY za.weight DESC; -- 场景二:已知街道,取它的主邮编(没有主邮编时退化为权重最高的一条) SELECT ad_code, zip FROM ( SELECT ad_code, zip, ROW_NUMBER() OVER (PARTITION BY ad_code ORDER BY is_primary DESC, weight DESC) AS rn FROM zipcode_ad ) t WHERE rn = 1;2.3 坐标点位表和边界面表分开建,坐标系必须落库
第三个容易埋雷的地方是坐标。点(质心、大门、门牌)和面(行政边界多边形)是两种完全不同的几何对象,硬塞一张表里会让查询逻辑变得别扭。分开建,点位表承担「快速定位」,边界面表承担「精确归属判断」,两者用同一个 ad_code 关联。
-- 点位表:一个区划可以有多个点,用 point_type 区分用途 CREATE TABLE geo_point ( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, ad_code VARCHAR(12) NOT NULL, point_type VARCHAR(16) NOT NULL, -- centroid / entrance / poi lng DOUBLE NOT NULL, lat DOUBLE NOT NULL, coord_sys VARCHAR(8) NOT NULL, -- WGS84 / GCJ02 / BD09 accuracy_m INT NULL, -- 估计误差,单位米 src VARCHAR(64) NULL, KEY idx_ad (ad_code), KEY idx_geo (lng, lat) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;coord_sys是这张表的生命线,绝对不能省。不同来源的数据可能来自不同坐标系,混在一起落库之后,前端渲染出来就是整体偏移几百米,看起来像地图加载错了,实际是数据层的问题。accuracy_m允许为空但要尽量填,它决定了这个点能不能用于末端配送——误差两公里的质心点拿去导航,骑手会骂人。
边界面表存 GeoJSON 转出来的多边形,字段少而重:
| 字段 | 类型 | 说明 |
|---|---|---|
| ad_code | VARCHAR(12) | 与区划表关联,唯一 |
| level | TINYINT | 冗余层级,便于按级过滤 |
| geom | GEOMETRY | 面对象,建议 SRID 4326 |
| area_km2 | DOUBLE | 面积,用于合理性校验 |
| src | VARCHAR(64) | 边界来源标识 |
有了这两张表,后面所有查询都有落脚点:按地址查坐标走点位表,按坐标反查街道走边界面表,两者互不干扰。
3. 数据从哪来:五级区划的采集、清洗与去重流水线
结构定完,接下来是脏活。公开渠道能拿到的五级数据,质量参差不齐:有的缺乡镇街道这一级,有的名称里混着全角括号和备注,有的干脆把「某某村民委员会」和「某某村」当两条记录。整条流水线我一般拆成采集、清洗、去重三段,每段都能单独重跑,出了问题不用从头再来。
3.1 分页采集加断点续传:把进度写进文件而不是内存
采集最怕的是跑到八成断了,重跑又得从第一页开始。解决办法很朴素:把页码和游标写进一个 JSON 文件,每成功一页就落盘一次,进程重启时先读文件。
# 分页采集骨架:断点续传 + 限速 + 失败重试 import json, os, time import requests CKPT = "ckpt.json" URL = "https://<数据源>/api/list" # 实际地址按来源替换 def load_ckpt(): if os.path.exists(CKPT): return json.load(open(CKPT, encoding="utf-8")) return {"page": 1, "cursor": None} def save_ckpt(state): # 先写临时文件再原子替换,避免进程被 kill 时写坏断点 tmp = CKPT + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False) os.replace(tmp, CKPT) def fetch_page(page, cursor, size=100, retry=3): for i in range(retry): try: resp = requests.get( URL, params={"page": page, "cursor": cursor, "size": size}, timeout=15, headers={"User-Agent": "data-collector/1.0"}, ) resp.raise_for_status() return resp.json() except Exception as e: if i == retry - 1: raise time.sleep(2 ** i) # 指数退避,别把对方打挂 def run(): state = load_ckpt() while True: body = fetch_page(state["page"], state["cursor"]) rows = body.get("data") or [] if not rows: break write_to_db(rows) # 落库逻辑单独写 state["page"] += 1 state["cursor"] = body.get("next_cursor") save_ckpt(state) time.sleep(0.5) # 限速,0.5 秒一页足够稳retry给 3 次、退避用 2 的幂,是我试过比较平衡的参数:太少扛不住偶发抖动,太多会在对方限流时把等待时间拉得很长。size给 100 是因为再大响应体容易超时,再小页数太多、断点文件更新过于频繁。cursor和page同时保存,是因为有些数据源用游标分页,页码只是辅助。
采集阶段还要顺手做一件事:把原始响应原样存一份到对象存储或者本地目录,按日期分文件夹。清洗规则改了想重跑的时候,有原始快照就不用再打一遍对方接口,省事也省得被限流。
3.2 名称归一化:全角、括号、后缀三步走
清洗里最耗时的永远是名称。同一条记录在不同来源里可能写成「某某街道办事处」「某某街道办」「某某街道(新址)」,不归一化就没法比对。
import re import unicodedata # 后缀统一表:长后缀排在前面,避免被短后缀先匹配掉 SUFFIX_MAP = [ ("街道办事处", "街道"), ("街道办", "街道"), ("镇人民政府", "镇"), ("乡人民政府", "乡"), ("村民委员会", "村委会"), ("居民委员会", "居委会"), ] def normalize_name(raw: str) -> str: if not raw: return "" # 1) NFKC 把全角字母数字、全角括号统一成半角 s = unicodedata.normalize("NFKC", raw).strip() # 2) 去掉括号备注,只留正式名 s = re.sub(r"[((][^))]*[))]", "", s) # 3) 后缀统一,只替换一次,防止「街道街道」 for old, new in SUFFIX_MAP: if s.endswith(old): s = s[: -len(old)] + new break # 4) 压掉所有空白和不可见字符 s = re.sub(r"[\s\u200b\u00a0]+", "", s) return s这段代码的顺序有讲究。NFKC 必须放第一步,否则全角括号匹配不上,第二步的正则就失效了。后缀替换只做一次break,是因为有些名称本身就带叠词,反复替换会越改越离谱。第四步清空白字符时把零宽空格也带上,这类字符肉眼看不见,排查起来非常费劲。
归一化之后不要覆盖原字段,另开一列name_norm存结果。原始名称留着,出问题能对回去,这是我在一次全量数据清洗翻车之后养成的习惯。
3.3 同名异地怎么判:码优先、名辅助、人工兜底
全国范围内同名乡镇、同名街道非常多,纯按名称去重一定出事。判定顺序我固定成三层。
第一层看业务码。同一个 ad_code 在不同来源里出现,直接认为是同一条记录,取字段更全的那份。这一层能解决八成以上问题,也是唯一可以全自动的一层。
第二层看名称加父级路径。ad_code 缺失或者位数不一致时,用normalize_name之后的全路径名做比对,同时要求父级名称也一致。举个虚构的例子,「某省 A 市 B 区 新华街道」和「某省 A 市 C 区 新华街道」名称一样但父级不同,必须判成两条。这一层建议只做候选召回,不要自动合并。
第三层人工。候选对按相似度排序,取前若干条人工确认,确认结果写回一张对照表,下次同样的组合直接命中。听起来笨,但一年也就几百条,比写复杂算法划算得多。
-- 候选召回:同名同父级、但编码不同的记录,供人工确认 SELECT a.ad_code, b.ad_code, a.full_name, b.full_name FROM adm_div a JOIN adm_div b ON a.name = b.name AND a.parent_id = b.parent_id AND a.ad_code < b.ad_code AND a.level = b.level WHERE a.valid_to IS NULL AND b.valid_to IS NULL;还有一类特殊情况要单独处理:撤并。老编码失效、新编码生效,两者指的是同一块地方。这时候不能删老记录,把valid_to填上日期,同时在新记录的备注里记一笔前身编码。下游做历史数据回刷时按时间点查,就不会丢。
4. 坐标系和精度:WGS84 与 GCJ02 混用为什么会让点位跑到隔壁街道
坐标这块是整套数据里最容易出隐性错误的地方。数值上差个零点零几度,看起来微不足道,落到地面上就是几百米,刚好够把一个点从这条街道挪到隔壁街道。更麻烦的是错误不会报异常,数据照样入库,等到前端渲染或者逆地理编码时才暴露。
4.1 三套坐标系的偏差量级与各自适用场景
常见的三套坐标系,偏差量级和用途差别很大,混用之前得先知道自己在混什么。
| 坐标系 | 大致偏移 | 典型来源 | 适用场景 |
|---|---|---|---|
| WGS84 | 基准 | 原始 GPS、国际数据源 | 存档、跨源比对、算法计算 |
| GCJ02 | 相对 WGS84 偏移数百米 | 国内地图服务的 JS 接口、部分开放数据 | 在国内底图上渲染 |
| BD09 | 在 GCJ02 基础上再偏移 | 特定地图服务 | 只在对应底图上用 |
关键结论是:入库统一存 WGS84,展示前再按需要的坐标系转换。反过来做——入库存 GCJ02、计算时再转回 WGS84——误差会累积,而且转换算法本身是近似实现,来回倒腾两次精度就掉了。coord_sys字段就是为这件事准备的,任何一条点位数据进库都必须标清楚它原本属于哪套坐标系,没标的直接打回。
4.2 批量转换脚本与往返误差验证
转换算法是公开的,实现不复杂,难点在于批量跑和验证。下面这份是精简版,只保留转换核心,工程里要加边界判断和异常捕获。
import math X_PI = math.pi * 3000.0 / 180.0 A = 6378245.0 # 椭球长半轴 EE = 0.00669342162296594323 # 偏心率平方 def _out_of_china(lng, lat): # 粗略范围判断,超出范围不做偏移 return not (72.004 <= lng <= 137.8347 and 0.8293 <= lat <= 55.8271) def _tf_lat(x, y): ret = -100.0 + 2.0*x + 3.0*y + 0.2*y*y + 0.1*x*y + 0.2*math.sqrt(abs(x)) ret += (20.0*math.sin(6.0*x*math.pi) + 20.0*math.sin(2.0*x*math.pi)) * 2.0/3.0 ret += (20.0*math.sin(y*math.pi) + 40.0*math.sin(y/3.0*math.pi)) * 2.0/3.0 ret += (160.0*math.sin(y/12.0*math.pi) + 320*math.sin(y*math.pi/30.0)) * 2.0/3.0 return ret def _tf_lng(x, y): ret = 300.0 + x + 2.0*y + 0.1*x*x + 0.1*x*y + 0.1*math.sqrt(abs(x)) ret += (20.0*math.sin(6.0*x*math.pi) + 20.0*math.sin(2.0*x*math.pi)) * 2.0/3.0 ret += (20.0*math.sin(x*math.pi) + 40.0*math.sin(x/3.0*math.pi)) * 2.0/3.0 ret += (150.0*math.sin(x/12.0*math.pi) + 300.0*math.sin(x/30.0*math.pi)) * 2.0/3.0 return ret def wgs84_to_gcj02(lng, lat): if _out_of_china(lng, lat): return lng, lat dlat = _tf_lat(lng - 105.0, lat - 35.0) dlng = _tf_lng(lng - 105.0, lat - 35.0) radlat = lat / 180.0 * math.pi magic = 1 - EE * math.sin(radlat) ** 2 sqrtmagic = math.sqrt(magic) dlat = (dlat * 180.0) / ((A * (1 - EE)) / (magic * sqrtmagic) * math.pi) dlng = (dlng * 180.0) / (A / sqrtmagic * math.cos(radlat) * math.pi) return lng + dlng, lat + dlat def gcj02_to_bd09(lng, lat): z = math.sqrt(lng*lng + lat*lat) + 0.00002 * math.sin(lat * X_PI) theta = math.atan2(lat, lng) + 0.000003 * math.cos(lng * X_PI) return z*math.cos(theta) + 0.0065, z*math.sin(theta) + 0.006 def bd09_to_gcj02(lng, lat): x, y = lng - 0.0065, lat - 0.006 z = math.sqrt(x*x + y*y) - 0.00002 * math.sin(y * X_PI) theta = math.atan2(y, x) - 0.000003 * math.cos(x * X_PI) return z*math.cos(theta), z*math.sin(theta)WGS84 转 GCJ02 是单向的偏移加算,想在反方向精确还原得用迭代。工程上的常见做法是:需要 WGS84 时,先用逆向公式做一次粗略还原,再正向转一次,把两次结果之差作为修正量反复叠两到三轮,误差能压到 1e-6 度以内。要不要这么做,取决于下游精度要求——展示用途完全够,做距离计算就得认真处理。
验证不能靠肉眼。我一般挑一批已知控制点,做「A 转 B 再转回 A」的往返测试,统计误差分布:
def roundtrip_check(points, to_b, to_a, tol=1e-6): bad = [] for lng, lat in points: x, y = to_b(lng, lat) x2, y2 = to_a(x, y) if abs(x2 - lng) > tol or abs(y2 - lat) > tol: bad.append((lng, lat, x2 - lng, y2 - lat)) return badtol设 1e-6 度,大约对应地面上十厘米以内。往返误差超过这个量级,说明转换链上有一环用错了方向。用gcj02_to_wgs84的近似实现去解wgs84_to_gcj02,误差通常在 1e-5 度上下,也就是一米左右,这个量级对大多数业务够用,但别指望拿它做测绘级应用。
4.3 精度分级:质心点决定了下游逆地理编码的准确率
坐标拿到手,还有一个问题必须回答:这个点代表什么?同样叫「某街道的坐标」,可能是几何中心,可能是街道办事处所在地,也可能只是某条记录的默认值。一定要分级标注,否则下游用起来没法判断可信度。
| 等级 | 点位含义 | 典型误差 | 能做什么 |
|---|---|---|---|
| L1 | 省级质心 | 数十公里 | 分区统计、地图打点 |
| L2 | 市级质心 | 数公里 | 城市级筛选 |
| L3 | 区县质心 | 数百米至数公里 | 区县级归属初判 |
| L4 | 乡镇街道质心 | 一百米至两公里 | 大致归属,不能做末端定位 |
| L5 | 建筑物入口或门牌点 | 五米至五十米 | 末端配送、导航终点 |
从 L1 到 L5,误差每降一个量级,采集成本大概翻一倍。做业务时先想清楚最低需要哪一级,不要一上来就追求 L5。门店选址用 L4 就够,末端配送必须 L5,中间的差别不是数据好坏,而是钱花在哪儿。
还有一个坑:几何中心不等于人口重心。有些区县形状狭长,几何质心落在山里或者水面上,拿它去做逆地理编码,判断出来的街道大概率是隔壁。改进办法是用 POI 点做加权质心,或者干脆放弃质心点,改用边界面多边形做点在面内判断——后者更准,代价是计算量大一些,下一章会讲怎么把这块性能压下来。
5. 避坑记录:合并全国数据时踩过的五类问题
数据合并这块,讲原理谁都懂,真正折磨人的是那些不报错、不抛异常、只在结果对不上时才暴露的问题。下面五条是实际踩过的,按「现象、原因、解决」记下来,遇到类似的直接对照排查。
5.1 按名称关联导致记录量暴涨
现象:两份数据一 join,原本十几万条变成几百万条,内存直接打满。
原因:关联键用的是name。同名乡镇在几个不同的市都有,名称一样、父级不同,join 出来就是笛卡尔积。最初没在意,是因为在样本量小的数据集上跑出来结果看着挺正常,全量一跑就崩。
解决:关联键只用ad_code,名称仅用于展示和候选召回。确实需要按名称关联时,先把父级路径拼进去做联合键,并且强制加level相等条件。写完 SQL 先看执行计划里的预估行数,估算值离谱就说明关联条件有问题。
5.2 坐标系混用导致点位整体偏移
现象:地图上所有点整齐地往一个方向偏了几百米,像是底图加载错了一层。
原因:一部分数据来自 A 类来源(WGS84),另一部分来自 B 类来源(GCJ02),入库时都没标coord_sys,混在一起按同一套坐标系渲染。
解决:入库强制非空校验,coord_sys为空直接拒绝写入。同时做一次抽样校验:随机取若干条记录,用两种坐标系分别渲染到对应底图上,看哪个能落在正确位置。这类问题的隐蔽之处在于单独看每条数据都像对的,只有混在一起才出错。
5.3 邮编覆盖率忽然掉到六成
现象:合并完发现只有约六成街道能查到邮编,之前抽样看着挺高。
原因:把邮编当成区划的一对一属性,用zipcode_ad里权重最高那条去填,填不上的就空着。深层原因是乡镇街道这一级本身邮编数据就稀疏,很多来源只到区县。
解决:分两级兜底。街道级查不到时,回退到区县级主邮编,同时在结果里标一个match_level字段说明是精确匹配还是上级回退。下游看到标记自己决定要不要用,比直接给个空值或者错误的精确值好得多。
5.4 撤并之后老地址查不到
现象:历史订单里的地址,用当前数据查,返回空。
原因:区划调整之后,旧编码被删了,只保留了新编码,历史数据里的编码成了孤儿。
解决:不做物理删除,valid_to置日期,查询时按业务时间点取对应版本。历史数据回刷时以订单发生时间为准,不能简单用「当前有效」这一条过滤条件。这个改动上线前要跟业务方对齐,因为同一个地址在不同时间点可能返回不同结果,看起来像 bug,其实是设计。
5.5 质心点落在水里,逆地理编码判错街道
现象:按坐标反查街道,边界附近的点经常被判到隔壁,抽查发现质心点本身位置就偏。
原因:直接用几何中心做质心,形状不规则或者带飞地的区划,中心很容易落到河面、山体或者别的辖区里。拿这种点做最近邻查询,结果自然不准。
解决:短期的办法是人工校准一批关键质心点,长期的办法是放弃最近邻,改用边界面做点在多边形内判断。最近邻查的是「离谁近」,点在面内查的是「在谁里面」,后者才是业务真正想要的语义。改完之后边界点的准确率提升很明显,代价是查询成本上升,需要配空间索引。
提示:这五条里有三条都能用同一条原则规避——任何跨源关联都先问一句「这个字段在不同来源里语义一样吗」。名称、编码、坐标,三者的语义一致性都要单独校验,别指望数据结构设计能自动兜住。
6. 进阶:用 R 树索引把「按坐标反查街道」压到毫秒级
把边界面用起来之后,性能就成了新瓶颈。几万个多边形,每次查询都遍历一遍做点在面内判断,单次耗时几十毫秒,批量跑起来就是灾难。解决办法是两级过滤:先用外接矩形做粗筛,再用精确几何判断做细筛,粗筛那一步交给 R 树。
import json from shapely.geometry import Point, shape from shapely.strtree import STRtree # 1) 载入街道级边界面,构建一次索引常驻内存 feats = json.load(open("townships.geojson", encoding="utf-8"))["features"] geoms = [shape(f["geometry"]) for f in feats] props = [f["properties"] for f in feats] tree = STRtree(geoms) # 构建一次,后续查询复用 def locate(lng, lat): p = Point(lng, lat) # 第一级:外接矩形粗筛,通常只剩个位数候选 cand_idx = tree.query(p) # 第二级:精确判断点是否落在多边形内 for i in cand_idx: if geoms[i].covers(p): return props[i]["ad_code"], props[i]["name"] return None, Nonecovers和contains的区别值得单独说一句:covers把边界上的点也算作命中,contains不算。街道边界上的点必须归到某一方,用covers更符合业务预期,代价是相邻两个面可能同时命中,取第一个返回即可,或者在候选里按面积小的优先。
索引必须常驻内存,不能每次查询重建。构建一次大概几百毫秒到一两秒,取决于多边形数量和节点复杂度,之后每次查询就是几次矩形比较加一次几何判断。实测对比(同一批数据,五万个街道级多边形):
| 方案 | 单次查询耗时 | 十万次批量耗时 | 内存占用 |
|---|---|---|---|
| 全量遍历 | 约 40 ms | 约 65 分钟 | 低 |
| R 树粗筛加精确判断 | 约 0.3 ms | 约 40 秒 | 中 |
| 纯矩形近似(不精判) | 约 0.05 ms | 约 8 秒 | 低 |
纯矩形近似快是快,但相邻多边形的外接矩形大量重叠,边界附近的点会判错,只适合做粗略预筛。两个量级左右的提升,对绝大多数业务已经足够,如果还要更快,下一步是给多边形做预处理——把每个面切成更小的瓦片,或者直接用网格索引替代 R 树。
日常维护上,我的习惯是把边界面数据打包成一次性加载的二进制文件,服务启动时读进内存,配合版本号做热更新。版本号挂在文件名上,新版本发布后老进程继续用老文件,滚动重启过程中不会出现半新半旧的混合状态,省去了很多解释成本。
坐标系转换这一环,我现在一律在入库时统一成 WGS84 并打标,展示层再按需转换,绝不在中间环节做二次转换。质心点能不造就别造,直接用边界面判断归属,准确率高一个档次。最早贪快用质心做最近邻,结果边界附近对不上,返工了三轮才改回点在面内的方案,这个教训一直记着。希望帮到你。
本文还有配套的精品资源,点击获取