perf(etcd): reuse unchanged items on a full reload - #13772
Open
AlinsRan wants to merge 1 commit into
Open
Conversation
A full reload is how APISIX recovers from a compacted watch, and today it rebuilds everything unconditionally: every item is re-validated through check_schema, the checker and the filter, and load_full_data sets `changed` as soon as any item is valid, so conf_version always moves and every router rebuilds its radixtree. The item tables are new objects too, so downstream caches keyed on them all miss. None of that is necessary when nothing actually changed, which is the common case for the deployment that suffers from this: a prefix idle enough to fall behind compaction is a prefix whose configuration did not change. Compare each key against the previous snapshot and reuse the item when the modifiedIndex matches. etcd increments mod_revision on every write, so an equal modifiedIndex means equal content. A reload that changes nothing now keeps the existing objects, leaves conf_version alone and rebuilds no routers. This is the same semantics the incremental watch path already has: sync_data re-runs the checker and filter only for the keys that changed, and leaves the other items untouched. Every filter but /plugins' only mutates fields of the item it is given, so an item that was filtered once is already in its filtered state; /plugins is single_item and is left out of the optimisation because its filter calls plugin.load(), which has global effects. Deletions need an explicit check. Keys that vanished while we were not watching leave every surviving key untouched, so `changed` would stay false, conf_version would not move, and the routers would go on serving the deleted items. Fixes apache#12167
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.
Fixes #12167
Makes the
compactedrecovery reload cheap instead of trying to avoid it. A reload that changes nothing now reuses the existing items, leavesconf_versionalone and rebuilds no routers.Companion to #13721, which removes the unsafe way of avoiding the reload. Together they close #12167 and #13067; each stands on its own and they can be reviewed independently.
The problem
#12167 reports periodic CPU spikes correlated with this log line:
The reporter's ask is specific — "APISIX CPU usage fluctuates when 'compacted' errors occur. I want to avoid this problem." Not the log line: the CPU.
Recovery from
compactedis a full reload, and today it rebuilds everything unconditionally:check_schema, thecheckerand thefilterload_full_datasetschangedas soon as any item is valid, soconf_versionalways moves and every router rebuilds its radixtreeAnd it happens for every resource type — routes, services, upstreams, consumers, ssls, global_rules, plugin_configs … — in every worker, because
need_reloadis per config instance andproduce_res(nil, "compacted")broadcasts to all of them.None of that work is necessary when nothing actually changed — which is precisely the case for the deployment that suffers from this. A prefix idle enough to fall behind compaction is a prefix whose configuration did not change.
The fix
Compare each key against the previous snapshot and reuse the item when
modifiedIndexmatches:etcd increments
mod_revisionon every write, so an equalmodifiedIndexmeans equal content.The
prev_values/get_prev_itemplumbing already exists — it was added so an item whose new data fails validation can keep serving its last valid value. This reuses it.Why skipping the filter is safe
The incremental watch path already works this way:
sync_datare-runs the checker and filter only for the keys that changed, and never touches the other items. So this is not new semantics, it aligns the reload path with the watch path.Checked every filter individually:
/routeshas_domain,set_plugins_meta_parent, host lowercasing,filter_upstream/services/upstreamshas_domain,filter_upstream/consumers,/consumer_groups,/global_rules,/plugin_configsset_plugins_meta_parent/ssls/pluginsplugin.load(item)— global effectsThe first eight are idempotent and only mutate fields of the item they are handed, so an item that was filtered once is already in its filtered state.
/pluginsis the exception, and it issingle_item— one item, negligible gain — so thesingle_itembranch is left out of the optimisation entirely.Deletions need an explicit check
This is the trap. Keys that vanished while we were not watching leave every surviving key untouched, so
changedwould stayfalse,conf_versionwould not move, and the routers would go on serving the deleted items:Tests
Both are verified to be discriminating — a test that passes either way proves nothing.
TEST 19 — a reload with nothing changed. Two independent probes: a tag on the
valuesarray (a reload always allocates a fresh one, so losing it proves the reload really ran) and a tag on the item inside it (which must survive). Assertsconf_versionmoved once for the incremental write that wakes the watcher, not twice.Against unpatched
master:TEST 20 — a reload whose only change is a deletion. Passes on unpatched
master(which bumps unconditionally), so it was verified against the variant that matters: the reuse optimisation with the deletion check disabled:Full file run locally: the failure set is identical before and after this change (TEST 3/4/5/9, which need a TLS etcd on :12379 that this machine does not have), and TEST 16/17/18 — the existing full-reload tests — still pass.
What this does not do
The
readdiritself still happens on everycompacted: without reading the full snapshot there is no way to know what was missed. The transfer and JSON parse remain. What goes away is the rebuild on top of it, which is the part that scales with configuration size and shows up as the spike.