资讯动态

自托管地图平台atlas:PostGIS+MapLibre实践

发布时间:2026/10/19 14:16:56 来源:尧图企业网站定制
前阵子我把自己的地图数据展示工具折腾完了项目代号就叫 atlas一开始只是想在页面上把散落各地的坐标点、轨迹、边界数据统一扔到一张图里结果越做越像一个自托管的小型地理信息平台。atlas 解决的是一个挺常见的问题平时做数据可视化要么被在线地图平台的配额和样式限制卡住要么涉及业务数据时担心脱域风险要么就是公共 API 对大量点数据支持太差。于是干脆自己搭了一套从数据存储、瓦片服务到前端渲染全部自闭环。这套方案对需要做室内外定位展示、城市数据盘点、设备轨迹回放或者单纯想摸清地图工具链的开发者都适用它能帮你把数据的“最后一公里”彻底攥在自己手里。这篇就把 atlas 的核心设计、技术选型、踩坑记录和部署维护过程完整写出来照着走能省掉大量试错时间。1. 项目定位与技术选型1.1 为什么需要一张自己的“地图集”很多人第一反应是直接用高德、百度、Google Maps 的 JS SDK 不就行了确实如果只是在地图上标几个点、弹个信息窗公共 SDK 三五分钟就能搞定。但 atlas 的定位不是“一个地图页面”而是“一层持续增长的地理数据底座”。我在实际使用中遇到几个刚性需求数据量一上来几千个点直接塞给前端 JS SDK卡顿和内存占用立刻显现公共平台往往建议你用它的数据可视化组件但这类组件基本是黑盒样式和交互都不好改。业务坐标点、巡检轨迹、设备分布这类数据很多都不能直接打在任何第三方平台的地图上数据出了自己的服务器就存在合规风险。公共平台的地图样式的定制能力都有限想做一套符合自己产品气质的暗色底图、高亮边界、动态流动线基本就得靠自己的图层渲染。多数据源整合时公共 SDK 只负责“画”不负责“管”。我更需要一个能统一存储、空间查询、切片缓存的地方也就是把数据层和渲染层彻底分开。所以 atlas 的核心理念就变成底层用空间数据库管数据中间用轻量 API 出数据前端用开源渲染引擎画图所有环节都在自己的服务器上闭环。1.2 技术栈怎么选才算稳选型阶段我对比了几条路线最后确定的组合是模块选型替代方案选择理由空间数据库PostgreSQL PostGISMySQL Spatial / SQLite SpatialitePostGIS 扩展成熟空间索引和查询函数极其丰富社区资料多后端 APIFastAPI UvicornFlask / Node.js Express异步性能好自带 OpenAPI 文档调试地理数据接口非常顺手前端渲染MapLibre GL JSLeaflet / OpenLayers支持矢量瓦片和 WebGL 渲染局部更新和动画效果好样式定制自由度高瓦片底图本地栅格瓦片 OSM 拓扑数据在线瓦片自托管闭环内网部署不依赖公网请求部署Docker Compose NginxKubernetes / 裸机服务数量不多Compose 足够投入维护成本最低这套组合最稳的原因在于PostGIS 是当前开源空间数据领域的绝对主力MapLibre 是 Mapbox GL JS 的开源延续版本WebGL 渲染表现很能打FastAPI 的代码量对我这种“能少写就少写”的人来说太友好了。提示如果只是做小规模原型直接用 Leaflet 加 GeoJSON 就能跑但如果目标是长期沉淀数据、支持复杂空间运算建议直接上 PostGIS别等数据多了再迁移。2. 数据准备与后端实现2.1 数据源整理别等写代码时才找数据atlas 加载的原始数据主要分几类城市行政区划边界、兴趣点POI列表、设备轨迹点、历史事件点位。这些数据一开始散在 CSV、Excel、GeoJSON、Shapefile 等各种格式里接口开发前必须统一处理成可入库的形式。踩过最大的坑是坐标系不统一。不同来源的数据可能分别用经纬度度和平面坐标米存储有的甚至带高程。我一直坚持的规范是数据库里统一使用 EPSG:4326WGS84 经纬度。入库前全部通过 GDAL/OGR 工具转换命令很简单ogr2ogr -t_srs EPSG:4326 -s_srs EPSG:3857 -f GeoJSON output.geojson input.shp如果只有一个 CSV 文件带经纬度两列也可以直接让 PostGIS 的 COPY 配合 ST_SetSRID 和 ST_MakePoint 完成坐标列到几何列的转换CREATE TABLE poi ( id BIGSERIAL PRIMARY KEY, name TEXT, category TEXT, geom GEOMETRY(Point, 4326) ); COPY poi (name, category, lon, lat) FROM /data/poi.csv DELIMITER , CSV HEADER; UPDATE poi SET geom ST_SetSRID(ST_MakePoint(lon, lat), 4326);我特意把原始经纬度列保留下来而不是删掉主要原因是在排查异常点时能快速比对源数据不至于为了看原始值还得翻 csv。2.2 用 PostGIS 建库并导入数据PostGIS 建库的步骤比较固定先创建扩展CREATE DATABASE atlas; \c atlas; CREATE EXTENSION IF NOT EXISTS postgis;然后用 ogr2ogr 导入 shapefile 或 GeoJSONogr2ogr -f PostgreSQL PG:hostlocalhost dbnameatlas useratlas passwordatlas -nln city_boundary -nlt MULTIPOLYGON city.geojson导入后会默认建好一个 city_boundary 表但索引需要自己补。空间查询若没有 GIST 索引一旦数据量过万操作延迟就完全不可控这个千万别忘CREATE INDEX idx_city_boundary_geom ON city_boundary USING GIST(geom);之后我还会习惯性地做两件小事一是用 CLUSTER 按几何索引重排物理顺序这样空间相关的范围查询能少读很多磁盘块二是针对轨迹、历史和 POI 这类高频查询字段建立 BTREE 索引CLUSTER city_boundary USING idx_city_boundary_geom; ANALYZE city_boundary;CLUSTER 会锁表如果是持续写入的线上库尽量安排在维护窗口执行。对 atlas 这种以查询为主的自用系统收益非常明显。2.3 地图瓦片服务与坐标系处理后端最重要的一层是瓦片服务。前端 MapLibre 加载地图时实际会按“缩放级别、x、y”三个参数请求瓦片。atlas 开放两类瓦片接口底图瓦片提前用工具切好存储在磁盘上由 Nginx 直接返回静态文件。数据瓦片实时从 PostGIS 查询对应范围内的要素动态生成 GeoJSON 返回给前端。数据瓦片的接口设计成符合切片习惯的形式app.get(/tiles/{z}/{x}/{y}.geojson) async def get_tile(z: int, x: int, y: int, layer: str poi): bounds tile_bounds(x, y, z) # 根据 x/y/z 计算经纬度范围 async with engine.connect() as conn: result await conn.execute( text( SELECT ST_AsGeoJSON(ST_Transform(geom, 4326)) FROM poi WHERE ST_Intersects(geom, ST_MakeEnvelope(:minx, :miny, :maxx, :maxy, 4326)) LIMIT 5000 ), {minx: bounds[0], miny: bounds[1], maxx: bounds[2], maxy: bounds[3]} ) features [] for row in result: features.append(json.loads(row[0])) return {type: FeatureCollection, features: features}这里有一个核心权衡如果每个瓦片都实时查数据库高并发下数据库会吃不消。我后来加了一层 Redis 做瓦片缓存把 z、x、y、图层名拼接成 keyTTL 设为 10 分钟效果立竿见影。瓦片的计算逻辑里爬过几个坑最常见的是坐标系搞混。前端 MapLibre 默认使用 EPSG:4326 或 EPSG:3857 的瓦片体系如果后端返回的坐标是平面投影坐标前端会把数据画到完全错误的位置而且这种错位在缩放级别切换时表现诡异容易让人误以为是代码问题。所以我在所有查询里都显式地写了 ST_Transform 或直接保证源数据已转为 4326绝不依赖隐式转换。3. 前端展示与交互功能3.1 地图容器与底图加载前端容器我用 MapLibre GL JS核心就是初始化一个 Map 实例指定中心点、初始缩放级别和底图样式。底图样式我用本地瓦片源的 JSON 来描述避免加载在线样式时的跨域和延迟问题import maplibregl from maplibre-gl; import maplibre-gl/dist/maplibre-gl.css; const map new maplibregl.Map({ container: map, style: { version: 8, sources: { osm: { type: raster, tiles: [/tiles/osm/{z}/{x}/{y}.png], tileSize: 256, }, poi: { type: geojson, data: /api/layers/poi, cluster: true, clusterMaxZoom: 14, clusterRadius: 50, }, }, layers: [ { id: osm, type: raster, source: osm }, ], }, center: [116.391, 39.907], zoom: 10, });MapLibre 的样式模型由 source 和 layer 两部分组成source 定义数据从哪来layer 定义怎么画。这种设计拆分得很明确业务上经常是一个 source 对应多种显示方式比如同一个 POI 数据源既能画散点也能画聚合圈。3.2 图层管理、点击查询与样式配置atlas 最常用的功能之一就是点击地图某个 POI 图标弹出详情面板。MapLibre 里通过 queryRenderedFeatures 可以拿到当前鼠标位置下的要素map.on(click, (e) { const features map.queryRenderedFeatures(e.point, { layers: [poi-point] }); if (features.length 0) { const props features[0].properties; document.getElementById(info).innerHTML h3${props.name}/h3 p分类${props.category}/p p坐标${features[0].geometry.coordinates}/p ; } });这里需要注意queryRenderedFeatures 返回的是“当前屏幕渲染结果”因此任何一个被遮挡的要素都可能拿不到。点击弹窗失效的问题十有八九是图层层级把被点图层盖住了调整图层顺序就行。配置样式时要灵活使用数据驱动属性。比如按 category 给 POI 分配不同颜色通过表达式实现circle-color: [ match, [get, category], 餐饮, #f28d62, 交通, #6a9fb5, 公共设施, #9bc158, #dedede // 其他默认灰色 ]这种表达式写法一开始不太习惯但熟练后非常强大。它把样式逻辑和业务数据解耦不用为了一个颜色分支写一堆 if-else 判断。3.3 搜索与定位功能怎么做搜索功能 atlas 分两级实现。第一级是空间关键字搜索直接用 PostgreSQL 的 ILIKE 模糊匹配适合名称、地址等字段第二级是范围搜索比如“找某坐标半径 3 公里内所有餐饮 POI”。第一级的 SQL 大概是SELECT id, name, category, ST_AsGeoJSON(geom) AS geojson, ST_Distance(geom::geography, ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography) AS distance FROM poi WHERE name ILIKE % || :keyword || % ORDER BY distance ASC LIMIT 20;第二级的核心是 ST_DWithin配合 geography 类型做米制距离筛选单位直接是米不用再手动做扇形角度换算SELECT id, name, category FROM poi WHERE ST_DWithin(geom::geography, ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography, 3000) LIMIT 100;前端定位我优先使用浏览器 Geolocation API再通过用户点击按钮调地图 flyTo 移动视野。对内网场景的定位atlas 做了另一个方案读取自定义设备上报的最近坐标不依赖浏览器定位这个在后期扩展时特别有用。4. 部署上线与运行维护4.1 Docker Compose 编排整套服务atlas 拆成了四个服务postgis、api、map-frontend、nginx。用 Docker Compose 编排时最核心的是把 PostGIS 数据目录挂载到宿主机否则容器一删数据就全没了。version: 3.8 services: postgis: image: postgis/postgis:16-3.4 container_name: atlas-postgis environment: POSTGRES_USER: atlas POSTGRES_PASSWORD: atlas POSTGRES_DB: atlas volumes: - ./pgdata:/var/lib/postgresql/data - ./init:/docker-entrypoint-initdb.d ports: - 5432:5432 restart: always api: build: ./api environment: DATABASE_URL: postgresql://atlas:atlaspostgis:5432/atlas depends_on: - postgis restart: always frontend: build: ./frontend ports: - 8080:80 depends_on: - api restart: always nginx: image: nginx:1.26-alpine volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro - ./tiles:/srv/tiles:ro ports: - 80:80 - 443:443 depends_on: - api - frontend restart: always这里提一个实操细节PostGIS 镜像首次启动时会自动执行 init 目录里的 .sql 文件因此我把建表、建扩展、导入基础数据的脚本全放进去而不是等服务起来后手工执行这样新环境从零到可用只需要一条 docker compose up -d。4.2 域名、HTTPS 与性能优化对外服务必须上 HTTPS否则浏览器在 Geolocation 权限上会有限制而且地理数据接口被明文抓包的后果也很严重。我用 Nginx 做了反向代理 gzip 缓存控制静态瓦片直接由 Nginx 返回动态 API 再转发给后端server { listen 443 ssl http2; server_name atlas.example.com; ssl_certificate /etc/letsencrypt/live/atlas.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/atlas.example.com/privkey.pem; gzip on; gzip_types application/json application/geojson image/svgxml text/css text/javascript; location /tiles/ { alias /srv/tiles/; expires 30d; add_header Cache-Control public; } location /api/ { proxy_pass http://api:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { proxy_pass http://frontend:80; } }性能优化上我做了几个决定瓦片文件统一 zlib 压缩后再存Nginx gzip_static 直接返回预压缩的 .gz 文件节省 CPU。接口层给每个数据瓦片设置了 ETag前端刷新缩放级别时命中缓存减少重复加载。PostGIS 表定期执行 VACUUM ANALYZE尤其是反复删除插入的轨迹表否则统计信息滞后会导致查询计划走偏。数据库连接池做了上限控制我用的 SQLAlchemy 连接池配置了 pool_size10max_overflow20避免多用户并发时把 PostgreSQL 进程压垮。4.3 数据备份与日常巡检atlas 的数据价值比服务本身更重要备份必须自动化。我用系统 cron 每天凌晨执行一次 pg_dump再把 dump 文件同步到另一块磁盘并保留最近 30 天#!/bin/bash # /usr/local/bin/backup_atlas.sh BACKUP_DIR/data/backups DATE$(date %Y%m%d_%H%M%S) pg_dump -h localhost -U atlas -F c -f $BACKUP_DIR/atlas_$DATE.dump atlas find $BACKUP_DIR -name *.dump -mtime 30 -delete日常巡检我建议重点盯三样东西磁盘空间、PostGIS 日志错误、瓦片缓存命中率。磁盘空间是最容易忽视的轨迹点数据和瓦片文件增长远比想象中快我加了一条定时任务当磁盘使用率超过 85% 时发邮件通知。虽然 atlas 是个人项目但数据丢了要重来一遍的痛苦实在不想体验第二次。5. 踩坑记录与问题排查5.1 边界数据错位刚开始导入城市边界后在地图上看到的边界整体向东南方向偏移了大概四五百米一开始我以为是瓦片底图的问题排查了很久才发现是坐标系“双重转换”。问题出在我先用 ogr2ogr 把数据转成了 EPSG:3857然后又用 ST_Transform 把 3857 当成 4326 做了一次“假转换”。PostGIS 不会校验你的几何值到底是哪个坐标系它只按照元数据里的 SRID 来理解。如果你告诉它这是 4326那它就认了哪怕实际值是 3857 的数值最后画出来自然错位。排查方法很简单拿一个已知地物的坐标点直接 SQL 查询对比几何值而不是在页面上靠肉眼判断SELECT ST_AsText(geom) FROM city_boundary WHERE name 某区;发现经纬度数值明显不在合理范围比如经度变成了 11639101 这种而不是 116.39基本就是 SRID 标错了。5.2 瓦片加载缓慢始终转圈atlas 上线后遇到最恼人的问题是地图上大范围区域一直显示灰色网格瓦片转圈半天加载不出来。逐层排查发现两个原因一是 Nginx 对瓦片请求的并发连接限制太紧我把 worker_connections 设为 128地图一拖动瞬间请求几十张瓦片连接直接排队。后来调到 1024 并开启了 keepalive问题基本消失。二是某些缩放级别下底图瓦片文件没有预生成完后端对不存在的瓦片返回 200 空响应而不是 404。浏览器把空文件缓存住之后刷新多少次都加载不出来。后来我把瓦片生成脚本改成增量模式并强制 Nginx 对缺失瓦片返回 404前端会自动跳过缺块不会一直卡在加载状态。5.3 点击事件失效与弹窗空白MapLibre 中点击图层没反应排查思路主要集中在三个点图层是否真的存在、图层是否被遮挡、事件绑定是否在底层图层加载之前执行。我遇到过图层确实存在但某个缩放级别下要素被聚合成了 cluster 番茄圆点绑定的点击图层写的是原始点图层自然触发不了。解决办法是同时绑定原始点和聚合点两层map.on(click, [poi-point, poi-cluster], (e) { if (e.features.length 0) return; const feature e.features[0]; if (feature.properties.cluster) { // 展开聚合层逻辑 const clusterId feature.properties.cluster_id; const source map.getSource(poi); source.getClusterLeaves(clusterId, 50, 0, (err, leaves) { // 展示聚合下的点位列表 }); } });弹窗空白则大多是因为获取 properties 时字段名大小写写错了GeoJSON 属性名区分大小写事后发现是后端字段叫 Name前端读 props.name 导致取值为空。统一前后端字段命名规范后这问题再没出现过。5.4 数据库连接池被打满有个阶段 atlas 上同时挂了几个批量导入任务接口层直接报“too many clients for role”。看 PostgreSQL 日志发现连接数飙到了 200超过默认 max_connections100。根因是 FastAPI 异步接口里用 SQLAlchemy 的同步连接池时连接没有正确释放。后来改用了异步引擎from sqlalchemy.ext.asyncio import create_async_engine engine create_async_engine( postgresqlasyncpg://atlas:atlaslocalhost:5432/atlas, pool_size10, max_overflow20, pool_pre_pingTrue )同时也把 PostgreSQL 的 max_connections 调到了 200并在 Nginx 层对 /api/tiles 加了简单的 IP 限流。这个问题彻底消失。5.5 权限控制缺失导致的数据泄露风险最初 atlas 对所有 API 一视同仁任何拿到地址的人都能下载完整的数据瓦片。对内网自用就算了一旦映射到外网就非常危险尤其 POI 里可能包含敏感设备坐标。后来在 FastAPI 层加了简单的 Token 鉴权前端请求头里统一携带 Authorization 字段。瓦片和查询接口都依赖这个校验只有通过验证才能拿到数据。虽然自用系统不需要太复杂但这一点绝对值得做哪怕只是为了防搜索引擎爬虫抓取和防止无关人员扫端口。6. 后续还能怎么扩展atlas 跑起来之后我最大的感受是“数据一旦进了自己的库很多事情就解开了限制”。我后续准备加的功能包括轨迹回放动画把历史轨迹点按时间排序通过 MapLibre 的 geoJSON source 定时 update 来实现移动效果主要用于巡检路径复盘。热力图图层利用 MapLibre 的 heatmap 图层类型对几千上万个点做密度展示不再需要前端逐点绘制。数据定时同步写一个爬虫每天拉取外部公开数据源经过去重、坐标清洗后入库让地图数据保持新鲜。多用户权限划分如果团队要接入就得按用户角色控制图层可见性和编辑权限前端配合做一个简单的管理面板。最后说一点个人经验做 atlas 这类地图项目前端的图层效果只是表象真正的核心在于空间数据的规范管理和查询效率。坐标系统一、字段命名规范、索引合理、瓦片缓存到位这四件事做好了项目自然就稳了。如果你正准备捣鼓自己的地图工具不妨把第一版的目标定小一点先把一条完整的数据链路跑通后面再加功能都会顺很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑