Implementing Idempotency in Spring Boot with Custom Annotations and Redis
This article explains the concept of idempotency, identifies naturally idempotent HTTP methods, discusses why idempotency is essential for retries, async callbacks, and message queues, and provides a complete Spring Boot implementation using a custom @Idempotent annotation, AOP, and Redis for token storage.
1. What Is Idempotency?
In simple terms, idempotency means that executing the same API request multiple times yields the same result as a single execution. Achieving true idempotency in production systems requires careful design, as every interface should behave idempotently.
2. Which Requests Are Naturally Idempotent?
Query (GET) requests are inherently idempotent. Delete (DELETE) requests are also idempotent in most cases, except for ABA scenarios where a deleted resource might be recreated between calls.
Example
If a delete request for entity A times out and is retried, but another A is inserted between the two calls, the second delete would incorrectly remove the newly inserted record – this is the ABA problem, which is usually negligible.
Update (PUT/PATCH) operations are generally idempotent, except when the update statement itself is non‑idempotent, such as update table set a = a + 1 where v = 1. Insert (POST) operations are typically non‑idempotent unless a unique database index guarantees no duplicate data.
3. Why Do We Need Idempotency?
1. Timeout Retry
When an RPC request fails due to network instability, the client may retry. The server must ensure that the repeated request does not cause side effects.
2. Asynchronous Callbacks
Asynchronous callbacks are used to improve throughput; such endpoints must be idempotent to handle possible duplicate invocations.
3. Message Queues
Message queue frameworks like Kafka, RocketMQ, and RabbitMQ follow the “at‑least‑once” delivery guarantee, which can produce duplicate messages. Consumers therefore need to implement idempotent processing logic.
4. Key Factors for Implementing Idempotency
Factor 1: Idempotent Token
An idempotent token (or global ID) uniquely identifies a client request. It is usually generated by the client, but can also be provided by a third‑party service.
Factor 2: One‑Time Use
The server must ensure that each token is used only once, commonly by leveraging a unique constraint in the database.
5. Implementing Idempotency with Annotations
Below is a step‑by‑step demonstration using Redis for token storage.
1. Custom Annotation
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(value = ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/** Parameter name from which the token will be extracted */
String name() default "";
/** Field name inside the parameter that holds the token */
String field() default "";
/** Parameter type */
Class type();
}2. Unified Request Wrapper
@Data
public class RequestData<T> {
private Header header;
private T body;
}
@Data
public class Header {
private String token;
}
@Data
public class Order {
String orderNo;
}3. AOP Aspect
import com.springboot.micrometer.annotation.Idempotent;
import com.springboot.micrometer.entity.RequestData;
import com.springboot.micrometer.idempotent.RedisIdempotentStorage;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.lang.reflect.Method;
import java.util.Map;
@Aspect
@Component
public class IdempotentAspect {
@Resource
private RedisIdempotentStorage redisIdempotentStorage;
@Pointcut("@annotation(com.springboot.micrometer.annotation.Idempotent)")
public void idempotent() {}
@Around("idempotent()")
public Object methodAround(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Idempotent idempotent = method.getAnnotation(Idempotent.class);
String field = idempotent.field();
String name = idempotent.name();
Class clazzType = idempotent.type();
String token = "";
Object object = clazzType.newInstance();
Map<String, Object> paramValue = AopUtils.getParamValue(joinPoint);
if (object instanceof RequestData) {
RequestData idempotentEntity = (RequestData) paramValue.get(name);
token = String.valueOf(AopUtils.getFieldValue(idempotentEntity.getHeader(), field));
}
if (redisIdempotentStorage.delete(token)) {
return joinPoint.proceed();
}
return "Duplicate request";
}
}4. Utility Classes
public class AopUtils {
public static Object getFieldValue(Object obj, String name) throws Exception {
Field[] fields = obj.getClass().getDeclaredFields();
Object object = null;
for (Field field : fields) {
field.setAccessible(true);
if (field.getName().equalsIgnoreCase(name)) {
object = field.get(obj);
break;
}
}
return object;
}
public static Map<String, Object> getParamValue(ProceedingJoinPoint joinPoint) {
Object[] paramValues = joinPoint.getArgs();
String[] paramNames = ((CodeSignature) joinPoint.getSignature()).getParameterNames();
Map<String, Object> param = new HashMap<>(paramNames.length);
for (int i = 0; i < paramNames.length; i++) {
param.put(paramNames[i], paramValues[i]);
}
return param;
}
}5. Token Generation
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
@RestController
@RequestMapping("/idGenerator")
public class IdGeneratorController {
@Resource
private RedisIdempotentStorage redisIdempotentStorage;
@RequestMapping("/getIdGeneratorToken")
public String getIdGeneratorToken() {
String generateId = IdGeneratorUtil.generateId();
redisIdempotentStorage.save(generateId);
return generateId;
}
}6. Storage Interface and Redis Implementation
public interface IdempotentStorage {
void save(String idempotentId);
boolean delete(String idempotentId);
} @Component
public class RedisIdempotentStorage implements IdempotentStorage {
@Resource
private RedisTemplate<String, Serializable> redisTemplate;
@Override
public void save(String idempotentId) {
redisTemplate.opsForValue().set(idempotentId, idempotentId, 10, TimeUnit.MINUTES);
}
@Override
public boolean delete(String idempotentId) {
return redisTemplate.delete(idempotentId);
}
}7. Order Controller Example
@RestController
@RequestMapping("/order")
public class OrderController {
@RequestMapping("/saveOrder")
@Idempotent(name = "requestData", type = RequestData.class, field = "token")
public String saveOrder(@RequestBody RequestData<Order> requestData) {
return "success";
}
}6. Request Flow Example
First, call the token endpoint to obtain a unique token. Then include this token in the request header when invoking the order‑saving API. The first call succeeds; a subsequent call with the same token is rejected as a duplicate.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Top Architect
Top Architect focuses on sharing practical architecture knowledge, covering enterprise, system, website, large‑scale distributed, and high‑availability architectures, plus architecture adjustments using internet technologies. We welcome idea‑driven, sharing‑oriented architects to exchange and learn together.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
