MDEV-32286 Reuse remembered clustered leaves in secondary-index scans - #5625
Open
iMineLink wants to merge 1 commit into
Open
MDEV-32286 Reuse remembered clustered leaves in secondary-index scans#5625iMineLink wants to merge 1 commit into
iMineLink wants to merge 1 commit into
Conversation
|
|
iMineLink
force-pushed
the
11.8-MDEV-32286
branch
from
September 2, 2026 16:42
c16a29b to
02da3ac
Compare
Row_sel_get_clust_rec_for_mysql::operator() descends the clustered B-tree from the root for every row whose clustered-index record a secondary-index scan must read, although consecutive rows often land on the same clustered leaf page. A non-covering scan needs one for every row, a locking read needs one whatever the secondary index holds, because an exclusive select lock type makes ha_innobase::build_template() build its template against the clustered index, and a covering scan needs one for every row of a secondary leaf whose PAGE_MAX_TRX_ID its read view cannot see. ANALYZE FORMAT=JSON charges each descent its full height, and those descents are nearly the whole cost: the secondary index is charged its own descent and one page for each further leaf, and nothing per row, because the position that its cursor holds between two rows is restored optimistically, which latches the leaf again without counting an access. The range estimate that ha_innobase::records_in_range() makes for the optimizer is charged over the same leaves, once more. So pages_accessed is the row count times the height of the clustered index, plus that handful. 1000 rows over a 2-level clustered index cost 2006, of which 3 are the scan of the secondary index and 3 the range estimate, and 750 rows over a 3-level one cost 2291, of which 27 and 14, where a full table scan of the same data costs 23 and 110. Remember, in the new row_prebuilt_t::clust_leaf_hint, the CLUST_LEAF_HINT_SLOTS (4) clustered leaves that the lookups of this statement reached, most recently used first. Each slot names one leaf: its page number, copies of its first and last user record truncated to the key fields, which bound the key range the leaf held when it was remembered, and the rec_get_offsets() of both. The copies are needed because the page is unlatched between two lookups, and the offsets spare a lookup the parsing of them. Several slots serve the scans that alternate between a few leaves, which one slot cannot serve at all, and a descent refreshes the slot of a leaf that is remembered already rather than spend a second one on the same page. A slot owns its key buffers and grows them only when a longer key arrives, so a row allocates nothing. The used-slot count and the miss counter are reset per statement in ha_innobase::reset(), matching autoinc_last_value. A lookup first compares its key against the remembered ranges, so an uncorrelated scan settles its misses in memory, with no buffer pool access and no pages_accessed. Only a covering range is probed, through the new btr_cur_t::try_leaf_hint(), which acquires the page with buf_page_try_get(): a hint is never derived from a latched parent page, so by the time it is tried it may precede the caller's already-latched secondary-index leaf in the latching order, where a blocking wait can deadlock. A stale range costs a wasted probe or a needless descent, never a wrong result, because the checks that try_leaf_hint() makes on the latched page remain the sole authority, and the ranges therefore need no invalidation protocol. After CLUST_LEAF_HINT_MAX_MISSES (8) consecutive unanswered lookups, a scan gives the slots up: row_sel_clust_leaf_hint_armed() stops both the test of the slots and the copies that refresh them, which are the larger half of their cost. One lookup in CLUST_LEAF_HINT_RETRY (1024) starts the count again, so a scan whose order becomes correlated only later recovers, and the trial that this begins refreshes the slots as it goes. The run is short because a hit saves little where the pages above the leaf are resident: one buffer pool access and one page-local search for each level. Measured against the same tree built without the hints, at 16k with a resident working set and no adaptive hash index, a wholly correlated scan runs a quarter faster over half the page accesses, a scan that answers three lookups in five runs level with it over 30% fewer, one whose locality appears only half way through runs an eighth faster over a quarter fewer, and a scan that answers nothing stays within the noise. A run of 8 is what keeps that last one there. A clustered index small enough to stay in cache is the exception that the run does not catch: it answers often, so the count never builds, and it saves nothing, because the descent that a hit replaces costs almost nothing there. Such a scan pays about a tenth. Where the adaptive hash index is enabled, the hints are neither used nor collected: its guess solves the same problem better, landing on the record with no page-local search and no page access to charge. It is off by default, so the hints are active in a default configuration. innodb.non_covering_sec_idx_scan measures pages_accessed over key orders that differ in how closely the secondary order tracks the clustered one, and eight further tables check query results over the record formats and key shapes that a clustered-index lookup has to read, down to the metadata pseudo-record of instant ALTER TABLE, to leaves that split and merge while a locking read walks them, and to a record that a remembered leaf supplies for a scan that must then rebuild an older version of it. non_covering_sec_idx_scan_debug runs the same body with the hints turned off, through a debug switch that returns before a lookup tests or refreshes the slots, so a diff of the two .result files is what the hints save: 2006 to 1028 (2-level clustered index), 2291 to 1007 (3-level), 20020 to 15780 (decorrelated), 4006 to 2012 (two interleaved key ranges), 20020 to 20006 (shuffled), 12016 to 9289 (locality in the second half alone) and 20020 to 10042 for a covering scan that FOR UPDATE makes non-covering, where the same scan without FOR UPDATE costs 20 in both files. The locality count pins the retry: without it the scan would keep only the 82 hits it makes before it gives up, and the count would be 11934. main.rowid_filter_innodb: 90 to 81, and its ahi combination unchanged.
iMineLink
force-pushed
the
11.8-MDEV-32286
branch
from
September 2, 2026 16:53
02da3ac to
b8c880c
Compare
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.
Row_sel_get_clust_rec_for_mysql::operator() descends the clustered B-tree
from the root for every row whose clustered-index record a secondary-index
scan must read, although consecutive rows often land on the same clustered
leaf page. A non-covering scan needs one for every row, a locking read needs
one whatever the secondary index holds, because an exclusive select lock
type makes ha_innobase::build_template() build its template against the
clustered index, and a covering scan needs one for every row of a secondary
leaf whose PAGE_MAX_TRX_ID its read view cannot see. ANALYZE FORMAT=JSON
charges each descent its full height, and those descents are nearly the
whole cost: the secondary index is charged its own descent and one page for
each further leaf, and nothing per row, because the position that its cursor
holds between two rows is restored optimistically, which latches the leaf
again without counting an access. The range estimate that
ha_innobase::records_in_range() makes for the optimizer is charged over the
same leaves, once more. So pages_accessed is the row count times the height
of the clustered index, plus that handful. 1000 rows over a 2-level
clustered index cost 2006, of which 3 are the scan of the secondary index
and 3 the range estimate, and 750 rows over a 3-level one cost 2291, of
which 27 and 14, where a full table scan of the same data costs 23 and 110.
Remember, in the new row_prebuilt_t::clust_leaf_hint, the
CLUST_LEAF_HINT_SLOTS (4) clustered leaves that the lookups of this
statement reached, most recently used first. Each slot names one leaf: its
page number, copies of its first and last user record truncated to the key
fields, which bound the key range the leaf held when it was remembered, and
the rec_get_offsets() of both. The copies are needed because the page is
unlatched between two lookups, and the offsets spare a lookup the parsing of
them. Several slots serve the scans that alternate between a few leaves,
which one slot cannot serve at all, and a descent refreshes the slot of a
leaf that is remembered already rather than spend a second one on the same
page. A slot owns its key buffers and grows them only when a longer key
arrives, so a row allocates nothing. The used-slot count and the miss
counter are reset per statement in ha_innobase::reset(), matching
autoinc_last_value.
A lookup first compares its key against the remembered ranges, so an
uncorrelated scan settles its misses in memory, with no buffer pool access
and no pages_accessed. Only a covering range is probed, through the new
btr_cur_t::try_leaf_hint(), which acquires the page with buf_page_try_get():
a hint is never derived from a latched parent page, so by the time it is
tried it may precede the caller's already-latched secondary-index leaf in
the latching order, where a blocking wait can deadlock. A stale range costs
a wasted probe or a needless descent, never a wrong result, because the
checks that try_leaf_hint() makes on the latched page remain the sole
authority, and the ranges therefore need no invalidation protocol.
After CLUST_LEAF_HINT_MAX_MISSES (8) consecutive unanswered lookups, a scan
gives the slots up: row_sel_clust_leaf_hint_armed() stops both the test of
the slots and the copies that refresh them, which are the larger half of
their cost. One lookup in CLUST_LEAF_HINT_RETRY (1024) starts the count
again, so a scan whose order becomes correlated only later recovers, and the
trial that this begins refreshes the slots as it goes.
The run is short because a hit saves little where the pages above the leaf
are resident: one buffer pool access and one page-local search for each
level. Measured against the same tree built without the hints, at 16k with a
resident working set and no adaptive hash index, a wholly correlated scan
runs a quarter faster over half the page accesses, a scan that answers three
lookups in five runs level with it over 30% fewer, one whose locality
appears only half way through runs an eighth faster over a quarter fewer,
and a scan that answers nothing stays within the noise. A run of 8 is what
keeps that last one there. A clustered index small enough to stay in cache
is the exception that the run does not catch: it answers often, so the count
never builds, and it saves nothing, because the descent that a hit replaces
costs almost nothing there. Such a scan pays about a tenth.
Where the adaptive hash index is enabled, the hints are neither used nor
collected: its guess solves the same problem better, landing on the record
with no page-local search and no page access to charge. It is off by
default, so the hints are active in a default configuration.
innodb.non_covering_sec_idx_scan measures pages_accessed over key orders
that differ in how closely the secondary order tracks the clustered one, and
eight further tables check query results over the record formats and key
shapes that a clustered-index lookup has to read, down to the metadata
pseudo-record of instant ALTER TABLE, to leaves that split and merge while a
locking read walks them, and to a record that a remembered leaf supplies for
a scan that must then rebuild an older version of it.
non_covering_sec_idx_scan_debug runs the same body with the hints turned
off, through a debug switch that returns before a lookup tests or refreshes
the slots, so a diff of the two .result files is what the hints save: 2006
to 1028 (2-level clustered index), 2291 to 1007 (3-level), 20020 to 15780
(decorrelated), 4006 to 2012 (two interleaved key ranges), 20020 to 20006
(shuffled), 12016 to 9289 (locality in the second half alone) and 20020 to
10042 for a covering scan that FOR UPDATE makes non-covering, where the same
scan without FOR UPDATE costs 20 in both files. The locality count pins the
retry: without it the scan would keep only the 82 hits it makes before it
gives up, and the count would be 11934.
main.rowid_filter_innodb: 90 to 81, and its ahi combination unchanged.