0

Idempotency là gì? Cách thiết kế API an toàn khi retry trong hệ thống phân tán

Nếu thấy hay, kết nối với mình tại LinkedIn

Trong thế giới hệ thống phân tán, retry là một chuyện bình thường đến mức gần như không thể tránh. Mạng chậm, connection reset, timeout, worker crash, query redelivery, downstream unavailable - chỉ cần một trong những thứ đó xảy ra, cùng một hành động rất có thể bị gửi lại.

Vấn đề không nằm ở retry. Vấn đề nằm ở chỗ: nếu request được gửi lại mà hệ thống không có cơ chế kiểm soát side effect, bạn sẽ tạo ra những lỗi rất khó chịu như double charge, duplicate order, trừ kho 2 lần, hoặc ghi dữ liệu sai trạng thái.

Idempotency là lớp thiết kế giúp bạn biến retry từ một rủi ro thành một cơ chế an toàn.

image.png

Idempotency thật ra là gì

Hiểu ngắn họn, một thao tác là idempotent nếu bạn thực hiện nó một lần hay nhiều lần thì trạng thái cuối cùng vẫn như nhau.

Nếu bạn GET /order/123 mười lần, bạn chỉ đang đọc. Nếu bạn PUT /users/42 với cùng một dữ liệu mười lần, trạng thái vẫn quay về cùng một kết quả. Nhưng nếu POST /payments, nếu không có thiết kế bổ sung, mỗi lần gọi có thể tạo ra một side effet mới.

Điểm cần hiểu rõ ở đây là: idempotency không có nghĩa là “request chỉ được đi qua đúng một lần” theo nghĩa vật lý. Nó có nghĩa là dù request có bị lặp lại, kết quả nghiệp vụ cuối cùng không được lặp lại.

Đây là một trong những khái niệm quan trọng nhất khi bạn làm API có side effect, nhất là các API liên quan đến tiền, đơn hàng, provisioning, hoặc workflow.

Retry chỉ là lớp ngoài cùng

Phần lớn developer mới thường nhìn retry như một kỹ thuật xử lý lỗi tạm thời. Cách nhìn đó không sai, nhưng chưa đủ. Retry là lớp ngoài cùng của một vấn đề lớn hơn: hệ thống có thể không biết request trước đó đã hoàn thành hay chưa.

Đây là lý do các hệ thống phân tán thường hiểu theo kiểu at-least-once. Nghĩa là thông điệp có thể được giao ít nhất một lần, nhưng thực tế có thể lặp lại. Trong ngữ cảnh đó, backend không được giả định rằng “nếu tôi xử lý rồi thì chắc chắn sẽ không bao giờ thấy lại request này.

Một ví dụ rất phổ biến:

  • Client gọi payment API
  • Server xử lý xong nhưng response bị mất trên đường về
  • Client không biết kết quả
  • Client retry
  • Backend xử lý lại và charge lần nữa

Vấn đề không phải do retry “xấu”, vấn đề là thiếu idempotency để chặn side effect trùng.

image.png

Một vài ngộ nhận thường gặp

Có 3 ngộ nhận xuất hiện rất nhiều khi nói về odempotency.

Đầu tiên là “chỉ cần hash request là đủ”: Nghe thì có vẻ hợp lý, nhưng dễ sai. Nếu request có timestamp, field phụ, trace id, hoặc thay đổi format nhỏ, hash có thể khác dù business intent vẫn giống nhau.

Thứ hai là “UNIQUE cónatraint là xong”: UNIQUE rất quan trọng, nhưng nó chỉ giải quyết được một phần bài toán: chặn trùng ở tầng dữ liệu. Nó chưa tự động xử lý chuyện request đã đến nhưng side effect chưa xong, hoặc chưa lưu response để replay.

Thứ ba là “idempotency = thực hiện yêu cầu đúng một lần”: Điều này không đúng. Thực hiện request đúng một lần trong hệ thống phân tán là mục tiêu cực khó. Idempotency chỉ giúp bạn đạt một điều mang tính thực dụng hơn: nếu request bị lặp lại, kết quả cuối cùng vẫn không bị nhân đôi.

Cách thiết kế đúng: client tạo khóa, server giữ trạng thái

Nếu muốn làm idempotency một cách bài bản, đừng bắt server cố đoán request nào là giống nhau. Hãy để client chủ động gửi một idempotency key ổn định cho cùng một business action. Ví dụ:

POST /payments
Idempotency-Key: 2f1b3d4a-9d8f-4a5d-a9de-1c8f7ce6f1a2

Mọi lần retry của cùng một hành động phải dùng lại key đó. Nếu key thay đổi theo mỗi lần retry thì hệ thống không còn cơ sở nhận diện request trùng nữa.

Stripe mô tả khá rõ cách làm này: hệ thống lưu kết quả request đầu tiên theo key, rồi các request sau với cùng key sẽ nhận lại cùng kết quả, kể cả khi đó là lỗi. Điều này rất quan teongj vì client không chỉ cần tránh duplicate charge, client còn cần một câu trả lời nhất quán. image.png

Đừng làm check-then-act

Một pattern sai lầm rất phổ biến là:

  • Query xem key đã tồn tại chưa
  • Nếu chưa tồn tại thì xử lý
  • Sau đó mới insert key

Cách này chết vì race condition. Hai request có thể cùng kiểm tra, cùng thấy “chưa tồn tại” key trong bảng idempotency key, rồi cùng thực hiện yêu cầu của client. Đây là lỗi kinh điển của mô hình check-then-act trong môi trường có tính concurent cao.

Cách đúng là dùng một atomic primitive:

  • INSERT … ON CONFLICT
  • ConditionExpression
  • Transaction
  • Compare-and-set
  • Unique write contraint

Nói cách khác là: không hỏi database rồi mới làm, hãy làm ngay, và để database quyết định request nào được đi tiếp.

Một mô hình idempotency records thực tế

Một hệ thống đủ tốt thường không chỉ lưu mỗi idempotency_key. Nó sẽ lưu một record đầy đủ, chẳng hạn:

  • idempotency_key
  • status
  • request_signature
  • response_code
  • response_body
  • resource_id
  • created_at
  • updated_at

Vì sao lại cần nhiều field như vậy ? Vì idempotency không chỉ là “đã thấy key này rồi”. Nó còn là “request này đang ở trạng thái nào, kết quả trước đó là gì, và có thể replay không”.

Flow thực tế thường như sau:

Khi request đầu tiên đến

  • Server cố tạo record với status = PROCESSING
  • Nếu thành công, request này là request đầu tiên được xử lý nghiệp vụ
  • Server thực thi business logic
  • Nếu thành công, cập nhật thành SUCCESS và lưu response
  • Nếu thất bại, cập nhật thành FAILED.

Khi request trùng đến

  • Server đọc record cũ
  • Nếu SUCCESS, trả lại response cũ
  • Nếu PROCESSING, có thể chờ, retry sau hoặc trả về 202/409
  • Nếu FAILED, tuỳ chiến lược mà cho retry hoặc chặn lại

Khi một request ở trạng thái FAILED, lỗi đó thường chia làm 2 loại:

  • Lỗi hệ thống (System Error - có thể retry): Ví dụ như Timeout kết nối, DB sập tạm thời. Lúc này hệ thống nên cho phép Client gửi lại chính Key đó để chạy lại từ đầu
  • Lỗi nghiệp vụ (Business Error - không nên Retry): Ví dụ như ngân hàng không cho phép sử dụng chi tiêu, hoặc thẻ đã hết hạn, lỗi này là lỗi thuộc về nghiệp vụ. Hệ thống phải khoá Key đó lại ở trạng thái FAILED vĩnh viễn và Replay lại lỗi đó nếu Client cố tính retry, yêu cầu Client phải tạo một phiên giao dịch mới với Key mới.

Đây là cách biến idempotency từ một ý tưởng thành một state machine có thể vận hành thật. Chính những điều kiện này giúp API của bạn rõ ràng hơn. Idempotency không chỉ là kỹ thuật chặn trùng request, mà còn là giao kèo giữa server và client về cách xử lý retry.

Tại sao chỉ lưu key là chưa đủ

Giả sử bạn chỉ lưu key, rồi sau khi insert thành công thì đi gọi payment gateway. Nếu payment service crash ngay sau khi insert nhưng trước khi gọi gateway, request tiếp theo nhìn thấy key tồn tại và tưởng rằng đã xử lý xong, Nhưng thực tế tiền vẫn chưa hề được trừ từ khách hàng.

Đây là vùng nguy hiểm nhất: đã có dấu vết, nhưng chưa hoàn tấy side effect. Muốn giải quyết bài toán này, bạn cần ít nhất:

  • status rõ ràng,
  • Khả năng ghi lại response
  • logic phục hồi hoặc retry hợp lệ
  • Quy định rõ cho các trạng thái trung gian

Đó là lý do idempotency không chỉ là một yêu cầu cần có. Nó là một chiến lược thiết kế cho toàn bộ vòng đời của một request có side effect.

Trả response cũ là phần rất quan trọng

Một trong những phần đáng giá nhất của idempotency là khả năng replay response cũ. Stripe nói khá rõ: request đầu tiên được lưu lại cùng status code và response, và request sau với cùng key sẽ nhận lại đúng kết quả đó.

Điều này đem lại hai lợi ích:

  • client không phải đoán kết quả trước đó
  • retry trở nên an toàn mà không làm tăng số lượng side effect.

Với payment, điều client muốn nghe không phải là “duplicate request”. Thứ họ thực sự cần là:

  • “Giao dịch đã thành công, đây là payment_id của bạn” hoặc
  • “Giao dịch trước đó thất bại, đây là lý do”

Đó là lý do cached response không phải chi tiết phụ, mà là một phần trung tâm của thiết kế idempotency

Ví dụ

Lý thuyết nhiều rồi, chúng ta cùng xây dựng một ví dụ dùng database để lưu cấu trúc idempotency, xử lý khi client thanh toán tiền. Nguyên tắc vẫn luôn là: Làm sao để thực hiện việc “Kiểm tra xem có key chưa + Nếu chưa có thì Insert” một cách tuyệt đối an toàn (Atomic) ?

Trong Database, vũ khí của chúng ta chính là UNIQUE Contraint kết hợp với cơ chế Transaction. Khi 2 request trùng nhau cùng lao vào cùng một mili-giây, database sẽ chỉ cho phép một ông INSERT thành công, còn ông còn lại sẽ bị ném ra ngoại lệ Unique Contraint Violation và bị đẩy ra ngoài ngay lập tức.

Ví dụ sau được thực hiện chi tiết bằng Java Spring Boot + Spring Data JPA

1. Tạo Entity lưu trạng thái Idempotency (Database Table)

package com.example.demo.entity;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "idempotency_keys")
public class IdempotencyKeyEntity {

    @Id
    @Column(name = "idempotency_key", nullable = false, length = 100)
    private String idempotencyKey;

    @Column(nullable = false, length = 20)
    private String status; // "PROCESSING" or "COMPLETED"

    @Column(name = "status_code")
    private Integer statusCode;

    @Column(columnDefinition = "TEXT") // Stores the old response body as a JSON string
    private String responseBody;

    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt;

    @Column(name = "updated_at")
    private LocalDateTime updatedAt;

    // Constructors, Getters, Setters
    public IdempotencyKeyEntity() {}

    public IdempotencyKeyEntity(String idempotencyKey, String status) {
        this.idempotencyKey = idempotencyKey;
        this.status = status;
        this.createdAt = LocalDateTime.now();
    }

    public String getIdempotencyKey() { return idempotencyKey; }
    public void setIdempotencyKey(String idempotencyKey) { this.idempotencyKey = idempotencyKey; }
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
    public Integer getStatusCode() { return statusCode; }
    public void setStatusCode(Integer statusCode) { this.statusCode = statusCode; }
    public String getResponseBody() { return responseBody; }
    public void setResponseBody(String responseBody) { this.responseBody = responseBody; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }
    public void preUpdate() { this.updatedAt = LocalDateTime.now(); }
}

2. Tạo Repository

Tầng giao tiếp database cơ bản để tìm kiếm thông tin key.

package com.example.demo.repository;

import com.example.demo.entity.IdempotencyKeyEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface IdempotencyKeyRepository extends JpaRepository<IdempotencyKeyEntity, String> {
}

3. Tầng Service điều phối và xử lý ngoại lệ

Dây là nơi mọi xử lý sẽ diễn ra. Chúng ta bọc hàm tryLock trong một Transaction riêng biệt (RequiresNew), cố gắng insert trạng thái PROCESSING. Nếu bị trùng, DB sẽ chặn đứng bằng lỗi DataIntegrityViolationException

package com.example.demo.service;

import com.example.demo.entity.IdempotencyKeyEntity;
import com.example.demo.repository.IdempotencyKeyRepository;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

@Service
public class IdempotencyDatabaseService {

    private final IdempotencyKeyRepository repository;

    public IdempotencyDatabaseService(IdempotencyKeyRepository repository) {
        this.repository = repository;
    }

    /**
     * Runs in an isolated transaction to quickly insert a "PROCESSING" record.
     * @return true if inserted successfully (lock acquired).
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public boolean tryLock(String key) {
        try {
            IdempotencyKeyEntity entity = new IdempotencyKeyEntity(key, "PROCESSING");
            repository.saveAndFlush(entity); // Forces the DB to check the Unique Constraint immediately
            return true;
        } catch (DataIntegrityViolationException e) {
            // A UNIQUE constraint violation proves that this key already exists in the DB
            return false;
        }
    }

    @Transactional(readOnly = true)
    public Optional<IdempotencyKeyEntity> getSavedKey(String key) {
        return repository.findById(key);
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveResponse(String key, int statusCode, String responseBodyJson) {
        repository.findById(key).ifPresent(entity -> {
            entity.setStatus("COMPLETED");
            entity.setStatusCode(statusCode);
            entity.setResponseBody(responseBodyJson);
            repository.save(entity);
        });
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void deleteKey(String key) {
        repository.deleteById(key);
    }
}

4. API Controller tích hợp với ObjectMapper để xử lý JSON

Vì DB lưu kết quả cũ dưới dạng chuỗi TEXT (JSON), chúng ta dùng thư viện ObjectMapper của Jackson để chuyển đổi qua lại giữa Object nghiệp vụ và String.

package com.example.demo.controller;

import com.example.demo.entity.IdempotencyKeyEntity;
import com.example.demo.service.IdempotencyDatabaseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api/db-payments")
public class PaymentDbController {

    private final IdempotencyDatabaseService idempotencyService;
    private final ObjectMapper objectMapper; // Used to parse JSON strings

    public PaymentDbController(IdempotencyDatabaseService idempotencyService, ObjectMapper objectMapper) {
        this.idempotencyService = idempotencyService;
        this.objectMapper = objectMapper;
    }

    @PostMapping
    public ResponseEntity<Object> processPayment(
            @RequestHeader(value = "X-Idempotency-Key", required = false) String idempotencyKey,
            @RequestBody Map<String, Object> paymentPayload) {

        if (idempotencyKey == null || idempotencyKey.isBlank()) {
            return ResponseEntity.badRequest().body("X-Idempotency-Key header is required.");
        }

        // 1. Attempt to claim processing rights by INSERTING into the DB
        boolean isInserted = idempotencyService.tryLock(idempotencyKey);

        if (!isInserted) {
            // If the insert fails, it means the request is a duplicate
            IdempotencyKeyEntity savedKey = idempotencyService.getSavedKey(idempotencyKey)
                    .orElseThrow(() -> new RuntimeException("Race condition error"));

            if ("PROCESSING".equals(savedKey.getStatus())) {
                return ResponseEntity.status(HttpStatus.CONFLICT)
                        .body("Request is being processed in another thread. Please try again later.");
            } else if ("COMPLETED".equals(savedKey.getStatus())) {
                // REPLAY: Read the JSON string from the DB and parse it back into an Object to return to the Client
                try {
                    Object oldResponseBody = objectMapper.readValue(savedKey.getResponseBody(), Object.class);
                    return ResponseEntity.status(savedKey.getStatusCode()).body(oldResponseBody);
                } catch (Exception e) {
                    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error replaying response");
                }
            }
        }

        // 2. If the lock is acquired (Insert successful), proceed with the actual Business Logic
        try {
            // Simulate a heavy payment processing/core banking call taking 2 seconds
            Thread.sleep(2000);

            Map<String, Object> paymentResponse = Map.of(
                    "transactionId", "TXN-DB-" + System.currentTimeMillis(),
                    "amount", paymentPayload.get("amount"),
                    "status", "SUCCESS"
            );

            // Convert the result to a JSON String to store in the DB in case the Client retries later
            String jsonResponse = objectMapper.writeValueAsString(paymentResponse);
            
            // 3. Update the status to COMPLETED and save the Response
            idempotencyService.saveResponse(idempotencyKey, 200, jsonResponse);

            return ResponseEntity.ok(paymentResponse);

        } catch (Exception e) {
            // If business execution fails (e.g., Bank declines, 3rd party API is down),
            // delete this key so the Client can fix the details and retry using the same Key.
            idempotencyService.deleteKey(idempotencyKey);
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Payment execution failed.");
        }
    }
}

5. Ưu nhược điểm của lưu Idempotency trong database thay vì Redis

Việc dùng Database thay vì Redis là một lựa chọn cực kỳ phổ biến đối với các dự án vừa và nhỏ, hoặc các hệ thống ưu tiên tính Consistent (nhất quán dữ liệu tuyệt đối), hơn là tốc độ cao.

Ưu điểm

  • Không sợ mất dữ liệu: Redis nếu cấu hình cache thuần có thể bị mất dữ liệu khi restart hoặc hết hạn TTL (Time to Live). Database lưu xuống ổ cứng nên thông tin IdemPotency sống mãi, đảm bảo 1 năm sau Client truyền lại key cũ, hệ thống vẫn chặn đứng được side-effect.
  • Bạn không cần phải tốn tiền thuê, vận hành hay bảo trì thêm một cụm Redis Cluster nếu dự án ban đầu chỉ dùng mỗi RDBMS.

Nhược điểm

  • Bảng phình to theo thời gian: Vì dữ liệu không tự động biến mất như cơ chế TTL (Time-To-Live) của Redis, bảng idempotency_keys sẽ ngày một nặng lên
  • Cách giải quyết: Phải viết một hàm Cronjob (Scheduled Task) chạy ngầm mỗi đêm để xoá các bản ghi có created_at cũ hơn 7 ngày hoặc 30 ngày tuỳ bạn cấu hình
  • Tăng tải cho Disk I/O: Mỗi request đi qua đều phải đọc ghi xuống đĩa cứng (Hard drive) thay vì RAM như Redis, nên thông lượng (Throughput) xử lý của API sẽ thấp hơn một chút.

Lambda, retry, và lý do idempotency quan trọng hơn bao giờ hết

AWS Lambda có nhiều kiểu retry khác nhau tuỳ nguồn trigger. Với Asynchronous invocation (Invoke bởi SNS, Event Bridge, S3 Event, SDK với Invocation Type = Event), Lambda có thể retry thêm 2 lần nếu lỗi xảy ra, và với Event source mappings (poll-based, nguồn event từ SQS, Kinesis, DynamoDB Stream), cùng một batch có thể được xử lý lại nhiều lần tuỳ theo cấu hình từ trước.

Điều đó có nghĩ là nếu function của bạn dùng để ghi database, gọi API bên ngoài, tạo order hay kích hoạt payment, thì code của bạn phải giả định event có thể quay lại nhiều lần.

Nếu không có idempotency, bạn sẽ rất dễ gặp lỗi kiểu như một message bị xử lý hai lần, một hob tạo ra hai tài nguyên, hoặc một event gây ra side effect gấp đôi.

Trong môi trường serverless, idempotency thường không phải best practice “cho đẹp”, nó là yêu cầu sống còn.

image.png

Hệ quả thực tế nếu làm sai

Khi idempotency bị bỏ qua, các lỗi thường không xuất hiện ngay. Chúng thường chỉ lộ ra trong những lúc tồi tệ nhất:

  • timeout lúc traffic cao
  • downstream chậm
  • deploy làm restart worker
  • queue redelivery
  • network partition
  • failover giữa các region

Điều tệ nhất là các lỗi này thường không dễ tái hiện. Bạn có thể thấy log “request processed successfully”. Nhưng hậu quả nghiệp vụ để lại bị nhân đôi. Vì vậy, những bug do thiếu idempotency thường là bug rất đắt đỏ để điều tra và rất khó sửa sau khi đã có dữ liệu thật.

Đi kèm với việc dữ liệu thật bị sai lệch là quá trình Data Reconciliation (đối soát và làm sạch dữ liệu) cực kỳ thủ công và mệt mỏi (chính mình là một người thường xuyên làm việc này 😦(). Developer và các team liên quan phải ngồi lọc từng dòng log, đối chiếu dữ liệu, số dư tài khoản có trùng khớp, viết script để “hoàn tiền” hoặc “chỉnh lại” số lượng tồn kho bị trừ âm. Nó ngốn hàng tuần trời và làm suy giảm nghiêm trọng niềm tin của khách hàng vào hệ thống.

Một cách tư duy rất đáng nhớ

Mình thường tóm gọn idempotency bằng một câu:

Retry là chuyện của hạ tầng, idempotency là chuyện của nghiệp vụ

Hạ tầng không thể hứa rằng không bao giờ lặp. Mạng không thể hữa rằng không bao giờ timeout. Queue không thể hứa rằng không bao giờ redeliver. Vì vậy, phần nghiệp vụ phải tự bảo vệ mình bằng thiết kế phù hợp.

Nếu operation của bạn có thể tạo ra tiền, tạo đơn, trừ tồn kho, provision tài nguyên, hoặc gọi hệ thống bên ngoài, hãy mặc định nó sẽ bị lặp. Thiết kế tốt không phải là mong điều xấu không xảy ra. Thiết kế tốt là điều xấy có xảy ra cũng không phá được hệ thống.

Kết bài

Idempotency nghe có vẻ chỉ là một kỹ thuật nhỏ để chống double request. Nhưng càng đi sâu, bạn sẽ thấy nó là một triết lý thiết kế rất quan trọng: hệ thống phải chấp nhận thế giới không hoàn hảo, và vẫn cho ra kết quả đúng.

Retry là thứ bạn sẽ luôn cần. Duplicate là thứ bạn sẽ luôn gặp. Vấn đề không phải là có hay không, mà là bạn đã chuẩn bị đủ để nó không phá nghiệp vụ của mình chưa.

Nếu bạn xây payment, order, workflow, data pipeline hay bất kỳ hệ thống nào có side effect, idempotency không phải là tính năng phụ. Nó là một phần của độ tin cậy.

Và trong production, độ tin cậy thường đáng giá hơn mọi tối ưu vi mô khác.


Bài viết này cũng được mình dịch sang tiếng Anh trên blog substack của mình.

Mình viết lại những điều này như một cách để ghi nhớ hành trình làm nghề của mình. Nếu bạn cũng đang làm backend, devops hoặc cloud, hy vọng những chia sẻ này có thể giúp bạn một chút gì đó. Còn nếu có chỗ nào mình hiểu chưa đúng, mình vẫn luôn sẵn sàng học thêm.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí