跳到主要內容

系統設計

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

主題 · 物件儲存

物件儲存

把檔案當成一個個帶鍵的物件存放:容量幾乎無限、便宜又耐久,但不能只改檔案的一部分,也不適合拿來當資料庫查詢。

看哪一段
網路
5.0 GB
每段
每傳 1 GB 斷線機率
79 段
  1. 2
一次成功重傳過(數字是次數)超過重試上限,未完成
成功/所需段數
79 / 79
重傳的段
1
分段:共送出
5.0 GB
單次 PUT:共送出
31 GB(放棄)

5.0 GB 需要 79 段、每段 64 MB。全部分段成功;其中 1 段重試,只重傳那些段。總共送了 5.0 GB。整個檔案一次 PUT 的話,試了 20 次都沒成功,送了 31 GB:每失敗一次就從頭來。

亮起來的是這一步執行的程式碼
const SIGNING_SECRET = "storage-secret", MAX_TRIES = 20;
type Transfer = (mb: number) => { ok: boolean; sent: number };
// Send a big file in parts; a failure costs one part, not the whole file.
function uploadMultipart(sizeMb: number, partMb: number, transfer: Transfer) {
const parts: { attempts: number; mbSent: number; ok: boolean }[] = [];
const totalParts = Math.ceil(sizeMb / partMb);
for (let offset = 0; offset < sizeMb; offset += partMb) {
const size = Math.min(partMb, sizeMb - offset);
const part = { attempts: 0, mbSent: 0, ok: false };
for (let tries = 0; tries < MAX_TRIES; tries++) {
const result = transfer(size);
part.attempts++;
part.mbSent += result.sent;
if (result.ok) { part.ok = true; break; }
}
parts.push(part);
if (!part.ok) return { parts, ok: false, failedPart: parts.length - 1, totalParts };
}
return { parts, ok: true, failedPart: null, totalParts }; // only success may complete
}
function fnv1a(text: string): number {
let h = 0x811c9dc5;
for (const byte of new TextEncoder().encode(text)) h = Math.imul(h ^ byte, 0x01000193);
return h >>> 0;
}
// Real services use HMAC-SHA256; the idea is identical.
function signature(key: string, expires: number): string {
return fnv1a(`${key}|${expires}|${SIGNING_SECRET}`).toString(16);
}
// The app server hands this out; the client uploads straight to storage.
function presign(key: string, expires: number): string {
return `/${key}?expires=${expires}&sig=${signature(key, expires)}`;
}
// Storage checks the URL without asking the app server anything.
function verify(key: string, expires: number, sig: string, now: number): string {
if (now > expires) return "expired";
if (sig !== signature(key, expires)) return "bad-signature";
return "ok";
}
// One parity part lets any single lost part be rebuilt.
function xorParity(parts: number[][]): number[] {
const parity = new Array(parts[0].length).fill(0);
for (const part of parts) {
for (let i = 0; i < part.length; i++) parity[i] ^= part[i];
}
return parity;
}
function recoverPart(parts: (number[] | null)[], parity: number[]): number[] {
const rebuilt = [...parity];
for (const part of parts) {
if (part === null) continue;
for (let i = 0; i < part.length; i++) rebuilt[i] ^= part[i];
}
return rebuilt;
}

模型假設與範圍

  • 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
  • 物件/中繼資料分離並展示分片、冗餘與簽章概念;範例協定不能代替正式授權、加密、durability 或儲存產品。

什麼時候用

  • 存大量、不常修改的檔案:照片、影片、備份、日誌、機器學習的資料集。容量幾乎無限,按用量付費。
  • 讓使用者直接上傳或下載大檔案,又不想讓應用伺服器經手:用預簽網址,伺服器只負責簽名。
  • 搭配 CDN 當原站:物件儲存負責耐久,CDN 負責離使用者近。

和其他主題的關係

延伸閱讀
CDN

出現在這些架構裡

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

操作平均最差
用鍵 PUT/GET 一個物件
找到物件是查一次中繼資料;時間主要花在傳資料
O(1) + O(size)O(1) + O(size)
分段上傳
每段一個請求,最多重試 k 次
O(size / part)O(size / part · k)
列出某個前綴底下的物件
鍵排好序,回傳 m 筆;物件儲存沒有真正的資料夾
O(log n + m)O(log n + m)
驗證預簽網址
重算一次簽名,不用查資料庫
O(1)O(1)

空間:O(r · size),r 是放大倍數:三副本是 3,RS(6, 3) 是 1.5

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

數的是:分段上傳的成本(n 是檔案 MB 數;64 MB 一段,每 GB 39% 機率斷線)

Big On = 1,000n = 10,000n = 100,000成長倍數:實測(理論)
送出的 MBO(n)1,00010,211101,407×101 (×100)
上傳請求數O(n)161631,607×100 (×100)

分段之後,送出的量和請求數都只跟檔案大小成正比,重傳只多出一點點。一次 PUT 就不是這樣:成功的機率隨檔案大小指數下降,大到某個程度就永遠傳不完,所以不放進這張表。

和其他做法比

分段:送出/請求數單次 PUT:送出/嘗試
100 MB100 MB / 2100 MB / 1
1.0 GB1.0 GB / 162.0 GB / 2
5.0 GB5.0 GB / 8031 GB / 20(放棄)
10 GB10 GB / 16331 GB / 20(放棄)

每傳 1 GB 有 39% 的機率斷線,分段每段 64 MB,每段或每次 PUT 最多試 20 次。小檔案兩種差不多;檔案一大,整個重來的成本就爆炸,到某個大小以上一次 PUT 幾乎不可能成功。

佔用空間可以壞幾顆物件遺失機率
只存 1 份1.00×01 / 200
3 份副本3.00×21 / 8,000,000
XOR 同位(4+1)1.25×11 / 4,040
糾刪碼 RS(6, 3)1.50×31 / 12,955,370
糾刪碼 RS(10, 4)1.40×41 / 165,957,979

每顆碟在修好之前有 0.5% 的機率也壞掉,片段放在不同的碟上。糾刪碼用比三副本少一半的空間、容忍更多顆碟壞掉;代價是讀寫時要計算,修復時要從好幾顆碟讀資料。

真實世界裡的它

  • Amazon S3、Google Cloud Storage、Azure Blob Storage、Cloudflare R2;自架有 MinIO、Ceph。
  • S3 標榜 11 個 9 的耐久性(99.999999999%),靠的就是跨機房的副本和糾刪碼。
  • 資料湖(data lake)把 Parquet 檔放在物件儲存裡,再用 Spark、Trino 直接查。

取捨與陷阱

  • 不能只改檔案的一部分:改一個位元組也要重寫整個物件。需要隨機寫入或查詢的資料應該放資料庫,物件儲存只放鍵和大檔。
  • 列出大量物件很慢、也要錢:用前綴設計好鍵,不要把「資料夾」當成資料庫的目錄在用。
  • 預簽網址要設短的有效期,而且簽名要涵蓋路徑和方法;不然網址外流,誰都能拿來讀寫。