Java 生成 MVT 矢量瓦片:PostGIS 与 JTS 双路线实战
简介这份资源面向具备一定Java与前端基础、希望掌握矢量瓦片地图开发的地图应用开发者聚焦于用Java后端生成MVT切片并交由前端渲染的完整实现思路。压缩包共13个文件约2.1MB以7个js脚本、3个html页面和2个css样式为主另含1个备份文件涵盖Mapbox GL JS、mapbox-gl-draw绘制插件、turf地理计算及jQuery等前端依赖可直接运行示例页面查看切片加载与交互效果。内容围绕GeoJSON转MVT、坐标转换与几何简化、按缩放级别分块生成切片服务以及前端配置vector数据源与图层样式等关键环节展开帮助读者理解后端切片与前端调用的衔接方式。目前已有297人学习下载适合需要快速搭建MVT地图原型、对照示例排查切片加载问题的开发者参考。1. 用 Java 生成 MVT 切片从 PostGIS 到瓦片目录的一条完整链路地图数据量一上来把整张矢量表直接丢给前端渲染基本等于自杀。一个省级行政区划表动辄几十万要素GeoJSON 序列化后轻松上百兆浏览器解析直接卡死。MVTMapbox Vector Tile就是为解决这个问题出现的它把矢量数据按瓦片切分、按需加载还自带几何简化和属性裁剪。而 Java 在这个环节的位置很特殊——大部分团队的后端是 Java 写的数据躺在 PostGIS 里如果能在 Java 服务里直接把切片吐出来就不用额外维护一套 Node 或 Python 的切片服务。这篇讲的就是这条链路数据在 PostGIS切片逻辑用 Java 写输出标准 MVT 瓦片目录或直接走 HTTP 接口。适合两类人一类是后端要接地图需求、被 GeoJSON 性能坑过的 Java 工程师另一类是想把切片能力内嵌进现有服务、不想引入独立切片中间件的团队。核心工具是 JTS 做几何运算、ST_AsMVT 做数据库侧聚合两条路线各有适用场景下面会拆开讲。2. 先搞清楚 MVT 的坐标体系和瓦片编号规则2.1 Web Mercator 与瓦片金字塔的对应关系MVT 不是随便切的它建立在 Web MercatorEPSG:3857和 XYZ 瓦片金字塔之上。第 z 级把整个世界切成 2^z × 2^z 个瓦片每个瓦片默认 256 或 512 像素。经纬度先投影到 3857 的米制坐标再按瓦片范围归一化到 04096 的整数网格——这个 4096 是 MVT 规范里的 extent 默认值几何坐标最终以这个范围内的整数存储用 varint 编码压缩。关键公式要记牢给定经纬度 lon/lat瓦片编号是x floor((lon 180) / 360 * 2^z) y floor((1 - ln(tan(lat_rad) 1/cos(lat_rad)) / π) / 2 * 2^z)纬度那项是墨卡托投影的逆推注意 lat 要先转弧度。很多人第一次写切片x 算对了 y 总是偏八成是把 y 轴方向搞反了——XYZ 规范里 y 从上往下递增而墨卡托的 y 是往上递增的所以公式里有个 1 减。2.2 瓦片内坐标为什么要归一化到 4096假设一个瓦片覆盖 3857 坐标下 10000 米的范围如果直接存米制坐标一个点可能是 12345678.9浮点数编码又大又慢。归一化到 04096 后坐标变成整数用 protobuf 的 varint 编码小数字只占 1 字节。4096 是精度和体积的平衡点再大体积涨得快再小几何会明显锯齿。换算逻辑是先把要素的 3857 坐标减去瓦片左上角坐标再除以瓦片实际跨度乘以 4096。Java 里用 JTS 拿到 Geometry 后遍历每个 Coordinate 做这个变换。注意 y 轴要翻转因为 MVT 的 y 向下而 JTS 的 y 向上。// tileBounds: 瓦片在3857下的范围 [minX, minY, maxX, maxY] // extent: 通常4096 double spanX tileBounds[2] - tileBounds[0]; double spanY tileBounds[3] - tileBounds[1]; for (Coordinate c : geometry.getCoordinates()) { int px (int) Math.round((c.x - tileBounds[0]) / spanX * extent); // y轴翻转MVT的y向下JTS的y向上 int py (int) Math.round((tileBounds[3] - c.y) / spanY * extent); // px, py 即瓦片内坐标范围 0~4096 }这段代码是切片几何处理的核心。参数说明tileBounds 由瓦片编号反推extent 建议保持 4096 不要乱改改了前后端都要同步。实际生产中还要处理几何裁剪——超出瓦片范围的要素必须先和瓦片矩形求交否则坐标会溢出 04096 范围渲染时出现飞线。3. 两条技术路线数据库侧 ST_AsMVT 还是 Java 侧 JTS3.1 ST_AsMVT 路线让 PostGIS 干重活如果数据已经在 PostGIS 里最省事的做法是用 PostGIS 2.4 提供的 ST_AsMVT 系列函数。它在数据库内部完成几何裁剪、坐标变换和 protobuf 编码Java 只负责传瓦片编号、拿二进制、写文件或返回响应。这条路线的好处是数据不出库省掉网络传输和 JTS 内存开销几十万要素的瓦片也能扛。典型 SQL 长这样WITH bounds AS ( SELECT ST_TileEnvelope(#{z}, #{x}, #{y}) AS geom ), mvtgeom AS ( SELECT ST_AsMVTGeom( ST_Transform(t.geom, 3857), bounds.geom, 4096, 256, true ) AS geom, t.name, t.code FROM your_table t, bounds WHERE ST_Intersects(ST_Transform(t.geom, 3857), bounds.geom) ) SELECT ST_AsMVT(mvtgeom.*, layer_name) FROM mvtgeom;ST_TileEnvelope 直接根据 z/x/y 生成瓦片矩形省得自己算。ST_AsMVTGeom 的五个参数依次是原始几何、瓦片范围、extent、buffer、是否裁剪。buffer 给 256 是为了让跨瓦片要素在边缘有重叠避免接缝处断裂。最后一个参数 true 表示裁剪到瓦片范围如果做点数据可以设 false 保留缓冲区内的点。Java 侧调用就是普通的 JDBC 查询把 z/x/y 作为参数传进去拿到 byte[] 直接写文件String sql WITH bounds AS (SELECT ST_TileEnvelope(?, ?, ?) AS geom) ...; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, z); ps.setInt(2, x); ps.setInt(3, y); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { byte[] mvt rs.getBytes(1); // mvt 即标准 MVT 瓦片二进制 Files.write(Paths.get(z / x / y .pbf), mvt); } } }注意 ST_AsMVT 返回的是 byteaJDBC 用 getBytes 拿。如果瓦片为空查询会返回 null要判空否则写文件会 NPE。3.2 JTS 路线Java 侧完全掌控切片逻辑有些场景 ST_AsMVT 不够用数据源不是 PostGIS或者要在切片过程中做复杂的属性过滤、多表关联、动态样式。这时候就得在 Java 侧用 JTS 自己实现。流程是从任意数据源读出 Geometry用 JTS 的 Intersection 做裁剪手动做坐标归一化再用 protobuf 库编码成 MVT。JTS 裁剪的核心代码// tilePolygon: 瓦片在3857下的矩形用JTS构建 GeometryFactory gf new GeometryFactory(); Polygon tilePolygon gf.toGeometry(new Envelope(minX, maxX, minY, maxY)); // 裁剪只保留瓦片内的部分 Geometry clipped featureGeometry.intersection(tilePolygon); if (clipped.isEmpty()) continue; // 完全在瓦片外跳过 // 再做坐标归一化见2.2的循环intersection 是 JTS 里比较重的操作几十万要素逐个裁剪会慢。优化思路是先做包围盒快速排除用 geometry.getEnvelopeInternal() 和瓦片范围比不相交直接跳过能过滤掉大部分要素。真正需要 intersection 的只是跨边界的少数。编码环节需要引入 protobuf 的 MVT schema。常见做法是用 mapbox-vector-tile 这个 Java 库它封装了 JTS 到 MVT 的转换VectorTileEncoder encoder new VectorTileEncoder(4096, 256, false); encoder.addFeature(layer_name, Collections.singletonMap(name, 某要素), clippedGeometry); byte[] mvt encoder.encode();VectorTileEncoder 构造参数是 extent、buffer、是否自动裁剪。addFeature 的第二个参数是属性 Map值支持 String、Number、Boolean。注意 JTS 的 Geometry 传进去前要确保坐标系已经是 3857库不会帮你做投影转换。3.3 两条路线的选型对照维度ST_AsMVTJTS 侧切片数据源仅 PostGIS任意Shapefile、GeoJSON、MongoDB性能高数据库内完成中受 JVM 内存和 GC 影响灵活性低SQL 能表达的有限高可任意过滤和关联部署复杂度低无需额外依赖中需管理 protobuf 和 JTS 版本适用规模百万级要素十万级以内较稳我的经验是数据在 PostGIS 且逻辑简单无脑选 ST_AsMVT数据源杂、切片逻辑要跟业务代码深度耦合才上 JTS。两者也可以混用——底图用 ST_AsMVT业务图层用 JTS 动态生成。4. 避坑切片生成中最容易翻车的五个点4.1 瓦片边缘出现断裂或飞线现象相邻瓦片拼接处几何断开或者出现一条横跨屏幕的直线。原因通常是没设 buffer 或者裁剪逻辑不对。MVT 规范建议 buffer 设为 extent 的 1/16 左右4096 对应 256。如果 buffer 为 0跨瓦片要素在边界被硬切渲染时接缝明显。飞线则多半是坐标归一化时没做裁剪某个点坐标溢出 04096 范围被解释成极端位置。解决ST_AsMVTGeom 的 buffer 参数给 256JTS 路线在归一化前先 intersection 裁剪。4.2 中文属性乱码或丢失现象切片里属性值显示为问号或空。原因是 protobuf 的 string 编码要求 UTF-8而数据库连接或 JVM 默认编码不是 UTF-8。PostgreSQL 连接串要加?charSetUTF-8JVM 启动参数加-Dfile.encodingUTF-8。另外 ST_AsMVT 对字段类型有要求jsonb 和数组类型不会自动序列化需要先转成 text。4.3 瓦片体积异常大现象单个瓦片几百 KB前端加载慢。原因通常是属性字段太多或几何精度过高。MVT 会带上所有 SELECT 的字段如果表里有几十个字段全查出来体积翻倍。解决SQL 里只 SELECT 渲染需要的字段。几何方面可以在 ST_AsMVTGeom 前用 ST_Simplify 做简化或者降低 extent 到 2048。另外检查是不是把整张表的数据都塞进了一个瓦片——WHERE 条件里的 ST_Intersects 必须走空间索引否则全表扫描还全量输出。4.4 高并发下 JVM 内存暴涨现象JTS 路线在并发切片时频繁 Full GC甚至 OOM。原因是每个请求都创建大量 Geometry 对象JTS 的对象生命周期管理依赖 GC。解决限制并发切片线程数用信号量控制对同一瓦片的请求做结果缓存GeometryFactory 复用单例而不是每次 new。如果数据是静态的预生成瓦片目录比实时切片稳得多。4.5 瓦片编号算错导致 404现象前端请求的瓦片大量 404但数据明明存在。原因多半是 y 轴方向搞反或者 z/x/y 的边界判断缺失。z 级下 x 和 y 的有效范围是 0 到 2^z - 1超出要返回 404 而不是去查库。另外注意 TMS 和 XYZ 的 y 是反的如果数据源用 TMS 编号要转换y_xyz 2^z - 1 - y_tms。这个坑我在两个项目里各踩过一次血泪经验是写个单元测试用已知经纬度验证瓦片编号。5. 把切片接进 HTTP 服务缓存、并发与预生成策略5.1 用 Spring Boot 暴露 /tiles/{z}/{x}/{y}.pbf 接口实时切片最直接的落地方式就是写个 Controller。核心是把 z/x/y 解析出来调切片逻辑设置正确的 Content-Type 和缓存头。GetMapping(value /tiles/{z}/{x}/{y}.pbf, produces application/x-protobuf) public ResponseEntitybyte[] getTile(PathVariable int z, PathVariable int x, PathVariable int y) { // 边界校验避免无效查询 int max 1 z; if (x 0 || y 0 || x max || y max) { return ResponseEntity.notFound().build(); } byte[] mvt tileService.generate(z, x, y); if (mvt null || mvt.length 0) { return ResponseEntity.noContent().build(); } return ResponseEntity.ok() .header(Cache-Control, public, max-age86400) .contentType(MediaType.valueOf(application/x-protobuf)) .body(mvt); }Content-Type 必须是 application/x-protobuf前端 Mapbox GL 或 Leaflet 的 vector tile 插件靠这个识别。Cache-Control 设长一点矢量瓦片除非数据更新否则不变。边界校验那几行别省否则恶意请求 z30 会让数据库直接崩。5.2 预生成瓦片目录 vs 实时切片实时切片灵活但每次请求都要查库和计算QPS 高了扛不住。预生成是把所有瓦片提前算好写成{z}/{x}/{y}.pbf目录用 Nginx 直接静态服务性能拉满。代价是数据更新要重新生成存储占用大。选择依据数据天级更新、访问量大选预生成数据实时变化、访问量中等选实时切片加缓存。预生成的 Java 实现就是遍历 z 级和 x/y 范围调切片逻辑写文件for (int z minZoom; z maxZoom; z) { int max 1 z; for (int x 0; x max; x) { for (int y 0; y max; y) { byte[] mvt tileService.generate(z, x, y); if (mvt null || mvt.length 0) continue; // 空瓦片不写 Path p Paths.get(outDir, String.valueOf(z), String.valueOf(x), y .pbf); Files.createDirectories(p.getParent()); Files.write(p, mvt); } } }注意空瓦片不要写文件否则目录里全是 0 字节文件浪费 inode。判断空瓦片的标准是编码后长度为 0 或者没有 feature。5.3 用 Caffeine 做瓦片级缓存实时切片加一层本地缓存能挡掉大部分重复请求。Caffeine 是 Java 里比较顺手的缓存库按 z/x/y 做 key设置最大条目和过期时间CacheString, byte[] tileCache Caffeine.newBuilder() .maximumSize(10_000) // 最多缓存1万个瓦片 .expireAfterWrite(Duration.ofHours(6)) .build(); public byte[] generate(int z, int x, int y) { String key z / x / y; return tileCache.get(key, k - doGenerate(z, x, y)); }maximumSize 根据内存定一个瓦片平均 10KB 的话1 万条约 100MB。expireAfterWrite 控制数据新鲜度。注意缓存的是 byte[]如果切片逻辑依赖用户权限或动态参数key 里要带上这些维度否则会串数据。5.4 验证切片是否正确三个检查手段生成完怎么确认没问题第一用 QGIS 加载生成的 pbf 文件能正常显示说明格式对。第二用 mapbox-vector-tile 库反解打印图层名和要素数跟源数据比对。第三前端用 Mapbox GL JS 配一个最小页面把 tile 地址指过去看渲染结果和接缝。反解验证的代码VectorTileDecoder decoder new VectorTileDecoder(); VectorTile tile decoder.decode(mvtBytes); for (String layerName : tile.getLayerNames()) { VectorTile.Layer layer tile.getLayer(layerName); System.out.println(layerName features: layer.getFeatureCount()); }如果 featureCount 和预期差很多回去查 WHERE 条件和裁剪逻辑。如果图层名不对检查 ST_AsMVT 的第二个参数或 encoder.addFeature 的第一个参数。6. 进阶动态样式字段与多图层合并的取舍切片做到后面绕不开两个进阶需求一是同一份几何按不同样式渲染二是多个图层合并进一个瓦片减少请求数。动态样式的常见做法是在切片时把样式相关字段带上前端根据字段值决定颜色。但字段带多了体积涨我的习惯是只带一个分类字段前端用表达式映射。如果样式完全由后端控制可以在切片时按样式分组同一几何重复写入不同图层——代价是体积翻倍慎用。多图层合并是更实用的优化。一个瓦片请求返回多个图层比请求多次省往返。ST_AsMVT 路线可以用多个 CTE 分别聚合最后用 ST_AsMVT 的并行版本或者 UNION 合并。JTS 路线就是一个 encoder 里多次 addFeature图层名不同即可VectorTileEncoder encoder new VectorTileEncoder(4096, 256, false); // 图层1道路 for (Geometry g : roadGeometries) { encoder.addFeature(road, attrs, g); } // 图层2建筑 for (Geometry g : buildingGeometries) { encoder.addFeature(building, attrs, g); } byte[] mvt encoder.encode(); // 一个瓦片含两个图层前端拿到后按图层名分别设样式。注意图层顺序影响压盖关系后 add 的在上层。最后说个我自己的习惯每次改切片逻辑先拿一个已知瓦片做基准把生成的 pbf 反解成 GeoJSON 存下来改完再反解一次做 diff。这样任何几何或属性的意外变化都能立刻发现比前端肉眼看来得快。切片这活儿玄学不多坑都在坐标和边界上把这两块盯死基本就稳了。希望帮到你。本文还有配套的精品资源点击获取