Implementing a Production‑Ready Idempotency Middleware with Spring Boot and Redis
This article explains how to build a lightweight, production‑grade idempotency middleware for Spring Boot 3 using AOP, a custom @Idempotent annotation, SpEL‑generated keys, and Redis atomic operations, covering architecture, core components, code examples, use cases, and performance characteristics.
Why Build This Middleware?
Repeated user actions such as clicking the "Submit Order" button multiple times, network timeout retries, MQ message redelivery, or candidates resubmitting enrollment information can generate duplicate orders, multiple payment callbacks, or duplicate identity records, violating the idempotency principle that the same operation should yield the same result regardless of how many times it is executed.
Existing Solutions and Their Drawbacks
Database unique index – only works for write scenarios and cannot prevent concurrent penetration.
Front‑end button disabling – unreliable and can be bypassed.
Token mechanism – requires front‑end and back‑end coordination, increasing complexity.
Manual Redis handling – repetitive code and high maintenance cost.
Therefore, the author chose to combine AOP, a custom annotation, and Redis to create a generic, lightweight, high‑performance idempotency middleware.
Overall Architecture
The entire process completes in milliseconds and imposes no pressure on the database.
Core Components
@Idempotent – custom annotation that declares idempotency rules.
IdempotentAspect – AOP aspect that intercepts methods annotated with @Idempotent.
SpelKeyGenerator – uses Spring SpEL to generate a unique key dynamically.
RedisIdempotentStore – implements atomic verification based on Redis.
IdempotentFailureHandler – customizable strategy for handling duplicate requests.
Implementation Details
Annotation Definition
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
String key();
int expire() default 300;
String value() default "1";
}AOP Aspect Logic
@Aspect
public class IdempotentAspect {
private final IdempotentService idempotentService;
private final ExpressionParser parser = new SpelExpressionParser();
private final StandardReflectionParameterNameDiscoverer discoverer = new StandardReflectionParameterNameDiscoverer();
public IdempotentAspect(IdempotentService idempotentService) {
this.idempotentService = idempotentService;
}
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String[] paramNames = discoverer.getParameterNames(signature.getMethod());
Object[] args = joinPoint.getArgs();
// Parse SpEL key
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < args.length; i++) {
context.setVariable(paramNames[i], args[i]);
}
String key = parser.parseExpression(idempotent.key()).getValue(context, String.class);
if (!idempotentService.tryLock(key, idempotent.expire())) {
if (idempotent.mode() == Idempotent.Mode.REJECT) {
throw new IllegalStateException("重复请求,请勿重复提交");
}
// TODO: RETURN_CACHE mode (needs result cache)
}
return joinPoint.proceed();
}
}Custom Failure Handler (Extensible)
public interface IdempotentFailureHandler {
void handle(String key, Method method);
}
@Component
public class DefaultIdempotentFailureHandler implements IdempotentFailureHandler {
@Override
public void handle(String key, Method method) {
// Default does nothing; AOP throws exception
}
}Usage Examples
Case 1: Order Submission (Prevent Duplicate Orders)
@PostMapping("/order")
@Idempotent(key = "'order:' + #userId + ':' + #goodsId", expire = 300)
public Result<String> createOrder(@RequestParam String userId, @RequestParam String goodsId) {
// Simulate order logic
orderService.create(userId, goodsId);
return Result.success("下单成功");
}If the same user attempts to order the same product within five minutes, subsequent requests are rejected.
Case 2: Candidate Enrollment (Prevent Duplicate ID Card)
@PostMapping("/enroll")
@Idempotent(key = "'enroll:' + #candidate.idCard", expire = 300)
public Result<Void> enroll(@RequestBody Candidate candidate) {
// Prevent duplicate enrollment with the same ID card
enrollmentService.save(candidate);
return Result.OK();
}
public class Candidate {
private String name;
private String idCard;
private String phone;
}The generated key looks like enroll:11010119900307XXXX; the same key cannot be submitted again within five minutes.
Case 3: Seckill Scenario (User + Product Dimension)
@PostMapping("/seckill")
@Idempotent(key = "'seckill:' + #userId + ':' + #goodsId", expire = 60)
public Result<String> seckill(@RequestParam String userId, @RequestParam Long goodsId) {
return seckillService.execute(userId, goodsId);
}Even if a user clicks aggressively, only one effective request is allowed per minute.
Performance and Reliability
Performance: Redis SET NX EX is an atomic operation; a single node can handle QPS > 50 k.
Consistency: Based on Redis distributed‑lock semantics, naturally supports cluster deployment.
Security: Keys are generated by business logic; no injection risk because SpEL runs in a controlled context.
Resource Management: Keys expire automatically, eliminating memory‑leak concerns.
Dependency and Integration
<dependency>
<groupId>io.github.songrongzhen</groupId>
<artifactId>once-kit-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>After adding the starter, simply annotate any endpoint that requires idempotency:
@Idempotent(key = "'order:' + #userId + ':' + #goodsId", expire = 300)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.
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.
