SQL 與 NoSQL
SQL(Structured Query Language,結構化查詢語言)代表關聯式資料庫,NoSQL(Not only SQL)泛指其他種類。關聯式資料庫給你交易和任意查詢;鍵值、文件、寬欄資料庫放棄其中一部分,換取容易水平擴充。該選哪個,取決於資料會怎麼被讀寫。
關聯式(SQL)
orders(id, user_id, created) order_lines(order_id, item_id, qty, price) items(id, name, price, stock) INDEX orders(user_id, created)
載入 44 列、寫入 0
文件
users/{id}: [
{ id, created,
lines: [{ item_id, name, qty, price }] }
]載入 1 份文件、寫入 0
寬欄(wide-column)
orders_by_user: PRIMARY KEY ((user_id), created DESC) items: PRIMARY KEY (item_id)
載入 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_idfunction 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 O | n = 10 | n = 100 | n = 1,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| 最近 10 筆:SQL(索引) | O(1) | 1,931 | 2,065 | 2,012 | ×1.0 (×1.0) |
| 最近 10 筆:寬欄 | O(1) | 1,204 | 1,277 | 1,248 | ×1.0 (×1.0) |
| 最近 10 筆:整份文件 | O(n) | 1,226 | 13,093 | 133,717 | ×109 (×100) |
| 改名:掃寬欄找副本 | O(n) | 13,509 | 128,269 | 1,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 個分片;跨分區:要分散式交易或 Saga | 5 次資料庫呼叫;載入 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)。本示範整份載入;實際產品可用投影只取部分欄位,也可把訂單拆成獨立文件再建索引。
- 反正規化的副本會不一致:改了一處、漏了一處,或改到一半失敗。要嘛接受,要嘛只複製不會變的東西(例如下單當時的價格)。