How to Implement Distributed Multi‑Rule Rate Limiting with Redis and Lua

This article explains how to design and implement a distributed rate‑limiting solution that supports multiple concurrent rules—such as per‑minute and per‑hour limits—by analyzing the shortcomings of simple string counters, introducing atomic Lua scripts and Zset structures, and providing complete Java annotation and AOP code examples.

Architect
Architect
Architect
How to Implement Distributed Multi‑Rule Rate Limiting with Redis and Lua

The author starts by stating the problem: most public tutorials on Redis rate limiting only handle a single rule (e.g., one request per minute), while real‑world services often need several rules simultaneously, such as "one request per minute and ten requests per hour" in a distributed environment.

Initial String‑Based Design

To record the number of accesses from a specific IP, the author proposes using a Redis key composed of prefix:className:methodName and storing the visit count as the value. The interception flow is:

On the first request, set [RedisKey] [RedisValue=1] [expiration] .

On subsequent requests, read the value, compare it with the allowed limit, and either reject the request or increment the value.

When the rule is "1000 requests per minute", the author analyses a concurrency problem: if the current value is 999 and many threads read it simultaneously, each sees 999, decides the limit is not exceeded, and all increment, resulting in >1000 requests. The proposed fix is to guarantee atomicity, for example by using a lock or a Lua script.

Zset‑Based Solution for Critical‑Value Handling

To avoid the race condition, the author switches to a sorted set (Zset) where each request is stored as a member with its timestamp as the score. This allows counting requests within any time window by using ZCOUNT. Two implementation variants are shown:

Using a UUID as the Zset member value.

Using the timestamp itself as the member value, with extra logic to handle duplicate timestamps under high concurrency.

Both variants share the same Lua script, which:

Receives KEYS[1]=key, KEYS[2]=uuid, KEYS[3]=currentTime, and ARGV containing pairs of count, timeWindow.

Iterates over the rule pairs, calls ZCOUNT to get the number of entries in the window, and returns true if any rule is violated.

Determines the maximum timeWindow to set the final TTL.

Adds the current timestamp to the Zset with ZADD, updates the TTL with PEXPIRE, and removes outdated entries with ZREMRANGEBYSCORE.

When using timestamps as values, the script includes a retry loop that increments the timestamp or adds a random offset if the ZADD fails after several attempts.

-- 1. Get parameters
local key = KEYS[1]
local uuid = KEYS[2]
local currentTime = tonumber(KEYS[3])
-- 2. Determine max TTL
local expireTime = -1
-- 3. Iterate over rule array
for i = 1, #ARGV, 2 do
    local rateRuleCount = tonumber(ARGV[i])
    local rateRuleTime = tonumber(ARGV[i+1])
    local count = redis.call('ZCOUNT', key, currentTime - rateRuleTime, currentTime)
    if tonumber(count) >= rateRuleCount then
        return true
    end
    if rateRuleTime > expireTime then
        expireTime = rateRuleTime
    end
end
-- 4. Add current timestamp
redis.call('ZADD', key, currentTime, uuid)
-- 5. Set TTL
redis.call('PEXPIRE', key, expireTime)
-- 6. Clean old entries
redis.call('ZREMRANGEBYSCORE', key, 0, currentTime - expireTime)
return false

Designing the @RateLimiter Annotation

The author defines two annotations to express the rules declaratively:

@RateLimiter(
    rules = {
        @RateRule(count = 10, time = 60, timeUnit = TimeUnit.SECONDS), // 10 times per 60 seconds
        @RateRule(count = 20, time = 120, timeUnit = TimeUnit.SECONDS) // 20 times per 120 seconds
    },
    preventDuplicate = true
)

Supporting annotation definitions:

public @interface RateLimiter {
    String key() default RedisKeyConstants.RATE_LIMIT_CACHE_PREFIX;
    LimitTypeEnum limitType() default LimitTypeEnum.IP;
    ResultCode message() default ResultCode.REQUEST_MORE_ERROR;
    RateRule[] rules() default {};
    boolean preventDuplicate() default false;
    RateRule preventDuplicateRule() default @RateRule(count = 1, time = 5);
}

public @interface RateRule {
    long count() default 10;
    long time() default 60;
    TimeUnit timeUnit() default TimeUnit.SECONDS;
}

AOP Interceptor Implementation

The interceptor generates the Redis key based on the annotation configuration and the method signature, then executes the Lua script via RedisTemplate. The key generation logic distinguishes IP, USER_ID, and GLOBAL limit types.

public String getCombineKey(RateLimiter rateLimiter, JoinPoint joinPoint) {
    StringBuffer key = new StringBuffer(rateLimiter.key());
    switch (rateLimiter.limitType()) {
        case IP:
            key.append(IpUtil.getIpAddr(((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest()))
               .append(":");
            break;
        case USER_ID:
            SysUserDetails user = SecurityUtil.getUser();
            if (!ObjectUtils.isEmpty(user)) key.append(user.getUserId()).append(":");
            break;
        case GLOBAL:
            break;
    }
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();
    Method method = signature.getMethod();
    Class<?> targetClass = method.getDeclaringClass();
    key.append(targetClass.getSimpleName()).append("-").append(method.getName());
    return key.toString();
}

The getRules method flattens the annotation data into a Long[] that matches the Lua script's ARGV format, handling the optional duplicate‑submission rule.

private Long[] getRules(RateLimiter rateLimiter) {
    int capacity = rateLimiter.rules().length << 1;
    Long[] args = new Long[rateLimiter.preventDuplicate() ? capacity + 2 : capacity];
    int index = 0;
    if (rateLimiter.preventDuplicate()) {
        RateRule dup = rateLimiter.preventDuplicateRule();
        args[index++] = dup.count();
        args[index++] = dup.timeUnit().toMillis(dup.time());
    }
    for (RateRule rule : rateLimiter.rules()) {
        args[index++] = rule.count();
        args[index++] = rule.timeUnit().toMillis(rule.time());
    }
    return args;
}

Finally, the @Before advice invokes the script, logs a warning when the request is blocked, and throws a ServiceException with the configured error message.

@Before("@annotation(rateLimiter)")
public void boBefore(JoinPoint joinPoint, RateLimiter rateLimiter) {
    String key = getCombineKey(rateLimiter, joinPoint);
    try {
        Boolean flag = redisTemplate.execute(limitScript,
            ListUtil.of(key, String.valueOf(System.currentTimeMillis())),
            (Object[]) getRules(rateLimiter));
        if (Boolean.TRUE.equals(flag)) {
            log.error("ip: '{}' intercepted, RedisKey: '{}'", IpUtil.getIpAddr(((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest()), key);
            throw new ServiceException(rateLimiter.message());
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

Through this step‑by‑step walkthrough, the article demonstrates how to reason from the requirement of multiple rate‑limit rules, identify race conditions, choose an atomic Redis data structure, express the rules declaratively with annotations, and enforce them efficiently in a Spring‑based distributed system.

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 SystemsJavaaopredisspringrate limitingLua
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.