DEV Community

Cover image for [🗄️DataBase] ACID - Consistency
Mercy
Mercy

Posted on

[🗄️DataBase] ACID - Consistency

目錄

  1. ACID 簡單介紹
  2. Consistency 是什麼
  3. Consistency 的兩個層面:資料庫/應用層
  4. 如何達到 Consistency
  5. 總結

1. ACID 簡單介紹

想像你要從 A 銀行轉帳 $100 到 B 銀行:

A 帳戶:-$100
B 帳戶:+$100
Enter fullscreen mode Exit fullscreen mode

如果轉帳到一半伺服器當機,會發生什麼事?

如果只扣了 A 沒加 B → A 少了 $100,B 沒收到 → 錢消失
如果扣了 A 也加了 B → 正常,但當機沒影響
如果兩邊都扣了 → 明顯不對
Enter fullscreen mode Exit fullscreen mode

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) 約束
Enter fullscreen mode Exit fullscreen mode

違反了資料庫直接拒絕操作,回傳錯誤
你不需要寫任何業務邏輯來防止這種情況。

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;
}
Enter fullscreen mode Exit fullscreen mode

這段程式碼的問題是,想像兩個人同時提款:

帳戶餘額 $100

Request 1(提款 $80):讀到 Balance = $100 → 通過 → 寫入 $20
Request 2(提款 $70):讀到 Balance = $100(髒資料)→ 通過 → 寫入 $30 ❌

最終餘額:$30(R1 扣的 $80 被覆蓋了,且 R2 基於過期資料做了錯誤決定)
正確應該要是:R1 扣成 $20 → R2 發現 $20 < $70,拒絕提款 → 最終 $20
Enter fullscreen mode Exit fullscreen mode

這不是 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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

兩個請求同時進來:

餘額 $100
R1(取 $80):讀到 $100 → 檢查通過 → 寫入 $20
R2(取 $70):讀到 $100(還沒被 R1 提交蓋掉)→ 檢查通過 → 寫入 $30 ❌
Enter fullscreen mode Exit fullscreen mode

問題出在「讀取」和「寫入」之間,另一個交易插了進來,讀到了舊資料

要防止這種情況,就要靠隔離層級來控制一個交易能不能看到別人「還沒提交」的變更。

什麼是隔離層級 (Isolation Level)?

資料庫為了效能,不會讓交易真的排隊一個一個執行,而是讓它們同時跑。隔離層級就是在控制一件事:

一個交易能不能看到別人「還沒提交」的變更?

先搞懂「提交」是什麼意思:

在資料庫裡,你對資料做完修改後,要下 COMMIT 指令資料才算真的寫進去。
執行 COMMIT 之前,你的修改叫「還沒提交」。
這時候如果執行 ROLLBACK,修改就會取消,像沒發生過一樣。
Enter fullscreen mode Exit fullscreen mode

回到提款的例子,R1 執行了 account.Balance -= amount 但還沒下 COMMIT:

R1 的修改:$100 → $20(還沒提交,隨時可以 ROLLBACK 取消)
R2 能不能看到這個 $20? → 看隔離層級決定
Enter fullscreen mode Exit fullscreen mode

各隔離層級對 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,請他重試
Enter fullscreen mode Exit fullscreen mode

看到這邊你可能會想:「所以只有 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:寫入
Enter fullscreen mode Exit fullscreen mode

步驟 1 和步驟 3 之間,別人可以趁機插進來修改資料。

這個從「讀取」到「寫入」之間的空檔,就是所謂的 時間窗口

時間窗口越長,別的交易插進來的機會就越大。

原子操作的思路:三步變一步

不要把「讀取 → 檢查 → 寫入」拆成三段程式碼分開執行,而是用一條 SQL 讓資料庫一次做完:

-- ✅ 庫存夠才扣,不夠則影響 0 筆,全程一條 SQL
UPDATE products SET stock = stock - 1 WHERE id = 1 AND stock >= 1;
Enter fullscreen mode Exit fullscreen mode

這條 SQL 做的事情等於:

1. 讀取 stock          ← 資料庫內部一次完成
2. 檢查 stock >= 1     ← 資料庫內部一次完成
3. 夠的話就扣 1        ← 資料庫內部一次完成
Enter fullscreen mode Exit fullscreen mode

因為檢查和寫入發生在同一條 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("庫存不足");
Enter fullscreen mode Exit fullscreen mode

原子操作的優點

不需要 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 了 → 拒絕 ❌
Enter fullscreen mode Exit fullscreen mode

管理員 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
Enter fullscreen mode Exit fullscreen mode

EF Core 實作

在 Entity 上加一個版本欄位,標記為 ConcurrencyToken

public class Product
{
    public int Id { get; set; }
    public decimal Price { get; set; }
    public int Version { get; set; }  // 樂觀鎖欄位
}
Enter fullscreen mode Exit fullscreen mode
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();
Enter fullscreen mode Exit fullscreen mode

衝突發生後怎麼辦

try
{
    await _db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
    // 重新載入最新資料
    var entry = _db.Entry(product);
    await entry.ReloadAsync();
    // 告訴使用者「資料已被別人修改,請重新確認」
    Console.WriteLine($"最新價格是 {product.Price},請重新填寫");
}
Enter fullscreen mode Exit fullscreen mode

樂觀鎖的特色是:不阻止並發,只偵測衝突

意思是讀取時不鎖資料,只是在寫入時才檢查

適用場景 不適用場景
一般 CRUD、後台管理操作 秒殺搶票
同一個人編輯自己的資料 高頻扣庫存
使用者很少同時改同一筆資料 會大量重試且重試成本高

4.5. 悲觀鎖(Pessimistic Locking)

如果今天的場景不是「兩個管理員修改商品價格」,
而是一千個人同時搶最後一件商品,樂觀鎖就不適用了。
因為每個人都會衝突,所以每個人都在 Retry,系統反而更慢

悲觀鎖的邏輯與樂觀所相反:

讀取時直接鎖起來,不讓別人碰,做完才解鎖

舉例來說

想像一個線上購物系統在特賣活動中,只剩最後一台筆電:

Request A(小明):SELECT ... FOR UPDATE → 鎖住這筆資料 → 扣庫存 → Commit → 解鎖
Request B(小華):                                             等到 A 解鎖才能讀 → 發現沒貨了
Enter fullscreen mode Exit fullscreen mode

小華不是「讀到舊庫存然後扣失敗」,他是根本讀不到,直到 A 做完。

SQL 範例

BEGIN;
SELECT * FROM products WHERE id = 1 FOR UPDATE;  -- 鎖住 id=1 這筆
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;  -- 解鎖
Enter fullscreen mode Exit fullscreen mode

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 結束 → 自動解鎖
Enter fullscreen mode Exit fullscreen mode

代價:並行度降低

A 鎖住資料時,B 必須排隊等

所以要確保 Transaction 短小輕快,千萬不要在使用者填表單的過程中鎖資料,不然使用者填 10 分鐘,資料就鎖 10 分鐘。

而且如果 Transaction A 鎖了商品等訂單,Transaction B 鎖了訂單等商品,兩邊都在等對方釋放,就會造成死鎖

有這種情況時,資料庫會自動偵測並中止其中一個,但你的程式碼要做好重試的處理。

樂觀鎖 vs 悲觀鎖

樂觀鎖 悲觀鎖
什麼時候檢查衝突 寫入時才檢查 讀取時就直接鎖
衝突時的代價 重試一次 排隊等待
適合情境 衝突率低 衝突率高
讀取效能 高(不鎖) 低(要等鎖)
典型場景 修改文章、更新個人資料 搶購、扣庫存、扣款

4.6. 業務規則檢查:自己寫程式碼檢查

資料庫不知道你的業務規則,例如「每日提款上限 $30,000」、「折扣不能超過 30%」。

這些必須你自己寫程式碼檢查

-- ❌ 資料庫不會幫你擋這個:
INSERT INTO transactions (user_id, amount) VALUES (1, 35000);
-- 即使超過日限額,資料庫照樣寫入,因為它不知道你的規則
Enter fullscreen mode Exit fullscreen mode
// ✅ 你要自己檢查:
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");
Enter fullscreen mode Exit fullscreen mode

5. 總結

Consistency(一致性)的核心問題只有一個:交易結束後,你定義的規則還有沒有被遵守?

Consistency 不是資料庫單方面能保證的,它需要幾個層級來做保護:

  1. 資料庫 Constraints,例如 PK、FK、CHECK、UNIQUE 的基本防護
  2. Transaction + 隔離層級 ,確保同時發生的操作不互相干擾
  3. 鎖機制(樂觀鎖 / 悲觀鎖), 精確控制並發存取
  4. 業務規則檢查,只有開發者知道的業務規則,要自己寫程式檢查

沒有哪一招能搞定所有情況,應該要在不同的情境下,選用不同的工具組合。

Top comments (0)