跳到主要內容

系統設計

把資料結構放大到好幾台機器

主題 · 案例:叫車服務

案例:叫車服務

幾十萬台車每幾秒回報一次位置,乘客叫車時要在毫秒內找到附近的空車:寫入量很大、資料很快就過期,所以放在記憶體裡的空間索引。

流程

叫車服務是同一個索引上兩條很不一樣的路:大量的小寫入(每台車每幾秒一次)和必須在毫秒內回答的「附近有誰」。兩者交會在記憶體裡的空間索引;不能遺失的行程則另外寫進資料庫。選一個流程一步步看;點方塊可以進到那個元件的主題。

亮起來的是這一步執行的程式碼
type Hit = { driver: string; km: number };
class GridIndex {
private cells = new Map<string, Set<string>>();
private where = new Map<string, { x: number; y: number; cell: string }>();
constructor(private cellKm: number, private cityKm: number) {}
cellOf(x: number, y: number): string {
return `${Math.floor(x / this.cellKm)},${Math.floor(y / this.cellKm)}`;
}
update(driver: string, x: number, y: number): void {
const cell = this.cellOf(x, y);
const old = this.where.get(driver);
if (old && old.cell !== cell) this.cells.get(old.cell)?.delete(driver);
if (!old || old.cell !== cell) {
if (!this.cells.has(cell)) this.cells.set(cell, new Set());
this.cells.get(cell)!.add(driver);
}
this.where.set(driver, { x, y, cell });
}
remove(driver: string): void {
const old = this.where.get(driver);
if (!old) return;
this.cells.get(old.cell)?.delete(driver);
this.where.delete(driver);
}
nearby(x: number, y: number, k: number): Hit[] {
const cx = Math.floor(x / this.cellKm), cy = Math.floor(y / this.cellKm);
const found: Hit[] = [];
const maxRing = Math.ceil(this.cityKm / this.cellKm) + 1;
for (let r = 0; r <= maxRing; r++) {
for (let dx = -r; dx <= r; dx++) {
for (let dy = -r; dy <= r; dy++) {
if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;
for (const driver of this.cells.get(`${cx + dx},${cy + dy}`) ?? []) {
const at = this.where.get(driver)!;
found.push({ driver, km: Math.hypot(at.x - x, at.y - y) });
}
}
}
found.sort((a, b) => a.km - b.km || (a.driver < b.driver ? -1 : 1));
// Anything not yet seen is at least r cells away.
if (found.length >= k && found[k - 1].km <= r * this.cellKm) break;
}
return found.slice(0, k);
}
}
司機數
格子邊長
4 s
看了幾個格子
9
算了幾台車的距離
403
全城逐一比對
5,000
位置寫入/秒
1,250
乘客最近的 5 台車看過的格子

圖上只畫出 5,000 位中的 3,000 位。

要在 5,000 位司機中找最近的 5 位,索引看了 9 個格子、算了 403 台車的距離,只占全部的 8.06%。每 4 秒回報一次,代表每秒 1,250 筆寫入;以時速 30 公里計,車子可能已經離索引記得的位置 33 公尺。

模型假設與範圍

  • 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
  • 平面城市與網格座標用來找附近司機;直線距離不能等同道路行程,未模擬交通、定位誤差與路線規劃。

什麼時候用

  • 所有「找我附近的…」:叫車、外送、附近的店家、交友 App。
  • 位置資料寫入量大、幾秒後就不準:放在記憶體裡、可以遺失,重點是快;需要永久保存的行程和金額才寫進資料庫。
  • 需求集中在少數區域時,格子大小要跟著密度調:市中心切細、郊區切粗。

和其他主題的關係

時間與空間複雜度(Big O)

操作平均最差
更新一台車的位置
只有換格子時才動兩個格子
O(1)O(1)
找最近的 k 台車
m 是看過的格子裡的車;最差是所有車擠在同一格
O(k + m)O(n)
全城逐一比對O(n)O(n)
接單後移出索引O(1)O(1)

空間:O(n),每台車一筆位置,加上非空的格子

Big O 實測:n 變大時步數怎麼長

數的是:找最近 5 台車時算了幾台車的距離(n 是司機數)

Big On = 500n = 2,000n = 8,000成長倍數:實測(理論)
全城逐一比對O(n)5002,0008,000×16 (×16)
格子固定 1 kmO(n)24.275.6303×13 (×16)
格子隨密度縮小O(1)53.665.268.2×1.3 (×1.0)

格子大小固定時,車越多、每格越擠,成本還是跟著 n 線性長(只是常數小很多)。讓格子隨密度縮小(每格平均 4 台),成本就幾乎不變:這正是四分樹自動做的事。

和其他做法比

看了幾個格子算了幾台車總工作量
格子 0.25 km27.220.848
格子 0.5 km12.251.464
格子 1 km9187.8197
格子 2 km9613.8623
格子 4 km91,731.11,740
全城逐一比對–5,0005,000

5,000 位司機(六成集中在三個熱區)、25 位乘客各找最近 5 台,取平均。格子太大,一格裡就有上千台車要算;太小,要翻很多空格子才湊得到 5 台。最省的大小取決於密度,所以實務上用四分樹或多層的 geohash 讓市中心的格子自動變小。

位置寫入/秒位置最多過時
每 2 秒回報25,00017 m
每 4 秒回報12,50033 m
每 8 秒回報6,25067 m
每 15 秒回報3,333125 m

50,000 位上線司機、時速 30 公里。回報越頻繁,位置越準,寫入量也同比例增加。

真實世界裡的它

  • Uber 開源的 H3 用六角形格子切整個地球;Lyft、Grab 也都是「格子索引+即時配對」。
  • Redis 的 GEOADD/GEOSEARCH 把 geohash 當成排序集合的分數來存座標。
  • 外送平台用同一套方法找附近的外送員,再把餐點準備時間算進配對。

取捨與陷阱

  • 格子大小沒有一體適用的值:太大一次要比對上千台車,太小要翻很多空格子(下方的實測表)。
  • 位置永遠是幾秒前的:每 4 秒回報、時速 30 公里,誤差就有幾十公尺;回報得更頻繁,寫入量就同比例增加。
  • 同一位司機不能同時派給兩個人:派單要先把司機標成忙碌,失敗或逾時再放回來。
  • 直線距離不等於到達時間:隔著河或單行道的「最近」司機可能最遠,實務上要用路網算預估到達時間。

LeetCode 練習