perf(consumer): incremental consumer plugin tree rebuild instead of O…#13707
Open
jlaupa wants to merge 1 commit into
Open
perf(consumer): incremental consumer plugin tree rebuild instead of O…#13707jlaupa wants to merge 1 commit into
jlaupa wants to merge 1 commit into
Conversation
…(N) full rebuild per change _M.plugin() rebuilt the entire consumer plugin tree via core.lrucache.global on every consumers.conf_version change, which is O(N) on the request path. With a large consumer base and steady consumer churn, rebuilds take seconds and stack up: request latency collapses and CPU saturates. Replace the full rebuild with an incremental one: a pending-set fed by the etcd filter callback applies only changed consumers on conf_version change; a full rebuild happens only on bootstrap (first request); a lightweight 30s background consistency check (count compare + id scan) triggers a full rebuild only if it detects a discrepancy (covers the simultaneous create+delete edge). No config or API changes; consumers_count_for_lrucache is unchanged.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Problem
apisix/consumer.lua's_M.plugin()rebuilds the entire consumer plugin tree on everyconsumers.conf_versionchange:plugin_consumer()iterates all consumers (and credentials) and reconstructs the per–auth-pluginnode arrays. Because the lru-cache key is
consumers.conf_version, any consumer create / update /delete invalidates the whole cache, and the next request that needs consumer data pays an O(N) full
rebuild on the request (access) path.
With a small consumer base this is invisible. With tens of thousands of consumers and steady consumer
churn (registrations, API-key rotations, deletions) it is not: each change forces a full rebuild, the
rebuilds run on request threads, they stack up under load, request latency collapses and CPU saturates.
The benchmark below reproduces exactly this in a controlled lab setup.
The lru-cache capacity knob (
consumers_count_for_lrucache) does not fix this — it only affects howmuch of the per-consumer object cache fits; the
_M.plugin()path still rebuilds the whole tree onevery
conf_versionchange regardless of that value.Change
Make the rebuild incremental:
/consumersetcdfiltercallback records changed consumers in apending set; on the next
conf_versionchange_M.plugin()applies only those changes — O(changed),not O(N). Auth-plugin node arrays are maintained with an index (
plugin\0id -> position) and O(1)swap-with-last removal.
#consumers.valuesdrops, the changed set is reconciled against the currentids and stale entries are removed.
compare tracked count and scan ids; it only falls back to a full rebuild if it finds an actual
discrepancy (this covers the rare simultaneous create+delete where the count is unchanged but the set
differs). It never reconstructs consumer data on the happy path, so steady state is a no-op.
consumers_count_for_lrucacheis left at its default (4096) — with incremental rebuild the LRU onlyaffects fill cost, not stability, so no tuning change is bundled here.
Benchmark
Same harness, same workload, run sequentially on the same host (APISIX container limited to 2 CPUs),
3.17.0 base, 50,000 pre-loaded key-auth consumers, ~600 rps of authenticated traffic rotating across 300
distinct keys, then a 5-minute churn phase of 500 consumer creates (@concurrency 10) + 100 deletes via
the Admin API under that load. The patched column is this PR's code with the default
consumers_count_for_lrucache = 4096.Under churn, vanilla's throughput collapses by ~98% (544 → 8 rps), p50 latency rises from 66 ms to
17 seconds, 85% of requests time out, and the Admin API itself falls over (only 2 of 500 creates
succeed — the rebuild storm starves the control plane too). With the patch, throughput and latency are
essentially flat through churn and the Admin API stays healthy.
Note on the patched column's non-2xx responses: they are 401s equal to the share of keys I deliberately
deleted during churn (100 of the 300 rotated keys) — i.e. deletions propagate immediately and
correctly (measured per deleted key: time-to-401 p50 110 ms / p95 370 ms / max 849 ms across all 99 deletes); there are zero timeouts. CPU is reported as
docker statspercentage for a containerhard-capped at 2 CPUs (values read high because they are scaled against the host core count); the
meaningful figure is the ratio.
Reproduction
Minimal stack:
etcd+apisix(image under test,cpus: 2.0) + an nginx echo upstream, on one host.APISIX config: etcd config provider,
key-auth+prometheusonly, admin API open, anddata_encryption.enable_encrypt_fields: false(so 50k consumers can be pre-loaded straight into etcd asplaintext key-auth for speed; identical for both runs, so it does not bias the comparison — if anything
it understates vanilla's cost by removing per-consumer decryption from the rebuild).
Steps, run once per image (
apache/apisix:3.17.0-debian, then a build of this PR), fresh etcd each time:(
POST /v3/kv/putof/apisix/consumers/<user>={"username","plugins":{"key-auth":{"key"}}}).GETwithapikeyrotating over 300 keys (multi-process generator),logging per-request latency + status.
evenly) → 2 min cooldown. Sample container CPU (
docker stats) every 1s.freshly created key takes to authenticate.
(The scripts used — compose file, minimal
config.yaml, the etcd bulk loader, the load generator and thechurn driver — are ~200 lines of Python/YAML total and can be attached on request.)
Reliability
A fair objection to incremental state (raised on the route-side PR #12826) is that it can drift and become
less reliable than a full rebuild over long-term operation. This design is built to not trust
incrementality blindly:
removal; the config
filtercallback flags it, and the next request reconciles the incremental indexagainst the current id set (an O(N) id scan with no consumer-data construction — the cheap part) and
drops the removed entries. A deleted consumer therefore stops authenticating on the next request, in the
same latency class as a create — which matters for revocations/bans (security-relevant).
tracked count against
#consumers.valuesand scans the incremental index's ids against the current ids.On any discrepancy it performs a full rebuild. It is a true anomaly net — not the delete mechanism —
so worst-case drift is bounded to ≤30s and heals automatically; the system cannot get "stuck" in a wrong
state the way silent drift would imply.
#consumers.valuesto decrease. Under net-positive churn (many creates interleaved with a fewdeletes) the count never drops, so deletes were missed on the fast path and only caught by the 30s full
rebuild — which, at the stock
consumers_count_for_lrucache, is itself expensive. My own benchmarksurfaced exactly this (deleted keys kept authenticating; CPU spiked on the periodic full rebuilds), so
this version makes deletes first-class as above and keeps the background check purely as a safety net.
The regression test
TEST 13–16in the suite reproduces the edge (delete a consumer while creatingothers, net count rising → the deleted key is rejected on the next request).
deployment I operate — tens of thousands of consumers, sustained high traffic with steady consumer
churn — without issues.
rejected / new key accepted), delete → rejected, delete-under-net-positive-churn, credential-based
consumers, and consumer-group plugin merge, so the incremental paths are validated in CI.
If a maintainer prefers, the background check interval is a one-line constant and could be exposed or
shortened; the full-rebuild fallback guarantees correctness regardless of its value.
Backward compatibility
No configuration changes, no Admin-API or schema changes, and no change to observable behavior: the same
consumers resolve to the same auth data.
consumers_count_for_lrucacheis untouched. The public surfaceof
apisix/consumer.lua(_M.plugin,_M.consumers_conf,filter_consumers_list,init_worker, …) isunchanged; only the internal rebuild strategy behind
_M.plugin()differs, plus one background timer.Prior art
This is the consumer-side counterpart of the route-side idea in
#12826 (incremental radixtree router build), which a
maintainer showed interest in and which was closed by the stale bot rather than rejected. Related consumer
performance reports: #9692,
#9140. An earlier iteration of this approach has been
running since February 2026 in a production deployment I operate — tens of thousands of consumers under
sustained high traffic with steady consumer churn — without issues; the submitted version additionally
hardens delete handling (see Reliability above).
Which issue(s) this PR fixes:
Related: #9692, #9140 (and #12826, the route-side counterpart of the same idea).
Checklist