perf(consumer): incremental consumer plugin tree rebuild instead of O…#13707
perf(consumer): incremental consumer plugin tree rebuild instead of O…#13707jlaupa wants to merge 3 commits 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.
|
@membphis I think this could be a great improvement 🙏🏼 can we add reviews? |
|
Thanks for working on this—avoiding an O(N) rebuild on every consumer change is valuable for large deployments. However, I found several consistency issues that should be addressed before merging:
Please add regression coverage for removing auth plugins, updating a credential that existed before bootstrap, duplicate credential leaf IDs under different consumers, parent consumer group/label updates, and secret rotation. Attaching the benchmark harness would also make the performance results independently reproducible. The optimization goal is worthwhile, but authentication correctness needs to be preserved first. |
juzhiyuan
left a comment
There was a problem hiding this comment.
Inline comments for the consistency issues from my earlier summary.
|
|
||
| -- Track changed consumer for incremental rebuild | ||
| if cached_plugins and consumer.value.id then | ||
| pending_set[consumer.value.id] = consumer |
There was a problem hiding this comment.
This only queues the changed consumer entry. Credential nodes clone their parent consumer, so changing the parent's group_id, labels, or custom_id must invalidate or rebuild all child credential entries as well. Otherwise requests authenticated by a credential keep using stale group policy and headers indefinitely.
| -- consumer map is cached on it and maintained incrementally by kv_upsert/ | ||
| -- kv_remove, so it is not rebuilt on every conf_version change. Rebuild only on | ||
| -- first use, a key_attr change, or a version gap the incremental path missed. | ||
| if consumer_conf.kv and consumer_conf.key_attr == key_attr |
There was a problem hiding this comment.
With this fast path, the per-plugin map has no time-based expiry, and changes to /secrets or an external secret do not affect consumer_conf.conf_version. Secret-backed auth values can therefore remain cached indefinitely. Please preserve the previous refresh bound or include a secret version or TTL in the cache validity check.
…evoke on empty plugins, TTL-bound secret refresh Follow-up to @juzhiyuan's review: - Normalize entity ids across both paths: the full build now keys nodes by the same "/consumers"-relative id the incremental events use (stamped in plugin_consumer), so bootstrapped credentials can be updated/removed and the consistency timer no longer flags them as stale. Fixes cross-consumer collisions between credentials that share a leaf id. - Enqueue an entity on change even when its new plugin set is empty, so removing all of a consumer's auth plugins removes its old auth entries. - On a parent consumer change, refresh all of its child credential entries (they clone the parent's group_id/labels/custom_id), so credential-auth'd requests do not keep stale group policy/headers. - Give the per-plugin key map a TTL (KV_TTL, matching the previous global lru-cache 300s bound) so secret-backed auth values are re-derived even though /secrets changes do not bump conf_version. - Remove the now-unused module-level lrucache (lint). Tests: add remove-all-plugins, duplicate credential leaf ids across consumers, credential rotation, and parent-group-change-propagates-to-credential cases.
60cec0b to
d9317ba
Compare
The admin-setup blocks used a --- config/location /t stanza with no --- request line, so Test::Nginx had no request to send and aborted the whole file at the first such block (Request line should be non-empty). Add --- request / GET /t to each config-only block so the setup requests actually fire and the suite runs.
bdc925e to
b6ef18c
Compare
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 87 ms / max 503 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