Chương 7 — Transactions
Phần II — Distributed Data

Chương 7 — Transactions

18 phút đọc DDIA · Martin Kleppmann

🎯 Mục tiêu chương: Hiểu transaction thực sự đảm bảo gì (ACID), các isolation level yếu (read committed, snapshot isolation) chặn được và KHÔNG chặn được race condition nào (dirty read/write, read skew, lost update, write skew, phantom), và ba cách hiện thực serializability (serial execution, 2PL, SSI) cùng trade-off của chúng.

Mở đầu: vì sao cần transaction?

Trong thực tế rất nhiều thứ có thể hỏng: DB/phần cứng chết giữa lúc ghi, application crash giữa chuỗi thao tác, mạng đứt, nhiều client ghi đè lên nhau, client đọc phải dữ liệu mới cập nhật một nửa, race condition gây bug khó hiểu.

Transaction gom nhiều lệnh đọc/ghi thành một đơn vị logic: hoặc toàn bộ thành công (commit), hoặc thất bại toàn bộ (abort / rollback) và có thể retry an toàn. Ý nghĩa cốt lõi: transaction không phải quy luật tự nhiên, nó được tạo ra để đơn giản hóa programming model — DB lo một số lỗi và vấn đề concurrency (gọi là safety guarantees) để application không phải nghĩ tới partial failure.

Không phải app nào cũng cần transaction; đôi khi nới lỏng/bỏ transaction để đổi lấy performance hoặc availability. Để quyết định, ta phải hiểu chính xác transaction cho ta cái gì và giá phải trả.

The Slippery Concept of a Transaction

Hầu hết relational DB hiện nay (MySQL, PostgreSQL, Oracle, SQL Server) có transaction theo phong cách của IBM System R (1975). Làn sóng NoSQL cuối 2000s thường bỏ transaction hoặc định nghĩa lại với đảm bảo yếu hơn nhiều. Hai quan điểm cực đoan — "transaction là kẻ thù của scalability" và "app nghiêm túc bắt buộc phải có transaction" — đều là cường điệu. Transaction là một lựa chọn thiết kế có trade-off.

The Meaning of ACID

ACID (Härder & Reuter, 1983) = Atomicity, Consistency, Isolation, Durability. Thực tế ACID của DB này ≠ ACID của DB kia, đặc biệt phần Isolation rất mơ hồ → "ACID compliant" giờ gần như là thuật ngữ marketing. BASE (Basically Available, Soft state, Eventual consistency) còn mơ hồ hơn — về cơ bản nghĩa là "không phải ACID".

Atomicity

  • Trong multi-threading, "atomic" nghĩa là thread khác không thấy trạng thái nửa vời. Nhưng trong ACID, atomicity không nói về concurrency (cái đó là Isolation).
  • ACID atomicity = nếu một chuỗi ghi gặp lỗi giữa chừng (crash, mất mạng, đầy disk, vi phạm constraint), transaction bị abort và mọi ghi đã làm phải bị hủy/undo.
  • Lợi ích: nếu không có atomicity, khi lỗi giữa chừng bạn không biết thay đổi nào đã có hiệu lực → retry có thể gây trùng lặp. Với atomicity, abort = chưa thay đổi gì → retry an toàn.
  • Tác giả gợi ý tên đúng hơn là abortability.

Consistency

Từ "consistency" bị dùng cho ít nhất 4 nghĩa: replica consistency/eventual consistency (Ch5), consistent hashing (Ch6), consistency trong CAP = linearizability (Ch9), và consistency trong ACID.

  • ACID consistency = dữ liệu luôn thỏa các invariant do ứng dụng định nghĩa (VD: trong kế toán, tổng credit và debit phải cân bằng).
  • Đây là thuộc tính của application, không phải của DB. DB chỉ kiểm tra được một số invariant cụ thể (foreign key, unique constraint). Nếu bạn ghi dữ liệu sai, DB không cản được.
  • Application dựa vào A, I, D của DB để đạt C → "chữ C thực ra không thuộc về ACID" (theo Joe Hellerstein, nó được thêm vào cho đủ từ viết tắt).

Isolation

  • Nhiều client truy cập cùng record đồng thời → race condition. Ví dụ kinh điển: hai client cùng tăng counter từ 42, mỗi bên đọc 42, cộng 1, ghi 43 → kết quả 43 thay vì 44.
  • Theo sách giáo khoa, isolation = serializability: mỗi transaction có thể giả vờ nó là transaction duy nhất; kết quả cuối giống như chạy tuần tự từng cái một.
  • Thực tế serializable ít được dùng vì tốn performance. Oracle 11g có level tên "serializable" nhưng thực chất là snapshot isolation (yếu hơn).

Durability

  • Cam kết: khi transaction đã commit thành công, dữ liệu không bị mất dù hardware lỗi hay DB crash.
  • Single-node: ghi xuống nonvolatile storage (HDD/SSD) + write-ahead log. Replicated: dữ liệu đã được copy sang đủ số node. DB phải chờ các bước này xong mới báo commit.
  • Không có durability tuyệt đối: nếu mọi disk và backup cùng bị hủy thì chịu.

Replication and Durability

Không kỹ thuật nào hoàn hảo:

  • Ghi disk xong mà máy chết → dữ liệu không mất nhưng không truy cập được cho tới khi sửa máy. Replication giúp vẫn available.
  • Correlated fault (mất điện, bug làm crash mọi node với cùng input) có thể giết mọi replica cùng lúc → dữ liệu chỉ ở memory sẽ mất. Vì vậy in-memory DB vẫn cần ghi disk.
  • Async replication: leader chết có thể làm mất các ghi gần đây.
  • SSD mất điện đột ngột có thể vi phạm đảm bảo, ngay cả fsync cũng không chắc chắn; firmware disk cũng có bug.
  • Tương tác storage engine – filesystem có thể gây corrupt file sau crash.
  • Dữ liệu có thể âm thầm hỏng dần (bit rot) và lan sang replica/backup gần đây → cần backup lịch sử.
  • Số liệu: 30–80% SSD có ít nhất một bad block trong 4 năm đầu; HDD ít bad sector hơn nhưng tỷ lệ hỏng hoàn toàn cao hơn. SSD để không cấp điện có thể bắt đầu mất dữ liệu sau vài tuần.

→ Chỉ có các kỹ thuật giảm rủi ro (ghi disk + replicate + backup), nên dùng kết hợp.

Single-Object and Multi-Object Operations

Atomicity (all-or-nothing) và Isolation (transaction khác thấy tất cả hoặc không thấy gì) thường được hiểu cho multi-object transaction.

Ví dụ email app: để hiển thị số email chưa đọc, thay vì SELECT COUNT(*) ... WHERE unread_flag = true (chậm), ta denormalize thành một trường counter riêng. Giờ mỗi email mới đến phải vừa INSERT email vừa tăng counter:

  • Vi phạm isolation: user thấy email chưa đọc trong hộp thư nhưng counter vẫn = 0 (đọc được ghi chưa commit — dirty read).
  • Vi phạm atomicity: INSERT email thành công nhưng tăng counter lỗi → hai thứ lệch nhau mãi mãi. Với atomicity, email bị rollback.

Relational DB xác định các lệnh cùng transaction dựa vào TCP connection: mọi thứ giữa BEGIN TRANSACTION và COMMIT trên một connection. (Nhược điểm: nếu connection đứt sau khi client gửi commit nhưng trước khi nhận ack, client không biết đã commit hay chưa.) Nhiều NoSQL không có cách gom nhóm như vậy; API multi-put không nhất thiết có ngữ nghĩa transaction — có thể thành công với key này, thất bại với key kia.

Single-object writes

Ngay cả ghi một object (VD: document JSON 20 KB) cũng cần atomicity & isolation: mạng đứt sau 10 KB? mất điện giữa lúc ghi đè → trộn giá trị cũ/mới? client khác đọc thấy giá trị nửa vời? Storage engine gần như luôn đảm bảo atomicity (qua log crash recovery) và isolation (qua lock per object) ở mức single object trên một node.

Một số DB có thao tác atomic phức tạp hơn: increment, compare-and-set (CAS). Chúng hữu ích để tránh lost update nhưng không phải transaction theo nghĩa thông thường; gọi chúng là "lightweight transactions" hay "ACID" là marketing gây hiểu lầm.

The need for multi-object transactions

Nhiều datastore phân tán bỏ multi-object transaction vì khó làm qua nhiều partition, nhưng không có gì ngăn cản về mặt nguyên lý. Các trường hợp cần phối hợp ghi nhiều object:

  • Relational: foreign key giữa các bảng phải đúng khi insert nhiều record tham chiếu nhau (graph: edge giữa các vertex).
  • Document DB: thiếu join → khuyến khích denormalization → cập nhật nhiều document cùng lúc.
  • Secondary index: index là object riêng; không có isolation thì record có thể xuất hiện ở index này mà chưa có ở index kia.

Handling errors and aborts

  • Triết lý ACID: thà abort toàn bộ còn hơn để transaction dở dang. Ngược lại, leaderless replication chạy kiểu "best effort" — không undo cái đã làm, application tự lo.
  • ORM phổ biến (Rails ActiveRecord, Django) không tự retry transaction bị abort → exception nổi lên, input của user bị vứt đi. Đáng tiếc, vì mục đích của abort là cho phép retry.
  • Retry không hoàn hảo:
    • Transaction thực ra đã commit nhưng ack bị mất → retry làm 2 lần (cần dedup ở tầng app).
    • Lỗi do overload → retry làm tệ hơn; dùng giới hạn số lần + exponential backoff.
    • Chỉ nên retry lỗi tạm thời (deadlock, isolation violation, mạng chập chờn, failover), không retry lỗi vĩnh viễn (vi phạm constraint).
    • Side effect ngoài DB (gửi email) vẫn xảy ra dù abort → cần 2PC nếu muốn nhiều hệ thống cùng commit/abort.
    • Client chết trong lúc retry → dữ liệu định ghi bị mất.

Weak Isolation Levels

Hai transaction không đụng cùng dữ liệu thì chạy song song an toàn. Race condition chỉ xảy ra khi một transaction đọc dữ liệu mà transaction khác đang sửa, hoặc hai transaction cùng sửa một dữ liệu. Bug concurrency rất khó test (phụ thuộc timing, hiếm, khó tái hiện) và khó suy luận.

Serializable isolation giải quyết triệt để nhưng tốn performance, nên nhiều DB mặc định dùng level yếu hơn. Hệ quả thật: mất tiền, bị kiểm toán điều tra, hỏng dữ liệu khách hàng. Câu "dùng ACID DB cho dữ liệu tài chính" không đủ — nhiều relational DB "ACID" vẫn chạy weak isolation mặc định.

Read Committed

Level cơ bản nhất, hai đảm bảo:

  1. No dirty reads: chỉ đọc được dữ liệu đã commit.
  2. No dirty writes: chỉ ghi đè lên dữ liệu đã commit.

(Level còn yếu hơn là read uncommitted: chặn dirty write nhưng không chặn dirty read.)

No dirty reads

Dirty read = thấy dữ liệu của transaction chưa commit. Chặn dirty read quan trọng vì:

  • Tránh thấy trạng thái cập nhật một phần (VD email mới có nhưng counter chưa tăng) → người dùng bối rối, transaction khác ra quyết định sai.
  • Nếu transaction kia abort, bạn đã đọc dữ liệu chưa từng tồn tại thật sự.

No dirty writes

Dirty write = ghi đè lên giá trị chưa commit của transaction khác. Thường chặn bằng cách bắt ghi thứ hai chờ transaction đầu commit/abort.

Ví dụ bán xe cũ: Alice và Bob cùng mua một chiếc xe; mua cần 2 ghi: cập nhật bảng listings (người mua) và invoices (gửi hóa đơn). Với dirty write, có thể listing ghi Bob thắng nhưng invoice lại gửi cho Alice.

Lưu ý: read committed không chặn được race tăng counter (lost update) — ghi thứ hai xảy ra sau khi transaction đầu đã commit nên không phải dirty write.

Implementing read committed

  • Là default của Oracle 11g, PostgreSQL, SQL Server 2012, MemSQL...
  • Dirty write: row-level lock — muốn sửa row phải giữ lock tới khi commit/abort.
  • Dirty read: có thể dùng read lock ngắn, nhưng một write transaction dài sẽ chặn hàng loạt read → hại latency và operability. Nên hầu hết DB nhớ cả giá trị committed cũ và giá trị mới của transaction đang giữ write lock; reader khác được trả về giá trị cũ cho đến khi commit.

Snapshot Isolation and Repeatable Read

Read committed vẫn cho phép read skew (nonrepeatable read). Ví dụ: Alice có $1.000 chia hai tài khoản mỗi bên $500. Một transaction chuyển $100 từ TK2 sang TK1. Alice xem số dư đúng lúc đó: thấy TK1 = $500 (trước khi nhận) và TK2 = $400 (sau khi trừ) → tổng $900, như thể $100 bốc hơi. Mỗi giá trị đều đã commit lúc đọc nên read committed chấp nhận.

Với Alice chỉ cần reload là hết. Nhưng một số tình huống không chịu được:

  • Backup: copy toàn DB mất hàng giờ trong khi ghi vẫn diễn ra → backup chứa phần cũ lẫn phần mới; restore thì sự không nhất quán trở thành vĩnh viễn.
  • Analytic query & integrity check: quét phần lớn DB, nếu thấy các phần ở các thời điểm khác nhau thì kết quả vô nghĩa.

Snapshot isolation: mỗi transaction đọc từ một consistent snapshot — thấy mọi dữ liệu đã commit tại thời điểm bắt đầu transaction; thay đổi sau đó bị bỏ qua. Rất hợp cho query read-only chạy lâu. Hỗ trợ bởi PostgreSQL, MySQL/InnoDB, Oracle, SQL Server.

Implementing snapshot isolation

  • Vẫn dùng write lock để chặn dirty write, nhưng read không cần lock. Nguyên tắc: readers never block writers, writers never block readers.
  • Dùng MVCC (multi-version concurrency control): giữ nhiều phiên bản committed của một object vì các transaction đang chạy cần thấy DB ở các thời điểm khác nhau.
  • Read committed chỉ cần 2 version; thường engine dùng MVCC cho cả hai: read committed lấy snapshot mới mỗi query, snapshot isolation dùng một snapshot cho cả transaction.
  • Cách PostgreSQL làm: mỗi transaction có txid tăng dần. Mỗi row có created_by và deleted_by. Delete chỉ đánh dấu deleted_by; garbage collection (vacuum) xóa sau khi không còn transaction nào cần. Update = delete + create. VD transaction 13 trừ $100 từ TK2 → bảng có 2 row cho TK2: $500 (deleted_by = 13) và $400 (created_by = 13).

Visibility rules for observing a consistent snapshot

Khi transaction bắt đầu:

  1. Lập danh sách các transaction đang chạy; mọi ghi của chúng bị bỏ qua (kể cả khi sau đó commit).
  2. Ghi của transaction đã abort bị bỏ qua.
  3. Ghi của transaction có txid lớn hơn (bắt đầu sau) bị bỏ qua.
  4. Còn lại đều thấy được.

Tóm gọn: object visible nếu (a) transaction tạo ra nó đã commit trước khi reader bắt đầu, và (b) nó chưa bị đánh dấu xóa, hoặc transaction xóa chưa commit tại thời điểm reader bắt đầu.

Indexes and snapshot isolation

  • Cách 1: index trỏ tới mọi version, query lọc version không visible; GC xóa luôn entry index cũ. PostgreSQL có tối ưu tránh update index nếu các version nằm cùng page.
  • Cách 2 (CouchDB, Datomic, LMDB): append-only / copy-on-write B-tree — không ghi đè page mà copy page bị sửa và các page cha tới root. Mỗi write transaction tạo root mới; mỗi root là một snapshot nhất quán, không cần lọc theo txid. Cần compaction/GC nền.

Repeatable read and naming confusion

Oracle gọi snapshot isolation là serializable; PostgreSQL và MySQL gọi là repeatable read. Lý do: SQL standard dựa trên định nghĩa System R 1975, lúc đó chưa có snapshot isolation; nó định nghĩa repeatable read trông giống bề ngoài. Standard lại mơ hồ, các DB cài khác nhau; IBM DB2 thậm chí dùng "repeatable read" cho serializability. Kết luận của tác giả: không ai thật sự biết repeatable read nghĩa là gì → đừng tin tên level, hãy đọc kỹ tài liệu DB.

Preventing Lost Updates

Read committed và snapshot isolation chủ yếu nói về việc read-only transaction thấy gì. Vấn đề nổi tiếng khi hai transaction cùng ghi là lost update: xảy ra với chu trình read-modify-write — ghi sau "clobber" ghi trước vì không bao gồm thay đổi của nó. Gặp trong:

  • Tăng counter, cập nhật số dư.
  • Sửa cục bộ một giá trị phức tạp (thêm phần tử vào list trong JSON).
  • Hai người cùng sửa trang wiki, mỗi người gửi nguyên nội dung trang.
-- Lost update: hai transaction chạy song song
-- T1: SELECT value FROM counters WHERE key = 'foo';  -- đọc 42
-- T2: SELECT value FROM counters WHERE key = 'foo';  -- đọc 42
-- T1: UPDATE counters SET value = 43 WHERE key = 'foo';
-- T2: UPDATE counters SET value = 43 WHERE key = 'foo';  -- mất 1 lần tăng

Atomic write operations

Giải pháp tốt nhất khi dùng được:

UPDATE counters SET value = value + 1 WHERE key = 'foo';

MongoDB có atomic op sửa một phần JSON, Redis có atomic op trên cấu trúc dữ liệu (priority queue...). Không phải mọi thứ biểu diễn được (sửa text wiki tùy ý). Thường cài bằng exclusive lock khi đọc (cursor stability) hoặc chạy mọi atomic op trên một thread. Cạm bẫy: ORM dễ khiến bạn vô tình viết read-modify-write thay vì dùng atomic op của DB.

Explicit locking

Khi logic phức tạp không biểu diễn được bằng atomic op — ví dụ game nhiều người chơi, nhiều người có thể di chuyển cùng một quân cờ, và cần kiểm tra nước đi hợp lệ theo luật game — application tự lock:

BEGIN TRANSACTION;
SELECT * FROM figures
 WHERE name = 'robot' AND game_id = 222
 FOR UPDATE;               -- lock mọi row trả về
-- Kiểm tra nước đi hợp lệ trong code, rồi:
UPDATE figures SET position = 'c4' WHERE id = 1234;
COMMIT;

Hoạt động được nhưng dễ quên thêm lock ở đâu đó → race condition.

Automatically detecting lost updates

Cho các read-modify-write chạy song song; nếu transaction manager phát hiện lost update thì abort và bắt retry. Hiệu quả khi kết hợp với snapshot isolation. PostgreSQL repeatable read, Oracle serializable, SQL Server snapshot isolation tự phát hiện; MySQL/InnoDB repeatable read thì không (nên theo một số định nghĩa, MySQL không thật sự cung cấp snapshot isolation). Ưu điểm lớn: không phụ thuộc việc dev nhớ dùng lock.

Compare-and-set

Trong DB không có transaction, CAS chỉ cho ghi nếu giá trị chưa đổi kể từ lúc đọc:

-- Có thể an toàn hoặc không, tùy cách DB cài đặt
UPDATE wiki_pages SET content = 'new content'
 WHERE id = 1234 AND content = 'old content';
-- Kiểm tra số row bị ảnh hưởng; nếu 0 thì retry

Cạm bẫy: nếu DB cho mệnh đề WHERE đọc từ snapshot cũ, điều kiện có thể vẫn đúng dù có ghi đồng thời → không chặn được lost update. Phải kiểm tra DB của bạn. (Biến thể thực tế hay dùng: cột version — optimistic locking.)

Conflict resolution and replication

  • Lock và CAS giả định có một bản copy mới nhất duy nhất. Multi-leader/leaderless replication cho phép ghi đồng thời trên nhiều node và replicate async → không áp dụng được.
  • Cách phổ biến: cho phép tạo nhiều version xung đột (siblings) rồi merge bằng application code hoặc cấu trúc dữ liệu đặc biệt.
  • Atomic op commutative (tăng counter, thêm vào set) hoạt động tốt khi replicate — ý tưởng của Riak 2.0 datatypes.
  • Last write wins (LWW) dễ gây lost update, nhưng lại là default của nhiều replicated DB.

Write Skew and Phantoms

Ví dụ bác sĩ trực (on-call)

Bệnh viện yêu cầu mỗi ca ít nhất một bác sĩ trực. Alice và Bob là hai bác sĩ trực ca 1234, cả hai cùng ốm và bấm "xin nghỉ" gần như cùng lúc. Mỗi transaction:

BEGIN TRANSACTION;
SELECT COUNT(*) FROM doctors
 WHERE on_call = true AND shift_id = 1234;   -- cả hai đều thấy 2
-- nếu >= 2 thì cho nghỉ
UPDATE doctors SET on_call = false
 WHERE name = 'Alice' AND shift_id = 1234;   -- Bob thì update row của Bob
COMMIT;

Dưới snapshot isolation cả hai đều thấy 2, cả hai cùng commit → không còn ai trực. Invariant bị phá.

Characterizing write skew

  • Không phải dirty write, không phải lost update, vì hai transaction ghi hai object khác nhau (row của Alice, row của Bob). Nhưng rõ ràng là race condition: chạy tuần tự thì bác sĩ thứ hai sẽ bị từ chối.
  • Write skew là tổng quát hóa của lost update: hai transaction đọc cùng tập object, rồi mỗi bên cập nhật một số object (có thể khác nhau). Nếu cùng cập nhật một object → thành dirty write/lost update.
  • Lựa chọn phòng chống hạn chế hơn:
    • Atomic single-object op: vô dụng (nhiều object).
    • Tự phát hiện lost update của snapshot isolation: không phát hiện write skew (PostgreSQL repeatable read, MySQL repeatable read, Oracle serializable, SQL Server snapshot đều không). Cần serializable thật.
    • Constraint nhiều object ("ít nhất một bác sĩ trực"): hầu hết DB không hỗ trợ; có thể thử trigger/materialized view.
    • Nếu không có serializable: lock tường minh các row mà transaction phụ thuộc:
BEGIN TRANSACTION;
SELECT * FROM doctors
 WHERE on_call = true AND shift_id = 1234
 FOR UPDATE;      -- lock tất cả bác sĩ đang trực ca này
UPDATE doctors SET on_call = false
 WHERE name = 'Alice' AND shift_id = 1234;
COMMIT;

More examples of write skew

  • Đặt phòng họp: kiểm tra không có booking trùng giờ cho phòng 123, nếu không có thì INSERT. Hai người cùng đặt 12h–13h → cả hai thấy 0 booking → double booking. Snapshot isolation không cứu được.
BEGIN TRANSACTION;
SELECT COUNT(*) FROM bookings
 WHERE room_id = 123
   AND end_time > '2015-01-01 12:00' AND start_time < '2015-01-01 13:00';
-- nếu = 0:
INSERT INTO bookings (room_id, start_time, end_time, user_id)
VALUES (123, '2015-01-01 12:00', '2015-01-01 13:00', 666);
COMMIT;
  • Game nhiều người chơi: lock FOR UPDATE chặn hai người cùng di chuyển một quân, nhưng không chặn hai quân khác nhau đi vào cùng một ô. Có thể dùng unique constraint tùy luật, nếu không thì dính write skew.
  • Đăng ký username: hai người cùng kiểm tra "tên chưa có" rồi tạo account. Giải pháp đơn giản: unique constraint — transaction thứ hai bị abort.
  • Chống double-spending: chèn khoản chi tạm, liệt kê các khoản, kiểm tra tổng dương. Hai khoản chi chèn đồng thời có thể cùng làm số dư âm mà không bên nào thấy bên kia.

Phantoms causing write skew

Pattern chung:

  1. SELECT kiểm tra một điều kiện bằng cách tìm các row khớp search condition.
  2. Code quyết định tiếp tục hay không dựa trên kết quả.
  3. Nếu tiếp tục, ghi (INSERT/UPDATE/DELETE) và commit — chính lệnh ghi này làm thay đổi tiền đề của bước 2.

Ví dụ bác sĩ: row bị sửa nằm trong kết quả bước 1 nên SELECT FOR UPDATE cứu được. Các ví dụ còn lại khác: chúng kiểm tra sự vắng mặt của row, rồi ghi thêm row khớp điều kiện. Không có row nào để gắn lock → FOR UPDATE vô dụng.

Phantom = một ghi trong transaction này làm thay đổi kết quả của search query trong transaction khác. Snapshot isolation tránh phantom cho read-only query, nhưng trong read-write transaction, phantom gây ra write skew rất khó.

Materializing conflicts

Nếu vấn đề là không có object để lock, hãy tạo object lock nhân tạo: VD bảng các slot (phòng × khung 15 phút) được tạo sẵn cho 6 tháng tới. Transaction đặt phòng SELECT FOR UPDATE các slot tương ứng, rồi mới kiểm tra và insert booking. Bảng này không chứa dữ liệu booking, chỉ là tập lock. Gọi là materializing conflicts — biến phantom thành lock conflict trên row cụ thể. Nhược điểm: khó, dễ sai, để cơ chế concurrency rò rỉ vào data model → chỉ là phương án cuối cùng; serializable tốt hơn.

Serializability

Tình hình đáng buồn: isolation level khó hiểu và cài đặt không nhất quán; nhìn code khó biết an toàn ở level nào; không có tool phát hiện race condition, test thì non-deterministic. Câu trả lời của giới nghiên cứu từ 1970s: dùng serializable isolation — mạnh nhất, đảm bảo kết quả như chạy tuần tự, tức DB chặn mọi race condition.

Ba kỹ thuật hiện thực:

  1. Chạy tuần tự thật sự (actual serial execution).
  2. Two-phase locking (2PL) — suốt ~30 năm là lựa chọn duy nhất.
  3. Optimistic concurrency control như Serializable Snapshot Isolation (SSI).

Actual Serial Execution

Bỏ hẳn concurrency: chạy từng transaction một trên một thread. Ý tưởng hiển nhiên nhưng chỉ khả thi từ khoảng 2007 nhờ:

  • RAM rẻ → giữ toàn bộ active dataset trong memory, transaction không chờ disk.
  • OLTP transaction thường ngắn, ít đọc/ghi; analytic query dài thì read-only, chạy trên snapshot ngoài vòng lặp serial.

Dùng trong VoltDB/H-Store, Redis, Datomic. Có thể nhanh hơn hệ concurrent vì không tốn overhead lock, nhưng throughput giới hạn bởi một CPU core.

Encapsulating transactions in stored procedures

  • Ban đầu người ta muốn một transaction bao cả luồng người dùng (đặt vé máy bay nhiều bước) — nhưng con người chậm; DB phải giữ vô số transaction idle. Nên OLTP giữ transaction ngắn, gói trong một HTTP request.
  • Dù vậy vẫn là kiểu interactive: app gửi query, chờ kết quả, gửi query tiếp... tốn nhiều thời gian network round-trip. Nếu chạy đơn luồng với kiểu này, DB sẽ chủ yếu ngồi chờ app → throughput thảm hại.
  • Vì vậy hệ serial không cho interactive multi-statement transaction: app phải gửi toàn bộ logic trước dưới dạng stored procedure. Với dữ liệu trong memory, chạy rất nhanh, không chờ I/O.

Pros and cons of stored procedures

Tiếng xấu: mỗi vendor một ngôn ngữ (PL/SQL, T-SQL, PL/pgSQL) cũ kỹ, thiếu thư viện; khó debug, version control, deploy, test, monitoring; DB dùng chung nhiều app server nên một procedure tệ gây hại lớn. Nhưng khắc phục được: VoltDB dùng Java/Groovy, Datomic dùng Java/Clojure, Redis dùng Lua.

VoltDB còn dùng stored procedure cho replication: chạy cùng procedure trên mỗi replica → procedure phải deterministic (thời gian hiện tại phải lấy qua API deterministic đặc biệt).

Partitioning

  • Để scale nhiều core/nhiều node: partition dữ liệu sao cho mỗi transaction chỉ chạm một partition; mỗi partition có thread riêng → throughput scale tuyến tính theo số core.
  • Transaction cross-partition cần phối hợp lock-step, chậm hơn nhiều: VoltDB báo cáo ~1.000 cross-partition write/giây, thấp hơn nhiều bậc so với single-partition và không tăng khi thêm máy.
  • Key-value đơn giản dễ partition; dữ liệu nhiều secondary index thì khó.

Summary of serial execution

Khả thi khi: transaction nhỏ và nhanh (một transaction chậm làm nghẽn tất cả); active dataset vừa memory (nếu cần dữ liệu trên disk có thể dùng anti-caching: abort, nạp dữ liệu async, restart); write throughput vừa một core hoặc partition được mà không cần cross-partition; cross-partition có nhưng bị giới hạn cứng.

Two-Phase Locking (2PL)

Mạnh hơn lock chống dirty write: nhiều transaction được đọc đồng thời miễn không ai ghi; nhưng hễ ai muốn ghi thì cần độc quyền:

  • A đã đọc object, B muốn ghi → B chờ A commit/abort.
  • A đã ghi object, B muốn đọc → B chờ A commit/abort (không được đọc version cũ như snapshot isolation).

Tức là writer chặn reader và reader chặn writer — trái ngược mantra của snapshot isolation. Đổi lại, 2PL là serializable nên chặn được lost update và write skew.

Implementation of two-phase locking

Dùng trong serializable của MySQL/InnoDB, SQL Server; repeatable read của DB2. Mỗi object có lock ở shared mode hoặc exclusive mode:

  • Đọc → cần shared lock (nhiều transaction giữ cùng lúc được, nhưng phải chờ nếu có exclusive).
  • Ghi → cần exclusive lock (không ai khác được giữ bất kỳ lock nào).
  • Đọc rồi ghi → nâng cấp shared lên exclusive.
  • Giữ lock tới cuối transaction. Tên "two-phase": pha 1 (đang chạy) lấy lock, pha 2 (kết thúc) nhả hết.

Nhiều lock → dễ deadlock; DB tự phát hiện và abort một transaction, app phải retry.

Performance of two-phase locking

  • Throughput và response time tệ hơn weak isolation đáng kể: overhead lấy/nhả lock, nhưng chủ yếu do giảm concurrency.
  • Không giới hạn thời gian chờ; hàng đợi hình thành trên object hot → latency không ổn định, p99 rất cao. Một transaction chậm hoặc lock nhiều dữ liệu có thể làm cả hệ thống đứng.
  • Deadlock xảy ra thường xuyên hơn nhiều so với read committed dùng lock → lãng phí công sức retry.

Predicate locks

Để chặn phantom, cần predicate lock: lock thuộc về mọi object khớp một điều kiện (kể cả object chưa tồn tại), VD:

SELECT * FROM bookings
 WHERE room_id = 123
   AND end_time > '2018-01-01 12:00' AND start_time < '2018-01-01 13:00';
  • A muốn đọc theo điều kiện → lấy shared predicate lock; nếu B đang giữ exclusive lock trên object khớp thì A chờ.
  • A muốn insert/update/delete → kiểm tra giá trị cũ hoặc mới có khớp predicate lock nào không; nếu có (do B giữ) thì chờ.

2PL + predicate lock = serializable thật sự.

Index-range locks

Predicate lock chậm (kiểm tra khớp lock tốn thời gian khi có nhiều lock). Hầu hết DB dùng index-range locking (next-key locking) — xấp xỉ predicate bằng một tập lớn hơn (an toàn vì ghi nào khớp predicate gốc cũng khớp bản xấp xỉ):

  • Có index trên room_id → gắn shared lock lên entry room_id = 123 (lock phòng 123 mọi giờ).
  • Có index thời gian → lock khoảng giá trị 12h–13h (mọi phòng).
  • Transaction khác muốn ghi booking liên quan phải sửa cùng phần index → gặp lock → chờ.

Kém chính xác hơn predicate lock nhưng overhead thấp — thỏa hiệp tốt. Không có index phù hợp → fallback shared lock cả bảng (an toàn nhưng chậm).

Serializable Snapshot Isolation (SSI)

Serializable thì hoặc chậm (2PL) hoặc không scale (serial); weak isolation thì nhanh nhưng dính race. SSI (Cahill, 2008) cho serializability đầy đủ với penalty nhỏ so với snapshot isolation. Dùng trong PostgreSQL serializable từ 9.1, và FoundationDB (thuật toán tương tự, phân tán).

Pessimistic versus optimistic concurrency control

  • Pessimistic (2PL): hễ có khả năng sai thì chờ cho an toàn — như mutex. Serial execution là pessimistic cực đoan (lock cả DB/partition), bù lại bằng transaction siêu nhanh.
  • Optimistic (SSI): cứ chạy tiếp, lúc commit mới kiểm tra có vi phạm isolation không; nếu có thì abort và retry.
  • Optimistic tệ khi contention cao (nhiều abort; retry thêm tải khi hệ gần full capacity). Tốt khi còn dư capacity và contention thấp. Commutative atomic op (tăng counter không đọc lại) giảm contention.
  • Khác biệt với OCC cũ: SSI xây trên snapshot isolation — mọi read đều từ consistent snapshot, cộng thêm thuật toán phát hiện serialization conflict.

Decisions based on an outdated premise

Write skew xảy ra vì transaction hành động dựa trên một premise (VD "đang có 2 bác sĩ trực") đúng lúc đọc nhưng có thể sai lúc commit. DB không biết app dùng kết quả query thế nào, nên phải giả định mọi thay đổi trong kết quả query có thể làm các ghi sau đó không hợp lệ. Hai trường hợp cần phát hiện:

Detecting stale MVCC reads

Khi đọc, transaction 43 bỏ qua ghi chưa commit của transaction 42 (Alice on_call đổi thành false) theo visibility rule. Đến lúc 43 commit, 42 đã commit → premise của 43 sai. DB theo dõi khi nào một transaction bỏ qua ghi của transaction khác do MVCC; lúc commit, nếu ghi bị bỏ qua đó đã commit thì abort.

Vì sao chờ tới commit mà không abort ngay? Vì nếu 43 là read-only thì không cần abort (không có write skew); và 42 có thể abort hoặc chưa commit. Tránh abort thừa giúp giữ ưu điểm đọc dài trên snapshot.

Detecting writes that affect prior reads

Khi transaction khác sửa dữ liệu sau khi đã được đọc. Dùng cơ chế giống index-range lock nhưng không chặn: DB ghi nhận trên index entry (VD shift_id = 1234) rằng transaction 42 và 43 đã đọc dữ liệu này (không có index thì ghi ở mức bảng). Khi một transaction ghi, nó tra index xem ai đã đọc dữ liệu bị ảnh hưởng và thông báo (như tripwire) rằng dữ liệu họ đọc có thể đã cũ. Trong ví dụ: 42 commit trước — thành công; khi 43 commit, ghi xung đột của 42 đã commit → 43 bị abort. Thông tin đọc chỉ cần giữ tới khi transaction và các transaction đồng thời kết thúc.

Performance of serializable snapshot isolation

  • Trade-off về granularity theo dõi: chi tiết → abort chính xác nhưng tốn bookkeeping; thô → nhanh nhưng abort thừa. PostgreSQL dùng lý thuyết để chứng minh một số trường hợp vẫn serializable, giảm abort thừa.
  • So với 2PL: không ai chờ lock của ai; reader và writer không chặn nhau → latency dễ dự đoán; read-only query chạy trên snapshot không cần lock — hợp workload đọc nhiều.
  • So với serial: không bị giới hạn một core; FoundationDB phân tán việc phát hiện conflict qua nhiều máy, transaction đọc/ghi nhiều partition vẫn serializable.
  • Tỷ lệ abort quyết định hiệu năng: read-write transaction dài dễ conflict → SSI yêu cầu read-write transaction ngắn (read-only dài thì ổn). Dù vậy ít nhạy với transaction chậm hơn 2PL và serial.

Bảng so sánh

Isolation level vs anomaly

AnomalyRead uncommittedRead committedSnapshot isolation (repeatable read)Serializable
Dirty writeChặnChặnChặnChặn
Dirty readCó thể xảy raChặnChặnChặn
Read skew (nonrepeatable read)Có thểCó thểChặnChặn
Lost updateCó thểCó thểTùy DB (PostgreSQL/Oracle/SQL Server tự phát hiện; MySQL InnoDB thì không)Chặn
Write skewCó thểCó thểCó thểChặn
Phantom (trong read-write txn)Có thểCó thểChặn với read-only; vẫn gây write skewChặn

Ba cách hiện thực serializability

Tiêu chíActual serial executionTwo-phase locking (2PL)SSI
KiểuPessimistic cực đoan (1 thread)Pessimistic (shared/exclusive lock)Optimistic (phát hiện lúc commit)
Reader/writer chặn nhau?Không có concurrencyCó, cả hai chiềuKhông
Chống phantomTự nhiênPredicate lock / index-range lockTheo dõi read qua index (tripwire)
Điểm mạnhĐơn giản, không overhead lockTrưởng thành, dùng rộng rãi hàng chục nămLatency ổn định, scale nhiều máy, tốt cho read-heavy
Điểm yếuGiới hạn 1 core; dữ liệu phải vừa RAM; cross-partition chậmThroughput thấp, p99 cao, deadlock nhiềuAbort nhiều khi contention cao; read-write txn phải ngắn
Yêu cầuStored procedure, transaction ngắnRetry khi deadlockRetry khi abort
Ví dụVoltDB/H-Store, Redis, DatomicMySQL InnoDB serializable, SQL Server, DB2 repeatable readPostgreSQL ≥ 9.1 serializable, FoundationDB

⚠️ Hiểu lầm & cạm bẫy thường gặp

  • "Atomicity trong ACID = không bị thread khác thấy nửa vời" → sai; đó là isolation. ACID atomicity = abortability khi lỗi.
  • "DB đảm bảo Consistency" → không; consistency là invariant của application, DB chỉ hỗ trợ một phần (constraint).
  • "DB relational = an toàn với dữ liệu tài chính" → nhiều DB mặc định read committed, vẫn có lost update, write skew.
  • Tin vào tên isolation level: "repeatable read" của MySQL ≠ PostgreSQL; "serializable" của Oracle thực chất là snapshot isolation.
  • Nghĩ snapshot isolation là đủ: nó không chặn write skew.
  • Dùng ORM đọc object → sửa field → save: đây là read-modify-write, dễ lost update; nên dùng value = value + 1 hoặc lock/version.
  • CAS với WHERE content = 'old' có thể không an toàn nếu WHERE đọc từ snapshot cũ.
  • SELECT ... FOR UPDATE không cứu được khi kiểm tra sự vắng mặt của row (phantom).
  • Retry mù quáng: có thể xử lý trùng (ack mất), làm overload tệ hơn, lặp side effect (gửi email 2 lần).
  • Nhầm 2PL với 2PC.
  • Lost update detection/lock/CAS không áp dụng cho multi-leader/leaderless replication; LWW (mặc định nhiều DB) âm thầm mất dữ liệu.
  • "Compare-and-set / lightweight transaction" không phải transaction multi-object thật sự.

💼 Áp dụng thực tế & phỏng vấn

  • Thiết kế hệ thống đặt chỗ (vé, phòng khách sạn, phòng họp, ghế máy bay): interviewer thường hỏi "làm sao tránh double booking?". Trả lời theo tầng: unique constraint (VD (room_id, slot)), SELECT FOR UPDATE trên slot đã materialize, serializable isolation, hoặc optimistic locking bằng cột version. Nêu rõ trade-off contention.
  • Ví/thanh toán, tồn kho (inventory): dùng atomic update có điều kiện UPDATE ... SET balance = balance - 100 WHERE id = ? AND balance >= 100 và kiểm tra số row ảnh hưởng — gọn và an toàn cho single-row. Với nhiều row (double-spending qua ledger), cần serializable hoặc lock.
  • Username/email duy nhất: unique index là câu trả lời chuẩn; ở hệ phân tán cần linearizable store hoặc consensus (Ch9).
  • Biết default của DB mình dùng: PostgreSQL = read committed; MySQL InnoDB = repeatable read (không tự phát hiện lost update). Khi bật serializable ở PostgreSQL phải chuẩn bị retry khi gặp serialization failure (SQLSTATE 40001).
  • Retry + idempotency: khi retry transaction, dùng idempotency key / request id để tránh xử lý 2 lần khi ack bị mất.
  • Analytics/backup trên OLTP: snapshot isolation/MVCC cho phép query dài không chặn ghi; nhưng transaction dài giữ version cũ → bloat (PostgreSQL vacuum).
  • Redis: đơn luồng + Lua script = serial execution; MULTI/EXEC không rollback khi lệnh lỗi.
  • Câu hỏi hay gặp: "phân biệt optimistic vs pessimistic locking, khi nào dùng cái nào?" → contention thấp chọn optimistic (version/SSI), contention cao/hot row chọn pessimistic (FOR UPDATE) hoặc atomic op / serial queue per key.
  • Khi đề xuất NoSQL trong thiết kế, nói rõ phần nào cần multi-object transaction và cách bù (single-document design, saga, outbox pattern).

❓ Câu hỏi ôn tập

Bấm vào câu hỏi để xem đáp án
1Atomicity trong ACID khác gì atomic trong lập trình đa luồng? Tại sao tác giả đề xuất gọi là "abortability"?
Atomic đa luồng nói về việc không ai thấy trạng thái nửa vời (thuộc isolation). ACID atomicity nói về việc khi lỗi giữa chừng thì toàn bộ ghi bị hủy, cho phép retry an toàn — bản chất là khả năng abort.
2Read committed chặn những anomaly nào và thường hiện thực ra sao?
Chặn dirty read và dirty write. Dirty write chặn bằng row-level write lock giữ tới commit; dirty read chặn bằng cách giữ giá trị committed cũ và trả giá trị cũ cho reader đến khi transaction ghi commit (không dùng read lock).
3Read skew là gì? Cho ví dụ và cách khắc phục.
Transaction thấy các phần DB ở các thời điểm khác nhau. VD Alice thấy tổng $900 thay vì $1.000 khi đang có chuyển khoản. Khắc phục bằng snapshot isolation (MVCC) — đọc từ một snapshot nhất quán cho cả transaction.
4Nêu các cách phòng lost update và giới hạn của từng cách.
Atomic write (value = value + 1) — không biểu diễn được mọi logic; explicit lock SELECT FOR UPDATE — dễ quên; tự phát hiện lost update ở snapshot isolation — không có ở MySQL InnoDB; compare-and-set — có thể không an toàn nếu WHERE đọc snapshot cũ; với replication multi-leader/leaderless thì cần siblings/merge hoặc commutative op, tránh LWW.
5Write skew là gì? Giải thích bằng ví dụ bác sĩ trực và vì sao snapshot isolation không chặn được.
Hai transaction đọc cùng tập dữ liệu, ra quyết định dựa trên premise, rồi ghi vào các object khác nhau làm premise sai. Alice và Bob cùng thấy 2 bác sĩ trực, mỗi người tự tắt on_call của mình → 0 người trực. Snapshot isolation chỉ phát hiện ghi cùng object nên không thấy xung đột.
6Phantom là gì và tại sao SELECT FOR UPDATE không giải quyết được trong ví dụ đặt phòng họp? Giải pháp?
Phantom là khi ghi của transaction này thay đổi kết quả search query của transaction khác. Đặt phòng kiểm tra sự vắng mặt của booking nên query trả về 0 row, không có gì để lock. Giải pháp: serializable (predicate/index-range lock hoặc SSI), hoặc materializing conflicts (bảng slot có sẵn để lock), hoặc constraint nếu có.
72PL hoạt động thế nào và vì sao hiệu năng kém?
Shared lock để đọc, exclusive lock để ghi, giữ tất cả tới cuối transaction; reader và writer chặn nhau; dùng predicate/index-range lock chống phantom. Kém vì giảm concurrency, hàng đợi lock không giới hạn → p99 cao, và deadlock thường xuyên gây retry lãng phí.
8SSI phát hiện xung đột thế nào, và khi nào SSI hoạt động kém?
Dựa trên snapshot isolation, thêm phát hiện (1) đọc bỏ qua ghi chưa commit mà sau đó đã commit (stale MVCC read), (2) ghi ảnh hưởng dữ liệu đã được transaction khác đọc (tripwire trên index). Kiểm tra lúc commit và abort nếu vi phạm. Kém khi contention cao hoặc read-write transaction dài → tỷ lệ abort lớn.

Đây là bản tóm tắt và ghi chú, không thay thế sách gốc. Hãy ủng hộ tác giả bằng cách đọc bản gốc.