PostgreSQL Transaction Isolation trong Production: chọn đúng Read Committed, Repeatable Read hay Serializable

Sơ đồ chọn transaction isolation phù hợp trong PostgreSQL production
Transaction isolation trong PostgreSQL là bài toán chọn đúng primitive cho đúng invariant, không phải cứ tăng lên mức cao nhất là xong.

Nhiều backend team chỉ chạm tới transaction isolation khi đã có incident: số lượng hàng tồn bị âm, cùng một request business chạy hai lần tạo trạng thái mâu thuẫn, job worker đọc một snapshot khác với web request, hoặc deadlock và serialization failure tăng đột biến sau khi siết consistency. Lúc đó cuộc tranh luận rất dễ đi sai hướng: “cứ tăng lên Serializable là an toàn nhất” hoặc “PostgreSQL có MVCC rồi nên khỏi cần nghĩ thêm”.

Cả hai cách nghĩ đó đều nguy hiểm.

Trong PostgreSQL, isolation level không chỉ là lựa chọn học thuật. Nó ảnh hưởng trực tiếp tới:

  • loại anomaly nào còn có thể xảy ra;
  • số lần ứng dụng phải retry;
  • khả năng scale khi có workload ghi đồng thời;
  • cách anh thiết kế lock, constraint và flow business;
  • độ khó của incident debug trong production.

Bài này nhìn transaction isolation dưới góc backend/database engineer đang vận hành hệ thống thật, không phải giải thích kiểu textbook. Mục tiêu là trả lời rõ:

  • Read Committed, Repeatable Read, Serializable trong PostgreSQL thực sự bảo vệ được gì;
  • vì sao MVCC giúp đọc không chặn ghi nhưng không tự động loại bỏ mọi anomaly business;
  • khi nào nên tăng isolation level, khi nào nên dùng unique constraint, SELECT ... FOR UPDATE, optimistic concurrency hoặc redesign flow;
  • cách giảm deadlock và serialization retry cost trong production.

Isolation level không phải “mức an toàn chung”, mà là hợp đồng về anomaly chấp nhận được

SQL standard mô tả isolation level bằng những anomaly như dirty read, nonrepeatable read, phantom read và serialization anomaly. PostgreSQL hiện thực chúng trên nền MVCC, nên cách hành xử thực tế có vài điểm quan trọng mà nhiều người nhớ nhầm:

  • Read Uncommitted trong PostgreSQL thực chất hành xử như Read Committed;
  • Repeatable Read của PostgreSQL mạnh hơn nhiều người tưởng, vì nó không cho phantom read theo kiểu chuẩn thông thường;
  • Serializable trong PostgreSQL không khóa mọi thứ một cách ngây thơ, mà dùng Serializable Snapshot Isolation (SSI) để phát hiện conflict và buộc một số transaction phải abort/retry.

Điểm cần nhớ là isolation level không trả lời câu hỏi “database có đúng không”, mà trả lời câu hỏi database cho phép ứng dụng quan sát và commit những kiểu tương tác đồng thời nào.

Nếu business invariant của anh yêu cầu “không bao giờ có hai bác sĩ cùng trực vượt quota”, hoặc “không được oversell inventory theo một điều kiện tổ hợp”, thì việc chọn isolation level là một phần của domain correctness, không chỉ là config database.

MVCC giúp đọc và ghi ít chặn nhau hơn, nhưng snapshot đúng chưa chắc business đã đúng

Minh họa PostgreSQL MVCC với nhiều transaction nhìn các snapshot khác nhau của cùng business state
MVCC giúp concurrency tốt hơn, nhưng mỗi transaction chỉ thấy một lát cắt dữ liệu; nếu invariant nằm trên nhiều bước hoặc nhiều row, snapshot nhất quán vẫn có thể cho ra trạng thái business sai.

PostgreSQL dùng MVCC để mỗi statement hoặc transaction nhìn một snapshot dữ liệu tại một thời điểm. Lợi ích rất rõ:

  • đọc thường không block ghi;
  • ghi thường không block đọc;
  • concurrency tổng thể tốt hơn kiểu lock-based thô.

Nhưng chính vì mỗi transaction có snapshot riêng, ứng dụng có thể rơi vào những tình huống business rất khó chịu nếu giả định “mình đọc đúng thì commit cũng sẽ đúng”.

Ví dụ kinh điển:

  • hai transaction cùng kiểm tra “còn ít nhất 1 on-call engineer chưa bị phân công”;
  • cả hai cùng thấy điều kiện hợp lệ trên snapshot của mình;
  • cả hai cùng update ở hai row khác nhau;
  • cuối cùng hệ thống vi phạm invariant tổng thể dù mỗi transaction riêng lẻ đều hợp logic.

Đây là kiểu bug mà log application thường không tố cáo rõ. Mỗi request nhìn như chạy thành công. Chỉ business state cuối cùng là sai.

Read Committed: mặc định hợp lý cho nhiều API, nhưng đừng tưởng nó bảo vệ logic nhiều bước

Read Committed là isolation mặc định trong PostgreSQL. Mỗi câu lệnh SELECT nhìn thấy dữ liệu đã commit trước khi câu lệnh đó bắt đầu. Điều này khiến nó phù hợp với nhiều workload CRUD, API đọc-ghi tương đối đơn giản, queue consumer cơ bản, hoặc flow mà invariant chính đã được neo bằng constraint cứng.

Khi Read Committed thường là đủ

  • Tạo bản ghi mới và dựa vào UNIQUE/EXCLUDE constraint để chặn duplicate.
  • Update một row bằng primary key, nơi correctness nằm ở row đó chứ không phụ thuộc snapshot nhiều bảng.
  • Endpoint đọc danh sách hoặc dashboard mà chấp nhận dữ liệu có thể hơi thay đổi giữa hai query.
  • Flow idempotent đã có khóa business key hoặc dedupe table ở database.

Khi Read Committed dễ gây tự tin sai

Read Committed không đảm bảo các câu lệnh trong cùng transaction cùng nhìn một snapshot. Nghĩa là:

  • query đầu và query sau có thể thấy dữ liệu khác nhau;
  • logic “đọc trước rồi quyết định update sau” có thể bị race nếu nhiều transaction làm cùng lúc;
  • check-then-act pattern rất dễ hở nếu không có row lock hoặc constraint đủ mạnh.

Một ví dụ phổ biến là booking/inventory:

  1. SELECT available_qty FROM inventory WHERE sku = ...
  2. nếu còn hàng thì UPDATE inventory SET available_qty = available_qty - 1

Nếu nhiều request cùng đi vào, Read Committed không tự ngăn oversell chỉ vì anh đã bọc transaction. Đúng hơn, anh cần:

  • UPDATE ... WHERE available_qty > 0 rồi kiểm tra row count;
  • hoặc row lock SELECT ... FOR UPDATE;
  • hoặc constraint/business model khác để neo invariant vào database.

Nói ngắn gọn: transaction không thay thế cho thiết kế write path đúng.

Repeatable Read: mạnh hơn cho snapshot nhiều bước, nhưng vẫn chưa phải “khỏi nghĩ nữa”

Trong PostgreSQL, Repeatable Read đảm bảo transaction nhìn cùng một snapshot trong suốt đời transaction. Điều này cực hữu ích khi một flow cần nhiều bước đọc logic dựa trên cùng trạng thái dữ liệu ban đầu.

Lúc nào Repeatable Read hữu ích

  • Một request phải đọc nhiều bảng rồi tính quyết định business từ cùng một snapshot.
  • Một job reconciliation/reporting cần consistency nội bộ giữa nhiều query trong một transaction đọc.
  • Một flow backend cần tránh việc query sau trong cùng transaction thấy dữ liệu mới hơn query trước và ra quyết định lệch.

Nhưng Repeatable Read vẫn có thể cho ra bug business nào?

Điểm nhiều team bỏ sót là snapshot nhất quán không đồng nghĩa invariant tổng thể luôn được bảo vệ. Một số pattern như write skew vẫn có thể xảy ra nếu hai transaction:

  • cùng đọc điều kiện trên snapshot cũ;
  • mỗi transaction update các row khác nhau;
  • không có lock hay constraint nào buộc chúng xung đột trực tiếp.

Khi đó mỗi transaction đều commit được, nhưng trạng thái cuối cùng vi phạm logic business.

Đây là chỗ nhiều người nâng từ Read Committed lên Repeatable Read rồi tưởng đã “fix race condition”, trong khi thực tế mới chỉ giảm một lớp anomaly quan sát snapshot.

Serializable: đúng nhất về mặt logic tổng thể, nhưng chi phí thật nằm ở abort và retry

So sánh trade-off giữa Read Committed, Repeatable Read và Serializable trong PostgreSQL production
Isolation level cao hơn có thể chặn nhiều anomaly hơn, nhưng đổi lại là retry cost, latency tail và áp lực vận hành lớn hơn nếu transaction dài hoặc contention cao.

Serializable là mức bảo đảm mạnh nhất. Mục tiêu của nó là mọi transaction commit thành công phải tương thích với một thứ tự chạy tuần tự nào đó. Trong PostgreSQL, điều này được thực hiện bằng SSI: hệ thống theo dõi dependency nguy hiểm giữa các transaction đồng thời và có thể buộc một transaction abort với lỗi serialization failure.

Khi nào Serializable đáng giá

  • Invariant business trải trên nhiều row/bảng và rất khó neo bằng constraint đơn giản.
  • Hệ thống tài chính, quota, reservation, capacity planning, matching engine nhẹ hoặc approval workflow có correctness cao hơn throughput thuần.
  • Anh đã debug đủ nhiều race condition kiểu write skew và muốn database trở thành lớp bảo vệ cuối cùng.

Cái giá thật sự của Serializable

Sai lầm phổ biến là nghĩ chi phí chính của Serializable là “chậm hơn một chút”. Trên production, chi phí đau hơn thường là:

  • transaction bị abort và phải retry;
  • latency tail tăng vì retry cascade;
  • job worker hoặc API client không được viết retry đúng sẽ biến lỗi transient thành lỗi business;
  • transaction dài hoặc đọc quá nhiều tập dữ liệu làm conflict graph dày hơn.

Vì vậy, nếu dùng Serializable, ứng dụng phải được thiết kế với một vài nguyên tắc rất thực dụng:

  • transaction càng ngắn càng tốt;
  • mọi side effect ngoài database phải ra ngoài transaction hoặc idempotent rõ ràng;
  • retry phải có bounded budget và observability;
  • cần phân biệt lỗi business thật với serialization failure để client không xử lý nhầm.

Đừng tăng isolation level để chữa thứ mà constraint hoặc write pattern nên chữa

Rất nhiều bug concurrency không nên giải bằng cách tăng isolation toàn flow. Cách ổn hơn thường là neo invariant bằng đúng primitive gần nhất với dữ liệu.

1. Unique constraint hoặc business key

Nếu vấn đề là duplicate logical entity như:

  • cùng order được tạo hai lần;
  • cùng webhook event được apply hai lần;
  • cùng user đăng ký hai lần vào một slot duy nhất;

thì UNIQUE constraint hoặc composite unique key thường rẻ, rõ và bền hơn nhiều so với nâng isolation cả transaction.

2. SELECT ... FOR UPDATE hoặc FOR NO KEY UPDATE

Nếu correctness xoay quanh một row hoặc một tập row cụ thể sẽ bị sửa ngay sau khi đọc, explicit row lock thường dễ hiểu hơn.

Ví dụ:

  • đọc số dư rồi ghi số dư mới;
  • đọc trạng thái order rồi chuyển state machine;
  • phân phối một job khỏi queue để chỉ một worker lấy được.

Điểm quan trọng là lock phải phản ánh đúng resource contention thật, không phải lock cho có.

3. Optimistic concurrency với version column

Nếu xung đột ít nhưng anh vẫn muốn phát hiện lost update rõ ràng, optimistic concurrency (version, updated_at, etag) thường hợp lý hơn việc ép transaction nặng hơn toàn cục.

4. Redesign flow business

Nếu mỗi request phải đọc quá nhiều rồi mới quyết định ghi, đôi khi vấn đề không nằm ở isolation mà ở bản thân workflow. Có thể cần:

  • gom invariant về ít aggregate hơn;
  • tách synchronous path và asynchronous reconciliation;
  • dùng reservation/hold model thay vì final commit ngay;
  • chuyển một số logic sang append-only event + validator.

Deadlock và isolation: tăng consistency sai chỗ có thể đổi bug race thành bug chờ khóa

Minh họa lock ordering khác nhau dẫn tới deadlock và retry trong PostgreSQL backend production
Khi nhiều transaction lấy lock khác thứ tự hoặc giữ transaction quá lâu, team có thể đổi race condition hiếm thành lock wait và deadlock xảy ra thường xuyên hơn.

Khi team bắt đầu thêm lock hoặc nâng isolation, một loại incident khác thường xuất hiện: deadlock hoặc lock wait kéo dài.

Deadlock không nhất thiết đến từ isolation level cao, nhưng thường bộc lộ rõ hơn khi nhiều transaction:

  • lấy lock theo thứ tự khác nhau;
  • giữ transaction quá lâu vì có thêm call ra network hoặc logic CPU nặng bên trong;
  • scan và lock tập row rộng hơn mức cần thiết.

Một vài quy tắc production rất đáng giữ:

  • lock tài nguyên theo thứ tự nhất quán giữa các code path;
  • không để HTTP call, RPC, publish message không idempotent nằm trong transaction dài;
  • transaction làm đúng phần đọc/ghi cần atomic, còn side effect khác nên tách ra;
  • log lock wait, deadlock count, serialization failure rate theo endpoint/job type.

Nếu không có observability này, team rất dễ chuyển từ một bug hiếm sang một loại sự cố mới khó nhìn hơn.

Cách chọn isolation level theo loại workload backend thực chiến

Không có một mức đúng cho mọi nơi. Cách tốt hơn là map theo loại workload.

CRUD/API thông thường

Thường bắt đầu với Read Committed, kết hợp:

  • constraint đúng;
  • statement update an toàn;
  • row lock khi thật sự cần;
  • idempotency key cho write nhạy cảm.

Reporting hoặc flow đọc nhiều bước cần snapshot ổn định

Repeatable Read hợp lý hơn nếu anh cần nhiều query trong cùng transaction cùng nhìn một ảnh chụp dữ liệu nhất quán.

Reservation/quota/approval logic có invariant tổ hợp khó

Cân nhắc Serializable nếu invariant không dễ neo bằng unique/row lock đơn giản, và đội ngũ sẵn sàng đầu tư retry + observability.

Worker/queue processing

Phần lớn queue consumer không cần tăng isolation toàn cục. Thường sẽ hiệu quả hơn nếu dùng:

  • row locking kiểu FOR UPDATE SKIP LOCKED;
  • idempotent consumer pattern;
  • dedupe table/business key;
  • transaction ngắn quanh phần claim + commit state.

Playbook debug khi nghi transaction isolation là gốc vấn đề

Khi có incident concurrency, đừng mở đầu bằng việc đổi config isolation trong production. Nên đi theo thứ tự:

  1. Viết lại invariant business bằng một câu rất cụ thể: “điều gì tuyệt đối không được xảy ra?”
  2. Xác định code path nào đang cùng chạm tới invariant đó.
  3. Kiểm tra primitive hiện có: constraint, row lock, optimistic version, retry, idempotency.
  4. Mô phỏng hai transaction cạnh tranh và xem anomaly nào thật sự xảy ra.
  5. Chỉ sau đó mới quyết định cần tăng isolation, thêm lock hay redesign write path.

Lý do là nhiều incident bị dán nhãn “cần Serializable”, trong khi gốc lại là:

  • thiếu unique key;
  • update không kiểm row count;
  • side effect ngoài DB nằm trong transaction;
  • retry client không idempotent;
  • lock order không nhất quán.

Checklist production để dùng isolation level mà không tự bẫy mình

  • Viết rõ invariant business nào đang được database bảo vệ, invariant nào đang để application bảo vệ.
  • Không bọc transaction cho có; transaction càng dài càng dễ gây lock wait và retry tốn kém.
  • Với Serializable, luôn coi retry là đường chính thức chứ không phải case ngoại lệ hiếm.
  • Theo dõi serialization failure, deadlock, lock wait, transaction age và retry success rate.
  • Không đưa network call hoặc side effect không idempotent vào giữa transaction khi tránh được.
  • Ưu tiên constraint/lock/business key cho các invariant cục bộ trước khi tăng isolation cả flow.
  • Review mọi pattern check-then-act vì đây là nơi race condition hay chui vào nhất.

Đọc tiếp trong cluster PostgreSQL / backend production

Kết luận

Transaction isolation trong PostgreSQL không phải nút “an toàn hơn” để bật dần theo cảm giác. Read Committed, Repeatable ReadSerializable là ba công cụ với trade-off rất khác nhau về anomaly, throughput, retry cost và độ phức tạp vận hành.

Với phần lớn backend production, quyết định đúng không phải là chọn mức cao nhất, mà là chọn primitive đúng cho invariant đúng: constraint khi cần uniqueness, row lock khi contention nằm trên cùng resource, optimistic concurrency khi conflict hiếm, và Serializable khi thật sự cần bảo vệ logic tổng thể nhiều bước.

Nếu nhớ một điều từ bài này, hãy nhớ điều này: snapshot nhất quán chưa chắc business đã đúng, còn transaction có mặt chưa chắc race condition đã biến mất. Database chỉ bảo vệ được thứ anh mô tả đúng bằng primitive phù hợp.