SpringBoot Techniques for API Debounce to Eliminate Duplicate Submissions

The article explains why API debounce is essential, identifies the types of endpoints that need it, and walks through two SpringBoot solutions—Redis‑based shared cache and Redisson distributed lock—complete with custom annotations, key generation, code samples, test results, and practical limitations.

Programmer XiaoFu
Programmer XiaoFu
Programmer XiaoFu
SpringBoot Techniques for API Debounce to Eliminate Duplicate Submissions

Debounce (防抖) prevents duplicate requests caused by user mis‑clicks or network jitter. In web systems, form submissions can be sent multiple times, creating duplicate records; front‑end loading states handle user clicks, but back‑end logic is required for network‑induced repeats.

Ideal Debounce Component

Correct logic without false positives.

Fast response.

Easy integration and business‑logic decoupling.

Clear user feedback, e.g., "You clicked too fast".

Which APIs Need Debounce?

User‑input APIs such as search or form fields where rapid input does not require immediate requests.

Button‑click APIs like submit or save where users may click repeatedly.

Scroll‑load APIs (pull‑to‑refresh, infinite scroll) that can fire many requests during scrolling.

How to Identify Duplicate Requests

Determine duplication by:

Setting a time interval; requests spaced beyond it are not duplicates.

Comparing a subset of parameters that uniquely identify the request.

Optionally comparing the request URL.

Distributed Deployment Options

Shared Cache (Redis)

A flowchart (image) shows using a shared Redis cache. The core code executes stringRedisTemplate.execute with SET_IF_ABSENT and an expiration time to create a lock key.

Distributed Lock (Redisson)

Another flowchart illustrates using Redisson's distributed lock. Redisson is preferred because it is commonly part of a SpringBoot stack.

Custom Annotations

@RequestLock

is placed on the controller method to configure lock prefix, expiration, time unit, and delimiter. @RequestKeyParam marks method parameters or object fields that should be concatenated to form the lock key.

Lock Key Generation

public class RequestKeyGenerator {
    public static String getLockKey(ProceedingJoinPoint joinPoint) {
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        RequestLock requestLock = method.getAnnotation(RequestLock.class);
        Object[] args = joinPoint.getArgs();
        Parameter[] parameters = method.getParameters();
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < parameters.length; i++) {
            RequestKeyParam keyParam = parameters[i].getAnnotation(RequestKeyParam.class);
            if (keyParam == null) continue;
            sb.append(requestLock.delimiter()).append(args[i]);
        }
        if (StringUtils.isEmpty(sb.toString())) {
            Annotation[][] parameterAnnotations = method.getParameterAnnotations();
            for (int i = 0; i < parameterAnnotations.length; i++) {
                Object object = args[i];
                Field[] fields = object.getClass().getDeclaredFields();
                for (Field field : fields) {
                    RequestKeyParam annotation = field.getAnnotation(RequestKeyParam.class);
                    if (annotation == null) continue;
                    field.setAccessible(true);
                    sb.append(requestLock.delimiter()).append(ReflectionUtils.getField(field, object));
                }
            }
        }
        return requestLock.prefix() + sb;
    }
}

Redis Implementation

@Aspect
@Configuration
@Order(2)
public class RedisRequestLockAspect {
    private final StringRedisTemplate stringRedisTemplate;
    @Autowired
    public RedisRequestLockAspect(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }
    @Around("execution(public * * (..)) && @annotation(com.summo.demo.config.requestlock.RequestLock)")
    public Object interceptor(ProceedingJoinPoint joinPoint) {
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        RequestLock requestLock = method.getAnnotation(RequestLock.class);
        if (StringUtils.isEmpty(requestLock.prefix())) {
            throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "重复提交前缀不能为空");
        }
        String lockKey = RequestKeyGenerator.getLockKey(joinPoint);
        Boolean success = stringRedisTemplate.execute((RedisCallback<Boolean>) connection ->
            connection.set(lockKey.getBytes(), new byte[0],
                Expiration.from(requestLock.expire(), requestLock.timeUnit()),
                RedisStringCommands.SetOption.SET_IF_ABSENT));
        if (!success) {
            throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,请稍后重试");
        }
        try {
            return joinPoint.proceed();
        } catch (Throwable t) {
            throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "系统异常");
        }
    }
}

Redisson Implementation

@Aspect
@Configuration
@Order(2)
public class RedissonRequestLockAspect {
    private final RedissonClient redissonClient;
    @Autowired
    public RedissonRequestLockAspect(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }
    @Around("execution(public * * (..)) && @annotation(com.summo.demo.config.requestlock.RequestLock)")
    public Object interceptor(ProceedingJoinPoint joinPoint) {
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        RequestLock requestLock = method.getAnnotation(RequestLock.class);
        if (StringUtils.isEmpty(requestLock.prefix())) {
            throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "重复提交前缀不能为空");
        }
        String lockKey = RequestKeyGenerator.getLockKey(joinPoint);
        RLock lock = redissonClient.getLock(lockKey);
        boolean isLocked = false;
        try {
            isLocked = lock.tryLock();
            if (!isLocked) {
                throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,请稍后重试");
            }
            lock.lock(requestLock.expire(), requestLock.timeUnit());
            return joinPoint.proceed();
        } catch (Throwable t) {
            throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "系统异常");
        } finally {
            if (isLocked && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

Testing Results

First submission: "Add user success".

Rapid repeat within lock period: "BIZ-0001: 您的操作太快了,请稍后重试".

After a few seconds (lock expired): "Add user success" again.

The tests confirm that debounce works, but once the cache expires or the lock is released, duplicate requests can still be processed. True idempotency also requires business‑level checks such as a unique index on userPhone and possibly including user‑ID or IP in the lock key.

Practical Recommendations

When generating the lock key, select fields that uniquely identify the request but keep the key size small for Redis efficiency. In production, add user‑related information (e.g., user ID, IP region) to reduce false positives. Combine debounce with database unique constraints for complete protection against duplicate data.

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.

JavaredisSpringBootredissondebounceduplicate-submissionrequest-lock
Programmer XiaoFu
Written by

Programmer XiaoFu

xiaofucode.com – a programmer learning guide driven by the pursuit of profit

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.