@@ -20,55 +20,113 @@ local max = math.max
2020local ngx_now = ngx .now
2121local ngx_null = ngx .null
2222local tonumber = tonumber
23+ local core = require (" apisix.core" )
2324
2425
2526local _M = {version = 0.1 }
2627
2728
28- -- the "commit" argument controls whether should we record the event in shm.
29- function _M .incoming (self , red , key , commit )
30- local rate = self .rate
31- local now = ngx_now () * 1000
32-
33- key = " limit_req" .. " :" .. key
34- local excess_key = key .. " excess"
35- local last_key = key .. " last"
29+ -- Redis Lua script that reads, decays and persists the leaky-bucket state in a
30+ -- single atomic step. With separate GET/GET/SET/SET commands, concurrent
31+ -- requests can each read the same stale excess value, all conclude they are
32+ -- within limits, and all get admitted, exceeding the configured rate.
33+ --
34+ -- The whole state lives in a single Redis hash (fields "excess" and "last")
35+ -- so the script only needs one key. lua-resty-rediscluster refuses to run
36+ -- EVAL with more than one key, and a single key is trivially slot-safe in
37+ -- Redis Cluster.
38+ --
39+ -- KEYS[1] = state key (hash with fields "excess" and "last")
40+ -- ARGV[1] = rate (req/s * 1000), ARGV[2] = burst (* 1000), ARGV[3] = now (ms),
41+ -- ARGV[4] = ttl (seconds)
42+ -- Returns {1, excess} on allow (state already stored)
43+ -- {0, excess} on reject (nothing stored)
44+ -- excess is returned as a string to keep its fractional part: Redis truncates
45+ -- Lua numbers to integers when converting them to a reply.
46+ local redis_commit_script = core .string .compress_script ([=[
47+ local state_key = KEYS[1]
48+ local rate = tonumber(ARGV[1])
49+ local burst = tonumber(ARGV[2])
50+ local now = tonumber(ARGV[3])
51+ local ttl = tonumber(ARGV[4])
3652
37- local excess , err = red :get (excess_key )
38- if err then
39- return nil , err
40- end
41- local last , err = red :get (last_key )
42- if err then
43- return nil , err
44- end
53+ local state = redis.call('hmget', state_key, 'excess', 'last')
54+ local excess_raw = state[1]
55+ local last_raw = state[2]
4556
46- if excess ~= ngx_null and last ~= ngx_null then
47- excess = tonumber ( excess )
48- last = tonumber ( last )
49- local elapsed = now - last
50- excess = max (excess - rate * abs (elapsed ) / 1000 + 1000 , 0 )
57+ local excess
58+ if excess_raw and last_raw then
59+ -- state exists: apply leaky-bucket decay then add one request-unit (1000 )
60+ local elapsed = now - tonumber(last_raw)
61+ excess = math. max(tonumber(excess_raw) - rate * math. abs(elapsed) / 1000 + 1000, 0)
5162
52- if excess > self . burst then
53- return nil , " rejected "
63+ if excess > burst then
64+ return {0, tostring(excess)}
5465 end
5566 else
67+ -- no prior state: mirror the original behaviour, which skips the
68+ -- leaky-bucket formula and starts with excess = 0 so the very first
69+ -- request is always admitted
5670 excess = 0
5771 end
5872
59- if commit then
60- local ttl = math.ceil (self .burst / self .rate ) + 1
61- local ok , err
73+ redis.call('hset', state_key, 'excess', excess, 'last', now)
74+ redis.call('expire', state_key, ttl)
75+ return {1, tostring(excess)}
76+ ]=] )
6277
63- ok , err = red :set (excess_key , excess , " EX" , ttl )
64- if not ok then
78+
79+ -- the "commit" argument controls whether should we record the event in shm.
80+ function _M .incoming (self , red , key , commit )
81+ local rate = self .rate
82+ local now = ngx_now () * 1000
83+
84+ -- all leaky-bucket state lives in a single Redis hash so that the commit
85+ -- script can run with one key on both Redis and Redis Cluster
86+ local state_key = " limit_req" .. " :" .. key
87+
88+ if not commit then
89+ -- read-only path: a plain HMGET is fine here because nothing is
90+ -- written back, so a stale read only affects this advisory check
91+ local state , err = red :hmget (state_key , " excess" , " last" )
92+ if not state then
6593 return nil , err
6694 end
6795
68- ok , err = red :set (last_key , now , " EX" , ttl )
69- if not ok then
70- return nil , err
96+ local excess = state [1 ]
97+ local last = state [2 ]
98+ local excess_val
99+ if excess ~= ngx_null and last ~= ngx_null then
100+ excess_val = tonumber (excess )
101+ local last_val = tonumber (last )
102+ local elapsed = now - last_val
103+ excess_val = max (excess_val - rate * abs (elapsed ) / 1000 + 1000 , 0 )
104+
105+ if excess_val > self .burst then
106+ return nil , " rejected"
107+ end
108+ else
109+ excess_val = 0
71110 end
111+
112+ return excess_val / rate , excess_val / 1000
113+ end
114+
115+ -- commit path: run the read-compute-write cycle atomically in Redis so
116+ -- that concurrent requests cannot be admitted based on the same stale
117+ -- state
118+ local ttl = math.ceil (self .burst / self .rate ) + 1
119+
120+ local res , err = red :eval (redis_commit_script , 1 , state_key ,
121+ rate , self .burst , now , ttl )
122+ if not res then
123+ return nil , err
124+ end
125+
126+ local allowed = res [1 ]
127+ local excess = tonumber (res [2 ])
128+ if allowed == 0 then
129+ return nil , " rejected"
72130 end
73131
74132 -- return the delay in seconds, as well as excess
0 commit comments