目錄
1. ACID 簡單介紹
想像你要從 A 銀行轉帳 $100 到 B 銀行:
A 帳戶:-$100
B 帳戶:+$100
如果轉帳到一半伺服器當機,會發生什麼事?
如果只扣了 A 沒加 B → A 少了 $100,B 沒收到 → 錢消失
如果扣了 A 也加了 B → 正常,但當機沒影響
如果兩邊都扣了 → 明顯不對
ACID 就是用來保證這種操作不會出錯的四個規則:
- A = Atomicity(原子性):全部成功或全部失敗,沒有「只做一半」
- C = Consistency(一致性):操作前後,所有的規則都沒有被打破
- I = Isolation(隔離性):同時發生的操作,互相不要干擾
- D = Durability(持久性):一旦成功,資料不會無故消失
要注意 Atomicity ≠ Consistency
回到轉帳的故事:
Atomicity 保證了:A 扣 $100 和 B 加 $100 要嘛一起成功,要嘛一起失敗 Consistency 保證了:轉帳前 A+B 的總額 = 轉帳後 A+B 的總額看出差別了嗎?
Atomicity 管的是「操作有沒有完整執行」,Consistency 管的是「操作完結果合不合理」。
即使 Atomicity、Isolation、Durability 都滿足了,還是可能違反Consistency。
2. Consistency 到底是什麼?
指的是交易開始前是合法的狀態,交易結束後也必須是合法的狀態。
這個「合法」不是法律上的合法,是「你定義的所有規則都沒有被打破」。
舉例來說
你規定:「冰箱裡的飲料不能少於 5 瓶。」
- 開始前:冰箱有 8 瓶 ✅
- 你拿走 2 瓶 → 還剩 6 瓶 ✅
- 你拿走 4 瓶 → 還剩 4 瓶 ❌(規則被打破)
- 你把冰箱洗劫一空 → 0 瓶 ❌(規則被打破)
Consistency 就是在檢查:
每一次你開關冰箱後,那個「不能少於 5 瓶」的規則還有沒有被遵守
同樣的道理,在資料庫裡就是:
| 規則類型 | 範例 | 誰來檢查 |
|---|---|---|
| 資料庫內建規則 | Primary Key 不能重複、ForeignKey 不能指向不存在的資料、NOT NULL 欄位不能空、UNIQUE 不能重複 | 資料庫自動擋掉 |
| 應用層業務規則 | 「餘額不能為負」、「每天只能發 5 篇文」、「購物車總金額不得超過 $10,000」 | 你自己寫程式檢查 |
常見的誤區
很多人以為:「資料庫會自動保證一致性。」
資料庫只會保證 Primary Key 不重複、ForeignKey 正確指向這些「內建規則」。
但你寫的業務規則,像「餘額不能負的」、「每天提款上限 $30,000」,資料庫根本不知道。
Consistency 的最終責任在開發者身上。
3. Consistency 的兩個層面: 資料庫/應用層
3.1. 資料庫層級的 Consistency
這是資料庫幫你做的事,不用寫額外程式碼:
- Entity Integrity:Primary Key 不能是 NULL
- Referential Integrity:ForeignKey 必須指向存在的資料
- Domain Integrity:欄位的型別、長度、格式必須正確(例如 INT 欄位不能存文字)
- Unique Constraints:不能有重複值
-- 資料庫會幫你擋掉這些:
INSERT INTO users (id, email) VALUES (NULL, 'test@test.com');
-- ❌ Primary Key 不能為 NULL
INSERT INTO orders (user_id) VALUES (999);
-- ❌ 如果 user_id 999 不存在,FK 約束會拒絕
INSERT INTO products (price) VALUES (-100);
-- ❌ 如果有 CHECK(price >= 0) 約束
違反了資料庫直接拒絕操作,回傳錯誤。
你不需要寫任何業務邏輯來防止這種情況。
3.2. 應用層級的 Consistency
這是你自己要寫程式處理的,像是:
- 業務邏輯規則:例如「用戶餘額不能為負」、「訂單折扣不能超過 30%」
- 跨表狀態一致性:例如「A 表扣庫存時,B 表的訂單明細也要寫入」
- 衍生資料一致性:例如「快取裡的資料必須和資料庫一致」
- 操作紀錄的追蹤:例如「每次修改權限都要有操作記錄」
以業務邏輯規則的案例來看為何會違反應用層級的 Consistency :
違反的規則:用戶餘額不能為負(餘額必須 ≥ 0)
// 讀取和寫入之間有時間窗口
public async Task<bool> WithdrawAsync(int accountId, decimal amount)
{
var account = await _db.Accounts.FindAsync(accountId);
if (account.Balance < amount) return false;
account.Balance -= amount;
await _db.SaveChangesAsync();
return true;
}
這段程式碼的問題是,想像兩個人同時提款:
帳戶餘額 $100
Request 1(提款 $80):讀到 Balance = $100 → 通過 → 寫入 $20
Request 2(提款 $70):讀到 Balance = $100(髒資料)→ 通過 → 寫入 $30 ❌
最終餘額:$30(R1 扣的 $80 被覆蓋了,且 R2 基於過期資料做了錯誤決定)
正確應該要是:R1 扣成 $20 → R2 發現 $20 < $70,拒絕提款 → 最終 $20
這不是 Atomicity 的問題(每個扣款都完整執行),也不是資料庫層級的問題(沒有違反 PK/FK),而是應用層的 Consistency 被違反了。
這其實就是典型的 Lost Update(遺失更新)
指的是:兩個交易先後讀取了同一筆資料,各自做了判斷後寫入,後寫的那個把前一個的修改覆蓋掉了。
4. 如何達到 Consistency
Consistency 沒有單一解法,需要在資料庫約束、隔離層級、鎖機制、業務邏輯檢查等多方面做處理。
4.1. 資料庫層級:用 Constraints 建立防火牆
把你能想到的規則,盡可能用資料庫的 Constraints 表達出來,讓資料庫自動幫你檢查
| Constraint | 用途 | 範例 |
|---|---|---|
PRIMARY KEY |
每筆資料都要有唯一識別 | id INT PRIMARY KEY |
FOREIGN KEY |
確保關聯存在 | user_id INT REFERENCES users(id) |
UNIQUE |
值不能重複 | email VARCHAR(255) UNIQUE |
CHECK |
自訂條件 | CHECK (price >= 0) |
NOT NULL |
欄位不能為空 | name VARCHAR(100) NOT NULL |
-- ✅ CHECK 約束:價格不能為負
CREATE TABLE products (
id INT PRIMARY KEY,
price DECIMAL(10,2) CHECK (price >= 0)
);
-- ✅ 違反時資料庫直接拒絕
INSERT INTO products (id, price) VALUES (1, -100);
-- ❌ ERROR: CHECK constraint violation
4.2. 應用層級:Consistency 需要 Isolation 來保護
前面 3.2 的提款範例有一個關鍵問題:沒有違反原子性 Atomicity,每個扣款都完整執行,但還是違反 Consistency 。
為什麼?因為 Isolation 不夠。
問題的本質
// 這段程式碼有 Transaction,也有 Atomicity,但還是錯了
public async Task<bool> WithdrawAsync(int accountId, decimal amount)
{
var account = await _db.Accounts.FindAsync(accountId); // 讀取
if (account.Balance < amount) return false; // 檢查
account.Balance -= amount; // 寫入
await _db.SaveChangesAsync();
return true;
}
兩個請求同時進來:
餘額 $100
R1(取 $80):讀到 $100 → 檢查通過 → 寫入 $20
R2(取 $70):讀到 $100(還沒被 R1 提交蓋掉)→ 檢查通過 → 寫入 $30 ❌
問題出在「讀取」和「寫入」之間,另一個交易插了進來,讀到了舊資料。
要防止這種情況,就要靠隔離層級來控制一個交易能不能看到別人「還沒提交」的變更。
什麼是隔離層級 (Isolation Level)?
資料庫為了效能,不會讓交易真的排隊一個一個執行,而是讓它們同時跑。隔離層級就是在控制一件事:
一個交易能不能看到別人「還沒提交」的變更?
先搞懂「提交」是什麼意思:
在資料庫裡,你對資料做完修改後,要下 COMMIT 指令資料才算真的寫進去。
執行 COMMIT 之前,你的修改叫「還沒提交」。
這時候如果執行 ROLLBACK,修改就會取消,像沒發生過一樣。
回到提款的例子,R1 執行了 account.Balance -= amount 但還沒下 COMMIT:
R1 的修改:$100 → $20(還沒提交,隨時可以 ROLLBACK 取消)
R2 能不能看到這個 $20? → 看隔離層級決定
各隔離層級對 Consistency 的保護程度:
| 隔離層級 | 白話解釋 |
|---|---|
| Read Uncommitted | 別人還沒 COMMIT 的資料你也看得到 |
| Read Committed | 只能看到別人已經 COMMIT 的資料 |
| Repeatable Read | 同一筆資料在這筆交易內,不管讀幾次結果都不變 |
| Serializable | 所有交易像排隊一個一個執行,互不干擾 |
舉例來說
原本餘額 $100,R1 取 $80,R2 取 $70:
Read Uncommitted(最弱)
R1:讀到 $100 → 扣成 $20(還沒提交)
R2:讀到 $20 ⚠️(看到 R1 還沒提交的變更)
→ $20 < $70 → 拒絕提款 ✅
但如果 R1 最後 Rollback,R2 看到的 $20 就是髒資料 (Dirty Read)
Read Committed(通常資料庫的預設都是這個層級)
R1:讀到 $100 → 扣成 $20(還沒提交)
R2:讀到 $100(看不到 R1 未提交的 $20)
→ 檢查通過 → 扣成 $30 ❌ 結果超賣
Repeatable Read
R1:讀到 $100 → 扣成 $20
R2:讀到 $100 → 檢查通過 → 開始扣...
└ 實際結果依資料庫而異:
MySQL / SQL Server:UPDATE 會讀最新值 → 餘額變 -$50 ❌
PostgreSQL:發現資料被改過 → 中止交易,報錯 ✅
Serializable
R1:讀到 $100 → 扣成 $20
R2:準備扣成 $30
❌ 資料庫發現衝突 → 中止 R2,請他重試
看到這邊你可能會想:「所以只有 Serializable 能解決問題?」
對,如果只靠隔離層級,確實只有 Serializable 保證安全。
但 Serializable 會把所有交易排隊執行,效能很差。
所以我們常常會搭配下面幾種方法一同做保護。
4.3. 原子操作:讓資料庫一次做完
為什麼前面 3.2 的寫法會出事?因為程式碼是分三步走的:
var account = await _db.Accounts.FindAsync(accountId); // 步驟 1:讀取
if (account.Balance < amount) return false; // 步驟 2:檢查
account.Balance -= amount; // 步驟 3:寫入
步驟 1 和步驟 3 之間,別人可以趁機插進來修改資料。
這個從「讀取」到「寫入」之間的空檔,就是所謂的 時間窗口。
時間窗口越長,別的交易插進來的機會就越大。
原子操作的思路:三步變一步
不要把「讀取 → 檢查 → 寫入」拆成三段程式碼分開執行,而是用一條 SQL 讓資料庫一次做完:
-- ✅ 庫存夠才扣,不夠則影響 0 筆,全程一條 SQL
UPDATE products SET stock = stock - 1 WHERE id = 1 AND stock >= 1;
這條 SQL 做的事情等於:
1. 讀取 stock ← 資料庫內部一次完成
2. 檢查 stock >= 1 ← 資料庫內部一次完成
3. 夠的話就扣 1 ← 資料庫內部一次完成
因為檢查和寫入發生在同一條 SQL 裡,資料庫在執行這條 SQL 時不會讓其他人插隊,所以根本沒有時間窗口。
// ✅ 對應的 EF Core 寫法
var affected = await _db.Products
.Where(p => p.Id == productId && p.Stock >= quantity)
.ExecuteUpdateAsync(p => p.SetProperty(x => x.Stock, x => x.Stock - quantity));
if (affected == 0)
throw new InvalidOperationException("庫存不足");
原子操作的優點
不需要 Transaction 也能保證一致性。 檢查和寫入在同一條 SQL 中完成,沒有時間差。
當你的操作可以濃縮成一條 SQL 時,等於資料庫幫你擋住了所有並發干擾。
什麼時候不該用
原子操作雖然簡單,但它有極限:
| 不適合的場景 | 原因 | 該用什麼 |
|---|---|---|
| 需要先讀取舊值做複雜判斷(eg. 計算每日提款總額、檢查折扣上限) | 一條 SQL 塞不下這麼多邏輯 | 樂觀鎖(4.4) 或 悲觀鎖(4.5) |
| 需要跨多個 table 保持同步(eg. 扣庫存 + 同時寫訂單明細) | 單條 SQL 只能動一個 table | Transaction + 適當隔離層級 |
| 寫入後需要寫 Log 或發送通知 | 資料庫只管資料,不管 side effect | 應用層處理 + Transaction |
4.4. 樂觀鎖(Optimistic Locking)
適合衝突率低的場景,主要是在讀取資料時記下那筆資料的版本號,寫入時檢查版本號有沒有被別人改過。
舉例來說
想像兩位管理員同時打開商品編輯頁面:
管理員 A:讀到價格 $50 (version=1) → 改成 $100 → 存檔 → version 變 2 ✅
管理員 B:讀到價格 $50 (version=1) → 改成 $90 → 存檔 → version 已經是 2 了 → 拒絕 ❌
管理員 B 在開啟頁面到按下存檔之間,別人已經改過了。
樂觀鎖的做法很簡單:
每筆資料帶一個
version欄位,寫入時檢查版本。
SQL 範例
UPDATE products SET price = @newPrice, version = version + 1
WHERE id = @id AND version = @oldVersion;
-- 管理員 B 傳入 @oldVersion = 1,但資料庫中的 version 已被 A 改成 2
-- WHERE version = 1 找不到資料 → 影響 0 筆 → 拋出 DbUpdateConcurrencyException
EF Core 實作
在 Entity 上加一個版本欄位,標記為 ConcurrencyToken:
public class Product
{
public int Id { get; set; }
public decimal Price { get; set; }
public int Version { get; set; } // 樂觀鎖欄位
}
var product = await _db.Products.FindAsync(productId);
product.Price = newPrice;
// EF Core 自動生成類似這樣的 SQL:
// UPDATE products SET price = @p0, version = version + 1
// WHERE id = @p1 AND version = @p2
// 如果影響 0 筆 → 拋出 DbUpdateConcurrencyException
await _db.SaveChangesAsync();
衝突發生後怎麼辦
try
{
await _db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
// 重新載入最新資料
var entry = _db.Entry(product);
await entry.ReloadAsync();
// 告訴使用者「資料已被別人修改,請重新確認」
Console.WriteLine($"最新價格是 {product.Price},請重新填寫");
}
樂觀鎖的特色是:不阻止並發,只偵測衝突
意思是讀取時不鎖資料,只是在寫入時才檢查
| 適用場景 | 不適用場景 |
|---|---|
| 一般 CRUD、後台管理操作 | 秒殺搶票 |
| 同一個人編輯自己的資料 | 高頻扣庫存 |
| 使用者很少同時改同一筆資料 | 會大量重試且重試成本高 |
4.5. 悲觀鎖(Pessimistic Locking)
如果今天的場景不是「兩個管理員修改商品價格」,
而是一千個人同時搶最後一件商品,樂觀鎖就不適用了。
因為每個人都會衝突,所以每個人都在 Retry,系統反而更慢
悲觀鎖的邏輯與樂觀所相反:
讀取時直接鎖起來,不讓別人碰,做完才解鎖
舉例來說
想像一個線上購物系統在特賣活動中,只剩最後一台筆電:
Request A(小明):SELECT ... FOR UPDATE → 鎖住這筆資料 → 扣庫存 → Commit → 解鎖
Request B(小華): 等到 A 解鎖才能讀 → 發現沒貨了
小華不是「讀到舊庫存然後扣失敗」,他是根本讀不到,直到 A 做完。
SQL 範例
BEGIN;
SELECT * FROM products WHERE id = 1 FOR UPDATE; -- 鎖住 id=1 這筆
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT; -- 解鎖
FOR UPDATE 的意思是:「我要鎖這筆,其他人要讀取或修改都請排隊。」
EF Core 實作
await using var tx = await _db.Database.BeginTransactionAsync(IsolationLevel.ReadCommitted);
// 鎖住這筆資料,其他交易要讀同一筆時必須等
var product = await _db.Products
.FromSqlRaw("SELECT * FROM products WHERE id = {0} FOR UPDATE", productId)
.FirstAsync();
if (product.Stock >= quantity)
{
product.Stock -= quantity;
await _db.SaveChangesAsync();
await tx.CommitAsync();
}
// Transaction 結束 → 自動解鎖
代價:並行度降低
A 鎖住資料時,B 必須排隊等
所以要確保 Transaction 短小輕快,千萬不要在使用者填表單的過程中鎖資料,不然使用者填 10 分鐘,資料就鎖 10 分鐘。
而且如果 Transaction A 鎖了商品等訂單,Transaction B 鎖了訂單等商品,兩邊都在等對方釋放,就會造成死鎖。
有這種情況時,資料庫會自動偵測並中止其中一個,但你的程式碼要做好重試的處理。
樂觀鎖 vs 悲觀鎖
| 樂觀鎖 | 悲觀鎖 | |
|---|---|---|
| 什麼時候檢查衝突 | 寫入時才檢查 | 讀取時就直接鎖 |
| 衝突時的代價 | 重試一次 | 排隊等待 |
| 適合情境 | 衝突率低 | 衝突率高 |
| 讀取效能 | 高(不鎖) | 低(要等鎖) |
| 典型場景 | 修改文章、更新個人資料 | 搶購、扣庫存、扣款 |
4.6. 業務規則檢查:自己寫程式碼檢查
資料庫不知道你的業務規則,例如「每日提款上限 $30,000」、「折扣不能超過 30%」。
這些必須你自己寫程式碼檢查。
-- ❌ 資料庫不會幫你擋這個:
INSERT INTO transactions (user_id, amount) VALUES (1, 35000);
-- 即使超過日限額,資料庫照樣寫入,因為它不知道你的規則
// ✅ 你要自己檢查:
var todayTotal = await _db.Transactions
.Where(t => t.UserId == userId && t.CreatedAt.Date == DateTime.UtcNow.Date)
.SumAsync(t => t.Amount);
if (todayTotal + amount > 30000)
throw new InvalidOperationException("每日提款上限 $30,000");
5. 總結
Consistency(一致性)的核心問題只有一個:交易結束後,你定義的規則還有沒有被遵守?
Consistency 不是資料庫單方面能保證的,它需要幾個層級來做保護:
- 資料庫 Constraints,例如 PK、FK、CHECK、UNIQUE 的基本防護
- Transaction + 隔離層級 ,確保同時發生的操作不互相干擾
- 鎖機制(樂觀鎖 / 悲觀鎖), 精確控制並發存取
- 業務規則檢查,只有開發者知道的業務規則,要自己寫程式檢查
沒有哪一招能搞定所有情況,應該要在不同的情境下,選用不同的工具組合。
Top comments (0)