Hai request cùng đọc một số dư tài khoản, cùng kết luận khoản rút tiền là hợp lệ rồi cùng commit. Tài khoản vi phạm chính quy tắc mà từng request đã kiểm tra đúng khi chạy riêng lẻ. Mọi câu SQL đều thành công; lỗi nằm trong lịch sử được tạo khi chúng đan xen.

Transaction kiểm soát những lịch sử như vậy. ACID mô tả contract của một transaction, còn isolation level quy định lịch sử đồng thời nào database được phép bộc lộ. Chọn mức cô lập là quyết định về tính đúng đắn, phụ thuộc invariant, predicate được đọc và cách ứng dụng xử lý conflict.

ACID là bốn bảo đảm riêng, không phải một phép màu

Atomicity nghĩa là các thay đổi database cùng commit như một đơn vị hoặc cùng không commit. Nó không rollback HTTP call, email hay message đã publish; những effect ấy cần outbox, idempotency hoặc workflow protocol.

Consistency nghĩa là transaction đã commit đưa database từ trạng thái hợp lệ này sang trạng thái hợp lệ khác, nếu code và constraint diễn tả đúng quy tắc. Database cưỡng chế được NOT NULL, foreign key, unique và check constraint, nhưng không tự suy ra “phải còn ít nhất một bác sĩ trực”. Consistency trong ACID cũng khác consistency của replication phân tán.

Isolation giới hạn điều transaction đồng thời quan sát và kết quả nào được commit. Serializability yêu cầu kết quả tương đương với một thứ tự chạy tuần tự nào đó, dù các câu lệnh thực tế chồng lấn. Mức yếu hơn chấp nhận thêm lịch sử để giảm blocking hoặc abort.

Durability nghĩa là sau xác nhận commit, kết quả sống sót qua failure thuộc cấu hình durability. Write-ahead log, replication và synchronous flush quyết định phạm vi chính xác. Nó không có nghĩa mọi replica đã apply write và không thay thế backup.

Note

ACID không hứa transaction code luôn đúng. Nó cung cấp một mô hình thực thi có kiểm soát cho code ấy. Hãy đưa invariant cấu trúc vào database constraint khi có thể, rồi chọn isolation và cách xử lý conflict cho các quy tắc trải trên nhiều row hoặc predicate.

Đơn vị quan trọng để suy luận là một history: chuỗi đan xen các thao tác read, write, commit và abort từ nhiều transaction. Isolation level khác nhau ở những history mà chúng từ chối, chặn hoặc cho phép.

Nhận diện anomaly bằng lịch thực thi cụ thể

Giả sử có bảng accounts(id, balance). Dirty read xảy ra khi T2 quan sát write của T1 trước lúc T1 commit. Nếu T1 rollback sau đó, T2 đã hành động dựa trên một giá trị chưa từng tồn tại trong lịch sử đã commit.

Bước Transaction T1 Transaction T2
1 BEGIN;
2 UPDATE accounts SET balance = 50 WHERE id = 1;
3 SELECT balance FROM accounts WHERE id = 1; -- thấy 50
4 ROLLBACK;

Read committed và các mức mạnh hơn ngăn lịch này. Non-repeatable read tinh tế hơn: T1 đọc cùng một row đã commit hai lần, trong khi T2 commit thay đổi ở giữa. T1 thấy 100, rồi thấy 80, dù vẫn ở trong một transaction. Phantom là phiên bản áp dụng cho predicate: T1 đếm được bốn hóa đơn đang mở, T2 chèn thêm một hóa đơn thỏa điều kiện rồi commit, sau đó cùng query của T1 trả về năm. Những row đã đọc trước đó không đổi; tập kết quả đã đổi.

sql
-- T1                                      -- T2
BEGIN;                                     BEGIN;
SELECT balance FROM accounts WHERE id=1;   -- trả về 100
                                           UPDATE accounts
                                           SET balance=80 WHERE id=1;
                                           COMMIT;
SELECT balance FROM accounts WHERE id=1;   -- có thể trả về 80
COMMIT;

Lost update thường xuất hiện trong logic read-modify-write đặt ở ứng dụng. Hai transaction cùng đọc tồn kho 10, cùng tính ra 9, rồi cùng ghi 9; hai lượt bán chỉ làm dữ liệu giảm một đơn vị.

sequenceDiagram participant T1 as Transaction T1 participant DB as Database participant T2 as Transaction T2 T1->>DB: SELECT stock (10) T2->>DB: SELECT stock (10) T1->>DB: UPDATE stock = 9 T1->>DB: COMMIT T2->>DB: UPDATE stock = 9 T2->>DB: COMMIT Note over T1,T2: Yêu cầu giảm hai lần, dữ liệu chỉ giảm một lần

Một statement nguyên tử như UPDATE products SET stock = stock - 1 WHERE id = ? AND stock > 0 tránh phép tính stale đơn giản này vì database đánh giá điều kiện và khóa row như một phần của write. Optimistic version check và SELECT ... FOR UPDATE là hai lựa chọn khác. Chỉ dùng repeatable read không phải phương thuốc phổ quát; hành vi khi concurrent write khác nhau giữa các engine.

Write skew tác động lên những row khác nhau, vì thế khó bắt bằng write conflict trên row. Giả sử Alice và Bob đều đang trực, còn quy tắc yêu cầu ít nhất một bác sĩ phải trực. Mỗi transaction đọc cả hai row, thấy hai người, rồi tắt trạng thái của chính mình. Dưới snapshot isolation, cả hai có thể commit vì chúng ghi hai row khác nhau, khiến không còn ai trực.

sql
-- T1, Alice                               -- T2, Bob
BEGIN;                                     BEGIN;
SELECT count(*) FROM shifts                SELECT count(*) FROM shifts
 WHERE day='2025-10-01' AND on_call;        WHERE day='2025-10-01' AND on_call;
-- 2                                       -- 2
UPDATE shifts SET on_call=false            UPDATE shifts SET on_call=false
 WHERE day='2025-10-01' AND doctor='Alice'; WHERE day='2025-10-01' AND doctor='Bob';
COMMIT;                                    COMMIT;

History này không serializable: trong bất kỳ thứ tự tuần tự nào, transaction chạy thứ hai cũng chỉ thấy một bác sĩ và phải từ chối update. Muốn ngăn write skew, ta cần thực thi serializable, explicit lock bao phủ invariant hoặc data model giúp database constraint tuần tự hóa quyết định.

Isolation level là contract phụ thuộc từng engine

SQL standard đặt tên các hiện tượng, nhưng sản phẩm hiện thực level bằng những tổ hợp lock và snapshot khác nhau. Đặc biệt, REPEATABLE READ của PostgreSQL là snapshot isolation: kết quả predicate thông thường giữ ổn định nhưng write skew vẫn có thể xảy ra. Một số engine dùng next-key lock ở level có cùng tên. Luôn đọc tài liệu đúng engine và version.

Mức Dirty read Non-repeatable read Phantom Lost update Write skew Hệ quả vận hành
Read committed Bị ngăn Có thể Có thể Có thể với stale read-modify-write Có thể Mỗi statement thấy dữ liệu đã commit; snapshot có thể đổi trong transaction
Repeatable read Bị ngăn Bị ngăn theo chuẩn tối thiểu Chuẩn cho phép; engine dùng snapshot thường ngăn phantom thông thường Tùy engine và mẫu write Có thể dưới snapshot isolation Row hoặc snapshot ổn định, nhưng history chưa chắc serializable
Serializable Bị ngăn Bị ngăn Bị ngăn như một serial anomaly Bị ngăn như một serial anomaly Bị ngăn Có thể block, deadlock hoặc abort một transaction để ứng dụng retry

“Bị ngăn” không có nghĩa database luôn chờ rồi thành công. Nó có thể từ chối một bên bằng serialization failure. Chính việc từ chối ấy bảo toàn contract. Ngược lại, snapshot ổn định không chứng minh serializability: cả hai bác sĩ trong ví dụ write skew đều quan sát snapshot hoàn toàn ổn định.

Chỉ chọn level yếu nhất sau khi đã gọi tên invariant và chứng minh mọi history được level đó cho phép vẫn giữ invariant. Read committed thường phù hợp cho CRUD độc lập và atomic conditional update. Repeatable read hữu ích khi transaction cần một góc nhìn phân tích ổn định, miễn là anomaly riêng của snapshot được chấp nhận. Serializable là default dễ lý giải nhất cho thao tác ngắn, giá trị cao, có quy tắc trải trên nhiều row hoặc predicate.

Warning

Đừng ánh xạ tên isolation sang guarantee dựa trên trí nhớ. Hãy xác minh engine dùng snapshot theo statement hay transaction, có gap lock hoặc predicate lock không, và chế độ “serializable” của nó có thật sự serializable hay không.

Lock, MVCC và SSI cưỡng chế history theo cách khác nhau

Lock-based control dùng shared lock cho read và exclusive lock cho write. Strict two-phase locking giữ lock đến commit, buộc conflict chờ nhau để tạo thứ tự serializable. Row lock bảo vệ row hiện có; predicate, range hoặc next-key lock bảo vệ cả sự vắng mặt của row, như “không có reservation chồng lấn”. Đổi lại là blocking, deadlock và giảm concurrency quanh hot range.

Multi-version concurrency control (MVCC) lưu nhiều version của row. Reader chọn version từ snapshot nên thường không chặn writer; writer vẫn phối hợp khi ghi cùng row. MVCC là kỹ thuật hiện thực, không phải isolation level: nó có thể phục vụ read committed, snapshot isolation hoặc serializable.

Snapshot isolation ngăn dirty read và cấp cho transaction một snapshot ổn định. Nó cũng từ chối một số write-write conflict. Tuy nhiên, bản thân nó không phát hiện cycle đứng sau write skew vì Alice và Bob update hai row version khác nhau.

Serializable snapshot isolation (SSI) theo dõi read-write dependency. Nếu T1 đọc dữ liệu mà T2 thay đổi sau đó, hệ thống ghi một anti-dependency. Cấu trúc dependency nguy hiểm có thể tạo cycle trong serialization graph, nên database abort một participant. SSI giữ phần lớn concurrency của MVCC, đổi lại cần tracking và serialization failure.

sequenceDiagram participant A as Transaction của Alice participant DB as MVCC và SSI participant B as Transaction của Bob A->>DB: Đọc row trực của Alice và Bob B->>DB: Đọc row trực của Alice và Bob A->>DB: Ghi Alice nghỉ trực B->>DB: Ghi Bob nghỉ trực Note over A,B: Snapshot isolation thấy hai write tách biệt A->>DB: Yêu cầu COMMIT DB-->>A: COMMIT B->>DB: Yêu cầu COMMIT DB-->>B: Serialization failure, cần retry

Explicit locking vẫn hữu ích khi dùng MVCC. SELECT ... FOR UPDATE phù hợp khi ta biết những row chi phối quyết định và muốn tuần tự hóa chúng. Nó không đủ nếu sự thật cần bảo vệ là một predicate mà hiện chưa có row nào thỏa, trừ khi engine cung cấp range lock hoặc predicate lock phù hợp. Advisory lock có thể tuần tự hóa một logical key, nhưng chỉ an toàn khi mọi writer cùng tuân theo convention.

Retry transaction và kiểm thử invariant

Serializable transaction có thể abort vì serialization conflict hoặc deadlock. Hãy retry toàn bộ transaction từ snapshot mới, giới hạn số lần, thêm jittered backoff và theo dõi contention. Không retry mọi error, cũng không đặt external effect non-idempotent trong callback có thể chạy lại.

ts
interface TransactionClient {}

interface Database {
  transaction<Result>(options: {
    isolationLevel: 'serializable';
  }, operation: (client: TransactionClient) => Promise<Result>): Promise<Result>;
}

interface DatabaseError extends Error {
  code?: string;
}

const retryableCodes = new Set([
  '40001', // SQLSTATE serialization_failure
  '40P01', // PostgreSQL deadlock_detected
]);

const delay = (milliseconds: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, milliseconds));

export async function withSerializableRetry<Result>(
  database: Database,
  operation: (client: TransactionClient) => Promise<Result>,
  maxAttempts = 5,
): Promise<Result> {
  for (let attempt = 1; ; attempt += 1) {
    try {
      return await database.transaction({ isolationLevel: 'serializable' }, operation);
    } catch (error) {
      const code = (error as DatabaseError).code;
      if (!code || !retryableCodes.has(code) || attempt >= maxAttempts) throw error;

      const ceiling = Math.min(25 * 2 ** (attempt - 1), 400);
      await delay(Math.floor(Math.random() * ceiling));
    }
  }
}

Hãy điều chỉnh retry code theo driver và engine; chẳng hạn MySQL dùng identifier khác cho deadlock và timeout. Timeout chỉ retry được khi biết rõ trạng thái commit của operation hoặc operation có tính idempotent. Nếu transaction code phải publish event, hãy ghi outbox record trong cùng transaction rồi giao event sau.

Test phải chủ động tạo overlap. Dùng barrier để hai transaction cùng đọc trước khi write, giải phóng chúng cùng lúc, chờ commit hoặc retry, rồi query trạng thái cuối từ transaction mới. Assert business invariant thay vì chỉ kiểm tra error. Chạy nhiều lần trên engine thật vì bản in-memory hiếm khi mô phỏng đúng lock và MVCC.

Với quy tắc trực, assert count(on_call) >= 1 sau mỗi lượt đồng thời và ghi nhận retry, latency dưới load. Nếu hot key liên tục hết lượt retry, hãy dùng conditional atomic update, một row sở hữu invariant hoặc queue.

Tip

Hãy bắt đầu invariant test từ kết quả bị cấm: tồn kho âm, active lease trùng nhau, hai leader hoặc không còn bác sĩ trực. Sau đó dựng history đồng thời nhỏ nhất có thể tạo ra nó.

Ranh giới transaction dừng ở ranh giới tài nguyên

Database transaction bảo vệ công việc do database đó phối hợp. Nó không tự động bao gồm database của service khác, cache, email provider hay message broker chỉ vì lời gọi nằm giữa BEGINCOMMIT. Giữ transaction mở qua network call còn kéo dài thời gian giữ lock nhưng vẫn không thể rollback remote effect.

Dùng transactional outbox để gắn thay đổi trạng thái cục bộ với ý định publish bền vững. Consumer phải idempotent vì delivery thường là at-least-once. Với business process chạy qua nhiều service, dùng saga hoặc một giao thức phân tán tường minh khác, đồng thời định nghĩa compensation, trạng thái trung gian và reconciliation. Two-phase commit chỉ là lựa chọn khi mọi resource manager cùng yêu cầu vận hành chấp nhận đánh đổi về availability và coupling.

Isolation cũng không định nghĩa độ mới của replica. Một serializable write trên primary rồi một read từ replica đang lag có thể trông như dữ liệu đi lùi, trừ khi routing layer bảo đảm read-your-writes. Hãy phát biểu guarantee tại system boundary, không chỉ tại SQL connection.

Những điểm cần nhớ

  • Các thuộc tính ACID tách biệt: isolation kiểm soát concurrent history, còn consistency vẫn phụ thuộc invariant được mã hóa và transaction logic đúng.
  • Dirty read, non-repeatable read, phantom, lost update và write skew là những lịch thực thi. Hãy tái tạo lịch trước khi chọn cách sửa.
  • Read committed, repeatable read và serializable là contract của sản phẩm, không phải công thức hiện thực portable. Cần xác minh semantics chính xác của engine.
  • Lock tuần tự hóa conflict trực tiếp; MVCC cung cấp snapshot; SSI biến snapshot execution thành serializable bằng cách phát hiện dependency structure nguy hiểm rồi abort công việc.
  • Serialization failure và deadlock đòi hỏi retry toàn transaction có giới hạn, đọc lại từ đầu, thêm jitter và xử lý effect theo cách idempotent.
  • Hãy kiểm thử invariant cuối dưới concurrency được đồng bộ có chủ ý, đồng thời đo contention chứ không chỉ tính đúng đắn.
  • Database isolation kết thúc ở transaction boundary. Qua thành phần phân tán, cần outbox, idempotency, saga và consistency guarantee tường minh.