This article discusses the architectural evolution of a rate limiter, moving from a flawed in-memory approach to a robust distributed solution using Redis and Lua scripting. It highlights the critical need for atomic operations and global consistency in a distributed environment to prevent race conditions and ensure fair rate limiting across multiple API gateway instances. The solution leverages Redis's single-threaded nature and Lua scripts to achieve atomic increment-and-check operations.
Read original on Dev.to #systemdesignInitially, the rate limiter was implemented with per-instance in-memory counters. While simple for single-node applications, this approach quickly failed in a distributed production environment. Key issues included lack of global state, leading to inconsistent rate limits across different service instances, and race conditions where multiple threads could bypass the limit due to non-atomic check-then-increment operations. This highlighted the necessity for a centralized, globally consistent mechanism.
The core insight was to centralize the rate limiting logic in a system that guarantees atomicity and provides a single source of truth. Redis, with its single-threaded event loop, is an excellent candidate for this. By executing the increment, expiry-set, and comparison operations within a Lua script on Redis, the entire sequence becomes an atomic transaction. This prevents race conditions and ensures that all API gateway instances operate against the same, consistent view of the current rate limit for a given user or key.
-- rate_limiter.lua
-- KEYS[1] = redis key for this user (e.g., "rate:user:123")
-- ARGV[1] = limit (e.g., 100)
-- ARGV[2] = window size in seconds (e.g., 60)
local current = redis.call('GET', KEYS[1])
if current == false then
-- first request in this window
redis.call('SET', KEYS[1], 1)
redis.call('EXPIRE', KEYS[1], ARGV[2])
return 1 -- allowed
end
if tonumber(current) >= tonumber(ARGV[1]) then
return 0 -- deny
end
local newval = redis.call('INCR', KEYS[1])
return newval -- allowed (new count)Key System Design Takeaway
When designing distributed systems, identifying and isolating stateful operations that require global consistency into a single, atomic service (like Redis with Lua for counters) is crucial. This pattern helps avoid complex distributed consensus protocols for simpler tasks and ensures predictable behavior across all nodes.