Idempotency API với Spring Boot 3 & Redis – Hướng dẫn chi tiết

VMas-Dev-AnHuynh

Idempotency API với Spring Boot 3 & Redis – Hướng dẫn chi tiết

1. Idempotency là gì và tại sao cần trong hệ thống thanh toán?

“An idempotent operation is one that can be applied multiple times without changing the result beyond the initial application.” – IETF Draft on Idempotency-Key

Trong khoa học máy tính, tính khả đẳng (idempotency) đảm bảo rằng dù gọi 1 lần hay 10 lần, kết quả cuối cùng vẫn không thay đổi sau lần gọi đầu tiên.

Ví dụ thực tế:

  • GET /users/123 – gọi bao nhiêu lần cũng trả về cùng thông tin → idempotent.
  • DELETE /orders/456 – xóa lần đầu thành công, các lần sau trả về 404 → idempotent.
  • POST /payments – tạo giao dịch thanh toán → không idempotent nếu không có cơ chế bảo vệ.

Tại sao API thanh toán cần Idempotency? Trong thế giới phân tán, duplicate request xuất hiện khắp nơi:

Nguyên nhân Mô tả
Network timeout Client không nhận được response → retry
Mobile connectivity Kết nối yếu, request bị gửi lại
Browser retry Trình duyệt tự động gửi lại request
API Gateway retry Gateway retry khi upstream timeout
User double-click Người dùng bấm nút nhiều lần

Không có idempotency, một lần bấm “Thanh toán” có thể tạo ra 2 giao dịch, trừ tiền 2 lần – hậu quả tài chính nghiêm trọng.

🔗 Bài viết liên quan: Triển khai cơ chế API Idempotency Key trong Laravel 11 – so sánh cách làm ở các ngôn ngữ khác.


2. Kiến trúc xử lý Idempotency Key chuẩn IETF với Redis

IETF đang phát triển draft chuẩn cho header Idempotency-Key:

  • Client tự sinh Idempotency Key duy nhất (UUID v4)
  • Gửi key trong HTTP header: Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
  • Server dùng key này để nhận diện request trùng lặp

2.1. Ba trạng thái của Idempotency Key

┌─────────────────────────────────────────────────────────────────┐
│                    IDEMPOTENCY KEY LIFECYCLE                    │
├─────────────────────────────────────────────────────────────────┤
│  ┌──────────┐     ┌──────────┐     ┌──────────┐               │
│  │ PENDING  │ ──► │ SUCCESS  │ ──► │ (expired)│               │
│  └──────────┘     └──────────┘     └──────────┘               │
│       │                  │                                      │
│       ▼                  ▼                                      │
│  ┌──────────┐     ┌──────────┐                                 │
│  │ FAILED   │     │ CACHED   │                                 │
│  └──────────┘     │ RESPONSE │                                 │
│                   └──────────┘                                 │
│                                                                 │
│  - PENDING:  Request đang xử lý, lock đã chiếm                 │
│  - SUCCESS:  Xử lý thành công, response được cache             │
│  - FAILED:   Lỗi business, key bị xóa để cho phép retry       │
│  - CACHED:   Request trùng lặp, trả về response cached        │
└─────────────────────────────────────────────────────────────────┘

2.2. Kiến trúc tổng thể

Kiến trúc xử lý Idempotency Key với Spring Boot 3 và Redis Hình 1: Luồng xử lý từ Client → Controller → AOP → Redis → Business Service

┌─────────────┐     ┌─────────────────────────────────────────────┐
│   Client    │     │              Spring Boot 3 Server           │
│  (Web/Mob)  │     │                                             │
│             │     │  ┌─────────────────────────────────────┐    │
│  Generate   │     │  │     @Idempotent Aspect (AOP)       │    │
│  UUID v4    │     │  │                                     │    │
│             │     │  │  1. Read Idempotency-Key header    │    │
│  ────────── │ ──► │  2. Validate UUID format             │    │
│  Idempotency│     │  3. SETNX lock in Redis (PENDING)    │    │
│  -Key header│     │  4. If success → proceed to Service   │    │
│             │     │  5. If duplicate → return cached resp │    │
│             │     │  6. After service → cache response    │    │
│             │     │  └─────────────────────────────────────┘    │
│             │     │                    │                        │
│             │     │                    ▼                        │
│             │     │  ┌─────────────────────────────────────┐    │
│             │     │  │      Business Service Layer         │    │
│             │     │  │  (Pure business logic, no clutter) │    │
│             │     │  └─────────────────────────────────────┘    │
│             │     │                    │                        │
│             │     │                    ▼                        │
│             │     │  ┌─────────────────────────────────────┐    │
│             │     │  │         Redis (Distributed)        │    │
│             │     │  │  Key: idempotent:{key}:{endpoint} │    │
│             │     │  │  Value: PENDING / cached response │    │
│             │     │  │  TTL: 24 hours (configurable)     │    │
│             │     │  └─────────────────────────────────────┘    │
└─────────────┘     └─────────────────────────────────────────────┘

2.3. Tại sao chọn Redis?

Tiêu chí Lý do chọn Redis
Atomic operations SETNX kiểm tra và tạo key trong 1 operation
TTL tự động Key tự động hết hạn sau thời gian cấu hình
Distributed Hoạt động trong môi trường cluster, nhiều instance
Hiệu năng In-memory, sub-millisecond latency (thường < 1ms)
Data structures Hỗ trợ String, Hash, List linh hoạt

3. Thực chiến: Viết Custom Annotation @Idempotent bằng Spring AOP

⚠️ Lưu ý: Bài viết được test với Spring Boot 3.2.4, Java 21, Redis 7.2.4.

3.1. Cấu hình Redis trong Spring Boot 3

Dependencies:

<!-- Maven dependencies -->
<dependency>

<groupId>io.micrometer</groupId>

<artifactId>micrometer-core</artifactId>
</dependency>
<dependency>

<groupId>io.micrometer</groupId>

<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>

<groupId>org.apache.commons</groupId>

<artifactId>commons-pool2</artifactId>
</dependency>

application.yml:

spring:
  data:
    redis:
      host: localhost
      port: 6379
      password: ${REDIS_PASSWORD:}
      timeout: 2000ms
      lettuce:
        pool:
          max-active: 8
          max-idle: 8
          min-idle: 2
          max-wait: 1000ms

# TTL mặc định cho Idempotency Key (24 giờ)
idempotency:
  default-ttl: 24
  default-time-unit: HOURS
  redis-key-prefix: idempotent

RedisConfig.java:

package com.example.idempotency.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;

@Configuration
public class RedisConfig {

    @Bean
    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
        return new StringRedisTemplate(connectionFactory);
    }

    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper()
            .registerModule(new JavaTimeModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    }
}

3.2. Tạo Annotation @Idempotent

package com.example.idempotency.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;

/**
 * Đánh dấu API endpoint cần bảo vệ bởi cơ chế Idempotency.
 * Sử dụng cùng với {@link IdempotentAspect} để tự động kiểm tra
 * và cache response dựa trên Idempotency-Key header.
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {

    /**
     * Thời gian sống của Idempotency Key trong Redis.
     * Mặc định: 24 giờ.
     */
    long expireTime() default 24;

    /**
     * Đơn vị thời gian cho expireTime.
     * Mặc định: HOURS.
     */
    TimeUnit timeUnit() default TimeUnit.HOURS;

    /**
     * Prefix cho Redis key.
     * Mặc định: "idempotent"
     */
    String prefix() default "idempotent";

    /**
     * Cache response thành công hay không.
     * Mặc định: true.
     */
    boolean cacheResponse() default true;
}

3.3. Viết Class @Aspect – IdempotentAspect

package com.example.idempotency.aspect;

import com.example.idempotency.annotation.Idempotent;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class IdempotentAspect {

    private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
    private static final String CACHE_STATUS_HEADER = "X-Idempotency-Cache-Status";

    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper objectMapper;

    /**
     * Lua script atomic để kiểm tra và tạo lock cho Idempotency Key.
     * Trả về: "1" nếu lock thành công, "0" nếu key đã tồn tại.
     */
    private static final String LUA_SCRIPT = 
        "local key = KEYS[1] " +
        "local ttl = tonumber(ARGV[1]) " +
        "if redis.call('EXISTS', key) == 1 then " +
        "    return '0' " +
        "else " +
        "    redis.call('SET', key, 'PENDING', 'EX', ttl) " +
        "    return '1' " +
        "end";

    @Around("@annotation(com.example.idempotency.annotation.Idempotent)")
    public Object handleIdempotency(ProceedingJoinPoint joinPoint) throws Throwable {
        // Lấy request và response từ context
        ServletRequestAttributes attributes = 
            (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attributes == null) {
            return joinPoint.proceed();
        }

        HttpServletRequest request = attributes.getRequest();
        HttpServletResponse response = attributes.getResponse();

        // 1. Lấy Idempotency-Key từ header
        String idempotencyKey = request.getHeader(IDEMPOTENCY_HEADER);
        if (idempotencyKey == null || idempotencyKey.isBlank()) {
            log.warn("Missing Idempotency-Key header for request: {}", request.getRequestURI());
            // Cho phép request đi tiếp (không áp dụng idempotency)
            return joinPoint.proceed();
        }

        // 2. Validate UUID format
        try {
            UUID.fromString(idempotencyKey);
        } catch (IllegalArgumentException e) {
            log.warn("Invalid Idempotency-Key format: {}", idempotencyKey);
            response.setStatus(HttpStatus.BAD_REQUEST.value());
            return Map.of(
                "error", "Invalid Idempotency-Key format. Must be a valid UUID v4.",
                "idempotencyKey", idempotencyKey
            );
        }

        // 3. Lấy thông tin annotation
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        Idempotent idempotent = method.getAnnotation(Idempotent.class);

        String prefix = idempotent.prefix();
        long ttlSeconds = idempotent.timeUnit().toSeconds(idempotent.expireTime());

        // 4. Tạo Redis key: idempotent:{prefix}:{endpoint}:{idempotencyKey}
        String endpoint = request.getRequestURI();
        String redisKey = String.format("%s:%s:%s:%s", prefix, "api", endpoint, idempotencyKey);

        log.debug("Idempotency check - key: {}, ttl: {}s", redisKey, ttlSeconds);

        // 5. Kiểm tra và tạo lock bằng Lua script (atomic)
        DefaultRedisScript
<String> script = new DefaultRedisScript<>(LUA_SCRIPT, String.class);
        String result = redisTemplate.execute(
            script, 
            Collections.singletonList(redisKey), 
            String.valueOf(ttlSeconds)
        );

        if ("0".equals(result)) {
            // KEY ĐÃ TỒN TẠI → Request trùng lặp
            log.warn("Duplicate request detected - key: {}", redisKey);

            String cachedResponse = redisTemplate.opsForValue().get(redisKey);

            if (cachedResponse != null && !"PENDING".equals(cachedResponse)) {
                // Trả về response cached
                response.setHeader(CACHE_STATUS_HEADER, "HIT");
                response.setHeader(IDEMPOTENCY_HEADER, idempotencyKey);
                response.setStatus(HttpStatus.OK.value());
                return objectMapper.readValue(cachedResponse, Object.class);
            } else {
                // Key ở trạng thái PENDING → request khác đang xử lý
                response.setHeader(CACHE_STATUS_HEADER, "PENDING");
                response.setHeader(IDEMPOTENCY_HEADER, idempotencyKey);
                response.setStatus(HttpStatus.CONFLICT.value());
                return Map.of(
                    "error", "Request is being processed",
                    "idempotencyKey", idempotencyKey,
                    "status", "PENDING"
                );
            }
        }

        // 6. LOCK THÀNH CÔNG → Thực thi business logic
        response.setHeader(CACHE_STATUS_HEADER, "MISS");
        response.setHeader(IDEMPOTENCY_HEADER, idempotencyKey);

        try {
            Object proceedResult = joinPoint.proceed();

            // Cache response nếu annotation yêu cầu
            if (idempotent.cacheResponse()) {
                String jsonResponse = objectMapper.writeValueAsString(proceedResult);
                redisTemplate.opsForValue().set(redisKey, jsonResponse, ttlSeconds, TimeUnit.SECONDS);
                log.debug("Cached response for key: {}", redisKey);
            } else {
                // Không cache → xóa key sau khi xử lý thành công
                redisTemplate.delete(redisKey);
            }

            return proceedResult;

        } catch (BusinessException e) {
            // 🔴 LỖI BUSINESS có thể khắc phục → XÓA key để cho phép retry
            log.warn("Business error for key: {}, deleting key to allow retry", redisKey, e);
            redisTemplate.delete(redisKey);
            throw e;

        } catch (Exception e) {
            // 🔴 LỖI HỆ THỐNG (5xx) → GIỮ key để tránh xử lý trùng lặp
            // Không xóa key để prevent duplicate processing
            log.error("System error for key: {}, keeping key to prevent duplicate", redisKey, e);
            // Có thể gửi alert đến team
            throw e;
        }
    }
}

BusinessException.java:


package com.example.idempotency.exception;

/**
 * Khi gặp exception này, Idempotency Key sẽ bị xóa để cho phép client retry.
 */
public class BusinessException extends RuntimeException {

    public BusinessException(String message) {
        super(message);
    }

    public BusinessException(String message, Throwable cause) {
        super(message, cause);
    }
}

Giải thích các phần quan trọng:

Phần Mô tả
UUID validation Kiểm tra định dạng UUID hợp lệ, tránh injection
Lua script Đảm bảo atomicity cho SETNX + EXPIRE
Phân biệt Exception Business error → xóa key; System error → giữ key
Cache response Cache toàn bộ response để trả về cho request sau

3.4. Sử dụng Annotation trong Controller

package com.example.payment.controller;

import com.example.idempotency.annotation.Idempotent;
import com.example.payment.dto.PaymentRequest;
import com.example.payment.dto.PaymentResponse;
import com.example.payment.dto.RefundRequest;
import com.example.payment.service.PaymentService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.TimeUnit;

@RestController
@RequestMapping("/api/v1/payments")
@RequiredArgsConstructor
public class PaymentController {

    private final PaymentService paymentService;

    @PostMapping("/process")
    @Idempotent(expireTime = 24, timeUnit = TimeUnit.HOURS)
    public PaymentResponse processPayment(@RequestBody PaymentRequest request) {
        // Business logic thuần túy – KHÔNG có code idempotency nào ở đây!
        return paymentService.process(request);
    }

    @PostMapping("/refund")
    @Idempotent(expireTime = 48, timeUnit = TimeUnit.HOURS, cacheResponse = false)
    public PaymentResponse processRefund(@RequestBody RefundRequest request) {
        // Refund ít khi retry, có thể không cache response
        return paymentService.refund(request);
    }
}

Điểm mạnh: Business code hoàn toàn không biết đến idempotency. Mọi logic được AOP xử lý trong suốt.


4. Xử lý tranh chấp đồng thời (Concurrent Requests)

Tình huống nguy hiểm nhất: hai request với cùng Idempotency-Key đến server cùng lúc (user bấm nút thanh toán 2 lần trong 1 millisecond).

Luồng xử lý concurrent requests với Redis SETNX Hình 2: Timeline xử lý 2 request cùng key – Request A lock thành công, Request B nhận 409 Conflict

4.1. Giải pháp: Redis SETNX Atomic

Trong IdempotentAspect, chúng ta dùng Lua script để đảm bảo atomicity:

local key = KEYS[1]
local ttl = tonumber(ARGV[1])
if redis.call('EXISTS', key) == 1 then
    return '0'   -- Key đã tồn tại → không lock được
else
    redis.call('SET', key, 'PENDING', 'EX', ttl)
    return '1'   -- Lock thành công
end

Tại sao cần Lua script? EXISTS + SETNX là 2 operation riêng biệt:

Request A: EXISTS(key) → false
Request B: EXISTS(key) → false (cùng lúc)
Request A: SETNX(key) → success
Request B: SETNX(key) → success (vẫn success vì chưa ai SET)
→ CẢ HAI ĐỀU LOCK THÀNH CÔNG → THẢM HỌA

Lua script đảm bảo toàn bộ logic được thực thi atomic trên Redis.

4.2. Luồng xử lý Concurrent

Thời gian │ Request A (key=X)          │ Request B (key=X)          │ Redis State
─────────┼─────────────────────────────┼─────────────────────────────┼─────────────
t1       │ Lua: EXISTS(key)? → false   │                             │ (empty)
t2       │ Lua: SET key='PENDING'      │ Lua: EXISTS(key)? → true   │ key='PENDING'
t3       │ → Lock success, proceed     │ → Lock failed              │ key='PENDING'
t4       │ Business logic...           │ Return 409 Conflict        │ key='PENDING'
t5       │ Business success            │                             │ key='{"status":"ok"}'
t6       │ SET key='cached_response'   │                             │ key='cached'
t7       │ Return 200 OK               │                             │ key='cached'

Request B nhận 409 Conflict ngay lập tức mà không cần chờ business logic hoàn thành.


5. Lỗi thường gặp và cách khắc phục

Lỗi 1: Cache cả response lỗi (Business Exception)

Sai: Cache tất cả response, bao gồm cả lỗi business.

Hậu quả: User nạp thêm tiền, retry với cùng key → vẫn nhận lỗi cũ từ cache → không thể thực hiện giao dịch.

Đúng: Chỉ cache response thành công (2xx). Với lỗi business, xóa key.

catch (BusinessException e) {
    // Lỗi business → XÓA key để cho phép retry
    redisTemplate.delete(redisKey);
    throw e;
}

Lỗi 2: TTL quá ngắn

❌ Đặt TTL = 5 phút cho giao dịch thanh toán. Hệ thống xử lý chậm (do third-party) mất 6 phút → key hết hạn → request bị xem là mới → trùng lặp.

Best practice: TTL tối thiểu 24 giờ cho giao dịch tài chính.

Lỗi 3: Không validate Idempotency-Key header

❌ Không kiểm tra header → dễ bị tấn công injection hoặc lỗi format.

Đúng: Validate UUID format trước khi xử lý.

try {
    UUID.fromString(idempotencyKey);
} catch (IllegalArgumentException e) {
    return 400 Bad Request;
}

Lỗi 4: Xóa key khi có lỗi hệ thống (5xx)

❌ Xóa key khi gặp lỗi 5xx → user retry → tạo giao dịch mới → trùng lặp.

Đúng: Giữ key cho lỗi hệ thống, chỉ xóa cho lỗi business.

catch (Exception e) {
    // System error → KEEP key
    log.error("System error, keeping key", e);
    throw e; // Không xóa key
}

6. Best Practices cho Production

6.1. TTL hợp lý

Loại giao dịch TTL đề xuất Lý do
Thanh toán 24 – 48 giờ Đủ thời gian user phát hiện và retry
Refund/Hoàn tiền 48 – 72 giờ Thường phức tạp hơn, cần thời gian xử lý
Đặt hàng 24 giờ Đủ cho retry do network timeout
API internal 5 – 10 phút Ít bị retry, tiết kiệm Redis memory

Biểu đồ TTL khuyến nghị cho Idempotency Key Hình 3: TTL khuyến nghị theo loại giao dịch

6.2. Client sinh Idempotency Key đúng chuẩn

Theo IETF draft, client nên dùng UUID v4:

// JavaScript (Frontend)
function generateIdempotencyKey() {
    return crypto.randomUUID(); // UUID v4
}

// Gửi request
fetch('/api/payments', {
    method: 'POST',
    headers: {
        'Idempotency-Key': crypto.randomUUID(),
        'Content-Type': 'application/json'
    },
    body: JSON.stringify(paymentData)
});
// TypeScript với thư viện uuid
import { v4 as uuidv4 } from 'uuid';

const idempotencyKey = uuidv4();

6.3. HTTP Status Code chuẩn

Tình huống HTTP Status Giải thích
Lần đầu xử lý thành công 200 OK Response được cache
Request trùng lặp (đã có response) 200 OK Trả về response cached
Request trùng lặp (đang xử lý) 409 Conflict Theo IETF draft
Missing Idempotency-Key 200 OK (tiếp tục) hoặc 400 Tùy policy
Invalid UUID format 400 Bad Request Validate header

6.4. Response headers nên trả về

HTTP/1.1 200 OK
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
X-Idempotency-Cache-Status: HIT
Content-Type: application/json
  • Idempotency-Key: Echo lại key client gửi
  • X-Idempotency-Cache-Status: MISS (lần đầu), HIT (cache), PENDING (đang xử lý)

6.5. Monitoring & Alerting

Metrics cần theo dõi:

Metric Ý nghĩa Threshold alert
idempotency.hit.total Số duplicate requests (HIT) > 5% tổng requests → alert
idempotency.pending.total Số 409 Conflict > 1% → cảnh báo timeout
idempotency.key.deleted Số key bị xóa do lỗi Đột biến → kiểm tra hệ thống
idempotency.redis.latency Latency Redis > 10ms → alert

Code monitoring với Micrometer:

@Aspect
@Component
public class IdempotentAspect {
    private final MeterRegistry meterRegistry;
    private final Counter hitCounter;
    private final Counter pendingCounter;

    public IdempotentAspect(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
        this.hitCounter = Counter.builder("idempotency.hit")
            .description("Number of idempotency cache hits")
            .register(meterRegistry);
        this.pendingCounter = Counter.builder("idempotency.pending")
            .description("Number of 409 pending responses")
            .register(meterRegistry);
    }

    // Trong handleIdempotency:
    if ("0".equals(result)) {
        if (cachedResponse != null && !"PENDING".equals(cachedResponse)) {
            hitCounter.increment();
            // ...
        } else {
            pendingCounter.increment();
            // ...
        }
    }
}

7. Unit Testing cho IdempotentAspect

package com.example.idempotency.aspect;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class IdempotentAspectTest {

    @Mock
    private StringRedisTemplate redisTemplate;

    @Mock
    private ValueOperations<String, String> valueOperations;

    @InjectMocks
    private IdempotentAspect aspect;

    @Test
    void shouldReturnCachedResponseForDuplicateRequest() {
        // Given
        String idempotencyKey = "550e8400-e29b-41d4-a716-446655440000";
        String cachedResponse = "{\"status\":\"SUCCESS\"}";

        when(redisTemplate.opsForValue()).thenReturn(valueOperations);
        when(valueOperations.get(anyString())).thenReturn(cachedResponse);

        // When
        // ... call handleIdempotency

        // Then
        // verify cached response returned
    }

    @Test
    void shouldReturn409ForConcurrentRequests() {
        // Given
        String idempotencyKey = "550e8400-e29b-41d4-a716-446655440000";

        when(redisTemplate.execute(any(DefaultRedisScript.class), anyList(), anyString()))
            .thenReturn("0"); // Lock failed

        // When
        // ... call handleIdempotency

        // Then
        // verify 409 Conflict returned
    }

    @Test
    void shouldDeleteKeyForBusinessException() {
        // Given
        String idempotencyKey = "550e8400-e29b-41d4-a716-446655440000";

        when(redisTemplate.execute(any(DefaultRedisScript.class), anyList(), anyString()))
            .thenReturn("1"); // Lock success

        // When business logic throws BusinessException
        // ... call handleIdempotency

        // Then
        verify(redisTemplate).delete(anyString()); // Key deleted
    }
}

8. FAQ

Câu hỏi 1: Tại sao không dùng Unique Constraint trong SQL thay cho Redis Idempotency Key?

Trả lời:

Tiêu chí SQL Unique Constraint Redis Idempotency Key
Hiệu năng Chậm hơn (disk I/O) Rất nhanh (in-memory, < 1ms)
Phân tán Phụ thuộc vào database Hoạt động với mọi instance
TTL tự động Không có sẵn Có sẵn (EXPIRE)
Lưu response Phải tạo bảng riêng Lưu trực tiếp trong Redis
Atomic lock Cần transaction phức tạp SETNX/Lua script đơn giản

Redis phù hợp hơn cho idempotency vì hiệu năng cao và TTL tự động. SQL constraint có thể dùng kết hợp để đảm bảo consistency ở tầng database.

Câu hỏi 2: Client nên sinh Idempotency Key như thế nào?

Trả lời:

Theo IETF draft, client cần đảm bảo:

  1. Tính duy nhất: Mỗi request có một key riêng
  2. Không tái sử dụng: Không dùng lại key cho request khác
  3. Định dạng: UUID v4 được khuyến nghị
// ✅ Đúng
const key = crypto.randomUUID(); // "550e8400-e29b-41d4-a716-446655440000"

// ❌ Sai (không đảm bảo duy nhất)
const key = userId + timestamp; // Có thể trùng
const key = hash(payload); // 2 giao dịch khác nhau có thể cùng hash

Câu hỏi 3: Idempotency Key có cần lưu trong database không?

Trả lời: Tùy thuộc vào yêu cầu hệ thống:

  • Chỉ cần Redis: Đủ cho hầu hết use-case, đặc biệt khi TTL ≤ 24h
  • Cần lưu DB: Khi cần audit trail, TTL dài (> 7 ngày), hoặc truy vấn lịch sử
  • Kết hợp: Redis để check nhanh, DB để audit

Câu hỏi 4: Làm sao để xử lý khi Redis bị down?

Trả lời: Có một số chiến lược:

  1. Fallback to local cache: Dùng Caffeine/Guava cache local
  2. Circuit breaker: Dùng Resilience4j để ngắt kết nối Redis
  3. Retry với backoff: Thử lại Redis với exponential backoff
  4. Degrade: Tạm tắt idempotency, ghi log để xử lý sau

Câu hỏi 5: Spring AOP có hoạt động với Java 21 Virtual Threads không?

Trả lời: Spring Boot 3.2+ hỗ trợ virtual threads với spring.threads.virtual.enabled=true. Tuy nhiên cần lưu ý:

  • AOP dùng proxy dựa trên subclass (CGLIB) hoặc interface (JDK) – vẫn hoạt động
  • RequestContextHolder sử dụng ThreadLocal – cần kiểm tra với virtual threads (có thể dùng ScopedValue trong tương lai)
  • Khuyến nghị test kỹ trên staging trước production

9. Kết luận

Xây dựng Idempotency API không chỉ là yêu cầu kỹ thuật mà còn là trách nhiệm với người dùng và hệ thống tài chính.

Với cách tiếp cận sử dụng Spring Boot 3 + Redis + Custom Annotation + Spring AOP, chúng ta đã:

  • ✅ Đóng gói hoàn toàn logic idempotency, giữ business code trong sạch
  • ✅ Xử lý concurrent requests an toàn với Redis Lua script atomic
  • ✅ Cache response thành công để tối ưu hiệu năng
  • ✅ Xóa key khi có lỗi business để cho phép retry
  • ✅ Giữ key khi có lỗi hệ thống để tránh duplicate processing
  • ✅ Tuân thủ chuẩn IETF Idempotency-Key header
  • ✅ Validate UUID format để tăng bảo mật
  • ✅ Có monitoring và alerting cho production

Flowchart xử lý Idempotency API với Spring Boot AOP và Redis – các bước quyết định từ request đến response

Tóm tắt luồng xử lý:

1. Client gửi request với header Idempotency-Key: {UUID}
2. AOP Aspect bắt request, check Redis:
   - Key chưa tồn tại → SETNX thành công → thực thi business
   - Key đã tồn tại (PENDING) → trả về 409 Conflict
   - Key đã tồn tại (cached response) → trả về response cached
3. Business logic thành công → cache response vào Redis với TTL
4. Business logic thất bại (BusinessException) → xóa key → cho phép retry
5. Hệ thống lỗi (Exception) → giữ key → tránh duplicate
Chia sẻ bài viết này
By VMas-Dev-AnHuynh Software Engineer
Follow:
SOFTWARE ENGINEER / FULL-STACK DEVELOPER