Implementing Idempotent Repeat Submission Prevention in Spring Boot with AOP and Redis
This article explains how to prevent duplicate data submissions in a Spring Boot application by designing an idempotent mechanism using AOP and Redis, covering the concept, design strategy, custom annotation, aspect implementation, and a simple test controller.
Idempotent repeat submission refers to preventing duplicate data submissions that could cause dirty data or business chaos; it is a low‑probability event distinct from concurrency stress testing.
The goal is to design a system that, after a successful request, restricts the same data from being submitted again within a configurable time window, allowing rapid release of the restriction on failure or exception.
Design strategy : generate a unique ID from the user’s request path, parameters, and token, store it in Redis, and follow these steps:
Intercept the request via an AOP advice, extract the URL, parameters, and token, and compute a unique ID.
Check whether the ID already exists in Redis and whether it is still valid.
If the ID does not exist, proceed with normal business logic; otherwise, throw an exception indicating a duplicate submission.
After method execution, if the result is successful, keep the Redis key; if the business fails, delete the key so that the next submission can pass.
Custom annotation @RepeatSubmit is defined to mark methods that require idempotent protection. It includes three attributes:
interval : the minimum interval time (ms) between two submissions.
timeUnit : the time unit, default milliseconds.
message : an internationalized warning message.
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {
/** Interval time (ms) */
int interval() default 5000;
TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
/** Internationalized message */
String message() default "{repeat.submit.message}";
}Custom aspect @RepeatSubmitAspect implements the idempotent logic with three advices:
doBefore : a @Before advice that extracts the interval, request URL, token, and parameters, builds a submitKey (MD5 of token + parameters), constructs a Redis key, and attempts to set it with an expiration; if the key already exists, it throws a ServiceException with the configured message.
doAfterReturning : an @AfterReturning advice that receives the method’s return object, checks if it is an instance of R , and if the response code indicates failure, deletes the Redis key and clears the thread‑local cache.
doAfterThrowing : an @AfterThrowing advice that removes the Redis key and thread‑local entry when an exception occurs.
@Aspect
@Component
public class RepeatSubmitAspect {
private static final ThreadLocal
KEY_CACHE = new ThreadLocal<>();
@Before("@annotation(repeatSubmit)")
public void doBefore(JoinPoint point, RepeatSubmit repeatSubmit) throws Throwable {
long interval = repeatSubmit.timeUnit().toMillis(repeatSubmit.interval());
if (interval < 1000) {
throw new ServiceException("Repeat interval cannot be less than 1 second");
}
HttpServletRequest request = ServletUtils.getRequest();
String nowParams = argsArrayToString(point.getArgs());
String url = request.getRequestURI();
String submitKey = StringUtils.trimToEmpty(request.getHeader(SaManager.getConfig().getTokenName()));
submitKey = SecureUtil.md5(submitKey + ":" + nowParams);
String cacheRepeatKey = CacheConstants.REPEAT_SUBMIT_KEY + url + submitKey;
if (RedisUtils.setObjectIfAbsent(cacheRepeatKey, "", Duration.ofMillis(interval))) {
KEY_CACHE.set(cacheRepeatKey);
} else {
String message = repeatSubmit.message();
if (StringUtils.startsWith(message, "{") && StringUtils.endsWith(message, "}")) {
message = MessageUtils.message(StringUtils.substring(message, 1, message.length() - 1));
}
throw new ServiceException(message);
}
}
@AfterReturning(pointcut = "@annotation(repeatSubmit)", returning = "jsonResult")
public void doAfterReturning(JoinPoint joinPoint, RepeatSubmit repeatSubmit, Object jsonResult) {
if (jsonResult instanceof R) {
try {
R
r = (R
) jsonResult;
if (r.getCode() == R.SUCCESS) {
return;
}
RedisUtils.deleteObject(KEY_CACHE.get());
} finally {
KEY_CACHE.remove();
}
}
}
@AfterThrowing(value = "@annotation(repeatSubmit)", throwing = "e")
public void doAfterThrowing(JoinPoint joinPoint, RepeatSubmit repeatSubmit, Exception e) {
RedisUtils.deleteObject(KEY_CACHE.get());
KEY_CACHE.remove();
}
private String argsArrayToString(Object[] paramsArray) {
StringJoiner params = new StringJoiner(" ");
if (ArrayUtil.isEmpty(paramsArray)) {
return params.toString();
}
for (Object o : paramsArray) {
if (ObjectUtil.isNotNull(o) && !isFilterObject(o)) {
params.add(JsonUtils.toJsonString(o));
}
}
return params.toString();
}
@SuppressWarnings("rawtypes")
public boolean isFilterObject(final Object o) {
Class
clazz = o.getClass();
if (clazz.isArray()) {
return clazz.getComponentType().isAssignableFrom(MultipartFile.class);
} else if (Collection.class.isAssignableFrom(clazz)) {
Collection collection = (Collection) o;
for (Object value : collection) {
return value instanceof MultipartFile;
}
} else if (Map.class.isAssignableFrom(clazz)) {
Map map = (Map) o;
for (Object value : map.values()) {
return value instanceof MultipartFile;
}
}
return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse || o instanceof BindingResult;
}
}A simple test controller demonstrates the usage:
@RestController
@RequestMapping("/repeat")
@SaIgnore
public class RepeatController {
@PostMapping
@RepeatSubmit(interval = 2000)
public R
repeat1(String info) {
log.info("Request succeeded, info: " + info);
return R.ok("Request succeeded");
}
}Using Apifox, sending two requests within 2 seconds triggers the duplicate‑submission exception, confirming that the AOP + Redis solution successfully enforces idempotency.
Thus, the article provides a complete guide to designing and implementing an idempotent repeat‑submission mechanism in a Spring Boot backend.
Selected Java Interview Questions
A professional Java tech channel sharing common knowledge to help developers fill gaps. Follow us!
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.