How to Implement Robust Request Debounce in Java Backend with Redis and Redisson

This article explains why request debounce is essential for preventing duplicate submissions, outlines which APIs need protection, and walks through two distributed‑lock solutions—shared Redis cache and Redisson—showing concrete annotations, key‑generation logic, and Spring‑AOP code with full test results.

Architect
Architect
Architect
How to Implement Robust Request Debounce in Java Backend with Redis and Redisson

Preface

As a veteran Java backend developer who has built multi‑tenant systems, message centers, and integrated many open platforms, I have never suffered a production loss caused by code crashes. The reasons are threefold: the business logic is relatively simple, I strictly follow a large‑company coding standard, and years of experience have given me a set of practical techniques.

What Is Debounce?

Debounce covers two scenarios: user‑hand shaking and network jitter. In web systems, a form submission can be sent multiple times if the user clicks repeatedly or the network retries, leading to duplicate records. Frontend can mitigate the former by showing a loading state; the latter requires backend logic to reject repeated requests.

An ideal debounce component should satisfy:

Correct logic without false positives.

Fast response.

Easy integration and business‑logic decoupling.

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

Thought Process

Before writing code we must answer three questions:

Which APIs Need Debounce?

User‑input APIs such as search or form fields – requests fire frequently but can be delayed until the user stops typing.

Button‑click APIs like submit or save – multiple clicks should be coalesced.

Scroll‑load APIs such as pull‑to‑refresh or infinite scroll – requests should wait until scrolling stops.

How to Identify Duplicate Requests?

We treat two calls as duplicates when:

The time interval between them is less than a configured threshold.

Key parameters (not necessarily all) match; choose the most identifying fields.

Optionally, the request URL is also the same.

Distributed Deployment: How to Do API Debounce?

Two common solutions are presented.

Shared Cache (Redis)

Flow diagram (omitted) shows the steps:

Key points:

Redis is usually already part of a web stack, so no extra component is needed.

We store a temporary lock key with SET_IF_ABSENT and an expiration.

Distributed Lock (Redisson)

Flow diagram (omitted) shows the lock acquisition and release steps.

Redisson is chosen because it provides a high‑level API for distributed locks on top of Redis.

Concrete Implementation

Assume we have a user‑creation endpoint:

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

DTO definition:

package com.summo.demo.model.request;

import java.util.List;
import lombok.Data;

@Data
public class AddReq {
    /** User name */
    private String userName;
    /** User phone */
    private String userPhone;
    /** Role ID list */
    private List<Long> roleIdList;
}
The database currently lacks a unique index on userPhone , so each call creates a new record even if the phone number repeats.

RequestLock Annotation

We define a custom annotation to mark methods that need debounce:

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;

/** Request debounce lock */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RequestLock {
    /** Redis key prefix */
    String prefix() default "";
    /** Expiration time, default 2 seconds */
    int expire() default 2;
    /** Time unit, default seconds */
    TimeUnit timeUnit() default TimeUnit.SECONDS;
    /** Key delimiter */
    String delimiter() default "&";
}
The annotation stores the prefix, expiration, time unit, and delimiter used to build the lock key.

RequestKeyParam Annotation

To mark which method parameters (or fields inside a parameter object) should be part of the lock key:

package com.example.requestlock.lock.annotation;

import java.lang.annotation.*;

/** Mark a parameter as part of the lock key */
@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RequestKeyParam {}

Lock Key Generation

The utility scans method parameters and, if a parameter or its fields carry @RequestKeyParam, it concatenates their values using the delimiter defined in RequestLock:

public class RequestKeyGenerator {
    /** Build the lock key from the join point */
    public static String getLockKey(ProceedingJoinPoint joinPoint) {
        MethodSignature ms = (MethodSignature) joinPoint.getSignature();
        Method method = ms.getMethod();
        RequestLock lockAnno = method.getAnnotation(RequestLock.class);
        Object[] args = joinPoint.getArgs();
        Parameter[] params = method.getParameters();
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < params.length; i++) {
            RequestKeyParam pk = params[i].getAnnotation(RequestKeyParam.class);
            if (pk != null) {
                sb.append(lockAnno.delimiter()).append(args[i]);
            }
        }
        if (sb.length() == 0) {
            Annotation[][] paramAnnos = method.getParameterAnnotations();
            for (int i = 0; i < paramAnnos.length; i++) {
                Object obj = args[i];
                for (Field field : obj.getClass().getDeclaredFields()) {
                    if (field.getAnnotation(RequestKeyParam.class) != null) {
                        field.setAccessible(true);
                        sb.append(lockAnno.delimiter())
                          .append(ReflectionUtils.getField(field, obj));
                    }
                }
            }
        }
        return lockAnno.prefix() + sb.toString();
    }
}

Redis‑Based Aspect

Using Spring AOP we acquire a Redis lock via StringRedisTemplate.execute with SET_IF_ABSENT and the configured expiration:

@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) throws Throwable {
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        RequestLock lockAnno = method.getAnnotation(RequestLock.class);
        if (StringUtils.isEmpty(lockAnno.prefix())) {
            throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "Lock prefix cannot be empty");
        }
        String lockKey = RequestKeyGenerator.getLockKey(joinPoint);
        Boolean success = stringRedisTemplate.execute((RedisCallback<Boolean>) conn ->
            conn.set(lockKey.getBytes(), new byte[0],
                Expiration.from(lockAnno.expire(), lockAnno.timeUnit()),
                RedisStringCommands.SetOption.SET_IF_ABSENT));
        if (!Boolean.TRUE.equals(success)) {
            throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "Your operation is too fast, please retry later");
        }
        try {
            return joinPoint.proceed();
        } catch (Throwable t) {
            throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "System error");
        }
    }
}
SET_IF_ABSENT means the key is set only when it does not already exist, which is the core of the debounce logic.

Redisson‑Based Aspect

Redisson provides a higher‑level lock API. After configuring a RedissonClient, the aspect tries to acquire the lock, sets the same expiration, and releases it finally:

@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) throws Throwable {
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        RequestLock lockAnno = method.getAnnotation(RequestLock.class);
        if (StringUtils.isEmpty(lockAnno.prefix())) {
            throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "Lock prefix cannot be empty");
        }
        String lockKey = RequestKeyGenerator.getLockKey(joinPoint);
        RLock lock = redissonClient.getLock(lockKey);
        boolean acquired = lock.tryLock();
        if (!acquired) {
            throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "Your operation is too fast, please retry later");
        }
        lock.lock(lockAnno.expire(), lockAnno.timeUnit());
        try {
            return joinPoint.proceed();
        } catch (Throwable t) {
            throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "System error");
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}
Redisson’s approach is to acquire the lock, hold it for the configured period, and reject any concurrent request that cannot obtain the lock.

Testing Results

First submission – "Add user success" (image omitted).

Rapid repeat submission – error code BIZ-0001: Your operation is too fast, please retry later (image omitted).

After a few seconds – "Add user success" again (image omitted).

The tests confirm that debounce works, but once the cache expires or the lock is released, the same request can be sent again. True idempotency still requires business‑level checks such as a unique index on the database and possibly adding user‑ID or IP to the lock key.

Overall, the article demonstrates a complete workflow: identify the need for debounce, decide which APIs to protect, generate a deterministic lock key via annotations, and enforce the lock using either raw Redis commands or Redisson’s distributed‑lock API.

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.

BackendJavaredisspringdistributed-lockDebounce
Architect
Written by

Architect

Professional architect sharing high‑quality architecture insights. Topics include high‑availability, high‑performance, high‑stability architectures, big data, machine learning, Java, system and distributed architecture, AI, and practical large‑scale architecture case studies. Open to ideas‑driven architects who enjoy sharing and learning.

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.