跳到主要內容

系統設計

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

主題 · SQL 與 NoSQL

SQL 與 NoSQL

SQL(Structured Query Language,結構化查詢語言)代表關聯式資料庫,NoSQL(Not only SQL)泛指其他種類。關聯式資料庫給你交易和任意查詢;鍵值、文件、寬欄資料庫放棄其中一部分,換取容易水平擴充。該選哪個,取決於資料會怎麼被讀寫。

要做的事

關聯式(SQL)

PostgreSQL, MySQL
orders(id, user_id, created)
order_lines(order_id, item_id, qty, price)
items(id, name, price, stock)
INDEX orders(user_id, created)
資料庫呼叫
1
碰到的分片
1
載入資料(JSON)
1.9 KiB
寫入資料(JSON)
0 B

載入 44 列、寫入 0

不需要交易

文件

MongoDB, Firestore
users/{id}: [
  { id, created,
    lines: [{ item_id, name, qty, price }] }
]
資料庫呼叫
1
碰到的分片
1
載入資料(JSON)
1.2 KiB
寫入資料(JSON)
0 B

載入 1 份文件、寫入 0

不需要交易

寬欄(wide-column)

Cassandra, ScyllaDB
orders_by_user:
  PRIMARY KEY ((user_id), created DESC)
items: PRIMARY KEY (item_id)
資料庫呼叫
1
碰到的分片
1
載入資料(JSON)
1.2 KiB
寫入資料(JSON)
0 B

載入 10 列、寫入 0

不需要交易

這裡三種做法都呼叫一次資料庫。寬欄載入 10 列訂單(1.2 KiB);SQL 走索引、join,載入 44 筆紀錄(1.9 KiB)。這個文件模型載入一整份使用者文件(1.2 KiB),包含全部訂單歷史,只為顯示十筆;改用投影或不同的文件排列,成本也會改變。

200 位使用者、30 種商品、每人 10 筆訂單。模型假設:SQL 一個節點,另兩種排列有 8 個分片。呼叫計一次 SQL 陳述/批次交易、一次鍵查詢或更新、每分片一次掃描,不含交易協調。資料量是載入/寫入紀錄的 UTF-8 JSON 大小,含內嵌明細,不含索引、壓縮和協定。這是存取成本,並非產品速度排名。

亮起來的是這一步執行的程式碼
type Line = { orderId: number; itemId: number; qty: number; price: number };
type DocLine = { itemId: number; name: string; qty: number; price: number };
type DocOrder = { id: number; created: number; lines: DocLine[] };
type Item = { name: string; price: number; stock: number };
type Want = { itemId: number; qty: number };
// orders(id, user_id, created), order_lines(order_id, item_id, qty, price),
// items(id, name, price, stock), INDEX orders(user_id, created)
type Sql = { byUser: Map<number, number[]>; lines: Map<number, Line[]>; items: Map<number, Item> };
// users/{id} = [ { id, created, lines: [ { itemId, name, qty, price } ] } ]
type Docs = { users: Map<number, DocOrder[]>; items: Map<number, Item> };
// orders_by_user: PRIMARY KEY ((user_id), created DESC)
type Wide = { partitions: Map<number, DocOrder[]>; items: Map<number, Item> };
// SELECT ... FROM orders JOIN order_lines JOIN items
// WHERE user_id = ? ORDER BY created DESC LIMIT ?
function recentSql(db: Sql, userId: number, limit: number): number[] {
const ids = (db.byUser.get(userId) ?? []).slice(-limit).reverse();
for (const id of ids) for (const l of db.lines.get(id) ?? []) db.items.get(l.itemId);
return ids;
}
// One read, but it brings back every order the user ever placed.
function recentDoc(db: Docs, userId: number, limit: number): number[] {
const orders = db.users.get(userId) ?? [];
return [...orders].sort((a, b) => b.created - a.created).slice(0, limit).map((o) => o.id);
}
// The partition is stored newest first: read its first rows.
function recentWide(db: Wide, userId: number, limit: number): number[] {
return (db.partitions.get(userId) ?? []).slice(0, limit).map((o) => o.id);
}
// UPDATE items SET name = ? WHERE id = ?
function renameSql(db: Sql, itemId: number, name: string): number {
db.items.get(itemId)!.name = name;
return 1;
}
// The name was copied into orders: rewrite every document that has it.
function renameDoc(db: Docs, itemId: number, name: string): number {
db.items.get(itemId)!.name = name;
let written = 1;
for (const orders of db.users.values()) {
const hits = orders.flatMap((o) => o.lines).filter((l) => l.itemId === itemId);
hits.forEach((l) => (l.name = name));
if (hits.length > 0) written++;
}
return written;
}
// Same in every partition, row by row.
function renameWide(db: Wide, itemId: number, name: string): number {
db.items.get(itemId)!.name = name;
let written = 1;
for (const rows of db.partitions.values()) for (const row of rows) {
const hits = row.lines.filter((l) => l.itemId === itemId);
hits.forEach((l) => (l.name = name));
if (hits.length > 0) written++;
}
return written;
}
// SELECT item_id, SUM(qty * price) FROM order_lines GROUP BY item_id
function revenueSql(db: Sql): Map<number, number> {
const totals = new Map<number, number>();
for (const lines of db.lines.values()) for (const l of lines) {
totals.set(l.itemId, (totals.get(l.itemId) ?? 0) + l.qty * l.price);
}
return totals;
}
// No GROUP BY across partitions: read them all and add up here.
function revenueScan(groups: Iterable<DocOrder[]>): Map<number, number> {
const totals = new Map<number, number>();
for (const orders of groups) for (const o of orders) for (const l of o.lines) {
totals.set(l.itemId, (totals.get(l.itemId) ?? 0) + l.qty * l.price);
}
return totals;
}
// BEGIN; check stock; INSERT order, lines; UPDATE stock; COMMIT.
function placeOrderSql(db: Sql, userId: number, orderId: number, want: Want[]): boolean {
if (!want.every((w) => db.items.get(w.itemId)!.stock >= w.qty)) return false;
db.byUser.get(userId)!.push(orderId);
db.lines.set(orderId, want.map((w) => ({ orderId, itemId: w.itemId, qty: w.qty, price: db.items.get(w.itemId)!.price })));
for (const w of want) db.items.get(w.itemId)!.stock -= w.qty;
return true;
}
// The order is on the user's shard and the stock on the items' shards:
// keeping them in step takes a multi-document transaction, or a saga.
function placeOrderDoc(db: Docs, userId: number, orderId: number, want: Want[]): boolean {
if (!want.every((w) => db.items.get(w.itemId)!.stock >= w.qty)) return false;
const lines = want.map((w) => ({ itemId: w.itemId, name: db.items.get(w.itemId)!.name, qty: w.qty, price: db.items.get(w.itemId)!.price }));
db.users.get(userId)!.push({ id: orderId, created: orderId, lines });
for (const w of want) db.items.get(w.itemId)!.stock -= w.qty;
return true;
}
// A batch inside one partition is atomic; the stock rows are elsewhere.
function placeOrderWide(db: Wide, userId: number, orderId: number, want: Want[]): boolean {
if (!want.every((w) => db.items.get(w.itemId)!.stock >= w.qty)) return false;
const lines = want.map((w) => ({ itemId: w.itemId, name: db.items.get(w.itemId)!.name, qty: w.qty, price: db.items.get(w.itemId)!.price }));
db.partitions.get(userId)!.unshift({ id: orderId, created: orderId, lines });
for (const w of want) db.items.get(w.itemId)!.stock -= w.qty;
return true;
}

模型假設與範圍

  • 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
  • 同一商店資料用關聯、文件與寬欄模型表示;計數反映這組查詢。沒有涵蓋各資料庫產品的完整功能或效能。

什麼時候用

  • 還不確定會怎麼查詢、需要交易、資料之間關係多:先用關聯式資料庫。單機 PostgreSQL 撐得住的量,比多數人以為的大很多。
  • 存取方式固定、量非常大、要水平擴充(訊息、事件、時間序列、使用者的動態):寬欄或鍵值。
  • 資料天生是一整包一起讀寫、結構常變(商品頁、設定、內容管理):文件資料庫。

和其他主題的關係

出現在這些架構裡

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

操作平均最差
最近 k 筆:SQL(有索引)O(log n + k)O(log n + k)
最近 k 筆:寬欄(依時間排好)O(k)O(k)
最近 k 筆:整份文件再排序
h 是這位使用者的全部訂單;載入 O(h),範例另外排序
O(h log h)O(h log h)
改名:正規化(只存一份)O(log n)O(log n)
改名:反正規化、沒有反向索引
先掃全部 N 筆紀錄(文件/列及內嵌明細),再寫 m 份副本;若有商品 ID 索引,查找成本才會下降
O(N + m)O(N + m)
跨分區報表
N 是全部資料;除非事先維護好彙總表
O(N)O(N)

空間:O(N),反正規化的副本會讓它多出一個常數倍

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

數的是:每位使用者有 n 筆訂單時,載入紀錄的 JSON 位元組數

Big On = 10n = 100n = 1,000成長倍數:實測(理論)
最近 10 筆:SQL(索引)O(1)1,9312,0652,012×1.0 (×1.0)
最近 10 筆:寬欄O(1)1,2041,2771,248×1.0 (×1.0)
最近 10 筆:整份文件O(n)1,22613,093133,717×109 (×100)
改名:掃寬欄找副本O(n)13,509128,2691,318,253×98 (×100)

照存取方式排好的資料,讀取成本和資料總量無關;把整段歷史塞進一份文件,讀取就跟著歷史一起長。反正規化的副本則讓改名的成本跟著訂單數長。

和其他做法比

關聯式(SQL)文件寬欄(wide-column)
某人最近 10 筆訂單1 次資料庫呼叫;載入 1.9 KiB、寫入 0 B JSON;1 個分片;不需要交易1 次資料庫呼叫;載入 1.2 KiB、寫入 0 B JSON;1 個分片;不需要交易1 次資料庫呼叫;載入 1.2 KiB、寫入 0 B JSON;1 個分片;不需要交易
商品改名1 次資料庫呼叫;載入 43 B、寫入 44 B JSON;1 個分片;單筆原子更新96 次資料庫呼叫;載入 260.9 KiB、寫入 115.6 KiB JSON;8 個分片;每份各自原子,整體不是126 次資料庫呼叫;載入 256.6 KiB、寫入 16.2 KiB JSON;8 個分片;每份各自原子,整體不是
各商品營收報表1 次資料庫呼叫;載入 176.1 KiB、寫入 0 B JSON;1 個分片;不需要交易8 次資料庫呼叫;載入 260.9 KiB、寫入 0 B JSON;8 個分片;不需要交易8 次資料庫呼叫;載入 256.6 KiB、寫入 0 B JSON;8 個分片;不需要交易
下單並扣庫存1 次資料庫呼叫;載入 88 B、寫入 215 B JSON;1 個分片;單機交易6 次資料庫呼叫;載入 1.3 KiB、寫入 1.4 KiB JSON;3 個分片;跨分區:要分散式交易或 Saga5 次資料庫呼叫;載入 88 B、寫入 219 B JSON;3 個分片;跨分區:要分散式交易或 Saga

這張表比較三種排列下的存取成本。呼叫數與資料量分開看:一次呼叫也可能讀進整段歷史。資料量以紀錄的 UTF-8 JSON 計,不含索引與壓縮;交易協調也未計入。MongoDB 可以跨分片聚合,DynamoDB 是鍵值/文件資料庫;SQL 與 NoSQL 都能有交易與分片,支援範圍依產品而異。

真實世界裡的它

  • Discord 把幾兆則訊息從 MongoDB 搬到 Cassandra,再搬到 ScyllaDB:每個頻道一個分區、依時間排序。
  • DynamoDB 是鍵值/文件資料庫;「單表設計」用分區鍵和排序鍵服務預先列好的存取方式,能表達類似這裡的依使用者取訂單。
  • 多數公司同時用好幾種:交易在 PostgreSQL、快取在 Redis、搜尋在 Elasticsearch、分析在資料倉儲。

取捨與陷阱

  • 「NoSQL 比較快」不是真的:只有在照著設計好的存取方式查詢時才快;換一種查法,可能就只能全表掃描。
  • 把一個人的全部歷史塞進一份文件,文件會持續長大(MongoDB 單一文件上限 16 MB)。本示範整份載入;實際產品可用投影只取部分欄位,也可把訂單拆成獨立文件再建索引。
  • 反正規化的副本會不一致:改了一處、漏了一處,或改到一半失敗。要嘛接受,要嘛只複製不會變的東西(例如下單當時的價格)。