PMTiles 取代傳統 tile server:HTTP Range Request 的單檔魔術
沒有底圖、tile、vector tile 的基本概念? 建議先看 Web 地圖底圖是什麼?vector vs raster、tile pyramid、style spec 一次搞懂。
在 上一篇底圖比較 提到「自架需要 100GB+ 儲存」,我把自架直接排除了。但其實有第三條路:PMTiles。
PMTiles 是 Protomaps 出品的新型 tile 格式。它的核心想法很瘋狂:
把上千萬個 tile 打包成 1 個檔案,丟到 S3 / R2 / Cloudflare Pages,瀏覽器靠 HTTP Range Request 只讀需要的位元組。
沒有 tile server、沒有 Docker、沒有資料庫。你可以把它想成「靜態檔案上的 tile 服務」。
TaxMap-TW 的 7,747 個村里 polygon 就是這樣上線的:一個 5.7 MB 的 .pmtiles 檔放在 Cloudflare Pages,瀏覽器只讀當前視窗需要的 chunks。整個地圖服務沒有任何 server。
這篇拆解 PMTiles 的設計,以及實戰怎麼產一個。
為什麼 PMTiles 想取代傳統 tile server
傳統做法是這樣的:
- 用 tippecanoe 把 GeoJSON 切成 vector tiles,輸出成 MBTiles(一個 SQLite 檔,內含上萬條 tile blob)
- 跑一個 tile server(tileserver-gl、martin)讀 MBTiles
- tile server 暴露
/{z}/{x}/{y}.pbfHTTP endpoint - 瀏覽器透過 MapLibre 請求 tile
這個架構的痛點:
- 需要 server:得有一台機器跑 24/7
- 需要 SQLite 隨機讀:tile server 要把 MBTiles 開著、不停讀
- 冷啟動慢:Cloud Run / Lambda 第一次請求 tile 要載入整個 SQLite
- 延展性與成本:流量大時 server 變瓶頸
對小型 / 靜態網站來說,光是「為了底圖而養一台 server」就讓很多人卻步。
不過先把話說公道:tile server 的這些「麻煩」反過來也是它的能力。它能即時更新——資料一改、下一次請求就拿到新 tile,不用重切;能在伺服器端依參數過濾,回傳不同子集;能依登入身分或權限產出不同的 tile。這些都是把資料封進一個靜態檔的 PMTiles 做不到的。所以它不是「過時」,而是換了一組取捨。下面講的痛點,是針對「資料不常變、不需要動態查詢」的場景。
PMTiles 的核心想法
PMTiles 直接把問題拆掉:先把所有 tile(z/x/y 索引到 pbf binary)排成一個檔案、前面附上一份目錄(directory),然後把這個檔案丟到任何支援 HTTP Range Request 的地方(S3、R2、Cloudflare Pages、GitHub Pages 都行)。之後瀏覽器需要某個 (z, x, y) 的 tile 時,會先讀目錄查到那塊 tile 的 byte offset 和 length,再發一個像 Range: bytes=12345-67890 的請求,CDN 就只回那段 binary,MapLibre 直接吃。
整個流程沒有任何 server。.pmtiles 就是個靜態檔,CDN 加速、邊緣節點、cache 全都能用——前提是 host 真的支援 Range Request。它得在回應帶 Accept-Ranges: bytes、對 Range 請求回 206 Partial Content,跨網域用時還要設好 CORS。S3、R2、Cloudflare Pages、GitHub Pages 都 OK,但自架 nginx 或某些 CDN 設定不見得預設開。上線前用 curl -I -H "Range: bytes=0-99" 看一眼回的是不是 206,最省事。
HTTP Range Request 是什麼?
HTTP/1.1 早就有的功能(規格出自 1997 年的 RFC 2068,後來併入 1999 年的 RFC 2616),但很多人沒用過。
瀏覽器送出:
GET /villages.pmtiles HTTP/1.1
Host: tiles.example.com
Range: bytes=12345-67890
伺服器回應:
HTTP/1.1 206 Partial Content
Content-Range: bytes 12345-67890/5998619
Content-Length: 55546
<那段 binary>
關鍵點:伺服器只回那個範圍,不會把整個 6 MB 檔案傳給你。
這個能力一直存在,PMTiles 只是聰明地用它來實作「單檔 tile 服務」。
PMTiles vs MBTiles 比較
| 項目 | MBTiles | PMTiles |
|---|---|---|
| 格式 | SQLite(gzip 壓縮的 tile blob) | 自訂二進位 (header + directory + tile blobs) |
| 需要 server | 是(tile-server-gl / martin) | 否 |
| 部署 | server + SQLite 檔 | 一個 .pmtiles 檔 + 靜態 host |
| 客戶端讀取 | 透過 server HTTP API | 瀏覽器直接 HTTP Range |
| Cloud-native | 不友善(要 server 跑 SQLite) | 完美(純靜態檔) |
| 工具支援 | 老牌、生態廣 | 較新但 tippecanoe 直接支援 |
| 適合場景 | 自架 tile 服務、企業內部 | 靜態網站、無 server、低成本 |
對 Cloudflare Pages、Vercel、GitHub Pages 這類只能放靜態檔、不能跑 server 的託管,PMTiles 幾乎是唯一不用另外架服務就能上 vector tile 的選項。
PMTiles 的限制:什麼時候不該用
寫到這裡好像 PMTiles 全面碾壓,但它換來的好處是有代價的。Protomaps 官方自己就有一篇〈You Might Not Want PMTiles〉,我自己用下來,下面幾個情況我不會選它:
- 資料常變動:PMTiles 是不可變的單一檔。改一個村里的幾何,就得整包重切、重傳、讓 CDN 重新快取。TaxMap 的村里界一年才更新一次,這成本可以接受;如果是每天甚至每小時更新的資料,傳統 tile server 的「改了就生效」反而省事。
- 需要動態查詢或權限控管:PMTiles 出去的是固定的一份檔,沒辦法依使用者身分、登入狀態回不同內容。要「付費才看高解析、依角色給不同圖層」這種需求,還是得有一層 server 把關。
- 巨型資料集:檔案越大、目錄越深,一次 tile 請求可能要先讀好幾跳目錄才找到 byte offset,Range Request 的來回次數跟頻寬都會上去。幾 MB 到幾百 MB 很舒服,但到了幾十 GB 的全球底圖等級,就要認真評估 CDN 命中率與請求數,不是無腦丟上去就好。
簡單說:資料靜態、規模中小、不需要伺服器端邏輯時,PMTiles 很香;反過來就回去用 tile server。
3 步驟產一個 PMTiles
完整實戰:把一份 GeoJSON 變成可以丟 CDN 的 .pmtiles。
1. 裝 tippecanoe
tippecanoe 是把 GeoJSON 切成 vector tile 的 CLI。原來是 Mapbox 出的,但他們從 2020 停更,現在用社群維護的 felt fork:
brew install tippecanoe
# 或從 source build
git clone https://github.com/felt/tippecanoe.git
cd tippecanoe && make -j && sudo make install
注意 Mapbox 原版
tippecanoe已 2020 年停更,felt fork 是現役版本。它的--output看副檔名自動決定格式,.mbtiles和.pmtiles兩種都能直接吐——所以同一份 GeoJSON 想換格式,只要改輸出檔名就好。
2. 從 GeoJSON 產 PMTiles
最簡單的命令:
tippecanoe \
--output=villages.pmtiles \
--layer=villages \
--minimum-zoom=6 \
--maximum-zoom=13 \
--detect-shared-borders \
--coalesce-densest-as-needed \
--extend-zooms-if-still-dropping \
--force \
villages.geojson
幾個關鍵 flag:
--minimum-zoom / --maximum-zoom:要切哪些 zoom level。z=6 看全國,z=13 看里街道--detect-shared-borders:相鄰 polygon 共用邊界,省 50% 大小--coalesce-densest-as-needed:低 zoom 自動合併小 polygon 避免破圖--extend-zooms-if-still-dropping:高 zoom 還有特徵會自動延伸
跑 30 秒,吐出 5.7 MB 的 villages.pmtiles。
3. 放到 CDN + 在 MapLibre 接
把 villages.pmtiles 丟到 public/data/geometry/villages.pmtiles,部署到 Cloudflare Pages。
前端裝 pmtiles npm:
pnpm add pmtiles
接到 MapLibre:
import maplibregl from 'maplibre-gl';
import { Protocol } from 'pmtiles';
// 註冊 pmtiles:// protocol handler
const protocol = new Protocol();
maplibregl.addProtocol('pmtiles', protocol.tile);
const map = new maplibregl.Map({
container: 'map',
style: 'https://tiles.openfreemap.org/styles/positron',
center: [121.0, 23.7],
zoom: 7,
});
map.on('load', () => {
map.addSource('villages', {
type: 'vector',
url: 'pmtiles:///data/geometry/villages.pmtiles',
promoteId: 'VILLCODE', // 把 properties.VILLCODE 提升為 feature.id
});
map.addLayer({
id: 'villages-fill',
type: 'fill',
source: 'villages',
'source-layer': 'villages',
paint: {
'fill-color': '#41b6c4',
'fill-opacity': 0.7,
},
});
});
關鍵在 pmtiles:// 這個自訂 protocol。MapLibre 看到 pmtiles://... 開頭的 URL 會交給 Protocol.tile handler,handler 用 HTTP Range Request 讀取 PMTiles。
上面
fill-color我先寫死成 YlGnBu 的青色#41b6c4只是為了把幾何畫出來。所得地圖真正的色階後來改成了 OrRd,原因見 為什麼我把所得地圖色階從 viridis 換成 OrRd。
對 MapLibre 來說,這就像是個正常的 vector tile source。但實際上瀏覽器只下載當前視窗需要的幾個 chunks(首屏約 200 KB),不是整個 6 MB。
PMTiles 在 TaxMap-TW 的實戰
TaxMap-TW 是個剛上線的台灣所得稅地圖,全台 7,747 個村里 polygon 都用 PMTiles 上線。
實際整合的踩雷:
1. 從 MOI 下載的 Shapefile 是 Big5 編碼
內政部國土測繪中心 的村里界 shapefile zip 內含中文檔名,macOS 預設 unzip 會崩潰。改用 macOS native ditto:
ditto -x -k villages-1130928.zip villages-shp/
2. tippecanoe 的 --read-parallel 會把 5.6 MB 的 GeoJSON 切碎解析錯誤
我的 GeoJSON 一行一個 feature(mapshaper 輸出格式)。--read-parallel 想平行讀但會在某些行切錯。單線程讀更穩,30 秒也不算慢。
3. promoteId 是必須的
預設情況下 tippecanoe 產出的 tile 不會把 properties 的 ID 欄位提升到 feature.id。這會導致 MapLibre 的 setFeatureState(用來做 hover 反白)失效。在 source 設定加 promoteId: 'VILLCODE' 即可。
4. Mapshaper 簡化要用 keep-shapes
把 NLSC 50 MB 的 shapefile 簡化到 5.6 MB GeoJSON,預設會「丟掉太小的 polygon」。但每個村里都要保留,所以要加 -simplify 5% keep-shapes,太小的村里也會被保留。
反思
讓我印象最深的是 Range Request 這個東西本身。它出自 1997 年的 RFC 2068、躺在 HTTP 規格裡快三十年,PMTiles 在 2021 年初發表、那年 FOSS4G 上正式亮相後,GIS 圈才比較多人這樣用它來做單檔 tile 服務。倒不是「沒人想到」,而是要等 R2 這類便宜物件儲存、CDN 對 Range 的普遍支援、瀏覽器端讀 PMTiles 的函式庫這幾個條件同時成熟,這套玩法才划算。我那天接完 pmtiles:// 看到地圖跑起來、整個專案連一台 server 都沒有,確實愣了一下——原來繞了一圈,答案是 HTTP 本來就會的事。
架構上 PMTiles 是「移除中間層」:傳統 tile 架構有 4 層(origin DB → tile server → CDN → browser),它直接拿掉 tile server 那層(origin file → CDN → browser)。少一層確實少一個故障點,但代價是原本集中在 server 那層的快取策略、可觀測性、錯誤處理也一起沒了,這些複雜度其實是轉嫁到了客戶端和 CDN 設定上,只是不再是「我要顧的一台機器」而已。
成本面,PMTiles + Cloudflare 的零成本全端組合對個人專案、低中流量、用量落在免費額度內的情況,幾乎可以做到月付 $0:TaxMap-TW 的 OpenFreeMap 底圖、PMTiles polygon、SSG 靜態頁、CDN 流量加起來就是 $0。但 $0 不是物理定律——Range Request 一樣算請求數和頻寬,真的爆紅、流量衝破免費額度,帳單照樣會來。
後記:這篇講的「TaxMap 放在 Cloudflare Pages」其實是當時的狀態。後來「一頁一檔」的 SSG 頁面撞到 Cloudflare Pages 的 2 萬檔上限,我把站搬到了 Netlify,過程寫在 Cloudflare Pages 的 20,000 檔案上限。PMTiles 這套單檔做法本身沒變,變的是放它的平台。
如果你有「想做地圖但被 server 嚇退」的點子,週末花一兩個小時,從 GeoJSON 到丟上 CDN 是真的跑得完的——只是記得先確認你的資料是不是適合走靜態這條路。