-
Notifications
You must be signed in to change notification settings - Fork 2.8k
fix(limit-req): Make Redis path atomic via EVAL + use hash key with TTL #12605
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
d60f099
74d8587
1332808
6399925
8263caa
9108c98
3a562e6
9bc4c94
9079326
48c93ee
05657f6
b28ca4d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,61 +14,103 @@ | |
| -- See the License for the specific language governing permissions and | ||
| -- limitations under the License. | ||
| -- | ||
| local math = require "math" | ||
| local abs = math.abs | ||
| local max = math.max | ||
| local ngx_now = ngx.now | ||
| local ngx_null = ngx.null | ||
| local tonumber = tonumber | ||
| local core = require("apisix.core") | ||
|
|
||
|
|
||
| local _M = {version = 0.1} | ||
|
|
||
|
|
||
| local redis_incoming_script = core.string.compress_script([=[ | ||
| local state_key = KEYS[1] -- state_key (hash), fields: "excess", "last" | ||
| local rate = tonumber(ARGV[1]) -- req/s | ||
| local now = tonumber(ARGV[2]) -- ms | ||
| local burst = tonumber(ARGV[3]) -- req/s | ||
| local commit = tonumber(ARGV[4]) -- 1/0 | ||
|
|
||
| local vals = redis.call("HMGET", state_key, "excess", "last") | ||
| local prev_excess = tonumber(vals[1] or "0") | ||
| local prev_last = tonumber(vals[2] or "0") | ||
|
|
||
| local new_excess | ||
| if prev_last > 0 then | ||
| local elapsed = math.abs(now - prev_last) | ||
| new_excess = math.max(prev_excess - rate * (elapsed) / 1000 + 1000, 0) | ||
| else | ||
| new_excess = 0 | ||
| end | ||
|
|
||
| if new_excess > burst then | ||
| return {0, new_excess} | ||
| end | ||
|
|
||
| if commit == 1 then | ||
| redis.call("HMSET", state_key, "excess", new_excess, "last", now) | ||
| local ttl = math.ceil(burst / rate) + 1 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nice! |
||
| redis.call("EXPIRE", state_key, ttl) | ||
| end | ||
|
|
||
| return {1, new_excess} | ||
| ]=]) | ||
|
|
||
| local redis_incoming_script_sha | ||
|
|
||
|
|
||
| local function generate_redis_sha1(red) | ||
| local sha1, err = red:script("LOAD", redis_incoming_script) | ||
| if not sha1 then | ||
| return nil, err | ||
| end | ||
| return sha1 | ||
| end | ||
|
|
||
|
|
||
| -- the "commit" argument controls whether should we record the event in shm. | ||
| function _M.incoming(self, red, key, commit) | ||
| local rate = self.rate | ||
| local now = ngx_now() * 1000 | ||
|
|
||
| key = "limit_req" .. ":" .. key | ||
| local excess_key = key .. "excess" | ||
| local last_key = key .. "last" | ||
| local state_key = "limit_req:{" .. key .. "}:state" | ||
|
|
||
| local excess, err = red:get(excess_key) | ||
| if err then | ||
| return nil, err | ||
| end | ||
| local last, err = red:get(last_key) | ||
| if err then | ||
| return nil, err | ||
| end | ||
| local commit_flag = commit and "1" or "0" | ||
|
|
||
| if excess ~= ngx_null and last ~= ngx_null then | ||
| excess = tonumber(excess) | ||
| last = tonumber(last) | ||
| local elapsed = now - last | ||
| excess = max(excess - rate * abs(elapsed) / 1000 + 1000, 0) | ||
| local res, err | ||
|
|
||
| if excess > self.burst then | ||
| return nil, "rejected" | ||
| if self.use_evalsha then | ||
| if not redis_incoming_script_sha then | ||
| redis_incoming_script_sha, err = generate_redis_sha1(red) | ||
| if not redis_incoming_script_sha then | ||
| core.log.error("failed to generate redis sha1: ", err) | ||
| return nil, err | ||
| end | ||
| end | ||
| -- Try EVALSHA first (fast path). | ||
| res, err = red:evalsha(redis_incoming_script_sha, 1, state_key, | ||
| rate, now, self.burst, commit_flag) | ||
|
|
||
| -- If the script isn't cached on this Redis node, fall back to EVAL. | ||
| if err and core.string.has_prefix(err, "NOSCRIPT") then | ||
| core.log.warn("redis evalsha failed: ", err, ". Falling back to eval...") | ||
| redis_incoming_script_sha = nil | ||
| res, err = red:eval(redis_incoming_script, 1, state_key, | ||
| rate, now, self.burst, commit_flag) | ||
| end | ||
| else | ||
| excess = 0 | ||
| -- rediscluster: prefer reliability (scripts are cached per node) | ||
| res, err = red:eval(redis_incoming_script, 1, state_key, | ||
| rate, now, self.burst, commit_flag) | ||
| end | ||
|
|
||
| if commit then | ||
| local ttl = math.ceil(self.burst / self.rate) + 1 | ||
| local ok, err | ||
| if not res then | ||
| return nil, err | ||
| end | ||
|
|
||
| ok, err = red:set(excess_key, excess, "EX", ttl) | ||
| if not ok then | ||
| return nil, err | ||
| end | ||
| local allowed = tonumber(res[1]) == 1 | ||
| local excess = tonumber(res[2]) or 0 | ||
|
|
||
| ok, err = red:set(last_key, now, "EX", ttl) | ||
| if not ok then | ||
| return nil, err | ||
| end | ||
| if not allowed then | ||
| return nil, "rejected" | ||
| end | ||
|
|
||
| -- return the delay in seconds, as well as excess | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -660,3 +660,114 @@ passed | |||||||||||||
| GET /t | ||||||||||||||
| --- response_body eval | ||||||||||||||
| qr/property \"rate\" validation failed: expected 0 to be greater than 0/ | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| === TEST 25: verify atomic Redis operations with hash key structure | ||||||||||||||
| --- config | ||||||||||||||
| location /t { | ||||||||||||||
| content_by_lua_block { | ||||||||||||||
| local redis = require "resty.redis" | ||||||||||||||
| local red = redis:new() | ||||||||||||||
| red:set_timeout(1000) | ||||||||||||||
|
|
||||||||||||||
| local ok, err = red:connect("127.0.0.1", 6379) | ||||||||||||||
| if not ok then | ||||||||||||||
| ngx.say("failed to connect: ", err) | ||||||||||||||
| return | ||||||||||||||
| end | ||||||||||||||
|
|
||||||||||||||
| -- Clean up any existing keys | ||||||||||||||
| red:del("limit_req:{test_key}:state") | ||||||||||||||
|
|
||||||||||||||
| -- Test the new hash-based key structure | ||||||||||||||
| local util = require("apisix.plugins.limit-req.util") | ||||||||||||||
| local limiter = { | ||||||||||||||
| rate = 10, -- 10 req/s | ||||||||||||||
| burst = 1000 -- 1000 req/s burst | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| -- First request should succeed | ||||||||||||||
| local delay, excess = util.incoming(limiter, red, "test_key", true) | ||||||||||||||
| if delay then | ||||||||||||||
| ngx.say("first request: delay=", delay, " excess=", excess) | ||||||||||||||
| else | ||||||||||||||
| ngx.say("first request failed: ", excess) | ||||||||||||||
| end | ||||||||||||||
|
|
||||||||||||||
| -- Verify the Redis hash was created with correct key format | ||||||||||||||
| local vals = red:hmget("limit_req:{test_key}:state", "excess", "last") | ||||||||||||||
| if vals[1] and vals[2] then | ||||||||||||||
|
Comment on lines
+699
to
+700
|
||||||||||||||
| local vals = red:hmget("limit_req:{test_key}:state", "excess", "last") | |
| if vals[1] and vals[2] then | |
| local vals, err = red:hmget("limit_req:{test_key}:state", "excess", "last") | |
| if not vals then | |
| ngx.say("failed to hmget: ", err) | |
| elseif vals[1] and vals[2] then |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Lua script comments label
rate/burstas "req/s", but in this limiter implementation they are passed around in the same scaled units used by the original algorithm (rate = configured_rate * 1000,burst = configured_burst * 1000). Updating these comments to reflect the actual units would reduce the risk of future logic changes introducing subtle math/TTL bugs.