This is an automated email from the ASF dual-hosted git repository.

membphis pushed a commit to branch feat/router-min-rebuild-interval
in repository https://gitbox.apache.org/repos/asf/apisix.git

commit f5dd1dde07b86449503f3a3f8574e57160c7b0e9
Author: Rain <[email protected]>
AuthorDate: Thu Mar 5 13:32:48 2026 +0800

    feat: add router_rebuild_min_interval to reduce CPU spikes from frequent 
route changes
    
    When routes change frequently (e.g., 20-50 changes/sec), the router is
    rebuilt on every change which causes CPU spikes under high-frequency
    updates due to full radixtree recompilation with all routes.
    
    This adds a configurable minimum interval between router rebuilds.
    When set to a positive value (e.g., 1 or 5 seconds), rebuilds are
    throttled so the router is not recompiled more frequently than the
    configured interval, significantly reducing CPU usage during bulk
    route updates.
    
    Default value is 0 (rebuild immediately on every change), preserving
    backward compatibility with existing behavior.
---
 apisix/cli/config.lua                              |   3 +-
 apisix/http/router/radixtree_host_uri.lua          |  14 +
 apisix/http/router/radixtree_uri.lua               |  14 +
 .../http/router/radixtree_uri_with_parameter.lua   |  14 +
 apisix/router.lua                                  |  12 +
 apisix/stream/router/ip_port.lua                   |  15 +
 conf/config.yaml.example                           |   6 +
 t/router/router-rebuild-min-interval.t             | 363 +++++++++++++++++++++
 8 files changed, 440 insertions(+), 1 deletion(-)

diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua
index 956eef30c..24b85d3d6 100644
--- a/apisix/cli/config.lua
+++ b/apisix/cli/config.lua
@@ -49,7 +49,8 @@ local _M = {
     normalize_uri_like_servlet = false,
     router = {
       http = "radixtree_host_uri",
-      ssl = "radixtree_sni"
+      ssl = "radixtree_sni",
+      router_rebuild_min_interval = 0,
     },
     proxy_mode = "http",
     resolver_timeout = 5,
diff --git a/apisix/http/router/radixtree_host_uri.lua 
b/apisix/http/router/radixtree_host_uri.lua
index 680a04fbe..46b475fcf 100644
--- a/apisix/http/router/radixtree_host_uri.lua
+++ b/apisix/http/router/radixtree_host_uri.lua
@@ -20,6 +20,7 @@ local core = require("apisix.core")
 local event = require("apisix.core.event")
 local get_services = require("apisix.http.service").services
 local service_fetch = require("apisix.http.service").get
+local ngx_now = ngx.now
 local ipairs = ipairs
 local type = type
 local tab_insert = table.insert
@@ -27,6 +28,7 @@ local loadstring = loadstring
 local pairs = pairs
 local cached_router_version
 local cached_service_version
+local last_router_rebuild_time
 local host_router
 local only_uri_router
 
@@ -148,11 +150,23 @@ function _M.match(api_ctx)
     if not cached_router_version or cached_router_version ~= 
user_routes.conf_version
         or not cached_service_version or cached_service_version ~= 
service_version
     then
+        local min_interval = _M.router_rebuild_min_interval or 0
+        if min_interval > 0 and last_router_rebuild_time then
+            local elapsed = ngx_now() - last_router_rebuild_time
+            if elapsed < min_interval then
+                core.log.info("skip router rebuild, elapsed: ", elapsed,
+                              "s, min_interval: ", min_interval, "s")
+                goto MATCH
+            end
+        end
+
         create_radixtree_router(user_routes.values)
         cached_router_version = user_routes.conf_version
         cached_service_version = service_version
+        last_router_rebuild_time = ngx_now()
     end
 
+    ::MATCH::
     return _M.matching(api_ctx)
 end
 
diff --git a/apisix/http/router/radixtree_uri.lua 
b/apisix/http/router/radixtree_uri.lua
index 7c1b5c0c1..a3520053f 100644
--- a/apisix/http/router/radixtree_uri.lua
+++ b/apisix/http/router/radixtree_uri.lua
@@ -18,8 +18,10 @@ local require = require
 local core = require("apisix.core")
 local base_router = require("apisix.http.route")
 local get_services = require("apisix.http.service").services
+local ngx_now = ngx.now
 local cached_router_version
 local cached_service_version
+local last_router_rebuild_time
 
 
 local _M = {version = 0.2}
@@ -33,12 +35,24 @@ function _M.match(api_ctx)
     if not cached_router_version or cached_router_version ~= 
user_routes.conf_version
         or not cached_service_version or cached_service_version ~= 
service_version
     then
+        local min_interval = _M.router_rebuild_min_interval or 0
+        if min_interval > 0 and last_router_rebuild_time then
+            local elapsed = ngx_now() - last_router_rebuild_time
+            if elapsed < min_interval then
+                core.log.info("skip router rebuild, elapsed: ", elapsed,
+                              "s, min_interval: ", min_interval, "s")
+                goto MATCH
+            end
+        end
+
         uri_router = 
base_router.create_radixtree_uri_router(user_routes.values,
                                                              uri_routes, false)
         cached_router_version = user_routes.conf_version
         cached_service_version = service_version
+        last_router_rebuild_time = ngx_now()
     end
 
+    ::MATCH::
     if not uri_router then
         core.log.error("failed to fetch valid `uri` router: ")
         return true
diff --git a/apisix/http/router/radixtree_uri_with_parameter.lua 
b/apisix/http/router/radixtree_uri_with_parameter.lua
index 3f10f4fca..bafab2c22 100644
--- a/apisix/http/router/radixtree_uri_with_parameter.lua
+++ b/apisix/http/router/radixtree_uri_with_parameter.lua
@@ -18,8 +18,10 @@ local require = require
 local core = require("apisix.core")
 local base_router = require("apisix.http.route")
 local get_services = require("apisix.http.service").services
+local ngx_now = ngx.now
 local cached_router_version
 local cached_service_version
+local last_router_rebuild_time
 
 
 local _M = {}
@@ -33,12 +35,24 @@ function _M.match(api_ctx)
     if not cached_router_version or cached_router_version ~= 
user_routes.conf_version
         or not cached_service_version or cached_service_version ~= 
service_version
     then
+        local min_interval = _M.router_rebuild_min_interval or 0
+        if min_interval > 0 and last_router_rebuild_time then
+            local elapsed = ngx_now() - last_router_rebuild_time
+            if elapsed < min_interval then
+                core.log.info("skip router rebuild, elapsed: ", elapsed,
+                              "s, min_interval: ", min_interval, "s")
+                goto MATCH
+            end
+        end
+
         uri_router = 
base_router.create_radixtree_uri_router(user_routes.values,
                                                              uri_routes, true)
         cached_router_version = user_routes.conf_version
         cached_service_version = service_version
+        last_router_rebuild_time = ngx_now()
     end
 
+    ::MATCH::
     if not uri_router then
         core.log.error("failed to fetch valid `uri_with_parameter` router: ")
         return true
diff --git a/apisix/router.lua b/apisix/router.lua
index 6cdd07175..64fff5876 100644
--- a/apisix/router.lua
+++ b/apisix/router.lua
@@ -23,6 +23,7 @@ local str_lower = string.lower
 local ipairs  = ipairs
 
 local _M = {version = 0.3}
+local router_rebuild_min_interval = 0
 
 
 local function filter(route)
@@ -76,11 +77,14 @@ function _M.http_init_worker()
     if conf and conf.apisix and conf.apisix.router then
         router_http_name = conf.apisix.router.http or router_http_name
         router_ssl_name = conf.apisix.router.ssl or router_ssl_name
+        router_rebuild_min_interval = 
conf.apisix.router.router_rebuild_min_interval
+                                      or router_rebuild_min_interval
     end
 
     local router_http = require("apisix.http.router." .. router_http_name)
     attach_http_router_common_methods(router_http)
     router_http.init_worker(filter)
+    router_http.router_rebuild_min_interval = router_rebuild_min_interval
     _M.router_http = router_http
 
     local router_ssl = require("apisix.ssl.router." .. router_ssl_name)
@@ -92,6 +96,7 @@ function _M.http_init_worker()
     if conf and conf.apisix and conf.apisix.stream_proxy then
         local router_stream = require("apisix.stream.router.ip_port")
         router_stream.stream_init_worker(filter)
+        router_stream.router_rebuild_min_interval = router_rebuild_min_interval
         _M.router_stream = router_stream
     end
 
@@ -100,10 +105,17 @@ end
 
 
 function _M.stream_init_worker()
+    local conf = core.config.local_conf()
     local router_ssl_name = "radixtree_sni"
+    local min_interval = 0
+
+    if conf and conf.apisix and conf.apisix.router then
+        min_interval = conf.apisix.router.router_rebuild_min_interval or 0
+    end
 
     local router_stream = require("apisix.stream.router.ip_port")
     router_stream.stream_init_worker(filter)
+    router_stream.router_rebuild_min_interval = min_interval
     _M.router_stream = router_stream
 
     local router_ssl = require("apisix.ssl.router." .. router_ssl_name)
diff --git a/apisix/stream/router/ip_port.lua b/apisix/stream/router/ip_port.lua
index 4d502cab0..14bf17ea9 100644
--- a/apisix/stream/router/ip_port.lua
+++ b/apisix/stream/router/ip_port.lua
@@ -21,12 +21,14 @@ local stream_plugin_checker = 
require("apisix.plugin").stream_plugin_checker
 local router_new = require("apisix.utils.router").new
 local apisix_ssl = require("apisix.ssl")
 local xrpc = require("apisix.stream.xrpc")
+local ngx_now   = ngx.now
 local error     = error
 local tonumber  = tonumber
 local ipairs = ipairs
 
 local user_routes
 local router_ver
+local last_router_rebuild_time
 local tls_router
 local other_routes = {}
 local _M = {version = 0.1}
@@ -144,14 +146,27 @@ do
 
     function _M.match(api_ctx)
         if router_ver ~= user_routes.conf_version then
+            local min_interval = _M.router_rebuild_min_interval or 0
+            if min_interval > 0 and last_router_rebuild_time then
+                local elapsed = ngx_now() - last_router_rebuild_time
+                if elapsed < min_interval then
+                    core.log.info("skip stream router rebuild, elapsed: ", 
elapsed,
+                                  "s, min_interval: ", min_interval, "s")
+                    goto MATCH
+                end
+            end
+
             local err = create_router(user_routes.values)
             if err then
                 return false, "failed to create router: " .. err
             end
 
             router_ver = user_routes.conf_version
+            last_router_rebuild_time = ngx_now()
         end
 
+        ::MATCH::
+
         local sni = apisix_ssl.server_name()
         if sni and tls_router then
             local sni_rev = sni:reverse()
diff --git a/conf/config.yaml.example b/conf/config.yaml.example
index a32499710..49e378e7d 100644
--- a/conf/config.yaml.example
+++ b/conf/config.yaml.example
@@ -71,6 +71,12 @@ apisix:
                                 # radixtree_uri: match route by URI
                                 # radixtree_uri_with_parameter: similar to 
radixtree_uri but match URI with parameters. See 
https://github.com/api7/lua-resty-radixtree/#parameters-in-path for more 
details.
     ssl: radixtree_sni          # radixtree_sni: match route by SNI
+    # router_rebuild_min_interval: 0  # Minimum interval (in seconds) between 
router rebuilds.
+                                      # When routes change frequently, the 
router is rebuilt on every
+                                      # change which can cause CPU spikes 
under high-frequency updates.
+                                      # Set this to a positive value (e.g. 1 
or 5) to limit the
+                                      # rebuild frequency. Default 0 means 
rebuild immediately on
+                                      # every change (current behavior).
 
   # http is the default proxy mode. proxy_mode can be one of `http`, `stream`, 
or `http&stream`
   proxy_mode: "http"
diff --git a/t/router/router-rebuild-min-interval.t 
b/t/router/router-rebuild-min-interval.t
new file mode 100644
index 000000000..67dc6a1f1
--- /dev/null
+++ b/t/router/router-rebuild-min-interval.t
@@ -0,0 +1,363 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+log_level('info');
+worker_connections(256);
+no_root_location();
+no_shuffle();
+
+add_block_preprocessor(sub {
+    my ($block) = @_;
+
+    if (!defined $block->yaml_config) {
+        my $yaml_config = <<_EOC_;
+apisix:
+    node_listen: 1984
+    router:
+        http: 'radixtree_uri'
+        router_rebuild_min_interval: 0
+_EOC_
+        $block->set_value("yaml_config", $yaml_config);
+    }
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: default behavior (min_interval=0) - router rebuilds on every route 
change
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/hello"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 2: hit route to trigger router rebuild
+--- request
+GET /hello
+--- response_body
+hello world
+--- no_error_log
+skip router rebuild
+
+
+
+=== TEST 3: update route to trigger another rebuild
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1981": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/hello"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 4: hit route - should rebuild immediately (min_interval=0)
+--- request
+GET /hello
+--- response_body_like eval
+qr/hello world/
+--- no_error_log
+skip router rebuild
+
+
+
+=== TEST 5: set router_rebuild_min_interval to 2 seconds and create initial 
route
+--- yaml_config
+apisix:
+    node_listen: 1984
+    router:
+        http: 'radixtree_uri'
+        router_rebuild_min_interval: 2
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/hello"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 6: hit route to trigger initial router build
+--- yaml_config
+apisix:
+    node_listen: 1984
+    router:
+        http: 'radixtree_uri'
+        router_rebuild_min_interval: 2
+--- request
+GET /hello
+--- response_body
+hello world
+--- no_error_log
+skip router rebuild
+
+
+
+=== TEST 7: update route and request immediately - rebuild should be skipped
+--- yaml_config
+apisix:
+    node_listen: 1984
+    router:
+        http: 'radixtree_uri'
+        router_rebuild_min_interval: 2
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1981": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/hello"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+                ngx.say(body)
+                return
+            end
+
+            -- request immediately after route change, within min_interval
+            local http = require("resty.http")
+            local httpc = http.new()
+            local res, err = httpc:request_uri("http://127.0.0.1:1984/hello";)
+            if not res then
+                ngx.say("request failed: ", err)
+                return
+            end
+
+            ngx.say("status: ", res.status)
+            ngx.say("body: ", res.body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+status: 200
+body: hello world
+--- error_log
+skip router rebuild
+
+
+
+=== TEST 8: wait for min_interval to pass, then rebuild should happen
+--- yaml_config
+apisix:
+    node_listen: 1984
+    router:
+        http: 'radixtree_uri'
+        router_rebuild_min_interval: 2
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+
+            -- update route
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/hello"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+                ngx.say(body)
+                return
+            end
+
+            -- wait for min_interval to expire
+            ngx.sleep(2.1)
+
+            -- this request should trigger a rebuild
+            local http = require("resty.http")
+            local httpc = http.new()
+            local res, err = httpc:request_uri("http://127.0.0.1:1984/hello";)
+            if not res then
+                ngx.say("request failed: ", err)
+                return
+            end
+
+            ngx.say("status: ", res.status)
+            ngx.say("body: ", res.body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+status: 200
+body: hello world
+--- no_error_log
+skip router rebuild
+--- timeout: 5
+
+
+
+=== TEST 9: rapid route updates within min_interval - only one rebuild
+--- yaml_config
+apisix:
+    node_listen: 1984
+    router:
+        http: 'radixtree_uri'
+        router_rebuild_min_interval: 3
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local http = require("resty.http")
+
+            -- create initial route and trigger first build
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/hello"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+                ngx.say(body)
+                return
+            end
+
+            -- trigger initial build
+            local httpc = http.new()
+            local res, err = httpc:request_uri("http://127.0.0.1:1984/hello";)
+            if not res then
+                ngx.say("initial request failed: ", err)
+                return
+            end
+
+            -- rapid updates (simulating high-frequency route changes)
+            for i = 1, 5 do
+                t('/apisix/admin/routes/1',
+                     ngx.HTTP_PUT,
+                     [[{
+                            "upstream": {
+                                "nodes": {
+                                    "127.0.0.1:1980": 1
+                                },
+                                "type": "roundrobin"
+                            },
+                            "uri": "/hello"
+                    }]]
+                    )
+                -- request after each update
+                httpc = http.new()
+                res, err = httpc:request_uri("http://127.0.0.1:1984/hello";)
+                if not res then
+                    ngx.say("request ", i, " failed: ", err)
+                    return
+                end
+            end
+
+            ngx.say("all requests succeeded")
+        }
+    }
+--- request
+GET /t
+--- response_body
+all requests succeeded
+--- error_log
+skip router rebuild

Reply via email to