Which pagination strategy fits a 40M-row Phalcon table: QueryBuilder paginator COUNT cost or manual keyset?
0 reputation · 06 Feb 2022, 11:45 UTC
0 reputation · 06 Feb 2022, 11:45 UTC
I am designing the read path for a Phalcon 5 application backed by an InnoDB table expected to reach roughly 40 million rows. The UI is a classic page-number grid today, but an infinite-scroll view is on the roadmap, so I want to pick a pagination approach that will not need to be thrown away later.
My understanding is that Phalcon\Paginator\Adapter\QueryBuilder applies LIMIT/OFFSET per page and issues a separate COUNT query to compute total_pages. On a table this size I expect both the deep-OFFSET scans and the COUNT itself to become the dominant cost, and the total may be stale under write load anyway. The alternative I am weighing is manual keyset pagination with WHERE id > :cursor: ORDER BY id LIMIT n, which avoids the count and the offset scan but gives up total page numbers and requires a stable indexed sort column.
Is there a documented way to make the QueryBuilder paginator skip or approximate its COUNT query, or does that require a custom adapter?
For a dataset of this size, is accepting the paginator's COUNT plus caching the total a reasonable middle ground, or is keyset the only defensible option?
Does keyset pagination conflict with anything in the paginator result contract (total_items, last, next) that consumers typically rely on?
35275 reputation · 06 Feb 2022, 17:41 UTC
At 40 million rows with infinite scroll on the roadmap, manual keyset pagination is the defensible choice. The QueryBuilder paginator's COUNT plus deep OFFSET scans are both costs that grow with the table, and keyset eliminates them while matching the UI you already plan to build. Caching the COUNT is a workable bridge only while the page-number grid still exists.
Phalcon\Paginator\Adapter\QueryBuilder does two things that hurt on a large InnoDB table:
COUNT(*) to compute total_pages. Without a covering index this scans a large fraction of the table, and the number is stale the moment a concurrent write lands — so you pay the cost and still get an approximate answer.LIMIT n OFFSET m. InnoDB must read and discard m rows before returning the page, so latency grows roughly linearly with page depth. Page 10 is fine; page 5,000 is not.Verify this on your own data before committing: run EXPLAIN on the generated COUNT query, and time the page query at offsets of 100, 10,000, and 1,000,000 rows. If the deep-offset times are already in the hundreds of milliseconds at 40M rows, the trend only gets worse.
There is no documented configuration flag on the QueryBuilder adapter to skip or approximate the COUNT — computing totals is part of what the adapter does. If you want paginator-shaped results without the COUNT, you write a custom adapter (implement the paginator interface, run only the page query, and populate the result fields yourself). That is a reasonable pattern, but at that point you are maintaining your own pagination logic anyway, which weakens the argument for keeping the adapter versus doing keyset directly.
Keyset pagination — WHERE id > :cursor ORDER BY id LIMIT n — turns page depth into a constant-time indexed lookup. The trade-offs are real but manageable:
n + 1 rows and checking whether the extra row exists.next/previous page fields must be updated to pass an opaque cursor.created_at), append the id as a tiebreaker: WHERE (created_at, id) > (:ts, :id) ORDER BY created_at, id, or pages will skip/duplicate rows.So yes, keyset conflicts with the standard paginator result contract — but only the fields your future UI doesn't need. If you keep the page-number grid for now, a hybrid works: use the QueryBuilder paginator with the COUNT cached (e.g., in Redis with a short TTL or write-triggered invalidation), and expose keyset endpoints for the infinite-scroll path. Accept that the cached total is approximate; display it as such.
One caveat worth flagging: if your grid lets users jump to an arbitrary page number ("go to page 12,000"), keyset cannot serve that directly — that feature and 40M-row performance are fundamentally in tension, and it is usually the feature that has to go.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.