Mọi orchestration đáng tin cậy đều đứng trên một ý tưởng: quy trình là một state machine (máy trạng thái) — tập hợp các trạng thái hữu hạn và các phép chuyển hợp lệ giữa chúng. Thay vì hỏi "đơn này chạy tới đâu trong hàm?", bạn hỏi "đơn này đang ở trạng thái nào trong DB?". State nằm trong storage bền, không phải trong ngăn xếp lời gọi.
Vì sao state phải bền (durable)
Giả sử orchestrator của bạn đang chạy bước "thu tiền" thì container bị OOM-kill. Nếu trạng thái chỉ nằm trong RAM, bạn không biết tiền đã trừ chưa. Nếu trạng thái là một dòng trong bảng orders với cột status='INVOICED' và payment_status='PENDING', thì tiến trình khác chỉ cần đọc lên và tiếp tục đúng chỗ.
Thiết kế bảng state
CREATE TABLE order_workflow (
id BIGINT PRIMARY KEY,
order_id VARCHAR(64) NOT NULL UNIQUE,
state VARCHAR(32) NOT NULL, -- PENDING, RESERVED, INVOICED, PAID, FAILED, COMPENSATED
step_data JSON, -- reservation_id, invoice_id, payment_id...
attempt_count INT DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
step_data cực quan trọng: nó giữ các ID trả về từ hệ thống ngoài (mã giữ hàng, số hóa đơn). Khi cần compensate, bạn dùng chính các ID này để hủy.
Định nghĩa chuyển trạng thái hợp lệ
Viết ra bảng chuyển tường minh, và từ chối mọi chuyển không nằm trong bảng:
PENDING --reserve--> RESERVED
RESERVED --invoice--> INVOICED
INVOICED --charge--> PAID
* --error--> FAILED
FAILED --compensate->COMPENSATED
Nếu ai đó cố chuyển từ PENDING thẳng sang PAID, code phải ném lỗi. Điều này chặn cả bug lẫn hành vi lạ của AI agent.
Pseudocode một bước chuyển
def transition(order_id, from_state, to_state, apply_fn):
row = db.select_for_update(order_id) # khóa dòng
if row.state != from_state:
return # ai đó đã chuyển rồi -> bỏ qua (an toàn)
result = apply_fn(row.step_data) # gọi hệ thống ngoài
row.step_data.update(result)
row.state = to_state
db.save(row)
Điều kiện if row.state != from_state biến hàm này thành an toàn khi chạy lại: nếu bước đã hoàn thành, lần chạy thứ hai không làm gì.
Khung tư duy: đặt tên trạng thái
- Trạng thái mô tả kết quả đã đạt, không mô tả hành động đang làm. Dùng
RESERVED(đã giữ hàng) chứ không phảiRESERVING. - Luôn có một trạng thái kết thúc thành công và ít nhất một trạng thái kết thúc thất bại.
- Không trạng thái nào được phép "lửng lơ mãi" — mỗi trạng thái không-cuối phải có timeout đưa nó đi tiếp hoặc sang FAILED.
Các bước áp dụng
- Liệt kê mọi kết quả trung gian của quy trình, biến mỗi cái thành một trạng thái.
- Vẽ ma trận chuyển hợp lệ; đánh dấu trạng thái đầu và cuối.
- Tạo bảng state với cột
state,step_data,attempt_count. - Bọc mỗi bước bằng hàm
transitioncó kiểm trafrom_state. - Thêm một view/truy vấn liệt kê đơn "kẹt" (updated_at quá cũ mà chưa ở trạng thái cuối).
Vì sao khóa dòng (row lock) lại quan trọng
Trong production, cùng một đơn có thể bị hai worker chạm vào cùng lúc: một job trùng trong queue, một retry đến sớm, một lần đánh thức từ callback. Nếu cả hai cùng đọc state RESERVED rồi cùng gọi bước invoice, bạn tạo hai hóa đơn. SELECT ... FOR UPDATE khóa dòng đơn đó: worker thứ hai phải chờ, và khi vào được thì state đã đổi sang INVOICED, nên điều kiện from_state khiến nó bỏ qua. Khóa dòng cộng kiểm tra from_state là hai lớp bảo vệ bổ trợ nhau — lock chống chạy song song, kiểm tra state chống chạy lặp tuần tự.
Bất biến (invariant) của state machine
Mỗi state machine tốt có vài bất biến — điều luôn đúng dù chuyện gì xảy ra. Ví dụ: "một đơn không bao giờ ở PAID mà không có payment_id trong step_data"; hoặc "đơn COMPENSATED phải từng đi qua FAILED". Viết các bất biến này ra và thêm một job kiểm tra định kỳ (audit) quét DB tìm vi phạm. Khi có bug lạ, chính các bất biến bị vỡ sẽ chỉ cho bạn thấy vấn đề sớm, thay vì để dữ liệu hỏng âm thầm lan rộng.
-- Audit: đơn PAID nhưng thiếu payment_id là dấu hiệu bug
SELECT order_id FROM order_workflow
WHERE state = 'PAID'
AND (step_data ->> 'payment_id') IS NULL;
Sai lầm thường gặp
- Trạng thái quá thô: chỉ
NEWvàDONE. Khi hỏng, bạn không biết đã đi tới đâu để compensate. - Cho phép mọi chuyển: không kiểm
from_state, dẫn tới double-charge khi message trùng. - Nhét thông tin runtime lớn vào
step_data: chỉ lưu ID và thứ cần cho bù trừ, không lưu cả payload nặng. - Quên trạng thái cuối cho nhánh lỗi: đơn FAILED không bao giờ được dọn, chất đống trong hệ thống.