Sliding Window Rate Limiting with Redis Lua and Java
This article explains how to implement precise sliding‑window rate limiting using a Redis Lua script and a Spring‑based Java component, compares it with fixed‑window and token‑bucket approaches, and provides advanced techniques, production tips, and performance optimizations.
1. Redis Lua Script Implementation
The sliding‑window algorithm stores request timestamps in a Redis sorted set (ZSET) and removes entries older than the current window using ZREMRANGEBYSCORE. It then counts the remaining entries with ZCARD and, if the count is below the limit, adds the current request with ZADD and sets an expiration equal to the window size.
-- KEYS[1]: limit key
-- ARGV[1]: current timestamp (ms)
-- ARGV[2]: window size (ms)
-- ARGV[3]: max allowed requests
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window)
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, now)
redis.call('EXPIRE', key, window/1000)
return 1 -- allow
end
return 0 -- rejectParameters: KEYS[1]: Redis key for the limiter. ARGV[1]: Current timestamp in milliseconds. ARGV[2]: Window size in milliseconds (e.g., 1000 for 1 s). ARGV[3]: Maximum number of requests allowed in the window.
Core logic steps:
Store timestamps in a ZSET.
Remove entries with scores older than now - window using ZREMRANGEBYSCORE.
Get the current count with ZCARD.
If the count is below the limit, add the new timestamp with ZADD and set the key’s TTL.
Return 1 for allowed, 0 for rejected.
Advantages: atomic execution, millisecond‑level precision, and high performance thanks to in‑memory Redis operations.
2. Java Implementation (Spring)
The Java side uses StringRedisTemplate to execute the Lua script. The script is loaded as a RedisScript<Long> and invoked with the key, current time, window size, and limit.
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;
import java.util.Collections;
@Component
public class SlidingWindowRateLimiter {
private final StringRedisTemplate redisTemplate;
private static final String LUA_SCRIPT =
"local key = KEYS[1]
" +
"local now = tonumber(ARGV[1])
" +
"local window = tonumber(ARGV[2])
" +
"local limit = tonumber(ARGV[3])
" +
"redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window)
" +
"local count = redis.call('ZCARD', key)
" +
"if count < limit then
" +
" redis.call('ZADD', key, now, now)
" +
" redis.call('EXPIRE', key, window/1000)
" +
" return 1
" +
"end
" +
"return 0";
private final RedisScript<Long> redisScript;
public SlidingWindowRateLimiter(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
this.redisScript = new DefaultRedisScript<>(LUA_SCRIPT, Long.class);
}
/**
* Try to acquire permission for a request.
* @param key limiter key
* @param windowMs window size in milliseconds
* @param limit maximum requests allowed in the window
* @return true if allowed, false otherwise
*/
public boolean tryAcquire(String key, long windowMs, int limit) {
long now = System.currentTimeMillis();
Long result = redisTemplate.execute(
redisScript,
Collections.singletonList(key),
String.valueOf(now),
String.valueOf(windowMs),
String.valueOf(limit)
);
return result != null && result == 1;
}
}Key method tryAcquire(String key, long windowMs, int limit) obtains the current timestamp, executes the Lua script, and returns a boolean indicating whether the request passes the rate limit.
@RestController
@RequestMapping("/api")
public class ApiController {
@Autowired
private SlidingWindowRateLimiter rateLimiter;
@GetMapping("/resource")
public ResponseEntity<String> getResource() {
String key = "api:resource:limit"; // limiter key
long windowMs = 1000; // 1 s window
int limit = 10; // max 10 requests per second
if (!rateLimiter.tryAcquire(key, windowMs, limit)) {
return ResponseEntity.status(429).body("Too many requests");
}
return ResponseEntity.ok("Success");
}
}3. Advanced Implementations & Optimizations
AOP‑based non‑intrusive rate limiting decouples the limiter from business code.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
String key() default "";
long window() default 1000;
int limit() default 10;
}
@Aspect
@Component
public class RateLimitAspect {
@Autowired
private SlidingWindowRateLimiter rateLimiter;
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
String key = rateLimit.key();
if (key.isEmpty()) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
key = signature.getDeclaringTypeName() + "." + signature.getName();
}
if (!rateLimiter.tryAcquire(key, rateLimit.window(), rateLimit.limit())) {
throw new RuntimeException("Rate limit exceeded");
}
return joinPoint.proceed();
}
}Performance tips:
Script caching: load the script once (SHA1) and invoke with EVALSHA to avoid sending the full script each time.
Pipeline: batch multiple limiter checks in a single round‑trip.
Local cache: combine a fast in‑process counter (e.g., Guava RateLimiter) with Redis for a first‑level guard.
Key design: include user/IP/endpoint identifiers, e.g., rate:user:123:api:getUser.
4. Algorithm Comparison
Sliding window vs Fixed window : higher precision (millisecond), no boundary spikes, higher implementation complexity, medium performance.
Sliding window vs Token bucket / Leaky bucket : supports bursts, counts requests, medium complexity, suited for API rate limiting; token bucket provides dynamic rate, leaky bucket gives smooth flow.
5. Production‑grade Considerations
Redis cluster : all limiter keys must reside in the same hash slot; use hash tags like {user123}.rate.limit to guarantee co‑location.
Monitoring & alerts : track limit‑trigger events and set sensible alarm thresholds.
Degradation strategy : when Redis is unavailable, fall back to a local limiter or allow traffic to pass.
Stress testing : verify that Redis can sustain the expected QPS under the chosen window and limit settings.
6. Performance Bottlenecks & Optimizations
ZSET operation cost : ZREMRANGEBYSCORE is O(log N + M), ZADD is O(log N); large windows with many entries increase CPU load.
Lua script blocking : the script runs atomically and blocks other commands; the default lua-time-limit is 5 s.
Memory pressure : each key stores a timestamp for every request within the window; long windows and high QPS can consume significant memory.
Network overhead : sending the full script on every call adds bandwidth usage if the script is not cached.
Optimization approaches :
Shard limiter keys (e.g., per user/IP) to distribute load.
Approximate counting with INCR + EXPIRE for a fixed‑window fallback.
Split complex logic into smaller scripts to reduce execution time.
Adjust lua-time-limit (e.g., CONFIG SET lua-time-limit 500) for tighter bounds.
Shorten the window duration or periodically clean up old entries.
Cache the script SHA1 and invoke via EVALSHA.
Batch multiple checks into a single script call.
Combine a local counter with Redis (architecture‑level cache) to cut down Redis calls.
Use Redis cluster sharding to spread keys across nodes.
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.
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.
