Đang tải nội dung…
Đang tải nội dung…
100 trừ 10 hai lần vẫn ra 90: lỗi nằm ở khoảng cách giữa lần đọc và lần ghi.
Transaction, Isolation & Locking · Phần 2Bối cảnh hư cấu, bằng chứng lab thật. Fortex trong tuyến truyện này là bối cảnh hư cấu; Tom và Ben là nhân vật. Các cuộc trao đổi minh họa quá trình suy nghĩ. Số đo và assertion thuộc lab Docker được dẫn trong bài, không phải số liệu production của Fortex.
Hành trình ở Fortex · Chương 4/8
Ben đem phần xử lý tồn kho mới của Fortex đến bàn Tom. Cậu đã nhớ đóng transaction và kiểm tra lỗi. Đường đi rất dễ đọc: lấy số lượng hiện tại, trừ số khách mua, lưu lại.
“Em kiểm tra không âm nữa rồi. Một đơn mua được thì tồn kho giảm đúng.”
Tom đọc hết đoạn code trước khi hỏi: “Anh cho hai request đi đến cùng dòng đọc này được không?”
“Database có lock lúc UPDATE mà anh.”
“Có. Mình giữ giả thuyết ấy, rồi ghi ra hai giá trị mà ứng dụng sẽ gửi.”
Ben đặt hai cửa sổ cạnh nhau. Với tồn kho 100, mỗi đơn lấy 10, cậu viết 90 ở cả hai cửa sổ. Lúc này cậu mới thấy: lock có thể bắt chúng đi lần lượt, nhưng vẫn là hai lần ghi 90. Tom để cậu tự nhấn chạy nhánh sai trong lab, rồi cùng đổi cách biểu đạt phép trừ.
Ben ghi kết quả mong đợi trước khi chạy: còn 80 sau hai đơn khác nhau. Cậu cũng giữ lại nhánh chỉ có một request để giải thích vì sao test cũ đã khiến mình yên tâm.
Giả sử kho còn 100 sản phẩm. Hai đơn hàng, mỗi đơn mua 10, đến gần như cùng lúc. API trả thành công cả hai. Kho phải còn 80.
Nhưng nếu ứng dụng làm theo kiểu “đọc số lượng, trừ trong code, ghi số mới”, database có thể nhận hai lệnh SET stock = 90. Cả hai lệnh đều hợp lệ. Không có query lỗi để cảnh báo bạn. Sai lệch nằm trong điều mà ứng dụng tưởng hai lần ghi ấy đại diện.
Đây là lost update. Ta sẽ dựng một lịch thực thi cụ thể, rồi đổi đúng cách ghi dữ liệu để thấy khác biệt. Không cần bắn hàng nghìn request và hy vọng race condition xuất hiện.
“Vậy vấn đề không phải database bỏ qua lock,” Ben nói. “Nó nhận một giá trị em tính từ trước.”
Tom gật đầu. “Mình xem khoảng trống giữa lần đọc và lần ghi. Lock ở cuối không tự bảo vệ được phép tính đã diễn ra bên ngoài.”
Request A đọc 100. B cũng đọc 100 trước khi A ghi. A tính 90 rồi commit. B mang kết quả đã tính từ trước đến ghi 90. Dữ liệu của A bị ghi đè về mặt ý nghĩa nghiệp vụ, dù B không hề ghi một giá trị “cũ” theo thời gian commit.
-- A và B đã đọc cùng stock = 100
UPDATE inventory SET stock = 90 WHERE id = 1; -- A
UPDATE inventory SET stock = 90 WHERE id = 1; -- B
Trong lab, hai lần đọc là hai statement đã commit; chương trình giữ lại giá trị như một ứng dụng giữ biến trong bộ nhớ. Lịch này được sắp đặt, không phải số liệu về tần suất race ngoài production. Nhánh đối chiếu dùng hai session thực thi đồng thời.
PostgreSQL mặc định dùng READ COMMITTED. Với UPDATE đụng cùng row, writer sau có thể phải đợi; sau khi writer trước commit, PostgreSQL xét lại điều kiện trên phiên bản row vừa cập nhật. Cơ chế đó hữu ích khi biểu thức cập nhật phụ thuộc vào giá trị hiện tại trong row. Nó không biết số 90 mà client gửi được tính từ số 100 cũ. Xem phần Read Committed trong tài liệu isolation PostgreSQL 17.
Ben sửa sang stock = stock - 10, rồi dừng ở phần trả về. “Nếu hết hàng, query không lỗi nhưng không có row. Em phải đổi cả cách API hiểu kết quả.”
“Đúng. Câu SQL mới chỉ có ích khi lớp gọi nó hiểu được trường hợp không cập nhật,” Tom nói.
Với bài toán trừ kho đơn giản, có thể viết:
UPDATE inventory
SET stock = stock - 10
WHERE id = 1 AND stock >= 10
RETURNING stock;
Một request nhận 90, request còn lại nhận 80. Điều kiện stock >= 10 cũng thuộc statement ấy. Khi không đủ hàng, không row nào được trả về; ứng dụng phải coi đó là kết quả nghiệp vụ thất bại, không phải “query chạy xong nên đặt hàng thành công”.
Thêm CHECK (stock >= 0) là một lớp bảo vệ tốt, nhưng chỉ riêng constraint này không bắt được lost update: 90 vẫn lớn hơn 0. Nó kiểm tra invariant của row, không đếm số đơn hàng đã được chấp nhận.
Còn một chi tiết dễ bỏ sót: trừ kho đúng không có nghĩa toàn bộ đơn hàng đúng. Nếu tạo đơn và trừ kho phải cùng thành công, hãy đặt cả hai trong một transaction và xử lý rollback. Nếu client retry việc tạo đơn sau timeout, cần thêm idempotency, vì hai phép trừ hợp lệ vẫn có thể xuất phát từ cùng một ý định mua.
“Thế những màn hình phải tính trong code thì sao?” Ben hỏi. “Em không thể chuyển hết nghiệp vụ thành một biểu thức SQL.”
Tom không cố bảo vệ một đáp án duy nhất. Anh để Ben so sánh version check với việc giữ lock, bắt đầu từ chỗ nào phát hiện xung đột và ai phải làm lại công việc.
Một màn hình chỉnh sửa có thể đọc nhiều trường, tính giá theo quy tắc, rồi gửi lại cả cấu trúc. Khi đó version check thường dễ kiểm soát hơn:
UPDATE inventory
SET stock = 90, version = version + 1
WHERE id = 1 AND version = 0
RETURNING version;
Người ghi sau dùng version 0 sẽ cập nhật 0 dòng. Ứng dụng phải đọc lại trạng thái, tính lại và retry có giới hạn, hoặc báo xung đột cho người dùng. Chỉ gửi lại nguyên giá trị 90 với version mới sẽ làm mất ý nghĩa của kiểm tra.
SELECT ... FOR UPDATE là một lựa chọn khác khi cần đọc rồi tính trong transaction. Lock phải được giữ trên cùng connection đến khi ghi và commit. Đọc có khóa ở một transaction rồi ghi ở transaction khác không giải quyết được vấn đề. Xem row-level locks của PostgreSQL.
Ben tự thêm ca tồn kho không đủ vào danh sách kiểm tra. Cậu không còn muốn chỉ có một biểu đồ cho thấy nhánh mới đẹp hơn; cậu muốn biết nó có từ chối đúng khi không thể bán hay không. Các assertion dưới đây ghi nhận kết quả thật của những nhánh ấy.
Môi trường là PostgreSQL 17.6, image pin bằng digest, một row tồn kho ban đầu 100. Không có connection đến DB WhyBit hay 40study. Mỗi nhánh reset dữ liệu trước khi chạy.
| Nhánh / điểm đo | Kết quả quan sát |
|---|---|
| Stale read + literal write | 90 |
| Atomic conditional UPDATE | 80 |
| Version check + retry | 80 |
Nhánh optimistic xác nhận lần ghi bằng version cũ trả 0 dòng; sau khi thử lại trên version mới, tồn kho còn 80. Một assertion riêng đặt tồn kho bằng 5 rồi thử trừ 10: statement có điều kiện không cập nhật row nào, tồn kho vẫn 5.
Report của lần chạy và toàn bộ SQL, stdout, stderr đi kèm source tải ở cuối bài. Có thể chạy make reproduce, rồi make verify RUN_ID=<id> để đối chiếu các assertion. Đây là kiểm tra tính đúng; biểu đồ không đo latency hay throughput.
Tom hỏi Ben sẽ trình bày gì với nhóm. “Atomic UPDATE cho phép trừ đơn giản này,” cậu đáp. “Nhưng em không hứa nó tăng throughput. Các request tranh một row vẫn phải đợi nhau. Và tạo đơn phải cùng ranh giới commit.”
Tom nhường cậu phần viết quyết định, rồi cùng kiểm tra các phương án còn lại.
| Cách làm | Hợp với trường hợp nào | Cái giá phải chấp nhận |
|---|---|---|
| Conditional atomic UPDATE | Counter, tồn kho, quota trên một row | Contention vẫn xếp hàng ở row nóng; kiểm tra số row bị ảnh hưởng |
| Version check | Chỉnh sửa dựa trên dữ liệu đã đọc | Phải thiết kế conflict UX hoặc retry; contention cao gây làm lại nhiều |
| SELECT FOR UPDATE | Quy tắc cần đọc nhiều bước | Giữ connection và lock lâu hơn; cần thứ tự khóa nhất quán để giảm deadlock |
| SERIALIZABLE | Invariant liên quan nhiều row và lịch thực thi | Có thể bị serialization failure; phải retry cả transaction |
Nếu invariant là “hai bác sĩ không được cùng nghỉ” trên hai row khác nhau, ví dụ trừ kho này chưa đủ. Đó có thể là write skew, cần mô hình khóa hoặc isolation phù hợp hơn. Cũng đừng kéo HTTP call vào giữa transaction đang giữ lock; thời gian chờ bên ngoài sẽ biến thành thời gian giữ tài nguyên bên trong.
Điểm cần mang về khá cụ thể: khi dữ liệu cũ nằm trong bộ nhớ ứng dụng, hãy xác định điều gì chứng minh nó còn đủ mới để ghi. Phép tính ngay trong database, version check và lock là ba câu trả lời khác nhau cho câu hỏi ấy.
Ben ghi thêm hai câu vào sổ: “SQL thành công chưa chắc nghiệp vụ đúng. Một giá trị từng đọc cần có lý do để còn được tin.” Cậu tự đặt câu hỏi kiểm tra cuối: nếu cùng một đơn bị gửi lại thì sao? Tom đánh dấu câu đó để quay lại ở bài retry; hôm nay họ đang giải quyết hai đơn khác nhau.
Còn trước mắt, trang sản phẩm của Fortex vẫn có lúc kéo cả đám request xuống database. Ben đã chuẩn bị một cách giảm tải bằng Redis.
Chương trước: Một transaction chỉ đọc có thể giữ PostgreSQL phình lên thế nào? · Chương tiếp: Một key hết hạn, 12 request cùng đổ xuống database · Bắt đầu hành trình
PostgreSQL 17.6 · digest pinned · isolated Docker
Tải source Docker Lab (.zip)COMMIT e4881db7ab326ce553bbb0172c44fab421517f08
Đọc kỹ README trước khi chạy. Các lệnh thực nghiệm chuẩn trong Docker Lab:
make setupmake seedmake reproducemake verifymake cleanupMôi trường Docker độc lập, giới hạn tài nguyên và tự động dọn dẹp sau khi chạy. Số liệu trong bài viết là kết quả lần chạy chính thức; mỗi lần chạy mới đều lưu lịch sử riêng.