How to Prevent Duplicate Submissions in Java APIs with Redis and Redisson Locks

This article explains what debounce means for web APIs, identifies which endpoints need it, describes how to detect duplicate requests, and provides complete Java implementations using shared Redis caches and Redisson distributed locks with code examples and key generation strategies.

Code Ape Tech Column
Code Ape Tech Column
Code Ape Tech Column
How to Prevent Duplicate Submissions in Java APIs with Redis and Redisson Locks

Preface

As an experienced backend Java developer who has built management back‑ends and mini‑programs, I have designed single‑ and multi‑tenant systems, integrated many open platforms, and built complex message‑center applications, yet I have never suffered financial loss due to a code crash in production.

The reasons are threefold: the business systems are not overly complex, I strictly follow a large‑company coding standard, and years of experience have given me practical techniques.

What Is Debounce

Debounce means preventing user “hand‑shakes” and network jitter. In web systems, form submissions are common; without control, accidental clicks or network delays can cause the same request to be sent multiple times, creating duplicate records.

For user mistakes, the front end usually shows a loading state to block repeated clicks. For network‑induced resubmissions, the back end must also implement debounce logic to ensure the same request is not processed multiple times.

An ideal debounce component should have the following characteristics:

Logical correctness – no false positives.

Fast response – not overly slow.

Easy integration – logic decoupled from business.

Clear user feedback, e.g., “You are clicking too fast”.

Thought Process

We now know debounce is necessary, but before development we must clarify a few questions.

Which APIs Need Debounce?

Not every API requires debounce. Generally, the following categories do:

User input APIs : search boxes, form inputs, etc. Requests can be delayed until the user finishes typing.

Button click APIs : submit forms, save settings, etc. Requests can be delayed until the user stops clicking.

Scroll‑load APIs : pull‑to‑refresh, infinite scroll, etc. Requests can be delayed until scrolling stops.

How to Determine a Duplicate Request?

Debounce is essentially duplicate‑submission prevention. To decide whether two calls are duplicates, we first set a time interval – calls spaced farther apart are not duplicates.

Second, compare request parameters; it is enough to compare a few identifying fields rather than the whole payload.

Finally, for stricter checks, the request URL can also be compared.

How to Implement Debounce in a Distributed Deployment?

There are two main solutions:

Shared Cache

图片
图片

Use a common Redis cache to store a lock key for each request. If the key already exists, the request is rejected.

Distributed Lock

图片
图片

Common distributed components include Redis and Zookeeper; Redis is usually chosen because it is already a core component of most web systems.

Specific Implementation

Define a @RequestLock annotation and place it on the target method.

RequestLock.java (annotation definition)

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

Data class example:

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

The annotation includes basic attributes: Redis lock prefix, lock time, time unit, and a key delimiter. The delimiter joins selected parameters to form a unique key, e.g., "张三&123456" plus the prefix.

Define @RequestKeyParam to mark method parameters or fields that should be used as part of the lock key.

@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RequestKeyParam {}

Key generation logic (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 (RedisRequestLockAspect):

@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, "系统异常");
    }
}

The SET_IF_ABSENT option means the key is set only when it does not already exist, preventing duplicate submissions.

Redisson Distributed Lock

Maven dependency:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.10.6</version>
</dependency>

Redisson configuration:

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

Redisson lock aspect:

@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 shows the debounce works: the first request succeeds, rapid repeated submissions are blocked with a “operation too fast” error, and after the lock expires the request succeeds again. However, once the cache expires or the lock times out, duplicate requests can still reach the service, so true idempotency also requires business‑level checks such as database unique constraints.

In production, it is advisable to include user‑related information (e.g., user ID, IP) in the lock key to reduce false positives.

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.

JavaRedisSpring Bootdistributed lockredissonDebounce
Code Ape Tech Column
Written by

Code Ape Tech Column

Former Ant Group P8 engineer, pure technologist, sharing full‑stack Java, job interview and career advice through a column. Site: java-family.cn

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.