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.

Xiaolin Talks Programming
Xiaolin Talks Programming
Xiaolin Talks Programming
Building Distributed Rate Limiting with Spring Boot, Redis & Lua: Sliding Window, Token Bucket & Flash Sale Defense

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 1

Two-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.

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 SystemsAOPRedisSpring BootRate LimitingLuaSliding WindowToken BucketFlash Sale
Xiaolin Talks Programming
Written by

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.

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.