Đang tải nội dung…
Đang tải nội dung…
Response có thể mất sau commit. Một idempotency key chỉ có ích khi ranh giới transaction đúng.
Consistency, Reliability & Distributed Patterns · Phần 4Bố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 8/8
Fortex muốn người dùng thử lại một thao tác cộng credit khi mạng chập chờn. Ben viết retry lên bảng, rồi tự gạch dưới chữ cùng trong câu “gửi lại cùng yêu cầu”.
“Em từng nghĩ timeout là thất bại,” cậu nói. “Nhưng nếu DB đã commit thì gửi lại có thể cộng hai lần. Mình cần key.”
Tom ngồi xuống. “Em dẫn anh qua vòng đời của key nhé.”
“Client tạo UUID khi gửi request.”
“Khi người dùng bấm thử lại?”
Ben nhìn câu vừa viết. “Nếu chỗ đó cũng tạo UUID mới thì server lại thấy việc mới.” Cậu sửa sơ đồ, vẫn giữ tốc độ nói nhanh quen thuộc, nhưng lần này tự thêm hai nhánh: cùng ý định và ý định mới.
Tom chưa cần đưa đáp án. Anh chỉ đặt thêm câu hỏi tại từng ranh giới mà Ben sắp coi là hiển nhiên.
Ben bắt đầu bằng cách tách hai góc nhìn: server đã làm gì và client biết được gì. Cậu mượn lại câu hỏi từ bài outbox — có một khoảng hở ở đâu? — nhưng lần này khoảng hở nằm giữa commit và việc người gọi nhận được kết quả.
Client gửi yêu cầu cộng 100 credit. Server ghi ledger, commit, rồi kết nối đứt trước khi response đến client. Màn hình báo timeout. Người dùng bấm thử lại.
Nếu server xử lý lần gửi thứ hai như một yêu cầu mới, tài khoản được cộng 200. Cả hai transaction đều đúng về mặt SQL. Lỗi là hệ thống không nhận ra hai lần gửi thuộc cùng một ý định.
Timeout không chứng minh thao tác thất bại. Nó chỉ cho biết bên chờ chưa nhận được kết quả đúng hạn. Vì thế “thêm retry” cần đi cùng câu trả lời cho câu hỏi: server nhận ra một ý định đã xử lý bằng cách nào?
“Key thuộc tài khoản nào?” Tom hỏi.
Ben nhớ bài RLS. “Phải lấy danh tính đã xác thực. Hai tài khoản trùng chuỗi key không được giẫm lên nhau, và replay vẫn phải kiểm tra quyền.” Cậu thêm phạm vi vào thiết kế trước khi viết bảng.
Client tạo một key ngẫu nhiên khi bắt đầu ý định, rồi giữ nguyên key nếu retry cùng ý định đó. Một giao dịch mới cần key mới. Sinh UUID mới ở mỗi lần bấm retry sẽ khiến server không thể nhận ra sự lặp lại.
Server lưu key theo phạm vi danh tính và loại thao tác, kèm fingerprint của input cùng kết quả cần trả. Một hệ thống thực tế có thể dùng unique key (account_id, operation, idempotency_key). Lab chỉ có một loại thao tác nên rút gọn thành (account_id, key).
CREATE TABLE requests (
account_id int NOT NULL,
key text NOT NULL,
amount int NOT NULL,
result text NOT NULL,
PRIMARY KEY (account_id, key)
);
Unique constraint là điểm phân xử giữa các request tranh cùng key. Một lệnh SELECT kiểm tra trước rồi INSERT sau vẫn có race. Với PostgreSQL, INSERT ... ON CONFLICT cho phép biến việc giành key thành thao tác do database phân xử.
Tom vẽ marker và ledger thành hai ô. Ben nối chúng bằng một đường bao transaction, giống inbox và projection hôm trước.
“Nếu lưu key trước rồi lỗi khi cộng credit, retry sẽ bị chặn oan. Nếu cộng trước rồi chưa lưu key thì bị cộng lại.” Cậu đã tự tìm ra hai cửa sổ mà trước đây Tom phải đặt con trỏ giúp.
BEGIN;
WITH accepted AS (
INSERT INTO requests VALUES (1, 'req-42', 100, 'accepted:100')
ON CONFLICT DO NOTHING
RETURNING account_id
)
INSERT INTO ledger(account_id, amount)
SELECT account_id, 100 FROM accepted;
SELECT result FROM requests
WHERE account_id = 1 AND key = 'req-42' AND amount = 100;
COMMIT;
Nếu chưa có key, transaction ghi cả request lẫn ledger. Nếu key đã có, phần INSERT ledger không nhận row nào từ accepted; câu SELECT sau lấy kết quả đã lưu. Nếu transaction rollback, cả marker lẫn tác động đều rollback.
Trong ví dụ này, SELECT là statement tiếp theo, không gộp mọi thứ vào một snapshot duy nhất. Khi có request đồng thời tranh unique key, cần hiểu semantics isolation và thử đường chờ/conflict; lab hiện tại tái hiện retry tuần tự sau mất response, chưa phải stress test concurrent HTTP.
Code minh họa chỉ có amount nguyên dương trong fixture, result cố định và số tiền dạng số nguyên. Sản phẩm thật còn phải validate input, quyền tài khoản, đơn vị và giới hạn số học. Không lấy account_id từ body rồi coi đó là danh tính đã xác thực.
“Cùng key, lần sau số tiền khác thì sao?” Tom hỏi.
Ben không chọn cách trả kết quả cũ cho xong. “Phải báo xung đột theo hợp đồng API. Nếu coi khác payload là cùng việc, mình đang làm sai ý định của người gọi.” Hai người xem lại những field phải tham gia fingerprint.
Giả sử request đầu là cộng 100, lần sau cùng key lại là cộng 999. Không nên trả thành công như thể hai yêu cầu tương đương. Lab đối chiếu amount; payload khác không được ghi ledger và không nhận result khớp. Ở HTTP layer, đây thường là một lỗi conflict hoặc validation được định nghĩa trong API, không phải response rỗng.
Với input phức tạp, fingerprint cần bao phủ dữ liệu có ý nghĩa nghiệp vụ theo cách canonical hóa ổn định. So sánh raw JSON có thể coi hai payload tương đương là khác vì thứ tự field. Ngược lại, bỏ sót currency hoặc destination có thể coi hai yêu cầu khác nhau là một.
Stripe API v1 mô tả cách lưu và trả lại status/body theo idempotency key. Đó là một thiết kế API cụ thể, không phải quy tắc bắt mọi hệ thống phải cache mọi lỗi theo cùng cách. Hãy công bố rõ trạng thái nào được lưu, thời hạn lưu và cách client retry.
Ben tự đề xuất các ca: không chống trùng, cùng key, cùng key khác amount, rồi cùng chuỗi key ở tài khoản khác. Tom giúp dựng lab và kiểm tra các assertion.
Trước khi viết kết luận, Ben chủ động ghi giới hạn: driver bỏ kết quả đầu rồi gọi lại tuần tự. Họ không có bằng chứng về một HTTP server bị đứt mạng hay một đợt tranh key đồng thời từ thí nghiệm này.
PostgreSQL 17.6 chạy trong Docker riêng. Nhánh không chống trùng ghi hai dòng ledger, mỗi dòng 100. Nhánh có key gọi thao tác hai lần với req-42; driver cố ý không dùng kết quả đầu trước khi gửi lần hai, mô hình hóa mất response. Không có HTTP server hay mạng bị ngắt thật trong phép thử này.
| Nhánh / điểm đo | Kết quả quan sát |
|---|---|
| Two retries, no dedupe | 200 |
| Same key + atomic record | 100 |
Cả hai lần gọi có key trả accepted:100; ledger chỉ có một dòng cho account 1. Gọi lại cùng key với amount 999 không thay đổi tổng. Account 2 được phép dùng cùng chuỗi key mà vẫn có bản ghi riêng. Report và raw logs chứa những assertion này; source và lệnh chạy ở cuối bài.
Tom đề nghị rút ngắn bản thiết kế: “Key với unique constraint là đủ mô tả phần chính rồi?”
“Phần chính, nhưng chưa đủ hợp đồng,” Ben đáp. “Xóa key khi nào? Retry đến muộn thì sao? Nếu tác động ở provider thì transaction của mình không bao phủ.”
Tom kéo bảng quyết định lại gần. “Ừ, giữ những câu đó. Em dẫn tiếp đi.”
| Tình huống | Quyết định cần làm |
|---|---|
| Xóa key sau một ngày, retry đến ngày thứ hai | Xác định cửa sổ dedupe; có thể cần business key bền hơn retention kỹ thuật |
| Tác động nằm ở cổng thanh toán bên ngoài | Dùng idempotency phía provider, lưu external reference, có quy trình reconcile |
| Công việc dài hơn request | Thiết kế pending/in-progress và cách lấy kết quả; không giữ DB transaction qua HTTP dài |
| Hai request đồng thời cùng key | Test chờ unique constraint, timeout, rollback; client cần biết nên retry hay theo dõi |
| Lưu response có dữ liệu nhạy cảm | Kiểm tra quyền mỗi lần replay, hạn chế lưu và bảo vệ nội dung |
| Retry hàng loạt khi dependency lỗi | Backoff, jitter, retry budget; idempotent không có nghĩa miễn phí |
Nếu thao tác vừa ghi DB vừa phát event, transactional outbox giữ ý định gửi bền vững, nhưng consumer vẫn có thể nhận trùng. Nếu nhiều request khác nhau cùng sửa stock, kiểm soát lost update bảo vệ phép cập nhật. Ba cơ chế bổ sung cho nhau: nhận diện cùng ý định, bảo vệ trạng thái và chuyển tiếp công việc sang hệ thống khác.
Một retry an toàn cần nhiều hơn một header. Nó cần định nghĩa ý định, điểm phân xử bền vững và ranh giới commit bao phủ đúng tác động cần chống lặp.
Ben chưa biến thành người không bao giờ sai. Cậu vẫn thích một lời giải gọn, vẫn muốn đẩy thay đổi sớm. Nhưng khi gửi bản thiết kế cho nhóm Fortex, cậu đính kèm điều kiện đúng, cửa sổ lỗi, bằng chứng lab và những ca chưa thử.
Tom đọc xong, không viết lại hộ. Anh để tên Ben ở phần người trình bày.
Trang sổ cuối chặng này không ghi một pattern mới: “Trước khi hỏi dùng công cụ gì, hỏi mình đang bảo vệ điều gì, ở ranh giới nào, và bằng chứng nào có thể làm mình đổi ý.”
Chương trước: Quên WHERE tenant_id: RLS có chặn được rò dữ liệu? · Bắt đầu hành trình
PostgreSQL 17.6 · digest pinned · isolated Docker
Tải source Docker Lab (.zip)COMMIT e4881db7ab326ce553bbb0172c44fab421517f08
Đọ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.