Skip to content
JackSparrow414
Go back

Implementing Rate-Limiting Algorithms with Lua Scripts

Table of contents

Open Table of contents

Executing Lua Scripts in Redis

redis-cli --eval /tmp/script.lua mykey somekey , arg1 arg2

Pay particular attention to the separator between keys and arguments: space + comma + space. Otherwise, invoking the script with redis-cli produces an error.

I recommend reading through the Redis CLI documentation. Familiarize yourself with common operations such as connecting with redis-cli, supplying a password, and specifying a Lua script.

Debugging Lua Scripts in Redis

You can use redis-cli to debug Lua scripts in Redis.

redis-cli --ldb --eval /tmp/script.lua mykey somekey , arg1 arg2

The official documentation also explains debugging clearly. Lua debugging commands and examples in the Redis documentation

For more details, see the Lua debugging section of the Redis documentation.

We will implement four algorithms using a limit of at most 3 requests per minute.

Fixed Window

Take the one-minute interval 10:01:00–10:02:00 as an example. The key is userId:current window start, for example 8848:01.

local key = KEYS[1]
-- Use -1 if this key does not exist yet
-- See the screenshot below for the use of or
local requests = tonumber(redis.call('GET', key) or '-1')
-- Maximum requests allowed in the fixed interval; 3 in this example
local max_requests = tonumber(ARGV[1])
-- Usually the fixed-window duration, such as 60s
local expiry = tonumber(ARGV[2])
-- When the window key does not exist or the request limit has not been reached
if (requests == -1) or (requests < max_requests) then
  -- Increment
  redis.call('INCR', key)
  -- Reset the key expiry to 60s from now
  redis.call('EXPIRE', key, expiry)
  return false
else
  return true
end

Key Parts of the Code

Suppose the first request arrives at 10:01:30. Since 8848:01 does not exist, the key is incremented by 1 and given an expiry 60s from now. Some readers may question the final EXPIRE call. This is simply a different implementation approach. Another approach is to calculate the remaining time when the first request arrives, then only increment the counter for subsequent requests. The implementation above simplifies the code. At 10:02, the key changes, so requests no longer read the 10:01 key. That earlier key is naturally deleted 60s after its expiry was last set. Lua manual explaining short-circuit evaluation and return values for and and or

Verification

redis-cli running the rate-limit script repeatedly, with the fourth call returning 1 for a reached limit After the limit of 3 requests is reached, the result is true, which is converted to 1.

Sliding Window

A fixed window has a boundary problem: 2 requests at 10:01:59 and another 2 at 10:02:01 produce 4 requests in just 2 seconds, exceeding the limit of 3. Each group is valid in its own fixed window, but the behavior across the boundary is incorrect. A sliding window solves this problem.

local key = KEYS[1]
-- Current timestamp
local current_time = tonumber(ARGV[1])
-- Window size: 60 * 1000 in this example
local window_size = tonumber(ARGV[3])
-- 3 in this example
local max_requests = tonumber(ARGV[4])
-- Expiry cutoff: current time in milliseconds minus the window duration in milliseconds
local has_expired = current_time - window_size
-- Remove expired entries
redis.call('ZREMRANGEBYSCORE', key, 0, has_expired)
-- Get the current number of zset members
local current_num = tonumber(redis.call('ZCARD', key))
local next = current_num + 1
-- Return 0 when the rate limit is reached
if next > max_requests then
  return 0;
else
  -- Add a zset member whose value and score are both the current timestamp: [value, score]
  redis.call("ZADD", key, current_time, current_time)
  -- Reset the zset expiry on every access
  redis.call("PEXPIRE", key, window_size)
  return next
end

A sliding window can handle most real-world business scenarios. Why, then, do we need token-bucket and leaky-bucket algorithms? I looked into this online, and the most convincing answer I found was traffic shaping.

Token Bucket

In application development, a token bucket can limit requests from upstream callers to our server. Upstream includes direct browser requests and unknown requests from outside our system. Limiting the request rate helps prevent our server from being overwhelmed.

The token bucket involves two scripts:

Adding tokens

-- Current timestamp
local ts = tonumber(ARGV[1])
-- Set a 1s window
local min = ts -1
-- Set the token addition rate for multiple keys
for i,key in pairs(KEYS) do
    -- Remove expired tokens
    redis.call('ZREMRANGEBYSCORE', key, '-inf', min)
    redis.call('ZADD', key, ts, ts)
    redis.call('EXPIRE', key, 10)
end

Retrieving tokens

-- Current timestamp
local ts  = tonumber(ARGV[1])
local key = KEYS[1]
local min = ts -1
-- Remove expired tokens
redis.call('ZREMRANGEBYSCORE', key, '-inf', min)
-- Check the bucket size; allow the request if the bucket contains a value
return redis.call('ZCARD', key)

Leaky Bucket

A leaky bucket sends requests to a downstream service at a specified rate, avoiding errors from overloading that service. If more requests arrive during an interval than the fixed rate permits, they must queue and wait.

local ts  = tonumber(ARGV[1])
-- Calls per second, for example 4 per second, allowing 1 request every 250ms
local cps = tonumber(ARGV[2])
local key = KEYS[1]
local min = ts -1
redis.call('ZREMRANGEBYSCORE', key, '-inf', min)
local last = redis.call('ZRANGE', key, -1, -1)
local next = ts
if type(last) == 'table' and #last > 0 then
  for key,value in pairs(last) do
    -- Last member plus the interval determined by the fixed rate
    next = tonumber(value) + 1/cps
    break
  end
end
if ts > next then
  -- the current ts is > than last+1/cps
  -- we'll keep ts
  next = ts
end
-- If ts < next, next remains the last zset member timestamp + 1/cps
redis.call('ZADD', key, next, next)
-- Required waiting time
-- If ts > next, return 0: no wait is needed; call immediately
-- If ts < next, the next permitted time has not arrived; wait for next - ts
return tostring(next - ts)

References


Share this post:

Previous Post
Shipping Tomcat Access Logs from EC2 to ELK with Filebeat and AWS CloudWatch Logs
Next Post
Shipping Tomcat access_logs from EC2 to Elasticsearch with Filebeat and AWS CloudWatch Logs, with Automated Log Management via ILM

Comments

Questions, corrections, and experiences are welcome. Sign in with GitHub to comment; both language versions share this discussion.

Comments are available on the live site only.