Giới thiệu
Khi một ứng dụng backend phục vụ nhiều người dùng cùng lúc, database trở thành chiến trường trung tâm cho tính nhất quán. Hai người cùng đặt chỗ ngồi cuối cùng, một xác nhận thanh toán chạy đua với một yêu cầu hoàn tiền, một báo cáo đọc dữ liệu trong khi một thao tác cập nhật đang diễn ra — từng tình huống này đều chạm đến cơ chế transaction và concurrency của PostgreSQL. Làm sai sẽ dẫn đến tham nhũng dữ liệu âm thầm, đơn hàng bị trùng lặp, hay số dư không hợp lệ.
Bài học này tập trung vào những gì một lập trình viên backend cần biết để lý giải về hành vi đồng thời: cách PostgreSQL gói gọn công việc trong transaction, cách isolation levels thay đổi khả năng nhìn thấy giữa các transaction đồng thời, cách MVCC cho phép reader và writer không chặn nhau, và row-level locks thực sự can thiệp ở đâu. Mục tiêu không phải đào sâu vào quản trị database, mà là mô hình tư duy thực tiễn cần thiết khi một báo cáo lỗi production ghi rằng "hai request đã làm điều giống nhau" hay "dashboard hiển thị số liệu cũ".
ACID và Ranh giới Transaction
Một transaction trong PostgreSQL là một đơn vị công việc hoặc commit toàn bộ hoặc rollback toàn bộ. Bốn đảm bảo — Atomicity, Consistency, Isolation, Durability — mô tả những gì database hứa hẹn nếu bạn gói các câu lệnh trong BEGIN và COMMIT.
Trong backend, hình thái điển hình trông như sau:
Nếu UPDATE thứ hai thất bại, UPDATE thứ nhất cũng sẽ được rollback. Không bao giờ có trạng thái mà tiền đã bị trừ nhưng chưa được cộng. Atomicity ở đây không phải là thứ có thì tốt — chính nó là thứ giữ cho sổ sách cân bằng.
Một điểm thực tiễn cho lập trình viên backend: PostgreSQL coi mỗi câu lệnh đơn lẻ là một implicit transaction riêng trừ khi bạn BEGIN một cách tường minh. Vì vậy, nếu bạn thực thi hai câu lệnh UPDATE riêng biệt mà không có khối transaction, mỗi câu sẽ commit độc lập. Đây là nguyên nhân gốc rễ của nhiều bug dạng "áp dụng một phần" trong code ứng dụng giao tiếp với database thông qua connection pooler hoặc ORM với cài đặt autocommit mặc định.
Các mức cô lập trong PostgreSQL
SQL định nghĩa bốn mức cô lập: READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ và SERIALIZABLE. PostgreSQL triển khai ba mức trong số đó thành các hành vi thực sự khác biệt — READ UNCOMMITTED hoạt động giống READ COMMITTED vì cách MVCC vận hành bên trong.
Mức bạn chọn quyết định transaction được phép nhìn thấy gì từ các transaction chạy đồng thời. READ COMMITTED là mặc định và thường là điều bạn cần: mỗi câu lệnh nhìn thấy dữ liệu đã được commit trước thời điểm câu lệnh đó bắt đầu. Nếu một transaction khác commit một bản cập nhật giữa hai lần SELECT của bạn, lần SELECT thứ hai sẽ thấy giá trị mới — điều này có thể khiến những người kỳ vọng một viewổn định cảm thấy bất ngờ.
REPEATABLE READ khóa snapshot vào thời điểm transaction bắt đầu, không phải mỗi câu lệnh. SERIALIZABLE đi xa hơn một bước: nó phát hiện các vi phạm khả tuần tự (serializability violations) và hủy một trong các transaction xung đột bằng lỗi serialization failure mà ứng dụng phải sẵn sàng thử lại.

MVCC và khóa cấp hàng (Row-Level Locks)
PostgreSQL sử dụng Multi-Version Concurrency Control. Khi bạn UPDATE một hàng, database không ghi đè phiên bản cũ tại chỗ. Nó ghi một phiên bản hàng mới và đánh dấu phiên bản cũ là đã chết (dead). Bên đọc thấy phiên bản đã được commit tại thời điểm snapshot của họ được chụp. Đó là lý do một truy vấn báo cáo chạy lâu không chặn các lệnh ghi đến, và ngược lại — chúng hoạt động trên các phiên bản hàng khác nhau.
Nhưng MVCC không loại bỏ hoàn toàn việc chặn. Khi hai transaction cùng UPDATE một hàng, PostgreSQL cần một người thắng, và người thua sẽ chờ trên khóa cấp hàng cho đến khi transaction đầu tiên commit hoặc rollback. SELECT FOR UPDATE là cách tường minh để chiếm khóa đó trước khi thực hiện business logic phụ thuộc vào giá trị hiện tại.
Mẫu SELECT FOR UPDATE sau đó là một UPDATE có điều kiện là cách sửa chuẩn cho race condition cổ điển: hai request cùng đọc giá trị, cả hai đều thấy an toàn, và cả hai đều giảm nó. Nếu không có khóa tường minh, cả hai transaction đọc cùng số lượng tồn kho ban đầu và bạn sẽ bán vượt tồn kho (oversell).
Các lỗi thường gặp và bài học thực tế
Ba kiểu lỗi xuất hiện liên tục trong code backend. Thứ nhất, transaction chạy lâu giữ snapshot và ngăn quá trình dọn tuple, khiến bảng phình to và hiệu năng suy giảm theo thời gian — hãy đóng transaction kịp thời, không mở một transaction lúc đầu HTTP request rồi để mở xuyên suốt các lệnh gọi downstream. Thứ hai, deadlock xảy ra khi hai transaction chiếm khóa theo thứ tự ngược nhau; hãy giữ thứ tự nhất quán trong toàn bộ codebase, hoặc dựa vào PostgreSQL để phát hiện và hủy một trong hai rồi thử lại. Thứ ba, bỏ qua lỗi serialization failure dưới SERIALIZABLE dẫn đến hỏng dữ liệu — database đang cho bạn biết các thao tác này thực sự không an toàn khi chạy song song, và logic thử lại của bạn là một phần của hợp đồng, không phải tùy chọn.
Tư duy thực tế: hãy coi việc chọn mức cô lập và khóa là một phần của thiết kế ứng dụng, không phải chi tiết tinh chỉnh database. Mặc định READ COMMITTED kết hợp với SELECT FOR UPDATE tường minh quanh các đoạn quan trọng giải quyết sạch sẽ hầu hết bug về đồng thời. Hãy dùng SERIALIZABLE khi tính đúng đắn quan trọng hơn throughput và bạn có thể xử lý các lỗi phát sinh.
Tóm tắt
Giao dịch (transaction) gói nhiều câu lệnh SQL vào một đơn vị không thể phân chia (atomic). Mức cô lập (isolation level) kiểm soát việc các giao dịch đồng thời có thể thấy gì trong phần công việc chưa commit của nhau. MVCC cho phép PostgreSQL thực hiện đọc và ghi không chặn nhau bằng cách lưu giữ nhiều phiên bản của cùng một dòng, trong khi khóa cấp dòng (row-level lock) giải quyết xung đột khi hai giao dịch cùng nhắm vào một dòng. Đối với lập trình viên backend, các công cụ cụ thể bao gồm: kỷ luật sử dụng BEGIN/COMMIT, mẫu SELECT FOR UPDATE cho các thao tác cập nhật dễ xảy ra race condition, và thứ tự khóa nhất quán để tránh deadlock. Hãy chọn mức cô lập một cách có chủ đích — mức mặc định phù hợp với hầu hết trường hợp, nhưng REPEATABLE READ và SERIALIZABLE tồn tại cho những tình huống mà hành vi mặc định chưa đủ chặt chẽ.
Bài kiểm tra cuối bài
Ôn lại kiến thức, thử sức và nhận góp ý cho bài làm.