← Bài trước | Series hub | Tiếp theo →

Chương 7: Củng Cố Nền Tảng Thanh Toán Bằng Idempotent APIs

Trong thế giới E-commerce hay Fintech, cơn ác mộng kinh hoàng nhất không phải là chuyện sập hệ thống, mà là trừ tiền một khách hàng tới hai lần cho cùng một hóa đơn. Cớ sự thường bắt nguồn từ mạng lag, do khách hàng sốt ruột ấn “Pay” (Thanh toán) lặp đi lặp lại, hoặc do thuật toán retry (thử lại) dập máy móc của app.

Giải pháp bắt buộc cho bất kỳ API mang tính giao dịch nào (Payment/Order) chính là Idempotency (Tính lũy đẳng / đồng dạng).

1. Tính Idempotency Là Gì?

Answer-first: Một thao tác được gọi là idempotent nếu bạn chạy nó 1 lần hay 1.000 lần thì trạng thái kết tủa của hệ thống và kết quả đầu ra vẫn y xì đúc như nhau. Trong khi GET và PUT sinh ra đã mang sẵn tính idempotent, thì thằng POST đòi hỏi kỹ thuật can thiệp bằng tay.

Chiếu theo giao thức HTTP REST APIs:

  • GET, PUT, DELETE: Bản chất đã là idempotent. (Lệnh xóa một user 10 lần thì kết cục chung quy user đó vẫn bốc hơi).
  • POST: Không phải idempotent. Cứ gõ POST /charge 10 lần là 10 lần tài khoản bay mất tiền.

2. Idempotency-Key Và Vòng Đời Của Một Request

Answer-first: Phía Clients có nghĩa vụ đính kèm một mã UUID độc nhất vô nhị gọi là Idempotency-Key vào requests của họ. Phía Server lôi cái key đó ra rà quét dưới Redis để xem cái mẻ giao dịch này là hàng mới cứng, đang kẹt xử lý, hay đã chốt sổ xong xuôi.

Muốn ép cái tính idempotency trói chặt một API POST, thiết bị Client (Mobile/Web) phải tự đúc ra một mã Unique ID (đại trà là dạng UUID v4) rồi nhét tọt nó vào Request Header: Idempotency-Key: 123e4567...

Cái server Golang sẽ đem 3 trạng thái rạch ròi ra để phân định:

  1. Trạng Thái 1 (Key Mới Tinh):

    • Server đăng ký cái Key xuống Redis gắn cái mác IN_FLIGHT (đang bay).
    • Nó quay qua thực thi business logic (đấu cổng payment gateways, trừ số dư).
    • Xong xuôi đâu đấy, nó vặn cái Key sang DONE (Hoàn tất) và lưu nguyên xi cục Response Payload nhét thẳng vô Redis. Trả kết quả về cho phía Client.
  2. Trạng Thái 2 (Key Đang Mắc Kẹt IN_FLIGHT):

    • Khách mất kiên nhẫn ấn đúp chuột. Cái Request số 2 phi tới trong lúc cái Request 1 vẫn đang è cổ chạy.
    • Server lục lọi Redis, thấy cờ IN_FLIGHT chình ình, lập tức vả gãy mặt cái Request 2, hất văng một cục lỗi HTTP 409 Conflict (hoặc 423 Locked).
  3. Trạng Thái 3 (Key Đã Xong DONE):

    • Khách bị rớt mạng ngay đúng lúc Request 1 chạy xong, xui xẻo mất luôn kết quả trả về. Khách lết vô app bấm retry y nguyên cái request mang đúng mã Key đó.
    • Server dòm Redis, thấy cờ DONE. Server CẤM CHỈ không được phép múa lại cái logic trừ tiền. Thay vào đó, nó móc lại cái cục Response Payload đã chôn dưới Redis rồi ném trả về ngay tắp lự. Khách nhận lại đúng chuẩn payload thông báo thành công mà họ đã đánh rơi.
stateDiagram-v2
    [*] --> NewRequest
    NewRequest --> CheckRedis: Header chứa Idempotency-Key
    
    CheckRedis --> IN_FLIGHT: Key Không Tồn Tại (SET NX)
    CheckRedis --> CONFLICT: Key == IN_FLIGHT
    CheckRedis --> DONE: Key == DONE
    
    IN_FLIGHT --> ExecuteLogic: Xử lý Payment
    ExecuteLogic --> SaveResponse: Chuyển Key thành DONE
    SaveResponse --> ReturnNewResponse
    
    CONFLICT --> Return409Error: "Đang Xử Lý"
    
    DONE --> ReturnCachedResponse: Trả về payload cũ

3. Rủi Ro Race Condition & Những Thao Tác Atomicity (Nguyên Tử)

Answer-first: Quá trình kiểm tra và đóng dấu cái cờ IN_FLIGHT phải diễn ra nguyên tử (atomic). Sử dụng tuyệt kỹ Redis SET key value NX để khóa chặt cam đoan rằng duy nhất chỉ cái request chạy nhanh chân tới đích đầu tiên mới chộp được cái quyền cầm khóa (processing lock).

Thế làm sao ta an tâm vừa quét vừa đặt mốc IN_FLIGHT? Giả sử hai cái requests y hệt nhau phi thẳng tới server cách nhau cỡ phần nghìn giây, mảng Go hoàn toàn có thể vô tình đọc Redis GET key (cả hai nhận về Null), tiếp đó bồi SET key IN_FLIGHT (cả hai lọt cửa thành công). Thế là toang, đẻ ra thảm họa trừ tiền đúp.

Bắt buộc phải xài các tuyệt chiêu Atomic (Nguyên tử) trong Redis: dùng lệnh SET key value NX EX ttl. Tham số NX (Set if Not Exists - Chỉ nạp nếu rỗng) bảo chứng thép rằng đúng 1 sợi thread duy nhất nhét Key lọt cửa. Cái sợi thread số 2 chậm chân sẽ ăn một cái cờ false ném từ Redis về, lập tức bị vạch mặt là đồ lặp lại.

4. Ngách Bảo Mật Tối Đa: Băm Payload (Payload Hashing)

Answer-first: Đám ác ý có thể khoét lỗ hổng tính idempotency thông qua trò xài lại một cái Key cũ nhưng tráo ruột cái payload đắt đỏ hơn. Lấp bịt bằng cách lưu chung cái mã băm SHA256 (Hash) của cái Request Body đính kèm bên cạnh Idempotency Key.

Một trò lừa phỉnh phổ thông là gã client ác ý moi móc lại cái Idempotency-Key rêu phong có mác DONE nhưng truyền đi gói payload mới tinh (ví dụ: mua cái Tivi ngàn đô). Nếu hệ thống ngu ngơ chỉ săm soi mỗi cái Key, nó sẽ hồn nhiên ném trả kết quả thành công rực rỡ từ thuở nảo thuở nào (của một món hàng bèo nhèo) mặc kệ cái payload mới kia chễm chệ ở đó!

Khắc Chế: Băm (ví dụ: SHA256) nguyên con cái Request Body. Chôn luôn cái mã Hash này kè kè sát vách cái Idempotency-Key dưới Redis. Khách quen chìa đúng Key trùng lặp mà mã Body Hash không khớp nhau, hất cẳng ngay lập tức tống một quả HTTP 400 Bad Request.

(YMYL Note: Trong giới Core Banking, mấy cái mác Idempotency keys không vứt lăn lóc dưới Redis đâu; chúng được đúc thành các cột chuẩn UNIQUE CONSTRAINT đúc đặc ngay chốn SQL Database cốt để hưởng sái trọn vẹn lớp bảo hộ vô khuyết của ACID Transactions).