How to Implement Robust API Debounce with Redis and Redisson in Spring Boot 3
This article explains why API debounce is essential, outlines which endpoints need it, describes how to generate unique keys, and provides complete implementations using Redis cache and Redisson distributed locks in a Spring Boot 3 backend, complete with testing results.
1. Introduction
As a backend Java developer with six years of experience, I mainly build backend services such as management consoles and mini‑programs. I have designed single‑ and multi‑tenant systems, integrated many open platforms, and built complex message centers, yet I have never suffered data loss from code crashes because the business logic is simple, I follow strict coding standards, and I have accumulated practical techniques.
2. What Is Debounce?
Debounce means preventing user mistakes and network jitter. In web systems, form submissions are common; without control, user errors or network delays can cause the same request to be sent multiple times, creating duplicate records. Frontend can show a loading state to block rapid clicks, but backend must also implement debounce logic to ensure the same request is not processed repeatedly during network fluctuations.
An ideal debounce component should be:
Logically correct, without false positives.
Responsive, not slow.
Easy to integrate and decoupled from business logic.
Provide clear user feedback, e.g., “You clicked too fast”.
3. Design Considerations
Not every endpoint needs debounce. Typically required endpoints include:
User‑input interfaces such as search boxes or form fields, where frequent requests can be delayed until the user finishes typing.
Button‑click interfaces such as form submission or settings save, where rapid clicks should be throttled.
Scroll‑loading interfaces such as pull‑to‑refresh or load‑more, where requests should wait until scrolling stops.
3.1 Determining Duplicate Requests
Duplicate detection can be based on a time interval (requests within the interval are not duplicates) and a comparison of key parameters; full parameter comparison is unnecessary—choose strongly identifying fields. Optionally, compare request URLs as well.
3.2 Distributed Deployment Strategies
Two common solutions:
(1) Shared Cache
(2) Distributed Lock
Common distributed components include Redis and Zookeeper, but Redis is usually chosen because it is already a core component of most web systems.
4. Implementation
Example controller method for adding a user:
<code>@PostMapping("/add")
@RequiresPermissions(value = "add")
@Log(methodDesc = "Add user")
public ResponseEntity<String> add(@RequestBody AddReq addReq) {
return userService.add(addReq);
}
</code>Request DTO:
<code>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;
}
</code>The database currently lacks a unique index on userPhone , so each call creates a new user even if the phone number is identical.
4.1 Request Lock Annotation
Define
@RequestLockto configure lock properties:
<code>import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RequestLock {
/** Redis lock prefix */
String prefix() default "";
/** Expiration time (seconds by default) */
int expire() default 2;
/** Expiration time unit */
TimeUnit timeUnit() default TimeUnit.SECONDS;
/** Key delimiter */
String delimiter() default "&";
}
</code>The annotation defines basic lock attributes; the delimiter joins multiple fields to form a unique key.
4.2 Unique Key Generation
Mark parameters or fields with
@RequestKeyParamto include them in the lock key:
<code>@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RequestKeyParam {}
</code>Key generator extracts annotated values and concatenates them:
<code>public class RequestKeyGenerator {
public static String getLockKey(ProceedingJoinPoint joinPoint) {
// (implementation omitted for brevity)
return requestLock.prefix() + sb;
}
}
</code>4.3 Duplicate Submission Check
(1) Redis Cache Implementation
<code>@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) {
MethodSignature ms = (MethodSignature) joinPoint.getSignature();
Method method = ms.getMethod();
RequestLock lock = method.getAnnotation(RequestLock.class);
if (StringUtils.isEmpty(lock.prefix())) {
throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "Duplicate 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(lock.expire(), lock.timeUnit()),
RedisStringCommands.SetOption.SET_IF_ABSENT));
if (!success) {
throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "Your operation is too fast, please try later");
}
try {
return joinPoint.proceed();
} catch (Throwable t) {
throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "System error");
}
}
}
</code>SET_IF_ABSENT ensures the key is set only when it does not already exist.
(2) Redisson Distributed Lock Implementation
<code>@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) {
MethodSignature ms = (MethodSignature) joinPoint.getSignature();
Method method = ms.getMethod();
RequestLock lock = method.getAnnotation(RequestLock.class);
if (StringUtils.isEmpty(lock.prefix())) {
throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "Duplicate prefix cannot be empty");
}
String lockKey = RequestKeyGenerator.getLockKey(joinPoint);
RLock rLock = redissonClient.getLock(lockKey);
boolean acquired = false;
try {
acquired = rLock.tryLock();
if (!acquired) {
throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "Your operation is too fast, please try later");
}
rLock.lock(lock.expire(), lock.timeUnit());
return joinPoint.proceed();
} catch (Throwable t) {
throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "System error");
} finally {
if (acquired && rLock.isHeldByCurrentThread()) {
rLock.unlock();
}
}
}
}
</code>Redisson acquires a lock; if acquisition fails, the request is considered a duplicate.
4.4 Testing
First submission: “Add user success”.
Rapid repeat submission: “BIZ-0001: Your operation is too fast, please try later”.
After a few seconds: “Add user success”.
Debounce works, but when the cache expires or the lock is released, duplicate requests can still occur; true idempotency also requires business‑level checks and database unique constraints.
macrozheng
Dedicated to Java tech sharing and dissecting top open-source projects. Topics include Spring Boot, Spring Cloud, Docker, Kubernetes and more. Author’s GitHub project “mall” has 50K+ stars.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.