Chuyển đến nội dung
MINH VO Ghi chép từ công việc
của một kỹ sư tại Việt Nam
Cơ bản10 phút đọc

Read Committed và kết quả thay đổi ở lần SELECT thứ hai

Theo dõi số tồn kho đổi giữa các lần đọc Read Committed, so sánh Repeatable Read và xác định ranh giới snapshot ở truy vấn đầu cùng lúc câu lệnh bắt đầu.

Các thành phần máy chủ màu ô liu và than chì nối nhau trên nền be ấm

Endpoint đọc được mười hai quyển sổ còn hàng, chạy thêm một ít mã ứng dụng rồi thực hiện lại đúng truy vấn đó và nhận bảy. Cả hai lần đọc nằm giữa cùng một BEGIN và COMMIT. Đây có thể là hành vi bình thường của PostgreSQL: Read Committed cho mỗi câu lệnh một góc nhìn mới về dữ liệu đã commit, thay vì cố định góc nhìn cho toàn bộ giao dịch.

Nếu đã biết SELECT và cú pháp giao dịch cơ bản, bạn chỉ cần hai phiên cơ sở dữ liệu để tái hiện sự khác biệt. Ví dụ dùng PostgreSQL 17 và một bảng tồn kho thông thường. Phiên A đọc, phiên B cập nhật. Đây là bài học về mức cô lập, chưa phải dịch vụ giữ hàng hoàn chỉnh. Hãy chạy trong cơ sở dữ liệu dùng thử, thực hiện từng bước riêng và giữ các phiên mở ở những chỗ được chỉ dẫn.

Chỉ để một giá trị thay đổi trong thí nghiệm

Chạy phần thiết lập một lần, khi chưa có giao dịch đang mở:

CREATE TABLE inventory (
  sku text PRIMARY KEY,
  available integer NOT NULL CHECK (available >= 0)
);
INSERT INTO inventory VALUES ('notebook', 12);

Số lượng ban đầu là mười hai. Một lần trừ năm món thành công sẽ để lại bảy. Khóa chính loại bỏ sự mơ hồ về hàng mà truy vấn cần đọc, còn ràng buộc check ngăn lưu số lượng âm. Không ràng buộc nào trong hai ràng buộc này hứa rằng hai lần đọc riêng luôn trả cùng một giá trị.

Tài liệu mức cô lập của PostgreSQL nêu Read Committed là mức mặc định. Ví dụ vẫn chỉ rõ mức này để thay đổi cấu hình cục bộ không âm thầm làm thí nghiệm khác đi. “Committed” mô tả thay đổi đồng thời nào mà một câu lệnh được thấy. Nó không có nghĩa mọi câu lệnh trong giao dịch bao ngoài dùng chung một snapshot.

Snapshot là góc nhìn cơ sở dữ liệu dùng để quyết định phiên bản hàng nào được nhìn thấy. Với các truy vấn đọc thông thường bên dưới, ranh giới liên quan là lúc từng truy vấn bắt đầu. Cần phân biệt thời điểm ấy với lúc ứng dụng nhận rồi sử dụng kết quả.

Tách cập nhật chưa commit khỏi cập nhật đã commit

Trong phiên A, mở giao dịch và đọc:

BEGIN ISOLATION LEVEL READ COMMITTED READ ONLY;
SELECT available FROM inventory WHERE sku = 'notebook';

Kết quả là mười hai. Giữ giao dịch A mở. Trong B, chạy:

BEGIN;
UPDATE inventory
SET available = available - 5
WHERE sku = 'notebook' AND available >= 5
RETURNING available;

Kết quả RETURNING của B là bảy. Chưa commit B. Quay lại A, chạy đúng truy vấn sau:

SELECT available FROM inventory WHERE sku = 'notebook';

A vẫn thấy mười hai. B đã thay đổi trạng thái trong giao dịch của mình nhưng chưa làm thay đổi đó hiển thị cho A. Đây không phải dirty read, tức đọc dữ liệu chưa commit từ giao dịch khác. Câu SELECT thông thường của A cũng hoàn tất trong khi B còn giữ cập nhật hàng chưa kết thúc; nó đọc phiên bản cũ đang hiển thị thay vì chờ để lấy giá trị tạm thời của B.

Bây giờ chạy trong B:

COMMIT;

Lặp lại truy vấn của A. Nó trả bảy dù A chưa kết thúc giao dịch riêng. Đây là non-repeatable read, hay lần đọc không lặp lại được cùng kết quả: cùng hàng và cùng truy vấn đã cho hai giá trị đã commit khác nhau trong một giao dịch. Kết thúc A bằng COMMIT trước khi sang thí nghiệm tiếp theo.

Phiên A đọc mười hai trước và trong lúc B giữ khoản trừ chưa công bố, rồi đọc bảy sau khi B commit; giao dịch A vẫn mở xuyên suốt ba lần đọc.Mở ảnh kích thước đầy đủ ↗

Sự kiện làm kết quả sau thay đổi là B commit trước khi câu lệnh tiếp theo bắt đầu. Ranh giới giao dịch A không cố định các snapshot Read Committed của nó.

Nếu lặp lại thí nghiệm nhưng rollback B, A tiếp tục thấy mười hai. Chờ lâu hơn không làm lộ một cập nhật đã bị hủy. PostgreSQL còn xử lý READ UNCOMMITTED như Read Committed, nên đổi sang tên đó cũng không làm A thấy bảy chưa commit của B trong cơ sở dữ liệu này. Với hệ quản trị khác, cần xem tài liệu và kiểm tra riêng.

Hướng dẫn ngày 14 tháng 3 năm 2025 của Dan trên dansvetlov.me về các bất thường cô lập trong PostgreSQL dùng một lần đọc đặt chỗ nhỏ được lặp lại để làm rõ cùng sự khác biệt này. Các phần về dirty read và snapshot bổ trợ tốt cho ví dụ tồn kho ở đây. Những con số trong bài đến từ fixture cục bộ; kết luận được giới hạn ở hành vi PostgreSQL đã nêu.

Chỉ đọc và đọc lặp lại được là hai lựa chọn khác nhau

A cố ý dùng READ ONLY. Thiết lập này cấm cập nhật bảng tồn kho thông thường ở đây; nó không cố định những gì A được quan sát. Cả mười hai ban đầu lẫn bảy về sau đều tương thích với một giao dịch chỉ đọc. SET TRANSACTION mô tả chế độ truy cập và mức cô lập là hai đặc tính giao dịch riêng.

Sự khác biệt ảnh hưởng cách tổ chức ứng dụng. Giả sử một hàm trợ giúp đọc số lượng để đưa vào phản hồi, còn hàm thứ hai tự đọc lại để chọn thông báo. Bọc cả hai bằng Read Committed không buộc câu trả lời của chúng khớp nhau. Nếu cần dùng cùng một giá trị đã quan sát, hãy truyền kết quả đầu giữa hai hàm. Cách đó tránh một lần quan sát mới; nó không làm kết quả đầu luôn mới mãi.

Cũng cần kiểm tra ranh giới kết nối thực tế. Hai truy vấn gửi qua hai kết nối lấy riêng từ pool không thành một giao dịch chỉ vì cùng nằm trong một HTTP handler. Bài thực hành SQL giữ A trên một phiên duy nhất để việc quản lý kết nối không thể là nguyên nhân khiến kết quả đổi.

Chọn Repeatable Read khi nhiều lần đọc cần chung góc nhìn

Sau khi hai giao dịch trước đã đóng, khôi phục dữ liệu trong B:

UPDATE inventory SET available = 12 WHERE sku = 'notebook';

Trong A, mở giao dịch mới với mức cô lập khác:

BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT available FROM inventory WHERE sku = 'notebook';

A đọc mười hai. Trong B, chạy lại khối trừ hàng trước đó rồi commit. Chạy lại truy vấn đọc thông thường của A: nó vẫn trả mười hai. Snapshot của A có trước lần commit của B. Một phiên độc lập mới thấy bảy; A cũng thấy bảy sau khi kết thúc giao dịch rồi gửi truy vấn mới.

Số lượng mười hai không phải một lần giữ hàng. B đã hoàn tất khoản trừ trong khi giao dịch đọc của A còn mở. Repeatable Read cung cấp góc nhìn ổn định cho các lần đọc này, không trao quyền sở hữu số hàng tồn. Tài liệu khóa của PostgreSQL phân biệt truy vấn đọc thông thường với thao tác lấy những khóa hàng xung đột.

Góc nhìn ổn định hữu ích khi nhiều truy vấn phải mô tả cùng một trạng thái cơ sở dữ liệu. Nó không thay thế tốt cho số hàng thực sự còn sẵn tại thời điểm mua sau đó. Hãy giới hạn thời gian giữ loại giao dịch này: mọi kết quả về sau trong thí nghiệm chỉ đọc có thể vẫn là mười hai dù số lượng đã commit mới hơn tiếp tục thay đổi.

Chỉ BEGIN chưa tạo snapshot này

Đóng A, đặt lại số lượng về mười hai trong B, rồi chỉ chạy câu sau ở A:

BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;

Trước khi A chạy bất kỳ truy vấn nào, để B trừ năm rồi commit. Truy vấn tồn kho đầu tiên của A lúc này trả bảy. Repeatable Read dùng snapshot được thiết lập bởi truy vấn hoặc câu sửa dữ liệu đầu tiên, không phải chỉ bởi việc BEGIN đã đến máy chủ. Ngay cả một truy vấn chẩn đoán tưởng như vô hại cũng có thể đẩy ranh giới ấy sớm hơn, nên đừng chèn nó vào khi tái hiện thứ tự này.

Nếu B tiếp tục trừ năm rồi commit, lần đọc sau của A vẫn là bảy. Sau khi A commit và đọc mới, nó thấy hai. Thứ tự đó phân biệt “giao dịch đã bắt đầu” với “giao dịch đã lấy snapshot dùng cho các lần đọc”. Phải chọn mức cô lập trước truy vấn đầu; đổi mức sau đó sẽ bị từ chối.

Một giao dịch Repeatable Read truy vấn trước khi B commit và giữ mười hai; giao dịch khác BEGIN trước lần commit của B nhưng đợi tới sau đó mới truy vấn nên giữ bảy.Mở ảnh kích thước đầy đủ ↗

Truy vấn đầu là ranh giới snapshot có ý nghĩa trong các giao dịch chỉ đọc này. BEGIN sớm hơn ở hàng thứ hai không giữ lại mười hai.

Kết quả có thể đã cũ hơn ngay khi đến nơi

Để quan sát riêng điều này, đóng các giao dịch đang mở rồi đặt lại số lượng về mười hai. Cho B chuẩn bị khoản trừ năm nhưng chưa commit. Trên phiên thứ ba ở chế độ autocommit, chạy:

SELECT available, pg_sleep(2)
FROM inventory WHERE sku = 'notebook';

Commit B trong lúc truy vấn đó đang ngủ. Câu lệnh có độ trễ vẫn trả mười hai; truy vấn thông thường tiếp theo của nó trả bảy. Khoảng ngủ chỉ giúp làm lộ thứ tự thực hiện, không phải phép đo hiệu năng hay truy vấn đề xuất cho sản phẩm. Kiểm tra cục bộ đã quan sát truy vấn thực sự ở trạng thái ngủ trước khi cho B commit.

Đây vẫn là hệ quả của quy tắc snapshot theo câu lệnh. Read Committed không hứa giá trị mới nhất tại lúc phản hồi tới nơi. Thời gian truyền mạng và xử lý ứng dụng còn có thể làm giá trị đã quan sát cũ thêm. Vì thế, nhãn số hàng trong trình duyệt nên được hiểu là một quan sát, không phải bằng chứng rằng thao tác ghi sau đó chắc chắn thành công.

Xác định thao tác thực sự cần gì

Khi cần một quan sát hiện thời ở từng câu lệnh, Read Committed có thể phù hợp. Khi nhiều truy vấn đọc thông thường cần chung góc nhìn cơ sở dữ liệu, hãy chọn rõ mức cô lập phù hợp và giữ cùng kết nối. Với khoản trừ, đặt điều kiện chấp nhận vào đường ghi. Câu UPDATE trong bài yêu cầu còn ít nhất năm món và không trả hàng nào nếu điều kiện thất bại; mã sử dụng nó phải kiểm tra kết quả ấy.

Tài liệu SELECT mô tả các mệnh đề khóa hàng là hành vi bổ sung. Thêm FOR UPDATE thay đổi sự tranh chấp và trách nhiệm của giao dịch, nên không phải cách trung tính để làm truy vấn báo cáo “nhất quán hơn”. Tương tự, Repeatable Read chưa phải giao thức đặt hàng đồng thời hoàn chỉnh. Ví dụ không lưu đơn hàng, thời hạn giữ, danh tính khách hay khóa chống lặp.

Để tự kiểm tra, hãy ghi kết quả dự kiến của A ở ba thời điểm: khi khoản trừ B còn mở, sau khi B commit và sau khi A kết thúc giao dịch. Chạy chuỗi ấy với Read Committed rồi Repeatable Read. Sau đó trì hoãn truy vấn đầu của A tới sau khi B commit. Giải thích từng con số bằng ranh giới snapshot trước khi đổi mức cô lập trong ứng dụng.

Nguồn tham khảo & đọc thêm

  1. PostgreSQL 17: transaction isolation
  2. PostgreSQL 17: SET TRANSACTION
  3. PostgreSQL 17: SELECT
  4. PostgreSQL 17: explicit locking
  5. Dan (dansvetlov.me): A Practical Guide to Taming Postgres Isolation Anomalies (14 March 2025)
← Về trang bài viết

Hình minh họa

100%