How to Build Distributed Multi‑Rule Rate Limiting with Redis and Spring AOP
This article explains how to implement multi‑rule rate limiting in a distributed Java application using Redis, covering String‑based counters, Zset timestamp storage, Lua scripts for atomic checks, custom @RateLimiter annotations, key generation logic, and an AOP interceptor that enforces the limits.
Many existing Redis rate‑limiting tutorials only support a single rule (e.g., one request per minute), which is insufficient when an API must satisfy multiple constraints such as "one request per minute" and "ten requests per hour" in a distributed system. This article presents a complete solution.
Thinking
The required capabilities are:
Enforce several independent limits on the same endpoint (e.g., per‑minute and per‑hour limits).
Prevent burst attacks that generate a large number of requests in a short period.
Restrict the total number of accesses within a configurable time window.
Solution Overview
3.1 Using a String to Record Access Count in a Fixed Time Window
RedisKey is defined as prefix:className:methodName and RedisValue stores the current request count. The workflow is:
On the first request, set [RedisKey] [RedisValue=1] [expire‑time].
For each subsequent request, retrieve the value, compare it with the allowed count, and either reject the request or increment the value.
Concurrency problem: if the stored value is 999 and many threads read it simultaneously, they may all see 999, pass the check, and increment, causing the real count to exceed 1000. The article recommends guaranteeing atomicity with a lock or a Lua script.
3.2 Using a Zset to Store Timestamps and Solve Boundary Issues
Instead of a simple counter, a sorted set (Zset) records each request as a member with a score equal to the request timestamp (or a UUID). The Lua script iterates over rule pairs (count, time) supplied in ARGV and performs the following steps:
Retrieve key, uuid (or timestamp), and currentTime from KEYS.
For each rule, use ZCOUNT to count members whose scores fall within currentTime‑ruleTime to currentTime.
If any count exceeds the allowed limit, return true (rate‑limited).
Otherwise, add the new member with ZADD, set the TTL to the maximum rule time, and prune old entries with ZREMRANGEBYSCORE.
Two implementation variants are shown:
UUID as member value : each request generates a unique identifier, avoiding collisions.
Timestamp as member value : uses the current time as the member; because multiple threads may generate the same timestamp, the script includes a retry loop (up to five attempts) and, if still failing, adds a random offset to ensure uniqueness.
-- Example Lua script (UUID variant)
local key = KEYS[1]
local uuid = KEYS[2]
local currentTime = tonumber(KEYS[3])
local expireTime = -1
for i = 1, #ARGV, 2 do
local countLimit = tonumber(ARGV[i])
local window = tonumber(ARGV[i+1])
local count = redis.call('ZCOUNT', key, currentTime - window, currentTime)
if tonumber(count) >= countLimit then
return true
end
if window > expireTime then expireTime = window end
end
redis.call('ZADD', key, currentTime, uuid)
redis.call('PEXPIRE', key, expireTime)
redis.call('ZREMRANGEBYSCORE', key, 0, currentTime - expireTime)
return falseAnnotation Design
The article defines two custom annotations:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
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);
}
@Target(ElementType.ANNOTATION_TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface RateRule {
long count() default 10;
long time() default 60;
TimeUnit timeUnit() default TimeUnit.SECONDS;
}Example usage demonstrates two rules (10 requests per 60 s and 20 requests per 120 s) plus duplicate‑submission protection (1 request per 5 s).
Key Generation Logic
public String getCombineKey(RateLimiter rateLimiter, JoinPoint joinPoint) {
StringBuilder key = new StringBuilder(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();
}AOP Interceptor
The RateLimiterAspect intercepts methods annotated with @RateLimiter. It builds the Redis key, executes the appropriate Lua script via RedisTemplate, and throws a ServiceException when the script signals a limit breach.
@Before("@annotation(rateLimiter)")
public void before(JoinPoint joinPoint, RateLimiter rateLimiter) {
String key = getCombineKey(rateLimiter, joinPoint);
Boolean limited = redisTemplate.execute(limitScript,
List.of(key, String.valueOf(System.currentTimeMillis())),
(Object[]) getRules(rateLimiter));
if (Boolean.TRUE.equals(limited)) {
log.error("ip: '{}' intercepted, RedisKey: '{}'", IpUtil.getIpAddr(((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest()), key);
throw new ServiceException(rateLimiter.message());
}
}
private Long[] getRules(RateLimiter rateLimiter) {
int capacity = rateLimiter.rules().length << 1;
Long[] args = new Long[rateLimiter.preventDuplicate() ? capacity + 2 : capacity];
int idx = 0;
if (rateLimiter.preventDuplicate()) {
RateRule dup = rateLimiter.preventDuplicateRule();
args[idx++] = dup.count();
args[idx++] = dup.timeUnit().toMillis(dup.time());
}
for (RateRule rule : rateLimiter.rules()) {
args[idx++] = rule.count();
args[idx++] = rule.timeUnit().toMillis(rule.time());
}
return args;
}The full source code is available at the provided Gitee link.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
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.
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.
