0

[Series Phỏng Vấn Backend]: Lời Nguyền Thanh Toán - Chống Trùng Lặp Giao Dịch Bằng Tính Lũy Đẳng (Idempotency)

Chào mừng anh em quay trở lại với series Giải Mã Phỏng Vấn Backend.

Làm hệ thống bình thường, request chậm một chút không sao. Nhưng làm hệ thống thanh toán, việc sinh ra 2 mã QR cho cùng một đơn hàng, hoặc gọi API trừ tiền user 2 lần là một "tội ác" không thể tha thứ.

Hôm nay, nhà tuyển dụng sẽ đặt bạn vào một kịch bản rất thực tế:

"Trong nghiệp vụ thanh toán, tối kỵ việc tạo trùng giao dịch. Giả sử user bấm nút 'Thanh Toán' 2 lần liên tiếp do lag máy, hoặc Mobile App tự động 'retry' (gọi lại API) khi mạng chập chờn. Hậu quả là API của em nhận được 2 request gần như cùng lúc. > Làm sao em thiết kế hệ thống để đảm bảo chỉ sinh ra ĐÚNG 1 transaction / 1 mã QR, không bắt user trả tiền 2 lần?"

Nhiều anh em thậm chí là Middle thường đáp ngay: "Em dặn tụi Frontend thêm cái hiệu ứng loading và disable (khóa) cái nút bấm lại là xong!" hoặc "Em vô DB kiểm tra, nếu có transaction rồi thì không tạo nữa".

Trả lời như vậy là nộp mạng. Tại sao ư? Hãy cùng bóc tách.

1. Ảo Tưởng Về Frontend Và Cạm Bẫy Race Condition

Đầu tiên quy tắc số 1 của Backend Security: Không bao giờ tin tưởng vào Frontend. Frontend disable nút bấm chỉ chống được thao tác tay của user ngoan ngoãn. Nó không chống được việc mạng lag khiến App tự động retry, không chống được hacker dùng postman bắn API 100 lần trên 1 giây.

Nếu bạn dùng logic kiểm tra database trước rồi mới tạo" (Check-then-Act), bạn sẽ dính lại ngay đòn Race Condition (Kẽ hở TOCTOU). Hai request bắn tới cùng một mili-giây, cả hai cùng thấy DB chưa có giao dịch nào, và cả hai sẽ cùng tạo ra 2 giao dịch. Bùm!

Để giải bài toán này, chúng ta phải thiết lập 3 lớp phòng ngự từ ngoài vào trong.

2. Cấp Độ 1: Bức Tường Thép Database (Unique Constraint)

Dù bạn có code giời bể gì, Database vẫn luôn là chốt chặn sinh tử cuối cùng.

Để đảm bảo không bao giờ có 2 transaction cho cùng một đơn hàng, bạn phải thiết kế cấu trúc bảng payment_transactions với Unique Index (Chỉ mục duy nhất).

  • Đánh Unique Index lên tổ hợp cột (order_id, status) (nếu nghiệp vụ cho phép tạo lại transaction khi cái cũ đã thất bại)
  • Hoặc tạo một cột hash độc nhất cho mỗi nỗ lực thanh toán

Luồng chạy: Khi 2 request ập tới cùng lúc, rủ nhau cùng INSERT vào DB. Database (MySQL/PostgreSQL) với cơ chế Row-level locking nội tại sẽ xếp hàng chúng lại. Thằng số 1 chui lọt. Thằng số 2 sẽ bị văng một lỗi cực kỳ cục súc: Duplicate entry '...' for key '...'. Bắt lấy cái Exception này, bạn biết ngay là có hiện tượng trùng lặp và chặn đứng nó.

Nhược điểm: Chặn bằng DB rất an toàn, nhưng DB vốn đã gánh nặng. Nếu để rác (duplicate requests) đập thẳng vào DB rồi mới văng lỗi thì hệ thống sẽ rất tốn resource. Ta cần chặn nó từ cửa.

3. Cấp Độ 2: Chặn Ở Cổng Bằng Distributed Lock (Redis)

Để không làm phiền Database, chúng ta kéo Redis ra làm bảo vệ. Sử dụng cơ chế khóa phân tán (Distributed Lock) với lệnh SETNX (Set if Not eXists).

Luồng chạy:

  1. Request 1 và Request 2 cùng ập tới.
  2. Cả hai cố gắng tạo một cái khóa trên Redis: SET payment_lock_order_123 "locked" EX 10 NX.
  3. Nhờ tính chất đơn luồng (Single-thread) của Redis, chỉ duy nhất Request 1 tạo khóa thành công.
  4. Request 2 tạo khóa thất bại \rightarrow Trả về luôn lỗi HTTP 429 Too Many Requests (Bạn đang thao tác quá nhanh, vui lòng chờ).
  5. Request 1 ung dung xuống DB tạo transaction, sinh mã QR, sau đó mở khóa (Delete key) hoặc để khóa tự hết hạn (TTL).

Nhược điểm: Nếu app retry lại sau khi Request 1 đã xử lý xong và nhả khóa thì sao? Khóa không còn, Request 2 lại chui lọt và tạo ra mã QR thứ 2 à? Đây là lúc ta phải dùng đến "Tuyệt kỹ" cuối cùng.

4. Cấp Độ 3: Trùm Cuối - Tính Lũy Đẳng (Idempotency)

Đây là chuẩn mực (Industry Standard) mà các ông lớn như Stripe, PayPal, hay Momo đều sử dụng.Trong toán học và khoa học máy tính, Tính Lũy Đẳng (Idempotency) có nghĩa là: Bạn gọi một hàm 1 lần hay 100 lần, thì kết quả trả về và trạng thái hệ thống vẫn y như gọi 1 lần. Biểu diễn bằng toán học: f(f(x))=f(x)f(f(x)) = f(x).Cách triển khai Idempotency Key:

  1. Frontend tạo Key: Trước khi gọi API thanh toán, Client (Web/App) tự sinh ra một chuỗi UUID ngẫu nhiên duy nhất (gọi là Idempotency-Key) và nhét nó vào HTTP Header. (Ví dụ: Idem-Key: a1b2-c3d4).
  2. Backend ghi nhận: Khi nhận request, Backend lấy cái Key này kiểm tra trong Redis.
  3. Xử lý lần 1: Chưa có Key này \rightarrow Cho đi tiếp. Backend xử lý lấy mã QR, sau đó LƯU kết quả (Response body) vào Redis, map với cái Idempotency-Key đó (Thời gian lưu thường là 24h).
  4. Xử lý lần 2 (Bị lag, bấm đúp, retry): Client gửi lại request với cùng một cái Idempotency-Key đó. Backend nhìn thấy Key đã tồn tại trong Redis.
  5. Magic xảy ra: Backend không chạy lại bất kỳ logic nào, không query DB, không chọc sang đối tác. Nó chỉ lôi cái Response từ Redis (chứa đúng cái mã QR của lần 1) ra và trả về ngay lập tức với HTTP Status 200 OK (hoặc 201 Created).

Kết quả: Client bấm 10 lần, vẫn chỉ nhận về đúng 1 mã QR, hệ thống backend chỉ sinh ra đúng 1 transaction. Trải nghiệm người dùng cực kỳ mượt mà (App tưởng mình gọi API thành công), và Database thì thở phào nhẹ nhõm.

Tổng Kết

Chống trùng lặp giao dịch không phải là bài toán dùng lệnh IF để kiểm tra. Đó là sự kết hợp của kiến trúc hệ thống nhiều lớp:

  1. Dùng Idempotency Key (Redis) để triệt tiêu các request retry tự động / bạo lực.
  2. Dùng Distributed Lock (Redis) để chống dẫm chân nhau (Race Condition) trong cùng 1 mili-giây.
  3. Dùng Unique Constraint (Database) làm bức tường thép cuối cùng bảo vệ túi tiền của hệ thống.

Một kỹ sư nắm vững bộ 3 này có thể tự tin bước vào bất kỳ dự án Fintech ngàn đô nào mà không sợ "sập hầm"!

*** Hệ thống anh em đang dùng cơ chế nào để chống Double-click? Đã từng phải đền tiền vì bug này chưa? Cùng thả comment giao lưu nhé! Nhớ Upvote để series có thêm động lực!


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.