SQLite 中使用 UUID 主鍵的風險
在配置為 WITHOUT ROWID 的 SQLite 表中,使用隨機 UUID(特別是 UUIDv4)作為主鍵會導致嚴重的性能下降,其插入速度與整數主鍵相比可能下降多達 14-16 倍。這是因為隨機 UUID 會迫使資料庫不斷重新平衡叢集索引 B-tree,導致過度的分頁與磁碟 I/O。
叢集索引對性能的影響
在叢集索引中,資料列的物理儲存順序是由索引鍵決定的。由於資料列只能以一種方式進行物理排序,因此一個表只能有一個叢集索引;實際上,叢集索引 就是 表本身。
在 SQLite 中,預設行為是使用一個名為 rowid 的隱含 64 位元整數主鍵。表中的資料是使用此 rowid 作為鍵值儲存在 B-tree 中,使其成為叢集索引。當使用 WITHOUT ROWID 優化時,所宣告的主鍵會成為叢集索引,而非隱含的 rowid。
基準測試結果:整數 vs. UUIDv4 vs. UUIDv7
以每批次 100 萬列進行 1,000 萬列插入的基準測試,展示了 SQLite 中不同主鍵策略的性能成本:
1. 基準:整數主鍵 (Standard rowid)
插入效率極高,每秒可達到約一百萬次插入。隨著表的大小增長,每百萬列所需的時間保持穩定。
2. UUIDv4 WITHOUT ROWID
在 WITHOUT ROWID 表中使用隨機 UUIDv4 作為主鍵時,性能會顯著下降。隨著表的大小增長,插入後續每百萬列所需的時間會增加(例如,從首個百萬列的 2,649ms 增加到第 1 億列的 12,586ms)。這種性能退化是由於 UUIDv4 的無序特性,迫使 SQLite 將資料列隨機插入 B-tree 中,從而觸發不斷的重新平衡與增加的分頁。
3. UUIDv7 WITHOUT ROWID
使用具有時間順序的 UUIDv7,可以很大程度上解決性能問題。插入時間回到了合理的水平(平均每百萬列約 1,250ms),儘管仍比整數基準稍慢。這歸因於 UUID blob 的較大尺寸(16 bytes)相對於整數主鍵(8 bytes)。
4. UUIDv4 WITH ROWID
使用 UUIDv4 作為主鍵但保留隱含的 rowid(預設的表類型)時,其性能優於 WITHOUT ROWID,因為叢集索引仍保持順序(rowid)。然而,它仍然比 UUIDv7 慢,因為資料庫仍必須為 UUID 主鍵維護一個非叢集索引,這仍會受到隨機插入的開銷。
主鍵策略摘要
| 策略 | 叢集索引 | 性能 | 權衡 (Trade-off) |
|---|---|---|---|
| Integer PK | 順序 rowid |
最快 | 洩露行數/相鄰 ID |
| UUIDv7 (WITHOUT ROWID) | 時間順序 UUID | 快 | 洩露建立時間戳記 |
| UUIDv4 (WITH ROWID) | 隨機 UUID | 中等 | 寫入放大 (兩個索引) |
| UUIDv4 (WITHOUT ROWID) | 隨機 UUID | 最慢 | 嚴重的 B-tree 重新平衡開銷 |
社群見解與替代方案
圍繞這些基準測試的技術討論突顯了使用 UUID 作為主鍵的幾種架構替代方案:
- 混合方法: 許多開發者建議使用順序整數作為內部主鍵用於關聯 (join) 與查詢,同時維護一個獨立的 UUID 欄位作為對外公開的識別碼。這可以避免隨機鍵值的性能陷阱,同時為 API 提供不透明的 ID。
- Distributed Systems Needs: 雖然整數在單一實例資料庫中是首選,但 UUIDv7 被視為多個應用程式實例之間需要合併數據或在不發生衝突的情況下跨裝置同步數據的關鍵工具。
- Security Considerations: 有人認為,當必須嚴格隱藏建立時間或行數時,UUIDv4 仍然有用,前提是插入性能不是主要限制因素。
"隨機 ID 主鍵是一個壞主意,無論是 UU 類型的還是 SQ 類型的,或是任何其他類型。就我的資料庫知識而言,這類型的 ID 會摧毀所有的樹狀演算法..."
"UUIDv7 和順序整數非常相似。順序整數會洩露行數與相鄰 ID,而 UUIDv7 會洩露時間戳記。"
結論
對於 SQLite 用戶而言,實施 UUID 的最高效方式是在 WITHOUT ROWID 表中使用時間順序的 UUIDv7。如果需要絕對最高的性能,請堅持使用標準的整數 rowid。避免將隨機 UUIDv4 作為叢集索引,因為性能懲罰會隨著數據集的大小線性增長。