+4

[Backend Architecture] Hệ thống Idempotency - Bí quyết thiết kế API "miễn nhiễm" với việc bị gọi trùng lặp

Chào anh em, tiếp nối series chống "cháy server" và "mất tiền oan", hôm nay chúng ta bàn về một khái niệm nghe tên thì rất học thuật nhưng lại là "tấm khiên" sống còn của mọi hệ thống thanh toán/giao dịch: Idempotency (Tính lũy đẳng).

Ở bài trước, chúng ta dùng Atomic Locks để ngăn các worker chạy đè lên nhau cùng 1 thời điểm. Nhưng giả sử user bấm thanh toán, mạng lag, app báo "Timeout". Khách hàng bực mình bấm thanh toán phát nữa. Lúc này request thứ 2 bay lên sau request 1 tận 30 giây. Atomic Lock lúc này vô tác dụng vì request 1 đã chạy xong và mở khóa rồi!

Hậu quả? Khách bị trừ tiền 2 lần cho 1 đơn hàng. Và đây là lúc Idempotency xuất hiện để cứu rỗi hệ thống của bạn.

1. Idempotency (Tính lũy đẳng) là gì?

Nói theo ngôn ngữ toán học: f(f(x)) = f(x). Nói theo ngôn ngữ anh em thợ code: Một API có tính Idempotency nghĩa là dù bạn gọi nó 1 lần hay 100 lần (với cùng một dữ liệu đầu vào), thì kết quả thay đổi trên hệ thống (side-effects) chỉ xảy ra ĐÚNG 1 LẦN DUY NHẤT.

  • API GET /users/1: Là Idempotent. Gọi 100 lần thì data user chả thay đổi gì.
  • API DELETE /users/1: Là Idempotent. Gọi lần 1 xóa user. Gọi lần 2, lần 3 thì user vẫn là "đã bị xóa" (có thể trả về 404, nhưng state hệ thống không đổi).
  • API POST /payments: KHÔNG Idempotent. Gọi 2 lần là trừ tiền 2 lần. Bắt buộc phải thiết kế lại!

2. Bí kíp thực chiến: Idempotency Key (Khóa lũy đẳng)

Để biến một API POST thành Idempotent, chúng ta sử dụng một "mật mã" giao kèo giữa Client (App/Web) và Server gọi là Idempotency-Key.

Luồng hoạt động chuẩn mực:

  1. Client: Trước khi gọi API tạo giao dịch, tự sinh ra một chuỗi ngẫu nhiên duy nhất (thường là UUID v4). Gắn chuỗi này vào HTTP Header: Idempotency-Key: 123e4567-e89b....
  2. Server: Nhận được request, ngay lập tức kiểm tra cái Key này trong Database hoặc Redis.
  • Trường hợp 1 (Key chưa từng tồn tại): Tuyệt vời, đây là request mới. Server lưu Key này vào Redis với trạng thái processing, xử lý trừ tiền, lưu DB. Xong xuôi, update trạng thái Key thành success kèm theo response trả về (ví dụ: {"order_id": 99}).
  • Trường hợp 2 (Key đang processing): Khách hàng bấm đúp click quá nhanh. Server trả về mã lỗi 409 Conflict (hoặc 425 Too Early) và bảo: "Từ từ, tao đang xử lý cái này rồi!".
  • Trường hợp 3 (Key đã success): Khách hàng bị timeout, gọi lại request. Server thấy Key này đã làm xong từ kiếp nào rồi, KHÔNG xử lý lại logic nữa, mà lấy luôn cái response cũ ({"order_id": 99}) trả về luôn. Khách hàng vẫn thấy app báo thành công mượt mà.

3. Triển khai bằng Middleware trong Laravel & Redis

Đừng nhét logic check Key này vào từng Controller, nhìn sẽ rất "phèn" và lặp code. Hãy viết một Middleware chặn ngay từ cửa!

namespace App\Http\Middleware;

use Closure;
use Illuminate\Support\Facades\Redis;

class IdempotencyMiddleware
{
    public function handle($request, Closure $next)
    {
        // 1. Lấy key từ Header
        $idempotencyKey = $request->header('Idempotency-Key');

        if (!$idempotencyKey) {
             // Bắt buộc phải có đối với các API quan trọng
             return response()->json(['error' => 'Missing Idempotency-Key'], 400);
        }

        $redisKey = 'idempotency:' . $idempotencyKey;

        // 2. Thử setNX (Set if Not eXists) để tránh Race Condition ngay tại bước check key
        // Nếu set thành công, nó sẽ mang giá trị 'processing'
        $isNewRequest = Redis::setnx($redisKey, json_encode(['status' => 'processing']));

        if (!$isNewRequest) {
            // Key đã tồn tại, kiểm tra trạng thái
            $cachedData = json_decode(Redis::get($redisKey), true);

            if ($cachedData['status'] === 'processing') {
                return response()->json(['error' => 'Request is already being processed'], 409);
            }

            // Nếu đã success, trả về luôn response cũ mà không thèm chạy vào Controller
            return response()->json($cachedData['response'], 200);
        }

        // Đặt TTL (Time to Live) cho key này, ví dụ 24h để dọn rác Redis
        Redis::expire($redisKey, 86400);

        // 3. Cho phép request đi tiếp vào Controller xử lý logic chính
        $response = $next($request);

        // 4. Controller xử lý xong, update lại Redis với trạng thái success và lưu response
        if ($response->isSuccessful()) {
             Redis::setex($redisKey, 86400, json_encode([
                 'status' => 'success',
                 'response' => json_decode($response->getContent(), true)
             ]));
        } else {
             // Nếu logic lỗi (ví dụ số dư không đủ), xóa key để client có thể retry
             Redis::del($redisKey);
        }

        return $response;
    }
}

4. Những cú lừa (Edge Cases) cần cảnh giác

Biết làm Middleware là ngon rồi, nhưng anh em cẩn thận mấy "cái bẫy" sau khi làm Idempotency:

  • Tráo ruột (Payload Mismatch): Cùng một Idempotency-Key, nhưng ở request 1 client gửi lên amount: 100k, request 2 gửi amount: 500k. Nếu bạn chỉ check mỗi Key thì toang! Giải pháp: Bạn nên hash (băm) cái payload gửi lên, lưu kèm vào Redis. Nếu trùng Key nhưng Hash Payload khác nhau -> chửi luôn (400 Bad Request).
  • Xóa Key khi Internal Server Error: Ở đoạn code trên, nếu API lỗi do nghiệp vụ (ví dụ hết số dư), ta xóa Key để client thử lại. Nhưng nếu lỗi do Hệ thống (Code bug, sập DB), bạn nên cân nhắc giữ lại Key (hoặc đánh dấu status error) để đội Ops điều tra, tránh client retry đập server liên tục.
  • Client "quên" gửi: Bắt buộc phía Mobile/Frontend dev phải lưu cái Key này xuống local storage của app trước khi gọi API. Chứ timeout xong tạo Key mới gửi lên thì Idempotency cũng... bất lực!

Tổng kết

Tóm lại: Atomic Lock sinh ra để chống Race Condition (chạy song song), còn Idempotency sinh ra để chống Duplicate Request (gọi lặp lại theo thời gian). Kết hợp cả hai, hệ thống thanh toán của bạn sẽ vững như bàn thạch. Stripe hay Paypal cũng đang áp dụng kiến trúc chuẩn mực này.

Hệ thống của chúng ta đã khá "lì lợm" rồi đấy. Nhưng khi Microservices phình to ra, làm sao để biết một request bị chậm là do thằng App, thằng DB hay thằng Redis? Bài viết sau mình sẽ dắt anh em làm quen với Distributed Tracing (Truy vết phân tán) - Ánh sáng cuối đường hầm cho việc Debug Microservices.

Cảm ơn anh em đã theo dõi, nếu thấy hay thì tặng mình 1 Upvote lấy động lực nhé!


All Rights Reserved

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