Đang tải nội dung…
Đang tải nội dung…
Prepared transaction giữ lock, còn một Saga ngây thơ tạo đơn sau khi đã hoàn tiền
Consistency, Reliability & Distributed Patterns · Phần 5Bố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 16
14:30 chiều tại phòng kỹ thuật Fortex, Ben ôm đầu trước màn hình cơ sở dữ liệu kho vận, hai mắt mở to bàng hoàng.
Một sự cố quái đản vừa xảy ra trên đơn hàng #8842:
RESERVED (Đã khóa tồn kho)! Món hàng bị biến mất khỏi kệ bán lẻ trên web, nhưng tiền thì không thu được của ai!Ben vội vàng truy vết chuỗi sự kiện trong hệ thống Message Queue và phát hiện ra một nghịch lý khó tin:
“Anh Tom ơi, em không thể tưởng tượng nổi! Event ReleaseInventory (lệnh nhả hàng bù trừ) lại bay tới dịch vụ kho trước cả event ReserveInventory (lệnh giữ hàng ban đầu)!”
Ben chỉ vào log:
ReleaseInventory. Mạng thông thoáng nên event này bay vèo tới kho chỉ trong 5 mili-giây.ReleaseInventory. Cậu tìm trong database thì chưa thấy đơn hàng #8842 đâu cả (vì event tạo đơn bị lag mạng chưa tới). Handler ngây thơ kết luận: “Chưa có đơn thì không có gì để nhả kho!”, và trả về thành công 200 OK!ReserveInventory lững thững bay tới nơi. Handler nhận diện đây là đơn hàng mới, liền hớn hở tạo bản ghi và đổi trạng thái thành RESERVED!“Kết quả là đơn hàng bị hủy tiền nhưng hàng thì bị giam vĩnh viễn trong kho!” Ben thở dài thườn thượt. “Kiến trúc phân tán Event-driven này đau đầu quá anh ơi! Hay là mình vứt hết mấy cái Saga phức tạp này đi, quay về dùng Two-Phase Commit (2PC / XA) của Database cho lành? Em thấy PostgreSQL có hỗ trợ lệnh PREPARE TRANSACTION mà. Cứ bắt cả Payment và Warehouse cùng prepare, thành công cả hai thì commit, lỗi một cái thì rollback hết, vừa an toàn vừa nguyên tử tuyệt đối!”
Tom mỉm cười, với tay lấy chiếc bút dạ rồi chỉ lên bảng trắng:
“Em đang muốn mua tính Nguyên tử (Atomicity) phân tán. Được thôi, nhưng cái giá phải trả của 2PC đắt đỏ đến mức nào trong thực tế, em đã tính hết chưa?”
“Trong các buổi phỏng vấn kiến trúc,” Tom bắt đầu, “nhiều người thường phán một câu xanh rờn: ‘2PC bị cấm tuyệt đối trong microservices’. Nói như vậy là thiển cận. 2PC là một chuẩn giao thức toán học hoàn toàn hợp lệ, và PostgreSQL hỗ trợ nó chính thức thông qua cú pháp PREPARE TRANSACTION (PostgreSQL two-phase transactions). Nó giải quyết một bài toán thật: Đảm bảo tính nguyên tử giữa nhiều database node độc lập.”
“Vậy tại sao các công ty lớn lại sợ nó như sợ cọp hả anh?” Ben tò mò.
“Bởi vì thời gian bất định nằm giữa Phase 1 (Prepare) và Phase 2 (Commit/Rollback)!”
Tom phân tích:
Khi một transaction chạy lệnh PREPARE TRANSACTION 'tx_123', database engine sẽ ghi toàn bộ trạng thái xuống đĩa và tiếp tục giữ chặt tất cả các Row Lock, Table Lock kể cả khi session kết nối client đã ngắt! Các khóa này chỉ được giải phóng khi có một lệnh COMMIT PREPARED hoặc ROLLBACK PREPARED được gửi xuống (PostgreSQL PREPARE TRANSACTION).
Trong bài lab thực nghiệm của WhyBit:
PREPARE TRANSACTION. Sau đó kết nối mạng bị ngắt.pg_prepared_xacts.statement_timeout và văng lỗi sập hệ thống!ROLLBACK PREPARED, khóa mới được nhả ra, và Transaction sau đó mới cập nhật thành công đưa số dư về 80.“Đó là blocking cục bộ,” Tom nhấn mạnh mối nguy lớn hơn: “Nguy hiểm hơn gấp vạn lần là: Một prepared transaction bị treo sẽ cản trở tiến trình dọn dẹp rác VACUUM của PostgreSQL! Postgres không thể dọn các dead tuples cũ hơn transaction đó, khiến toàn bộ database bị phình to (Table Bloat), cạn kiệt dung lượng đĩa và làm chậm toàn bộ các câu query khác trên toàn sàn!”
Và nếu Transaction Coordinator trung tâm bị crash đúng lúc vừa yêu cầu các service prepare xong, toàn bộ cụm microservices sẽ rơi vào trạng thái 'lấp lửng' (In-doubt state), không ai dám commit, không ai dám rollback, kéo theo hàng nghìn kết nối bị treo cứng cho đến khi sập sạch connection pool!
“Em hiểu rồi...” Ben rùng mình. “2PC bắt các hệ thống phân tán phải cùng nhau nín thở chờ đợi. Nếu một người ngất xỉu thì tất cả cùng chết nghẹt!”
“Chính vì thế,” Tom gật đầu, “mô hình Saga ra đời để giải thoát các hệ thống khỏi chiếc cùm đó. Thay vì cố gắng gộp mọi thứ vào một transaction phân tán khổng lồ, Saga chia nhỏ nghiệp vụ thành một chuỗi các Local Transaction độc lập.”
Mỗi service thực hiện xong phần việc của mình thì commit ngay lập tức vào database riêng:
Nếu chẳng may một bước ở giữa bị thất bại (ví dụ tài khoản không đủ tiền), Saga sẽ kích hoạt một chuỗi các Giao dịch Bù trừ (Compensating Transactions) chạy ngược lại để khắc phục hậu quả của các bước trước đó (AWS Prescriptive Guidance: Saga patterns).
Tom nhấn mạnh một nguyên tắc vàng:
“Giao dịch bù trừ trong Saga không phải là phép quay ngược thời gian (Undo)! Tiền đã trừ thì phải chạy lệnh Cộng tiền hoàn lại (Refund); email xác nhận đã gửi cho khách thì không thể thu hồi mà phải gửi thêm email xin lỗi; hàng đã lỡ khóa thì phải chạy lệnh Nhả kho. Mọi transaction bù trừ đều là một hành động nghiệp vụ mới toanh, và nó bắt buộc phải được thiết kế có ngữ nghĩa rõ ràng!”
“Quay lại sự cố đơn hàng #8842 lúc nãy của em,” Tom quay sang nhìn Ben. “Vì sao lệnh bù trừ lại đến trước lệnh chính?”
“Dạ, vì mạng Internet và Message Queue là môi trường bất đồng bộ!” Ben trả lời. “Event Reserve có thể bị nghẽn mạng hoặc rebalance partition, trong khi event Release lại đi đường tắt đến trước.”
Tom yêu cầu Ben viết lại logic xử lý ngây thơ (Naive Handler) ban đầu của mình lên bảng:
release(order): nếu đang RESERVED thì chuyển CANCELED; không có thì bỏ qua
reserve(order): nếu đang NEW thì chuyển RESERVED
Tom chỉ vào từng dòng:
release đến trước → Không thấy order đâu → Handler lẳng lặng bỏ qua!reserve đến → Thấy order mới → Ung dung chuyển thành RESERVED!RESERVED!Bài lab của WhyBit đã tái hiện chính xác kịch bản lỗi này: Với cùng chuỗi giao tin compensate → forward, handler ngây thơ kết thúc với kết quả sai trái là RESERVED!
“Vậy làm thế nào để dạy cho hệ thống biết rằng: Một đơn hàng đã bị hủy thì tuyệt đối không được phép giữ hàng nữa, kể cả khi lệnh giữ hàng đến sau?” Ben trăn trở.
“Ta phải thiết kế một Finite State Machine (Máy trạng thái hữu hạn) có khả năng ghi nhớ Ý định Hủy (Cancellation Intent), ngay cả khi bản ghi chính thức chưa hề tồn tại!” Tom giải thích. “Trong kỹ thuật phân tán, người ta gọi dấu vết này là Tombstone (Bia mộ) hoặc Semantic Lock.”
Bảng chuyển dịch trạng thái mới được Ben vẽ lại:
NEW --compensate--> CANCELED
RESERVED --compensate--> CANCELED
CANCELED --forward-----> CANCELED (Tác vụ đến muộn bị TỪ CHỐI THẲNG THỪNG!)
CANCELED --compensate--> CANCELED (Event bù trừ gửi lặp lại không gây hại)
Cơ chế hoạt động cực kỳ thông minh:
ReleaseInventory đến trước: Handler kiểm tra database chưa có đơn. Thay vì bỏ qua, nó tạo ngay một bản ghi đơn hàng với trạng thái CANCELED (cắm bia mộ Tombstone)!ReserveInventory lững thững đến muộn: Handler đọc database thấy trạng thái hiện tại đã là CANCELED. Thay vì thực hiện giữ hàng, nó từ chối thẳng thừng tác vụ này và giữ nguyên trạng thái CANCELED!ReleaseInventory bị gửi lại lần thứ hai: Trạng thái hiện tại đã là CANCELED, handler coi đây là thao tác idempotent và không làm gì cả.Kết quả thực nghiệm trên bài lab: Cùng một chuỗi giao tin compensate → forward, State Machine chuẩn xác kết thúc an toàn ở trạng thái CANCELED! Sự toàn vẹn của kho hàng được bảo vệ tuyệt đối!
Tom lùi lại phía sau, nhường toàn bộ bảng trắng cho Ben. Ben tự tin vạch ra kiến trúc hoàn chỉnh cho bộ điều phối Saga (Saga Orchestrator) của Fortex:
+----------------------------------+
| Saga Orchestrator |
| (State Machine + Versioning CAS) |
+-----------------+----------------+
|
+------------------------+------------------------+
| |
v (Command 1) v (Command 2)
+---------------+ +---------------+
| Payment | | Warehouse |
| Local Xact | | Local Xact |
+---------------+ +---------------+
Bản thiết kế bao gồm 7 trụ cột vững chắc:
saga_instance(id, state, version, deadline): Lưu trữ trạng thái tiến trình dưới database, không giữ trong RAM.WHERE id = $id AND version = $current_version.processed_messages(message_id): Loại bỏ ngay lập tức các event bị phân phát trùng lặp từ Message Broker.setTimeout() vốn sẽ biến mất khi restart ứng dụng.MANUAL_REVIEW để con người vào xử lý, không retry mù quáng vô tận.Toàn bộ các hiện tượng và giải pháp nêu trên đã được kiểm chứng tự động trong phòng lab độc lập với PostgreSQL 17.6:
Hình báo cáo được dựng từ kết quả run 38f739f3-429d-4c27-8ff9-94754f9109eb: một prepared transaction giữ khóa, lệnh cạnh tranh timeout sau 560 ms; handler ngây thơ kết thúc ở RESERVED, còn máy trạng thái an toàn kết thúc ở CANCELED.
| Nhánh thực nghiệm | Kịch bản quan sát | Kết quả đo lường thực tế |
|---|---|---|
| PostgreSQL Prepared Xact | Transaction gọi PREPARE rồi ngắt session | Xuất hiện trong pg_prepared_xacts, update cạnh tranh bị chặn và timeout sau 560 ms |
| Naive Saga Handler | Chuỗi giao tin: compensate → forward | Kết thúc ở trạng thái sai lệch: RESERVED (giữ hàng dù tiền đã hủy) |
| Tombstone State Machine | Cùng chuỗi giao tin: compensate → forward | Chặn đứng tác vụ muộn, kết thúc an toàn ở CANCELED; chịu được event trùng lặp |
Report JSON · Raw log · Mã nguồn lab · Run 38f739f3-429d-4c27-8ff9-94754f9109eb
Lưu ý về phạm vi thực nghiệm: Thử nghiệm sử dụng một instance PostgreSQL để cô lập logic khóa và máy trạng thái. Bài lab chứng minh hiện tượng lock kéo dài của prepared transaction và cách transition table triệt tiêu out-of-order message; không mô phỏng hạ tầng mạng phân tán hay sự cố sập broker.
Trong buổi họp Architectural Review chiều hôm đó, Ben không còn mở đầu bằng những khẩu hiệu sáo rỗng hay tranh cãi xem "2PC hay Saga xịn hơn".
Cậu đứng trước ban giám đốc và định nghĩa rõ ràng điều kiện bất biến (Invariant):
“Một đơn hàng một khi đã bị kích hoạt giao dịch bù trừ thì vĩnh viễn không bao giờ được phép quay lại trạng thái giam tiền hoặc khóa hàng của khách chỉ vì tin nhắn mạng đến muộn!”
Sau đó, Ben lần lượt trình bày 2PC, Choreography Saga và Orchestrated Saga như ba phương án đánh đổi kỹ thuật: Chọn cái nào thì phải chấp nhận trả giá bằng tài nguyên, độ trễ và độ phức tạp vận hành tương ứng.
Tom ngồi ở cuối bàn, mỉm cười lặng lẽ. Khi chị Hà quay sang hỏi ai sẽ là người chịu trách nhiệm chính thiết kế bảng ma trận lỗi (Failure Matrix) cho toàn bộ hệ thống đơn hàng Fortex, Tom tự hào chỉ tay sang phía Ben.
Fortex và các nhân vật hội thoại là bối cảnh hư cấu minh họa. Toàn bộ số đo, đồ thị khóa và log kỹ thuật trong bài viết đều được trích xuất nguyên bản từ phòng lab Docker có thể tái lập.
Trước: Chương 15 — Kafka Hot Key
PostgreSQL 17.6 prepared transactions + state-machine simulation · digest pinned · isolated Docker
Tải source Docker Lab (.zip)COMMIT a035d919d67e3863bdaab6961089bec3d917905e
Đọ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.