ACID & Weak Isolation Levels — Transactions trong hệ thống (DDIA)

Phong

Vì sao transaction quan trọng?

Ảnh: Brett Sayles — Pexels

Trong một hệ thống backend, chuyện gì xảy ra khi hai request cùng ghi vào một record cùng lúc? Hoặc khi server crash giữa lúc đang cập nhật nhiều bảng? Transaction sinh ra để giải quyết những vấn đề đấy. Nó nhóm nhiều thao tác đọc-ghi vào một unit duy nhất — nếu có lỗi ở bất kỳ bước nào, tất cả đều rollback, dữ liệu không bao giờ ở trạng thái "nửa vời".

Martin Kleppmann đặt transaction ở vị trí trung tâm của Chapter 7 trong DDIA, và section 7.1 bắt đầu bằng một câu chuyện thú vị: thuật ngữ ACID, tưởng là chuẩn mực, thực ra lại rất mơ hồ. Mỗi database implement ACID một kiểu, và thứ họ gọi là "Isolation" thường khác xa với isolation lý tưởng.

ACID — four letters, nhiều cách hiểu

Atomicity: Nếu một transaction bị lỗi ở giữa chừng, database phải đảm bảo rollback toàn bộ — không có chuyện "cập nhật một nửa". Chẳng hạn, bạn chuyển $100 từ tài khoản A sang B: nếu trừ A rồi server crash, atomicity đảm bảo A cũng được khôi phục.

Consistency: Đây là property khó nhất vì nó phụ thuộc vào application logic hơn là database. Consistency nói rằng dữ liệu phải luôn ở trạng thái "hợp lệ" (đúng kiểu dữ liệu, đúng ràng buộc, đúng business rules). Nhưng Kleppmann chỉ ra rằng consistency không phải là trách nhiệm duy nhất của database — application cũng phải đảm bảo các invariants. Database chỉ giúp enforce những constraint ở mức schema.

Isolation: Đây là phần thú vị và phức tạp nhất. Về lý thuyết, isolation có nghĩa là "các transaction chạy đồng thời không được ảnh hưởng lẫn nhau" — như thể chúng chạy tuần tự. Nhưng trong thực tế, isolation lý tưởng (serializability) có performance cost rất lớn, nên hầu hết database chỉ implement các weak isolation levels.

Durability: Một khi transaction đã commit, dữ liệu không bị mất dù có sự cố gì (crash, mất điện, v.v.). Database thường dùng write-ahead log (WAL) để ghi nhận thay đổi trước khi áp dụng vào storage. Nhưng durability cũng có giới hạn — nếu ổ cứng hỏng hoặc cả datacenter sập, sao lưu sang replica mới giúp.

Read Committed — mức cơ bản nhất

Ảnh: cottonbro studio — Pexels

Read Committed là weak isolation level phổ biến nhất (PostgreSQL mặc định, Oracle, SQL Server, v.v.). Nó chỉ đảm bảo hai điều:

1. Không dirty read (dirty read) — Một transaction không thể đọc dữ liệu chưa commit từ transaction khác. Nếu transaction A đang update record X rồi rollback, transaction B đang chạy song song chỉ thấy phiên bản cũ (chưa bị thay đổi).

2. Không dirty write — Hai transaction cùng update một record không thể "đè" lên nhau. Nếu A và B cùng UPDATE cùng một hàng, database sẽ tuần tự hoá (một transaction chờ transaction kia commit hoặc abort).

Dirty write nguy hiểm hơn dirty read nhiều. Hãy tưởng tượng bạn mua vé concert: nếu không có dirty write protection, hai request mua vé cuối cùng có thể cùng insert vào database và bán thành "một vé cho hai người". Hầu hết database đều ngăn dirty write ở mọi isolation level.

Nhưng Read Committed vẫn có lỗ hổng. Nó không ngăn được read skew: transaction A đọc record X (đã được B cập nhật), sau đó đọc record Y (chưa được B cập nhật) — kết quả là A thấy database ở trạng thái "không nhất quán", vì B chỉ mới commit một trong hai thay đổi.

Ví dụ kinh điển: bạn đếm số dư hai tài khoản. Transaction 1 trừ tài khoản A $100 và cộng vào tài khoản B $100. Nếu bạn đọc A sau khi T1 commit (thấy -$100) nhưng đọc B trước khi T1 commit (chưa thấy +$100) — bạn sẽ thấy tổng số dư thiếu $100 dù không có lỗi gì xảy ra.

Snapshot Isolation — giải pháp cho read skew

Snapshot Isolation (SI) giải quyết read skew bằng cách cho mỗi transaction thấy một snapshot ổn định của database tại thời điểm nó bắt đầu. Transaction chỉ nhìn thấy dữ liệu đã commit tại thời điểm snapshot — bất kỳ thay đổi nào từ transaction khác sau đó đều vô hình.

Cơ chế phổ biến nhất để implement SI là MVCC (Multi-Version Concurrency Control). Mỗi row trong database có thể tồn tại nhiều phiên bản, mỗi phiên bản gắn với transaction id đã tạo ra nó. Khi một transaction đọc một row, database dùng transaction id của nó để chọn phiên bản phù hợp — phiên bản đã commit trước thời điểm snapshot.

PostgreSQL gọi SI là "Repeatable Read" (mặc dù chuẩn SQL định nghĩa Repeatable Read khác). Oracle và MySQL (InnoDB) cũng dùng MVCC nhưng với sắc thái khác nhau. Điểm chung: người dùng được bảo vệ khỏi read skew mà không phải trả giá về performance quá lớn.

Những vấn đề SI không giải quyết được

Ảnh: Jakub Zerdzicki — Pexels

Snapshot Isolation mạnh hơn Read Committed, nhưng vẫn để ngỏ một số anomaly:

Lost Updates — Hai transaction đọc cùng một giá trị, rồi cùng cập nhật nó, và cập nhật sau đè lên cập nhật trước. Vd: hai admin cùng tăng view count của một bài blog từ 10 lên 11, kết quả là 11 thay vì 12. Siêu thị, kho hàng, counter — bất kỳ dạng "read-modify-write" nào cũng dễ gặp lost update.

Write Skew — Hai transaction đọc một tập dữ liệu chồng lấn, mỗi transaction update một phần khác nhau của tập đó, và kết quả tổng thể vi phạm một invariant. Ví dụ: ứng dụng cho phép bất kỳ ai cũng có thể gỡ bỏ quyền "admin cuối cùng" — nếu hai admin cùng click gỡ quyền của nhau cùng lúc, cả hai đều đọc thấy "còn người kia là admin" và đều tự gỡ quyền mình, kết thúc với zero admin.

Phantoms — Khi một transaction đọc một tập row khớp điều kiện WHERE, nhưng transaction khác INSERT một row mới khớp điều kiện đó, và transaction đầu đọc lại thấy row mới. Điều này gây ra "phantom reads" mà Snapshot Isolation giải quyết một phần (dùng predicate locking, nhưng không triệt để ở SI).

Key Takeaways

  • ACID không phải là một chuẩn thống nhất — mỗi database hiểu ACID khác nhau; cần hiểu rõ database mình đang dùng implement những gì
  • Read Committed ngăn dirty read và dirty write, nhưng không ngăn read skew — dễ gây ra những bất nhất tạm thời
  • Snapshot Isolation (dùng MVCC) giải quyết read skew bằng cách cho mỗi transaction thấy snapshot ổn định từ thời điểm nó bắt đầu
  • SI vẫn còn lỗ hổng: lost updates, write skew, và phantoms — những vấn đề này chỉ có serializability mới giải quyết triệt để
  • MVCC là kỹ thuật chủ đạo trong các database hiện đại (PostgreSQL, Oracle, MySQL InnoDB) để đạt isolation mà không hy sinh performance quá nhiều

📋 Phụ lục thuật ngữ

Dirty Readđọc dữ liệu chưa commit từ transaction khác
Dirty Writehai transaction ghi đè lên cùng một row chưa commit
Read Skewđọc dữ liệu ở trạng thái không nhất quán giữa các row
Snapshot Isolationmỗi transaction thấy dữ liệu tại thời điểm nó bắt đầu
MVCCMulti-Version Concurrency Control — duy trì nhiều phiên bản row để xử lý concurrent access
Write Skewhai transaction update các row khác nhau nhưng tổng thể vẫn vi phạm invariant
Lost Updateread-modify-write mà cập nhật sau đè lên cập nhật trước
Phantomrow mới xuất hiện giữa hai lần đọc cùng một điều kiện WHERE
Serializabilitymức isolation cao nhất — kết quả giống như chạy tuần tự
WALWrite-Ahead Log — ghi log trước khi áp dụng thay đổi vào storage

Đọc thêm

Bài tiếp theo trong series sẽ nói về Serializable Isolation — mức isolation mạnh nhất và cách database implement nó (2PL, SSI). Đây là phần cuối của Chapter 7 và cũng là một trong những chủ đề khó nhất trong DDIA.

Nếu bạn muốn đào sâu hơn về MVCC, bài viết "MVCC in PostgreSQL" của PostgresPro giải thích rất chi tiết về tuple-level versioning, transaction wraparound, và vacuum. Ngoài ra sách gốc DDIA (Chapter 7) có phần so sánh isolation level giữa các database cụ thể.