案例:檔案同步
像 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% 的位置插入 10 位元組:固定大小的區塊在修改處之後全部位移、雜湊全變,要重傳 57.8 KB(檔案的 90.2%)。依內容切的刀口跟著內容移動,只有修改附近的 214 位元組 是新的。
固定亂數種子:一個 64.0 KB、內容為隨機位元組的檔案(像照片或壓縮檔),伺服器上已有。CDC 在「最後 16 個位元組的總和除以 256 餘 255」的地方下刀,區塊保持在 64~1024 位元組。上傳量是伺服器上還沒有該 SHA-256 雜湊的區塊大小總和。
② 一個下午,實際量
- 筆電第一次存檔上傳 64.0 KBreport.bin v1
- 通知同帳號的其他裝置phone report.bin v1
- 手機下載下載 64.0 KB
- 筆電在 30% 處插入 10 位元組上傳 406 位元組report.bin v2
- 手機下載新版下載 406 位元組
- 另一個帳號存了同一個檔案上傳 0 位元組their-copy.bin v1
- 手機根據舊版存檔上傳 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 O | n = 8,192 | n = 16,384 | n = 32,768 | n = 65,536 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|---|
| 固定大小區塊 | O(n) | 8,202 | 16,394 | 32,522 | 65,034 | ×7.9 (×8.0) |
| 依內容切 | O(1) | 257 | 276 | 252 | 379 | ×1.5 (×1.0) |
檔案從 8 KB 變成 64 KB,固定大小的切法要重傳的量從 8,202 跟著漲到 65,034 位元組;CDC 只看修改附近的一兩塊,維持在幾百位元組(257~379)。
和其他做法比
| 整個檔案 | 固定 256 位元組 | 依內容切 | |
|---|---|---|---|
| 插入 10 位元組(30% 處) | 64.0 KB | 45.0 KB | 406 位元組 |
| 改寫 100 位元組(30% 處) | 64.0 KB | 512 位元組 | 396 位元組 |
| 刪除 50 位元組(30% 處) | 64.0 KB | 45.0 KB | 346 位元組 |
| 結尾加 1024 位元組 | 65.0 KB | 1.0 KB | 1.3 KB |
| 別的帳號存同一個檔案 | 64.0 KB | 0 位元組 | 0 位元組 |
同一個 64.0 KB 檔案,伺服器上已有修改前的版本。插入和刪除讓固定大小的區塊全部位移,CDC 不受影響;原地改寫和加在結尾不會位移,固定大小反而稍好。以雜湊去重則讓兩種切法對完全相同的內容都不用再傳。
真實世界裡的它
- Dropbox 把檔案切成 4 MB 的區塊、以 SHA-256 命名;中繼資料和區塊分別存放,衝突時產生「conflicted copy」。
- rsync 用滾動校驗和找出兩邊相同的區塊,只傳差異。
- 備份工具如 restic、borg 用 CDC 切塊,讓每天的備份只多存真正改變的部分。
取捨與陷阱
- 用固定大小切塊:檔案開頭插入一個位元組,後面每一塊都變,等於整個重傳(上面量得到)。
- 先提交中繼資料、區塊還沒傳完:其他裝置收到通知卻下載不到區塊。區塊要先到齊,再提交版本。
- 跨帳號去重會洩漏資訊:上傳瞬間完成,就代表別人已經有這個檔案。重視隱私的服務只在同一帳號內去重,或用加密讓雜湊因人而異。
- 用「最後存檔勝出」處理同時修改,會靜靜地丟掉其中一方的成果;寧可多一個衝突副本。