MDEV-32286 Reuse remembered clustered leaves in secondary-index scans - #5625
MDEV-32286 Reuse remembered clustered leaves in secondary-index scans#5625iMineLink wants to merge 1 commit into
Conversation
|
|
02da3ac to
b8c880c
Compare
Thirunarayanan
left a comment
There was a problem hiding this comment.
row_sel_clust_leaf_hint_covers() discards a slot whose n_core_fields no
longer matches the index, because a delete that empties a single-page table
invokes dict_index_t::clear_instant_alter(). Are we have any test case for it?
| and rec_copy_prefix_to_buf() can move a copy when it grows its buffer. | ||
| prebuilt->heap is passed for a growth that the sizing above rules out, | ||
| so that an array which did grow would still outlive the statement. */ | ||
| hint.first= rec_copy_prefix_to_buf(first, index, n_fields, &hint.first_buf, |
There was a problem hiding this comment.
we already going through per-field offsets in rec_copy_prefix_to_buf(). Again we do the same in rec_get_offsets()
There was a problem hiding this comment.
True, but it seems not easy to extract the offsets computed in rec_copy_prefix_to_buf() covering all handled cases. This may belong to a future optimization which requires some refactoring, if row_sel_clust_leaf_hint_remember() becomes hot in some workloads.
| authority. It therefore needs no invalidation protocol and no | ||
| modify_clock guard. */ | ||
| struct clust_leaf_hint_t { | ||
| const rec_t* first; /*!< copy of the leaf's first user |
There was a problem hiding this comment.
Use parallel array? first[4], last[4] etc... Because covers[] won't touch first_buf, first_buf_size, last_buf, last_buf_size at all. Just an idea though
There was a problem hiding this comment.
I think, especially after the suggested resize of the clust_leaf_hint_t struct (to be renamed to remove the _t), that the fields accessed in row_sel_clust_leaf_hint_covers() are already packed enough, and the current form makes row_sel_clust_leaf_hint_move() easier, since the struct holds all one slot's fields. Again, this can be refactored however if it ever shows hot later. I still have to address the "MRU variable" comment though, but I don't think it'll change my answer here.
0fad331 to
386d928
Compare
|
I addressed all previous review comments (thanks for the detail of those). |
There was a problem hiding this comment.
🟡 Changes recommended
The debug wrapper can leak its global DBUG setting when the sourced test skips.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds statement-scoped clustered-leaf hints to reduce repeated B-tree descents during secondary-index scans.
Changes:
- Implements leaf-hint caching, validation, retry, and eviction.
- Integrates lifecycle management and adaptive-hash-index handling.
- Adds extensive correctness and performance regression coverage.
File summaries
| File | Description |
|---|---|
storage/innobase/row/row0sel.cc |
Implements hint policy and lookup integration. |
storage/innobase/row/row0mysql.cc |
Frees retained key buffers. |
storage/innobase/include/row0mysql.h |
Defines hint storage and counters. |
storage/innobase/include/btr0cur.h |
Declares direct leaf probing. |
storage/innobase/handler/ha_innodb.cc |
Resets hints per statement. |
storage/innobase/btr/btr0cur.cc |
Implements validated nonblocking leaf probes. |
mysql-test/suite/innodb/t/clust_leaf_hint.test |
Tests performance and correctness scenarios. |
mysql-test/suite/innodb/t/clust_leaf_hint_off_debug.test |
Provides an unhinted debug comparison. |
mysql-test/suite/innodb/t/clust_leaf_hint_instant_alter.test |
Tests stale instant-ALTER metadata handling. |
mysql-test/suite/innodb/r/clust_leaf_hint.result |
Records hinted results. |
mysql-test/suite/innodb/r/clust_leaf_hint_off_debug.result |
Records unhinted results. |
mysql-test/suite/innodb/r/clust_leaf_hint_instant_alter.result |
Records instant-ALTER results. |
mysql-test/main/rowid_filter_innodb.result |
Updates page-access expectations. |
mysql-test/main/rowid_filter_innodb,ahi.rdiff |
Updates AHI result differences. |
Review details
- Files reviewed: 14/14 changed files
- Comments generated: 1
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
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 land on the same clustered leaf page wherever the secondary order tracks the clustered one. 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. So pages_accessed is the row count times the height of the clustered index, plus a handful: 1000 rows over a 2-level clustered index cost 2006 and 750 rows over a 3-level one cost 2291, where a full table scan of the same data costs 23 and 110. Let a handle remember the clustered leaves that the lookups of one statement reached, and let the next lookup try them before it descends again. The reasoning behind each value and each rejection is in the comments beside it. row0mysql.h defines clust_leaf_hint_slot, which names one leaf: its page number, copies of its first and last user record truncated to the key fields, which bound the key range that the leaf held when it was remembered, the rec_get_offsets() of both, and the dict_index_t::n_core_fields that the copies were made under. A slot of a leaf that had no right sibling names no last key, because every key above the last record of the rightmost leaf still belongs to it. CLUST_LEAF_HINT_SLOTS (4) slots hang off the new row_prebuilt_t::clust_leaf_hint, beside clust_leaf_hint_mru, the most recently used order held as slot numbers, and clust_leaf_hint_n and clust_leaf_hint_miss, the used-slot count and the miss counter. row0sel.cc holds the policy. row_sel_clust_leaf_hint_covers() compares a key against the remembered ranges, so a lookup that no slot can answer costs no buffer pool access and no pages_accessed. row_sel_clust_leaf_hint_search() probes the first slot that covers the key and moves it to the front of the order. row_sel_clust_leaf_hint_remember() records the leaf that a descent landed on, and refreshes the slot of a leaf that is remembered already rather than spend a second one on the same page. Two descents fill no slot: the first CLUST_LEAF_HINT_MIN_LOOKUPS (4) lookups of a statement, and a leaf that is the root. row_sel_clust_leaf_hint_armed() stands a scan down once the slots stop paying for themselves: a miss adds CLUST_LEAF_HINT_MISS_WEIGHT (2) to the miss counter and a hit takes one away, so a scan gives the slots up where it answers too little of its lookups to pay for them, CLUST_LEAF_HINT_MAX_MISSES (8) misses with no hit between them still reach the threshold, and one lookup in CLUST_LEAF_HINT_RETRY (1024) starts the count again, so a scan whose order becomes correlated only later recovers. Both halves of the cost stop there, the test of the slots and the copies that refresh them. Row_sel_get_clust_rec_for_mysql::operator() calls all of this in place of its btr_pcur_open_with_no_init(), and only where the adaptive hash index is disabled, whose guess solves the same problem better: it lands on the record with no page-local search and no page access to charge. That index is off by default, so the hints are active in a default configuration. btr0cur.h and btr0cur.cc add btr_cur_t::try_leaf_hint(), a PAGE_CUR_LE, BTR_SEARCH_LEAF search on one named leaf. It 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 can precede the caller's already-latched secondary-index leaf in the latching order, where a blocking wait can deadlock. It then rejects the page unless the checks that it makes on the latched frame put the match on it. Those checks are the sole authority on the result, so a stale range costs a wasted probe or a needless descent, never a wrong result, and the ranges need no invalidation protocol. ha_innodb.cc: ha_innobase::reset() zeroes the used-slot count and the miss counter per statement, matching autoinc_last_value. row0mysql.cc: row_prebuilt_free() frees the key buffers that the slots own. innodb.clust_leaf_hint 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. Two of the tables scan a covering index, which reads a clustered record under an exclusive select lock type and under a PAGE_MAX_TRX_ID that the read view cannot see. clust_leaf_hint_off_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 1031 (2-level clustered index), 2291 to 1011 (3-level), 4006 to 2015 (two interleaved key ranges), 12016 to 9078 (locality in the second half alone) and 20020 to 10045 for a covering scan that FOR UPDATE makes non-covering, where the same scan without FOR UPDATE costs 20 in both files. Two orders with too little locality to pay for the slots give them up early and end within a hundred accesses of the unhinted count: 20020 to 19966 (decorrelated) and 20020 to 19999 (shuffled). innodb.clust_leaf_hint_instant_alter covers the one rejection that no count reaches, of a slot whose keys were copied under another dict_index_t::n_core_fields than the index reports. dict_index_t::clear_instant_alter() is the only writer of that value that a shared metadata lock allows, and it needs the clustered index to lose the last user record of its root page, while no leaf that is the root fills a slot, so the tree has to shrink between the two, which purge does there. The reader therefore reads uncommitted rows at READ UNCOMMITTED and waits in a stored function while a rollback and purge take them away, and one row that arrives above the position it stopped at is the lookup that tests the slots. The rejection leaves nothing that a query can read, so that branch writes the two counts to the error log under a debug switch and the case reads them back with search_pattern_in_file.inc. They are printed and not named in the pattern, so that a run which reaches the branch with other counts, or in the direction where the clear lowers them, is a difference to look at and not a pass. main.rowid_filter_innodb: 90 to 88, and its ahi combination unchanged.
386d928 to
aa7f72c
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 land on the same clustered leaf
page wherever the secondary order tracks the clustered one. 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. So pages_accessed is the row
count times the height of the clustered index, plus a handful: 1000 rows
over a 2-level clustered index cost 2006 and 750 rows over a 3-level one
cost 2291, where a full table scan of the same data costs 23 and 110.
Let a handle remember the clustered leaves that the lookups of one
statement reached, and let the next lookup try them before it descends
again. The reasoning behind each value and each rejection is in the
comments beside it.
row0mysql.h defines clust_leaf_hint_slot, which names one leaf: its page
number, copies of its first and last user record truncated to the key
fields, which bound the key range that the leaf held when it was
remembered, the rec_get_offsets() of both, and the
dict_index_t::n_core_fields that the copies were made under. A slot of a
leaf that had no right sibling names no last key, because every key above
the last record of the rightmost leaf still belongs to it.
CLUST_LEAF_HINT_SLOTS (4) slots hang off the new
row_prebuilt_t::clust_leaf_hint, beside clust_leaf_hint_mru, the most
recently used order held as slot numbers, and clust_leaf_hint_n and
clust_leaf_hint_miss, the used-slot count and the miss counter.
row0sel.cc holds the policy. row_sel_clust_leaf_hint_covers() compares a
key against the remembered ranges, so a lookup that no slot can answer
costs no buffer pool access and no pages_accessed.
row_sel_clust_leaf_hint_search() probes the first slot that covers the key
and moves it to the front of the order.
row_sel_clust_leaf_hint_remember() records the leaf that a descent landed
on, and refreshes the slot of a leaf that is remembered already rather
than spend a second one on the same page. Two descents fill no slot: the
first CLUST_LEAF_HINT_MIN_LOOKUPS (4) lookups of a statement, and a leaf
that is the root. row_sel_clust_leaf_hint_armed() stands a scan down once
the slots stop paying for themselves: a miss adds
CLUST_LEAF_HINT_MISS_WEIGHT (2) to the miss counter and a hit takes one
away, so a scan gives the slots up where it answers too little of its
lookups to pay for them, CLUST_LEAF_HINT_MAX_MISSES (8) misses with no hit
between them still reach the threshold, and one lookup in
CLUST_LEAF_HINT_RETRY (1024) starts the count again, so a scan whose order
becomes correlated only later recovers. Both halves of the cost stop
there, the test of the slots and the copies that refresh them.
Row_sel_get_clust_rec_for_mysql::operator() calls all of this in place of
its btr_pcur_open_with_no_init(), and only where the adaptive hash index
is disabled, whose guess solves the same problem better: it lands on the
record with no page-local search and no page access to charge. That index
is off by default, so the hints are active in a default configuration.
btr0cur.h and btr0cur.cc add btr_cur_t::try_leaf_hint(), a PAGE_CUR_LE,
BTR_SEARCH_LEAF search on one named leaf. It 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 can precede the caller's already-latched
secondary-index leaf in the latching order, where a blocking wait can
deadlock. It then rejects the page unless the checks that it makes on the
latched frame put the match on it. Those checks are the sole authority on
the result, so a stale range costs a wasted probe or a needless descent,
never a wrong result, and the ranges need no invalidation protocol.
ha_innodb.cc: ha_innobase::reset() zeroes the used-slot count and the miss
counter per statement, matching autoinc_last_value. row0mysql.cc:
row_prebuilt_free() frees the key buffers that the slots own.
innodb.clust_leaf_hint 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. Two of the
tables scan a covering index, which reads a clustered record under an
exclusive select lock type and under a PAGE_MAX_TRX_ID that the read view
cannot see. clust_leaf_hint_off_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 1031 (2-level clustered index), 2291 to 1011 (3-level), 4006
to 2015 (two interleaved key ranges), 12016 to 9078 (locality in the
second half alone) and 20020 to 10045 for a covering scan that FOR UPDATE
makes non-covering, where the same scan without FOR UPDATE costs 20 in
both files. Two orders with too little locality to pay for the slots give
them up early and end within a hundred accesses of the unhinted count:
20020 to 19966 (decorrelated) and 20020 to 19999 (shuffled).
innodb.clust_leaf_hint_instant_alter covers the one rejection that no
count reaches, of a slot whose keys were copied under another
dict_index_t::n_core_fields than the index reports.
dict_index_t::clear_instant_alter() is the only writer of that value that
a shared metadata lock allows, and it needs the clustered index to lose
the last user record of its root page, while no leaf that is the root
fills a slot, so the tree has to shrink between the two, which purge does
there. The reader therefore reads uncommitted rows at READ UNCOMMITTED and
waits in a stored function while a rollback and purge take them away, and
one row that arrives above the position it stopped at is the lookup that
tests the slots. The rejection leaves nothing that a query can read, so
that branch writes the two counts to the error log under a debug switch
and the case reads them back with search_pattern_in_file.inc. They are
printed and not named in the pattern, so that a run which reaches the
branch with other counts, or in the direction where the clear lowers them,
is a difference to look at and not a pass.
main.rowid_filter_innodb: 90 to 88, and its ahi combination unchanged.