Thiết Kế API Idempotent Không Lo Race Condition

Hướng dẫn xây dựng API Idempotent an toàn với máy trạng thái, hàm băm payload và cơ chế khóa atomic trong cơ sở dữ liệu.

Thiết Kế API Idempotent Không Lo Race Condition
Trong bài viết này

Khi một yêu cầu mạng bị time out giữa chừng, client rơi vào tình thế tiến thoái lưỡng nan kinh điển của hệ phân tán: server đã xử lý giao dịch chưa, hay chỉ có gói tin phản hồi bị rớt trên đường truyền? Nếu client tự động gửi lại một HTTP request không idempotent như POST hoặc PATCH, rủi ro tạo đơn hàng trùng lặp hoặc trừ tiền tài khoản hai lần là điều khó tránh khỏi.

Để giải quyết vấn đề này, draft đặc tả Idempotency-Key của IETF và chuẩn thực tế từ các cổng thanh toán như Stripe đã trở thành tiêu chuẩn chung. Tuy nhiên, việc hiện thực hóa tính năng này trên thực tế đòi hỏi nhiều hơn là việc đặt một bộ nhớ đệm Redis phía trước API handler. Một thiết kế hời hợt sẽ mở ra những lỗi race condition và sai lệch dữ liệu rất khó dò tìm.

Cạm Bẫy Của Cách Làm Cache Đơn Thuần

Sai lầm phổ biến nhất khi mới bắt tay làm API idempotent là xem Idempotency-Key như một khóa cache kết quả thông thường:

  1. Kiểm tra xem idempotency_key đã tồn tại trong Redis chưa.
  2. Nếu có, trả về kết quả JSON đã lưu.
  3. Nếu chưa, gọi hàm xử lý nghiệp vụ, ghi kết quả vào cache rồi phản hồi client.

Quy trình này sụp đổ ngay khi có các yêu cầu trùng lặp được gửi đồng thời. Nếu một ứng dụng mobile gặp mạng chập chờn và lập tức gửi lại request thứ hai trong khi worker đầu tiên vẫn đang ghi dữ liệu vào database, cả hai worker sẽ cùng kiểm tra bước 1 tại cùng một thời điểm. Cả hai đều nhận diện là cache miss, cả hai cùng thực thi logic nghiệp vụ, và tài khoản của khách hàng bị trừ tiền hai lần.

Cơ chế Idempotency thực chất phải là một cơ chế giữ chỗ nguyên tử (atomic reservation) và khóa đồng bộ tiến trình, chứ không phải một tầng cache đọc đơn thuần.

Máy Trạng Thái 3 Pha Của Một Request

Quy trình máy trạng thái cơ sở dữ liệu xử lý idempotency key

Để kiểm soát an toàn các request đang trong quá trình thực thi và những trường hợp lỗi nửa chừng, mỗi bản ghi idempotency cần vận hành theo một máy trạng thái rõ ràng:

  • STARTED (hoặc IN_PROGRESS): Khóa đã được một worker chiếm giữ thành công. Bất kỳ request nào khác đến với cùng key trong giai đoạn này đều được nhận diện là đang xử lý đồng thời.
  • RESOLVED: Giao dịch nghiệp vụ đã hoàn tất và commit thành công. Mã HTTP status, header và body phản hồi đã được lưu trữ an toàn để phát lại cho các lần thử lại sau này.
  • FAILED: Xảy ra lỗi hạ tầng nghiêm trọng trước khi nghiệp vụ được chốt. Khóa có thể được giải phóng hoặc đánh dấu để client thử lại an toàn.

Nếu một request thứ hai gửi đến trong khi request đầu tiên vẫn mang trạng thái STARTED, server nên trả về ngay mã lỗi 409 Conflict (kèm header Retry-After) để báo cho client biết thao tác đang được xử lý, tránh việc thực thi song song.

Kiểm Tra Vân Tay Yêu Cầu (Request Fingerprinting)

Điều gì sẽ xảy ra nếu một client bị lỗi hoặc một kẻ tấn công gửi cùng một Idempotency-Key nhưng nội dung body lại hoàn toàn khác nhau—chẳng hạn request đầu nạp 10 USD, request sau đổi thành nạp 1.000 USD?

Nếu hệ thống chỉ so khớp mỗi chuỗi key, request thứ hai có thể nhận nhầm kết quả thành công của request đầu, gây che giấu sai sót dữ liệu nghiêm trọng. Để ngăn chặn điều này, mỗi bản ghi idempotency bắt buộc phải lưu kèm một dấu vân tay (fingerprint), thường là mã băm SHA-256 tổng hợp từ: HTTP method, đường dẫn URL, danh tính tài khoản (user_id) và toàn bộ nội dung body.

Fingerprint = SHA256(Method + "|" + Path + "|" + UserID + "|" + RequestBody)

Khi tìm thấy một key đã tồn tại trong kho dữ liệu, hệ thống phải so sánh fingerprint của request mới với fingerprint đã lưu. Nếu key trùng nhau nhưng fingerprint khác nhau, hãy lập tức trả về mã lỗi 422 Unprocessable Entity hoặc 400 Bad Request với thông báo vi phạm tái sử dụng khóa.

Hiện Thực Hóa Nguyên Tử Trong PostgreSQL

Với các hệ thống tài chính hoặc dữ liệu cốt lõi, việc lưu trữ bảng idempotency ngay trong database quan hệ chính giúp đảm bảo tính nguyên tử (ACID) của giao dịch mà không cần duy trì thêm một cụm distributed lock phức tạp bên ngoài.

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

Khi có request mới đến, thực hiện câu lệnh ghi nhận khóa nguyên tử:

INSERT INTO idempotency_records (key, user_id, request_hash, status)
VALUES ($1, $2, $3, 'STARTED')
ON CONFLICT (user_id, key) DO UPDATE
SET locked_at = NOW()
WHERE idempotency_records.status = 'FAILED'
RETURNING status, request_hash, response_code, response_body;

Nếu lệnh insert thành công, worker hiện tại chính thức nắm giữ khóa thực thi và tiếp tục tiến trình nghiệp vụ. Nếu câu lệnh đụng độ với một bản ghi đã tồn tại:

  1. Kiểm tra request_hash. Nếu không khớp, trả về ngay mã 422.
  2. Nếu status là RESOLVED, lấy nguyên văn response_code cùng response_body đã lưu và trả về cho client.
  3. Nếu status là STARTED, trả về mã 409 Conflict.

Sau khi logic nghiệp vụ và các thao tác cập nhật dữ liệu hoàn tất, cập nhật trạng thái bản ghi trong cùng transaction:

UPDATE idempotency_records
SET status = 'RESOLVED',
    response_code = 201,
    response_body = $1
WHERE user_id = $2 AND key = $3;

Các Tình Huống Vận Hành Thực Tế

Trong môi trường production, bạn cần tính trước các tình huống ngoài ý muốn:

  • Worker bị chết giữa chừng: Nếu tiến trình xử lý bị crash khi bản ghi vẫn ở trạng thái STARTED, các lần retry tiếp theo của client sẽ bị kẹt mãi mãi ở mã lỗi 409. Cần có cơ chế mở khóa dựa trên thời gian (ví dụ cho phép ghi đè nếu trạng thái STARTED kéo dài quá 60 giây).
  • Chính sách dọn dẹp (TTL): Idempotency key không cần lưu trữ vĩnh viễn. Stripe thường lưu giữ khóa trong 24 giờ. Bạn có thể phân vùng bảng (partitioning) theo ngày hoặc chạy job định kỳ để xóa các bản ghi quá 7 ngày.
  • Phân tách theo tenant: Luôn đặt khóa chính phức hợp gồm (user_id, key) hoặc (client_id, key) để tránh trường hợp hai khách hàng khác nhau vô tình tạo ra chuỗi UUID trùng nhau.

Nhìn nhận idempotency như một máy trạng thái nguyên tử thay vì một lớp cache tạm thời sẽ giúp hệ thống của bạn đứng vững trước mọi sự cố gián đoạn mạng và các đợt retry dồn dập từ phía client.

Nguồn tham khảo

  1. vertexaisearch.cloud.google.com
  2. vertexaisearch.cloud.google.com
  3. vertexaisearch.cloud.google.com
  4. datatracker.ietf.org
  5. stripe.com

Có hỗ trợ AI · PKN duyệt lại

0

Phản hồi

Đang tải bình luận…