首页 / 资讯中心 / 文章详情

自建内网地图服务:从经纬度清洗到GeoHash聚合的实践

自建内网地图服务:从经纬度清洗到GeoHash聚合的实践 ★ FEATURED ARTICLE
lingbot-map 这个名字最开始只是我手里一个临时脚本的代称把一堆躺在 CSV 里的经纬度变成网页上能看的点。后来这个脚本越写越完整变成了一个带数据导入、坐标纠偏、聚合渲染的地图数据处理小服务。如果你也遇到过“数据里有经纬度但不知道怎么展示”“不想每次开个地图服务都要申请各种 key”“内部数据不方便传给第三方地图平台”这类问题这篇文字就是我把它从零做起来的过程以及中间踩过的一些坑。我们不聊大而全的地图平台只聊一个能放在内网、自己掌控数据图层的小工具应该能帮你少走不少弯路。1. 为什么我不直接用现成地图平台要自己写一个 lingbot-map1.1 我的真实痛点散落的坐标数据没法看也没法用我手里的数据场景不算复杂但很典型一批门店或设备记录表里有lat、lng两个字段剩下的是业务类型、时间、负责人之类的属性。数据量是小几万条的时候用表格软件还能靠筛选凑合看一旦到了几十万条表格软件直接卡成幻灯片更别提把位置关系、密集成度搞清楚。最初我想的是接入商业地图服务注册开发者账号、创建应用、申请 key、研究配额限制。一套流程走下来发现真正麻烦的不是写代码而是三个问题。第一对公网接口的数据上传有顾虑有些原始记录包含了内部编码不适合送去外部服务做逆地理编码或存储第二配额和计费规则很琐碎日调用量超了就降级地图展示这种场景请求量波动又很大第三也是最核心的商业地图平台更擅长展示它们自己的 POI 数据对我这种“把自有业务数据打成图层”的需求产品形态并不完全匹配。折腾过一轮以后我意识到自己需要的不是一个“地图应用”而是一个“数据地图图层工厂”输入一条带坐标的记录输出一个能渲染在地图上的点或聚合块数据全程不离开内网。这就是 lingbot-map 最早的出发点。1.2 自建边界只做数据图层不做地图底座规划项目时我给自己划了一条非常明确的边界lingbot-map 不碰地图底图不去做瓦片服务不做路网搜索更不做在线导航。地图底图这个“画板”仍然来自开源瓦片数据lingbot-map 只负责“画板上层的业务数据图层”。打个比方底图是一张白纸lingbot-map 是在上面贴图钉和数据标签的人而不是造纸厂。这么划分有三个直接好处。一是开发量被压缩到一个周末就能出原型二是不涉及地图数据的版权和合规风险因为我不存储、不加工、不二次分发底图数据三是前端可以完全同源访问不用处理跨域调第三方瓦片的问题在内网部署也更干净。实际开发中这个边界帮我避免了很多诱惑。曾经有朋友建议我加一个“经纬度转地址”的功能从产品角度确实有用但如果走在线接口就破坏了内网数据闭环。我最后选择使用离线行政区边界数据完成“坐标落区”的能力虽然精度不如商业逆地理编码但胜在可控、免费、不依赖外网。1.3 目标清单和技术红线项目动工前我用几行文字列清楚了边界数据导入支持 CSV、Excel、GeoJSON统一清洗经纬度字段。坐标归一把不同来源的坐标统一到同一坐标系避免点位错位。行政区补全通过离线边界数据给每个点补上省市区字段。聚合渲染按地图缩放级别动态聚合减少前端渲染压力。部署形态Docker 编排能跑在内网的一台小服务器上。红线所有业务数据不访问外部接口不依赖在线瓦片服务实现核心功能瓦片可代理、可预缓存不保存任何非授权获取的地理数据只处理自己导入的数据。这个清单非常朴素但每一条都在后续开发中帮我兜住了底。如果一开始贪多想做成“地图全家桶”这个项目大概率又会变成一个吃灰的半成品。2. lingbot-map 的地图数据链路设计一条经纬度从清洗到呈现的流水线2.1 数据入口接受常见格式但字段规矩必须立好导入模块我做得比较宽容CSV、Excel、GeoJSON 都支持解析逻辑就是各自读一遍再标准化成内部结构。宽容归宽容字段约定必须严格业务主键id、纬度lat、经度lng、业务类型biz_type、时间戳ts是五个核心字段。业务字段可以多但那五个字段里缺任何一个这条记录都会被标成异常不会静默吞掉。一段典型的 CSV 长这样id,lat,lng,biz_type,ts 1001,28.2315,112.9876,store,2024-03-01 09:00:00 1002,28.2321,112.9901,device,2024-03-01 09:05:00导入时我会做三类基础检查经纬度范围是否合法纬度在 -90 到 90经度在 -180 到 180、是否存在重复主键、坐标是否明显偏离预期城市范围这个通过和现有数据的均值距离做粗筛。清洗的意义说过很多次但真正帮到我的不是“去掉几行坏数据”而是坏数据不会在聚合算平均值时把整个格子的中心点拖偏好几公里。这个教训花了我一个下午才排查出来后面细说。2.2 坐标系统一WGS-84、GCJ-02谁跟谁差多远地图坐标这关绕不开。不同来源的数据坐标系很可能是不同的我把最常见的情况列在下面坐标系来源常见表现WGS-84原始 GPS 设备、部分海外数据全球通用美国 GPS 原始输出GCJ-02国内地图产品输出的坐标、部分国产设备对 WGS-84 做了非线性偏移其他厂商专用坐标某些地图厂商在其 GCJ-02 基础上二次加密和 WGS-84 差距更大这三个坐标系之间没有简单的“加一个常量”就能互转。GCJ-02 对 WGS-84 的偏移是纬度和经度的非线性函数在城市区域可能只差几百米在某些山区可能差到几公里。如果不统一混着画到同一张地图上点位会成片错位。lingbot-map 的做法是所有原始数据入库前必须标明来源坐标系然后统一转换到 GCJ-02。我没有用在线转换接口而是在本地实现了一个坐标转换库。核心逻辑大概是这样def normalize_coord(lng, lat, source_crs): if source_crs wgs84: return wgs84_to_gcj02(lng, lat) elif source_crs gcj02: return lng, lat else: # 某些二次加密坐标先按其转换规则还原再统一 return vendor_coord_to_gcj02(lng, lat)选 GCJ-02 作为统一存储坐标系是因为我用的开源瓦片在国内区域也是基于这个坐标系对齐的统一成它之后点和瓦片能严丝合缝。如果你的部署环境完全使用 WGS-84 瓦片那就统一存 WGS-84。关键原则只有一条存储坐标系和渲染坐标系必须一致不一致就会出现“点在郊区”的灵异事件。2.3 逆地理编码与行政区补全离线也能落区聚合统计时我经常需要按省市区筛选。如果原始数据里没有行政区字段地图上看到的只是一个点没办法回答“某市有多少个门店”这种问题。所以我加了“坐标落区”能力本质是 point-in-polygon点是否落在多边形内的批量查询。实现思路不复杂维护一份离线行政区边界数据每个行政区对应一个多边形集合。为了不“每条数据都遍历全量边界”我先给每个行政区边界建了外接矩形索引查询时先筛出可能包含该点的少数几个行政区再做精确的射线法判断。这样做完一次落区的耗时从秒级降到了毫秒级。数据量大时落区操作是导入过程里最慢的一步。我的优化方式是分批处理每批 5 万条边导入边落区而不是等全量数据都进入内存再统一处理。离线边界数据本身也会更新所以 lingbot-map 里维护了一个region_version字段行政区划调整时只需要重新构建索引不用推翻整张数据表。2.4 GeoHash 网格聚合原理把散点变成区块聚合是 lingbot-map 的核心能力。地图上一万个点如果全画出来前端不仅卡视觉上也糊成一团。我需要按网格聚合成“格子点”每个格子显示数量。这里我选了 GeoHash而不是直接用行政区边界。GeoHash 的本质是把经纬度编码成一串字符串字符串越长代表的网格范围越小。因为它是基于 Z 序曲线做的空间编码相邻网格的前缀往往相同所以GROUP BY left(geohash, n)就能快速得到任意精度的网格统计。下面是一张常用精度对照表GeoHash长度网格宽约公里网格高约公里3156156439.119.554.894.8961.220.6170.1530.153聚合还有一个额外收益位置隐私保护。前端拿到的只是一个格子里的计数和格子中心点不再暴露每条记录的精确经纬度。这在给外部合作伙伴开放只读地图时特别有用。2.5 数据表结构示意两张核心表就够了数据库我用了 PostgreSQL空间数据量不大时一张普通表加索引完全能撑住。核心是两张表CREATE TABLE points ( id BIGINT PRIMARY KEY, lng DOUBLE PRECISION NOT NULL, lat DOUBLE PRECISION NOT NULL, province VARCHAR(64), city VARCHAR(64), district VARCHAR(64), biz_type VARCHAR(32), ts TIMESTAMP NOT NULL ); CREATE INDEX idx_points_city ON points(city); CREATE INDEX idx_points_lng_lat ON points(lng, lat);第二张表存的是聚合结果但不是所有缩放级别都存只存预统计的固定层级比如 4、6、8 位 GeoHashCREATE TABLE grid_stats ( geohash VARCHAR(16) PRIMARY KEY, grid_lng DOUBLE PRECISION, grid_lat DOUBLE PRECISION, level INT, total_count INT, biz_type VARCHAR(32) ); CREATE INDEX idx_grid_level ON grid_stats(level);实际查询里grid_stats并不是查所有缩放级别的唯一答案因为地图前端实时传入的 bbox 可能只覆盖某几个格子。更平滑的做法是直接对points表按left(geohash, n)实时聚合利用经纬度索引做范围限制数据量在百万条以内时速度完全可以接受。这个我在下一节展开。3. 后端聚合引擎和前端渲染接口的实现3.1 动态聚合按缩放级别切换网格精度前端地图缩放级别变了需要的网格精度也要变。放大到城市内部时4 位 GeoHash 的格子太大可能一个格子装下了整个区缩小到省级视角时7 位 GeoHash 又碎得没法看。所以我维护了一张缩放级别到 GeoHash 长度的映射表GEOHASH_LEVEL_BY_ZOOM { 3: 1, 4: 2, 5: 2, 6: 3, 7: 4, 8: 4, 9: 5, 10: 5, 11: 6, 12: 6, 13: 7, 14: 7, }聚合接口收到前端的zoom和可视范围bbox后先算出当前需要的 GeoHash 长度然后对points表做一次分组统计。核心 SQL 长这样SELECT left(st_geohash(geom, :precision), :plen) AS gh, count(*) AS c, avg(lng) AS clng, avg(lat) AS clat FROM points WHERE lng BETWEEN :minx AND :maxx AND lat BETWEEN :miny AND :maxy AND (:biz_type IS NULL OR biz_type :biz_type) GROUP BY left(st_geohash(geom, :precision), :plen)前端拿到的不只是格子数量还有格子中心点坐标。中心点用avg(lng)、avg(lat)而不是取格子中心是因为点通常在局部区域有聚集用均值中心能让聚合后的点更贴近真实分布视觉上不会出现“格子中心围着一圈空地”的别扭感。3.2 缓存设计让每个缩放级别只算一次动态聚合虽然能用但用户在页面上拖动一下地图前端就会产生好几个新 bbox 请求。每次都现算数据库压力会很大。我给聚合接口加了一层缓存key 的设计是aggr:{data_version}:{zoom}:{bbox_rounded}:{biz_type}bbox_rounded是关键我不会用前端传的精确 bbox 做 key而是把经纬度范围取整到 0.01 度。这样用户拖动时产生的几百个相近 bbox 会落到同一个缓存条目上否则缓存命中率会非常低。缓存长度设了 5 分钟数据导入接口只要更新data_version旧缓存自然失效。小部署我直接用了进程内字典存缓存没有先上 Redis。原因很简单量级没到。单机应用最多同时几十个聚合请求进程内内存存活足够。等以后需要多实例部署再把缓存层抽出来换成 Redis接口逻辑不需要大改。3.3 聚合 API 的响应格式直接给 GeoJSON减少前端计算聚合接口的返回我直接设计成 GeoJSON FeatureCollection。这样做的好处是前端拿到就能给图层插件消费不需要再在 JavaScript 里做字段到几何的拼接。{ type: FeatureCollection, features: [ { type: Feature, properties: { count: 132, geohash: wts3 }, geometry: { type: Point, coordinates: [112.93, 28.21] } } ] }单独提一点聚合接口不要加分页。很多人习惯性地给所有接口都上分页但聚合结果的“页”概念不应该存在。前端的地图视野是一个面用户想看的是当前范围内所有格子分页会让图层被截断用户体验很糟。明细接口才需要分页我把这两类接口彻底分开了。3.4 前端渲染开源地图库 三种图层模式前端我用开源地图库作为渲染核心加载瓦片和 GeoJSON 图层都足够省心。核心代码体现在自定义的pointToLayer回调里const featureLayer L.geoJSON(features, { pointToLayer: (feature, latlng) { const count feature.properties.count; const radius Math.sqrt(count) * 2; return L.circleMarker(latlng, { radius: Math.min(radius, 20), color: #ff6b35, fillOpacity: 0.6 }); } });这里故意用了Math.sqrt(count)而不是count本身来映射圆半径。因为圆的面积和 count 才接近线性关系如果直接用半径线性映射数量大的格子会异常突出小格子几乎看不见。前端还做了缩放事件的 debounce等用户停下来 200ms 以后再发请求避免拖拽过程里发几十个请求把后端打懵。4. 开发过程中被反复折磨的四个坑4.1 坐标系不一致点位整体偏出去几公里这个坑我记得很清楚。某次导入一批门店数据后地图上所有点都出现在目标城市的东北方向边缘整体偏移大概一两公里。一开始我以为是数据采集设备有问题抽查了原始文件里的几行坐标单独拿到在线地图里看位置是对的啊。排查链拉下来才发现问题在线地图产品默认坐标是 GCJ-02而我把这批坐标当成 WGS-84 写进了数据库再叠加投影绘制双重处理之后自然偏了。设备输出的坐标本身已经是 GCJ-02我入库时按 WGS-84 理解就相当于做了一次多余的坐标转换把点推偏了。修复方法很直接导入接口增加“来源坐标系”参数让用户显式告诉系统这批数据是什么坐标系导入期间先抽 10 个已知坐标点做转换前后对比验证误差小于 5 米再放行。后来每次接新数据源我都不再默认“全世界都是 WGS-84”。4.2 GeoHash 边界锯齿和跨网格聚合聚合功能刚上线时我在市级边界附近发现了一个很难看的现象本来沿着街道连续分布的点聚合成格子之后沿边界出现了一条锯齿状的断带。原因不复杂同一个真实聚落可能落在两个相邻的 GeoHash 格子里聚合后各自统计视觉上就被切开了。我试过两种处理方式。第一种是在统计时对每个格子的八邻域也做一次查询把邻域范围内的点合并到当前格子能明显改善锯齿代价是查询复杂度升高。第二种更简单在低缩放级别不展示点聚合改用热力图渲染让边界锯齿看起来像是在渐变而不是一条断裂线。最后我把两种方案结合了缩放级别大于 10 时用合并邻域的聚合缩放级别小于等于 10 时用热力图。教训是聚合不能只看 SQL 结果对不对一定要在地图上逐级缩放肉眼排查数据特征的“视觉正确”和“统计正确”同样重要。4.3 瓦片跨域与内网混合内容拦截lingbot-map 跑在内网 http 环境时前端页面加载开源瓦片源遇到了一堆问题。最常见的是混合内容拦截页面是 http 的瓦片源是 https 的某些浏览器直接不给加载。还有跨域限制导致瓦片请求被拒绝地图白屏。解决方式是在后端加一个瓦片代理路由/tiles/{z}/{x}/{y}后端去上游瓦片源拉取图片再作为本地资源传给前端。这样前端只请求同源地址不涉及跨域也不会出现混合内容。代理层还加了磁盘缓存常用级别的瓦片只回源一次后面直接读缓存文件。这个优化让页面在内网加载速度反而比直连公网瓦片更快了。给同样要做内网地图的朋友一个建议瓦片代理尽量做得“透明”前端代码不需要知道真正的瓦片源是谁只需要指向本地代理路径。以后要换瓦片源或者做离线预加载前端完全不用动。4.4 低配服务器内存被打满导入程序崩溃有一段时间我一次性导入 200 万条设备记录导入脚本把文件全部读进内存再做 GeoHash 和落区结果进程内存涨到 3GB 多小服务器直接卡死连 SSH 都一度失去响应。后来我把导入流程改成了批处理每批读 5 万条清洗、落区、生成 GeoHash、写库然后释放这批数据再读下一批。内存占用从 3GB 降到了不到 400MB。聚合接口的潜在风险也同步处理了不让 Python 把整个聚合结果都构造成内存列表而是分批从数据库游标取边取边生成响应。200 万条数据看起来很多但处理方式和几万条没有本质区别问题从来不是数据量本身而是我用“开发期的数据量思维”写了“生产期数据量的代码”。批处理听起来简单但真正执行时很多人会忘记给每批加日志和进度记录导致导入到一半不知道卡在哪。我后来在批处理循环里加了进度输出每 5 万条打一行日志排查问题省了大力气。5. 部署、实测和性能数字5.1 部署编排一条命令起服务lingbot-map 没有做成 SaaS而是保持“自己部署自己用”的形态。我用 Docker Compose 编排了三个服务数据库、后端 API、前端静态文件。一份最小配置长这样service: db: postgres memory_cache: 512MB tile: proxy: true cache_dir: /data/tile_cache环境变量里我特别做了DATA_VERSION每次全量导入完成后递增这个值聚合缓存就会自动失效。部署在内网时没有公网依赖唯一可能需要联网的是第一次拉取瓦片数据之后瓦片进了代理缓存断网也不影响已有区域的地图显示。5.2 性能实测50 万条数据在低配机器上的表现测试环境是单机 4 核 8GBPostgreSQL 数据库数据量 50 万条。聚合接口的冷启动耗时有代表性缩放级别GeoHash 位数返回格子数冷启动耗时 (ms)6392348010528517601471800420第一次查询最慢因为需要扫描经纬度范围并做分组命中缓存后相同区域的请求耗时能压到 10ms 以下。真正影响体验的不是单次聚合耗时而是前端连续拖动地图时产生的请求风暴。加了 debounce 和 bbox 取整缓存后热门区域的重复请求几乎全部命中服务器负载非常稳定。5.3 当前局限和后续优化方向这个工具能实用但我不准备无脑吹。它目前只支持点数据的聚合线和面数据没有做数据更新是批处理模式不是实时流式离线的行政区边界数据也需要手动维护行政区划调整后需要重建索引。后续我最想补三个能力一是给点数据加时间轴前端可以按时间段播放聚合变化这对分析门店客流、设备移动轨迹会很有价值二是把聚合结果做成物化视图导入完成后后台任务直接重建固定层级的统计表查询时连实时聚合都省了三是优化空间索引结构把left(geohash, n)这种前缀查询改成覆盖索引进一步压大包络区域查询的耗时。这套小工具带来的最大收获不是“地图好用”而是让我真正理解了数据可视化链路里那些容易被忽略的环节坐标系、网格边界、缓存粒度、批量导入。它们单独看都不难但串在一起任何一个不一致最终都原封不动体现在地图上。最后分享两个我反复用到的小习惯。新数据源接入后先取 50 条做“三点检查”坐标是否落在预期城市、行政区字段是否补全、缩放几级后聚合是否平滑。确认没问题再全量导入不要嫌麻烦边界问题在少量数据里很容易发现淹没在几十万条数据里就只能靠运气了。另外聚合粒度一定要和业务尺度对齐门店覆盖一个城市用 5~6 位 GeoHash跨省分布用 3~4 位如果丢开业务尺度只看“格子要好看”后面每次调整都要重建一次缓存浪费时间。如果你也在做类似的数据地图小工具希望这篇记录能帮你绕开我走过的那些弯路。
阅读完成 · 觉得有帮助?
咨询建站