Series bài viết Offline Concurrency Control:
Part 1: Offline Concurrency Control - Phần 1: Hiện tượng
Part 2: Offline Concurrency Control - Phần 2: Optimistic - "Lời xin lỗi"
Part 3: (Bài viết này) Offline Concurrency Control - Phần 3: Optimistic và Inconsistent Read
***
Ở phần trước, chúng ta đã xem chi tiết cơ chế hoạt động của Offline Optimistic Lock pattern và triển khai qua một ví dụ đơn giản. Ở ví dụ đó, ứng dụng quản lý kho hàng có chức năng cập nhật thông tin sản phẩm bao gồm mô tả và số tồn kho đã được hai user Tuần và Hùng sử dụng. Optimistic đã giúp ứng dụng không xử lý sai khi đã khắc phục được hiện tượng Lost Update cơ bản của Concurrency. Và ở lời kết, tôi đã đề cập đến vấn đề có phần "nâng cao" hơn khi triển khai pattern này: Dùng nó để phát hiện Inconsistent Read - Đọc bất nhất.
Vấn đề
Cũng trong ứng dụng ở ví dụ trước, có chức năng "Tính tổng doanh thu có thể đạt được" của một số sản phẩm đang nằm trong kho theo công thức: { Đơn giá } * { Số lượng sản phẩm còn tồn }, hay:
{ Revenue } = { Unit Price } * { Quantity }
Tuấn mở một giao dịch: Transaction 1. Đồng thời lúc đó, Hùng cần cập nhật lại đơn giá của “Áo sơ mi nam, cỡ L” và “Áo sơ mi nam, cỡ XL” thông qua Transaction 2. Hình dưới đây mô tả tình huống:

Rõ ràng rằng, kết quả của Transaction 1 phụ thuộc vào đơn giá nhưng đơn giá lại bị thay đổi trong lúc nó diễn ra, nên sự xung đột này không được phát hiện:
- Tuấn tính toán Revenue dựa vào thông tin sản phẩm đọc lên từ database, gọi là Product. Thông tin Product bao gồm: Mã sản phẩm, tên sản phẩm, đơn giá (Unit Price), số lượng còn lại (Quantity).
- Do Optimistic chỉ phát hiện được xung đột trên dữ liệu được ghi, ở đây là Revenue, nên sự thay đổi tại Product (trên Unit Price) bị bỏ sót.
- Cụ thể:
- Transaction 1 của Tuấn được mở trước, lấy lên 10 chiếc "Áo sơ mi nam, cỡ L". Lúc này, đơn giá là 100K. Revenue tạm thời là { 100K * 10 } = { 1000K }.
- Ngay sau đó, Transaction 2 của Hùng được mở và cập nhật:
- "Áo sơ mi nam, cỡ L" từ 100K thành 150K.
- "Áo sơ mi nam, cỡ XL" từ 100K thành 90K.
- Transaction 1 tiếp tục đọc lên 20 chiếc "Áo sơ mi nam, cỡ XL". Lúc này, đơn giá đã là 90K nên Revenue sẽ là { 1000K + 90K * 20 } = { 2800K }
- Transaction 1 ghi Revenue vào bảng REVENUES nên cơ chế Optimistic không phát hiện được sự thay đổi tại Product. Cơ sở dữ liệu rơi vào trạng thái Inconsistent - Không nhất quán.
- Nhận xét: Tính nhất quán của cơ sở dữ bị phá vỡ vì nếu nó nhất quán, giá trị Revenue phải là một trong hai con số sau:
- { 100K * 10 } + { 100K * 20 } = { 3000K }, hoặc
- { 150K * 10 } + { 90K * 20 } = { 3300K }
- Có thể bạn thắc mắc ở Transaction 1 (giả sử là System Transaction), tại sao giữa hai lần đọc lại đọc được dữ liệu committed ở Transaction 2 nếu thiết lập mức Isolation Level là Repeatable Read? Câu trả lời là, ngay cả khi bạn thiết lập cấp độ Isolation này thì dữ liệu bị tác động ở Transaction 2 vẫn thấy được ở Transaction 1 do ở lần đọc này, Transaction 1 đọc dữ liệu không giống với lần đọc thứ nhất. Repeatable Read đảm bảo đọc lại cùng một dữ liệu là không thay đổi trong một transaction.
Sự sai lệch thông tin này về bản chất là do hiện tượng Inconsistent Read gây ra.

Hình 2 cho thấy ranh giới mà Optimistic bảo vệ được chỉ là dữ liệu mà nó đang cập nhật, Revenue, còn dữ liệu mà Revenue tham chiếu tới, là Product, bị thay đổi nhưng không được giám sát.
Giải pháp
Để giải quyết Inconsistent Read ta có hai hướng chính sau:
- Hướng 1: Sử dụng Optimistic cho cả dữ liệu liên quan bên cạnh dữ liệu chính. Trường hợp này, ta bổ sung kiểm tra phiên bản trên cả Product khi cập nhật Revenue. Về bản chất, cách này không giải quyết Inconsistent Read, nó chỉ không chấp nhận kết quả sai mang tới sự không nhất quán trong database.
- Hướng 2: Giải quyết trực tiếp Inconsistent Read bằng cơ chế Isolation trong database.
Từ hai hướng đi này, ta sẽ đi chi tiết hơn vào giải pháp.
Giải pháp 1: Optimistic mở rộng: Version Check
Khi tính toán Revenue, ta lưu lại cả thông tin của Product đưa vào tính toán. Thông tin Product này bao gồm { ID } và { Version } của nó. Vậy, ngoài việc kiểm tra version của dữ liệu chính, trường hợp này là Revenue, thì ta còn kiểm tra cả version của thông tin cần tracking, trường hợp này là Product. Nếu bất cứ dữ liệu nào bị thay đổi khi cập nhật, Optimistic sẽ đưa ra một exception:

Hình trên cho thấy, ranh giới của Optimistic được mở rộng hơn bằng cách không chỉ cần tracking version của Revenue, nó còn cần tracking cả version của Product – là thứ có thể thay đổi bởi một transaction khác trong quá trình tính toán thông tin dựa trên nó.
Ưu điểm của cách triển khai này là dữ liệu nếu được cập nhật sẽ là chính xác nhưng nhược điểm của nó là không phù hợp với những dữ liệu có mối quan hệ phức tạp. Tính "lạc quan" của chức năng cũng bị giảm do xác suất xung đột tăng lên khiến ý nghĩa của giải pháp bị lung lay. Một phiên bản nâng cao hơn là Change Set: Tập hợp các thông tin cần tracking vào một nơi nhưng cách triển khai phức tạp hơn. Change Set tạm thời chúng ta sẽ không bàn tới ở đây.
Từ hình trên ta cũng thấy rõ rằng, giải pháp này không cần quan tâm Isolation Level của database đang là gì vì nó không ngăn cản Inconsistent Read, nó chỉ cần đảm bảo Revenue là đúng, là nhất quán với Product, và là up-to-date.
Giải pháp 2: Database Isolation High-Levels
Giải pháp 1 yêu cầu phải lập trình thì với cách sử dụng Isolation cấp độ cao (chặt chẽ), ta không cần sử dụng cơ chế Optimistic cho dữ liệu liên quan (tức là ta chỉ cần triển khai Optimistic cho Revenue như bài viết trước).
Isolation trong database thông thường chia ra 4 cấp độ (level):
- Read Uncommitted. Một giao dịch có thể thấy được dữ liệu còn chưa được commit ở một transaction khác.
- Read Committed. Một giao dịch có thể thấy được dữ liệu vừa commit thành công ở một giao dịch khác.
- Repeatable Read. Dữ liệu của giao dịch được cô lập hoàn toàn giúp cho nó có thể đọc lại được cùng một dữ liệu nếu thực thi một SQL nhiều lần.
- Serializable. Các giao dịch truy cập vào cùng một dữ liệu, thay vì có thể đồng thời thì chúng phải xếp hàng lần lượt đợi chờ nhau.
Hai cấp độ đầu không thể đảm bảo Inconsistent Read được nên ta bỏ qua. Hai cấp độ sau được bàn tới dưới đây.
Repeatable Read
Cấp độ này vẫn có thể gặp sai sót nếu cách triển khai không đúng. Ví dụ như sau: Giả sử Transaction 1 của Tuấn sử dụng nhiều câu lệnh SQL để đọc ra từng loại sản phẩm tương ứng vì mỗi loại sản phẩm này, ví dụ, nằm tại những bảng khác nhau (có thể xảy ra điều này nếu ta mô hình hóa dữ liệu này theo hướng Generalization/Specialization và triển khai tách biệt các bảng vật lý). Tình huống này cũng có thể xảy ra khi Tuấn có trong tay 2 loại sản phẩm, ở input params, và thực hiện vòng for loop để lấy thông tin từng sản phẩm, mỗi sản phẩm do đó nằm trong một truy vấn thuộc cùng Transaction 1.
- Thời điểm 1: Transaction 1 của Tuấn đọc lên 10 sản phẩm “cỡ L”, tính toán doanh thu.
- Thời điểm 2: Transaction 2 của Hùng đổi đơn giá của sản phẩm “cỡ L” và cả “cỡ XL” và save thành công.
- Thời điểm 3: Transaction 1 của Tuấn đọc tiếp lên 20 sản phẩm “cỡ XL”, tính toán tiếp doanh thu.
Như vậy, tính nhất quán đọc bị phá vỡ giữa thời điểm 1 và thời điểm 3 của Transaction 1. Dữ liệu Revenue do đó mà không đảm bảo tính nhất quán của database.
Để khắc phục, ta cần để Tuấn đọc hết sản phẩm cần tính toán lên trong cùng một lần đọc:
- Thời điểm 1: Transaction 1 của Tuấn đọc lên tất cả các sản phẩm cần tính toán, bao gồm 10 sản phẩm "cỡ L" và 20 sản phẩm “cỡ XL”, tính toán doanh thu.
- Thời điểm 2: Transaction 2 của Hùng đổi đơn giá của sản phẩm “cỡ L” và cả “cỡ XL” và save thành công.
- Thời điểm 3: Nếu Transaction 1 có đọc lại dữ liệu các sản phẩm đã đọc lên ở thời điểm 1 thì dữ liệu này vẫn y hệt như lần đọc đầu tiên. Đây là điều được đảm bảo bởi Repeatable Read.
Bạn có thể thắc mắc phải không? Thời điểm 2, Hùng đã cập nhật đơn giá rồi thì sau đó Tuấn nhận được con số { Revenue = 3000K } không phản ánh đúng thực tế, đúng phải là { Revenue = 3300K } chứ. Hãy phân tích thêm chút chỗ này:
- Trước hết, cả hai con số này đều phản ánh sự nhất quán của dữ liệu trong database.
- Con số { 3000K } chỉ là con số quá khứ, nó chỉ là lỗi thời chứ không sai tại thời điểm nó được tính toán (thời điểm mở Transaction 1).
- Nếu sau đó, Tuấn lại mở một transaction tính toán mới, Revenue sẽ là up-to-date.
- Điều này có thể khắc phục bằng một cơ chế "cao cấp": Event-Driven.
- Khi Hùng thay đổi thông tin sản phẩm, ứng dụng tạo ra một Domain Event và publish event này.
- Các bên quan tâm, ở đây là luồng tính toán doanh thu, consume và cập nhật thông tin cho phù hợp.
Serializable
Sử dụng Isolation cấp độ Serializable khiến cho Tuấn có thể: Cho phép người khác chỉ đọc mà không được ghi Product. Cách này đáp ứng được mục tiêu vì Tuấn không ghi vào Product mà anh ghi vào Revenue. Khi ghi vào Revenue, anh vẫn dùng Optimistic cho nó.
Hùng lúc này muốn ghi dữ liệu sẽ buộc phải lấy được Write Lock, mà muốn lấy được khóa này, phải chờ Tuấn "nhả" Read Lock. Cách thức sử dụng Read Lock và Write Lock sẽ được bàn chi tiết hơn trong mục nói về Offline Pessimistic Lock pattern.
Con số Revenue tính toán được luôn đảm bảo chính xác, nhất quán, và up-to-date.
Triển khai
(Bài viết tiếp theo)