Backend Development 21 min read

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.

Java Captain
Java Captain
Java Captain
Rate Limiting in Java: Redis, Spring AOP, and Custom Annotations

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
ip2region

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

JavaAOPRedisSpringAnnotationdistributedrate limiting
Java Captain
Written by

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.

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.