主題: PostgreSQL
UUIDv7 適合當主鍵嗎?更好的 UUID,不是萬用答案
UUIDv7 改善 UUIDv4 的索引局部性,PostgreSQL 18 與 Python 3.14 也已原生支援;但 bigint、時間隱私與遷移成本仍不能跳過。
動態迷因(展開/收合)
主鍵該用自增整數還是 UUID,大概是資料庫世界最耐吵的問題之一。
最近一篇討論 SQLite 使用 UUID 主鍵的代價 的 Reddit 貼文,又把熟悉的兩派叫回現場:一邊說 UUIDv7 已經解決亂序問題;另一邊說 128-bit 的 key 仍然比整數肥,join 也不會因為版本號變成 7 就免費。
兩邊依然都對。
UUIDv7 的確修掉 UUIDv4 對資料庫最不友善的一部分,但它沒有改寫資料結構的基本成本。比較務實的結論不是「從此全部改用 UUIDv7」,而是:如果需求本來就需要 UUID,現在大多數新設計應先考慮 v7;如果根本不需要 UUID,bigint 仍然很好。
UUIDv4 真正惹麻煩的地方:每次都插到不同角落
UUIDv4 幾乎完全由隨機資料組成。這讓不同節點可以各自產生 ID,不必先向中央資料庫領號;代價是連續產生的值在排序上彼此毫無關係。
對 B-tree index 而言,這代表新資料會落在索引的隨機位置。快取命中、page locality、page split 與寫入放大,都可能比遞增 key 更難看。RFC 9562 在制定新版 UUID 時,直接把 UUIDv4 的資料庫索引局部性列為問題之一。
這不是「UUID 很長」造成的,而是「下一個值不知道會掉在哪裡」。UUIDv7 主要修的就是這件事。
UUIDv7 做了什麼?把時間放到最前面
依照 RFC 9562,UUIDv7 前 48 bits 是 Unix Epoch 的毫秒時間戳。後面保留版本、variant,以及由實作安排的隨機值、counter 或次毫秒精度。
結果是:較晚產生的 UUID,通常也會排在較後面。資料庫可以把它當 128-bit 值直接比較,不必先解析字串才能得到時間順序。
這帶來三個實際好處:
- 仍可在 application、裝置或不同服務中先產生 ID
- 新值在 B-tree 中通常靠近最近寫入的位置
- 日誌與游標分頁可得到大致符合產生時間的順序
但「大致依時間排序」不等於「精確事件時間」。同一毫秒內如何維持單調性,取決於 generator 使用 counter、額外時間精度或其他方法;跨機器時還會遇到時鐘偏移。
PostgreSQL 18 終於讓這件事變無聊
UUIDv7 過去最大的摩擦,不是格式難懂,而是每個專案都要挑 extension 或第三方 library。PostgreSQL 18 已加入原生 uuidv7(),新表可以直接寫:
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT uuidv7(),
created_at timestamptz NOT NULL DEFAULT now()
);
這裡刻意保留 created_at。PostgreSQL 雖然也提供 uuid_extract_timestamp(id),官方文件明確提醒:抽出的時間不一定精確等於 UUID 的實際產生時間,取決於 generator。
Application 端也不再一定需要 dependency。例如 Python 3.14 標準函式庫已支援 uuid.uuid7():
from uuid import uuid7
order_id = uuid7()
需要離線建立物件、先組好關聯再一次寫入,或讓多個 writer 不經協調就產生 ID 時,client-side generation 很方便。只有單一 PostgreSQL writer 時,讓資料庫產生通常更簡單,也更容易維持單一節點內的排序。
動態迷因(展開/收合)
為什麼 bigint 還沒退休?
UUIDv7 仍是 128 bits;PostgreSQL 的 bigint 是 8 bytes。當主鍵同時出現在多個 secondary indexes、foreign keys 與 join buffer 裡,差距會重複累積。
如果系統符合下面條件,bigint GENERATED ... AS IDENTITY 往往仍是最省事的答案:
- 只有中央資料庫負責建立資料
- 不需要在寫入前知道 ID
- ID 不必跨資料庫、裝置或服務保持唯一
- 內部 join 與儲存效率比公開 ID 更重要
這不是在替老技術辯護,只是沒有必要為不存在的分散式需求付 16-byte key 的永久成本。
另一個常見折衷也很合理:內部用 bigint 當 primary key,再加一個 uuid 欄位作為公開或跨系統識別碼。它多一個 unique index,卻能讓大量內部 foreign keys 繼續保持小巧。是否值得,要看公開 ID 與內部 join 哪一邊才是高頻路徑。
UUIDv7 不是 created_at,更不是 secret
看到 ID 可以排序,很容易順手省掉時間欄位。不要。
RFC 允許 generator 因時鐘精度、修正或其他考量調整 timestamp,也沒有保證它與真實時間完全貼合。業務上的建立時間、接收時間、排程時間仍應使用明確欄位與資料庫約束。
UUID 也不該被當成授權能力。RFC 明確警告,不要假設 UUID 難以猜測;UUIDv7 還會暴露大致產生時間與順序。若「拿到這串值就能存取資源」,真正需要的是有足夠 entropy、可撤銷、可輪替的 secret token,以及正常的授權檢查。
ID 負責辨識,timestamp 負責時間,secret 負責權限。把三件事拆開,系統比較不會在半年後長出奇怪例外。
儲存時別把 128 bits 又膨脹回文字
標準 UUID 看起來像 36 個字元,但資料庫不需要用 varchar(36) 保存它。RFC 9562 的 DBMS 建議 指出,文字形式要用 288 bits 表示原本的 128-bit 值;能用原生或 binary 型別時就該用。
PostgreSQL 已有 uuid type,會處理輸入、輸出與比較。自己用字串欄位,通常只會得到更大的 index、較弱的型別檢查,以及更多無聊的格式問題。
不要為了換版本,重寫一套正常運作的主鍵
如果既有 UUIDv4 table 沒有量到 index locality 或寫入效能問題,單純為了「v7 比較新」去改 primary key,通常不值得。
主鍵遷移會碰到每個 foreign key、replication、cache key、event payload 與外部整合。UUIDv7 最適合的導入點是新 table、新 bounded context,或本來就要重建 ID 契約的 migration。舊系統先用實際指標證明問題,再決定是否付遷移成本。
我的選擇規則
| 情境 | 優先選擇 |
|---|---|
| 單一 DB writer、純內部資料 | bigint identity |
| 多 writer、離線建立、跨系統合併 | UUIDv7 |
| 需要公開 ID,但內部 join 很重 | bigint PK + UUIDv7 unique column |
| 需要可靠事件時間 | 獨立 timestamptz 欄位 |
| 需要授權 token | 獨立的密碼學安全隨機值 |
選 UUIDv7 時,再補五個檢查:
- 使用 RFC 9562 相容 generator,不自己拼 bits。
- 資料庫使用原生
uuid,不用文字欄位。 - 保留真正的
created_at。 - 不把 ID 當 secret。
- 用自己的資料量、indexes 與 query pattern 做 benchmark。
結論:它是更好的 UUID,不是更好的所有東西
UUIDv7 最重要的貢獻,是讓「可分散產生」與「對索引較友善」不必再二選一。RFC 已定案,PostgreSQL 18 與 Python 3.14 也把它帶進原生工具鏈,現在採用它比幾年前簡單得多。
但主鍵選擇從來不只看排序。key 寬度、foreign key 數量、writer 拓撲、公開 ID、時間隱私與遷移成本都還在。
所以我的預設很簡單:已經決定要 UUID,就先選 v7;還沒證明需要 UUID,就繼續用 bigint。