Tìm hiểu giao dịch Postgres cho luồng công việc nhiều bước: cách gom các cập nhật an toàn, ngăn ghi một phần, xử lý thử lại và giữ dữ liệu nhất quán.

Hầu hết tính năng thực tế không chỉ là một cập nhật cơ sở dữ liệu. Chúng là một chuỗi ngắn: chèn một hàng, cập nhật số dư, đánh dấu trạng thái, ghi bản ghi audit, có thể xếp một job. Ghi một phần xảy ra khi chỉ một vài bước trong số đó đến được cơ sở dữ liệu.
Điều này xuất hiện khi có thứ gì đó ngắt giữa chuỗi: lỗi server, timeout giữa app và Postgres, crash sau bước 2, hoặc một lần thử lại chạy lại bước 1. Mỗi câu lệnh riêng lẻ có thể ổn. Luồng công việc bị vỡ khi nó dừng giữa chừng.
Bạn thường phát hiện nhanh:
Một ví dụ cụ thể: nâng cấp gói cập nhật gói của khách, thêm bản ghi thanh toán, và tăng tín dụng khả dụng. Nếu app crash sau khi lưu thanh toán nhưng trước khi thêm tín dụng, bộ phận hỗ trợ thấy "đã thanh toán" ở một bảng và "không có tín dụng" ở bảng khác. Nếu client thử lại, bạn có thể ghi nhận thanh toán hai lần.
Mục tiêu đơn giản: coi luồng công việc như một công tắc duy nhất. Hoặc mọi bước thành công, hoặc không bước nào thành công, để bạn không bao giờ lưu công việc dở dang.
Giao dịch là cách cơ sở dữ liệu nói: coi những bước này như một đơn vị công việc. Hoặc mọi thay đổi xảy ra, hoặc không có thay đổi nào. Điều này quan trọng bất cứ khi nào luồng công việc cần nhiều hơn một cập nhật, như tạo một hàng, cập nhật số dư và ghi bản ghi audit.
Hãy nghĩ về việc chuyển tiền giữa hai tài khoản. Bạn phải trừ khỏi Tài khoản A và cộng vào Tài khoản B. Nếu app crash sau bước đầu, bạn sẽ không muốn hệ thống "ghi nhớ" chỉ việc trừ mà thôi.
Khi bạn commit, bạn nói với Postgres: giữ mọi thứ tôi đã làm trong giao dịch này. Tất cả thay đổi trở nên vĩnh viễn và có thể thấy được bởi các session khác.
Khi bạn rollback, bạn nói với Postgres: quên mọi thứ tôi đã làm trong giao dịch này. Postgres hoàn tác những thay đổi như thể giao dịch chưa từng xảy ra.
Trong một giao dịch, Postgres đảm bảo bạn sẽ không để lộ kết quả dở dang cho các session khác trước khi bạn commit. Nếu có lỗi và bạn rollback, cơ sở dữ liệu dọn dẹp các ghi từ giao dịch đó.
Giao dịch không sửa được thiết kế luồng công việc kém. Nếu bạn trừ sai số tiền, dùng sai user ID, hoặc bỏ qua một kiểm tra cần thiết, Postgres sẽ commit kết quả sai đó một cách trung thành. Giao dịch cũng không tự động ngăn mọi xung đột ở cấp nghiệp vụ (như bán vượt tồn) trừ khi bạn kết hợp với ràng buộc, khóa, hoặc cấp độ cô lập phù hợp.
Bất cứ khi nào bạn cập nhật nhiều hơn một bảng (hoặc nhiều hơn một hàng) để hoàn thành một hành động thực tế, đó là ứng viên cho giao dịch. Ý nghĩa luôn như cũ: hoặc mọi thứ xong, hoặc không có gì xong.
Luồng đơn hàng là ví dụ cổ điển. Bạn có thể tạo một hàng order, giữ tồn kho, thu tiền, rồi đánh dấu đơn là đã thanh toán. Nếu thanh toán thành công nhưng cập nhật trạng thái thất bại, bạn có tiền đã được chụp nhưng đơn vẫn hiện là chưa thanh toán. Nếu hàng order được tạo nhưng tồn kho không được giữ, bạn có thể bán hàng mà thực tế không có.
Onboarding người dùng cũng hay hỏng tương tự. Tạo user, chèn profile, gán quyền, và ghi rằng một email chào mừng nên được gửi là một hành động logic duy nhất. Nếu không gom, bạn có thể có user có thể đăng nhập nhưng không có quyền, hoặc profile tồn tại nhưng không có user.
Các thao tác back-office thường cần hành vi "nhật ký + thay đổi trạng thái" chặt chẽ. Phê duyệt một yêu cầu, ghi bản audit và cập nhật số dư cần thành công cùng nhau. Nếu số dư thay đổi nhưng log audit thiếu, bạn mất bằng chứng ai đã thay đổi gì và vì sao.
Job nền cũng được lợi, đặc biệt khi xử lý một mục công việc nhiều bước: claim mục để hai worker không làm trùng, áp dụng cập nhật nghiệp vụ, ghi kết quả để báo cáo và thử lại, rồi đánh dấu mục là xong (hoặc failed với lý do). Nếu các bước này tách rời, thử lại và đồng thời sẽ tạo ra mớ hỗn độn.
Tính năng nhiều bước hỏng khi bạn coi chúng như một đống cập nhật độc lập. Trước khi mở client cơ sở dữ liệu, hãy viết luồng công việc như một câu chuyện ngắn với một vạch đích rõ ràng: chính xác điều gì được tính là "hoàn thành" cho người dùng?
Bắt đầu bằng liệt kê các bước bằng ngôn ngữ đơn giản, rồi xác định một điều kiện thành công duy nhất. Ví dụ: "Đơn được tạo, tồn kho được giữ, và người dùng thấy số xác nhận đơn." Bất cứ điều gì ngắn hơn đều không phải thành công, ngay cả khi một vài bảng đã được cập nhật.
Tiếp theo, vẽ một đường rạch rõ giữa công việc trong cơ sở dữ liệu và công việc ngoài (external). Các bước DB là những thứ bạn có thể bảo vệ bằng giao dịch. Cuộc gọi ngoài như thanh toán thẻ, gửi email, hoặc gọi API bên thứ ba có thể thất bại một cách chậm và khó đoán, và bạn thường không thể rollback chúng.
Một cách lập kế hoạch đơn giản: tách các bước thành (1) bắt buộc phải toàn hoặc không, (2) có thể xảy ra sau commit.
Trong giao dịch, chỉ giữ các bước phải nhất quán cùng nhau:
Đưa các side effect ra ngoài. Ví dụ, commit đơn rồi gửi email xác nhận dựa trên bản ghi outbox.
Với mỗi bước, viết điều gì nên xảy ra nếu bước tiếp theo thất bại. "Rollback" có thể có nghĩa là rollback DB, hoặc một hành động bù trừ.
Ví dụ: nếu thanh toán thành công nhưng giữ tồn kho thất bại, hãy quyết trước là hoàn tiền ngay lập tức, hay đánh dấu đơn là "đã thu tiền, chờ tồn kho" và xử lý bất đồng bộ.
Giao dịch nói với Postgres: coi những bước này là một đơn vị. Hoặc tất cả xảy ra, hoặc không bước nào xảy ra. Đó là cách đơn giản nhất để ngăn ghi một phần.
Dùng một kết nối cơ sở dữ liệu (một session) từ đầu đến cuối. Nếu bạn phân tán các bước qua nhiều kết nối, Postgres không thể đảm bảo kết quả toàn hoặc không.
Trình tự đơn giản: begin, chạy các đọc và ghi cần thiết, commit nếu mọi thứ thành công, ngược lại rollback và trả lỗi rõ ràng.
Đây là ví dụ tối thiểu bằng SQL:
BEGIN;
-- reads that inform your decision
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- writes that must stay together
UPDATE accounts SET balance = balance - 50 WHERE id = 42;
INSERT INTO ledger(account_id, amount, note) VALUES (42, -50, 'Purchase');
COMMIT;
-- on error (in code), run:
-- ROLLBACK;
Giao dịch nắm giữ khóa trong khi chạy. Bạn giữ càng lâu, càng chặn công việc khác và càng dễ gặp timeout hoặc deadlock. Làm những việc thiết yếu trong giao dịch, và chuyển tác vụ chậm (gửi email, gọi nhà cung cấp thanh toán, sinh PDF) ra ngoài.
Khi có lỗi, ghi đủ ngữ cảnh để tái tạo vấn đề mà không lộ dữ liệu nhạy cảm: tên luồng, order_id hoặc user_id, các tham số chính (amount, currency), và mã lỗi Postgres. Tránh log payload đầy đủ, dữ liệu thẻ, hoặc thông tin cá nhân.
Đồng thời là hai việc xảy ra cùng lúc. Hãy tưởng tượng hai khách cố mua vé buổi hòa nhạc cuối cùng. Cả hai màn hình đều hiện "còn 1", cả hai bấm Pay, và giờ app phải quyết ai được.
Nếu không có bảo vệ, cả hai request có thể đọc giá trị cũ giống nhau và cả hai ghi cập nhật. Đó là cách bạn có thể gặp tồn kho âm, đặt chỗ trùng, hoặc thanh toán không có đơn.
Khóa hàng là biện pháp che chắn đơn giản nhất. Bạn khóa hàng cụ thể sẽ thay đổi, kiểm tra, rồi cập nhật. Các giao dịch khác chạm vào cùng hàng phải chờ bạn commit hoặc rollback, ngăn các cập nhật kép.
Một mẫu phổ biến: bắt đầu giao dịch, select hàng inventory với FOR UPDATE, xác minh còn hàng, giảm nó, rồi insert order. Việc đó "giữ cửa" trong khi bạn hoàn thành các bước quan trọng.
Cấp độ cô lập điều khiển mức độ hiện tượng bất thường bạn cho phép từ các giao dịch đồng thời. Quyết định thường là an toàn vs tốc độ:
Giữ khóa ngắn. Nếu một giao dịch mở trong khi bạn gọi API ngoài hay chờ hành động người dùng, bạn sẽ tạo chờ lâu và timeouts. Ưu tiên đường lỗi rõ ràng: đặt lock timeout, bắt lỗi, và trả "vui lòng thử lại" thay vì để request treo.
Nếu cần làm việc ngoài DB (như charge thẻ), tách luồng: reserve nhanh, commit, rồi làm phần chậm, và hoàn tất bằng một giao dịch ngắn khác.
Thử lại là chuyện bình thường trong ứng dụng có Postgres. Một request có thể fail dù code đúng: deadlock, statement timeout, mạng ngắt, hoặc lỗi serialization dưới cấp độ cô lập cao hơn. Nếu bạn chạy lại handler y hệt, bạn có nguy cơ tạo đơn thứ hai, charge hai lần, hoặc insert bản ghi sự kiện trùng.
Cách khắc phục là idempotency: thao tác nên an toàn khi chạy hai lần với cùng input. Cơ sở dữ liệu nên nhận ra "đây là cùng một request" và phản hồi nhất quán.
Một mẫu thực tế là gắn idempotency key (thường do client sinh, request_id) vào mỗi luồng nhiều bước và lưu nó trên bản ghi chính, rồi thêm ràng buộc unique trên key đó.
Ví dụ: trong checkout, sinh request_id khi người dùng bấm Pay, rồi insert order với request_id đó. Nếu thử lại, lần thứ hai sẽ vấp ràng buộc unique và bạn trả lại order hiện có thay vì tạo mới.
Những điều thường quan trọng:
Giữ vòng thử lại ở ngoài giao dịch. Mỗi lần thử nên bắt đầu một giao dịch mới và chạy lại toàn bộ đơn vị công việc từ đầu. Thử lại trong một giao dịch đã fail không giúp vì Postgres đánh dấu nó là aborted.
Một ví dụ nhỏ: app cố tạo order và giữ tồn kho, nhưng timeout ngay sau COMMIT. Client thử lại. Với idempotency key, request thứ hai trả order đã tạo và bỏ qua việc giữ thêm thay vì nhân đôi công việc.
Giao dịch gom luồng với nhau, nhưng không tự động làm dữ liệu đúng. Cách mạnh là khiến trạng thái "sai" trở nên khó hoặc không thể trong DB, ngay cả khi có bug trong app.
Bắt đầu với hàng rào an toàn cơ bản. Khóa ngoại đảm bảo tham chiếu tồn tại (order line không thể trỏ tới order mất). NOT NULL ngăn hàng bị nửa vời. CHECK constraint bắt các giá trị vô lý (ví dụ quantity > 0, total_cents >= 0). Những quy tắc này chạy trên mọi ghi, bất kể dịch vụ hay script nào chạm DB.
Với luồng dài, mô hình hóa chuyển đổi trạng thái rõ ràng. Thay vì nhiều flag boolean, dùng một cột status (pending, paid, shipped, canceled) và chỉ cho phép chuyển hợp lệ. Bạn có thể thi hành điều này bằng constraint hoặc trigger để DB từ chối nhảy trạng thái bất hợp pháp như shipped -> pending.
Tính duy nhất là một dạng đúng khác. Thêm unique constraints nơi bản sao sẽ phá vỡ luồng: order_number, invoice_number, hoặc idempotency_key dùng cho thử lại. Khi app thử lại cùng request, Postgres chặn insert thứ hai và bạn an toàn trả "đã xử lý" thay vì tạo order thứ hai.
Khi cần truy vết, lưu rõ ràng. Một bảng audit (hoặc history) ghi ai thay đổi gì và khi nào biến các "cập nhật bí ẩn" thành sự kiện bạn có thể truy vấn khi điều tra sự cố.
Hầu hết ghi một phần không phải do "SQL xấu." Chúng đến từ quyết định luồng công việc làm cho dễ commit chỉ nửa câu chuyện.
accounts rồi orders, nhưng request khác cập nhật orders rồi accounts, bạn tăng nguy cơ deadlock dưới tải.Ví dụ cụ thể: trong checkout, bạn giữ tồn kho, tạo order, rồi charge thẻ. Nếu charge thẻ trong cùng giao dịch, bạn có thể giữ khóa tồn kho trong khi chờ mạng. Nếu charge thành công nhưng giao dịch sau đó rollback, bạn đã charge khách hàng mà không có order.
Mẫu an toàn hơn: giữ giao dịch tập trung vào trạng thái DB (giữ tồn kho, tạo order, ghi payment pending), commit, rồi gọi API ngoài, rồi ghi lại kết quả trong giao dịch ngắn mới. Nhiều đội làm điều này với status pending và job nền.
Khi một luồng có nhiều bước (insert, update, charge, gửi), mục tiêu đơn giản: hoặc mọi thứ được ghi, hoặc không gì được ghi.
Giữ mọi ghi cần thiết trong một giao dịch. Nếu một bước thất bại, rollback và để dữ liệu như ban đầu.
Rõ điều kiện thành công. Ví dụ: "Order được tạo, tồn kho được giữ, và trạng thái thanh toán được ghi." Bất kỳ thứ gì khác dẫn tới abort giao dịch.
BEGIN ... COMMIT.ROLLBACK, và caller nhận kết quả lỗi rõ ràng.Giả sử cùng request có thể được thử lại. DB nên giúp thi hành quy tắc chỉ-một-lần.
Làm tối thiểu công việc trong giao dịch, và tránh đợi cuộc gọi mạng khi đang giữ khóa.
Nếu bạn không thấy chỗ hỏng, bạn sẽ cứ đoán.
Checkout có vài bước nên di chuyển cùng nhau: tạo order, giữ tồn kho, ghi nỗ lực thanh toán, rồi đánh dấu trạng thái order.
Hãy tưởng tượng người dùng bấm Buy cho 1 món.
Trong một giao dịch, chỉ làm thay đổi DB:
orders với status pending_payment.inventory.available hoặc tạo hàng reservations).payment_intents với idempotency_key do client cung cấp (unique).outbox như "order_created".Nếu bất kỳ câu lệnh nào thất bại (hết hàng, lỗi constraint, crash), Postgres rollback toàn bộ giao dịch. Bạn không bị một order không có reservation, hoặc reservation không có order.
Nhà cung cấp thanh toán ở ngoài DB, nên xử lý nó như bước riêng.
Nếu gọi provider thất bại trước khi bạn commit, bỏ giao dịch và không có gì được ghi. Nếu gọi provider thất bại sau khi bạn commit, chạy một giao dịch mới để đánh dấu nỗ lực thanh toán là thất bại, giải phóng reservation, và set order về canceled.
Yêu cầu client gửi idempotency_key cho mỗi lần checkout. Énforce nó bằng index unique trên payment_intents(idempotency_key) (hoặc trên orders nếu bạn muốn). Khi thử lại, code tìm hàng hiện có và tiếp tục thay vì insert order mới.
Đừng gửi email trong giao dịch. Ghi một bản ghi outbox trong cùng giao dịch, rồi để worker gửi email sau commit. Như vậy bạn không bao giờ gửi email cho một order đã bị rollback.
Chọn một luồng chạm hơn một bảng: signup + enqueue email chào mừng, checkout + tồn kho, invoice + ledger, hoặc tạo project + thiết lập mặc định.
Viết các bước trước, rồi viết quy tắc luôn đúng (invariants). Ví dụ: "Một order hoặc được trả tiền và giữ tồn kho đầy đủ, hoặc không được trả và không được giữ. Không bao giờ một nửa giữ." Biến những quy tắc đó thành một đơn vị toàn hoặc không.
Kế hoạch đơn giản:
Rồi test các trường hợp xấu có chủ ý. Giả lập crash sau bước 2, timeout ngay trước commit, và double-submit từ UI. Mục tiêu là kết quả nhàm chán: không có hàng mồ côi, không charge đôi, không pending mãi.
Nếu bạn đang prototype nhanh, hữu ích khi phác thảo luồng trong công cụ planning trước khi tạo handler và schema. Ví dụ, Koder.ai (koder.ai) có Planning Mode và hỗ trợ snapshot và rollback, điều này có thể tiện khi bạn lặp ranh giới giao dịch và ràng buộc.
Làm điều này cho một luồng trong tuần này. Luồng thứ hai sẽ nhanh hơn nhiều.