Vitess Cross-Shard Pagination: Limits of OFFSET Merging Versus Keyset Bounds
0 reputation · 07 Sept 2021, 15:01 UTC
0 reputation · 07 Sept 2021, 15:01 UTC
Pagination across a sharded Vitess keyspace forces a choice between offset-based paging and keyset bounds, but VTGate does not resolve that choice automatically. When a scatter query carries LIMIT and OFFSET, VTGate pushes both down to each shard and merges the results, so a page at offset n causes every shard to read roughly n + limit rows before the merge. On large tables this multiplies per-shard I/O and memory, and deep offsets can approach query-server limits before VTGate assembles the final page.
Keyset pagination avoids that cost by bounding the query with a WHERE predicate on an indexed, monotonic column, but it requires a suitable column and a deterministic sort order that eliminates ties. If the desired ORDER BY is not the sharding key, Vitess performs a cross-shard merge sort, adding coordination overhead. Because Vitess does not rewrite an OFFSET query into seek bounds or compute a single global offset, the application must decide the strategy per table.
When the required sort column is not the sharding key, what are the practical limits of OFFSET depth before VTGate merging becomes a bottleneck, and how should an application weigh keyset bounds against the need for a non-sharding ORDER BY?
32275 reputation · 07 Sept 2021, 21:28 UTC
There is no fixed OFFSET depth at which VTGate merging "breaks." The practical ceiling is where shards × (offset + limit) rows exceed the tightest of four budgets: per-shard query-server memory, result-size caps, network transfer, or your latency SLO. Shard count and row width dominate that number, so a threshold measured on a 4-shard test keyspace will not transfer to a 64-shard production keyspace. Keyset bounds are the better default when a deterministic total order exists and the sort column is indexed on every shard; when it is not, you are choosing between deep-offset cost and cross-shard merge-sort cost, not between a good and a bad option.
Confirmed scatter semantics: a query carrying LIMIT and OFFSET pushes both to each shard, and VTGate merges the returned slices. Keyset pagination replaces the offset with an indexed range predicate, so each shard returns at most limit rows.
Likely, and worth measuring rather than assuming: VTGate's merge is a heap-based merge over sorted shard streams, so its own CPU scales with rows consumed, not with offset alone. The dominant cost is usually the shards scanning, sorting, or buffering offset + limit rows before returning their slice. Planner rules, limit pushdown, and merge behavior vary by release and configuration, so treat any specific number as version- and workload-specific.
shards × limit and stops growing with depth. The remaining cost is the scatter and merge sort, which keyset does not remove.Run read-only plans and compare. On a shard's MySQL endpoint, or through VTGate for the scatter form:
-- OFFSET form
EXPLAIN SELECT id, created_at, title
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 50 OFFSET 100000;
-- Keyset form (expanded predicate)
EXPLAIN SELECT id, created_at, title
FROM articles
WHERE created_at < ?
OR (created_at = ? AND id < ?)
ORDER BY created_at DESC, id DESC
LIMIT 50;
Confirm the keyset plan uses the intended composite index and that each shard returns at most limit rows. Then measure at increasing offsets against a representative keyspace, watching per-shard rows examined, query-server memory, and p99 latency; the knee is your real ceiling. Check VTGate and vttablet query statistics and result-size settings for your release, since defaults change between versions.
Tie-breaking test: insert several rows sharing the same sort value and page through them with ORDER BY sort_col, unique_id. If any row is skipped or repeated, your tiebreaker is not unique.
Two caveats. OFFSET is not a stable pagination contract under concurrent writes: rows shift between pages even when the query succeeds. Keyset assumes the sort key and tiebreaker stay stable for the session. Both should be reviewed against your Vitess version before you commit to a threshold, and row-constructor predicate forms may index differently than the expanded form depending on the MySQL version underneath.
One detail changes the recommendation: is the ORDER BY column indexed on every shard for the exact predicate and sort direction? If yes, keyset is almost always the right default. If no, the honest answer is a capped OFFSET plus a data-model change, and index work should come before pagination tuning.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.