Implementing Distributed Rate Limiting in Spring Boot with Redis and Lua Scripts

This article explains how to protect a Spring Boot API from traffic spikes by applying common rate‑limiting algorithms, configuring Redis for distributed throttling, writing Lua scripts for atomic counters, and integrating a custom @Limit annotation with AOP to enforce limits across a cluster.

Selected Java Interview Questions
Selected Java Interview Questions
Selected Java Interview Questions
Implementing Distributed Rate Limiting in Spring Boot with Redis and Lua Scripts

One day a developer notices that a specific API endpoint receives ten times the normal request volume, causing the service to become unavailable and eventually crashing the whole system. To handle such overloads, a rate‑limiting "circuit breaker" similar to an electrical fuse is introduced.

1. Common Rate‑Limiting Algorithms

1. Counter Method

The traditional counter suffers from race conditions and may violate a fixed‑rate definition.

2. Token Bucket

The token‑bucket algorithm adds tokens to a bucket at a constant rate; a request must acquire a token before proceeding. If the bucket is empty, the request is rejected. It is conceptually opposite to the leaky‑bucket algorithm.

Guava's RateLimiter provides a simple token‑bucket implementation, but it works only in a single JVM and cannot be used directly in a distributed environment.

3. Leaky Bucket

The leaky‑bucket model treats incoming requests as water poured into a bucket; water leaks out at a fixed rate. If the inflow exceeds the leak rate, the bucket overflows and further requests are rejected.

Token bucket stores tokens and allows burst traffic, while leaky bucket stores actual request data and does not permit bursts.

2. Distributed Rate‑Limiting Implementation

Redis‑Based Solution

Single‑node solutions such as AtomicInteger, RateLimiter, or Semaphore cannot handle clustered environments. Using Redis allows us to enforce limits across multiple instances.

Dependency Declaration

<dependencies>
    <!-- Spring Boot starter dependencies -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>com.google.guava</groupId>
        <artifactId>guava</artifactId>
        <version>21.0</version>
    </dependency>
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-lang3</artifactId>
    </dependency>
</dependencies>

Redis Configuration

Add the following properties to application.properties:

spring.redis.host=localhost
spring.redis.port=6379
spring.redis.password=battcn

Custom @Limit Annotation

package com.johnfnash.learn.springboot.ratelimiter.annotation;

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;

/**
 * Rate‑limiting annotation
 */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Limit {
    /** Resource name */
    String name() default "";
    /** Resource key */
    String key() default "";
    /** Key prefix */
    String prefix() default "";
    /** Time window in seconds */
    int period();
    /** Max request count */
    int count();
    /** Limit type */
    LimitType limitType() default LimitType.CUSTOMER;
}
package com.johnfnash.learn.springboot.ratelimiter.annotation;

/** Limit type enumeration */
public enum LimitType {
    /** Custom key */
    CUSTOMER,
    /** Based on client IP */
    IP;
}

RedisTemplate Bean

package com.johnfnash.learn.springboot.ratelimiter;

import java.io.Serializable;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisLimiterHelper {
    @Bean
    public RedisTemplate<String, Serializable> limitRedisTemplate(LettuceConnectionFactory factory) {
        RedisTemplate<String, Serializable> template = new RedisTemplate<>();
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.setConnectionFactory(factory);
        return template;
    }
}

Limit Interceptor (AOP)

package com.johnfnash.learn.springboot.ratelimiter.aop;

import java.io.Serializable;
import java.lang.reflect.Method;
import javax.servlet.http.HttpServletRequest;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import com.google.common.collect.ImmutableList;
import com.johnfnash.learn.springboot.ratelimiter.annotation.Limit;
import com.johnfnash.learn.springboot.ratelimiter.annotation.LimitType;

@Aspect
@Configuration
public class LimitInterceptor {
    private static final Logger logger = LoggerFactory.getLogger(LimitInterceptor.class);
    private final String REDIS_SCRIPT = buildLuaScript();

    @Autowired
    private RedisTemplate<String, Serializable> redisTemplate;

    @Around("execution(public * *(..)) && @annotation(com.johnfnash.learn.springboot.ratelimiter.annotation.Limit)")
    public Object interceptor(ProceedingJoinPoint pjp) throws Throwable {
        MethodSignature signature = (MethodSignature) pjp.getSignature();
        Method method = signature.getMethod();
        Limit limitAnno = method.getAnnotation(Limit.class);
        LimitType limitType = limitAnno.limitType();
        String name = limitAnno.name();
        String key = null;
        int limitPeriod = limitAnno.period();
        int limitCount = limitAnno.count();
        switch (limitType) {
            case IP:
                key = getIpAddress();
                break;
            case CUSTOMER:
                key = limitAnno.key();
                break;
            default:
                break;
        }
        ImmutableList<String> keys = ImmutableList.of(StringUtils.join(limitAnno.prefix(), key));
        try {
            RedisScript<Number> redisScript = new DefaultRedisScript<>(REDIS_SCRIPT, Number.class);
            Number count = redisTemplate.execute(redisScript, keys, limitCount, limitPeriod);
            logger.info("Access try count is {} for name={} and key = {}", count, name, key);
            if (count != null && count.intValue() <= limitCount) {
                return pjp.proceed();
            } else {
                throw new RuntimeException("You have been dragged into the blacklist");
            }
        } catch (Throwable e) {
            if (e instanceof RuntimeException) {
                throw new RuntimeException(e.getLocalizedMessage());
            }
            throw new RuntimeException("server exception");
        }
    }

    /** Build token‑bucket Lua script */
    private String buildLuaScript() {
        StringBuilder lua = new StringBuilder();
        lua.append("local c")
           .append("
c = redis.call('get', KEYS[1])")
           .append("
if c and tonumber(c) > tonumber(ARGV[1]) then")
           .append("
return c;")
           .append("
end")
           .append("
c = redis.call('incr', KEYS[1])")
           .append("
if tonumber(c) == 1 then")
           .append("
redis.call('expire', KEYS[1], ARGV[2])")
           .append("
end")
           .append("
return c;");
        return lua.toString();
    }

    private static final String UNKNOWN = "unknown";

    public String getIpAddress() {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        String ip = request.getHeader("x-forwarded-for");
        if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
            ip = request.getHeader("Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
            ip = request.getHeader("WL-Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        return ip;
    }
}

Controller Usage

Apply the @Limit annotation on an endpoint to enforce the rule. The example below creates a key test that allows at most 10 requests within 100 seconds.

package com.johnfnash.learn.springboot.ratelimiter.controller;

import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import com.johnfnash.learn.springboot.ratelimiter.annotation.Limit;

@RestController
public class LimiterController {
    private static final AtomicInteger ATOMIC_INTEGER = new AtomicInteger();

    @Limit(key = "test", period = 100, count = 10)
    @GetMapping("/test")
    public int testLimiter() {
        return ATOMIC_INTEGER.incrementAndGet();
    }
}

Testing

After configuring the application, start it and test the endpoint using a browser, Postman, JUnit, or Swagger. When the request count is below the threshold, the service returns a normal response; once the limit is reached, an error is returned.

Conclusion

Many developers have written Spring Boot tutorials; this guide is based on spring-boot-starter-parent:2.0.3.RELEASE. The core Lua script is taken from the open‑source project Aquarius. For high‑traffic scenarios where simple counters cannot handle bursts, a second Lua script using a Redis list records timestamps of the last N requests. However, when running on a Redis cluster, the script may fail with "Write commands not allowed after non deterministic commands" because the TIME command is non‑deterministic. Adding redis.replicate_commands(); at the beginning of the script resolves the issue.

https://github.com/battcn/spring-boot2-learning/tree/master/chapter27

Note: The list‑based approach consumes more memory when N is large, so developers should weigh storage cost against precision.

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.

Distributed SystemsjavaredisSpring Bootrate limitingLua
Selected Java Interview Questions
Written by

Selected Java Interview Questions

A professional Java tech channel sharing common knowledge to help developers fill gaps. Follow us!

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.