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ể
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).
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 |
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ửiX-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:
- Tính duy nhất: Mỗi request có một key riêng
- Không tái sử dụng: Không dùng lại key cho request khác
- Đị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:
- Fallback to local cache: Dùng Caffeine/Guava cache local
- Circuit breaker: Dùng Resilience4j để ngắt kết nối Redis
- Retry với backoff: Thử lại Redis với exponential backoff
- 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
RequestContextHoldersử dụng ThreadLocal – cần kiểm tra với virtual threads (có thể dùngScopedValuetrong 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-Keyheader - ✅ Validate UUID format để tăng bảo mật
- ✅ Có monitoring và alerting cho production

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