Phần trước nói về đọc. Phần này nói về ghi, và về một tình huống mà mọi hệ thống có mạng đều gặp: client gửi POST /orders, server nhận được, tạo đơn, ghi database, gọi cổng thanh toán — rồi phản hồi không về tới client. Mạng rớt, proxy timeout, người dùng đóng tab rồi mở lại. Client chỉ biết "không có phản hồi", và cách phản ứng tự nhiên nhất là gửi lại. Server nhận một request thứ hai trông y hệt request đầu, và nó tạo thêm một đơn nữa.

Khoá idempotency (Idempotency-Key) là cách để server nhận ra "request này tôi đã xử lý rồi". Bài này dựng ba bản server và đo xem từng bản xử lý thế nào khi client retry, khi 50 request cùng key đến một lúc, khi client gửi nhầm nội dung, và tốn thêm bao nhiêu.

Giữ chỗ trước, tạo đơn sau

Ảnh chụp đoạn mã nền tối Idempotency-Key giữ chỗ trước tạo đơn sau trả lại phản hồi cũ với Go net/http POST /orders khoá trong Redis 7 bằng SET NX hoặc PostgreSQL 16 bằng ON CONFLICT, bản Redis SET NX giữ chỗ chỉ một request với key này đi tiếp gồm key lấy từ header Idempotency-Key, gọi rdb.SetNX với idem cộng key giá trị dang_xu_ly nối vân tay của thân và TTL 24 giờ, nếu không ok thì đã có người giữ chỗ nên Get giá trị về, nếu bắt đầu bằng dang_xu_ly thì trả 409 request với key này đang được xử lý, ngược lại giải mã saved gồm vt code body đã lưu lúc xong, nếu saved.VanTay khác vanTay của thân thì trả 422 cùng key nhưng khác nội dung, đặt header Idempotent-Replayed true và trả lại đúng phản hồi lần đầu, còn nếu ok thì id bằng taoDon khoảng 30 ms ghi DB và gọi cổng thanh toán rồi rdb.Set idem cộng key với vt 201 body TTL 24 giờ và trả 201, bản PostgreSQL dòng giữ chỗ nằm cùng giao dịch với đơn hàng gồm tx bằng pool.Begin, tag bằng tx.Exec insert into idem_keys key van_tay values on conflict do nothing, nếu tag.RowsAffected bằng 0 thì key đã có nên đọc phản hồi cũ bằng select van_tay ma phan_hoi from idem_keys where key và trả ma phan_hoi với ghi chú request thứ hai chờ tới khi tx đầu commit, ngược lại id bằng taoDon tx d rồi update idem_keys set ma 201 phan_hoi where key rồi tx.Commit để đơn hàng và khoá cùng sống cùng chết, phần vanTay của thân là sha256 của thân request cùng key khác thân thì 422 vì không có bước này client gửi nhầm so_tien sẽ nhận phản hồi của đơn cũ và tưởng đã thành công

Hình 1: Hai bản có khoá. Cả hai cùng một ý: giữ chỗ bằng một thao tác nguyên tử trước, xử lý sau, và lưu phản hồi để trả lại cho các lần gửi tiếp theo.

Client sinh một key ngẫu nhiên (UUID) cho mỗi ý định — một lần bấm "Đặt hàng" là một key — và gửi lại đúng key đó khi retry. Phía server, cả hai bản đi qua cùng ba bước:

  1. Giữ chỗ nguyên tử. Redis: SET NX chỉ thành công nếu key chưa có. PostgreSQL: INSERT ... ON CONFLICT DO NOTHING vào bảng idem_keys có khoá chính là key, và xét số dòng chèn được. Cả hai đều là "một lệnh, một kẻ thắng" — không có khoảng hở đọc-rồi-ghi.
  2. Xử lý rồi lưu phản hồi. Tạo đơn, rồi ghi mã trạng thái và thân phản hồi vào chỗ đã giữ.
  3. Gửi lại thì trả bản đã lưu, kèm header Idempotent-Replayed: true để client biết đây là bản phát lại.

Khác biệt nằm ở chỗ bản PostgreSQL đặt dòng giữ chỗ trong cùng giao dịch với đơn hàng: tx.Commit() ghi cả hai hoặc không ghi gì. Bản Redis có hai hệ thống, nên có một cửa sổ nhỏ giữa "tạo đơn xong" và "ghi phản hồi vào Redis" — mình sẽ quay lại chuyện đó ở phần đánh đổi.

Đo thật: 6 đơn so với 1

Server Go dùng net/http, pgx nối PostgreSQL 16, go-redis nối Redis 7. Hàm tạo đơn cố ý ngủ 30 ms để giả lập gọi cổng thanh toán. Bốn kịch bản:

Ảnh chụp output thật nền tối không khoá 6 đơn cho 1 lần mua có khoá đúng 1 với Go 1.23 pgx v5 go-redis v9 PostgreSQL 16 Redis 7 taoDon khoảng 30 ms, kịch bản 1 client timeout 10 ms server cần 30 ms retry 5 lần cùng một key rồi lần 6 chờ đủ cho thấy không khoá 5 lần timeout lần thứ 6 nhận 201 order_id 6 và đơn hàng trong DB là 6, khoá Redis 1 lần timeout lần thứ 6 nhận 201 order_id 1 và đơn hàng trong DB là 1, khoá PG 3 lần timeout lần thứ 6 nhận 201 order_id 1 và đơn hàng trong DB là 1 với ghi chú retry bị chờ khoá dòng, kịch bản 2 50 request đồng thời cùng key cùng nội dung như bấm nút nhiều lần nhiều tab cho thấy không khoá mã trả về 201 nhân 50 với 50 phản hồi khác nhau và 50 đơn trong DB, khoá Redis 201 nhân 1 và 409 nhân 49 đang được xử lý với 1 đơn trong DB, khoá PG 201 nhân 50 cùng một order_id 1 với 1 đơn trong DB và ghi chú 49 request chờ rồi nhận replay, kịch bản 3 dùng lại key cũ nhưng đổi số tiền cho thấy cả khoá Redis và khoá PG lần 1 trả 201 order_id 1 còn lần 2 với so_tien 1 trả 422 cùng key nhưng khác nội dung, kịch bản 4 chi phí thêm vào với 200 đơn mới key khác nhau và replay nhanh bao nhiêu gồm không khoá lần đầu p50 33.88 ms gửi lại cùng key p50 34.16 ms và 400 đơn trong DB trên 400 request, khoá Redis 34.57 ms và 0.22 ms với 200 đơn, khoá PG 34.45 ms và 0.28 ms với 200 đơn kèm ghi chú thêm khoảng 0,6 ms mỗi request để không bao giờ tạo đơn đôi, nói thẳng khoá idempotency không làm request đầu nhanh hơn mà làm request thứ hai trở đi rẻ 0,2 ms thay vì 34 ms và vô hại, Redis và PostgreSQL khác nhau ở chỗ xử lý request trùng đang chạy Redis từ chối ngay bằng 409 còn PostgreSQL bắt chờ rồi trả replay

Hình 2: Kết quả thật của bốn kịch bản. Con số quan trọng nhất là cột cuối: số đơn hàng thật sự nằm trong database.

Kịch bản 1 là tình huống mở đầu bài. Client đặt timeout 10 ms trong khi server cần 30 ms, nên lần nào cũng bỏ cuộc trước khi phản hồi về, và retry tổng cộng 5 lần với cùng một key; lần thứ 6 chờ đủ lâu. Bản không khoá: 6 đơn hàng cho một lần mua, vì server không có cách nào biết sáu request đó là một ý định. Hai bản có khoá: đúng 1 đơn, và lần thứ 6 nhận lại order_id: 1 — phản hồi của lần đầu tiên, không phải một đơn mới.

Để ý cột "lần timeout": bản Redis chỉ timeout 1 lần, bản PostgreSQL timeout 3 lần. Đây không phải nhiễu đo, mà là khác biệt hành vi sẽ rõ hơn ở kịch bản 2.

Kịch bản 2: 50 request cùng key đến cùng lúc — người dùng bấm nút liên tục, hoặc mở nhiều tab. Không khoá: 50 đơn. Redis: 1 request nhận 201, 49 request nhận 409 "đang được xử lý" ngay lập tức, vì SET NX thua là biết ngay. PostgreSQL: cả 50 request nhận 201 với cùng order_id: 1 — 49 request thua không bị từ chối mà bị chặn chờ ở câu INSERT ... ON CONFLICT cho tới khi giao dịch đầu commit, rồi đọc được phản hồi đã lưu và phát lại.

Đó là lý do kịch bản 1 bản PostgreSQL timeout nhiều hơn: retry thứ hai đến khi request đầu đang giữ khoá dòng, nó chờ, và chờ thì quá 10 ms. Hai cách đều đúng, nhưng chúng đặt gánh nặng ở hai chỗ khác nhau: Redis bắt client phải xử lý 409 (chờ rồi gửi lại), PostgreSQL bắt server giữ một kết nối database cho mỗi request đang chờ. Với 50 request đồng thời, đó là 50 kết nối từ pool — mình đã phải nâng pool_max_conns lên 20 để kịch bản này không tự nghẽn.

Kịch bản 3: cùng key, khác nội dung. Client có bug, gửi lại key cũ với so_tien: 1. Cả hai bản trả 422, vì key được lưu kèm vân tay SHA-256 của thân request. Không có bước này, client sẽ nhận phản hồi của đơn 250.000 đồng và tưởng đơn 1 đồng đã thành công — một lỗi im lặng, khó tìm hơn nhiều so với một đơn bị tạo đôi.

Kịch bản 4: chi phí. 200 đơn mới rồi gửi lại 200 key đó. Lần đầu: 33,88 ms không khoá so với 34,57 ms Redis và 34,45 ms PostgreSQL — thêm khoảng 0,6 ms cho một lần giữ chỗ và một lần lưu phản hồi. Lần gửi lại: không khoá vẫn 34 ms và tạo thêm 200 đơn; có khoá chỉ 0,22–0,28 ms vì server không làm gì ngoài đọc bản đã lưu. Khoá idempotency không làm request đầu nhanh hơn; nó làm request thứ hai trở đi rẻ và vô hại.

Chuyện nhỏ nhưng thật: phản hồi phát lại không giống byte-by-byte

Trong kịch bản 2, bản PostgreSQL báo "2 phản hồi 201 khác nhau" dù chỉ có một đơn. Nhìn kỹ: bản gốc là {"order_id":1,"trang_thai":"da_tao"}, bản phát lại là {"order_id": 1, "trang_thai": "da_tao"} — có khoảng trắng. Cột phan_hoi kiểu jsonb phân tích rồi định dạng lại JSON khi đọc ra, nên bytes khác dù nội dung như nhau. Client so sánh JSON theo cấu trúc thì không sao; client so chuỗi, hoặc ký số thân phản hồi, thì lệch. Muốn phát lại đúng từng byte thì lưu cột text hoặc bytea, không phải jsonb.

Đánh đổi cần cân nhắc

Bản Redis có hai hệ thống, nên có hai lúc để hỏng. Nếu tiến trình chết sau khi commit đơn nhưng trước khi ghi phản hồi vào Redis, key đứng ở trạng thái "đang xử lý" cho tới hết TTL 24 giờ — mọi retry nhận 409 dù đơn đã tạo xong. Cách chữa thường dùng là đặt TTL ngắn cho trạng thái "đang xử lý" (vài phút) và TTL dài cho trạng thái "đã xong", hoặc chấp nhận bản PostgreSQL — nơi COMMIT ghi cả đơn và khoá, không có khoảng hở. Cái giá của bản PostgreSQL là bảng idem_keys lớn dần và phải có job dọn key cũ, vì PostgreSQL không có TTL.

409 hay chờ — chọn theo client của bạn. Trình duyệt và ứng dụng di động xử lý 409 dễ: hiện "đang xử lý", thử lại sau một giây. Một hệ thống gọi từ hàng đợi (worker nhận message và retry tự động) thì hợp với kiểu chờ hơn, vì nó không có người để báo. Có thể lai: bản Redis chờ-rồi-đọc bằng cách poll key trong vài trăm mili-giây trước khi trả 409.

Key là của ai, sống bao lâu. Key phải gắn với tài khoản gửi nó (idem:<user_id>:<key>), nếu không hai người dùng tình cờ cùng key sẽ nhận phản hồi của nhau. TTL 24 giờ là con số Stripe dùng; retry thật hiếm khi kéo dài quá vài phút, nhưng 24 giờ che được cả trường hợp một job đêm chạy lại.

Số đo ở đây là loopback. Thêm 0,6 ms mỗi request là chi phí một lần SET NX + một lần SET qua mạng nội bộ. Qua mạng thật, con số này tỉ lệ với độ trễ tới Redis hoặc database, nhưng vẫn là một phần nhỏ so với 30 ms của việc gọi cổng thanh toán — và không có phép đo nào ở đây cho thấy nó đáng để bỏ qua.

Ba ý mang về

  1. Retry không có khoá idempotency là tạo đơn đôi, đo được: client timeout 10 ms retry 5 lần tạo 6 đơn, 50 request đồng thời tạo 50 đơn. Có khoá: đúng 1 đơn trong cả hai trường hợp, và lần gửi lại chỉ tốn 0,22 ms thay vì 34 ms.
  2. Giữ chỗ phải là một thao tác nguyên tử — SET NX hoặc INSERT ... ON CONFLICT — và key phải lưu kèm vân tay thân request, nếu không client gửi nhầm số tiền sẽ nhận phản hồi của đơn cũ mà không ai biết.
  3. Redis và PostgreSQL khác nhau ở request trùng đang chạy: Redis trả 409 ngay (49/50), PostgreSQL bắt chờ rồi phát lại (50/50 nhận 201) nhưng giữ một kết nối cho mỗi request chờ. Bản PostgreSQL không có khoảng hở giữa "tạo đơn" và "lưu phản hồi" vì cùng một COMMIT; bản Redis thì có, và phải dùng TTL để tự chữa.

Nguồn

Phần sau bàn về thứ client nhận được khi mọi chuyện không ổn: mã trạng thái nào cho lỗi nào, thân lỗi nên có gì để máy đọc được, và vì sao một 500 là thất bại của người thiết kế API chứ không phải của người gọi.