跳到主要內容

系統設計

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

主題 · 案例:檔案同步

案例:檔案同步

像 Dropbox 一樣在多台裝置間同步檔案:切成區塊、只傳有變動的部分、相同的內容只存一份,發生衝突時保留兩個版本。

流程

檔案同步把「位元組」和「哪些位元組組成哪個版本」分開存:內容切成區塊、用雜湊命名、相同的只存一份;中繼資料記錄版本,變更再透過佇列通知其他裝置。選一個流程一步步看;點方塊進到元件的主題。

亮起來的是這一步執行的程式碼
import { sha256 } from "@noble/hashes/sha2.js";
import { bytesToHex } from "@noble/hashes/utils.js";
const WINDOW = 16, MIN = 64, MAX = 1024;
// Cut where the sum of the last 16 bytes ends in 255 (mod 256): the cuts
// follow the content, so an insert moves only the cuts around it.
function chunk(data: Uint8Array): Uint8Array[] {
const out: Uint8Array[] = [];
let start = 0, sum = 0;
for (let i = 0; i < data.length; i++) {
sum += data[i];
if (i >= WINDOW) sum -= data[i - WINDOW];
const length = i + 1 - start;
if ((length >= MIN && sum % 256 === 255) || length >= MAX) {
out.push(data.subarray(start, i + 1));
start = i + 1;
}
}
if (start < data.length) out.push(data.subarray(start));
return out;
}
const hashOf = (bytes: Uint8Array) => bytesToHex(sha256(bytes));
type FileMeta = { version: number; chunks: string[]; device: string };
type Change = { path: string; version: number; device: string; account: string };
class SyncServer {
blocks = new Map<string, Uint8Array>(); // block store: hash -> bytes
files = new Map<string, FileMeta>(); // metadata database
queue: Change[] = []; // change events
devices = new Map<string, string>(); // device -> account
missing(hashes: string[]): string[] {
return [...new Set(hashes)].filter((h) => !this.blocks.has(h));
}
putBlock(bytes: Uint8Array): void {
this.blocks.set(hashOf(bytes), bytes);
}
metadata(account: string, path: string): FileMeta | undefined {
return this.files.get(JSON.stringify([account, path]));
}
commit(device: string, account: string, path: string, base: number, hashes: string[]) {
if (this.devices.get(device) !== account) throw new Error("Unknown device/account");
const current = this.metadata(account, path);
const conflict = current !== undefined && current.version !== base;
if (conflict) {
const copy = `${path} (conflicted copy from ${device})`;
path = copy; let suffix = 2;
while (this.metadata(account, path)) path = `${copy} ${suffix++}`;
}
const version = (this.metadata(account, path)?.version ?? 0) + 1;
this.files.set(JSON.stringify([account, path]), { version, chunks: hashes, device });
this.queue.push({ path, version, device, account });
return { path, version, conflict };
}
deliver(): string[] {
const notices: string[] = [];
for (const c of this.queue.splice(0)) {
for (const [d, account] of this.devices) {
if (d !== c.device && account === c.account) notices.push(`${d} ${c.path} v${c.version}`);
}
}
return notices;
}
}
class Device {
files = new Map<string, { version: number; data: Uint8Array }>();
blocks = new Map<string, Uint8Array>();
constructor(public name: string, public account: string, public server: SyncServer) {
server.devices.set(name, account);
}
save(path: string, data: Uint8Array) {
const chunks = chunk(data);
const hashes = chunks.map(hashOf);
const need = new Set(this.server.missing(hashes));
let uploaded = 0;
chunks.forEach((c, i) => {
if (need.delete(hashes[i])) { this.server.putBlock(c); uploaded += c.length; }
this.blocks.set(hashes[i], c);
});
const base = this.files.get(path)?.version ?? 0;
const result = this.server.commit(this.name, this.account, path, base, hashes);
this.files.set(result.path, { version: result.version, data });
return { ...result, uploaded };
}
pull(path: string): number {
const meta = this.server.metadata(this.account, path);
if (!meta) throw new Error("File not found in this account");
let downloaded = 0;
const parts = meta.chunks.map((h) => {
if (!this.blocks.has(h)) {
const bytes = this.server.blocks.get(h)!;
this.blocks.set(h, bytes);
downloaded += bytes.length;
}
return this.blocks.get(h)!;
});
const data = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let offset = 0;
for (const p of parts) { data.set(p, offset); offset += p.length; }
this.files.set(path, { version: meta.version, data });
return downloaded;
}
}

① 一次修改要上傳多少

修改
10%
整個檔案
64.0 KB
固定 256 位元組
57.8 KB
依內容切(CDC)
214 位元組
固定 256 位元組的區塊(257 塊)
25 個區塊、6,400 位元組:伺服器已有232 個區塊、59,146 位元組:要上傳
依內容切的區塊(201 塊)
21 個區塊、6,534 位元組:伺服器已有1 個區塊、214 位元組:要上傳179 個區塊、58,798 位元組:伺服器已有
要上傳伺服器已有

在檔案 10% 的位置插入 10 位元組:固定大小的區塊在修改處之後全部位移、雜湊全變,要重傳 57.8 KB(檔案的 90.2%)。依內容切的刀口跟著內容移動,只有修改附近的 214 位元組 是新的。

固定亂數種子:一個 64.0 KB、內容為隨機位元組的檔案(像照片或壓縮檔),伺服器上已有。CDC 在「最後 16 個位元組的總和除以 256 餘 255」的地方下刀,區塊保持在 64~1024 位元組。上傳量是伺服器上還沒有該 SHA-256 雜湊的區塊大小總和。

② 一個下午,實際量

  1. 筆電第一次存檔上傳 64.0 KBreport.bin v1
  2. 通知同帳號的其他裝置phone report.bin v1
  3. 手機下載下載 64.0 KB
  4. 筆電在 30% 處插入 10 位元組上傳 406 位元組report.bin v2
  5. 手機下載新版下載 406 位元組
  6. 另一個帳號存了同一個檔案上傳 0 位元組their-copy.bin v1
  7. 手機根據舊版存檔上傳 1.3 KBreport.bin (conflicted copy from phone) v1phone report.bin v3 · laptop report.bin (conflicted copy from phone) v1

只有第一次存檔與第一次下載搬了整個檔案;之後的修改上下各只要幾百個位元組。另一個帳號存了一模一樣的內容,什麼都不用傳——區塊早就在了。手機根據第 2 版提交、但筆電已經做出第 3 版時,伺服器兩份都留,並通知所有裝置。

模型假設與範圍

  • 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
  • 檔案版本以帳號+路徑隔離,區塊跨帳號去重;裝置身分由模型預先註冊。CDC 用 16-byte 滾動加總,未模擬正式 ACL、加密、跨帳號去重的資訊洩漏防護或儲存 crash recovery。

什麼時候用

  • 同一個人的很多台裝置要看到同一批檔案,而且檔案大、修改小:文件、照片、設計稿、程式專案。
  • 很多使用者存了一樣的東西(同一份安裝檔、同一張轉傳的圖),以雜湊去重可以省下大量儲存空間與頻寬。
  • 內容沒辦法自動合併時(二進位檔、任意格式),用版本號偵測衝突,留下衝突副本給人決定。

和其他主題的關係

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

操作平均最差
切塊
n 是檔案大小;滾動總和每個位元組加一減一
O(n)O(n)
算雜湊
每個位元組過一次 SHA-256
O(n)O(n)
問缺哪些、上傳
c 個區塊雜湊、d 是改到的位元組
O(c + d)O(c + n)
提交版本
寫入一列區塊清單
O(c)O(c)

空間:O(unique chunks),相同區塊不論出現在多少檔案、多少帳號,只存一份;每個版本另外只多一份清單

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

數的是:在 n 位元組的檔案開頭附近插入 10 位元組後,要上傳的位元組(兩個種子的平均)

Big On = 8,192n = 16,384n = 32,768n = 65,536成長倍數:實測(理論)
固定大小區塊O(n)8,20216,39432,52265,034×7.9 (×8.0)
依內容切O(1)257276252379×1.5 (×1.0)

檔案從 8 KB 變成 64 KB,固定大小的切法要重傳的量從 8,202 跟著漲到 65,034 位元組;CDC 只看修改附近的一兩塊,維持在幾百位元組(257~379)。

和其他做法比

整個檔案固定 256 位元組依內容切
插入 10 位元組(30% 處)64.0 KB45.0 KB406 位元組
改寫 100 位元組(30% 處)64.0 KB512 位元組396 位元組
刪除 50 位元組(30% 處)64.0 KB45.0 KB346 位元組
結尾加 1024 位元組65.0 KB1.0 KB1.3 KB
別的帳號存同一個檔案64.0 KB0 位元組0 位元組

同一個 64.0 KB 檔案,伺服器上已有修改前的版本。插入和刪除讓固定大小的區塊全部位移,CDC 不受影響;原地改寫和加在結尾不會位移,固定大小反而稍好。以雜湊去重則讓兩種切法對完全相同的內容都不用再傳。

真實世界裡的它

  • Dropbox 把檔案切成 4 MB 的區塊、以 SHA-256 命名;中繼資料和區塊分別存放,衝突時產生「conflicted copy」。
  • rsync 用滾動校驗和找出兩邊相同的區塊,只傳差異。
  • 備份工具如 restic、borg 用 CDC 切塊,讓每天的備份只多存真正改變的部分。

取捨與陷阱

  • 用固定大小切塊:檔案開頭插入一個位元組,後面每一塊都變,等於整個重傳(上面量得到)。
  • 先提交中繼資料、區塊還沒傳完:其他裝置收到通知卻下載不到區塊。區塊要先到齊,再提交版本。
  • 跨帳號去重會洩漏資訊:上傳瞬間完成,就代表別人已經有這個檔案。重視隱私的服務只在同一帳號內去重,或用加密讓雜湊因人而異。
  • 用「最後存檔勝出」處理同時修改,會靜靜地丟掉其中一方的成果;寧可多一個衝突副本。