關於 PostgreSQL 的 4 個 Isolation Levels

關於 PostgreSQL 的 4 個 Isolation Levels

在資料庫的世界中,資料的一致性(Consistency)和完整性(Integrity)是相當受到關注的議題,而為了確保這些特性,資料庫系統在設計上提供了不同的隔離級別(Isolation Levels),以控制 Transaction 之間的相互影響。

本文將簡述 PostgreSQL 提供的 4 種 Isolation Levels,並了解各自的特性與使用情境。

什麼是 Isolation Level?

Isolation Level 是資料庫系統用來控制 Transaction 間相互影響程度的機制。它定義了在多個交易同時執行時,如何隔離每個 Transaction 操作避免資料不一致或衝突。

為什麼需要 Isolation Level?

在多個 Session 與多個 Transaction 的環境下,交易之間可能會相互影響,導致各種問題,如資料損毀或不一致。Isolation Level 可以協助以避免以下的問題:

  1. Dirty Read - 讀取到尚未 Commit 修改的資料。
  2. Nonrepeatable Read - 同一 Transaction 中多次讀取結果不一致。
  3. Phantom Read - 同一 Transaction 中多次查詢的結果集不一致。
  4. Serialization Anomaly - 違反 Transaction 隔離性,導致不可預測的結果。

Isolation Level 的介紹

在 PostgreSQL 中定義了四種 Isolation Levels,分別是

  1. Read Uncommitted
  2. Read Committed
  3. Repeatable Read
  4. Serializable

Read uncommitted

Read Uncommitted 是隔離級別最低的模式,允許 Transaction 讀取其他未 Commit 的修改。這導致資料不一致的風險,因為一個 Transaction 可能讀取到另一個 Transaction 尚未 Commit 甚至 Rollback 後的數據。

比較有趣的是 Postgres 讓 Read uncommitted 這個 isolation level 的 behavior 是跟 Read committed 一樣的,防呆避免髒讀,節錄一段內文,詳細可以點這看文件

In PostgreSQL, you can request any of the four standard transaction isolation levels, but internally only three distinct isolation levels are implemented, i.e., PostgreSQL's Read Uncommitted mode behaves like Read Committed.

Read committed

Read Committed 是 PostgreSQL 的預設 Isolation Level。它保證每次讀取的數據都是已 Commit 的,避免了 Dirty Read 的情況。但它無法避免 Nonrepeatable Read 和 Phantom Read 問題。

Repeatable read

Repeatable Read 保證同一 Transaction 中多次讀取相同的數據時,結果是一致的。它透過在交易開始時進行 Snapshot,避免 Nonrepeatable Read。這樣的隔離性較高,但仍無法避免 Phantom Read。

Serializable

Serializable 是最高層級的 Isolation Level,模擬序列化的效果,保證 Transaction 之間完全隔離。此模式會防止所有類型的讀取異常,包括 Phantom Read 和 Serialization Anomaly,但代價是可能會導致較高的系統負荷及 Deadlock 機率。

根據文件中提到

When relying on Serializable transactions to prevent anomalies, it is important that any data read from a permanent user table not be considered valid until the transaction which read it has successfully committed.

當把 Isolation Level 設為 Serializable 時,直到 COMMIT 前,不應仰賴在 Transacion 中讀取到的數據。

實作範例

先用 docker 跑一個 postgres db

docker run --name postgres-demo -e POSTGRES_PASSWORD=password -d -p 5432:5432 postgres:16
docker exec -it postgres-demo /bin/bash

然後進到 postgres 的 command line tool.

su - postgres
psql

接著建立範例資料

CREATE TABLE bank_accounts (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100),
    balance INTEGER
);

INSERT INTO bank_accounts (name, balance) VALUES ('Alice', 1000), ('Bob', 1000);

先來看一下目前預設的 isolation level

SHOW TRANSACTION ISOLATION LEVEL;

顯示預設為 read committed

 transaction_isolation
-----------------------
 read committed
(1 row)

那我們來開啟另外一個 terminal 準備測試吧!

Read committed & Read uncommitted

上面提到的 Read uncommitted 在 Postgres 中會自動幫忙避免,可以用下面的範例來試試 Terminal A 輸入

BEGIN;
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT balance FROM bank_accounts WHERE name = 'Alice';

Terminal B 輸入

BEGIN;
UPDATE bank_accounts SET balance = balance - 100 WHERE name = 'Alice';
SELECT balance FROM bank_accounts WHERE name = 'Alice';

Terminal A 輸入

SELECT balance FROM bank_accounts WHERE name = 'Alice';

理論上如果是 Read uncommitted,預期看到的餘額會是 900,即便 Terminal B 那邊尚未 COMMIT,但結果仍然是顯示 1,000

Terminal B 輸入

COMMIT;

Terminal A 輸入

SELECT balance FROM bank_accounts WHERE name = 'Alice';
COMMIT;

這時候就會看到餘額為 900 ,可以確認在 Posgtres 中,Read committedRead uncommitted 的 behavior 會是一樣的。

Repeatable Read

先來選取 Bob 的餘額

Terminal A 輸入

BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM bank_accounts WHERE name = 'Bob';
-- Shows: 1000

接著在另一邊去變更餘額,讓他變成 1,200 Terminal B 輸入

BEGIN;
UPDATE bank_accounts SET balance = balance + 200 WHERE name = 'Bob';
COMMIT;

此時看到的結果仍然為 1,000,在這個 isolation level 下,保證了同一 Transaction 中多次讀取相同的數據時,結果是一致的。 Terminal A 輸入

SELECT balance FROM bank_accounts WHERE name = 'Bob';
COMMIT;

Serializable

先來計算所有餘額加總

Terminal A 輸入

BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT SUM(balance) FROM bank_accounts;
-- Shows: 2100

Terminal B 輸入下方指令,變更其中一個餘額,進而影響總數

BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
UPDATE bank_accounts SET balance = balance + 100 WHERE name = 'Alice';
COMMIT;

因為 Terminal A 剛剛還沒完成指令,他想要將所有餘額都乘上 1.1,此時系統會報錯然後 ROLLBACK

UPDATE bank_accounts SET balance = balance * 1.1;
COMMIT;
-- ERROR:  could not serialize access due to concurrent update
-- ROLLBACK

如何調整 Isolation Level?

在 PostgreSQL 中,可以在 Transaction 開始時透過語法調整 Isolation Level。

BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;


-- Do sth

COMMIT;

也可以在 Database 的層級進行修改

ALTER DATABASE <database-name> SET default_transaction_isolation TO 'repeatable read';
ALTER DATABASE <database-name> SET default_transaction_isolation TO 'serializable';
ALTER DATABASE <database-name> SET default_transaction_isolation TO 'read committed';
ALTER DATABASE <database-name> SET default_transaction_isolation TO 'read uncommitted';

結語

大概是這樣,留著當筆記,未來說不定有機會救救有緣人(aka 我自己)

參考資料

https://www.postgresql.org/docs/current/transaction-iso.html

Tags:
# database
# postgres
# backend

楊育晟 (Peter Yang)

嗨, 我是育晟, 部落格文章主題包含了程式設計、財務金融及投資...等等,內容多是記錄一些學習的過程和心得。

Email : ycy.tai@gmail.com
Medium: Yu Chen Yang