Transaction concurrency

Transaction Isolation 102: Isolation Level

1 tháng 1, 2021
18 phút đọc
Transaction Isolation 102: Isolation Level

Ở bài trước, chúng ta đã điểm mặt đặt tên những "kẻ phá hoại" kinh điển trong thế giới transaction: Dirty Write, Dirty Read, Lost Update, Write Skew và Read Skew. Vậy câu hỏi đặt ra là: Làm thế nào để chống lại chúng?

Câu trả lời nằm ở việc lựa chọn đúng mức độ Isolation (cô lập) cho transaction. Mỗi mức isolation cung cấp một lớp bảo vệ khác nhau, đi kèm với cái giá phải trả về hiệu năng và khả năng mở rộng.


Bản đồ các mức Isolation#

Về cơ bản, có 4 mức isolation chuẩn được định nghĩa, mỗi mức giải quyết được một nhóm vấn đề cụ thể:

Isolation LevelDirty WriteDirty ReadRead SkewLost UpdateWrite SkewPhantoms
Read Uncommitted
Read Committed
Repeatable Read
Serializable

Chú thích:

  • ✓ = Không bị lỗi ở mức isolation này

  • ✘ = Vẫn bị lỗi

  • — = Tùy trường hợp, phụ thuộc vào cách cơ sở dữ liệu cụ thể triển khai mức isolation này.

Tuy nhiên, đây chỉ là tiêu chuẩn lý thuyết. Thực tế, mỗi database lại có cách triển khai và đặt tên riêng, đôi khi gây nhầm lẫn không đáng có. Ví dụ điển hình:

  • OracleDB gọi Snapshot Isolation của họ là "Serializable", dù theo chuẩn nó mới chỉ đạt mức Repeatable Read.

  • SQL Server có tới tận 6 mức isolation khác nhau.

  • PostgreSQL thì dùng Snapshot Isolation cho mức Repeatable Read.

Nếu anh em muốn kiểm chứng chi tiết cách hoạt động của từng database, có thể tham khảo project thực nghiệm nổi tiếng Hermitage.

Giờ hãy cùng đi sâu vào từng mức độ, xem chúng hoạt động thế nào và tại sao lại có những ưu nhược điểm riêng.


Read Uncommitted – Mức Cơ Bản Nhất#

Đây là mức isolation nhẹ ký nhất. Nó đảm bảo được một điều tối thiểu: chống Dirty Write. Cơ chế rất đơn giản: transaction chỉ acquire khóa độc quyền (exclusive lock) khi thực hiện ghi, và giữ lock này cho đến khi commit hoặc rollback. Điều này đảm bảo trong một thời điểm, chỉ có một transaction được phép ghi vào một row dữ liệu.

Read Uncommitted có thật sự "tệ"?#

Câu trả lời là: Không hẳn.

Nếu biết dùng đúng chỗ, Read Uncommitted vẫn có giá trị. Trong các hệ thống lớn, việc ghi đúng thường quan trọng hơn đọc đúng. Một lỗi ghi có thể làm hỏng dữ liệu vĩnh viễn, trong khi đọc sai chỉ cần refresh lại hoặc fix code là xong. Read Uncommitted giúp tăng hiệu năng đọc lên rất nhiều vì không phải acquire lock khi đọc.

Tuy nhiên, cái giá phải trả là nguy cơ Dirty Read – đọc phải dữ liệu "dirty" chưa được commit. Vì vậy, câu hỏi đáng quan tâm hơn là:

"Hiệu năng của Read Uncommitted tốt hơn Read Committed bao nhiêu, và liệu có đáng để đánh đổi?"

https://sqlperformance.com/2015/04/t-sql-queries/the-read-uncommitted-isolation-level


Read Committed – Cân bằng giữa nhất quán và hiệu năng#

Read Committed là mức isolation mặc định của hầu hết các cơ sở dữ liệu (PostgreSQL, Oracle, SQL Server). Nó hứa hẹn hai điều: 1) Không Dirty Write2) Không Dirty Read. Có hai cách phổ biến để thực hiện lời hứa này.

2.1. Shared Lock#

Đây là cách tiếp cận cổ điển. Ta bổ sung thêm việc acquire khóa chia sẻ (shared lock) mỗi khi đọc một dòng dữ liệu.

  • Shared Lock cho phép nhiều transaction cùng đọc một dòng.

  • Khi có transaction muốn ghi (exclusive lock), nó phải chờ tất cả shared lock được release.

  • Shared lock sẽ được release ngay sau khi hoàn thành thao tác đọc.

Nhược điểm: Hiệu suất bị ảnh hưởng đáng kể vì mỗi lần đọc đều phải acquire lock. Nếu hệ thống có tỷ lệ đọc/ghi chênh lệch lớn (read-heavy), đây là một sự lãng phí.

2.2. Consistent Read#

Cách tiếp cận hiện đại hơn là sử dụng Multi-Version Concurrency Control (MVCC). Mỗi row dữ liệu không chỉ có một giá trị, mà được lưu nhiều phiên bản (version).

  • Khi transaction ghi, nó tạo một phiên bản mới của row đó.

  • Khi transaction đọc, nó chỉ nhìn thấy những phiên bản đã được commit tại thời điểm bắt đầu đọc.

  • Điều này cho phép các thao tác đọc chạy song song mà không cần khóa, trong khi thao tác ghi vẫn được serialize.

Ưu điểm: Hiệu năng đọc cực cao, không bị block bởi các transaction ghi.

Nhược điểm của Read Committed: Dù đã giải quyết được Dirty Read, nó vẫn bó tay trước các lỗi phức tạp hơn như Read Skew, Lost Update, Write SkewPhantom Read.


Repeatable Read – Khắc phục "Read Skew"#

Mức này hứa hẹn thêm một điều: giải quyết Read Skew (Non-repeatable Read), đảm bảo trong cùng một transaction, nhiều lần đọc cùng một dữ liệu sẽ cho ra kết quả giống nhau. Cũng có hai trường phái chính.

1. Repeatable Read sử dụng Lock (Cổ điển)#

Phương pháp này nâng cấp từ Shared Lock của Read Committed, với điểm khác biệt chính:

  • Giữ lock đến hết transaction: Lock (cả shared lẫn exclusive) được giữ cho đến khi transaction kết thúc (commit/rollback), thay vì release ngay sau thao tác.

  • Lock mọi row đã đi qua: Khi quét dữ liệu (scan), nó sẽ khóa mọi row nó "chạm tới", kể cả những row không nằm trong kết quả cuối cùng. Nếu bảng không có index, nó có thể khóa nguyên cả bảng!

  • Upgrade lock: Nếu transaction đọc rồi sau đó muốn ghi lên cùng row đó, shared lock sẽ được nâng cấp lên exclusive lock. Quá trình này phải chờ tất cả các shared lock khác trên row đó được release.

"Sân chơi" của phương pháp này là những transaction chỉ thao tác trên một số row xác định trước (get by id, update by id), chứ không phải các truy vấn tìm kiếm phức tạp. Các ví dụ kinh điển của nó:

Read Skew#

Lost Update#

Write Skew#

Hạn chế lớn#

  • Không giải quyết được Phantom Read: Vì cơ chế lock theo từng row, scan tới đâu lock tới đó. Các row mới được insert vào giữa các row đã scan sẽ không bị khóa.

  • Hiệu năng: Các long-running transaction có thể block nhiều operation khác, gây chậm hệ thống và dễ dẫn đến deadlock.

  • Có khả năng bị Deadlock. Ví dụ với ảnh minh họa của bài toán Read Skew: đổi thứ tự giảm account 2 trước thay vì tăng account 1.

2. Snapshot Isolation – "Cứu tinh" cho Read-Only Transaction#

Đây là cách tiếp cận MVCC nâng cao, được dùng trong PostgreSQL (Repeatable Read) và Oracle (Serializable). Thay vì chỉ lưu 2 version (Read Committed), nó lưu toàn bộ lịch sử version của mỗi row.

Cách hoạt động:

  • Mỗi transaction được gán một Transaction ID (TXID) duy nhất khi bắt đầu, tăng dần theo thời gian.

  • Khi đọc, transaction chỉ nhìn thấy những phiên bản dữ liệu được commit trước khi nó bắt đầu (tức là version.txid < current_transaction.txid). Nó hoàn toàn bỏ qua các thay đổi xảy ra sau đó.

  • Khi ghi, nó tạo ra một phiên bản mới của row, đánh dấu bằng txid của nó.

Ưu điểm vượt trội:

  • Hiệu năng đọc cực cao: Không cần lock khi đọc, không bị block.

  • Giải quyết Read Skew và Phantom Read: Vì có một snapshot dữ liệu nhất quán tại một thời điểm.

  • Tránh deadlock cho các thao tác đọc.

Nhược điểm:

  • Tốn bộ nhớ: Phải lưu trữ nhiều version. Cần một cơ chế Garbage Collection (GC) để dọn dẹp các version cũ.

  • Không giải quyết được Lost Update & Write Skew: Đây là điểm yếu chí tử. Vì các transaction làm việc trên các snapshot độc lập, chúng có thể cùng ghi đè lên nhau dựa trên một trạng thái dữ liệu đã lỗi thời.

  • Phức tạp: Phải triển khai MVCC cho cả index đi kèm, không chỉ riêng dữ liệu.


Serializable – Mức mạnh nhất, Giá đắt nhất#

Đây là "chân ái" của isolation: đảm bảo kết quả thực thi song song nhiều transaction giống hệt như khi chúng chạy tuần tự (serial). Có 3 cách tiếp cận chính để đạt được điều này.

1. Actual Serial Execution (Thực thi tuần tự thật sự)#

Ý tưởng đơn giản nhất: chỉ dùng một luồng (single thread) để xử lý tất cả transaction. Một khi không có song song, thì làm gì có race condition?

Ưu điểm: Logic đơn giản, hiệu suất trên single-core rất cao, không cần lock phức tạp.
Nhược điểm: Không scale được. Mọi thứ bị bottleneck tại một điểm.

Tuy nhiên, với sự phát triển của phần cứng và mô hình lập trình sự kiện (event loop), ý tưởng này được hồi sinh. Redis là ví dụ điển hình nhất. Các database như VoltDB, H-Store cũng theo trường phái này. Chúng cực kỳ nhanh nhờ loại bỏ hoàn toàn chi phí lock và ngữ cảnh chuyển đổi (context switching).

Điều kiện để sử dụng:

  • Transaction phải nhỏ và nhanh.

  • Data thường được lưu trên RAM.

  • Có thể dùng partitioning để chia data ra nhiều core, nhưng transaction xuyên partition vẫn cần cơ chế phối hợp phức tạp.

2. Two-Phase Locking (2PL – Khóa Hai Pha)#

Chắc tới đây thì anh em cũng bắt đầu tò mò về câu hỏi: hơn 30 năm qua, các Database truyền thống đã dùng phương pháp gì? Câu trả lời chính là Two-Phase Locking. Nguyên tắc của 2PL rất nghiêm ngặt:

  1. Phải acquire lock trước khi đọc/ghi bất kỳ hàng nào.

  2. Chỉ được release lock sau khi transaction kết thúc (commit/rollback). Đây là điểm khác biệt then chốt so với Repeatable Read.

  3. Lock có hai chế độ: Shared (cho đọc) và Exclusive (cho ghi).

Luật "vàng" của 2PL:

  • Nếu transaction A đã đọc một hàng, transaction B muốn ghi vào hàng đó phải đợi A kết thúc.

  • Nếu transaction A đã ghi vào một hàng, transaction B muốn đọc hàng đó cũng phải đợi A kết thúc.

Đấy là lý thuyết, khái quát thì không khác gì Repeatable Read, nhưng thực tế thì Database còn dùng row lock kết hợp với predicate lock hoặc index-range lock nữa.

Row-level lock#

Lock ở mức độ row giống như Repeated Read, không tránh được lỗi Phantoms. Cái này chỉ sử dụng cho những câu query get by id, hay write by id thôi.

Predicate Lock (Khóa vị từ)#

Khóa theo điều kiện (ví dụ: WHERE age > 10).

  • Ngay khi bắt gặp 1 câu lệnh mà không phải thuộc dạng get by id hay write by id, transaction sẽ sử dụng thêm Predicate lock (vẫn dùng cả Row lock, scan tới đâu thì acquire tới Row lock tương ứng ở đó).

  • Predicate lock cũng tồn tại 2 chế độ share mode và exclusive mode, nguyên lý tương tự như Row Lock.

  • Database sẽ lưu 1 danh sách các Predicate lock đang chưa được release.

  • Tất cả các Operation khác (dù chỉ get/write by id) đều phải duyệt qua cái danh sách Predicate lock kia trước khi tới bước acquire Row Lock. Mục đích để check xem row mà transaction định read/write nó có thỏa mãn predicate hay không. Nếu có thì phải tìm cách acquire Predicate lock được release đã thì mới được tiếp tục.

Cách này đã tránh được tình trạng Phantom Read, 100% serializable. Tuy nhiên, ta có thể thấy rằng việc phải check xem row có thỏa mãn predicate hay không rất là tốn thời gian.

Index-Range Lock (Khóa khoảng Index)#

Là tối ưu hóa của Predicate Lock.

Thay vì khóa logic, nó khóa một khoảng trên index (ví dụ: khóa tất cả truy cập tới index room_id có giá trị 123).

 SELECT * FROM bookings 
 WHERE room_id = 123 AND  end_time > '2018-01-01 12:00' AND  start_time < '2018-01-01 13:00';

Minh họa cho câu query trên, ta sẽ có 3 sự lựa chọn:

  • lock theo index room_id = 123

  • lock theo index end_time > '2018-01-01 12:00'

  • lock theo index start_time < '2018-01-01 13:00'

Ta chỉ cần sử dụng 1 trong 3 thôi, không cần phải lock cả 3 index lại. Như vậy vẫn đảm bảo lock được toàn bộ những row thỏa mãn predicate của câu query (mặc dù lock thừa nhiều bản ghi không cần thiết). Chọn lựa index nào để tối ưu lock thì đó lại là 1 câu chuyện khác, cái đó sẽ do optimizer của Database xử lý.

Cách này nhanh hơn nhưng có thể khóa "thừa" nhiều row không liên quan. Nếu cột trong điều kiện không có index, database sẽ phải khóa nguyên cả bảng.

Nhược điểm của 2PL#

Hiệu suất thấp, tỷ lệ deadlock cao, khả năng mở rộng kém. Nó thuộc nhóm Pessimistic Concurrency Control – luôn giả định xung đột sẽ xảy ra nên khóa từ đầu.

3. Serializable Snapshot Isolation (SSI) – Tương Lai Của Serializable#

Điểm qua lại những thứ ta đã tìm hiểu:

  • Snapshot Isolation: cho performance rất tốt, tuy nhiên lại bị gặp phải vấn đề với Lost Update, Write Skew, Phantoms,…

  • Actual serial execution: scale kém

  • Two-Phase Locking: performance kém

Hầu hết đều được cái này thì mất cái kia, tuy nhiên gần đây đã có nghiên cứu mới về 1 phương pháp có thể dung hòa được tất cả yếu tố bên trên. Thuật toán có tên là Serializable Snapshot Isolation (SSI), được đánh giá rất triển vọng để dung hòa giữa hiệu năng của Snapshot Isolation và tính nhất quán của Serializable, được ra mắt vào năm 2008 trong luận án tốt nghiệp tiến sĩ của Michael Cahill. SSI hiện đang được sử dụng trong PostgreSQL bắt đầu từ phiên bản 9.1.

SSI thuộc nhóm Optimistic Concurrency Control. Thay vì ngăn chặn xung đột ngay từ đầu bằng lock (pessimistic), nó cho phép mọi transaction chạy tự do, nhưng sẽ theo dõi và phát hiện (detect) các hành vi có thể vi phạm tính serializable. Nếu phát hiện vi phạm, nó sẽ hủy bỏ (abort) transaction đó và yêu cầu thử lại.

 SELECT COUNT(*) FROM staff 
 WHERE join_time < '2018-01-01 13:00';

 UPDATE staff SET revenue = revenue+500 
 WHERE join_time < '2018-01-01 13:00';

2 câu lệnh bên trên cùng tác động tới những row giống nhau nhưng kết quả thì không ảnh hưởng tới nhau. Nhóm Optimistic sẽ cứ để mọi thứ xảy ra, miễn là nó không ảnh hưởng tới transaction khác; transaction cứ việc thực thi, còn nó có được commit thành công hay không thì đấy lại là một chuyện khác!

Ưu điểm:

  • Hiệu năng đọc rất cao (kế thừa từ Snapshot Isolation).

  • Tránh được hầu hết deadlock.

  • Đảm bảo tính serializable thực sự.

Nhược điểm:

  • Tỷ lệ abort và retry cao hơn trong môi trường tranh chấp dữ liệu (high contention).

  • Thuật toán phức tạp, đòi hỏi database phải được thiết kế hỗ trợ từ gốc.


Kết luận & Lựa chọn công cụ#

Việc lựa chọn mức isolation và cơ chế triển khai không phải là tìm kiếm một "chén thánh", mà là một bài toán đánh đổi (trade-off) giữa tính nhất quán, hiệu năng, khả năng mở rộng và độ phức tạp.

Dưới đây là bảng tổng hợp toàn diện các phương pháp, bao gồm cả các cách triển khai khác nhau cho cùng một cấp độ:

Cấp Độ & Phương PhápCơ Chế ChínhƯu ĐiểmNhược ĐiểmPhù Hợp Khi...
Read Committed (Locking)Shared Lock cho đọc, Exclusive Lock cho ghi. Lock đọc được release ngay sau thao tác.• Đơn giản, dễ hiểu.
• Đảm bảo không Dirty Read.• Hiệu năng đọc thấp do phải acquire lock mỗi lần.
• Không chống được Read Skew, Lost Update, Write Skew.• Hệ thống có transaction ngắn, đơn giản.
• Ưu tiên tính đúng đắn tuyệt đối hơn hiệu năng.
Read Committed (MVCC)Multi-Version Concurrency Control. Đọc từ snapshot tại thời điểm bắt đầu câu lệnh.Hiệu năng đọc rất cao, không bị block bởi ghi.
• Là mặc định của nhiều DB (PostgreSQL, Oracle).• Vẫn không chống được Read Skew, Lost Update, Write Skew.Hầu hết ứng dụng web thông thường.
• Tỉ lệ đọc/ghi cao (read-heavy).
Repeatable Read (Locking)Giữ lock (Shared/Exclusive) đến hết transaction. Lock mọi hàng đã scan qua.• Giải quyết Read Skew.
• Có thể chống Lost Update nếu dùng lock đúng cách (SELECT FOR UPDATE).• Hiệu suất thấp, dễ deadlock.
Không giải quyết được Phantom Read/Write Skew (vì lock theo hàng).
• Có thể lock cả bảng nếu không có index.• Transaction ngắn, thao tác trên số hàng xác định (get/update by ID).
• Cần đảm bảo đọc lặp lại nhất quán.
Snapshot Isolation (MVCC nâng cao)MVCC với snapshot tại thời điểm bắt đầu transaction. Chỉ đọc các version đã commit trước đó.Hiệu năng đọc cực cao, không lock, không deadlock khi đọc.
• Giải quyết Read Skew & Phantom Read.
• Lý tưởng cho long-running query.Không giải quyết được Write Skew & Lost Update.
• Tốn bộ nhớ, cần GC.
• Có thể bị abort do write conflict.• Hệ thống report, analytics, backup.
• Ứng dụng read-heavy cần view nhất quán.
Serializable (2-Phase Locking - 2PL)Khóa chặt (Pessimistic): Giữ lock đến hết transaction. Dùng Predicate/Index-Range Lock chống Phantom.Đảm bảo tính serializable mạnh mẽ nhất (chống mọi lỗi).
• Kinh điển, được kiểm chứng.Hiệu suất thấp, tỉ lệ deadlock cao.
• Khả năng mở rộng kém.
• Dễ xảy ra lock toàn bộ bảng.• Hệ thống tài chính, ngân hàng yêu cầu nhất quán tuyệt đối.
• Khối lượng giao dịch vừa phải, ưu tiên an toàn trên hiệu năng.
Serializable (Actual Serial Execution)Single-thread xử lý tuần tự mọi transaction.Đơn giản, cực nhanh trên single-core, không cần lock.
• Đảm bảo serializable hoàn hảo.Không scale ngang được (bottleneck tại 1 thread).
• Yêu cầu transaction phải rất ngắn.• Cache (Redis), hệ thống xử lý sự kiện tốc độ cao.
• Data in-memory, workload đơn giản.
Serializable Snapshot Isolation (SSI)Optimistic Concurrency Control: Cho chạy tự do, sau đó detect và abort các transaction vi phạm tính serializable.• Kết hợp hiệu năng tốt của MVCC với tính nhất quán mạnh của Serializable.
• Tránh được nhiều deadlock.Tỷ lệ abort/retry cao trong môi trường tranh chấp (high contention).
• Thuật toán phức tạp, triển khai chưa phổ biến.• Ứng dụng cần nhất quán cao nhưng vẫn đòi hỏi hiệu năng tốt (tương lai).
• PostgreSQL từ version 9.1.

Lời khuyên thực tế để lựa chọn#

  1. Bắt đầu từ mặc định & Đơn giản nhất:

    • Đa số ứng dụng: Hãy bắt đầu với Read Committed (MVCC) – mức mặc định của PostgreSQL/Oracle. Nó cân bằng tốt giữa hiệu năng và nhất quán.

    • Xử lý Lost Update: Nếu có nghiệp vụ read-modify-write quan trọng (như tăng số dư), hãy dùng SELECT FOR UPDATE (pessimistic lock) ngay trong transaction Read Committed, thay vì nhảy lên Repeatable Read.

  2. Nâng cấp khi có nhu cầu rõ ràng:

    • Khi gặp Read Skew gây vấn đề (ví dụ: report tính tổng tiền bị sai), hãy cân nhắc Snapshot Isolation (REPEATABLE READ trong PostgreSQL).

    • CẢNH BÁO: Đừng nghĩ rằng lên Snapshot Isolation là xong. Hãy kiểm tra kỹ xem ứng dụng có dễ bị Write Skew không (các quy tắc nghiệp vụ như "luôn có 1 bác sĩ trực").

  3. Chỉ dùng Serializable khi thật sự cần thiết:

    • Serializable (2PL hay SSI) là "con dao mổ". Chỉ dùng khi nghiệp vụ không thể chấp nhận bất kỳ sự không nhất quán nào và bạn sẵn sàng trả giá bằng hiệu năng hoặc tỉ lệ retry.

    • Luôn thiết kế ứng dụng để chịu được transaction abort và có cơ chế retry thông minh khi dùng Serializable.

  4. Hiểu rõ Database của bạn:

    • ĐỪNG CHỈ NHÌN VÀO TÊN GỌI. "REPEATABLE READ" trong PostgreSQL là Snapshot Isolation, trong khi MySQL (InnoDB) gần với Repeatable Read (Locking) hơn. OracleDB gọi Snapshot Isolation của họ là "Serializable", dù theo chuẩn nó mới chỉ đạt mức Repeatable Read.

    • Tra cứu tài liệu chính thức để biết cơ chế thực sự đằng sau mỗi tên gọi.

  5. Thiết kế Schema & Truy vấn hỗ trợ Isolation:

    • Index là bạn tốt: Một truy vấn có predicate (WHERE) được index tốt sẽ giảm phạm vi khóa (lock range) đáng kể ở các mức cao, tăng hiệu năng.

    • Tránh scan toàn bộ bảng ở mức isolation cao (Serializable, Repeatable Read với lock) nếu không muốn bị khóa cả bảng.

Tóm lại, không có công cụ nào là tốt nhất.

  • Developer: Hãy hỏi "Đoạn code này cần nhất quán đến mức nào?" và chọn công cụ phù hợp.

  • Tech Lead/Architect: Hiểu rõ đặc điểm workload (read-heavy/write-heavy, contention level) của hệ thống và đặc điểm triển khai của database đang dùng để đưa ra lựa chọn sáng suốt.

Hiểu rõ bản chất của từng cơ chế giúp bạn không chỉ giải quyết vấn đề mà còn dự đoán và ngăn ngừa chúng ngay từ khâu thiết kế.

Khám phá thêm