An in-memory counter can't enforce a global limit when requests spread across instances. Redis gives you one authoritative counter that every node increments, so your rate limit means the same thing everywhere.
A fixed-window counter
Increment a per-client key and set its expiry on first use. If the count exceeds the limit within the window, reject the request.
@Component
public class RateLimiter {
private final StringRedisTemplate redis;
public boolean allow(String clientId, int limit, Duration window) {
String key = "rl:" + clientId;
Long count = redis.opsForValue().increment(key);
if (count != null && count == 1L) {
redis.expire(key, window);
}
return count != null && count <= limit;
}
}Enforce it in a filter
A servlet filter runs before controllers, so a rejected request never touches your business logic.
@Component
public class RateLimitFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String client = req.getRemoteAddr();
if (!limiter.allow(client, 100, Duration.ofMinutes(1))) {
res.setStatus(429);
return;
}
chain.doFilter(req, res);
}
}Takeaways
- A shared Redis counter enforces one global budget across all instances.
- Set the key's expiry only on the first increment of a window.
- Reject early in a filter so throttled requests are cheap.
Fixed windows allow bursts at boundaries; for smoother limits, move to a sliding window or token-bucket implemented with a Lua script.