Skip to content

MDEV-32286 ANALYZE displays a huge number of InnoDB secondary index pages_accessed - #5362

Closed
iMineLink wants to merge 1 commit into
MariaDB:10.11from
iMineLink:MDEV-32286
Closed

MDEV-32286 ANALYZE displays a huge number of InnoDB secondary index pages_accessed#5362
iMineLink wants to merge 1 commit into
MariaDB:10.11from
iMineLink:MDEV-32286

Conversation

@iMineLink

@iMineLink iMineLink commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Added two commits, one for reproducing the issue cited in the ticket, and the second one with an optimization that adds a shortcut to directly fetch the clustered index leaf page with an hint (without full traversal) in case there's correlation during a non-covering secondary index scan.
The optimization self-disengages after 2 consecutive failures.
That's the price these kind of queries have to pay in terms of pages_accessed even when they don't benefit from this (maybe due to fully decorrelated secondary-to-clustered index access pattern).

Row_sel_get_clust_rec_for_mysql::operator() descends the clustered B-tree
from the root for every row of a non-covering secondary-index scan, although
consecutive rows often land on the same clustered leaf page. 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. 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 2003, of which 3 are the secondary
index, and 750 rows over a 3-level one cost 2277, 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 30% 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 16% 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.

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
seven 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 and to leaves that split and merge
while a locking read walks them. 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: 2003 to 1025 (2-level clustered index), 2277
to 993 (3-level), 20010 to 15770 (decorrelated), 4003 to 2009 (two
interleaved key ranges), 20010 to 19996 (shuffled) and 12008 to 9281
(locality in the second half alone). The last of those pins the retry:
without it the count is 11926.
main.rowid_filter_innodb: 84 to 72.

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses MDEV-32286 by reducing inflated pages_accessed reporting during non-covering secondary-index scans in InnoDB, introducing a fast-path that reuses the previously accessed clustered-index leaf page when access patterns show correlation.

Changes:

  • Add a clustered-leaf “hint” fast-path (btr_cur_t::try_leaf_hint()) to avoid repeated root-to-leaf descents on correlated clustered lookups.
  • Track and reset per-statement hint state in row_prebuilt_t (remembered leaf page + consecutive miss streak with auto-disable).
  • Add an MTR test suite case for non-covering secondary index scans and update expected outputs affected by the new access pattern.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
storage/innobase/row/row0sel.cc Attempts clustered-leaf hint before full clustered-index descent; remembers last clustered leaf page id.
storage/innobase/include/row0mysql.h Adds per-statement state to row_prebuilt_t for clustered leaf hinting and miss streak tracking.
storage/innobase/include/btr0cur.h Declares btr_cur_t::try_leaf_hint() API for direct hinted leaf probing.
storage/innobase/handler/ha_innodb.cc Resets hint state in ha_innobase::reset() to scope hinting to a single statement.
storage/innobase/btr/btr0cur.cc Implements try_leaf_hint() using non-blocking, no-I/O page acquisition and page-local search.
mysql-test/suite/innodb/t/non_covering_sec_idx_scan.test Adds regression/repro test for pages_accessed behavior across correlated and decorrelated patterns.
mysql-test/suite/innodb/r/non_covering_sec_idx_scan.result Captures expected output for the new test case.
mysql-test/main/rowid_filter_innodb.result Updates expected pages_accessed values affected by the optimization.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread storage/innobase/btr/btr0cur.cc Outdated
Comment thread storage/innobase/btr/btr0cur.cc Outdated
Comment thread mysql-test/suite/innodb/t/non_covering_sec_idx_scan.test Outdated
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Comment thread storage/innobase/btr/btr0cur.cc
Comment thread storage/innobase/include/row0mysql.h Outdated
Comment thread storage/innobase/row/row0sel.cc Outdated
Comment thread storage/innobase/row/row0sel.cc Outdated
Comment thread storage/innobase/row/row0sel.cc Outdated
@iMineLink
iMineLink force-pushed the MDEV-32286 branch 2 times, most recently from 4f30143 to c1f5659 Compare July 15, 2026 08:14
@iMineLink

Copy link
Copy Markdown
Contributor Author

I reordered and adjusted the datatypes of the new row_prebuilt_t members.
Now they fit in pre-existing holes in the 3rd cacheline as verified with pahole:

### Before

$ pahole -C row_prebuilt_t build/RelWithDebInfo/storage/innobase/CMakeFiles/innobase.dir/row/row0sel.cc.o

...

struct btr_pcur_t *        pcur;                 /*   120     8 */
/* --- cacheline 2 boundary (128 bytes) --- */
struct btr_pcur_t *        clust_pcur;           /*   128     8 */
struct que_fork_t *        sel_graph;            /*   136     8 */
struct dtuple_t *          search_tuple;         /*   144     8 */
byte                       row_id[6];            /*   152     6 */

/* XXX 2 bytes hole, try to pack */

doc_id_t                   fts_doc_id;           /*   160     8 */
struct dtuple_t *          clust_ref;            /*   168     8 */
enum lock_mode             select_lock_type;     /*   176     4 */
bool                       skip_locked;          /*   180     1 */

/* XXX 3 bytes hole, try to pack */

enum lock_mode             stored_select_lock_type; /*   184     4 */

/* XXX 4 bytes hole, try to pack */

/* --- cacheline 3 boundary (192 bytes) --- */
ulint                      row_read_type;        /*   192     8 */

...

/* size: 456, cachelines: 8, members: 62 */
/* sum members: 418, holes: 8, sum holes: 34 */
/* sum bitfield members: 30 bits, bit holes: 1, sum bit holes: 2 bits */
/* last cacheline: 8 bytes */
### After

...

struct btr_pcur_t *        pcur;                 /*   120     8 */
/* --- cacheline 2 boundary (128 bytes) --- */
struct btr_pcur_t *        clust_pcur;           /*   128     8 */
struct que_fork_t *        sel_graph;            /*   136     8 */
struct dtuple_t *          search_tuple;         /*   144     8 */
byte                       row_id[6];            /*   152     6 */

/* XXX 2 bytes hole, try to pack */

doc_id_t                   fts_doc_id;           /*   160     8 */
struct dtuple_t *          clust_ref;            /*   168     8 */
enum lock_mode             select_lock_type;     /*   176     4 */
bool                       skip_locked;          /*   180     1 */
uint8_t                    clust_leaf_hint_miss_streak; /*   181     1 */

/* XXX 2 bytes hole, try to pack */

uint32_t                   clust_leaf_hint_page_no; /*   184     4 */
enum lock_mode             stored_select_lock_type; /*   188     4 */
/* --- cacheline 3 boundary (192 bytes) --- */
ulint                      row_read_type;        /*   192     8 */

...

/* size: 456, cachelines: 8, members: 64 */
/* sum members: 423, holes: 7, sum holes: 29 */
/* sum bitfield members: 30 bits, bit holes: 1, sum bit holes: 2 bits */
/* last cacheline: 8 bytes */

On a side note, it could be possible to reorder the fields in the struct to optimize its layout.

@iMineLink
iMineLink requested a review from Thirunarayanan July 15, 2026 08:19

@Thirunarayanan Thirunarayanan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current limitation of this patch is that single page clustered index leaf hint.
Correlated scans: Works well(i.e. Target leaf advances monotonically, so the
single slot walks the tree)

Interleaved scans: consecutive rows bounce between a small set of hot leaves
Uncorrelated scans: Each secondary rows scan a random leaf. Basically
every attempt misses.

Can we cache the leaf's key range, not just its page number? Basically,
store {min_rec_key, max_rec_key, page_no} from the remembered leaf.
Before fetching the hint page, we could test min_rec_key <= clust_ref <= max_rec_key
with in-memory key comparison. How about having std::set<{min_rec_key, max_rec_key}, page_no>? Just an idea.

Comment thread storage/innobase/include/row0mysql.h Outdated
@iMineLink

Copy link
Copy Markdown
Contributor Author

I implemented the key-caching variant similarly to what suggested by @Thirunarayanan, keeping an MRU cache with size 4 and a dedicated re-arm logic. This should prevent buffer pool access on miss (unless the cache is stale).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

goto search_loop;
}

bool btr_cur_t::try_leaf_hint(const dtuple_t *tuple, page_id_t hint_page_id,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

try_leaf_hint() holds the s-latch on leaf alone. Any caller of this function doesn't rely on tree latch being held? IIUC, descend logic do take tree latch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, btr_cur_t::serach_leaf() takes the tree latch to descent (with no BTR_ALREADY_S_LATCHED, the case here): when returning though only the S-latch on the leaf is held, same condition that btr_cur_t::try_leaf_hint() leaves to the caller.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#5625 now mentions this in a comment.

&& row_sel_clust_leaf_hint_armed(prebuilt);

if (hints_armed
&& row_sel_clust_leaf_hint_search(prebuilt, clust_index, mtr)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cursor->tree_height could be stale if we jump into this leaf_hint_search. Will it have any consequence ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think not: the critical use readers do is for sizing fsp_reserve_free_extents(), and they refresh their tree height by re-descending the tree with BTR_MODIFY_TREE latch mode.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#5625 now mentions this in a comment.

is enabled, the hints stay out of its way entirely, neither used nor
collected. */
#ifdef BTR_CUR_HASH_ADAPT
const bool use_hints = !btr_search_enabled;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if enable AHI in the middle of the query?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable is re-read at every clustered leaf lookup. Therefore, next lookup will disable hints (they will age out at the next ha_innobase::reset() at the end of the statement) and start possibly using AHI if pattern permits. If AHI is disabled in middle of query, the hints will start get used (again) likely needing refresh before being effective. The test is coarse: it can be I think that AHI is enabled, but not usable on the index for some reasons (one could be MDEV-37070 in newer versions), still we disable hints; I guess it's an acceptable tradeoff, since both are heuristics and do not affect correctness.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#5625 now mentions this in a comment.

Comment thread storage/innobase/row/row0sel.cc Outdated
Row_sel_get_clust_rec_for_mysql::operator() descends the clustered B-tree
from the root for every row of a non-covering secondary-index scan, although
consecutive rows often land on the same clustered leaf page. 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. 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 2003, of which 3 are the secondary
index, and 750 rows over a 3-level one cost 2277, 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 30% 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 16% 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.

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
seven 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 and to leaves that split and merge
while a locking read walks them. 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: 2003 to 1025 (2-level clustered index), 2277
to 993 (3-level), 20010 to 15770 (decorrelated), 4003 to 2009 (two
interleaved key ranges), 20010 to 19996 (shuffled) and 12008 to 9281
(locality in the second half alone). The last of those pins the retry:
without it the count is 11926.
main.rowid_filter_innodb: 84 to 72.
@iMineLink

Copy link
Copy Markdown
Contributor Author

Superseded by #5625.

@iMineLink iMineLink closed this Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

4 participants