SpringBoot Backend Anti‑Duplicate Submission: Stop “Hand‑Shake” Clicks with Redis‑AOP
This article explains why frontend debouncing cannot fully prevent duplicate form submissions, compares common backend anti‑duplicate strategies, and provides a step‑by‑step guide to implementing a robust, annotation‑driven solution in SpringBoot using Redis distributed locks, AOP, and custom exception handling.
1. Why Frontend Debounce Is Never Enough
Frontend disabling or debouncing can handle about 80% of accidental double‑clicks, but the remaining 20%—such as packet replay, network retransmission, or client crashes—bypass the UI layer. Backend safeguards are required for true reliability.
Can be bypassed : tech‑savvy users can replay captured requests.
Network latency : users may click repeatedly before the UI disables the button.
Client exceptions : app crashes or automatic retries resend requests.
System‑to‑system calls : internal service calls or third‑party callbacks have no UI.
Frontend protects normal user mistakes; backend protects all abnormal duplicate requests.
2. Common Backend Anti‑Duplicate Solutions
Various approaches exist, each suited to different scenarios. Selecting the wrong one can introduce new problems.
Database unique index : adds a unique constraint on business fields; strongest guarantee but may execute business logic before the exception and provides poor user experience.
Local lock (synchronized/Guava Cache) : simple JVM‑level lock; works only in single‑node deployments.
One‑time token : client obtains a token before submission; prevents duplicate submissions but requires extra request round‑trip.
Redis distributed lock + AOP annotation : uses Redis SET NX EX atomic command; works in clusters, low‑intrusion, and covers 90% of business cases.
The last option—Redis + custom annotation + AOP—is highlighted as the most cost‑effective and widely applicable solution.
3. Annotation‑Based Debounce Implementation
Core idea : define a custom annotation to mark methods that need debouncing; an AOP aspect intercepts the call, builds a unique lock key ( user + request + parameter hash ), attempts to acquire a Redis lock, proceeds if successful, and releases the lock afterward.
3.1 Dependencies
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>3.2 Step 1: Custom Annotation
/**
* 防重复提交注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {
/** 防抖时间窗口(毫秒),默认 500ms */
long interval() default 500;
/** 重复提交时的提示信息 */
String message() default "操作过于频繁,请稍后再试";
/** 是否按请求参数校验 */
boolean checkParams() default true;
}3.3 Step 2: Redis Lock Utility
@Component
@RequiredArgsConstructor
public class RedisLockUtil {
private final StringRedisTemplate redisTemplate;
/** Try to acquire lock */
public boolean tryLock(String key, long expireMs) {
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(key, "1", expireMs, TimeUnit.MILLISECONDS);
return Boolean.TRUE.equals(result);
}
/** Release lock */
public void unlock(String key) {
redisTemplate.delete(key);
}
}3.4 Step 3: AOP Aspect Core
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class RepeatSubmitAspect {
private final RedisLockUtil redisLockUtil;
@Pointcut("@annotation(repeatSubmit)")
public void pointcut(RepeatSubmit repeatSubmit) {}
@Around(value = "pointcut(repeatSubmit)", argNames = "joinPoint,repeatSubmit")
public Object around(ProceedingJoinPoint joinPoint, RepeatSubmit repeatSubmit) throws Throwable {
String lockKey = buildLockKey(joinPoint, repeatSubmit.checkParams());
boolean lockSuccess = redisLockUtil.tryLock(lockKey, repeatSubmit.interval());
if (!lockSuccess) {
log.warn("重复提交拦截,Key:{}", lockKey);
throw new BusinessException("REPEAT_SUBMIT", repeatSubmit.message());
}
try {
return joinPoint.proceed();
} finally {
// optional: redisLockUtil.unlock(lockKey);
}
}
private String buildLockKey(ProceedingJoinPoint joinPoint, boolean checkParams) {
String userId = getCurrentUserId();
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
String uri = attrs != null ? attrs.getRequest().getRequestURI() : "";
StringBuilder sb = new StringBuilder("repeat:submit:");
sb.append(userId).append(":").append(uri);
if (checkParams) {
Object[] args = joinPoint.getArgs();
String params = JSON.toJSONString(args);
String md5 = DigestUtil.md5Hex(params);
sb.append(":").append(md5);
}
return sb.toString();
}
private String getCurrentUserId() {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null) {
String uid = attrs.getRequest().getHeader("userId");
return StrUtil.isNotBlank(uid) ? uid : "anonymous";
}
return "anonymous";
}
}Two key design details: Parameter hash : ensures different parameters on the same endpoint are not mistakenly blocked. Lock auto‑expire : prevents deadlocks even if the service crashes.
3.5 Step 4: Global Exception Handler
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {
log.warn("业务异常:{} - {}", e.getCode(), e.getMessage());
return Result.fail(e.getCode(), e.getMessage());
}
}3.6 Step 5: Usage Example
@RestController
@RequestMapping("/order")
public class OrderController {
@PostMapping("/submit")
@RepeatSubmit(interval = 1000, message = "订单提交中,请勿重复点击")
public Result<OrderVO> submitOrder(@RequestBody OrderSubmitDTO dto) {
OrderVO order = orderService.submit(dto);
return Result.success(order);
}
@PostMapping("/saveDraft")
@RepeatSubmit(interval = 2000, checkParams = false, message = "保存过于频繁,请稍后再试")
public Result<Void> saveDraft(@RequestBody DraftDTO dto) {
draftService.save(dto);
return Result.success();
}
}Adding the annotation gives the endpoint instant debouncing without touching business logic.
4. Making Debounce Smarter
Beyond the basic version, you can tailor the solution:
4.1 Support SpEL for Business Keys
Allow the annotation to accept a SpEL expression so that only a specific business identifier (e.g., orderId) participates in the lock key.
@RepeatSubmit(interval = 3000, key = "#orderId")
public Result<Void> payOrder(Long orderId) {
// payment logic
}4.2 Tiered Debounce Strategies
Query interfaces: 200 ms window.
Form submissions: 500‑1000 ms.
Payment/ordering: 2‑5 s.
Batch imports: 10‑30 s.
Different time windows prevent both over‑blocking and under‑protection.
4.3 Upgrade from Debounce to Idempotence
By extending the lock expiration to minutes or hours and combining it with a business‑level unique key, the same mechanism becomes a full idempotence solution.
4.4 Whitelist and Degradation
Introduce a global switch and per‑endpoint whitelist; during load tests or Redis failures, temporarily disable debouncing to keep core services available. On Redis exceptions, the aspect should fall back to a pass‑through mode.
5. Pitfalls to Avoid
1. Too coarse lock granularity
Locking only by endpoint can reject legitimate different payloads. Use the default user + endpoint + parameter hash granularity.
2. Lock expires before business logic finishes
Ensure the lock timeout exceeds the maximum execution time of critical write operations, or keep the lock until after business completion.
3. Redis outage makes the whole service unusable
Catch Redis exceptions inside the aspect and degrade gracefully, logging a warning instead of throwing.
4. Parameter hash inaccurate for file uploads
Exclude file streams from the hash or disable parameter checking for upload endpoints.
5. Relying solely on debounce without idempotence
Debounce only protects short‑term repeats; critical writes should also have a database unique index as a final safeguard.
6. Full Summary
Frontend debouncing improves user experience but cannot guarantee data integrity. A backend anti‑duplicate mechanism—implemented as a Redis‑backed, annotation‑driven AOP aspect—provides a low‑intrusion, cluster‑compatible, and highly configurable solution. By handling lock key generation, expiration, exception handling, and optional enhancements (SpEL keys, tiered windows, whitelist, degradation), developers can achieve both robust debouncing and idempotence, preventing dirty data, duplicate orders, and costly production incidents.
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.
Java Tech Workshop
Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.
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.
