Building Distributed Rate Limiting with Spring Boot, Redis & Lua: Sliding Window, Token Bucket & Flash Sale Defense
This article details a production-ready distributed rate limiting system using Spring Boot AOP, Redis Sorted Sets, and atomic Lua scripts, covering sliding window and token bucket algorithms, multi-dimensional flash sale protection, and benchmark results showing 100% accuracy with minimal latency overhead.
1. Rate Limiting Algorithms Overview
Rate limiting acts as a gate for system traffic. Single-instance limiting uses JVM counters, but distributed systems require shared storage — Redis is the natural choice. Four common algorithms are compared:
Fixed Window : Simple INCR per time bucket (e.g., 1 minute). Low memory, but suffers boundary spikes — 100 requests at 0:59 and 100 at 1:00 allow 200 in 2 seconds.
Sliding Window : Time sliced into small buckets (e.g., per second). Requests carry timestamps; expired entries removed via ZREMRANGEBYSCORE. Smooths bursts, ideal for flash sale entry points. Uses Redis ZSet (score=timestamp, member=UUID), ~tens of bytes per request.
Leaky Bucket : Requests queued, processed at fixed rate. Constant output protects downstream (DB, third-party APIs), but cannot absorb bursts even when bucket empty.
Token Bucket : Tokens added at fixed rate; request consumes one. Burst tolerance via accumulated tokens. Guava's RateLimiter uses this. Suits API gateways and service-to-service calls.
Algorithm choice depends on scenario: token bucket for gateway throughput, sliding window for flash sale spike control, leaky bucket for DB traffic shaping.
2. Sliding Window with Redis ZSet & Lua Atomicity
Sliding window implementation uses Redis Sorted Set:
ZREMRANGEBYSCORE to remove expired entries
ZADD to insert current request (timestamp as score, UUID as member)
ZCARD to count requests in window
Concurrency bug : These three steps are not atomic. Two concurrent requests may both see count < threshold, both ZADD, exceeding limit.
Solution : Package check+write into a single Lua script executed atomically on Redis single-threaded engine. The script supports multiple keys (N dimensions) with all-or-nothing semantics:
-- KEYS[1..N] N rate limit keys
-- ARGV[1..N] N window sizes (seconds)
-- ARGV[N+1..2N] N thresholds
-- ARGV[2N+1] current timestamp (ms)
-- ARGV[2N+2] request unique prefix for member
for i=1, #KEYS do
local window = tonumber(ARGV[i])
local threshold = tonumber(ARGV[i + #KEYS])
local current = tonumber(ARGV[2 * #KEYS + 1])
local expired = current - window * 1000
redis.call('ZREMRANGEBYSCORE', KEYS[i], 0, expired)
if redis.call('ZCARD', KEYS[i]) >= threshold then
return 0
end
end
for i=1, #KEYS do
local window = tonumber(ARGV[i])
local current = tonumber(ARGV[2 * #KEYS + 1])
local member = ARGV[2 * #KEYS + 2] .. '-' .. i
redis.call('ZADD', KEYS[i], current, member)
redis.call('EXPIRE', KEYS[i], window)
end
return 1Two-pass design: first loop cleans and checks all keys; if any exceeds threshold, returns 0 without writing. Second loop writes all keys only if all checks pass. Guarantees atomic multi-dimensional limiting in one network round-trip.
Why ZSet? ZREMRANGEBYSCORE deletes by score range (time window), ZADD sorts by timestamp, ZCARD returns count in O(1). UUID member prevents collisions on identical timestamps.
3. Annotation-Driven AOP Integration
Custom @RateLimit annotation with SpEL key expressions, window, threshold, and fallback method. @Repeatable allows stacking multiple dimensions.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(RateLimits.class)
public @interface RateLimit {
String key(); // SpEL expression
long window() default 60;
long threshold() default 100;
String fallback() default "";
}Lua script loaded as Spring Bean:
@Bean
public DefaultRedisScript<Long> rateLimitScript() {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/sliding_window.lua")));
script.setResultType(Long.class);
return script;
}AOP Pitfall : Pointcut @annotation(rateLimits) fails for single @RateLimit because Spring doesn't auto-wrap into @RateLimits. Fix: pointcut on @RateLimit, then use AnnotatedElementUtils.getMergedRepeatableAnnotations to collect all repeats.
@Aspect
@Component
public class RateLimitAspect {
private final StringRedisTemplate redisTemplate;
private final DefaultRedisScript<Long> rateLimitScript;
private final SpelExpressionParser parser = new SpelExpressionParser();
private final DefaultParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
@Around("@annotation(io.ratelimit.RateLimit)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
RateLimit[] limits = AnnotatedElementUtils.getMergedRepeatableAnnotations(
method, RateLimit.class, RateLimits.class);
if (limits.length == 0) return joinPoint.proceed();
List<String> keys = new ArrayList<>();
List<Long> windows = new ArrayList<>();
List<Long> thresholds = new ArrayList<>();
for (RateLimit limit : limits) {
keys.add("rate:limit:" + evalKey(limit.key(), joinPoint));
windows.add(limit.window());
thresholds.add(limit.threshold());
}
List<String> args = new ArrayList<>();
windows.forEach(w -> args.add(String.valueOf(w)));
thresholds.forEach(t -> args.add(String.valueOf(t)));
args.add(String.valueOf(System.currentTimeMillis()));
args.add(UUID.randomUUID().toString());
Long result = redisTemplate.execute(rateLimitScript, keys, args.toArray(new String[0]));
if (result != null && result == 1L) return joinPoint.proceed();
RateLimit failed = limits[0];
if (!failed.fallback().isEmpty()) return invokeFallback(joinPoint, failed.fallback());
throw new RateLimitException("Too many requests");
}
private String evalKey(String expression, ProceedingJoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Object[] args = joinPoint.getArgs();
String[] paramNames = parameterNameDiscoverer.getParameterNames(signature.getMethod());
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < paramNames.length; i++) {
context.setVariable(paramNames[i], args[i]);
}
return parser.parseExpression(expression).getValue(context, String.class);
}
private Object invokeFallback(ProceedingJoinPoint joinPoint, String fallback) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method targetMethod = joinPoint.getTarget().getClass()
.getDeclaredMethod(fallback, signature.getParameterTypes());
targetMethod.setAccessible(true);
return targetMethod.invoke(joinPoint.getTarget(), joinPoint.getArgs());
}
}Key details: DefaultParameterNameDiscoverer avoids requiring -parameters compiler flag (though it helps). getDeclaredMethod used for fallback to support non-public methods.
Usage example — three dimensions on flash sale endpoint:
@RestController
public class SeckillController {
@RateLimit(key = "'seckill:api'", window = 1, threshold = 1000, fallback = "apiFallback")
@RateLimit(key = "'seckill:user:' + #userId", window = 60, threshold = 3, fallback = "userFallback")
@RateLimit(key = "'seckill:ip:' + #ip", window = 60, threshold = 5, fallback = "ipFallback")
@PostMapping("/seckill")
public Order doSeckill(Long userId, String ip) {
// business logic
return new Order();
}
public Order apiFallback(Long userId, String ip) { return Order.failed("System busy"); }
public Order userFallback(Long userId, String ip) { return Order.failed("No duplicate submissions"); }
public Order ipFallback(Long userId, String ip) { return Order.failed("Too frequent"); }
}SpEL #userId, #ip resolve from method parameters. Single quotes denote string literals.
4. Multi-Dimensional Flash Sale Protection
Flash sales need layered defense:
API-level : Global QPS cap (e.g., 1000 req/s) protects backend DB from overload.
User-level : Per-user frequency limit (e.g., 3 req/60s) thwarts scripted repeat attempts.
IP-level : Per-IP limit (e.g., 5 req/60s) blocks low-cost botnets.
All three keys evaluated in one Lua script — atomic, single RTT, no partial-write race conditions.
Post-success operations (inventory decrement, order creation) use Redis Pipeline to batch commands:
public void afterSeckillSuccess(Long userId, Long goodsId) {
stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {
connection.incr("seckill:success:count".getBytes());
connection.setEx("seckill:success:" + userId, 300, "1".getBytes());
connection.hSet("seckill:order".getBytes(), userId.toString().getBytes(), goodsId.toString().getBytes());
return null;
});
}Pipeline reduces network round-trips before DB commit.
5. Monitoring & Load Test Results
Observability via Micrometer counters in AOP (pass/reject), exported to Prometheus + Grafana — zero Redis overhead vs. INCR-in-script approach.
Benchmark environment: Redis 5.0 on 4C8G VM; Spring Boot 2.7, Tomcat 200 threads; JMeter 500 threads, 1 minute.
No limit: avg 15 ms
Single dimension: avg 16 ms
Three dimensions: avg 17 ms
Overhead ~1-2 ms (Lua execution inside Redis, far less than network RTT). Three-dimension cost ≈ single-dimension because script logic reused, only extra keys. Zero limit breaches observed — 100% accuracy.
6. Practical Takeaways
Rate limiting works with circuit breaking, degradation, isolation — not a silver bullet alone.
Algorithm per layer: token bucket at gateway, sliding window at flash sale entry, leaky bucket before DB.
Make thresholds dynamic config (no redeploy); set key TTL to avoid Redis garbage; design friendly fallback responses.
Future: trace-enabled limit logs, anomaly classification, multi-channel alerts. Code is easy; stability is the craft.
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.
Xiaolin Talks Programming
Focuses on sharing original technical insights. Senior architect at a top tech company with years of experience in technical architecture and management, and extensive interview experience. Offers one-on-one technical coaching, guiding you from beginner to architecture design to technical management. Follow for free learning resources. Free one-on-one interview coaching to help you land offers quickly.
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.
