Idempotency - "Phép màu" cứu rỗi Production và những cú lừa kinh điển
Chào anh em, lại là mình đây. Nếu anh em làm backend đủ lâu, đặc biệt là dính đến mảng e-commerce hay fintech, chắc chắn anh em sẽ hiểu cảm giác "tim đập chân run" mỗi khi đụng vào luồng thanh toán (Payment).
Một ngày đẹp trời, hệ thống báo lỗi. Khách hàng bấm thanh toán một lần, nhưng thẻ của họ bị trừ tiền... 3 lần. Sếp gọi, CSKH réo tên, và bạn biết đêm nay mình sẽ không ngủ. Để giải quyết triệt để cơn ác mộng "double charge" (trừ tiền nhiều lần) này, có một khái niệm mà anh em backend bắt buộc phải nằm lòng: Idempotency.
Hôm nay, mình sẽ chia sẻ với anh em Idempotency là gì, cách mình áp dụng nó vào hệ thống thực tế và quan trọng nhất là những cái "bẫy" mà anh em rất dễ đạp trúng khi mới làm quen.
1. Idempotency là gì? Tại sao lại cần thiết?
Nói một cách hàn lâm (theo Toán học), Idempotency (Tính luỹ đẳng) là một tính chất mà một thao tác có thể được thực hiện nhiều lần nhưng kết quả cuối cùng vẫn không thay đổi so với lần thực hiện đầu tiên.
Nói theo ngôn ngữ của anh em dev chúng ta: Dù Client có gửi request 1 lần hay 100 lần (do lỗi mạng, do user spam click), thì Server cũng chỉ xử lý đúng 1 lần duy nhất.
Tại sao nó lại là "cứu tinh" của Payment?
Trong một thế giới mạng lý tưởng, request gửi đi sẽ đến đích và response sẽ trả về an toàn.
Nhưng thực tế thì:
Người dùng đang thanh toán thì đi vào thang máy (rớt mạng mập mờ).
Hệ thống Payment Gateway (VNPay, Momo, Stripe...) bị lag, timeout.
Người dùng thiếu kiên nhẫn, thấy quay loading lâu quá nên bấm nút "Thanh toán" liên tục.
Nếu không có cơ chế chặn, mọi request tới đều sinh ra một giao dịch mới. Kết quả là khách hàng mua 1 cái áo nhưng bị trừ tiền 5 lần. Idempotency sinh ra để ngăn chặn thảm họa này.
2. Implement Idempotency như thế nào? (Flow cơ bản)
Cách phổ biến nhất là sử dụng một Idempotency Key (thường là UUID) được tạo ra từ phía Client (Frontend/App) hoặc Backend tự sinh ra ở bước tạo order. Key này sẽ được gửi kèm trong Header của request thanh toán (VD: Idempotency-Key: 123e4567-e89b-12d3-a456-426614174000).
Luồng xử lý của backend thường diễn ra như sau:
-
Nhận Request: Backend nhận request kèm Idempotency-Key.
-
Kiểm tra Trạng thái: Chọc vào DB hoặc Cache (Redis) để tìm key này.
- Trường hợp 1 (Đã xử lý xong): Key tồn tại và có trạng thái COMPLETED. Tuyệt vời, ta chỉ cần lấy Response cũ trả về cho Client. Bỏ qua mọi logic phía sau.
- Trường hợp 2 (Đang xử lý): Key tồn tại và có trạng thái PROCESSING (có thể do request trước đó vừa tới vài giây) . Trả về lỗi 409 Conflict hoặc thông báo "Giao dịch đang được xử lý, vui lòng đợi".
- Trường hợp 3 (Chưa từng tồn tại): Đây là request mới. Lưu key này vào DB/Redis với trạng thái PROCESSING.
-
Xử lý Logic: Gọi qua 3rd-party Payment Gateway, trừ tiền, cập nhật trạng thái đơn hàng...
-
Cập nhật Trạng thái: Sau khi xử lý xong, update trạng thái của key thành COMPLETED (hoặc FAILED), lưu kèm HTTP Response để xài cho "Trường hợp 1".
3. Những "cú lừa" kinh điển (Pitfalls) khi làm Idempotency
⚠️ Bẫy số 1: Race Condition ở bước kiểm tra Key Nếu anh em viết code kiểu: SELECT kiểm tra -> Nếu không có thì INSERT. Trường hợp user click đúp cực nhanh, 2 request đến cùng 1 mili-giây. Cả 2 request đều SELECT ra kết quả là chưa tồn tại, và bùm, hệ thống lại xử lý 2 lần.
Giải pháp: Bắt buộc phải dùng cơ chế Lock. Có thể là Database Unique Constraint (đặt Unique Index cho cột Idempotency Key) hoặc Distributed Lock (dùng Redis SETNX). Request nào cắm cờ trước thì được chạy, request sau sẽ vướng lock và bị văng ra.
⚠️ Bẫy số 2: Client tạo mới Key khi Retry Nhiều bạn Mobile/Web dev chưa hiểu rõ luồng nên cấu hình: Cứ mỗi lần gọi API bị lỗi (timeout) là lại sinh ra một cái UUID mới. Thế là hỏng bét! Bản chất Idempotency dựa vào việc Key phải cố định cho một hành động nhất định. Nếu gọi API thất bại do rớt mạng, Client khi bấm retry bắt buộc phải gửi lại Key cũ của phiên thanh toán đó.
⚠️ Bẫy số 3: Cùng Idempotency Key nhưng Payload lại khác nhau User cố tình gửi request với Key cũ (ví dụ đang mua đôi giày 1 củ), nhưng họ chặn request và đổi thông số giỏ hàng thành cái Macbook 50 củ. Nếu bạn chỉ check Key mà bỏ qua body, hệ thống sẽ trả về báo thành công của đôi giày, nhưng thực tế user ẵm cái Macbook.
Giải pháp: Hash toàn bộ Payload (Request Body) lại. Khi check Idempotency Key, nhớ đối chiếu luôn xem mã Hash của Payload hiện tại có khớp với Payload lúc đầu lưu cùng Key hay không. Nếu khác -> Đá văng với lỗi 400 Bad Request.
⚠️ Bẫy số 4: Bơm rác vào DB (Quên xoá Key) Mỗi ngày hệ thống có hàng chục ngàn giao dịch, bảng idempotency_records của bạn sẽ phình to khủng khiếp. Thực tế, chúng ta chỉ cần chặn retry trong một khoảng thời gian ngắn (ví dụ: 24h hoặc 48h tuỳ nghiệp vụ).
Giải pháp: Luôn có TTL (Time-To-Live) cho các Key này. Nếu dùng Redis thì set expire dễ rồi. Còn nếu lưu DB thì cần có một cronjob (worker) chạy hàng đêm dọn dẹp các record đã quá đát.
4. Tạm kết
Idempotency không phải là một thư viện tải về là chạy, nó là một Tư duy thiết kế hệ thống. Trong môi trường microservices, không chỉ luồng Payment mà việc bắn Event (Kafka/RabbitMQ) hay giao tiếp giữa các service cũng cần có tư duy này để đảm bảo tính nhất quán của dữ liệu.
Hy vọng bài viết này giúp anh em có cái nhìn rõ ràng hơn và bớt đi vài đêm thức trắng fix bug trừ tiền.
Happy Coding! 💻
All rights reserved