矢量瓦片实战:用 PostGIS 的 ST_AsMVT 从零搭实时切片服务
一个查询、一次往返、一份二进制响应。这是最优雅的 WebGIS 数据分发方案,而它的核心只有三个 SQL 函数。
问题场景
你有一个 Web 地图需要瓦片,数据已经在 PostGIS 里。传统做法是离线切一套静态瓦片金字塔——这意味着一次跑几个小时的构建任务,以及一份很快就会过期的数据副本。
ST_AsMVT 提供了另一条路:直接在数据库里、基于实时数据、用一条查询生成矢量瓦片。
诱人的部分显而易见。失败模式则不那么显然:
拖动地图时低层级瓦片要好几秒,因为编码了几千个根本看不见的细节多边形
几何没有按层级简化,zoom 4 的海岸线还是全精度
每个请求都重新生成,因为没有任何缓存
坐标错了,因为几何没有转到 Web Mercator
每一个都有确定的解法。这篇文章就是把这些解法一次讲清。
核心链路:三步一循环
ST_TileEnvelope → 算出这个瓦片在世界坐标里的方框
↓
空间索引过滤 → 用 && 走 GiST 索引筛掉绝大多数行
↓
ST_AsMVTGeom → 裁剪 + 量化到 0–4096 瓦片内部网格
↓
ST_AsMVT → 聚合成 MVT 字节流理解这四步,实时矢量瓦片服务就不再是黑盒。
ST_TileEnvelope:算瓦片范围
PostGIS 3.1+ 提供这个函数,一行拿到瓦片在 EPSG:3857 下的 box2d:
SELECT ST_TileEnvelope(10, 851, 418) AS geom;3.0 之前要手算:
SELECT ST_MakeEnvelope(
-20037508.34 + x * (40075016.68 / POWER(2, z)),
-20037508.34 + y * (40075016.68 / POWER(2, z)),
-20037508.34 + (x + 1) * (40075016.68 / POWER(2, z)),
-20037508.34 + (y + 1) * (40075016.68 / POWER(2, z)),
3857
);其中 40075016.68 是 Web Mercator 的完整周长。
ST_AsMVTGeom:把几何搬进瓦片坐标
这是整条链路里最容易被忽视、却最关键的一步。它做三件事:
1. 把 3857 几何映射到 0–extent 的瓦片内部网格
2. 按 buffer 保留跨瓦片边缘溢出
3. 按 clip_geom 决定是否裁掉超出的部分
ST_AsMVTGeom(
geometry geom,
box2d bounds,
integer extent DEFAULT 4096,
integer buffer DEFAULT 256,
boolean clip_geom DEFAULT TRUE
)buffer 是新手最容易设错的参数。默认 256 是经验值,不建议设为 0。设为 0 会导致相邻瓦片之间出现肉眼可见的缝隙——因为跨界的线要素在两个瓦片里都被裁断了,谁也没画全。
clip_geom 对线数据建议保持 TRUE。设为 FALSE 会让一条跨越很多瓦片的线在每个瓦片里都写入完整几何,是明显的性能浪费。
注意:它对在该分辨率下退化为空的几何返回 NULL。这意味着外层查询必须过滤掉 NULL,否则聚合会出错。
ST_AsMVT:聚合成字节流
bytea ST_AsMVT(
anyelement row,
text name DEFAULT 'default',
integer extent DEFAULT 4096,
text geom_name DEFAULT 'geom',
text feature_id_name DEFAULT NULL
)参数说明:
row:整行记录,传t.*即可,自动抽取几何列和所有其他列作为属性name:图层名,对应前端的source-layerextent:内部网格分辨率,必须与ST_AsMVTGeom的 extent 一致geom_name:几何列名,子查询里别名不是geom时必须显式指定feature_id_name:哪一列作为 Feature 的 id,影响前端交互时的高亮状态保留
关键:ST_AsMVT 只做编码,不做任何坐标变换。它假定传入的几何已经在瓦片内部坐标空间。直接喂原始地理几何进去会得到错误结果,而且不会报错——这是最难排查的一类 bug。
完整端点查询
把零件拼起来:
-- :z, :x, :y 由请求参数注入
WITH
bounds AS (
SELECT ST_TileEnvelope(:z, :x, :y) AS geom
),
mvtgeom AS (
SELECT
ST_AsMVTGeom(
ST_Transform(t.geom, 3857), -- 投到母坐标系
bounds.geom, -- 瓦片范围
extent => 4096,
buffer => 64,
clip_geom => true
) AS geom,
t.id,
t.name,
t.category
FROM features t, bounds
WHERE t.geom && ST_Transform(bounds.geom, 4326) -- 走 GiST 索引
)
SELECT ST_AsMVT(mvtgeom.*, 'features', 4096, 'geom')
FROM mvtgeom
WHERE geom IS NOT NULL; -- ← 必须过滤对应的 HTTP 端点:
@app.get("/tiles/{z}/{x}/{y}.mvt")
def tile(z: int, x: int, y: int):
body = pool.connection().execute(TILE_SQL, {"z": z, "x": x, "y": y}).fetchone()[0]
return Response(
bytes(body),
media_type="application/vnd.mapbox-vector-tile",
headers={"Cache-Control": "public, max-age=3600"}
)四条决定成败的规则
1. 先过滤,后变换
WHERE t.geom && ST_Transform(bounds.geom, 4326)&& 走 GiST 索引,在任何昂贵操作之前就把绝大多数行淘汰掉。
反过来写——先变换每一行再求交——会让索引完全失效,查询在每个 zoom 层级都变慢。这是实时瓦片服务最常见的性能事故。
2. 按 zoom 简化
zoom 4 时一个瓦片覆盖数千公里,256 像素的瓦片每像素解析约 360 / (2⁴ × 256) = 0.088° 经度。在这个分辨率下发送全精度海岸线是纯粹的浪费。
用 ST_Simplify,容差按层级动态调整:
CASE WHEN :z < 6 THEN ST_Simplify(geom, 0.5)
WHEN :z < 10 THEN ST_Simplify(geom, 0.05)
WHEN :z < 14 THEN ST_Simplify(geom, 0.005)
ELSE geom
END低层级容差大,把一条河简化成几个拐点;高层级保留细节。
ST_SimplifyVW(Visvalingam-Whyatt)按面积容差简化,视觉保形更好,适合对形状敏感的场景。
3. 显式设置 buffer 和 extent
默认值不是最优值。经验起点:
理解了 ST_AsMVT / ST_AsMVTGeom / ST_TileEnvelope 这三个函数之后,回头看 pg_tileserv、Martin 的配置文件,会发现它们本质上就是把你手写的 SQL 模板参数化了。函数是地基,工具是脚手架。
MVT 规范里的一个关键约定
如果你要自己写解码器或做数据还原,需要知道一条容易被忽略的规则:绕向(winding order)。
多边形可能有"洞",洞也是用多边形环表示的。规范要求:
外环(exterior ring)必须顺时针
内环(interior ring)必须逆时针(在屏幕坐标系下观察)
内环必须紧跟其所属的外环
违反这条规则的后果是:单个环渲染成实心多边形;两个同向环渲染成两个重叠的多边形而不是一个带洞的多边形。
注意:MVT 坐标系中 Y 轴正方向朝下。 这与很多 GIS 库的习惯相反,是绕向判断出错的高发原因。
参考资料
PostGIS 官方文档(ST_AsMVT / ST_AsMVTGeom / ST_TileEnvelope)— https://postgis.net/docs/
Mapbox Vector Tile Specification — https://github.com/mapbox/vector-tile-spec
Mapbox 矢量瓦片标准指南 — https://docs.mapbox.com/data/tilesets/guides/vector-tiles-standards/
tippecanoe — https://github.com/felt/tippecanoe
Martin — https://github.com/maplibre/martin
pg_tileserv — https://github.com/CrunchyData/pg_tileserv