Skip to content

PS-10110 (8.4) Fix poor MyRocks query plans caused by 1-row range estimates#6063

Draft
polchawa-percona wants to merge 1 commit into
8.4from
PS-10110-8.4-records-in-range-dive
Draft

PS-10110 (8.4) Fix poor MyRocks query plans caused by 1-row range estimates#6063
polchawa-percona wants to merge 1 commit into
8.4from
PS-10110-8.4-records-in-range-dive

Conversation

@polchawa-percona

@polchawa-percona polchawa-percona commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

https://perconadev.atlassian.net/browse/PS-10110

Problem

MyRocks estimates the number of rows in a key range as index_rows * (GetApproximateSizes(range) / index_disk_size). RocksDB's GetApproximateSizes() works at SST data-block offset granularity and returns 0 when the whole range falls inside a single (~16KB) data block; GetApproximateMemTableStats() has the same flaw for narrow memtable ranges (this is the variant reproduced by Dmitry Lenev in the ticket — records_in_range() estimates go wrong before any data reaches SSTs too). The zero estimate is floored to 1 row, so a selective non-unique range containing hundreds of rows is reported to the optimizer as 1 row, causing poor join orders and large performance regressions vs InnoDB (the customer's 0.00s query took 0.28s).

Fix

Refine small estimates with a bounded index dive, similar to InnoDB index dives: when the approximate estimate is below the new session variable rocksdb_records_in_range_dive_threshold (default 100, 0 disables, SET_VAR-hintable), scan the range with a key-only rocksdb::Iterator reading at most threshold keys:

  • fewer keys than the cap → exact count (also deterministic, which the new MTR test exploits);
  • cap reached → the threshold is used as a lower bound (max(approx, threshold)).

The dive only triggers when the estimate is already pathologically small — i.e. the range lies within 1–2 data blocks — so its cost is bounded and comparable to the GetApproximateSizes() call it refines. Reverse column families and successor-adjusted bounds are handled; partial indexes are skipped (a raw iterator would undercount unmaterialized groups); the existing rocksdb_records_in_range, rocksdb_force_index_records_in_range and rocksdb_debug_optimizer_n_rows overrides keep precedence. An alternative fix inside RocksDB's BlockBasedTable::ApproximateSize() was rejected: it would diverge the vendored RocksDB from upstream for all callers, still could not distinguish 3 rows from 500 within one block, and would not fix the memtable path.

Tests

  • New rocksdb.records_in_range_dive: reproduces the bug shape (200-row group hidden among 20000 rows) on both the memtable-only and SST paths, forward and reverse CFs, and uses a dedicated single-data-block column family where GetApproximateSizes() returns 0 by construction to deterministically demonstrate the legacy 1-row collapse, exact counts, and cap semantics. Verified stable across 5 repeat runs (no FORCE INDEX — that path skips records_in_range() entirely, which is also why the ticket's repro attempts with forced indexes did not hit the customer's stack).
  • New rocksdb_sys_vars.rocksdb_records_in_range_dive_threshold_basic (satisfies rocksdb_sys_vars.all_vars).
  • Re-recorded rocksdb.index_merge_rocksdb (estimates are now the exact key counts: 1→3, 2→4) and rocksdb.rocksdb (new variable in SHOW VARIABLES).
  • Full rocksdb + rocksdb_sys_vars suites run locally: remaining failures (bloomfilter*, drop_table2/3 timeouts, partial_index_stress missing python) reproduce identically with the dive disabled → environmental; add_index_inplace / trx_info_rpl pass repeatedly standalone → load-induced flakes.

An 8.0 port can follow as a separate PR once this is approved.

https://perconadev.atlassian.net/browse/PS-10110

MyRocks estimated the number of rows in a key range as
index_rows * (GetApproximateSizes(range) / index_disk_size).
GetApproximateSizes() works at SST data block offset granularity and
returns 0 when the whole range falls inside a single (~16KB) data
block; GetApproximateMemTableStats() has the same flaw for narrow
memtable ranges. The zero estimate was floored to 1 row, so a
selective non-unique range containing hundreds of rows was reported
to the optimizer as 1 row, causing poor join orders and large
performance regressions compared to InnoDB.

Fix by refining small estimates with a bounded index dive, similar to
InnoDB index dives: when the approximate estimate is below the new
session variable rocksdb_records_in_range_dive_threshold (default 100,
0 disables), scan the range with a key-only iterator reading at most
threshold keys. Fewer keys than the cap gives an exact count; hitting
the cap gives the threshold as a lower bound. Reverse column families
and successor-adjusted bounds are handled; partial indexes are skipped
because a raw iterator would undercount unmaterialized groups; the
rocksdb_records_in_range / rocksdb_force_index_records_in_range /
rocksdb_debug_optimizer_n_rows overrides keep precedence.

The dive only triggers when the estimate is already pathologically
small, i.e. the range lies within 1-2 data blocks, so its cost is
bounded and comparable to the GetApproximateSizes() call it refines.

index_merge_rocksdb.result: estimates are now the exact key counts.
rocksdb.result: new variable in SHOW VARIABLES output.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant