Bản Chất Của Trùng Lặp Request Trong Hệ Thống Phân Tán

Trong kiến trúc phân tán, lỗi mạng không phải là ngoại lệ mà là một điều chắc chắn sẽ xảy ra. Khi client gửi một yêu cầu POST /v1/payments, mạng có thể đánh mất gói tin ở hai thời điểm: trên đường truyền tới máy chủ, hoặc trên đường phản hồi sau khi máy chủ đã thực thi xong logic nghiệp vụ. Bởi vì các HTTP client và API Gateway tự động kích hoạt cơ chế retry khi gặp lỗi timeout hoặc TCP reset, một thao tác bấm nút duy nhất của người dùng có thể biến thành nhiều request ghi dữ liệu giống hệt nhau gửi tới backend cùng một thời điểm.

Nếu không có cơ chế idempotency (tính lũy thừa) hoàn chỉnh, các lần retry này sẽ gây ra thảm họa: trừ tiền hai lần, nhân bản đơn hàng hoặc sai lệch số dư sổ cái. Chỉ dựa vào ràng buộc duy nhất (unique constraint) trong cơ sở dữ liệu là chưa đủ, bởi các thao tác thay đổi dữ liệu thường kéo theo nhiều hệ thống phụ: gọi API thanh toán bên thứ ba (như Stripe), trừ tồn kho và phát sinh sự kiện.

Vòng Đời Và State Machine Của Idempotency

Một kiến trúc idempotency chuẩn production đòi hỏi một state machine xác định cho mỗi mã định danh duy nhất (thường được gửi qua HTTP header Idempotency-Key). Một thao tác ghi sẽ đi qua ba trạng thái rõ rệt:

  • STARTED (hoặc IN_PROGRESS): Máy chủ nhận quyền thực thi và khóa key này lại với một khoảng thời gian sống hữu hạn (TTL).
  • COMPLETED: Logic nghiệp vụ và transaction cơ sở dữ liệu đã commit thành công. Mã trạng thái HTTP và nội dung response được lưu trữ vĩnh viễn.
  • FAILED: Giao dịch gặp lỗi logic nghiệp vụ không thể phục hồi hoặc exception của ứng dụng, giải phóng khóa hoặc lưu lại kết quả thất bại để trả về sau này.

Nếu có một request trùng lặp gửi đến trong lúc trạng thái vẫn là STARTED, hệ thống phải từ chối ngay lập tức với mã 409 Conflict hoặc chờ đợi có kiểm soát cho đến khi transaction gốc hoàn tất.

Tầng 1: Chiếm Khóa Nguyên Tử Bằng Redis Lua Script

Khóa phân tán dùng lệnh SETNX tiêu chuẩn rất dễ gặp race condition nếu thao tác lấy khóa và kiểm tra dữ liệu cũ bị phân tách thành nhiều lần truyền nhận qua mạng. Chúng ta giải quyết triệt để điều này bằng cách thực thi một tập lệnh Lua nguyên tử trực tiếp bên trong Redis. Script này kiểm tra sự tồn tại của key, đọc trạng thái hiện tại, và hoặc là cấp khóa mới, hoặc trả về ngay response đã được cache trước đó.

-- KEYS[1]: Idempotency cache key (ví dụ: 'idemp:orders:req_8f14c0a')
-- ARGV[1]: Token định danh worker hiện tại (UUID)
-- ARGV[2]: TTL của khóa tính bằng giây (ví dụ: '120')

local current = redis.call('GET', KEYS[1])

if not current then
    -- Key chưa tồn tại: chiếm quyền thực thi và gán IN_PROGRESS
    local payload = cjson.encode({
        status = 'IN_PROGRESS',
        owner = ARGV[1],
        created_at = redis.call('TIME')[1]
    })
    redis.call('SET', KEYS[1], payload, 'EX', tonumber(ARGV[2]))
    return { 'ACQUIRED', '' }
else
    -- Key đã tồn tại: giải mã JSON và kiểm tra trạng thái
    local data = cjson.decode(current)
    if data.status == 'COMPLETED' then
        return { 'COMPLETED', current }
    elseif data.status == 'IN_PROGRESS' then
        return { 'LOCKED', current }
    else
        return { 'FAILED', current }
    end
end

Việc thực thi logic này nguyên tử bên trong Redis đảm bảo triệt tiêu hoàn toàn race condition. Nếu hai request giống hệt nhau chạm tới hai replica API khác nhau trong cùng một mili-giây, chính xác chỉ có một node nhận kết quả ACQUIRED, trong khi node còn lại nhận LOCKED và có thể dừng xử lý ngay lập tức.

Tầng 2: Lưu Trữ Bền Vững Và Chuyển Trạng Thái Trong PostgreSQL

Redis là bộ nhớ khả biến (volatile) nên chỉ đóng vai trò rào chắn thực thi và cache tạm thời. Nguồn dữ liệu tin cậy tối hậu bắt buộc phải nằm trong PostgreSQL. Ta thiết kế một bảng idempotency_records riêng biệt, gắn liền vòng đời request trực tiếp vào transaction của cơ sở dữ liệu ứng dụng.

CREATE TABLE idempotency_records (
    key VARCHAR(255) PRIMARY KEY,
    user_id UUID NOT NULL,
    request_path VARCHAR(255) NOT NULL,
    request_hash CHAR(64) NOT NULL,
    status VARCHAR(32) NOT NULL DEFAULT 'IN_PROGRESS',
    response_code INTEGER NULL,
    response_body JSONB NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_idempotency_user_created 
ON idempotency_records (user_id, created_at DESC);

Cột request_hash lưu mã băm SHA-256 của payload HTTP gửi lên. Nếu client bị lỗi hoặc kẻ tấn công gửi cùng một Idempotency-Key nhưng thay đổi dữ liệu bên trong (ví dụ đổi số tiền thanh toán từ $10 thành $1000), backend sẽ phát hiện mã băm không khớp và lập tức trả về mã lỗi HTTP 422 Unprocessable Entity.

Điều Phối Đầu-Cuối Trong Ứng Dụng Node.js

Dưới đây là cách thức kết hợp toàn bộ quy trình trên vào middleware xử lý đơn hàng:

import crypto from 'node:crypto';
import { db } from './database';
import { redis } from './redis';

export async function handleIdempotentOrder(req, res) {
  const idempotencyKey = req.header('Idempotency-Key');
  if (!idempotencyKey) {
    return res.status(400).json({ error: 'Missing Idempotency-Key header' });
  }

  const userId = req.user.id;
  const redisKey = `idemp:${userId}:${idempotencyKey}`;
  const workerId = crypto.randomUUID();
  const requestHash = crypto.createHash('sha256').update(JSON.stringify(req.body)).digest('hex');

  // 1. Thực thi script Lua trên Redis
  const [verdict, cachedPayload] = await redis.eval(
    LUA_IDEMPOTENCY_SCRIPT,
    1,
    redisKey,
    workerId,
    120 // TTL khóa là 120 giây
  );

  if (verdict === 'COMPLETED') {
    const cached = JSON.parse(cachedPayload);
    res.set('X-Cache-Lookup', 'HIT');
    return res.status(cached.response_code).json(cached.response_body);
  }

  if (verdict === 'LOCKED') {
    return res.status(409).json({
      error: 'Conflict: Request với khóa này đang được xử lý'
    });
  }

  // 2. Mở PostgreSQL transaction cho logic nghiệp vụ
  const client = await db.connect();
  try {
    await client.query('BEGIN');

    // Chèn bản ghi idempotency để xác nhận sở hữu
    await client.query(
      `INSERT INTO idempotency_records 
       (key, user_id, request_path, request_hash, status)
       VALUES ($1, $2, $3, $4, 'IN_PROGRESS')`,
      [idempotencyKey, userId, req.path, requestHash]
    );

    // Thực thi nghiệp vụ: trừ tiền, tạo bản ghi đơn hàng
    const orderResult = await executeOrderCreation(client, req.body);
    const responsePayload = { orderId: orderResult.id, status: 'processed' };
    const statusCode = 201;

    // Cập nhật trạng thái thành COMPLETED
    await client.query(
      `UPDATE idempotency_records
       SET status = 'COMPLETED',
           response_code = $1,
           response_body = $2,
           updated_at = NOW()
       WHERE key = $3`,
      [statusCode, JSON.stringify(responsePayload), idempotencyKey]
    );

    await client.query('COMMIT');

    // 3. Cập nhật cache trên Redis với kết quả vĩnh viễn (TTL 24 giờ)
    const finalCache = JSON.stringify({
      status: 'COMPLETED',
      response_code: statusCode,
      response_body: responsePayload
    });
    await redis.set(redisKey, finalCache, 'EX', 86400);

    return res.status(statusCode).json(responsePayload);
  } catch (err) {
    await client.query('ROLLBACK');
    // Giải phóng khóa Redis ngay lập tức nếu code nghiệp vụ ném exception
    await redis.del(redisKey);
    throw err;
  } finally {
    client.release();
  }
}

Những Vấn Đề Thực Tế Khi Triển Khai Production

1. Khóa Bị Treo (Zombie Lock) Khi Worker Bị Sập

Nếu tiến trình API worker gặp sự cố sập đột ngột (như Out-Of-Memory) giữa chừng khi đang chạy logic nghiệp vụ, khóa Redis bắt buộc phải có một khoảng thời gian sống (TTL) ngắn (khoảng 60 đến 120 giây). Tuyệt đối không bao giờ tạo khóa phân tán mà không gán TTL. Khi TTL hết hạn, request retry kế tiếp có thể thử lại. Trong PostgreSQL, hãy định kỳ chạy background sweeper để quét và chuyển các bản ghi bị kẹt ở trạng thái IN_PROGRESS quá 15 phút về FAILED.

2. Thay Đổi Payload Cùng Khóa (Payload Mutation)

Client có thể vô tình tái sử dụng một idempotency key đã sinh ra từ trước cho một thao tác hoàn toàn khác. Việc so sánh mã băm SHA-256 request_hash ngăn chặn tình trạng sai lệch dữ liệu ngầm. Ngoài ra, luôn kiểm tra quyền sở hữu để đảm bảo user_id của request hiện tại khớp chính xác với người đã tạo khóa ban đầu.

3. Trật Tự Commit Phân Tán (Distributed Commit Dilemma)

Lưu ý rằng transaction trong cơ sở dữ liệu luôn commit trước khi kết quả được ghi đè lên Redis. Nếu thao tác ghi Redis gặp lỗi kết nối mạng, tính toàn vẹn của dữ liệu trong PostgreSQL vẫn được bảo toàn trọn vẹn. Lần retry tiếp theo của client sẽ thấy khóa trên Redis đã hết hạn, đọc thẳng từ database table, thấy trạng thái COMPLETED, ghi bù lại vào Redis và trả về kết quả an toàn.

Điểm Cốt Lõi

  • Lỗi timeout không đồng nghĩa với thất bại: Không bao giờ giả định rằng một HTTP request bị timeout là logic phía server chưa được thực thi.
  • Lua script nguyên tử loại bỏ race condition: Các request retry đồng thời trong tích tắc phải được chặn đứng ngay từ tầng caching trước khi tạo tải lên lock của cơ sở dữ liệu.
  • Kiểm thực mã băm của payload: Ràng buộc chặt chẽ mỗi idempotency key với mã băm mật mã học của body request để ngăn chặn tình huống tái sử dụng key sai mục đích.
  • PostgreSQL là nguồn tin cậy tối hậu: Luôn duy trì bản ghi trạng thái trong transaction mang tính ACID của cơ sở dữ liệu quan hệ; chỉ xem Redis như một lớp tăng tốc và rào chắn khóa tạm thời.