Rate Limiting in Java: Redis, Spring AOP, and Custom Annotations
This article explains the concept of rate limiting, introduces common algorithms such as token bucket and leaky bucket, and provides a complete Spring‑Boot implementation using Redis, custom annotations, AOP, and utility classes for distributed request throttling.
Rate Limiting Introduction
Rate limiting refers to restricting the number of accesses to a resource within a certain time window to prevent abuse, overload, or excessive consumption. Proper rate limiting can protect servers from crashing, improve user experience, and increase system availability.
Common rate‑limiting algorithms include:
Leaky Bucket: A fixed‑size bucket receives requests; tokens are drained at a constant rate. Excess requests are discarded when the bucket overflows.
Token Bucket: Tokens are added to a bucket at a fixed rate. A request consumes a token; if none are available, the request is rejected.
Sliding Window: Counts requests within a moving time window; if the count exceeds a threshold, further requests are denied.
Typical scenarios for applying rate limiting are:
Prevent server crashes caused by sudden traffic spikes.
Maintain a smooth user experience by limiting rapid repeated calls.
Increase overall system reliability by avoiding resource exhaustion.
The implementation relies on Redis, Redisson, AOP, and custom annotations.
Dependencies
<!--redisson-->
org.redisson
redisson-spring-boot-starter
com.baomidou
lock4j-redisson-spring-boot-starter
org.springframework
spring-context-support
org.springframework
spring-web
org.springframework.boot
spring-boot-starter-validation
org.springframework.boot
spring-boot-starter-aop
org.apache.commons
commons-lang3
jakarta.servlet
jakarta.servlet-api
cn.hutool
hutool-core
cn.hutool
hutool-http
cn.hutool
hutool-extra
org.projectlombok
lombok
org.springframework.boot
spring-boot-configuration-processor
org.springframework.boot
spring-boot-properties-migrator
runtime
io.github.linpeilie
mapstruct-plus-spring-boot-starter
org.lionsoul
ip2region1. Define Rate Limiting Types
public enum LimitType {
/** Default global limit */
DEFAULT,
/** Limit by requester's IP */
IP,
/** Cluster instance limit */
CLUSTER
}2. Define the @RateLimiter Annotation
import java.lang.annotation.*;
/**
* Rate limiting annotation
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimiter {
/**
* Rate‑limiting key, supports Spring EL expressions.
*/
String key() default "";
/**
* Time window in seconds.
*/
int time() default 60;
/**
* Allowed number of requests.
*/
int count() default 100;
/**
* Limiting strategy.
*/
LimitType limitType() default LimitType.DEFAULT;
/**
* Message shown when limit is exceeded (supports i18n).
*/
String message() default "{rate.limiter.message}";
}Redis Utility Class
The following class provides common Redis operations used by the rate‑limiting logic. Only the relevant methods are shown; you can copy or adapt them as needed.
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@SuppressWarnings({"unchecked", "rawtypes"})
public class RedisUtils {
private static final RedissonClient CLIENT = SpringUtils.getBean(RedissonClient.class);
/**
* Perform rate limiting.
*/
public static long rateLimiter(String key, RateType rateType, int rate, int rateInterval) {
RRateLimiter limiter = CLIENT.getRateLimiter(key);
limiter.trySetRate(rateType, rate, rateInterval, RateIntervalUnit.SECONDS);
if (limiter.tryAcquire()) {
return limiter.availablePermits();
} else {
return -1L;
}
}
// ... (other cache helper methods omitted for brevity) ...
}i18n Resource Helper
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class MessageUtils {
private static final MessageSource MESSAGE_SOURCE = SpringUtils.getBean(MessageSource.class);
public static String message(String code, Object... args) {
try {
return MESSAGE_SOURCE.getMessage(code, args, LocaleContextHolder.getLocale());
} catch (NoSuchMessageException e) {
return code;
}
}
}Custom Business Exception
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
public final class ServiceException extends RuntimeException {
@Serial
private static final long serialVersionUID = 1L;
private Integer code;
private String message;
private String detailMessage;
public ServiceException(String message) { this.message = message; }
public ServiceException(String message, Integer code) { this.message = message; this.code = code; }
@Override
public String getMessage() { return message; }
// getters and setters omitted for brevity
}Servlet Utility Class
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ServletUtils extends JakartaServletUtil {
/** Get current HttpServletRequest */
public static HttpServletRequest getRequest() {
try { return getRequestAttributes().getRequest(); } catch (Exception e) { return null; }
}
/** Get client IP address */
public static String getClientIP() { return getClientIP(getRequest()); }
}3. Process the @RateLimiter Annotation (Aspect)
@Slf4j
@Aspect
public class RateLimiterAspect {
private final ExpressionParser parser = new SpelExpressionParser();
private final ParserContext parserContext = new TemplateParserContext();
private final EvaluationContext context = new StandardEvaluationContext();
private final ParameterNameDiscoverer pnd = new DefaultParameterNameDiscoverer();
private final String GLOBAL_REDIS_KEY = "global:";
private final String RATE_LIMIT_KEY = GLOBAL_REDIS_KEY + "rate_limit:";
@Before("@annotation(rateLimiter)")
public void doBefore(JoinPoint point, RateLimiter rateLimiter) throws Throwable {
int time = rateLimiter.time();
int count = rateLimiter.count();
String combineKey = getCombineKey(rateLimiter, point);
try {
RateType rateType = RateType.OVERALL;
if (rateLimiter.limitType() == LimitType.CLUSTER) {
rateType = RateType.PER_CLIENT;
}
long number = RedisUtils.rateLimiter(combineKey, rateType, count, time);
if (number == -1) {
String message = rateLimiter.message();
if (StringUtils.startsWith(message, "{") && StringUtils.endsWith(message, "}")) {
message = MessageUtils.message(StringUtils.substring(message, 1, message.length() - 1));
}
throw new ServiceException(message);
}
log.info("Limit token => {}, remaining => {}, cache key => '{}'", count, number, combineKey);
} catch (Exception e) {
if (e instanceof ServiceException) {
throw e;
} else {
throw new RuntimeException("Server rate‑limit exception, please try later");
}
}
}
/** Build the final Redis key based on the annotation and request context */
public String getCombineKey(RateLimiter rateLimiter, JoinPoint point) {
String key = rateLimiter.key();
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
if (StringUtils.containsAny(key, "#")) {
Object[] args = point.getArgs();
String[] paramNames = pnd.getParameterNames(method);
if (ArrayUtil.isEmpty(paramNames)) {
throw new ServiceException("Rate‑limit key parsing error! Contact administrator.");
}
for (int i = 0; i < paramNames.length; i++) {
context.setVariable(paramNames[i], args[i]);
}
try {
Expression expression = StringUtils.startsWith(key, parserContext.getExpressionPrefix()) &&
StringUtils.endsWith(key, parserContext.getExpressionSuffix())
? parser.parseExpression(key, parserContext)
: parser.parseExpression(key);
key = expression.getValue(context, String.class) + ":";
} catch (Exception e) {
throw new ServiceException("Rate‑limit key parsing error! Contact administrator.");
}
}
StringBuilder sb = new StringBuilder(RATE_LIMIT_KEY);
sb.append(ServletUtils.getRequest().getRequestURI()).append(":");
if (rateLimiter.limitType() == LimitType.IP) {
sb.append(ServletUtils.getClientIP()).append(":");
} else if (rateLimiter.limitType() == LimitType.CLUSTER) {
sb.append(RedisUtils.getClient().getId()).append(":");
}
return sb.append(key).toString();
}
}Testing the Rate Limiter
A sample controller demonstrates three scenarios: global limit, IP‑based limit, and cluster‑instance limit. The controller returns a generic response object R , which can be replaced with any project‑specific wrapper.
@Slf4j
@RestController
@RequestMapping("/demo/rateLimiter")
public class RedisRateLimiterController {
/** Global limit (affects all requests) */
@RateLimiter(count = 2, time = 10)
@GetMapping("/test")
public R
test(String value) {
return R.ok("Operation successful", value);
}
/** IP‑based limit */
@RateLimiter(count = 2, time = 10, limitType = LimitType.IP)
@GetMapping("/testip")
public R
testIp(String value) {
return R.ok("Operation successful", value);
}
/** Cluster instance limit (different backend nodes are independent) */
@RateLimiter(count = 2, time = 10, limitType = LimitType.CLUSTER)
@GetMapping("/testcluster")
public R
testCluster(String value) {
return R.ok("Operation successful", value);
}
/** IP limit with a dynamic key based on method parameter */
@RateLimiter(count = 2, time = 10, limitType = LimitType.IP, key = "#value")
@GetMapping("/testObj")
public R
testObj(String value) {
return R.ok("Operation successful", value);
}
}If you encounter any issues or have suggestions, feel free to comment and discuss.
Java Captain
Focused on Java technologies: SSM, the Spring ecosystem, microservices, MySQL, MyCat, clustering, distributed systems, middleware, Linux, networking, multithreading; occasionally covers DevOps tools like Jenkins, Nexus, Docker, ELK; shares practical tech insights and is dedicated to full‑stack Java development.
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.