Approach: use a Redis-backed token-bucket per user with an atomic Lua script that refills tokens based on elapsed time and decrements on request. Store for each user: last_ts and tokens. Use TTLs to let inactive users expire. For very hot users, use binning or sharding to avoid single-key contention.Lua script (atomic, runs server-side):lua
-- KEYS[1] = user_key
-- ARGV[1] = now_ms
-- ARGV[2] = refill_rate_per_ms
-- ARGV[3] = capacity
-- ARGV[4] = tokens_requested
-- returns {allowed(1/0), tokens_left}
local key = KEYS[1]
local now = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local cap = tonumber(ARGV[3])
local req = tonumber(ARGV[4])
local data = redis.call("HMGET", key, "tokens", "last_ts")
local tokens = tonumber(data[1]) or cap
local last = tonumber(data[2]) or now
local delta = math.max(0, now - last)
local add = delta * rate
tokens = math.min(cap, tokens + add)
if tokens >= req then
tokens = tokens - req
redis.call("HMSET", key, "tokens", tokens, "last_ts", now)
redis.call("PEXPIRE", key, 3600000) -- 1 hour TTL
return {1, tokens}
else
redis.call("HMSET", key, "tokens", tokens, "last_ts", now)
redis.call("PEXPIRE", key, 3600000)
return {0, tokens}
end
Handling race conditions: Lua ensures atomicity per key. For extreme concurrency on one key, consider client-side jitter/backoff, queueing, or use pipelined Lua that operates on sharded bins (see below).Key TTLs: set TTL slightly longer than maximum idle window to free memory for inactive users. TTL also prevents stale last_ts.Binning hot keys: instead of one per-user key, map heavy users to N bins (user_id % N) and maintain per-user counters inside a bucket or use per-user approximate counters (Redis Module / HyperLogLog not suitable here). Binning reduces lock contention at cost of per-user exactness — you must coordinate per-user capacity across bin aggregation or accept slightly looser guarantees.Trade-offs:- Exactness vs latency: exact token bucket via Redis+Lua is strong consistency but add network/Redis latency. To reduce latency, use local leaky bucket with periodic sync to Redis (eventual consistency), or Bloom/approx counters for low-cost checks.- State size vs accuracy: per-user precise state costs memory; use TTLs and approximate sketches for long-tail users.- Scalability: shard Redis cluster by user id to distribute load; use client-side caching for low-rate users.Metrics & safety: emit denied/allowed counts, latency histograms; add circuit-breaker to block abusive patterns.