From 62725e2de754199a14d269851f5513e1918342a1 Mon Sep 17 00:00:00 2001 From: Alessandro Vetere Date: Wed, 8 Jul 2026 12:58:18 +0200 Subject: [PATCH 1/2] MDEV-32286: add baseline repro for secondary-index pages_accessed inflation A full scan of t1 reports pages_accessed=23; a secondary-index scan touching 1,000 rows sharing one key reports 2003, the figure quoted in the ticket (selecting a column outside the secondary index forces a clustered-index lookup per row). t2 (3-level clustered index) and t3 (secondary index decorrelated from clustered key order) record the baselines 15165 and 20010 for the same shape. The .result records raw pages_accessed values, so a .opt pins every server option they depend on: page size (16k), row format, buffer pool size, and the adaptive hash index. --- .../innodb/r/non_covering_sec_idx_scan.result | 73 ++++++++++++++ .../innodb/t/non_covering_sec_idx_scan.opt | 4 + .../innodb/t/non_covering_sec_idx_scan.test | 94 +++++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 mysql-test/suite/innodb/r/non_covering_sec_idx_scan.result create mode 100644 mysql-test/suite/innodb/t/non_covering_sec_idx_scan.opt create mode 100644 mysql-test/suite/innodb/t/non_covering_sec_idx_scan.test diff --git a/mysql-test/suite/innodb/r/non_covering_sec_idx_scan.result b/mysql-test/suite/innodb/r/non_covering_sec_idx_scan.result new file mode 100644 index 0000000000000..86b7014c309fd --- /dev/null +++ b/mysql-test/suite/innodb/r/non_covering_sec_idx_scan.result @@ -0,0 +1,73 @@ +create table t1 ( +pk int not null primary key, +domain_grp int, +val int +) engine=innodb; +insert into t1 select seq, mod(seq,10), seq from seq_1_to_10000; +create index domain_idx on t1(domain_grp); +analyze table t1 persistent for all; +Table Op Msg_type Msg_text +test.t1 analyze status Engine-independent statistics collected +test.t1 analyze status OK +set @js='$out_scan'; +set @out=(select json_extract(@js,'$**.r_engine_stats.pages_accessed')); +select cast(json_extract(@out,'$[0]') as DOUBLE) as PAGES_ACCESSED_FULL_SCAN; +PAGES_ACCESSED_FULL_SCAN +23 +set @js='$out_idx'; +set @out=(select json_extract(@js,'$**.r_engine_stats.pages_accessed')); +select cast(json_extract(@out,'$[0]') as DOUBLE) as PAGES_ACCESSED_SEC_INDEX; +PAGES_ACCESSED_SEC_INDEX +2003 +drop table t1; +create table t2 ( +pk varchar(500) not null primary key, +domain_grp int, +val int +) engine=innodb; +insert into t2 select lpad(seq,500,'0'), mod(seq,4), seq from seq_1_to_20000; +create index domain_idx on t2(domain_grp); +analyze table t2 persistent for all; +Table Op Msg_type Msg_text +test.t2 analyze status Engine-independent statistics collected +test.t2 analyze status OK +select stat_value into @size from mysql.innodb_index_stats +where database_name='test' and table_name='t2' and index_name='PRIMARY' + and stat_name='size'; +select stat_value into @leaf from mysql.innodb_index_stats +where database_name='test' and table_name='t2' and index_name='PRIMARY' + and stat_name='n_leaf_pages'; +select @size as TOTAL_PAGES, @leaf as LEAF_PAGES, (@size - @leaf) as NON_LEAF_PAGES; +TOTAL_PAGES LEAF_PAGES NON_LEAF_PAGES +761 715 46 +select (@size - @leaf) > 1 as THREE_LEVEL_TREE; +THREE_LEVEL_TREE +1 +set @js='$out_scan2'; +set @out=(select json_extract(@js,'$**.r_engine_stats.pages_accessed')); +select cast(json_extract(@out,'$[0]') as DOUBLE) as PAGES_ACCESSED_FULL_SCAN_3LEVEL; +PAGES_ACCESSED_FULL_SCAN_3LEVEL +717 +set @js='$out_idx2'; +set @out=(select json_extract(@js,'$**.r_engine_stats.pages_accessed')); +select cast(json_extract(@out,'$[0]') as DOUBLE) as PAGES_ACCESSED_SEC_INDEX_3LEVEL; +PAGES_ACCESSED_SEC_INDEX_3LEVEL +15165 +drop table t2; +create table t3 ( +pk int not null primary key, +k int, +val int +) engine=innodb; +insert into t3 select seq, mod(seq*997+13,10007), seq from seq_1_to_10000; +create index k_idx on t3(k); +analyze table t3 persistent for all; +Table Op Msg_type Msg_text +test.t3 analyze status Engine-independent statistics collected +test.t3 analyze status OK +set @js='$out_uncorr'; +set @out=(select json_extract(@js,'$**.r_engine_stats.pages_accessed')); +select cast(json_extract(@out,'$[0]') as DOUBLE) as PAGES_ACCESSED_UNCORRELATED; +PAGES_ACCESSED_UNCORRELATED +20010 +drop table t3; diff --git a/mysql-test/suite/innodb/t/non_covering_sec_idx_scan.opt b/mysql-test/suite/innodb/t/non_covering_sec_idx_scan.opt new file mode 100644 index 0000000000000..e0c9d42f83a2d --- /dev/null +++ b/mysql-test/suite/innodb/t/non_covering_sec_idx_scan.opt @@ -0,0 +1,4 @@ +--innodb-page-size=16k +--innodb-buffer-pool-size=64M +--innodb-default-row-format=dynamic +--loose-innodb-adaptive-hash-index=0 diff --git a/mysql-test/suite/innodb/t/non_covering_sec_idx_scan.test b/mysql-test/suite/innodb/t/non_covering_sec_idx_scan.test new file mode 100644 index 0000000000000..5afae7fba7875 --- /dev/null +++ b/mysql-test/suite/innodb/t/non_covering_sec_idx_scan.test @@ -0,0 +1,94 @@ +# +# MDEV-32286 ANALYZE displays a huge number of InnoDB secondary index pages_accessed +# +# The .result records raw pages_accessed values, so the .opt pins every +# server option they depend on: page size and row format (tree shape), +# buffer pool size (the whole working set must stay resident, so that no +# access path depends on eviction timing), and the adaptive hash index +# (its hash guesses skip descent pages). +--source include/have_innodb.inc +--source include/have_sequence.inc + +create table t1 ( + pk int not null primary key, + domain_grp int, + val int +) engine=innodb; + +insert into t1 select seq, mod(seq,10), seq from seq_1_to_10000; + +create index domain_idx on t1(domain_grp); + +analyze table t1 persistent for all; + +# "val" is outside domain_idx, so each matching row needs a clustered-index +# lookup either way. +let $out_scan=`analyze format=json select sql_no_cache val from t1 ignore index(domain_idx) where domain_grp=3`; +evalp set @js='$out_scan'; +set @out=(select json_extract(@js,'$**.r_engine_stats.pages_accessed')); +select cast(json_extract(@out,'$[0]') as DOUBLE) as PAGES_ACCESSED_FULL_SCAN; + +# ~1000 rows share key 3 +let $out_idx=`analyze format=json select sql_no_cache val from t1 force index(domain_idx) where domain_grp=3`; +evalp set @js='$out_idx'; +set @out=(select json_extract(@js,'$**.r_engine_stats.pages_accessed')); +select cast(json_extract(@out,'$[0]') as DOUBLE) as PAGES_ACCESSED_SEC_INDEX; + +drop table t1; + +# Wide primary key: the clustered index needs a 3-level tree. +create table t2 ( + pk varchar(500) not null primary key, + domain_grp int, + val int +) engine=innodb; + +insert into t2 select lpad(seq,500,'0'), mod(seq,4), seq from seq_1_to_20000; + +create index domain_idx on t2(domain_grp); + +analyze table t2 persistent for all; + +# more than one non-leaf page implies an internal level below the root +select stat_value into @size from mysql.innodb_index_stats + where database_name='test' and table_name='t2' and index_name='PRIMARY' + and stat_name='size'; +select stat_value into @leaf from mysql.innodb_index_stats + where database_name='test' and table_name='t2' and index_name='PRIMARY' + and stat_name='n_leaf_pages'; +select @size as TOTAL_PAGES, @leaf as LEAF_PAGES, (@size - @leaf) as NON_LEAF_PAGES; +select (@size - @leaf) > 1 as THREE_LEVEL_TREE; + +let $out_scan2=`analyze format=json select sql_no_cache val from t2 ignore index(domain_idx) where domain_grp=3`; +evalp set @js='$out_scan2'; +set @out=(select json_extract(@js,'$**.r_engine_stats.pages_accessed')); +select cast(json_extract(@out,'$[0]') as DOUBLE) as PAGES_ACCESSED_FULL_SCAN_3LEVEL; + +# ~5000 rows share key 3 +let $out_idx2=`analyze format=json select sql_no_cache val from t2 force index(domain_idx) where domain_grp=3`; +evalp set @js='$out_idx2'; +set @out=(select json_extract(@js,'$**.r_engine_stats.pages_accessed')); +select cast(json_extract(@out,'$[0]') as DOUBLE) as PAGES_ACCESSED_SEC_INDEX_3LEVEL; + +drop table t2; + +# Secondary index decorrelated from clustered key order: the clustered-leaf +# hint must give up instead of paying a wasted probe on every row. +create table t3 ( + pk int not null primary key, + k int, + val int +) engine=innodb; + +insert into t3 select seq, mod(seq*997+13,10007), seq from seq_1_to_10000; + +create index k_idx on t3(k); + +analyze table t3 persistent for all; + +let $out_uncorr=`analyze format=json select sql_no_cache val from t3 force index(k_idx) where k between 0 and 10006`; +evalp set @js='$out_uncorr'; +set @out=(select json_extract(@js,'$**.r_engine_stats.pages_accessed')); +select cast(json_extract(@out,'$[0]') as DOUBLE) as PAGES_ACCESSED_UNCORRELATED; + +drop table t3; From c1f5659dfeecb1fe57ee07ff8b9d8fc365328bbd Mon Sep 17 00:00:00 2001 From: Alessandro Vetere Date: Wed, 8 Jul 2026 12:58:19 +0200 Subject: [PATCH 2/2] MDEV-32286: skip full clustered-index descent via a remembered leaf hint Row_sel_get_clust_rec_for_mysql::operator() re-descends the clustered B-tree from the root for every row of a non-covering secondary-index scan, although consecutive rows often share the same clustered leaf page; ANALYZE FORMAT=JSON exposes this as an inflated pages_accessed. Add btr_cur_t::try_leaf_hint(), which tries the leaf remembered in the new row_prebuilt_t::clust_leaf_hint_page_id before falling back to a full descent. The hint is a guess, never re-derived from a latched parent page, so the page is acquired with buf_page_try_get(): by the time it is tried it may precede the caller's already-latched secondary-index leaf in the B-tree latching order, where a blocking latch wait can deadlock (page latches have no deadlock detection), and BUF_GET would both trip the ignore_unfixed assertion on a freed-but-cached page and do a synchronous read for an evicted one. Freed pages are rejected explicitly: their stale contents would still pass the leaf/index-id validation. A tuple sorting after the last record of a non-rightmost leaf may have its PAGE_CUR_LE match on a later leaf, which cannot be resolved from the hinted page. The page-local search already proves this cheaply: its low_match falling short of n_fields_cmp with the cursor on the last user record means the tuple sorts strictly after every record on the page, so no separate boundary-record comparison is needed. Attempting the hint on every row regresses uncorrelated scans (each miss adds a real, pages_accessed-charged probe on top of the descent it falls back to), so the new row_prebuilt_t::clust_leaf_hint_miss_streak abandons the hint for the rest of the statement after CLUST_LEAF_HINT_MAX_MISSES (2) consecutive misses. The threshold must exceed 1 because a well-correlated scan misses once at every leaf-page boundary. The hint page id and streak are reset per statement in ha_innobase::reset(), matching autoinc_last_value. innodb.non_covering_sec_idx_scan: 2003 to 1046 (2-level clustered index), 15165 to 7309 (3-level, confirming internal levels are skipped too, not just the root), 20010 to 20012 (uncorrelated: only the two misses paid before giving up). rowid_filter_innodb: 84 to 86, the same bounded two-miss cost. --- mysql-test/main/rowid_filter_innodb.result | 4 +- .../innodb/r/non_covering_sec_idx_scan.result | 6 +- storage/innobase/btr/btr0cur.cc | 61 +++++++++++++++++++ storage/innobase/handler/ha_innodb.cc | 4 ++ storage/innobase/include/btr0cur.h | 13 ++++ storage/innobase/include/row0mysql.h | 14 +++++ storage/innobase/row/row0sel.cc | 44 ++++++++++++- 7 files changed, 140 insertions(+), 6 deletions(-) diff --git a/mysql-test/main/rowid_filter_innodb.result b/mysql-test/main/rowid_filter_innodb.result index d42da30fe535d..f1559103cbb39 100644 --- a/mysql-test/main/rowid_filter_innodb.result +++ b/mysql-test/main/rowid_filter_innodb.result @@ -1958,7 +1958,7 @@ ANALYZE "r_table_time_ms": "REPLACED", "r_other_time_ms": "REPLACED", "r_engine_stats": { - "pages_accessed": 84 + "pages_accessed": 86 }, "filtered": "REPLACED", "r_filtered": 2.43902439, @@ -2112,7 +2112,7 @@ ANALYZE "r_table_time_ms": "REPLACED", "r_other_time_ms": "REPLACED", "r_engine_stats": { - "pages_accessed": 84 + "pages_accessed": 86 }, "filtered": "REPLACED", "r_filtered": 2.43902439, diff --git a/mysql-test/suite/innodb/r/non_covering_sec_idx_scan.result b/mysql-test/suite/innodb/r/non_covering_sec_idx_scan.result index 86b7014c309fd..e8e1e33d82789 100644 --- a/mysql-test/suite/innodb/r/non_covering_sec_idx_scan.result +++ b/mysql-test/suite/innodb/r/non_covering_sec_idx_scan.result @@ -18,7 +18,7 @@ set @js='$out_idx'; set @out=(select json_extract(@js,'$**.r_engine_stats.pages_accessed')); select cast(json_extract(@out,'$[0]') as DOUBLE) as PAGES_ACCESSED_SEC_INDEX; PAGES_ACCESSED_SEC_INDEX -2003 +1046 drop table t1; create table t2 ( pk varchar(500) not null primary key, @@ -52,7 +52,7 @@ set @js='$out_idx2'; set @out=(select json_extract(@js,'$**.r_engine_stats.pages_accessed')); select cast(json_extract(@out,'$[0]') as DOUBLE) as PAGES_ACCESSED_SEC_INDEX_3LEVEL; PAGES_ACCESSED_SEC_INDEX_3LEVEL -15165 +7309 drop table t2; create table t3 ( pk int not null primary key, @@ -69,5 +69,5 @@ set @js='$out_uncorr'; set @out=(select json_extract(@js,'$**.r_engine_stats.pages_accessed')); select cast(json_extract(@out,'$[0]') as DOUBLE) as PAGES_ACCESSED_UNCORRELATED; PAGES_ACCESSED_UNCORRELATED -20010 +20012 drop table t3; diff --git a/storage/innobase/btr/btr0cur.cc b/storage/innobase/btr/btr0cur.cc index d7ce6ae996ef9..2f813cd06383b 100644 --- a/storage/innobase/btr/btr0cur.cc +++ b/storage/innobase/btr/btr0cur.cc @@ -1712,6 +1712,67 @@ dberr_t btr_cur_t::search_leaf(const dtuple_t *tuple, page_cur_mode_t mode, goto search_loop; } +bool btr_cur_t::try_leaf_hint(const dtuple_t *tuple, page_id_t hint_page_id, + mtr_t *mtr) +{ + /* hint_page_id was not read from a latched parent page, so it may now + precede the caller's already-latched secondary-index leaf in the + B-tree latching order: never block on its latch (page latches have no + deadlock detection) and never read it from disk. */ + buf_block_t *const block= buf_page_try_get(hint_page_id, mtr); + if (!block) + return false; + + const page_t *const page= block->page.frame; + if (block->page.is_freed() || !fil_page_index_page_check(page) || + !page_is_leaf(page) || + !!page_is_comp(page) != index()->table->not_redundant() || + btr_page_get_index_id(page) != index()->id) + { + /* Stale hint or, for search_leaf()'s own checks, corruption; we cannot + tell here, so fall back either way, and the full descent still reports a + corrupt live leaf. is_freed() is the guard that descent omits: a freed + but unreused page keeps old contents that pass the other checks. */ + mtr->release_last_page(); + return false; + } + + page_cur.block= block; + up_match= 0; + up_bytes= 0; + low_match= 0; + low_bytes= 0; + if (page_cur_search_with_match(tuple, PAGE_CUR_LE, &up_match, &low_match, + &page_cur, nullptr) || + page_rec_is_infimum(page_cur.rec)) + { + /* Corruption, or tuple precedes every record on this page: its + predecessor, if any, is on an earlier leaf. */ + mtr->release_last_page(); + return false; + } + + if (page_has_next(page) && low_match < dtuple_get_n_fields_cmp(tuple)) + { + /* The record found is strictly less than tuple: PAGE_CUR_LE lands on + the greatest record <= tuple, and a full match would have made + low_match == n_fields_cmp. If it is the last user record of a leaf + with a right sibling, the true match may be on a later leaf; we cannot + resolve that from here, so reject the hint. The rightmost leaf needs + no such check: its last record is that match for any larger tuple. */ + const rec_t *const next_rec= page_rec_get_next_const(page_cur.rec); + if (UNIV_UNLIKELY(!next_rec) || page_rec_is_supremum(next_rec)) + { + mtr->release_last_page(); + return false; + } + } + + /* Unlike search_leaf(), this feeds no btr_search_info_update(): a hit + already provides the direct leaf access the adaptive hash index would. */ + return true; +} + ATTRIBUTE_COLD void mtr_t::index_lock_upgrade() { auto &slot= m_memo[get_savepoint() - 1]; diff --git a/storage/innobase/handler/ha_innodb.cc b/storage/innobase/handler/ha_innodb.cc index 9ff636aca2ad6..73a98c59b155f 100644 --- a/storage/innobase/handler/ha_innodb.cc +++ b/storage/innobase/handler/ha_innodb.cc @@ -16037,6 +16037,10 @@ ha_innobase::reset() /* This is a statement level counter. */ m_prebuilt->autoinc_last_value = 0; + /* The clustered leaf hint is scoped to one statement. */ + m_prebuilt->clust_leaf_hint_page_no = 0; + m_prebuilt->clust_leaf_hint_miss_streak = 0; + m_prebuilt->skip_locked = false; return(0); } diff --git a/storage/innobase/include/btr0cur.h b/storage/innobase/include/btr0cur.h index 935be50543677..164c993872640 100644 --- a/storage/innobase/include/btr0cur.h +++ b/storage/innobase/include/btr0cur.h @@ -770,6 +770,19 @@ struct btr_cur_t { @return error code */ inline dberr_t open_random_leaf(rec_offs *&offsets, mem_heap_t *& heap, mtr_t &mtr); + + /** Try a PAGE_CUR_LE, BTR_SEARCH_LEAF lookup directly on a previously + remembered leaf page instead of descending from the root (MDEV-32286). + The hint is a guess, never derived from a latched parent page, so the + page is acquired via a non-blocking, no-I/O buf_page_try_get() and a + miss (stale hint) is expected, not corruption; the caller falls back + to a normal search. + @param tuple key to search for; must use n_fields_cmp fields + @param hint_page_id remembered leaf page id + @param mtr mini-transaction + @return whether the cursor was positioned on the hinted leaf page */ + bool try_leaf_hint(const dtuple_t *tuple, page_id_t hint_page_id, + mtr_t *mtr); }; /** Modify the delete-mark flag of a record. diff --git a/storage/innobase/include/row0mysql.h b/storage/innobase/include/row0mysql.h index 63858f25f023e..4d781bab0e62d 100644 --- a/storage/innobase/include/row0mysql.h +++ b/storage/innobase/include/row0mysql.h @@ -574,6 +574,20 @@ struct row_prebuilt_t { sel/upd/del */ lock_mode select_lock_type;/*!< LOCK_NONE, LOCK_S, or LOCK_X */ bool skip_locked; /*!< TL_{READ,WRITE}_SKIP_LOCKED */ + uint8_t clust_leaf_hint_miss_streak; /*!< Consecutive + btr_cur_t::try_leaf_hint() misses; at + CLUST_LEAF_HINT_MAX_MISSES (row0sel.cc) the hint + is abandoned for the rest of the statement by + zeroing clust_leaf_hint_page_no below. + Reset per statement in ha_innobase::reset(), + matching autoinc_last_value. */ + uint32_t clust_leaf_hint_page_no; /*!< Clustered leaf page + number remembered from the previous + Row_sel_get_clust_rec_for_mysql() lookup in this + statement, for btr_cur_t::try_leaf_hint(); the + tablespace is always the clustered index's own. + 0 (the zero-initialized state; page 0 is the FSP + header, never a leaf) means no hint. */ lock_mode stored_select_lock_type;/*!< this field is used to remember the original select_lock_type that was decided in ha_innodb.cc, diff --git a/storage/innobase/row/row0sel.cc b/storage/innobase/row/row0sel.cc index 663bfd2dbc4dc..054e7041902cf 100644 --- a/storage/innobase/row/row0sel.cc +++ b/storage/innobase/row/row0sel.cc @@ -68,6 +68,12 @@ to que_run_threads: this is to allow canceling runaway queries */ #define SEL_COST_LIMIT 100 +/** Consecutive btr_cur_t::try_leaf_hint() misses at which +Row_sel_get_clust_rec_for_mysql gives up on the hint for the rest of the +statement. Must be > 1: a well-correlated scan misses once at every +leaf-page boundary. */ +#define CLUST_LEAF_HINT_MAX_MISSES 2 + /* Flags for search shortcut */ #define SEL_FOUND 0 #define SEL_EXHAUSTED 1 @@ -3397,9 +3403,45 @@ Row_sel_get_clust_rec_for_mysql::operator()( clust_index = dict_table_get_first_index(sec_index->table); prebuilt->clust_pcur->btr_cur.page_cur.index = clust_index; - dberr_t err = btr_pcur_open_with_no_init(prebuilt->clust_ref, + /* Consecutive rows of a non-covering secondary-index scan often + share a clustered leaf page, so try the remembered leaf before a + full descent; give up once misses show the scan has no correlation + to clustered key order. */ + dberr_t err; + if (prebuilt->clust_leaf_hint_page_no + && prebuilt->clust_pcur->btr_cur.try_leaf_hint( + prebuilt->clust_ref, + page_id_t(clust_index->table->space_id, + prebuilt->clust_leaf_hint_page_no), + mtr)) { + err = DB_SUCCESS; + /* Mirror what btr_pcur_open_with_no_init() below sets for + the same (PAGE_CUR_LE, BTR_SEARCH_LEAF) arguments. */ + prebuilt->clust_pcur->latch_mode + = BTR_LATCH_MODE_WITHOUT_INTENTION(BTR_SEARCH_LEAF); + prebuilt->clust_pcur->search_mode = PAGE_CUR_LE; + prebuilt->clust_pcur->pos_state = BTR_PCUR_IS_POSITIONED; + prebuilt->clust_leaf_hint_miss_streak = 0; + } else { + err = btr_pcur_open_with_no_init(prebuilt->clust_ref, PAGE_CUR_LE, BTR_SEARCH_LEAF, prebuilt->clust_pcur, mtr); + if (prebuilt->clust_leaf_hint_page_no + && ++prebuilt->clust_leaf_hint_miss_streak + >= CLUST_LEAF_HINT_MAX_MISSES) { + /* Give up on the hint for the rest of the + statement. */ + prebuilt->clust_leaf_hint_page_no = 0; + } else if (err == DB_SUCCESS + && prebuilt->clust_leaf_hint_miss_streak + < CLUST_LEAF_HINT_MAX_MISSES) { + /* Remember the leaf this descent landed on, for + the next lookup in this statement. */ + prebuilt->clust_leaf_hint_page_no + = prebuilt->clust_pcur->btr_cur.page_cur + .block->page.id().page_no(); + } + } if (UNIV_UNLIKELY(err != DB_SUCCESS)) { return err; }