DEV Community

Isolation Level — Read Committed

Read Committed: snapshot mỗi statement, và vì sao hai SELECT trong cùng transaction có thể trả khác nhau

READ COMMITTED là isolation level mặc định của PostgreSQL, và là level mà phần lớn workload OLTP đang chạy mà không biết. Khác với mô hình "transaction lấy một snapshot rồi giữ nguyên" mà nhiều dev tưởng tượng từ MVCC, ở Read Committed mỗi statement lấy một snapshot mới tại thời điểm statement bắt đầu, không phải tại thời điểm BEGIN. Hậu quả thực tế: hai SELECT liên tiếp trong cùng một transaction có thể trả về dữ liệu khác nhau nếu giữa hai lần đó có transaction khác commit. Đây là non-repeatable read — đúng spec của Read Committed, không phải bug — và là nguồn của một class lỗi rất hay gặp: code đọc một giá trị, ra quyết định, rồi cập nhật dựa trên giá trị đã đọc, trong khi giá trị thực tế đã thay đổi.

Cơ chế hoạt động

Một transaction ở Read Committed không có transaction-level snapshot. Khi mỗi statement (mỗi SELECT, UPDATE, DELETE, INSERT ... SELECT...) bắt đầu thực thi, backend lấy một snapshot mới gồm xmin, xmax và xip list — chính cái snapshot quyết định row version nào "visible" theo MVCC. Statement chỉ thấy:

  • row có xmin đã commit trước thời điểm statement bắt đầu,
  • xmax chưa tồn tại hoặc thuộc một transaction chưa commit / đã abort.

Ngay sau khi statement kết thúc, snapshot đó bị bỏ. Statement kế tiếp lấy snapshot mới — nếu trong khoảng giữa có transaction khác commit, statement này sẽ thấy dữ liệu mới đó.

-- T1
BEGIN;                                    -- KHÔNG lấy snapshot ở đây
SELECT balance FROM accounts WHERE id=1;  -- snapshot S1 -> trả 1000
-- ... T2 chạy: UPDATE accounts SET balance=500 WHERE id=1; COMMIT;
SELECT balance FROM accounts WHERE id=1;  -- snapshot S2 -> trả 500
COMMIT;
Enter fullscreen mode Exit fullscreen mode

Với UPDATE/DELETE/SELECT ... FOR UPDATE/FOR NO KEY UPDATE/FOR SHARE, Read Committed làm thêm một bước đặc biệt mà SELECT thường không làm: nếu target row bị một transaction khác đang lock (chưa commit), statement đợi transaction đó kết thúc. Khi unblock:

  • nếu transaction kia ROLLBACK thì tiếp tục như bình thường với row gốc.
  • nếu COMMIT mà row đã bị xoá thì bỏ qua row đó.
  • nếu COMMIT và row đã bị update thì Postgres re-read row version mới nhất, chạy lại điều kiện WHERE lên row mới đó; nếu vẫn match thì áp UPDATE/DELETE/FOR UPDATE trên row mới; nếu không match thì bỏ qua.

Đây là điểm hay bị bỏ sót: Read Committed không có "lost update protection" như REPEATABLE READ (vốn raise serialization_failure). Nó âm thầm chuyển sang row version mới và chạy lại WHEREUPDATE vẫn thành công nhưng có thể ghi đè quyết định dựa trên dữ liệu cũ. Documentation chính thức của Postgres gọi pattern này là "lost update problem" và khuyến nghị dùng SELECT ... FOR UPDATE hoặc nâng isolation lên REPEATABLE READ/SERIALIZABLE khi nghiệp vụ thật sự cần.

Vấn đề gặp trong production

Failure mode 1: read-then-write dựa trên giá trị đã stale → lost update. Đây là pattern kinh điển. Application đọc số dư, kiểm tra, rồi trừ — giữa lúc đó một transaction khác cũng trừ. Cả hai thành công, tổng trừ sai.

# Sai: read-modify-write ở READ COMMITTED không có lock; race với concurrent withdraw
def withdraw(conn, account_id, amount):
    with conn:
        with conn.cursor() as cur:
            cur.execute("SELECT balance FROM accounts WHERE id=%s", (account_id,))
            balance = cur.fetchone()[0]
            if balance < amount:
                raise InsufficientFunds()
            cur.execute(
                "UPDATE accounts SET balance = %s WHERE id=%s",
                (balance - amount, account_id),
            )
Enter fullscreen mode Exit fullscreen mode

Hai client gọi withdraw(1, 700) trên account balance 1000 đồng thời: cả hai đọc 1000, cả hai thấy đủ, cả hai ghi 300. Mất 400. Bug này không xuất hiện trong test đơn luồng, chỉ lộ khi có concurrent traffic — và READ COMMITTED không sinh serialization_failure để cảnh báo, nên application không có gì để retry.

# Đúng (a): khóa row ngay khi đọc -> transaction khác phải đợi
def withdraw(conn, account_id, amount):
    with conn:
        with conn.cursor() as cur:
            cur.execute(
                "SELECT balance FROM accounts WHERE id=%s FOR UPDATE",
                (account_id,),
            )
            balance = cur.fetchone()[0]
            if balance < amount:
                raise InsufficientFunds()
            cur.execute(
                "UPDATE accounts SET balance = balance - %s WHERE id=%s",
                (amount, account_id),
            )

# Đúng (b): đẩy điều kiện vào chính UPDATE, để DB tự đảm bảo atomicity
def withdraw(conn, account_id, amount):
    with conn:
        with conn.cursor() as cur:
            cur.execute(
                """UPDATE accounts
                   SET balance = balance - %s
                   WHERE id=%s AND balance >= %s
                   RETURNING balance""",
                (amount, account_id, amount),
            )
            if cur.rowcount == 0:
                raise InsufficientFunds()
Enter fullscreen mode Exit fullscreen mode

Bản (b) gọn hơn vì statement UPDATE ở Read Committed đã re-evaluate WHERE trên row version mới sau khi đợi lock — đúng cái bảo đảm spec.

Failure mode 2: report giả định "transaction = snapshot ổn định". Code chạy nhiều SELECT trong cùng transaction để tổng hợp số liệu, rồi insert vào bảng tổng kết — giả định mọi SELECT nhìn cùng một thế giới.

-- Sai ở READ COMMITTED: hai SELECT có thể thấy thế giới khác nhau
BEGIN;
INSERT INTO report_totals(metric, value)
  SELECT 'orders_count', count(*) FROM orders WHERE created_at::date = current_date;
INSERT INTO report_totals(metric, value)
  SELECT 'orders_sum', sum(total) FROM orders WHERE created_at::date = current_date;
COMMIT;
-- count và sum có thể được tính trên hai bộ orders khác nhau nếu có order mới commit giữa chừng
Enter fullscreen mode Exit fullscreen mode

Cách sửa: nâng isolation cho transaction này lên REPEATABLE READ để cả hai statement chia chung snapshot.

BEGIN ISOLATION LEVEL REPEATABLE READ;
-- snapshot lấy ở statement đầu tiên, mọi SELECT sau dùng đúng snapshot đó
INSERT INTO report_totals(metric, value)
  SELECT 'orders_count', count(*) FROM orders WHERE created_at::date = current_date;
INSERT INTO report_totals(metric, value)
  SELECT 'orders_sum', sum(total) FROM orders WHERE created_at::date = current_date;
COMMIT;
Enter fullscreen mode Exit fullscreen mode

Failure mode 3: pagination "khớp toàn cục" trong nhiều request. Một endpoint trả page-by-page bằng LIMIT/OFFSET, mỗi request là một transaction Read Committed riêng. Giữa các request, dữ liệu thay đổi → có item bị thấy hai lần, có item bị skip. Đây không hẳn lỗi Read Committed mà là hệ quả: không có snapshot ổn định nào kéo dài giữa hai transaction. Cách xử lý là keyset pagination (WHERE id > :last_id ORDER BY id) thay vì OFFSET, hoặc snapshot export rồi paginate trên snapshot đó.

Failure mode 4: counter race khi UPDATE ... SET col = col + 1. Đây là trường hợp UPDATE ở Read Committed tự xử lý đúng, dễ hiểu lầm ngược lại. Hai transaction cùng chạy UPDATE counters SET n = n + 1 WHERE id=1: transaction đến sau đợi lock, khi unblock nó re-read row version mới (đã +1), evaluate lại expression n + 1 trên giá trị mới, ghi đúng +2 tổng. Hành vi này chỉ đúng vì biểu thức n + 1 được DB tính trên row version mới. Nếu code làm read-then-write ở application thì mất tăng (failure mode 1). Quy tắc: counter increment phải viết dưới dạng SET n = n + ?, không phải đọc rồi ghi.

Failure mode 5: long SELECT ở Read Committed vẫn giữ horizon trên statement đó. Read Committed không giữ snapshot xuyên transaction, nhưng trong khi một statement đang chạy, snapshot của nó vẫn là active snapshot và giữ xmin horizon của backend. Một SELECT analytics chạy 3 giờ trên primary block autovacuum y như transaction REPEATABLE READ dài — vì pg_stat_activity.backend_xmin của backend vẫn được set. Tách analytics ra replica hoặc chia thành các statement ngắn.

Cách debug và monitor

Triệu chứng nghi vấn Read Committed gây sai dữ liệu thường là silent: số liệu sai lệch dần, không có error, không có log. Phải đi tìm bằng cách:

  • Đo concurrent transactions trên cùng row "nóng". pg_stat_activity lọc theo wait_event_type = 'Lock'wait_event = 'transactionid' / 'tuple' cho thấy backend đang đợi row lock — điểm hot của lost-update race.
  • Dùng pg_blocking_pids(pid) để dựng blocker -> blocked graph trong giai đoạn nghi vấn:
  SELECT blocked.pid AS blocked_pid,
         blocking.pid AS blocking_pid,
         blocked.wait_event, blocked.wait_event_type,
         left(blocked.query, 80)  AS blocked_q,
         left(blocking.query, 80) AS blocking_q
  FROM pg_stat_activity blocked
  JOIN pg_stat_activity blocking
    ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
  WHERE blocked.wait_event_type = 'Lock';
Enter fullscreen mode Exit fullscreen mode
  • pg_locks (join pg_stat_activity qua pid) cho ra mode lock cụ thể: RowExclusiveLock, ShareLock, ExclusiveLock trên transactionid là chữ ký tx đang đợi tx khác kết thúc — đây chính là cơ chế "wait, re-evaluate" của Read Committed.
  • pg_stat_statements lọc theo query chứa pattern read-then-write (hai statement gần nhau trên cùng bảng) là kênh tìm hot path có nguy cơ lost update. Lưu ý: không có metric trực tiếp nói "lost update đã xảy ra" — Read Committed không raise gì cả. Phải audit code path.
  • Bật log_lock_waits = on (kèm deadlock_timeout mặc định 1s) để Postgres log mọi lần một backend đợi lock quá ngưỡng — chỉ dấu pattern row-lock contention cao trên một hot row.
  • Tracking xact_commit / xact_rollback trong pg_stat_database để thấy spike rollback do serialization_failure (chỉ xảy ra nếu code đã nâng lên REPEATABLE READ / SERIALIZABLE).

Rule phòng ngừa:

  • Mọi đường đi business-critical mà có read-modify-write trên cùng key đều bắt buộc SELECT ... FOR UPDATE (hoặc FOR NO KEY UPDATE nếu không đụng đến cột là foreign key target, để tránh khóa quá mạnh) hoặc viết lại dưới dạng UPDATE ... WHERE precondition.
  • Mọi report / aggregation cần consistency giữa nhiều SELECT dùng BEGIN ISOLATION LEVEL REPEATABLE READ.
  • Code review checklist: tìm pattern SELECT ... WHERE id=? rồi sau đó UPDATE ... WHERE id=? cùng một id mà không có FOR UPDATE -> flag.
  • Set lock_timeout ở session-level cho mọi job dài để khỏi treo vô tận trên row contention.

Tradeoff

READ COMMITTED cho concurrency cao nhất trong các isolation level của Postgres: writer không block reader (MVCC), và writer chỉ block writer trên đúng row tranh chấp; không có serialization_failure để retry; latency dự đoán được. Cái giá là non-repeatable read + phantom read + lost update possibility vẫn mở — application phải tự bảo vệ những đoạn business logic cần đọc-rồi-ghi đúng. REPEATABLE READ đóng non-repeatable + phantom (qua transaction-level snapshot) nhưng đẩy ra một class lỗi mới: serialization_failure khi hai transaction cùng update một row (đòi retry loop ở client). SERIALIZABLE (SSI) cho đảm bảo mạnh nhất — như chạy tuần tự — nhưng tỉ lệ abort tăng khi conflict, đòi retry nhiều hơn, không phù hợp khi hot row có write contention rất cao. Quy tắc thực tế: giữ READ COMMITTED mặc định cho OLTP; mọi read-modify-write trên cùng key dùng SELECT ... FOR UPDATE hoặc đẩy logic vào một UPDATE ... WHERE precondition; nâng lên REPEATABLE READ cho job report/aggregation; chỉ chạm SERIALIZABLE khi business cần invariant chéo nhiều bảng (vd accounting double-entry) và app đã có retry loop.

Câu hỏi phỏng vấn

Read Committed trong PostgreSQL hoạt động thế nào, và tại sao hai SELECT trong cùng một transaction lại có thể trả về kết quả khác nhau?

Ở Read Committed, transaction không có snapshot riêng. Mỗi statement bắt đầu sẽ tự lấy một snapshot mới tại thời điểm statement chạy: snapshot chứa xmin/xmax/xip list, và quyết định row version nào visible theo MVCC. Statement kết thúc thì snapshot bị bỏ; statement kế tiếp lấy snapshot mới, nên nếu giữa hai SELECT có transaction khác COMMIT, statement thứ hai sẽ thấy dữ liệu mới đó — đây là non-repeatable read và là đúng spec, không phải bug. Với UPDATE/DELETE/SELECT FOR UPDATE, Read Committed thêm cơ chế đặc biệt: nếu target row đang bị lock bởi tx khác thì đợi, khi tx kia commit, Postgres re-read version mới của row, chạy lại WHERE lên version mới đó, rồi áp update nếu vẫn match — không raise serialization_failure. Hậu quả production là lost update âm thầm: code làm SELECT balance -> kiểm tra -> UPDATE balance = balance - amount trong cùng tx Read Committed có thể bị ghi đè bởi tx song song mà không có lỗi nào, vì cả hai đều thấy "đủ tiền" tại snapshot riêng của mỗi SELECT. Cách xử lý là dùng SELECT ... FOR UPDATE ngay từ lúc đọc để row bị khóa cho tới khi commit, hoặc đẩy precondition vào trong UPDATE (SET balance = balance - ? WHERE id=? AND balance >= ?) để DB tự đảm bảo atomic, hoặc nâng tx lên REPEATABLE READ cho các đoạn cần snapshot ổn định (report, aggregation chéo nhiều statement). Điểm ăn điểm là nêu được rõ "snapshot per-statement, không phải per-transaction", chỉ ra cơ chế re-read + re-evaluate WHERE của writer, và gọi tên failure mode lost update kèm cách phòng — không chỉ nói "có non-repeatable read".

Hands-on

Mục tiêu: tận tay tái hiện non-repeatable read, thấy UPDATE re-evaluate WHERE trên row version mới, ép lost update xảy ra ở Read Committed và xác nhận FOR UPDATE chặn được nó.

Dựng Postgres:

docker run -d --name pg-rc \
  -e POSTGRES_PASSWORD=pw -p 5435:5432 \
  postgres:16 \
  -c log_lock_waits=on \
  -c deadlock_timeout=500ms

sleep 5
docker exec -i pg-rc psql -U postgres <<'SQL'
CREATE TABLE accounts (
  id      bigserial PRIMARY KEY,
  owner   text NOT NULL,
  balance numeric NOT NULL
);
INSERT INTO accounts(owner, balance) VALUES ('alice', 1000), ('bob', 0);

CREATE TABLE counters (
  id bigserial PRIMARY KEY,
  n  bigint NOT NULL DEFAULT 0
);
INSERT INTO counters DEFAULT VALUES;
SQL
Enter fullscreen mode Exit fullscreen mode

Bước 1 — Non-repeatable read. Mở session A:

docker exec -it pg-rc psql -U postgres
Enter fullscreen mode Exit fullscreen mode
-- Session A
SHOW default_transaction_isolation;   -- 'read committed'
BEGIN;
SELECT balance FROM accounts WHERE id=1;   -- 1000
Enter fullscreen mode Exit fullscreen mode

Mở session B trong terminal khác:

docker exec -it pg-rc psql -U postgres
Enter fullscreen mode Exit fullscreen mode
-- Session B
UPDATE accounts SET balance = 500 WHERE id=1;
-- autocommit; thay đổi đã commit
Enter fullscreen mode Exit fullscreen mode

Quay lại session A:

-- Session A
SELECT balance FROM accounts WHERE id=1;   -- 500 (snapshot mới ở statement này)
COMMIT;
Enter fullscreen mode Exit fullscreen mode

Hai SELECT trong cùng transaction trả khác nhau — đây là non-repeatable read.

Bước 2 — UPDATE re-evaluate WHERE sau khi đợi lock. Session A:

-- Session A
BEGIN;
UPDATE accounts SET balance = balance + 100 WHERE id=1 AND owner='alice';
-- chưa commit
Enter fullscreen mode Exit fullscreen mode

Session B (sẽ block tại statement này):

-- Session B
UPDATE accounts SET balance = balance + 200 WHERE id=1 AND owner='alice';
-- treo đợi lock của A
Enter fullscreen mode Exit fullscreen mode

Session A commit:

-- Session A
COMMIT;
Enter fullscreen mode Exit fullscreen mode

Session B tự unblock và hoàn tất. Cuối cùng:

SELECT balance FROM accounts WHERE id=1;  -- 1300 (1000 + 100 + 200): cả hai đều áp được
Enter fullscreen mode Exit fullscreen mode

Ngay cả khi B re-read row version mới (owner='alice' vẫn match), WHERE vẫn pass nên UPDATE của B vẫn áp lên row mới. Đây là cơ chế đúng của Read Committed.

Bước 3 — Counter increment race là an toàn nếu viết đúng. Bật pgbench để bắn concurrent:

docker exec -i pg-rc psql -U postgres -c "UPDATE counters SET n=0 WHERE id=1;"

cat > /tmp/inc.sql <<'SQL'
UPDATE counters SET n = n + 1 WHERE id=1;
SQL

docker cp /tmp/inc.sql pg-rc:/tmp/inc.sql
docker exec -i pg-rc pgbench -U postgres -n -c 20 -j 4 -t 500 -f /tmp/inc.sql postgres

docker exec -i pg-rc psql -U postgres -c "SELECT n FROM counters WHERE id=1;"
-- n = 10000 (20 client * 500 tx) — không mất tăng nào
Enter fullscreen mode Exit fullscreen mode

Bước 4 — Lost update race khi viết sai (read-modify-write trong application). Mô phỏng bằng hai psql shell.

Session A:

-- Session A
BEGIN;
SELECT balance FROM accounts WHERE id=1;  -- giả sử 1300
-- application "tính toán": balance - 700 = 600
Enter fullscreen mode Exit fullscreen mode

Session B (chen vào trước khi A ghi):

-- Session B
BEGIN;
SELECT balance FROM accounts WHERE id=1;  -- 1300
-- application "tính toán": balance - 500 = 800
UPDATE accounts SET balance = 800 WHERE id=1;
COMMIT;
Enter fullscreen mode Exit fullscreen mode

Session A ghi sau, dùng giá trị đã tính từ snapshot cũ:

-- Session A
UPDATE accounts SET balance = 600 WHERE id=1;
COMMIT;

SELECT balance FROM accounts WHERE id=1;   -- 600
Enter fullscreen mode Exit fullscreen mode

Mất 500 đồng: tổng rút phải là 1200 (700 + 500) trên gốc 1300, kết quả đúng phải là 100. Nhưng do A ghi đè bằng giá trị tính trên snapshot stale, B mất hẳn.

Bước 5 — SELECT FOR UPDATE chặn lost update. Reset:

UPDATE accounts SET balance=1300 WHERE id=1;
Enter fullscreen mode Exit fullscreen mode

Session A:

-- Session A
BEGIN;
SELECT balance FROM accounts WHERE id=1 FOR UPDATE;  -- giữ row lock
-- "tính toán": 1300 - 700 = 600
Enter fullscreen mode Exit fullscreen mode

Session B:

-- Session B
BEGIN;
SELECT balance FROM accounts WHERE id=1 FOR UPDATE;  -- TREO đợi A
Enter fullscreen mode Exit fullscreen mode

Session A:

-- Session A
UPDATE accounts SET balance = 600 WHERE id=1;
COMMIT;
Enter fullscreen mode Exit fullscreen mode

Session B tự unblock, đọc lại đúng 600:

-- Session B (sau khi unblock, SELECT trả 600)
-- "tính toán": 600 - 500 = 100
UPDATE accounts SET balance = 100 WHERE id=1;
COMMIT;

SELECT balance FROM accounts WHERE id=1;  -- 100, đúng
Enter fullscreen mode Exit fullscreen mode

Bước 6 — Quan sát lock chain trong khi B đang đợi (chạy ở session thứ ba):

SELECT blocked.pid AS blocked_pid,
       blocking.pid AS blocking_pid,
       blocked.wait_event_type, blocked.wait_event,
       left(blocked.query, 60) AS blocked_q
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
  ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.wait_event_type = 'Lock';
Enter fullscreen mode Exit fullscreen mode

Dọn dẹp:

docker rm -f pg-rc
Enter fullscreen mode Exit fullscreen mode

Top comments (0)