Trên mạng, một client có thể không biết request của mình đã tới server hay chưa. Timeout, proxy thử lại, người dùng bấm nút lần nữa: tất cả tạo ra cùng một ý định được gửi nhiều lần. Với API đọc dữ liệu, chuyện đó thường vô hại. Với API tạo giao dịch, gửi lại mà không có quy ước thì có thể trừ tiền hai lần.
Bài này là kiến thức minh hoạ. Mình dùng tài khoản, số tiền và mã key giả để nói về hình dạng của một API. Đây không phải đặc tả nội bộ của Anfin hay của bất kỳ hệ thống đang chạy nào. Nếu bạn đang xem một hợp đồng API thật, hãy đọc đúng tài liệu của hệ thống đó.
Chuyện gì xảy ra khi request bị gửi lạiLiên kết tới mục này
Hình dung một ứng dụng gọi API để ghi nhận một khoản 150.000đ. Server nhận request, ghi giao dịch, rồi trả lời. Gói trả lời bị rớt trên đường về. Ứng dụng hết giờ chờ và nghĩ là thất bại, nên gửi lại đúng nội dung. Nếu server xử lý lần hai như một ý định mới, người dùng bị trừ hai lần cho một việc.
Idempotent, trong ngữ cảnh này, nghĩa là nhiều lần gọi với cùng một ý định cho cùng một kết quả ở phía sau. Lần đầu tạo giao dịch. Những lần sau nhận ra đây là ý định đã thấy và trả lại kết quả đã lưu, thay vì tạo thêm. “Cùng kết quả” không bắt buộc cùng mã HTTP từng ký tự, nhưng hiệu ứng trên sổ giao dịch phải là một.
Idempotency keyLiên kết tới mục này
Cách làm phổ biến là client tạo một khoá cho mỗi ý định và gửi kèm request, thường ở header Idempotency-Key. Khoá nên do client sinh trước khi gọi, và được giữ lại để dùng cho mọi lần gửi lại của cùng ý định đó. Server lưu khoá cùng với phần nội dung đã chuẩn hoá và kết quả xử lý.
Khoá không thay thế cho định danh giao dịch ở phía nghiệp vụ. Nó trả lời câu hỏi “lần gọi này có phải là lần gọi trước đó không”. Một khoá tốt là duy nhất trong phạm vi người gọi và khoảng thời gian bạn còn giữ kết quả. UUID là lựa chọn dễ hiểu trong ví dụ. Điều quan trọng hơn kiểu mã là client không đổi khoá khi chỉ đang thử lại, và không tái sử dụng khoá cho một ý định khác.
Ví dụ request và responseLiên kết tới mục này
Lần gọi đầu tiên. Mình viết HTTP ở dạng văn bản để đọc, không phải một bản ghi log thật.
POST /v1/demo/payments HTTP/1.1
Host: api.example.local
Content-Type: application/json
Idempotency-Key: 7f1c0c3e-1b2a-4a7e-9c2d-6a0e5b1d4f88
{
"accountId": "acc_demo",
"amount": 150000,
"currency": "VND",
"reference": "demo-order-18"
}Nếu server xử lý xong và đây là lần đầu thấy khoá, một response minh hoạ có thể là 201. Phần thân chứa định danh giao dịch giả và trạng thái.
{
"paymentId": "pay_demo_1001",
"status": "recorded",
"amount": 150000,
"currency": "VND",
"idempotencyKey": "7f1c0c3e-1b2a-4a7e-9c2d-6a0e5b1d4f88"
}Khi client gửi lại đúng khoá và đúng nội dung, server không tạo pay_demo_1002. Nó trả bản đã lưu. Một số API giữ nguyên 201, số khác trả 200 để phân biệt lần tạo và lần đọc lại. Cả hai đều chấp nhận được nếu tài liệu nói rõ và phía sổ sách chỉ có một giao dịch. Điều không chấp nhận được là im lặng tạo thêm một bản ghi.
Các trường hợp lỗi cần nói trướcLiên kết tới mục này
Phần khó của idempotency không phải lần thành công, mà là các lần lệch. Dưới đây là những tình huống mình muốn một tài liệu API mẫu phải có câu trả lời, trước khi ai đó viết client.
| Tình huống | Key | Nội dung | Kết quả minh hoạ | Việc client nên làm |
|---|---|---|---|---|
| Lần đầu, xử lý xong | Mới | Hợp lệ | 201 và lưu kết quả | Giữ key để phòng gửi lại |
| Gửi lại giống hệt | Đã thấy | Giống lần đầu | Trả kết quả đã lưu | Không tạo ý định mới |
| Cùng key, khác số tiền | Đã thấy | Khác bản đã lưu | 422 | Đổi key nếu đây là ý định khác |
| Server chết giữa chừng | Đã nhận | Chưa có kết quả cuối | 409 hoặc đang xử lý | Gửi lại cùng key, không đổi thân |
| Hết hạn lưu key | Quá hạn | Bất kỳ | 409 | Hỏi người dùng trước khi tạo key mới |
- Cùng key, khác nội dung. Đây là lỗi của client, không phải một bản cập nhật. Server nên từ chối thay vì ghi đè kết quả cũ. Nếu không, một key bị tái sử dụng sẽ làm mất dấu vết của giao dịch đầu.
- Đang xử lý. Lần gửi lại có thể đến khi lần đầu chưa xong. API cần một trạng thái trung gian để client chờ hoặc thử lại, thay vì mở một giao dịch thứ hai.
- Hết hạn. Không ai lưu key mãi. Khi hết hạn, tạo key mới đồng nghĩa với một ý định mới. Với tiền, bước đó nên do người dùng xác nhận, không phải do một vòng retry tự động.
Lưu khoá ở đâuLiên kết tới mục này
Trong ví dụ, mình hình dung một bảng nhỏ: key, phần hash của thân request, response đã trả, và thời điểm hết hạn. Thao tác “nhìn thấy key lần đầu và ghi giao dịch” cần nằm trong cùng một quyết định, để hai request song song không cùng vượt qua bước kiểm tra. Cách làm cụ thể tuỳ database. Ý tưởng thì ổn định: khoá là một ràng buộc duy nhất, không phải một biến nhớ trong bộ nhớ của một instance.
Bộ nhớ cục bộ của một process không đủ. Request thứ hai có thể rơi vào instance khác. Nếu bạn có nhiều bản sao của dịch vụ, kho lưu key phải là chỗ mọi bản sao cùng nhìn thấy, và phải sống lâu hơn một lần deploy. Đây cũng là lý do một cache thuần tuý, có thể bị xoá khi đầy, là chỗ rủi ro để giữ kết quả giao dịch.
Hợp đồng nên viết những gìLiên kết tới mục này
- Client tạo key trước lần gọi đầu và dùng lại nguyên key đó cho mọi lần thử lại của cùng một ý định.
- Server định nghĩa thế nào là “cùng nội dung”: những trường nào tham gia so sánh, chữ ký số tiền và tiền tệ ra sao.
- Thời gian giữ kết quả, mã trả về khi hết hạn, và mã trả về khi key trùng nhưng thân request khác.
- Hành vi khi request trước chưa xong. Client được phép gửi lại sau bao lâu.
Một API giao dịch dễ dùng là API mà client biết phải làm gì khi mạng gãy. Idempotency key không làm hết việc của sổ cái, đối soát hay phân quyền. Nó chỉ bịt một lỗ rất cụ thể: ý định bị gửi lại. Nếu bạn đang đọc thêm về chuyện đo trước khi tối ưu các đường đi này, bài Những điều cần nghĩ trước khi tối ưu một hệ thống backend nói về phần đó.