Approach (summary):
Use a distributed token-bucket per customer enforced at each API gateway instance. Back the counters with Redis for low-latency, atomicity and cross-instance coordination. Each customer has a bucket defined by capacity=10000 and refill_rate=10000 tokens/minute (~166.66 tokens/sec). Gateways request tokens from Redis; if granted, forward request.
Redis implementation (atomic via Lua):
python
# Python pseudo-code using redis-py and a Lua script
GET_TOKENS_LUA = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local capacity = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'last')
local tokens = tonumber(bucket[1]) or capacity
local last = tonumber(bucket[2]) or now
-- refill
local elapsed = math.max(0, now - last)
tokens = math.min(capacity, tokens + elapsed * refill_rate)
if tokens < requested then
redis.call('HMSET', key, 'tokens', tokens, 'last', now)
redis.call('EXPIRE', key, 120)
return 0
else
tokens = tokens - requested
redis.call('HMSET', key, 'tokens', tokens, 'last', now)
redis.call('EXPIRE', key, 120)
return 1
end
"""
# Call with: EVAL GET_TOKENS_LUA 1 bucket:{customer_id} <now> <refill_per_sec> 10000 1
Why Redis + Lua:
- Lua script executes atomically, preventing race conditions across concurrent gateways.
- Using HMSET stores tokens and last timestamp; EXPIRE cleans inactive buckets.
- Refill is continuous (tokens += elapsed * refill_rate) for smoother behavior.
Correctness under race conditions:
- Atomic Lua prevents interleaving updates; concurrent requests are serialized at Redis for that key.
- If many gateways issue simultaneous requests, Redis will sequentially run the script ensuring no double-counting.
- Use consistent key hashing or Redis Cluster with requests routed to correct shard so the key is located on a single shard (or use client-side routing).
Failover and availability:
- Redis single node is a SPOF — use a Redis Cluster or primary-replica with automatic failover (Sentinel) to maintain availability.
- During primary failover there is a short window where requests may be rejected or accepted twice if clients connect to both old primary and new primary—mitigate by:
- Keep bucket state durable (RDB/AOF) and tune AOF for low-latency fsync (appendfsync everysec) balancing durability vs performance.
- Use conservative limits: e.g., slightly lower effective capacity to absorb transient duplication.
- Implement gateway-side local leaky-bucket as fallback: allow a small local cache of tokens (burst buffer) and periodically reconcile with Redis. This reduces pressure and provides availability when Redis latency spikes, but you must bound the local buffer to limit overshoot.
Operational considerations:
- Shard keys by customer_id to distribute load.
- Monitor Redis latency, error rates, and token consumption metrics; emit per-customer usage to alert on hotspots.
- Rate-limit configuration and emergency overrides stored in a config service and cached with TTL.
- Test failover scenarios (primary failover, network partition, heavy bursts) and measure overshoot.
Alternatives:
- Centralized token-bucket service (stateless gateways call gRPC service) with in-memory local caches and persisted checkpoints — simpler to reason about but adds another service to scale and make highly available.
- Use Redis atomic INCR with fixed window for simplicity, but it is less smooth and vulnerable to boundary bursts.
This design balances correctness (atomicity via Lua), performance (Redis latency), and availability (clustering, local fallback) appropriate for data ingestion at scale.