Backend Development 17 min read

Preventing Duplicate Submissions in Java Backend with Debounce and Distributed Locks

This article explains how to implement request debouncing and distributed locking in a Java backend using Redis and Redisson, covering the definition of debounce, identifying suitable APIs, generating unique keys, and providing complete code examples for both cache‑based and Redisson‑based lock mechanisms.

Top Architect
Top Architect
Top Architect
Preventing Duplicate Submissions in Java Backend with Debounce and Distributed Locks

As an experienced Java backend developer, the author shares practical techniques to avoid duplicate submissions caused by user mistakes or network instability, focusing on request debouncing (防抖) and distributed locking.

What is debounce? It prevents repeated actions by limiting how often a request can be processed, useful for user input, button clicks, and scroll‑load APIs.

Identifying interfaces that need debounce includes user‑input APIs, button‑click APIs, and scroll‑load APIs. Determining duplicates involves checking a time interval, comparing key parameters, and optionally the request URL.

Implementation approaches :

Shared cache using Redis SET_IF_ABSENT.

Distributed lock using Redisson.

Code example – controller method :

@PostMapping('/add')
@RequiresPermissions(value = 'add')
@Log(methodDesc = '添加用户')
public ResponseEntity
add(@RequestBody AddReq addReq) {
    return userService.add(addReq);
}

Data transfer object (AddReq.java):

package com.summo.demo.model.request;
import java.util.List;
import lombok.Data;
@Data
public class AddReq {
    /** 用户名称 */
    private String userName;
    /** 用户手机号 */
    private String userPhone;
    /** 角色ID列表 */
    private List
roleIdList;
}

Unique key generation using RequestKeyGenerator :

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]);
        }
        // fallback to field annotations if no method parameters are marked
        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 cache‑based lock ( RedisRequestLockAspect ) uses StringRedisTemplate.execute with SET_IF_ABSENT and an expiration time to ensure only the first request acquires the lock.

@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
) 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 distributed lock ( RedissonRequestLockAspect ) obtains a lock object via redissonClient.getLock(lockKey) , tries to acquire it, sets an expiration, executes the method, and finally releases the lock.

@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();
            }
        }
    }
}

Configuration for Redisson ( RedissonConfig ) creates a client pointing to a single‑node Redis instance.

@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");
        return Redisson.create(config);
    }
}

Testing shows the first request succeeds, rapid duplicate submissions are blocked with a clear error message, and after the lock expires the request succeeds again. The author notes that true idempotency also requires database unique constraints and business‑level checks.

Finally, the article includes promotional sections unrelated to the technical content, but the core tutorial provides a complete, production‑ready solution for preventing duplicate submissions in Java backend services.

BackendJavaRedisDistributed LockSpringBootDebounce
Top Architect
Written by

Top Architect

Top Architect focuses on sharing practical architecture knowledge, covering enterprise, system, website, large‑scale distributed, and high‑availability architectures, plus architecture adjustments using internet technologies. We welcome idea‑driven, sharing‑oriented architects to exchange and learn together.

0 followers
Reader feedback

How this landed with the community

login 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.