資料庫與應用程式產生主鍵的思考方向
資料庫與應用程式產生主鍵的思考方向
前言
設計資料表時,有個問題其實比想像中關鍵:這張表的主鍵(PK),到底是誰負責生出來的?
最直覺的答案是「資料庫啊」——int IDENTITY 自動加一,或 SQL Server 的 NEWSEQUENTIALID(),
反正 INSERT 下去資料庫就會幫我填好。寫起來省事,大家一開始也都這樣用。
但用久了會踩到一些尷尬的狀況:
- 我想在存檔之前就知道這筆資料的 Id(要先建關聯、要回傳給前端、要寫 log),但值在資料庫那邊,
非得SaveChanges()之後才拿得到。 - 寫單元測試想驗證物件之間的關聯,結果沒有真資料庫就生不出 Id。
- 一張主檔搭多張明細,明細要塞外鍵,但主檔 Id 還沒生出來,整個串接卡住。
於是另一條路浮上來:讓應用程式自己產生主鍵。我們團隊的開發規範也是這樣訂的——
所有實體的主鍵一律標上 [DatabaseGenerated(DatabaseGeneratedOption.None)],
把產生主鍵的責任從資料庫手上收回來。
這篇就把「資料庫產生 PK」與「應用程式自訂 PK」兩條路講清楚,
並用 EF Core 內建的 SequentialGuidValueGenerator 示範兩種落地寫法。
兩種派典
先把兩條路攤開來比:
| 面向 | 資料庫產生 PK | 應用程式自訂 PK |
|---|---|---|
| 代表做法 | int IDENTITY、NEWSEQUENTIALID() | 程式端產生 Guid |
| 何時知道 Id | SaveChanges() 之後 | 物件一 new 出來就有 |
| 對資料庫的依賴 | 綁特定資料庫機制 | 與資料庫無關,換 DB 不影響 |
| 測試 | 通常要連真的 DB | 純記憶體就能跑 |
| 分散式 / 離線 | 麻煩(要協調序號) | 天生適合,各節點各自產生不衝突 |
| 值的大小 | int 4 bytes、bigint 8 bytes | Guid 16 bytes |
| 可讀性 | 流水號好讀 | GUID 又長又醜 |
可以看到,資料庫產生 PK 的最大痛點,是「值在資料庫那邊」這件事本身——
只要你需要在存檔前就用到 Id,這條路就會處處卡關。而應用程式自訂 PK 把值的生成搬到程式端,
這些問題自然就消失了。
這也是為什麼一旦選擇 GUID 當主鍵,幾乎都會搭配「應用程式產生」。
因為 GUID 本來就不需要資料庫協調,硬要讓資料庫去NEWSEQUENTIALID()反而失去了「存檔前就拿得到值」這個最大的好處。
為什麼規範要求 DatabaseGeneratedOption.None
[DatabaseGenerated(...)] 這個 Attribute 是在告訴 EF Core:這個欄位的值由誰負責產生。
| 選項 | 意思 | EF Core 行為 |
|---|---|---|
Identity | 新增時由資料庫產生 | INSERT 後把資料庫生的值讀回來 |
Computed | 新增與更新都由資料庫產生 | 每次都讀回資料庫算好的值 |
None | 不由資料庫產生 | EF 不插手、不讀回,值得自己給 |
我們的規範統一用 None,等於宣告:「主鍵的值,由應用程式全權負責。」
public class Product
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.None)]
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
}這麼做的好處,正是上一節那張表的右半邊:存檔前就有 Id、不綁資料庫、好測試、適合分散式。
⚠️ 一個很容易忽略的副作用:標上
None之後,EF Core 就不會再自動幫你產生主鍵了。
如果你只標了None卻沒給值,存進去的就會是Guid.Empty(00000000-...),
而且第二筆會因為主鍵重複直接炸給你看。所以「自己產生」這件事一定要補上——這正是下面要解決的問題。
為什麼是 GUID,而不是流水號?
決定「應用程式產生」之後,還有一個選擇:要自己維護一個遞增流水號,還是用 GUID?
實務上幾乎都選 GUID,原因有三。
1. 不綁定特定資料庫
流水號的遞增邏輯往往得靠資料庫(IDENTITY、SEQUENCE)撐腰,等於把「值怎麼生」綁死在某個 DB 產品上。
一旦想換資料庫、或在沒有資料庫的情境下產生值(單元測試、訊息佇列先發事件、前端先建物件再送出),就生不出來了。
GUID 的產生只需要程式本身,與資料庫完全解耦——SQL Server、PostgreSQL、SQLite 換來換去都一樣能跑,
這也呼應了前面「應用程式產生不綁特定資料庫」的好處。
2. 多資料庫 / 分庫分表動態擴展不衝突
系統長大後常見的演進是水平切分:把資料拆到多個資料庫、多張分表。
這時流水號就尷尬了——每個節點各自從 1 開始遞增,合併或路由時主鍵直接撞車。
要避開就得搞「號段分配」「步長錯開(節點 A 走奇數、B 走偶數)」這類協調機制,又脆弱又難維運。
GUID 的命名空間大到 2^122,各節點各自悶著頭產生就幾乎不可能重複,
天生適合分散式擴展、跨庫合併、離線先生成再同步——未來要動態加機器、加分片,主鍵這層完全不用煩惱。
3. 不洩漏業務量
流水號會「被數出來」:訂單編號 #1024 等於告訴對手你總共接了一千多筆單。
GUID 不連續、不可預測,自然沒這個問題。
代價是 GUID 16 bytes 比
bigint8 bytes 大、又不可讀。大小帶來的索引衝擊靠循序 GUID 緩解(下一節),
不可讀則直接接受它——主鍵本來就不是拿來給人「讀」的,要給人看的編號請另開一個業務欄位。
GUID 不能亂產:碎片化與查詢效能
決定自己產生 Guid 之後,先別急著用 Guid.NewGuid()。
Guid.NewGuid() 產生的是完全隨機的值。問題在於,主鍵通常就是叢集索引(Clustered Index),
資料是依主鍵的順序實際排列在磁碟上的。隨機 GUID 意味著每次新增的值都「插在中間」,
造成大量的頁面分裂(Page Split)與索引碎片化,寫入效能與索引品質都會被拖垮。
而且碎片化不只傷寫入,查詢一樣遭殃。索引碎片化代表邏輯上相鄰的資料,實體上卻散落在不連續的頁面:
- 範圍查詢變慢:做
WHERE Id BETWEEN ...、ORDER BY Id、分頁掃描時,資料庫得跳著讀更多不連續的頁面,
I/O 次數上升、buffer pool(記憶體快取)命中率下降。 - 索引長得又高又胖:隨機值為了塞進中間頻繁分裂,頁面填充率低、B-Tree 層數變多,
每次查詢要多走幾層才找得到資料。 - 儲存空間浪費:頁面填不滿,同樣的資料佔用更多頁,連帶拖累全表掃描。
解法是用循序(sequential)GUID:值依然唯一,但整體是遞增的,新資料永遠接在尾端,
不會打亂既有的實體排序。頁面填得滿、碎片少,範圍掃描與快取都更友善,等於同時救回寫入與查詢效能。
SQL Server 的 NEWSEQUENTIALID() 就是在做這件事——只是它在資料庫端,又把我們拉回「存檔後才知道值」的老問題。
好消息是,EF Core 內建了一個在應用程式端產生循序 GUID 的工具:SequentialGuidValueGenerator。
它產生的 GUID 會依 SQL Server 的 GUID 排序規則遞增,等於把 NEWSEQUENTIALID() 的好處搬到了程式這一側。
SequentialGuidValueGenerator 的兩種用法
這個類別位於 Microsoft.EntityFrameworkCore.ValueGeneration 命名空間,有兩種截然不同的用法,
分別對應兩種主鍵策略。
用法一:手動產生(搭配 None,符合我們的規範)
直接 new 一個出來,呼叫 Next() 就拿到一顆循序 GUID。Next() 需要一個 EntityEntry 參數,
但這個產生器用不到它,傳 null 即可:
using Microsoft.EntityFrameworkCore.ValueGeneration;
var generator = new SequentialGuidValueGenerator();
Guid id = generator.Next(null!);這正是 EF Core 官方測試裡的用法——
new SequentialGuidValueGenerator()之後連續呼叫Next(null),
驗證連續產生的值都不重複。
實務上,最乾淨的落地方式是抽一個實體基底類別,在屬性初始化時就把 Id 生好。SequentialGuidValueGenerator.Next() 內部用 Interlocked 確保執行緒安全,所以可以放心共用一個靜態實例:
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore.ValueGeneration;
public abstract class EntityBase
{
private static readonly SequentialGuidValueGenerator _guidGenerator = new();
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.None)]
public Guid Id { get; set; } = _guidGenerator.Next(null!);
}繼承它就好,所有實體一出生就帶著一顆循序的、不重複的主鍵:
public class Product : EntityBase
{
public string Name { get; set; } = string.Empty;
}
// 物件一 new 出來,Id 就已經有值了——存檔前就能拿來建關聯、回傳前端
var product = new Product { Name = "咖啡豆" };
Console.WriteLine(product.Id); // 已經是一顆循序 GUID,不是 Guid.Empty這個寫法完全符合 DatabaseGeneratedOption.None 規範:值由程式產生,EF 與資料庫都不插手。
用法二:交給 EF Core 自動產生(HasValueGenerator)
另一種是把產生器註冊進模型,讓 EF Core 在新增實體時自動套用:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>()
.Property(e => e.Id)
.HasValueGenerator<SequentialGuidValueGenerator>();
}這對應的是 EF Core 另一份官方測試裡的寫法:
Property(e => e.Id).HasValueGenerator<SequentialGuidValueGenerator>()。
這種寫法看起來很優雅,但有個關鍵前提:值產生器只會在屬性的 ValueGenerated 是 OnAdd 時才會被觸發。
換句話說,它需要的是 ValueGeneratedOnAdd()(≒ DatabaseGeneratedOption.Identity 的語意),
而不是 None。
// 用法二的完整樣貌:值產生器 + OnAdd
modelBuilder.Entity<Product>()
.Property(e => e.Id)
.HasValueGenerator<SequentialGuidValueGenerator>()
.ValueGeneratedOnAdd();也就是說——
::: code-group
[DatabaseGenerated(DatabaseGeneratedOption.None)]
public Guid Id { get; set; } = _guidGenerator.Next(null!);
// 物件 new 出來就有值,EF 完全不插手// 屬性不給值,等 EF 在 Add 階段呼叫產生器補上
.HasValueGenerator<SequentialGuidValueGenerator>()
.ValueGeneratedOnAdd();
// 要進到 EF 的追蹤、Add 之後才有值:::
那到底該選哪個?
兩種用法的差別,其實是**「值在什麼時間點、由誰補上」**:
用法一(手動 / None) | 用法二(HasValueGenerator / OnAdd) | |
|---|---|---|
| 產生者 | 你的程式碼 | EF Core 的值產生管線 |
| 何時有值 | 物件 new 出來就有 | context.Add() 之後 |
DatabaseGenerated | None | 需 OnAdd(不能是 None) |
| 脫離 EF 也能用 | ✅ 純物件就有 Id | ❌ 得靠 EF 上下文 |
如果你的規範跟我們一樣要求 DatabaseGeneratedOption.None,那答案很明確:用法一。
因為 None 會讓 EF 的值產生管線整個停擺,用法二的 HasValueGenerator 根本不會被觸發。
把產生邏輯收進 EntityBase,是兼顧「符合規範」與「存檔前就有 Id」最省事的做法。
簡單記:要
None就自己Next();要 EF 幫你生就用HasValueGenerator+OnAdd。
兩者不能混——標了None又期待HasValueGenerator會動,是最常見的誤會。
延伸:除了 GUID,還有 Snowflake
「應用程式產生主鍵」不是只有 GUID 一條路。Twitter 提出的 Snowflake 是另一個很流行的方案,
它產生的是一個 64-bit 整數(long),結構大致是:
| 1 bit 保留 | 41 bits 時間戳記(毫秒) | 10 bits 節點 ID | 12 bits 同毫秒序號 |也就是「時間 + 節點 + 序號」拼成一個趨勢遞增的數字。它跟 GUID 一樣是程式端產生、分散式不衝突,
但用整數而非亂碼,補上了 GUID 最被詬病的幾個缺點:
| 面向 | 循序 GUID | Snowflake |
|---|---|---|
| 大小 | 16 bytes | 8 bytes(bigint,索引更小更快) |
| 排序 / 趨勢遞增 | ✅ | ✅ |
| 可反推產生時間 | ❌ | ✅(內含時間戳記,方便排序與除錯) |
| 不綁資料庫、分散式不衝突 | ✅ | ✅ |
| 需要協調 | 零協調 | 要分配節點 ID、要管時鐘 |
| 可預測性 | 不可預測(隱私佳) | 趨勢遞增,量級可被推測 |
Snowflake 的代價,正好是 GUID 的優勢:
- 依賴時鐘:機器時間若回撥(NTP 校時、跨機誤差),可能產生重複或亂序 ID,得額外處理「時鐘回撥」。
- 要管節點 ID:節點數有上限(10 bits = 1024 個),上線前得規劃分配——這正是 GUID 完全不需要的協調成本。
- 同毫秒序號有上限(12 bits = 4096/ms),瞬間爆量時要等下一毫秒。
一句話總結取捨:
GUID:零協調、絕對不撞、但 16 bytes 又不可讀;Snowflake:8 bytes、可排序、可反推時間,但要維運時鐘與節點 ID。
中小型系統用循序 GUID 最省事;當主鍵大小、可排序性變成關鍵,且你願意承擔節點分配與時鐘的維運成本,再考慮 Snowflake。實作上 Snowflake 通常自己寫產生器(或用 IdGen 這類套件),
一樣可以包進前面EntityBase的模式——把Id的初始值換成 Snowflake 產生器的輸出即可,搭配None的精神完全一致。
小結
- 主鍵由「資料庫產生」還是「應用程式產生」,核心差別在於存檔前拿不拿得到值。
- GUID 主鍵幾乎都搭配應用程式產生;我們的規範用
[DatabaseGenerated(DatabaseGeneratedOption.None)]統一這件事。 - 選 GUID 而非流水號,是為了不綁特定資料庫、多庫/分表動態擴展不衝突、不洩漏業務量。
- 標了
None之後 EF 不會再幫你生主鍵,務必自己補上,否則會是Guid.Empty並撞主鍵。 - 別用
Guid.NewGuid():隨機 GUID 會害叢集索引碎片化,寫入與查詢效能一起賠。改用SequentialGuidValueGenerator產生循序 GUID。 - 想要更小、可排序、可反推時間的主鍵,可考慮 Snowflake,代價是得維運時鐘與節點 ID。
SequentialGuidValueGenerator兩種用法:- 手動
Next(null!):搭配None,建議收進EntityBase,物件一出生就有 Id(符合規範)。 HasValueGenerator<T>():交給 EF 在OnAdd時產生,前提是ValueGenerated為OnAdd,不能是None。
- 手動