Tuyển Dụng & Phỏng Vấn
Tuyển Dụng & Phỏng Vấn

Thiết Kế Hệ Thống: Idempotency Key Là Gì Và Cách Trả Lời Khi Bị Hỏi Trong Phỏng Vấn

Người dùng bấm "Thanh toán", mạng chập chờn, app tự gửi lại request và tài khoản bị trừ tiền hai lần. Idempotency key là cách chuẩn để chặn chuyện này, và là câu hỏi rất hay gặp ở vòng thiết kế hệ thống. Bài này đi từ khái niệm tới thiết kế cụ thể.

· 7 phút đọc
Ảnh bìa bài “Thiết Kế Hệ Thống: Idempotency Key Là Gì Và Cách Trả Lời Khi Bị Hỏi Trong Phỏng Vấn”

Tóm tắt nhanh

  • Idempotent nghĩa là gọi một thao tác một lần hay nhiều lần thì kết quả cuối cùng như nhau. GET, PUT, DELETE theo chuẩn HTTP là idempotent; POST (tạo đơn, thanh toán) thì không.
  • Idempotency key là một chuỗi duy nhất client gửi kèm request (thường trong header Idempotency-Key). Server lưu khoá cùng kết quả lần xử lý đầu; request sau mang cùng khoá thì trả lại đúng kết quả cũ thay vì xử lý lại.
  • Thiết kế cần trả lời bốn câu: lưu khoá ở đâu (ràng buộc duy nhất trong database), request trùng đang chạy thì sao (khoá tạm, trả 409), cùng khoá khác nội dung thì sao (từ chối, 422), giữ khoá bao lâu (vd. 24 giờ).

Hãy tưởng tượng: người dùng bấm "Thanh toán 500.000đ". Request tới server, server trừ tiền thành công, nhưng phản hồi bị mất vì mạng điện thoại chập chờn. App không nhận được gì nên tự gửi lại (retry), hoặc người dùng sốt ruột bấm thêm lần nữa. Nếu server xử lý cả hai request như nhau, khách bị trừ 1.000.000đ.

Đây là lỗi kinh điển của mọi hệ thống thanh toán, đặt hàng, chuyển tiền, và cũng là lý do idempotency key xuất hiện trong rất nhiều buổi phỏng vấn thiết kế hệ thống.

1. Idempotent là gì?

Một thao tác idempotent là thao tác thực hiện một lần hay nhiều lần thì trạng thái cuối cùng của hệ thống vẫn như nhau.

  • Đặt số dư bằng 100 là idempotent: làm 3 lần, số dư vẫn 100.
  • Cộng thêm 100 vào số dư không idempotent: làm 3 lần, số dư tăng 300.

Theo chuẩn HTTP, GET, HEAD, PUT, DELETE được định nghĩa là idempotent; POST thì không. Vấn đề là các thao tác quan trọng nhất (tạo đơn hàng, thanh toán, chuyển khoản) lại thường là POST. Ta cần một cơ chế để biến một thao tác không idempotent thành an toàn khi bị gửi lại.

2. Vì sao không chỉ "chặn bấm hai lần" ở giao diện?

Khoá nút "Thanh toán" sau khi bấm là cần, nhưng không đủ:

  • Client có thể tự retry khi hết thời gian chờ (timeout), không qua tay người dùng.
  • Proxy, load balancer hay thư viện HTTP có thể gửi lại request.
  • Người dùng mở hai tab, hoặc app bị tắt rồi mở lại giữa chừng.
  • Ở hệ thống phân tán, chính các service cũng gọi nhau và retry.

Server không thể phân biệt "người dùng muốn thanh toán hai lần" với "cùng một lần thanh toán bị gửi lại", trừ khi client nói cho server biết. Đó là việc của idempotency key.

3. Idempotency key hoạt động thế nào?

  1. Trước khi gửi, client tạo một khoá duy nhất cho thao tác đó (thường là UUID v4) và gửi kèm, ví dụ header Idempotency-Key: 3f6c9e2a-….
  2. Lần retry của cùng thao tác dùng lại đúng khoá đó. Thao tác mới (người dùng thật sự muốn trả thêm một đơn khác) thì tạo khoá mới.
  3. Server nhận request:
    • Khoá chưa từng thấy: xử lý bình thường, lưu kết quả kèm khoá.
    • Khoá đã xử lý xong: không xử lý lại, trả về đúng phản hồi đã lưu.
    • Khoá đang được xử lý bởi một request khác: trả lỗi để client thử lại sau.

Nhiều cổng thanh toán lớn dùng đúng cách này cho API của họ, và IETF có bản thảo chuẩn hoá header Idempotency-Key cho HTTP.

4. Thiết kế phía server

4.1. Lưu khoá ở đâu?

Một bảng riêng, có ràng buộc duy nhất trên khoá (kèm người dùng, vì khoá do client tạo):

create table idempotency_keys (
  user_id       bigint not null,
  key           text not null,
  request_hash  text not null,        -- băm nội dung request để phát hiện dùng lại khoá sai
  status        text not null,        -- 'processing' | 'done'
  response_code int,
  response_body jsonb,
  created_at    timestamptz not null default now(),
  primary key (user_id, key)
);

Ràng buộc duy nhất là điểm mấu chốt: khi hai request cùng khoá tới cùng lúc, chỉ một cái chèn dòng thành công; cái còn lại nhận lỗi trùng khoá và biết có request khác đang xử lý. Kiểm tra bằng select trước rồi mới insert thì không an toàn, vì hai request có thể cùng thấy "chưa có" rồi cùng xử lý.

4.2. Luồng xử lý

  1. insert dòng với status = 'processing'. Nếu bị trùng khoá:
    • dòng đã done và request_hash khớp: trả lại response_code, response_body đã lưu;
    • dòng đã done nhưng request_hash khác: client dùng lại khoá cho một request khác, từ chối (thường là 422);
    • dòng còn processing: trả 409 Conflict, client đợi rồi thử lại.
  2. Thực hiện nghiệp vụ (trừ tiền, tạo đơn). Tốt nhất là trong cùng transaction với việc cập nhật dòng khoá, để hai việc cùng thành công hoặc cùng thất bại.
  3. Cập nhật dòng khoá thành done, lưu phản hồi.

4.3. Khi gọi sang hệ thống bên ngoài

Nếu bước nghiệp vụ gọi sang cổng thanh toán hay ngân hàng, transaction của bạn không bao được phía bên kia. Khi đó:

  • Truyền tiếp một khoá idempotency sang dịch vụ bên ngoài (nếu họ hỗ trợ), suy ra từ khoá của bạn.
  • Ghi lại trạng thái từng bước để khi request bị gián đoạn giữa chừng, lần retry biết làm tiếp từ đâu thay vì làm lại từ đầu.

4.4. Lỗi thì lưu không?

  • Lỗi do nội dung request (thiếu tiền, dữ liệu sai): lưu như một kết quả, retry nhận lại đúng lỗi đó.
  • Lỗi tạm thời phía server (database mất kết nối giữa chừng): xoá dòng processing hoặc cho nó hết hạn để lần retry được xử lý lại.

4.5. Giữ khoá bao lâu?

Đủ lâu để phủ mọi lần retry hợp lý, ví dụ 24 giờ, rồi xoá bằng job dọn dẹp định kỳ. Giữ mãi thì bảng phình to vô ích.

5. Trả lời trong phỏng vấn thiết kế hệ thống

Đề hay gặp: "Thiết kế API thanh toán sao cho người dùng không bị trừ tiền hai lần." Một khung trả lời gọn:

  1. Nêu vấn đề: retry do timeout, bấm nhiều lần, gửi lại ở tầng mạng; server không phân biệt được nếu không có thông tin thêm.
  2. Đề xuất idempotency key do client tạo, gửi trong header, retry dùng lại khoá.
  3. Thiết kế lưu trữ: bảng có ràng buộc duy nhất, trạng thái processing/done, lưu phản hồi, băm nội dung request.
  4. Xử lý trường hợp khó: hai request cùng khoá chạy đồng thời, cùng khoá khác nội dung, server chết giữa chừng, gọi sang hệ thống bên ngoài.
  5. Vận hành: thời hạn lưu khoá, job dọn dẹp, chỉ số theo dõi số request trùng.

Người phỏng vấn thường hỏi tiếp: "Dùng Redis thay database được không?" Được, với SET key value NX EX <thời hạn> để chiếm khoá, nhưng cần nói rõ đánh đổi: Redis mất dữ liệu khi sự cố là có thể xử lý trùng; với thanh toán, lưu trong cùng database và transaction với nghiệp vụ thường an toàn hơn.

Ôn thêm các đề khác ở bộ câu hỏi ôn phỏng vấn system design và bài vòng system design 45 phút. Thảo luận của cộng đồng về chủ đề này: Idempotency key: cách chống trừ tiền hai lần.

Câu hỏi thường gặp

Idempotency key là gì?

Là một chuỗi duy nhất client gửi kèm request cho một thao tác (thường trong header Idempotency-Key). Server dùng nó để nhận ra request gửi lại của cùng thao tác và trả lại kết quả cũ thay vì xử lý lần nữa.

Ai tạo idempotency key, client hay server?

Client. Chỉ client biết hai request là cùng một thao tác được gửi lại hay là hai thao tác khác nhau. Client tạo khoá (thường là UUID v4) trước lần gửi đầu và dùng lại nó cho mọi lần retry.

Có cần idempotency key cho request GET không?

Thường là không. GET chỉ đọc dữ liệu nên gửi lại nhiều lần không thay đổi trạng thái hệ thống. Idempotency key dùng cho các thao tác ghi không idempotent như POST tạo đơn, thanh toán.

Hai request cùng khoá đến cùng lúc thì xử lý thế nào?

Dựa vào ràng buộc duy nhất trong database: chỉ request chèn được dòng khoá trước mới được xử lý; request còn lại nhận 409 Conflict và thử lại sau, khi đó sẽ nhận được kết quả đã lưu.

Bài của ban biên tập Drama Công Ty, chuyên mục Tuyển Dụng & Phỏng Vấn. Có chuyện muốn kể? Viết bài ẩn danh.