
Mục lục
Ác mộng thanh toán 2 lần
Hãy tưởng tượng bạn đang mua một chiếc iPhone trên trang thương mại điện tử. Bạn nhập mã thẻ tín dụng, bấm nút "Thanh toán". Hệ thống xoay đều... 10 giây sau trình duyệt báo "Lỗi kết nối mạng (Timeout)".
Lúc này bạn làm gì? Theo phản xạ tự nhiên, bạn sẽ bấm nút F5 tải lại trang hoặc bấm nút "Thanh toán" thêm một lần nữa. Màn hình báo "Thanh toán thành công!". Nhưng 1 phút sau, điện thoại ting ting 2 tin nhắn trừ tiền từ ngân hàng 😱.
Tại sao lại có chuyện này?
- Lần bấm thứ 1: API trừ tiền bay lên server. Server xử lý trừ tiền thành công, nhưng trong lúc trả kết quả về cho điện thoại của bạn thì rớt mạng. App của bạn tưởng lỗi nên báo Timeout.
- Lần bấm thứ 2: API trừ tiền bay lên server lần 2. Server không biết đây là thao tác bị trùng lặp do lỗi mạng, nó cứ ngỡ bạn mua chiếc iPhone thứ 2 nên trừ tiền tiếp.
Đây là bài toán kinh điển trong môi trường mạng không đáng tin cậy (Unreliable Network). Để giải quyết vấn đề này, các kỹ sư phần mềm sinh ra khái niệm Idempotency.
Idempotency (Lũy đẳng) là gì?
Idempotency (tiếng Việt: Tính lũy đẳng) là một khái niệm xuất phát từ Toán học, nhưng được ứng dụng rộng rãi trong Khoa học máy tính.
Một thao tác được gọi là idempotent nếu: Việc bạn thực hiện thao tác đó 1 lần hay 1000 lần thì kết quả cuối cùng (trạng thái hệ thống) vẫn không thay đổi so với việc bạn thực hiện nó đúng 1 lần duy nhất.
Ví dụ ngoài đời thực:
- Nút bấm thang máy: Dù bạn nhấn nút gọi thang máy ở tầng 1 một lần hay điên cuồng bấm 100 lần, thì hệ thống vẫn chỉ gọi 1 cái thang máy xuống tầng 1 cho bạn. Đây là Idempotent.
- Hành động cộng 1: Nếu biến số dư là 100$, thao tác
balance = balance + 10$không phải là Idempotent. Nếu gọi 3 lần, nó sẽ ra 130$. - Hành động gán giá trị: Nếu thao tác là
balance = 110$, thì dù bạn chạy code này bao nhiêu lần, balance vẫn mãi là 110$. Đây là Idempotent.
HTTP Methods và Tính Lũy đẳng
Theo chuẩn thiết kế RESTful API, các HTTP Methods đã được quy ước rõ ràng về tính lũy đẳng:
- ✅ GET, HEAD, OPTIONS: Lũy đẳng. Đọc dữ liệu bao nhiêu lần cũng không làm thay đổi database.
- ✅ PUT: Lũy đẳng. PUT dùng để thay thế toàn bộ một tài nguyên. VD:
PUT /users/1 { name: "Canh" }. Bạn chạy 10 lần thì user 1 vẫn tên là Canh. - ✅ DELETE: Lũy đẳng. Xóa user 1 lần đầu thì thành công (200 OK), xóa lần 2 thì báo lỗi (404 Not Found), nhưng trạng thái hệ thống (user 1 đã bốc hơi) là không thay đổi.
- ❌ POST: KHÔNG lũy đẳng. POST thường dùng để tạo mới. Gọi
POST /orders3 lần sẽ đẻ ra 3 cái đơn hàng mới trong database. - ❌ PATCH: Tùy trường hợp, nhưng mặc định là KHÔNG lũy đẳng. Ví dụ lệnh
PATCH /users/1 { points: +10 }(cộng thêm 10 điểm) nếu gọi 5 lần sẽ cộng 50 điểm.
Triển khai Idempotency Key cho API
Để biến một API KHÔNG lũy đẳng (ví dụ POST thanh toán) thành một API lũy đẳng, giải pháp tiêu chuẩn được các ông lớn như Stripe, PayPal sử dụng là truyền thêm một header tên là Idempotency-Key.
Quy trình hoạt động:
- Client (Mobile App/Web) tạo ra một chuỗi ngẫu nhiên duy nhất (UUID) cho mỗi giao dịch, gọi là
Idempotency-Key. - Client đính kèm key này vào HTTP Header:
Idempotency-Key: e4b7...khi gọi API thanh toán. - Server nhận được request, sẽ lấy key này ra tra cứu trong Database (hoặc Redis).
- Trường hợp 1: Key này chưa từng tồn tại. Server cho phép xử lý giao dịch, trừ tiền, rồi lưu kết quả thành công vào Database cùng với Idempotency Key đó.
-
Trường hợp 2: App bị lỗi mạng, client gửi lại Y HỆT request cũ cùng cái
Idempotency-Keycũ. Server tra database thấy "À, key này mình vừa xử lý xong, trừ tiền rồi". Thay vì trừ tiền lần nữa, Server sẽ chặn lại và trả về nguyên xi kết quả thành công của lần 1.
Nhờ vậy, dù Client có retry do rớt mạng 10 lần, Server vẫn bảo đảm chỉ trừ tiền đúng 1 lần.
Xử lý Race Condition với Lock
Nghe thì có vẻ hoàn hảo, nhưng nếu Client gửi liên tiếp 2 request cực nhanh (Double Click), chúng đến Server cùng một lúc (cách nhau 1 mili-giây) thì sao?
Lúc này, cả 2 request cùng tra cứu Database và cùng thấy "Key này chưa ai xài". Thế là cả 2 cùng nhảy vào trừ tiền! Lỗi trừ tiền 2 lần lại tái diễn do hiện tượng Race Condition.
Quy trình an toàn tuyệt đối:
- Bước 1: Cố gắng lưu
Idempotency-Keyvào Redis với cơ chếSETNX(Set if Not eXists), hoặc `INSERT` vào bảng SQL với cột `idempotency_key` có đánh indexUNIQUE. - Bước 2: Chỉ khi insert/set thành công, request đó mới "giành được quyền" xử lý.
- Bước 3: Các request đến cùng lúc (chậm hơn 1ms) sẽ bị báo lỗi Duplicate Key (hoặc SETNX trả về 0). Lúc này chỉ cần cho các request chậm chân đợi một lát (Poling) hoặc báo lại "Giao dịch đang được xử lý, vui lòng chờ".
Tổng kết
Tính Lũy đẳng (Idempotency) là một khái niệm sống còn trong việc xây dựng các hệ thống phân tán, thanh toán, hoặc đơn giản là các tính năng thiết yếu (như gửi email, tạo đơn hàng). Bằng cách kết hợp Idempotency-Key từ client và cơ chế kiểm soát đồng thời (Lock/Unique) ở phía Server, bạn sẽ mang lại trải nghiệm không bao giờ lỗi (Zero-bug experience) cho người dùng, bất chấp mạng có chập chờn đến đâu đi nữa.
Đừng quên bổ sung Idempotency vào các API quan trọng trong dự án tiếp theo của bạn nhé!
