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.

Java Tech Workshop
Java Tech Workshop
Java Tech Workshop
SpringBoot Backend Anti‑Duplicate Submission: Stop “Hand‑Shake” Clicks with Redis‑AOP

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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

backendAOPRedisSpringBootAnnotationDuplicateSubmission
Java Tech Workshop
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.