迪图
发布于 2026-10-20 / 15 阅读
0
0

矢量瓦片实战:用 PostGIS 的 ST_AsMVT 从零搭实时切片服务

矢量瓦片实战:用 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-layer

  • extent:内部网格分辨率,必须与 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

默认值不是最优值。经验起点:

场景

extent

buffer

常规路网/建筑

4096

64

细长线要素(管线)

4096

128

大面积面(地块)

4096

32

4. 必须缓存

没有缓存的实时瓦片服务上线第一天就会被打挂。

Cache-Control: public, max-age=3600

瓦片内容对大多数业务场景是准静态的。一小时的缓存窗口能挡掉绝大部分重复请求。

性能优化:按收益排序

物化 3857 几何列 + 双索引

如果源数据是 4326(最常见),每次瓦片请求都做一次 ST_Transform 开销很大:

ALTER TABLE features ADD COLUMN geom_3857 geometry;
UPDATE features SET geom_3857 = ST_Transform(geom, 3857);

CREATE INDEX idx_features_geom_gix  ON features USING GIST (geom);
CREATE INDEX idx_features_geom3857_gix ON features USING GIST (geom_3857);

建完跑 ANALYZE —— 这一步不是可选的。查询规划器需要最新统计信息才能决定用索引还是顺序扫描。批量导入后总是要 ANALYZE。

多分辨率简化列

比每次实时简化快一个数量级:

ALTER TABLE features ADD COLUMN geom_z0_5   geometry;
ALTER TABLE features ADD COLUMN geom_z6_10  geometry;
ALTER TABLE features ADD COLUMN geom_z11_15 geometry;

端点按 zoom 切换列。

CLUSTER 物理聚簇

CLUSTER features USING idx_features_geom3857_gix;

按空间索引重排表数据,让相邻瓦片读到的行在磁盘上物理相邻,缓存命中率显著提升。代价是一次大表迁移,适合静态底图层。

连接池

每张瓦片一个独立短连接,握手开销会吃掉总时间的一半。用 PgBouncer 之类的连接池,并让瓦片服务进程长驻。

验证索引是否真的用上了

EXPLAIN ANALYZE
SELECT id FROM features
WHERE ST_Intersects(geom, ST_MakeEnvelope(500000, 4600000, 501000, 4601000, 3857));

找 Index Scan using ... 或 Bitmap Index Scan。

看到 Seq Scan 说明有问题,常见原因:

1. 索引缺失或统计信息陈旧 → 跑 ANALYZE

2. 谓词不可索引 → 典型错误是 ST_Buffer(geom, 100) 出现在比较左侧,函数包裹了列

3. SRID 不匹配 → 列和查询 envelope 的 SRID 必须一致

什么时候不该用实时切片

实时方案不是万能的。以下情况应该用预切片(tippecanoe 离线生产):

场景

推荐

数据每天更新,查询需要实时

PostGIS 实时

数据几乎不变,访问量巨大

tippecanoe 预切片

数据密集区(如市中心)

预切片

数据稀疏区(如远郊)

实时

底图

预切片

业务动态图层(订单轨迹、告警点)

实时

稀疏金字塔是两者结合的实用方案:数据密集的区域预切片保存,数据稀疏的区域动态生成。只有包含两个及以上要素的瓦片才值得保存。

工具生态

实际项目里很少有人手写端点拼 SQL,通常是把这些函数包进现成瓦片服务器:

工具

语言

特点

pg_tileserv

Go

配置即用,每张表自动暴露 ST_AsMVT 端点,新手友好

Martin

Rust

MapLibre 生态主力,支持函数源 + 表源,性能强

Tegola

Go

自带 SQL 模板系统,多数据源,Web 端配置友好

tippecanoe

C++

离线生产,与 PostGIS 实时方案互补

理解了 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


评论