seansie's blog

深度解析 S2 Geometry:在 Node.js 中構建高效能地理空間索引與範圍查詢

地理空間數據(Geospatial Data)的檢索與計算一直是分散式系統與資料庫工程中的經典難題。傳統的 R-Tree 索引在多維數據增長時容易出現樹高失衡,而傳統的 Geohash(基於二元網格劃分)在極點與換日線附近存在嚴重的幾何畸變與鄰域不連續性。

為了解決這些問題,Google 開發了 S2 Geometry Library。S2 將地球視為一個完整的三維球體,利用立方體投影(Cube Projection)與希爾伯特空間填充曲線(Hilbert Space-Filling Curve),將二維球面座標無損映射為一維的 64 位元整數(uint64)。這讓百萬級別的地理空間查詢轉換為極速的一維整數區間掃描(B-Tree Range Scan)。

本文將深入探討 S2 的核心數學原理,並基於 Node.js 生態(s2geometry-node / nodes2),詳細解析關鍵 Class、方法,並手把手實作「矩形經緯度區間查詢(Bounding Box Query)」的最佳實踐。


一、 S2 Geometry 核心架構與數學底層

S2 能夠在地理計算上超越傳統方案,核心在於三個數學轉換階段:

經緯度 (Lat/Lng) 
    ⬇  1. 三維球面映射
球面三維向量 (x, y, z) 
    ⬇  2. 立方體六面切線投影
立方體二維座標 (Face, u, v) 
    ⬇  3. 希爾伯特曲線四叉樹離散化
64 位元 S2CellId (整數索引)
  1. 球面投影到立方體(Cube Projection): S2 首先將地球包裹在一個六面立方體中。每個點會先被投影到立方體的某個面(Face 0 到 Face 5),並轉換為局部座標 $(u, v)$。這種投影大幅減少了極地附近的面積失真。
  2. 希爾伯特曲線(Hilbert Curve)與四叉樹離散化: 在立方體的每一個面上,S2 採用四叉樹(Quadtree)進行遞迴四等分,最多可劃分至 30 層(Level 30,單一 Cell 尺寸約為 $1\text{ cm} \times 1\text{ cm}$)。透過希爾伯特曲線遍歷這些四叉樹節點,保證了一維空間中相鄰的整數在二維球面上也是物理相鄰的(Locality-Preserving)
  3. S2CellId 的位元編碼結構: 每個 S2 網格由一個 64 位元的無號整數表示:
  • 前 3 個位元表示立方體的面(Face 0 ~ 5)。
  • 後續最多 60 個位元,每 2 個位元代表一層四叉樹分支方向(00, 01, 10, 11)。
  • 最末尾保留一個 1 作為標記位元(Sentinel Bit),其餘補 0,用以直接判斷該 Cell 處於哪一個層級(Level)。

二、 Node.js 環境安裝與核心 Class 解析

在 Node.js 中,最常使用的原生封裝為 s2geometry-node(或相容純 JS 實作的 nodes2)。本篇以相容 Google C++ 原生邏輯的套件為核心解說。

npm install s2geometry-node
# 或者純 JS 版本的
npm install nodes2

關鍵 Class 矩陣

Class 名稱 角色與責任 常用場景
S2LatLng 表示經緯度座標(支援度數與弧度轉換) 數據輸入、單點定位
S2Point 單位球體上的三維向量 $(x, y, z)$ 幾何距離、向量夾角計算
S2CellId 64 位元空間索引標識符 資料庫索引存儲、鄰域查找、階層轉換
S2Cell 實際的幾何單元實體 計算單元邊界、面積、包含判定
S2LatLngRect 球面上的經緯度矩形邊界 區域範圍查詢(Bounding Box)、可視區域過濾
S2RegionCoverer 空間區域覆蓋算法引擎 將任意幾何形狀分解為最佳 S2Cell 集合

三、 核心 Class 常用方法與代碼範例

1. 座標轉換與 S2CellId 生成

將經緯度轉換為 S2 空間索引值,通常儲存為字串(BigInt)以避免 JavaScript 64 位元浮點數精度遺失。

const s2 = require('s2geometry-node');

// 1. 建立台北 101 座標 (25.033976, 121.564538)
const latLng = s2.S2LatLng.fromDegrees(25.033976, 121.564538);

// 2. 獲取該點在 Level 16 (約 100m 精度) 的 S2CellId
const cellId = s2.S2CellId.fromLatLng(latLng).parent(16);

// 輸出 Token (Hex 字串) 與 64 位元整數 ID
console.log('Level 16 Token:', cellId.toToken());     // e.g. "3442abb..."
console.log('Level 16 ID (String):', cellId.id().toString());
console.log('當前 Level:', cellId.level());          // 16

2. 空間鄰域查詢(8 鄰域)

在地理圍欄或附近搜尋時,直接取周圍 8 個 Cell 可以有效避免跨邊界遺漏問題:

// 取得當前 Cell 在同層級的所有相鄰 Cell (包含角點共有 8 個鄰居)
const neighbors = [];
cellId.getEdgeNeighbors(neighbors);

console.log('鄰近的 Cell 數量:', neighbors.length);
neighbors.forEach((neighbor, idx) => {
  console.log(`鄰居 ${idx + 1} Token:`, neighbor.toToken());
});

四、 核心主題:方形經緯度區間查詢(Bounding Box Query)

在實際開發中(如地圖拖曳可視範圍搜尋、快遞外送派單區域劃分),最常見的需求是:「給定左下角與右上角經緯度,查詢該矩形區域內的所有標記點」

查詢核心邏輯與挑戰

若直接使用 SQL 的 lat BETWEEN minLat AND maxLat AND lng BETWEEN minLng AND maxLng,在資料量達數百萬時無法有效利用複合索引。

S2 的解法是:

  1. S2LatLngRect 定義經緯度矩形。
  2. S2RegionCoverer 將該矩形切分成有限數量的 S2CellUnion(一組不同大小但緊密貼合的 S2Cell 集合)。
  3. 每個 Cell 在一維空間中都是一個連續區間 [cell.rangeMin(), cell.rangeMax()]
  4. 將原本的 2D 範圍查詢轉換為資料庫中的 1D 區間 OR 查詢

實作範例:矩形覆蓋與範圍生成

const s2 = require('s2geometry-node');

/**
 * 給定矩形經緯度,生成用於資料庫查詢的 Cell 區間
 * @param {number} minLat 最小緯度 (南)
 * @param {number} minLng 最小經度 (西)
 * @param {number} maxLat 最大緯度 (北)
 * @param {number} maxLng 最大經度 (東)
 */
function getBoundingBoxQueryRanges(minLat, minLng, maxLat, maxLng) {
  // 1. 建立矩形範圍 (S2LatLngRect)
  const pLo = s2.S2LatLng.fromDegrees(minLat, minLng);
  const pHi = s2.S2LatLng.fromDegrees(maxLat, maxLng);
  const rect = s2.S2LatLngRect.fromPointPair(pLo, pHi);

  // 2. 配置 RegionCoverer 參數
  const coverer = new s2.S2RegionCoverer();
  coverer.setMinLevel(8);    // 最小 Cell 層級 (避免覆蓋塊過大造成過多 False Positives)
  coverer.setMaxLevel(16);   // 最大 Cell 層級 (避免切太碎造成過多 SQL 條件)
  coverer.setMaxCells(20);   // 最多產生的 Cell 數量 (建議 8~30 之間平衡查詢效能)

  // 3. 獲取覆蓋該矩形的 S2CellUnion
  const cellUnion = coverer.getCovering(rect);

  // 4. 將每個 S2CellId 轉換為查詢區間 [rangeMin, rangeMax]
  const ranges = [];
  const cellIds = cellUnion.cellIds();

  for (let i = 0; i < cellIds.length; i++) {
    const cid = cellIds[i];
    ranges.push({
      cellId: cid.id().toString(),
      token: cid.toToken(),
      level: cid.level(),
      // 區間起點:該 Cell 樹狀結構下的最底層第一個葉子節點
      rangeMin: cid.rangeMin().id().toString(),
      // 區間終點:該 Cell 樹狀結構下的最底層最後一個葉子節點
      rangeMax: cid.rangeMax().id().toString()
    });
  }

  return ranges;
}

// 範例:查詢台北大安森林公園周邊矩形
const queryRanges = getBoundingBoxQueryRanges(
  25.0260, 121.5320, // 南西 (SW)
  25.0360, 121.5400  // 北東 (NE)
);

console.log(`成功將矩形分解為 ${queryRanges.length} 個 S2 區間:`);
console.dir(queryRanges, { depth: null });

資料庫整合方案(SQL 範例)

當數據寫入時,我們為每一筆標記點計算最底層(Level 30)或固定層級(如 Level 16)的 s2_cell_id 並建立 B-Tree 索引:

-- 寫入時計算的 S2 索引欄位 (使用 BIGINT UNSIGNED 或 DECIMAL)
CREATE TABLE places (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255),
    lat DOUBLE,
    lng DOUBLE,
    s2_id BIGINT UNSIGNED NOT NULL,
    INDEX idx_s2_id (s2_id)
);

當進行矩形範圍查詢時,將前端產生的 ranges 拼裝為高效率的 SQL 區間查詢:

-- 透過 S2RegionCoverer 產生的多個區間快速篩選
SELECT id, name, lat, lng FROM places 
WHERE 
   (s2_id BETWEEN 4935105234527715328 AND 4935105238822682623)
OR (s2_id BETWEEN 4935105243117649920 AND 4935105247412617215)
OR (s2_id BETWEEN 4935105251707584512 AND 4935105256002551807);

由於覆蓋算法會在邊緣有些許冗餘(Covering 是以 Cell 為單位的超集),在資料量極度敏感的業務中,可在應用層或 SQL 最後加上精準經緯度過濾(AND lat BETWEEN ... AND lng BETWEEN ...)。


五、 進階技巧與架構設計注意事項

  1. maxCells 參數權衡
  • maxCells 設太大(如 100+):網格與查詢形狀貼合度極高,多餘數據少,但產生的 SQL 條件或 Redis 查詢次數大幅增加。
  • maxCells 設太小(如 4~8):產生的 SQL 極為簡短,但外圍覆蓋過大,導致資料庫檢索出較多不在目標矩形內的數據。
  • 推薦實踐:OLTP 資料庫查詢建議設定在 10 ~ 20 之間。
  1. 跨越換日線(180 度經線)與極點S2LatLngRect 支援跨越換日線的矩形判定。當 lngLo > lngHi 時,S2 會自動將其識別為跨越 180 度經線的區間,開發者無需手動切分為兩個查詢子句。
  2. 層級(Level)與空間尺寸對照
  • Level 10:約 $10\text{ km} \times 10\text{ km}$(城市級覆蓋)
  • Level 13:約 $1.2\text{ km} \times 1.2\text{ km}$(區域、商圈分析)
  • Level 16:約 $150\text{ m} \times 150\text{ m}$(街區、附近車輛定位)
  • Level 20:約 $9\text{ m} \times 9\text{ m}$(精確門牌、建築內部定位)

透過將高維空間幾何運算轉換為一維離散數學與 B-Tree 檢索,S2 Geometry 成為現代分散式地理系統(如 Uber H3 之外的另一主流)的堅實基石。利用 Node.js 結合 S2 覆蓋算法,能夠以極低的架構複雜度支撐起高併發的地理空間查詢業務。