做这个项目,起因其实有点狼狈。去年秋天我带队去山里做野外勘测,手机信号断断续续,在线地图加载转圈转到人崩溃。回来之后我就下定决心,要自己搭一套能离线跑、数据自己可控的地图服务。于是就有了“atlas”这个项目——名字没什么花哨含义,就是希望它像一本真正的地图册一样,把地理数据完整地装进去,需要的时候随时能翻开。
atlas解决的核心问题很简单:不再依赖外部在线地图服务,而是把OpenStreetMap(以下简称OSM)的开源地图数据拉到本地,用一套开源的矢量瓦片服务把数据发布出来,再配合前端渲染引擎做交互式地图展示。整套体系从数据导入、瓦片服务、前端渲染到路线规划,全部自托管,不依赖任何商业SDK。
这篇文章就把atlas从零到一的完整过程写下来,包括技术选型的思考、数据导入的细节、踩过的坑、以及最终的性能表现。如果你也想搭一套自托管的离线地图服务,或者需要在内部系统里嵌入一张不依赖外部服务的地图,这篇文章可以直接当作业来抄。
1. 内容整体设计与思路拆解
1.1 为什么是一张“地图册”
做地图服务,第一件要想清楚的事是边界。市面上的在线地图解决方案非常多,高德、百度、Mapbox、Google Maps,随便选一个都能快速集成。但它们的共性问题是:数据在别人手里,请求要走外部网络,遇到弱网环境就直接歇菜;而且涉及商用场景时,授权费用和合规成本会让人很头疼。
atlas这个名字本身就代表了方向——不做一个单点的地图调用,而是做一套能管、能查、能渲染的地图基础设施。就像传统的地图册,它把一整片区域的地理数据装订成册,用户可以按索引翻页查看任何角落。放到技术语境里,这套“地图册”由四层组成:数据层(OSM原始数据)、存储层(PostGIS空间数据库)、发布层(矢量瓦片服务)、渲染层(前端地图引擎)。
我给自己定的目标是:所有组件必须开源、必须能离线部署、必须有成熟的社区生态。这样不管项目将来是给内部系统做GIS底图,还是开放给外部用户使用,都不会被上游服务商卡脖子。
1.2 技术选型为什么是PostGIS + pg_tileserv + MapLibre GL
在atlas项目里,最终敲定的核心链路是:OSM数据 + osm2pgsql导入 + PostgreSQL/PostGIS存储 + pg_tileserv发布矢量瓦片 + MapLibre GL JS前端渲染。
这套组合不是我拍脑袋定的,而是对比过好几条路线之后筛出来的。先说PostGIS。PostGIS是PostgreSQL的地理空间扩展,相当于给数据库装上了处理点线面数据的能力。选它做存储层有两个原因:一是OSM的生态工具链对PostGIS的支持最成熟,osm2pgsql、osmosis这些工具都默认输出到PostGIS;二是PostGIS本身就是一个完整的GIS计算引擎,做空间查询、缓冲区分析、坐标转换都直接在SQL里完成,非常方便。
再来说瓦片服务这一层。选pg_tileserv的原因只有一个字:轻。它是一个用Go写的单二进制文件,不依赖Java、Node这些运行时环境,解压就能跑,内存占用极低,而且天然支持PostGIS数据库直接发布矢量瓦片。
前端渲染层我选了MapLibre GL JS,因为它是Mapbox GL JS的开源替代品,API接口基本保持一致,社区非常活跃。它支持矢量瓦片的高效渲染,可以完全自定义样式,前端交互响应速度比栅格瓦片方案快得多。
1.3 这套方案和商业地图SDK的本质差异
简单做一组对比,就能看出边界所在。
| 对比维度 | 商业在线地图服务 | atlas自托管方案 |
|---|---|---|
| 数据归属 | 归平台方所有 | 完全自主可控 |
| 离线能力 | 弱,依赖网络请求 | 天生支持,纯本地部署 |
| 网络依赖 | 必须公网请求 | 局域网或本机即可 |
| 商用授权费 | 按调用量计费 | 零成本(仅需遵守OSM版权协议) |
| 样式自定义 | 受平台限制 | 完全自由,可定义任意图层样式 |
| 部署复杂度 | 极低,接入即用 | 需要一定运维能力 |
从表格能看出来,atlas的侧重点很清晰:如果你只需要“几天就能上线一张地图”,那肯定直接选商业服务;但如果你需要的是“完全掌控的地图基础设施”,那商业方案反而会成为天花板。atlas更适合后者,也就是我常说的,为长期演进留出空间。
2. 核心细节解析与实操要点
2.1 不是所有地图数据都需要“全量下载”
OSM的全量全球数据(planet文件)超过60GB,而且还在持续增长。对于绝大多数项目来说,根本没有必要下载全部数据。atlas项目最初只覆盖国内全境,后来又追加了周边几个国家的一部分数据,总数据量控制在一个可接受的范围。
数据获取渠道,我推荐从Geofabrik下载按区域裁剪好的OSM数据包。Geofabrik是OSM社区最常用的数据镜像站,把全球数据按国家、州、省划分成不同粒度的文件,下载方便,更新也及时。
以国内数据为例,直接下载Geofabrik上的Asia分类下的china-latest.osm.pbf即可。这个文件大约是1GB左右的压缩格式,包含国内全境的完整OSM数据。如果只需要某个省的数据,可以先用osmium工具从全国数据里裁剪出来,命令也很简单:
osmium extract -b 112.0,38.0,120.0,43.0 china-latest.osm.pbf -o shandong.pbf这里的-b参数后面跟的是经纬度范围,格式是左、下、右、上。osmium是个非常好用的数据裁剪工具,支持按bbox、按行政区划、按geojson文件等多种方式裁剪,比我最早用的ogr2ogr要灵活得多。
2.2 osm2pgsql导入数据:参数决定成败
数据准备好了,接下来要做的是把.pbf格式的OSM数据导入PostGIS数据库。这一步是整套流程中最磨人的环节,踩坑概率极高,keys参数理解不到位,导入速度慢到怀疑人生。
先用一张命令图把核心步骤串起来。安装osm2pgsql之后,执行导入命令前需要先建好数据库和PostGIS扩展:
CREATE DATABASE atlas; \c atlas CREATE EXTENSION postgis;这里有一个非常容易忽略的坑:osm2pgsql连接数据库需要输入密码,如果你的PostgreSQL配置的是peer认证,导入时会直接报错。建议提前把数据库认证改成md5或者scram-sha-256,并且给atlas库单独建一个专用账号,权限最小化,避免在导入过程中一不小心把其他库搞挂了。
接下来核心导入,我用的命令是这样的:
osm2pgsql \ --cache 8000 \ --number-processes 4 \ --slim \ --hstore \ --output flex \ --style flex-config.lua \ -d atlas \ -U atlas_user \ -H localhost \ china-latest.osm.pbf逐个解释这些参数的含义。--cache这个参数是用来指定内存缓存的,单位是MB。它的作用是在导入节点和关系时,把一部分数据留在内存里,避免全部走磁盘读写。官方建议是给到物理内存的50%-70%。我当时机器是16GB内存,给了8GB,实测导入速度比默认的1GB快了三倍以上。
--slim模式表示导入时会保留中间数据在磁盘上,方便后续做增量更新和数据修正。如果只是做一次性导入且数据量不大,可以不开启这个参数来换速度,但atlas是要长期维护的地图服务,开启slim模式是更合理的选择。
--hstore参数非常关键。OSM数据里每个要素有大量的键值对标签,比如一条路的名称、路面类型、限速、车道数等。如果不开启hstore,这些标签会以hstore的列存储,查询起来很不方便;如果开启,所有标签都会被塞到一个jsonb字段里,做条件查询和属性渲染会灵活很多。
2.3 为什么用flex输出而不是传统方式
osm2pgsql历史上有两种输出模式:传统的pgsql模式和较新的flex模式。我在第一次导入时用的是传统模式,也就是指定一个style文件来映射OSM标签到数据库字段。后来发现传统模式的style定义表结构非常死板,新增一个字段要改一大堆配置文件,维护起来很痛苦。
flex模式的核心区别是:数据映射逻辑由Lua脚本控制,自由度极高,可以按要素类型生成多张表,比如单独把道路、建筑、水系、土地利用分到不同的数据表里,以后前端做图层渲染时直接按表查询,完全不用把所以数据混在一张表里再做类型过滤。
下面是一段简化的flex-config.lua配置片段,可以看到每个要素类型映射的关系和字段选择:
local tables = {} tables.roads = osm2pgsql.define_table({ name = 'roads', ids = { type = 'way', id_column = 'osm_id' }, columns = { { column = 'name', type = 'text' }, { column = 'highway', type = 'text' }, { column = 'tags', type = 'jsonb' }, { column = 'geom', type = 'linestring', projection = 3857 } } }) function osm2pgsql.process_way(object) if object.tags.highway then tables.roads:insert({ name = object.tags.name, highway = object.tags.highway, tags = object.tags, geom = object:as_linestring() }) end end注意geom字段我用了projection = 3857,也就是Web墨卡托投影坐标。这个初看可能觉得多余,因为PostGIS完全可以存4326经纬度坐标,然后在查询时再转换。但矢量瓦片服务在读取数据时默认期望的就是3857坐标系,如果存的是4326,每次瓦片请求都会附带一次动态坐标转换,性能上会有不小的损耗。
2.4 从Geofabrik选数据:先想清楚边界再动手
数据选择的逻辑是这样:OSM的数据虽然开放,但不同区域的完整度和精细度差异很大。欧洲和北美的数据覆盖程度相对高,很多地方甚至带3D建筑轮廓;国内的数据中小城市和乡村区域的覆盖率这些年也在快速提升,但一些偏远山区和新建路段仍然存在缺失。
如果你的应用场景只是城市级别的底图展示,建议直接下载对应城市所在省份的数据,不要贪多。数据包越大,导入时间越长,查询性能也会受拖累。我当时一开始下了整个亚洲的数据包,导入跑了快15个小时,后来裁剪到国内全境,时间直接缩短到1.5小时,性能提升立竿见影。
3. 实操过程与核心环节实现
3.1 启动瓦片服务:pg_tileserv与martin二选一
数据导入完成后,PostGIS里已经躺着几张完整的地图数据表。接下来要做的,是把这些表变成浏览器能直接读取的矢量瓦片。这一步我在atlas里先用了pg_tileserv做主力,后来为了验证方案的可替代性,也实测过Martin(另一个Rust写的矢量瓦片服务),最后两种方案都跑通了。
先看pg_tileserv的使用方式。我从GitHub的releases页面直接下载预编译的Linux二进制文件,解压后只需要指定数据库连接字符串和监听端口就能启动:
export DATABASE_URL=postgres://atlas_user:password@localhost:5432/atlas ./pg_tileserv --listen :7800启动之后,pg_tileserv会自动扫描目标数据库里的所有空间表,并把它们发布成vector瓦片服务。访问http://localhost:7800/index.json就能看到当前可用的所有图层列表。
如果你有多个数据库或者需要更灵活的图层组织方式,Martin可能更适合。Martin同样支持从PostGIS发布矢量瓦片,它的优势是配置文件化的方式组织图层,也支持从MBTiles等瓦片包直接读取。不过在atlas里,pg_tileserv已经足够满足需求,它有一个特别实用的功能:自动隔离和分页查询,可以给每个图层设置独立的访问权限,这在做内部系统时很有用。
3.2 坐标系和缩放级别:为什么瓦片切得“刚刚好”
矢量瓦片服务运行起来后,还需要理解一个基础概念:瓦片网格与坐标系的对应关系。
在线地图行业实际上已经形成了一个事实标准——Web墨卡托投影(EPSG:3857)。它将地球简化为一个正方形平面,然后把这个平面切成若干层级的瓦片。第0级只有一张瓦片,代表整个地球;每放大一级,瓦片数量按4倍递增。第18级时,整个地球已经被切成了超过680亿张瓦片。
这个底层的网格关系决定了前端渲染的精度。贴一段瓦片网格计算的示例,方便理解坐标到瓦片行列号的换算逻辑:
function lonLatToTile(lon, lat, zoom) { const x = (lon + 180) / 360 * Math.pow(2, zoom); const latRad = lat * Math.PI / 180; const y = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * Math.pow(2, zoom); return { x: Math.floor(x), y: Math.floor(y), z: zoom }; }在atlas里,我之所以选择支持从z0到z18的瓦片范围,是因为z0到z10的瓦片在实际查询中非常稀疏,也就是全球视角下只有少数几个要素被渲染。但设置完整范围可以保证前端地图在缩放过程中无缝衔接,不会出现某一层级出现空白的情况。如果数据量特别大,可以通过给关键表添加空间索引来优化瓦片查询速度,这是后面的性能优化重点。
3.3 前端渲染:接入MapLibre GL JS
服务端瓦片接口就绪后,前端渲染层是最直观的一步。atlas的前端部分我选用了MapLibre GL JS,因为它的矢量渲染能力极强,支持完整的样式自定义。
先上一段最简单的接入代码,展示如何让一张地图在页面上跑起来:
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>atlas地图</title> <meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no" /> <script src="https://unpkg.com/maplibre-gl@3.x/dist/maplibre-gl.js"></script> <link href="https://unpkg.com/maplibre-gl@3.x/dist/maplibre-gl.css" rel="stylesheet" /> </head> <body> <div id="map" style="width: 100%; height: 100%;"></div> <script> const map = new maplibregl.Map({ container: 'map', style: { version: 8, sources: { atlas: { type: 'vector', tiles: ['http://localhost:7800/tiles/{z}/{x}/{y}.pbf'], maxzoom: 18 } }, layers: [ { id: 'background', type: 'background', paint: { 'background-color': '#f2efe9' } }, { id: 'roads', type: 'line', source: 'atlas', 'source-layer': 'roads', paint: { 'line-color': '#888888', 'line-width': 2 } } ] }, center: [116.391, 39.907], zoom: 4 }); </script> </body> </html>前端接入有两点心得。第一,source-layer一定不能写错,它对应的是pg_tileserv发布的图层名,默认和数据库表名一致。写错的话控制台会报“Layer not found”的错误,但页面不会直接崩溃,排查起来需要耐心。第二,style里的version必须指定为8,MapLibre GL JS目前只支持style spec 8及其兼容版本,网上很多旧教程还在用version 5,照抄会报错。
3.4 路线规划模块:pgRouting还是OSRM
地图除了展示,另一个高频需求就是路线规划。atlas阶段二实现了点到点的驾车路线计算。
路线规划有两条现成的路:一条是PostGIS生态下的pgRouting扩展,在数据库层面完成路径计算并返回结果;另一条是独立的OSRM服务,它把OSM道路网络预计算成路由图,查询速度极快。
我实际两个都试了。pgRouting的优势是和PostGIS深度集成,SQL语句一次性搞定,部署省事;但缺点也很明显,国内路网复杂情况下计算耗时偏高,而且它对路网拓扑的构建需要自己处理单向道、转弯限制等细节,工作量不小。
OSRM则是为高性能路由设计的,预处理之后单次查询通常在毫秒级别。atlas最终选了OSRM做主路由引擎,搭建流程大致如下:
docker run -t -v "${PWD}:/data" ghcr.io/project-osrm/osrm-backend \ osrm-extract -p /opt/car.lua /data/china-latest.osm.pbf docker run -t -v "${PWD}:/data" ghcr.io/project-osrm/osrm-backend \ osrm-partition /data/china.osrm docker run -t -v "${PWD}:/data" ghcr.io/project-osrm/osrm-backend \ osrm-customize /data/china.osrm docker run -t -v "${PWD}:/data" -p "5000:5000" ghcr.io/project-osrm/osrm-backend \ osrm-routed --algorithm mld /data/china.osrm跑完这几条命令后,OSRM就监听在5000端口,可以通过HTTP接口查询路线了。像这样请求一个驾车路线:
curl "http://localhost:5000/route/v1/driving/116.391,39.907;121.474,31.230?overview=full&geometries=geojson"返回结果里包含整条路线的坐标点数组、距离和预估时间,前端可以直接把这些坐标点画在地图上。
3.5 效果验证:一张能用的离线地图长什么样
当所有模块串联起来之后,整个系统长这样:浏览器加载MapLibre GL JS页面,点击地图或输入坐标,前端请求pg_tileserv的矢量瓦片接口渲染地图底图;需要路线时,请求OSRM的路由接口,拿到坐标点后以线图层方式叠加到底图上。
在我实际测试中,使用本地局域网访问,地图瓦片请求的响应时间中位数在30ms到80ms之间。首屏加载只是从本地接口读取几十KB的矢量数据,再配合前端矢量渲染,远比在线栅格地图的加载速度快。整个系统的内存占用也不算高,pg_tileserv进程大约占30MB内存,PostgreSQL正常使用大约500MB到1GB,OSRM加上数据加载在2GB左右。对一台4GB内存的小服务器来说完全可跑。
4. 常见问题与排查技巧实录
4.1 osm2pgsql导入慢到想放弃?先检查这四件事
第一个用户最常见的坑是导入速度。我曾经遇到过导入中国全境数据跑了超过10小时还卡在90%的情况,刚开始以为是数据量太大,后来发现是配置不合理。
排查顺序是这样:先确认机器物理内存和swap大小,再用free -h看一下当前内存使用情况。如果内存充足(大于8GB),但导入速度依然很慢,接下来确认--cache参数是否真正生效。--cache的值如果大于可用的物理内存,操作系统会疯狂触发swap换页,速度反而降到一个可怕的水平。我实测的经验是,16GB内存的机器给10GB cache表现最好,给到14GB反而因为swap拖累,速度腰斩。
另外,--number-processes这个参数也不是越大越好。导入过程中四个进程分别处理节点、边、多边形等不同类型,但在数据落到磁盘写索引的时候,并发过高会导致磁盘I/O成为瓶颈。如果不清楚机器的I/O能力,建议保持默认或者设为4。
最后,别忘了检查磁盘存储空间。OSM数据导入PostGIS后,体积膨胀非常明显。中国全境的pbf文件是1GB左右,导入后加上索引和中间数据,可能会占用8GB以上的磁盘空间。我在一次实践中因为/var/lib/postgresql所在分区快满了,导入过程中数据库直接崩溃过一次,所以开始导入之前先用df -h确认剩余空间。
4.2 瓦片空白,地图什么也不显示
服务启动、前端接入之后,地图页面最常见的故障就是整张地图空白。这个问题涉及的环节非常多,我用排除法把它拆成三步。
第一步,确认瓦片接口本身能不能访问。直接用curl请求一张瓦片地址,比如curl "http://localhost:7800/tiles/roads/0/0/0.pbf",如果返回了一堆二进制数据,说明瓦片服务本身没问题。如果返回404,需要去pg_tileserv的index.json里确认一下图层名的准确拼写。
第二步,确认前端请求的source-layer和瓦片服务返回的图层名是否一致。MapLibre GL JS对图层名匹配非常严格,大小写、下划线都不能有偏差。我遇到过数据库表名是user_data,但前端写成了userdata,结果页面静默失败没有任何报错,排查了很久才发现。
第三步,确认图层样式是否设置了不可见的属性。有些样式规则会把线条的颜色值写成与背景色相同,或者线条宽度设置为0,这在视觉上等价于“空白”。建议调试时先把样式设置成高对比度的红色,能明显看到数据再说优化的事情。
4.3 要素错位或者坐标偏移,多半是坐标系惹的祸
如果你发现地图上的道路和底图对不上,偏差几个像素到几百米不等,那基本可以断定坐标系混乱了。我遇到过一次典型的场景:外部拿来一份shapefile数据,里面用的是GCJ-02加密坐标,而OSM数据是标准的WGS84坐标,两者混用导致整体偏移。
需要明确的是两类坐标系的差异。WGS84是全球通用的大地坐标,GPS采集的数据就是它。而GCJ-02是加密坐标系,主要应用于国内商业地图产品。OSM原始数据是WGS84坐标,不经过加密处理。如果直接把GCJ-02的数据叠到OSM底图上,会出现百米级别的偏移,这在城市道路上用肉眼就能看出来。
解决方案是写一套坐标转换逻辑,把外部数据统一转换成WGS84后再入库。如果你手头的shapefile是GCJ-02坐标,可以用一些开源的坐标转换库先做转换,或者用PostGIS的ST_Transform做投影变换。
4.4 性能优化:空间索引缺失是最常见的病根
整套服务跑起来之后,用户体验最直接的评判标准就是地图的流畅度。如果在拖动缩放时出现白块或者卡顿,大概率是瓦片请求的响应时间过长。
排查瓦片响应时间,可以先看PostgreSQL的慢查询日志,启用方式很简单:
SET log_min_duration_statement = 200; SET log_duration = on;这样所有执行时间超过200毫秒的SQL语句都会被记录下来。我在优化过程中发现,很多瓦片查询慢的根源就是没有给空间字段建索引。在PostGIS里,空间查询主要依赖GIST索引,建索引的命令是:
CREATE INDEX idx_roads_geom ON roads USING GIST (geom);建完索引后,瓦片接口的响应时间通常能从几百毫秒降到几十毫秒以内。另外,给频繁用于过滤的字段,比如highway、name加上普通的B-tree索引,也能明显提升按属性条件查询的性能。
4.5 常见问题速查表
把我在atlas开发和运行阶段遇到的高频问题整理成一张速查表,方便直接对照排查。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 导入数据时卡死 | --cache过大触发swap | 调低cache值,确保小于物理内存 |
| 瓦片接口404 | 图层名拼写错误 | 查看index.json确认图层名 |
| 地图渲染空白 | source-layer不匹配 | 核对前端source-layer与表名 |
| 要素位置偏移 | 坐标系不一致 | 统一转为WGS84并入库 |
| 瓦片响应缓慢 | 缺少空间索引 | 给geom字段建GIST索引 |
| 首屏加载慢 | 瓦片服务配置了过大的maxzoom | 按需降低最大瓦片级别 |
| 中文标注显示为方块 | 缺少中文字体资源 | 加载开源中文字体或选择带中文标注的样式模板 |
5. 踩坑实录与最终体会
整个atlas项目从零到落地,前后用了大约三周时间。这段时间里最大的收获不是学会了各种工具链参数的用法,而是理解了自托管地图体系真正的难点:它不是一个“装完就能用”的现成方案,而是一个需要根据数据情况和使用场景不断调优的系统。
最后的优化阶段,我把pm2的进程守护配置成了常驻服务,并把关键服务的日志轮转策略加入crontab,这样即使长时间不维护也不会把磁盘撑爆。
在这里再分享一个可以让中文地图渲染体验极大提升的小技巧:MapLibre GL的默认字体不包含中文字形,如果加载一般的地图样式,标注中文名的地方会显示成方块。解决方式是引入Noto Sans CJK的开源字体子集,用专门的字体服务器加载并合并到style中,走自定义的glyphs服务。我当时用了一个开源工具font-maker把Noto的常规字重切割成pbf字体片,替换掉默认字体路径后,中文地名全部正常显示了。如果你懒得自己切字体,也可以先直接用包含中文字符的CDN字体资源,但注意这会引入外网依赖,与全离线的目标冲突。
这个项目的扩展空间比预期大。数据更新方面,OSM数据是持续更新的,后续可以搭建osmosis同步机制,定期拉取增量数据并更新到PostGIS里;应用层面,atlas已经接入了几个内部系统的GIS底图需求,在校园地图、园区导航这类场景里表现很好。如果你也在考虑搭一套自己的地图服务,直接用这套方案起步,数据处理、瓦片发布、前端渲染、路线规划四条链路都打通了,后面不管是加图层还是接业务数据,都是在现有框架上做文章。