1. 需求拆解与整体方案设计
前阵子有个朋友找我,说他们要做个门店选址的小系统,前端要省、市、区县、街道四级下拉,选完还得在地图上把这块地方的轮廓画出来。他原话是“这个网上不是到处都有现成数据吗,随便拉一份不就完了”。我当时回他:这事儿一共三个难点——数据从哪儿来、怎么保证它是新的、怎么把它塞进一个响应够快的服务里。前两个决定了你后面三个月要不要反复返工,第三个决定了用户点下拉框的时候会不会转圈。
为什么“实时获取地图边界数据”这件事值得单独拿出来说?因为行政区划边界根本不是静态资源。每年都有撤县设区、乡镇合并、街道重新划分,一次区划调整在新闻里可能就是一句话,但在你的系统里就是一批失效的 code、一批画错的轮廓、一批对不上的报表。很多团队的做法是项目启动时下载一份“全国省市区边界 GeoJSON”,打进代码仓库,然后就默认它永远正确。等到第二年做数据核对,发现某个区的下级列表是空的,或者一个街道被拆成两个但系统里还是一个,排查起来极其难受。
我的判断很直接:边界数据必须当成“外部依赖”来管理,而不是当成“静态资源”来打包。它跟字典表、汇率、物流价格属于同一类东西——会变,但你又不希望它频繁打扰你。2024 年这套需求在技术上已经很成熟,难的不是“能不能拿到”,而是“拿得干不干净、更新得省不省事、联动得顺不顺”。
适合看这篇的人:正在做后台管理系统里省市区街道选择器的前端、需要给地图画行政区域轮廓的后端,以及被“数据网上随便下”这句话坑过一次的人。下面所有示例里的省市名我都用代号(S01 省、A 市、B 区、C 街道)代替,你自己替换成实际数据即可。
1.1 动手前必须定下来的三件事
第一个问题:你需要几级?省市区三级和加上街道的四级,是完全不同量级的工作量。三级的数据源相对好找,街道级颗粒度细、体积大、各地发布口径不一,很多平台要么不提供,要么只提供一部分。如果你业务上其实只需要到区县(大多数电商收货地址都是这个粒度),就别为了“看起来完整”硬上街道级,那会把数据维护成本翻好几倍。
第二个问题:你要的是“名称”还是“轮廓”?只做下拉框,你要的其实是一棵树,几十 KB 的 JSON 就够了。要在地图上画轮廓,你才需要 geometry。这两者的存储和传输策略完全不一样——树可以一次性全量推给前端,轮廓必须按需加载。很多人一开始不分,把带 geometry 的全量数据塞给前端,首屏直接几 MB 起步,手机上打开就是白屏。
第三个问题:更新频率要求多高?行政区划调整一年可能就几次,但因为你要做“实时”,往往误以为要每天全量拉一遍。我的经验是:每周做一次轻量比对(只比数量、比 code 列表、比哈希),发现变化再触发全量拉取。既不会漏,也不浪费带宽,更重要的是不会因为频繁请求被人限流。
1.2 数据来源怎么取舍
我把常见来源按“能不能用”横向列一下,比的是实用性,不是权威性。
| 来源类型 | 优点 | 问题 | 适用场景 |
|---|---|---|---|
| 某地图开放平台的行政区划接口 | 更新及时、层级完整、带轮廓 | 有调用配额、坐标体系与 GPS 有偏移、使用需遵守其条款 | 需要轮廓、需要街道级 |
| 政务数据开放门户的公开边界数据集 | 免费、可离线、精度较高 | 更新周期不定、格式不统一、部分只到区县 | 内部系统、不需要高频更新 |
| 开源社区整理的行政区划树 | 轻量、纯名称、接入快 | 往往没有 geometry、更新靠志愿者 | 只做下拉选择 |
| 从自身存量业务数据反推 | 与业务完全对齐 | 覆盖不全、无轮廓 | 已有地址数据的补全 |
实际项目里我基本都是“组合拳”:树用轻量源,轮廓按需从带 geometry 的源拉,拉下来落到自己的库里,前端永远只访问自己的服务。这么做的理由很简单——你不可能把外部服务的可用性当成自己的可用性。一旦对方限流或者字段调整,你的整个下单流程就挂了,而这时候你连回滚都做不到。
1.3 整条链路分四层
我把整条链路拆成四层,每层职责单一,出问题好定位。
- 采集层:按 code 列表去外部拉数据,做格式归一、坐标统一、去重、简化。
- 存储层:主表存行政区划树(code、name、parentCode、level、中心点),附表存 geometry(按 code 一行)。
- 服务层:对外只暴露“按 parentCode 查子级”和“按 code 查轮廓”两个接口,前面挂缓存。
- 前端层:下拉联动加地图渲染,轮廓懒加载。
四层之间用 code 作为唯一纽带,全链路共用一套行政区划代码,绝不混用名称做主键——名称会改,code 相对稳定,这是避坑的第一原则。我见过用名称做主键的系统,遇到“同名区”直接崩盘,两个不同城市的“新城区”互相覆盖,最后只能重构。
2. GeoJSON 到底怎么读:格式、坐标系与体积
拿到一份边界文件,打开一看是一大坨括号,很多人第一反应是“这咋用”。其实 GeoJSON 的结构非常规整,理解三个关键字就够:FeatureCollection、Feature、Geometry,剩下的都是属性。
2.1 结构拆解与几个致命细节
一个典型的行政区划边界文件长这样(我做了简化)。
{ "type": "FeatureCollection", "features": [ { "type": "Feature", "properties": { "code": "440300", "name": "示例市", "level": "city", "parentCode": "440000", "center": [114.05, 22.55] }, "geometry": { "type": "MultiPolygon", "coordinates": [[[[114.0, 22.5], [114.1, 22.5], [114.1, 22.6], [114.0, 22.5]]]] } } ] }几个必须记住的点:
- coordinates 的嵌套层数决定几何类型。Point 是
[x, y],LineString 是[[x,y],...],Polygon 是[[[x,y],...]](外面一层是环,第一个环是外环,后面的环是洞),MultiPolygon 再往外套一层。数错括号是新手最常见的错误,画出来要么是个点,要么什么都看不见。 - 坐标顺序是
[经度, 纬度],不是[纬度, 经度]。这和很多 GIS 工具的习惯相反,前端图表库又常常用[lat, lng],混着用必然出问题。 - 环必须闭合,第一个点和最后一个点要相同。部分数据源不闭合,渲染库会报错或者自动补,但你自己算面积时会算错。
- properties 里放什么由数据源决定,所以采集层一定要做字段映射,把外部字段统一成你自己的字段名,别让上层的代码依赖外部字段命名。
还有一个很多人不注意的细节:FeatureCollection 里的 features 顺序是不保证稳定的。有的数据源每次返回顺序都不一样。如果你做哈希比对时直接对整个 JSON 字符串取哈希,会得到“每次都变了”的假象。所以比对前要先按 code 排序再序列化,这是个小动作,但能省掉大量误报警。
2.2 坐标系:这一步错了后面全错
这是我最想强调的一段。地图上的坐标不是唯一的,同一栋楼在不同体系下的数值能差几百米。
| 坐标体系 | 常见来源 | 特点 |
|---|---|---|
| WGS84 | GPS 设备、国际通用数据 | 全球统一,无偏移 |
| GCJ-02 | 多数国内地图平台 | 与 WGS84 存在非线性偏移 |
| BD-09 | 某类地图产品 | 在 GCJ-02 基础上再偏移 |
踩坑现场通常是这样的:边界数据是 GCJ-02,你自己的定位点是 WGS84,叠在一起看,边界整体偏了。用户反馈“我明明站在路边,为什么显示我在小区里”。这不是代码 bug,是数据层面的口径不一致,而且你盯着代码看一整天也看不出来。
我的处理原则很简单:入库统一存 WGS84,展示时按目标底图实时转换。理由有三个:WGS84 是国际标准,长期最稳;转换是单向可逆的,反着存会累积误差;将来换底图供应商,你只需要改展示层一行配置。
转换本身不建议自己实现,用成熟的开源转换库即可,输入输出都是[lng, lat]数组,把整条 coordinates 递归遍历一遍替换。注意 MultiPolygon 嵌套深,写递归比写三重循环可靠得多,后者容易在遇到异常数据时越界。
提示:转换前后一定要做“回环校验”,也就是转过去再转回来,看偏差是否在可接受阈值内(一般要求小于 1 米)。如果偏差很大,八成是你把经纬度顺序搞反了,而不是算法有问题。
2.3 体积:不简化你会被流量费哭
街道级边界的数据量,如果原样存,一个省的街道轮廓加起来轻松上百 MB。为什么?因为官方发布的轮廓精度常常是米级甚至亚米级,一条海岸线能给你上万个点,而你在一个 375 像素宽的手机屏幕上看,根本看不出区别。
所以要做的第一件事是轮廓抽稀(简化)。常用的是 Douglas-Peucker 算法,核心参数是容差 tolerance,单位和你坐标系一致(度)。经验值如下。
| 使用场景 | 建议容差(度) | 效果 |
|---|---|---|
| 全国/全省总览 | 0.01 ~ 0.05 | 轮廓粗,点数降 90% 以上 |
| 市级地图 | 0.002 ~ 0.01 | 保留主要形状 |
| 区县级详情 | 0.0005 ~ 0.002 | 细节较完整 |
| 街道级高亮 | 0.0002 ~ 0.001 | 建议单独出一份文件 |
实测下来,一份 8 MB 的区县边界,容差取 0.005 简化后能压到 400 KB 左右,视觉上几乎无差别。想更省可以上 TopoJSON,共享边只存一次,体积能再降 30%~50%,代价是前端要多引一个解析库,值不值得看你项目对包体积的敏感程度。
注意:简化后的数据只用来看,不要用来算面积和判点位。判断某个坐标是否落在区域内这类业务,必须用未简化的原始几何,否则边界附近会大量误判。我的做法是库里存两份:
geom_full和geom_simple,展示用后者,判断用前者。
3. 采集实现:从“手动下载”到“定时比对”
这一节讲实操。假设场景是:你需要四级数据,每周检查一次是否更新,更新了就把差异拉下来。
3.1 先摸清数据源的规律
不管用哪个源,动手写脚本之前先干三件事:
- 打开浏览器开发者工具,手动点几下,把请求抓下来,看 URL 里的参数名和 code 的传法。
- 用同一个 code 请求两次,对比响应,确认有没有签名、时间戳、随机串之类的动态参数。
- 用不同层级(省、市、区、街道)各试一次,确认返回结构是否一致——很多平台街道级返回的是点而不是面。
这一步花半小时,能省你后面两天的调试。我见过有人直接照抄网上的脚本,结果对方接口早改了参数名,返回一直是空数组,他还在那儿排查字符编码问题。
3.2 采集脚本骨架
下面是我常用的结构,Python 写的,重点是“可重入”和“可断点续传”。
import json import time import hashlib from pathlib import Path import requests BASE_URL = "https://example-data-host/api/boundary" RAW_DIR = Path("./data/raw") RAW_DIR.mkdir(parents=True, exist_ok=True) HEADERS = { "User-Agent": "Mozilla/5.0 (compatible; boundary-collector/1.0)", "Accept": "application/json", } def fetch_boundary(code: str, level: str, retry: int = 3) -> dict: params = {"code": code, "level": level, "polygon": "1"} for i in range(retry): try: resp = requests.get(BASE_URL, params=params, headers=HEADERS, timeout=15) resp.raise_for_status() data = resp.json() if not data.get("features"): raise ValueError(f"empty features for {code}") return data except Exception as e: wait = 2 ** i print(f"[retry {i + 1}] {code} failed: {e}, sleep {wait}s") time.sleep(wait) raise RuntimeError(f"give up on {code}") def save(code: str, data: dict) -> str: # 先按 code 排序再序列化,保证哈希稳定 if data.get("features"): data["features"].sort(key=lambda f: f.get("properties", {}).get("code", "")) payload = json.dumps(data, ensure_ascii=False, sort_keys=True) digest = hashlib.sha1(payload.encode("utf-8")).hexdigest()[:12] path = RAW_DIR / f"{code}.{digest}.json" if not path.exists(): path.write_text(payload, encoding="utf-8") return digest def fetch_tree(parent_code: str, depth: int = 0): """先只拉树,不拉轮廓,速度快一个量级""" data = fetch_boundary(parent_code, "child") seen = set() for feat in data["features"]: props = feat["properties"] code = props["code"] if code in seen or code == parent_code: continue # 防止数据源把父级自己也返回 seen.add(code) save(code, feat) yield code, props if depth < 3: yield from fetch_tree(code, depth + 1)几个关键设计说明:
- 指数退避重试。对外部接口必须假设它会抽风,2 秒、4 秒、8 秒退避,比固定间隔重试友好得多,也不容易触发限流。
- 文件名带内容哈希。同一份数据不会重复落盘,天然实现了增量:哈希不变就是没变。
- 递归深度限制加 code 去重。别写无限递归,某些数据源的“子级”会返回自己和父级,无限循环能把磁盘写满。
- 限速。脚本里我省略了,但实际一定要加,比如每次请求之间
time.sleep(0.2),并发控制在 2~3。抓取速度和账号安全之间,永远选后者。
3.3 增量更新怎么判断
最朴素也最有效的办法:对比 code 集合加内容哈希。
def diff(old_list: list[str], new_list: list[str]) -> dict: old, new = set(old_list), set(new_list) return { "added": sorted(new - old), "removed": sorted(old - new), "unchanged": sorted(old & new), }每周跑一次“只拉列表不拉轮廓”的轻量任务(通常也就几十个请求),拿到 code 集合和上一版对比。有三种结果,处理方式完全不同。
| 情况 | 表现 | 处理 |
|---|---|---|
| 新增 code | 新设区、新设街道 | 拉轮廓入库 |
| 消失 code | 撤并 | 标记失效,不要直接删 |
| 集合不变 | 大多数时候 | 抽样比对哈希,全一致就结束 |
这里有个很实际的坑:消失的 code 千万别物理删除。用户的收货地址、历史报表、订单记录里都存着这个 code,你删了,历史数据就变成“未知区域”,报表直接缺一块。正确做法是加一个status字段(1 正常 / 0 已失效)和一个merged_to字段(记录它并入了哪个新 code),查询时默认过滤 status=1,历史数据仍然能翻译出名称。
提示:如果要把“旧 code 到新 code”的映射维护起来,建议单独建一张
boundary_mapping表,只增不改,作为审计和回溯用。区划调整期间,这张表的实际价值比你预想的大得多。
4. 存储与服务:让下拉框不转圈
数据采下来只是原料,真正决定用户体验的是存储结构和服务接口。
4.1 库怎么选
两种方案,按项目体量选。
方案一:单机文件加内存索引。把每个 code 的轮廓存成一个压缩后的.geojson,启动时把树(不含 geometry)全部读进内存字典,轮廓按需读文件,前面挂一层缓存。适合中小项目,几十万条树数据也就几十 MB 内存,零依赖,运维成本低。
方案二:PostGIS。把轮廓存成geometry类型,能直接做空间查询。适合有空间分析需求的项目,缺点是引入了一个较重的依赖,小项目有点杀鸡用牛刀。
我大部分时候选方案一,只有在确实需要“给定一个经纬度,返回它属于哪个街道”这类查询时,才上方案二。判断标准很简单:如果空间查询的调用量占比不到 5%,就本机算;超过 20%,再考虑迁到空间数据库。
4.2 表结构
走关系型数据库的话,我一般是这么设计的。
CREATE TABLE boundary ( code VARCHAR(12) PRIMARY KEY, name VARCHAR(64) NOT NULL, short_name VARCHAR(32), parent_code VARCHAR(12) NOT NULL DEFAULT '0', level SMALLINT NOT NULL, -- 1省 2市 3区县 4街道 center_lng DOUBLE PRECISION, center_lat DOUBLE PRECISION, status SMALLINT NOT NULL DEFAULT 1, merged_to VARCHAR(12), version INTEGER NOT NULL DEFAULT 1, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_boundary_parent ON boundary(parent_code, status); CREATE INDEX idx_boundary_level ON boundary(level, status); CREATE TABLE boundary_geom ( code VARCHAR(12) PRIMARY KEY, geom_full LONGTEXT, geom_simple LONGTEXT, bbox_min_lng DOUBLE PRECISION, bbox_min_lat DOUBLE PRECISION, bbox_max_lng DOUBLE PRECISION, bbox_max_lat DOUBLE PRECISION, simplified_tolerance DOUBLE PRECISION );几个设计点值得说明:
- 树和几何分表。树是高频读、小体积,几何是低频读、大体积。放一张表里,一个
SELECT *就能把几十 MB 拉出来,数据库连接池直接被打爆。 - 存 bbox 外接矩形。前端渲染时先用 bbox 判断区域是否在当前视野内,不在就不加载 geometry,这是让地图流畅的关键一步,收益比任何前端优化技巧都大。
- 加 version 字段。每次数据更新加一,前端缓存和 CDN 就能靠它做失效判断,比时间戳更准,时间戳会被时区和格式坑。
- level 用数字。字符串比较容易出错,数字排序方便,也省空间。
4.3 接口设计
只需要两个接口,别过度设计。
// 1) 按 parentCode 取子级(只返回轻量字段,不含 geometry) GET /api/region/children?parentCode=0 // 返回 { "code": 0, "children": [ { "code": "S01", "name": "示例省", "level": 1, "hasChildren": true } ], "version": 12 } // 2) 按 code 取轮廓(支持批量,单次建议不超过 20 个) GET /api/region/geometry?codes=S01,A01,B01&simple=1 // 返回 FeatureCollection第一个接口里的hasChildren字段非常重要。前端做联动时,如果不知道下一级有没有数据,就没法决定“选中后要不要发请求”。有了一棵树的是否叶子标记,可以避免大量空请求,也能避免用户选了半天发现下面什么都没有的尴尬。
第二个接口建议支持批量。地图初始化时往往要一次画好几个区域,一个个请求会让浏览器并发排队。但一定要限制批量上限(我一般设 20),防止有人拿它去做全量导出。
4.4 缓存策略
分三层来布。
| 层 | 存什么 | 失效方式 |
|---|---|---|
| 内存(进程内) | 完整的树 | 服务重启 / 订阅到版本变更事件时重建 |
| Redis | 常用 code 的 geometry、children 结果 | key 带 version,version 变了自然换新 key |
| 浏览器/CDN | children 结果、已加载的 geometry | 长缓存加 URL 带 version |
实测效果:一个省级下拉的 children 接口,走内存缓存后 P99 从 80ms 降到 3ms 以内;轮廓接口走 Redis 后命中率在 90% 以上,因为用户大多只看自己所在的那几个区域,长尾分布很明显。
注意:Redis 里存 geometry 时别存 JSON 字符串再序列化两次,直接存压缩后的字符串(比如 zlib 压一遍),体积能小 70%。一个 500 KB 的省界,压完大概 120 KB,对内存友好得多。
5. 前端联动:四级下拉怎么写得又稳又快
前端部分看起来最简单,其实问题最多。因为这里的 bug 都是“偶发”的,测试不容易复现,线上却天天有人报。
5.1 联动的核心是状态机,不是四个下拉框
很多人上手就写四个 select,每个 onchange 里清空后面三个、发请求。这样写出来的东西在快速操作时必然错乱:用户手速快一点,请求 A 还没回来,用户又选了 B,回来的是 A 的结果,把 B 的列表覆盖了。
正确思路是把“当前选择”当成一个状态机来管。
const state = { level1: null, level2: null, level3: null, level4: null, seq: { level2: 0, level3: 0, level4: 0 }, }; async function onChange(level, code) { // 1. 清空当前级之后的所有选择 for (let i = level + 1; i <= 4; i++) state[`level${i}`] = null; state[`level${level}`] = code; if (level >= 4) return; // 2. 递增序号,保证只有最新请求生效 const key = `level${level + 1}`; const mySeq = ++state.seq[key]; const children = await fetchChildren(code); // 3. 回来晚了就直接丢弃 if (mySeq !== state.seq[key]) return; renderOptions(level + 1, children); }这个“序号比对”的写法是我踩过坑之后固定下来的。不用它的时候,我在弱网环境(浏览器限速到 3G)下测过,快速切换省份有 10% 左右的概率列表错乱,而且很难复现。加上序号之后,一次都没出过。如果要更彻底,可以直接上AbortController把旧请求取消掉,性能更好,但序号法兼容性更广,看你的运行环境。
5.2 数据预加载与懒加载的平衡
用户填地址的心理预期是“我点一下就有”。所以策略要分级。
- 一级(省):随页面一起加载,或首屏后立即预取。数据量小,几十条,几十 KB。
- 二级(市):选中省之后立刻请求,响应通常 10~30 KB,加个 loading 就行。
- 三级(区县):同样请求,但可以提前预取——鼠标 hover 到某个市的时候就开始拉。
- 四级(街道):数据量大且多数场景用不上,严格懒加载。
还有一点值得单独提:把上一级选完就自动选中第一个的“贴心”行为去掉。看起来省事,实际上会让用户误操作,尤其在他只是想浏览一下的时候,选中的省被清空、变成另一个市,体验很差。
另一个很有效的优化是把常用省市的完整子树打进首屏包。比如用 localStorage 记住用户上次选择所在省,第二次进来直接秒开。这招在后台管理系统里效果特别明显,因为同一批用户反复访问同一批地区。
5.3 地图渲染
地图这块,非专业 GIS 项目我一般用轻量渲染库直接画 GeoJSON,不引重依赖。要点如下。
- 先算 bbox 再缩放。拿到 FeatureCollection 后遍历所有坐标算外接矩形,然后
fitBounds。不做这一步,地图会停在一个莫名其妙的位置。 - 点和面的绘制顺序。面永远比线、点先画,否则点会被盖住。
- 高亮用图层切换而不是改样式。每次改样式会触发重绘,把整个图层重画一遍。正确做法是维护两个图层(底色层加高亮层),只在高亮层里放当前选中的那一块。
- 交互命中测试交给渲染库。别自己写“点在多边形内”的算法,简化后的图形自己算容易误判,而且性能差。
用户点击地图上某个区域反查属于哪个 code,这个需求也挺常见(点地图选地址)。实现方式是给每个 feature 的 properties 带上 code,点击事件直接读e.features[0].properties.code,再拿它去更新下拉框状态。前提是你画上去的 feature 本身就是行政区域,而不是合并后的大多边形。
5.4 边界情况自查清单
这部分是我整理的一张表,前端联动每次上线前对着过一遍。
| 场景 | 期望行为 |
|---|---|
| 某市下无区县(直筒子市) | 第三级禁用或隐藏,不能卡住流程 |
| 某区县下无街道数据 | 第四级显示“请选择”但可空,不算必填 |
| 用户快速连续切换上级 | 下级列表只反映最后一次选择 |
| 编辑回填(改地址) | 按 code 逐级回填,不依赖名称匹配 |
| 名称重复(同名区) | 一律按 code 处理,显示时带上级名称区分 |
| 已失效的历史 code | 显示“原 XX(已撤销)”,允许用户重新选 |
最后一条特别容易忽略。用户三年前下的单,地址里的区已经撤并了,你打开详情页要显示名称,如果查询直接过滤掉 status=0 的记录,就会显示成空白。所以展示类查询要带上失效数据,选择类查询才过滤。这两个查询走的是不同接口,别图省事合成一个。
6. 常见问题与排查实录
这一节全是我和身边人实际遇到过的,按出现频率排。
6.1 问题速查表
| 现象 | 最可能原因 | 排查动作 |
|---|---|---|
| 下拉框某级一直是空的 | parentCode 层级传错,或该级确实无数据 | 直接查库SELECT count(*) WHERE parent_code=? |
| 地图画出来是横的或斜的 | 经纬度顺序反了 | 检查 coordinates 是否[lng, lat] |
| 轮廓整体偏移几百米 | 坐标系不一致 | 确认边界与底图是否同一体系 |
| 边界画出来只有一个小三角 | Polygon 环没闭合或嵌套层数错 | 打印 coordinates 长度和首尾点 |
| 首屏加载特别慢 | 一次性拉了带 geometry 的全量数据 | 看 Network,超过 1 MB 就该拆 |
| 数据更新后前端还是旧的 | 缓存没失效 | 检查 version 是否透传到 URL 和响应头 |
| 街道级接口报 400 | 该源不支持该层级 | 换源,或降级到区县级 |
| 偶发下拉列表错乱 | 并发请求互相覆盖 | 加请求序号或 AbortController |
6.2 几个不那么显然的坑
坑一:把“省直辖县级行政区”当成错误数据。有些省下面直接挂县级单位,没有地级市这一层。如果你的代码写死了“省下面是市,市下面是区”,这类数据就会被丢掉。解决办法是不要硬编码层级数字,而是靠 parentCode 关系构建树,渲染时如果某节点直接跳到第三级,就跳过中间那一级下拉。
坑二:跨省的飞地。某些区域在空间上和它的行政归属地不连在一起。如果你用“取省市轮廓再合并”的方式生成地级市边界,飞地会丢。所以每一级的 geometry 都要单独从数据源取,不要自己拼。
坑三:MultiPolygon 里混着空环。我遇到过一次,某个小岛的坐标数组是空数组,导致整个 MultiPolygon 渲染失败,整张地图白屏。采集层要加清洗,过滤掉坐标点少于 4 个的环(少于 3 个点构不成面,加上闭合点至少 4 个)。
坑四:把 center 当成“几何中心”。数据源给的 center 往往是政府驻地或标注点,不一定在多边形内部,尤其形状奇怪的区。如果你要拿中心点做地图初始定位,最好自己用 bbox 中心,或者用“面内点”算法算一个保证落在多边形内的坐标。
坑五:忽略数据源的使用条款。这条不是技术问题,但影响最大。某些平台的边界数据对使用场景、存储时长、二次分发都有要求。动手之前把条款读一遍,别等做完了才发现不能用。这也是我优先推荐“政务公开数据加自建缓存”的原因之一。
6.3 更新运维的几条心得
跑顺之后,日常运维其实很轻,但有几个习惯建议养成。
第一,每次更新都留一份快照。文件名带日期和哈希,出问题能一键回滚。我见过上游数据发版发错的情况,某个市的轮廓变成了另一块地方,如果没有快照,你只能干等对方修。
第二,更新后自动跑一次自检。自检项包括:总数量变化是否超过 5%(超了就告警,可能是源出问题)、有没有 code 重复、有没有孤儿节点(parentCode 找不到对应记录)、每个 geometry 的 bbox 是否合理(经度在 -180 到 180,纬度在 -90 到 90)。这几项能挡住九成以上的数据事故。
第三,把数据版本暴露出去。我一般在管理后台的页脚显示当前边界数据版本和更新时间。用户反馈“某个区选不到”的时候,你第一件事就是确认他看到的版本是不是最新的,比在那儿猜快得多。
第四,街道级数据单独发版。街道变化比区县频繁得多,如果和区县数据绑在一起发,会导致整个包每次都更新,前端缓存全部失效。分开之后,区县数据可能半年不动,街道数据每月小改,互不影响。
6.4 关于“实时”两个字的一点个人看法
标题里写“实时获取”,但说实话,行政区划数据不存在真正的实时需求。它的变化频率是以月甚至年计的,你每秒去拉一次,除了给对方增加压力、给自己增加风险,没有任何收益。
我更愿意把“实时”理解成**“随时可以拿到最新版”**,落地就是三句话:有可靠的比对机制、有可回滚的版本、有清晰的更新时间。做到这三点,业务上就已经是实时的了——用户永远看到的是当前正确的那一份,而不是三年前打进代码仓库里那一份。
真要提升时效,值得投入的地方不是把定时任务从每周改成每小时,而是把“变更发现”做得更聪明:监控自己数据库里出现的 code