Approach: use fixed-size circular shards per key (N sub-windows) representing equal-duration buckets covering the sliding window. For each key, store an array of AtomicLong counters plus a lastSeen timestamp. On each request, advance current shard index by time, zero out expired shards atomically, sum counters to get window count, and conditionally increment current shard. Use ConcurrentHashMap for key -> shard state and background TTL eviction to remove inactive keys.java
import java.time.Instant;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
public class SlidingWindowRateLimiter {
private final long windowMillis;
private final int shards;
private final long shardMillis;
private final long limit;
private final ConcurrentHashMap<String, Bucket> map = new ConcurrentHashMap<>();
private final ScheduledExecutorService cleaner = Executors.newSingleThreadScheduledExecutor();
public SlidingWindowRateLimiter(long windowMillis, int shards, long limit, long ttlMillis) {
this.windowMillis = windowMillis;
this.shards = shards;
this.shardMillis = windowMillis / shards;
this.limit = limit;
cleaner.scheduleAtFixedRate(() -> {
long now = System.currentTimeMillis();
map.entrySet().removeIf(e -> now - e.getValue().lastSeen.get() > ttlMillis);
}, ttlMillis, ttlMillis, TimeUnit.MILLISECONDS);
}
public boolean tryAcquire(String key, long permits) {
long now = System.currentTimeMillis();
Bucket b = map.computeIfAbsent(key, k -> new Bucket(shards, now));
return b.tryAcquire(now, shardMillis, shards, limit, permits);
}
static class Bucket {
final AtomicLong[] counts;
final AtomicLong lastSeen = new AtomicLong();
final AtomicLong lastShardStart = new AtomicLong(); // aligned start millis of current shard
Bucket(int shards, long now) {
counts = new AtomicLong[shards];
for (int i = 0; i < shards; i++) counts[i] = new AtomicLong(0);
lastSeen.set(now);
lastShardStart.set(now);
}
boolean tryAcquire(long now, long shardMillis, int shards, long limit, long permits) {
lastSeen.set(now);
long shardIndex = (now / shardMillis) % shards;
long base = (now / shardMillis) * shardMillis;
long prevBase = lastShardStart.getAndSet(base);
if (base != prevBase) {
// zero intermediate shards
long steps = (base - prevBase) / shardMillis;
if (steps >= shards) { // all expired
for (AtomicLong c : counts) c.set(0);
} else {
for (int i = 1; i <= steps; i++) {
int idx = (int)(( (prevBase / shardMillis) + i) % shards);
counts[idx].set(0);
}
}
}
long total = 0;
for (AtomicLong c : counts) total += c.get();
if (total + permits > limit) return false;
counts[(int)shardIndex].addAndGet(permits);
return true;
}
}
public void shutdown() { cleaner.shutdownNow(); }
}
Key points:- Thread-safety: ConcurrentHashMap for bucket lookup; per-bucket AtomicLong array avoids global locks. We use atomic getAndSet for shard rotation and atomic counters for increments.- Memory efficiency: fixed-sized arrays per active key (O(shards) per key). Choose shards small (e.g., 8-16) to balance accuracy vs memory.- TTL eviction: background cleaner removes keys idle beyond ttlMillis to free memory.- Time complexity: tryAcquire is O(shards) to sum counts; can optimize with running total per bucket (additional atomics) to O(1).- Trade-offs & scaling: fewer shards -> less memory, coarser accuracy. To scale across instances, use consistent hashing to shard keys or push decisions to a centralized Redis token-bucket (with Lua) for global limits. For strict global limits use distributed counters (Redis/CRDT) at the cost of latency.