Efficient Pagination Using Deferred Joins
aaronfrancis.com
aaronfrancis.com
1. If you are scanning the (clustered) primary key, this is no better than "normal" offset pagination. You need another index with a smaller record size, without all the ancillary fields of the PK, to make this have a benefit (since it'll read less data than reading the primary key).
2. It's still O(n^2) to page through n rows. Even if it's a constant factor better, it still doesn't scale well. Offset pagination works fine until it doesn't, and it'll fail spectacularly when it does.
3. You still get duplicated/missed elements in your response when things are added/removed with smaller keys than your current page.
SELECT * FROM T ORDER BY FOO LIMIT 15 OFFSET 150000
But the following will just scan the index:
SELECT FOO FROM T ORDER BY FOO LIMIT 15 OFFSET 150000
i guess the core of the statement was that the net cost to the db is more tied to offset than select, yes they interact and 'select * with offset' is more expensive than 'select foo with offset', but its really the offset that is the problem. If you did keyset style pagination and still did 'select *' you'd be fine and roughly the same cost as 'select foo'.
So it doesn't exactly avoid the wall, but it can push it considerably further away.
I hate pagination, nothing feels efficient to me. Fortunately, I've never worked on anything that required scale hehe..
(Note, I'm not positive my thought here is accurate.)
But the drawback is you can't have directly addressable pages. So it's just a tradeoff per app.
Some apps do offset/limit and then simply limit the number of pages to something reasonable, which is a pretty viable solution too.
I can see how the DB would need to scan the index from the left side to arrive at the correct offset value O(n). But once it gets there, it should be able to load 15 rows from primary storage.
(FWIW in postgres the primary key index is a secondary index.)
I do agree that "deferred join" is no better than the first option in theory.
How does your point #3 work? It seems to assume the DB tries to guess how many rows are in a single page in order to implement OFFSET. This seems like an obvious bug that would be long fixed.
With N records and page size P, with p=N/P pages: the client does list?page=1 then list?page=2 ... list?page=p. the server then does scans P records for the first client request, 2P for the second up to pP=N. The total record scanned is (1+2+...N/P)P = (1+N/P)(N/P)1/2*P ~ O(N^2)
I also mention cursor pagination, but that's not always an option for every app.
Will I might hesitate to deploy it in an app, because the index scan is still relatively heavy weight, I can certainly see myself using this for ad-hoc stuff, so thankyou.
> This "deferred join" works because it lets the server examine as little data as possible in an index without accessing rows, and then, once the desired rows are found, join them against the full table to retrieve the other columns from the row.
From High Performance MySQL, 4th Edition (2021)
> This "deferred join" works because it lets the server examine as little data as possible in an index without accessing rows, and then, once the desired rows are found, join them against the full table to retrieve the other columns from the row.
So I mean... it's not a crazy idea. Sometimes you just have to offer directly addressable pages in an application and can't get away with cursors, which are far better in certain circumstances.
I agree that you'll see no benefit if you were paginating PKs in the first place, but most app developers are paginating actual records, right?
This is written from the point of view of application developers. I'm sure that all the DBAs are skeptical right now, but when pages go from 30 seconds to 300 milliseconds [1] or 28 seconds to 2 seconds [2] that's kind of a win right?
[1] https://twitter.com/mdavis1982/status/1482429071288066054
You are unclear on this point, but I don't really see how it addresses any of parent's (grogers) counterpoints. Maybe be a critical concept for you to reconsider! (I suspect application developers mainly use OFFSET/LIMIT because it's simple to implement and readily availability in SQL, rather than the optimal way for the user to accomplish their task.)
So my playbook here is to ask if direct addressing is really needed and then, when it isn't, switch to using cursors.
Additionally, it hurts users when the data is changing beneath them. Rows can appear twice or disappear as you move between pages.
As a developer without a specific requirement if the time is short (it always is) I create a paged navigation using the defaults of the framework I'm using. Then the customer might or might not ask for a search filter. If frameworks defaulted on search filters I'd give customers them first and paged navigation if requested.
Most frameworks have some kind of cursor implementation. Laravel's docs do a good job describing theirs: https://laravel.com/docs/8.x/pagination#cursor-pagination
So on the first page load, the server will send the records and a cursor that contains the info {'lastId': 32} and when the user requests page two, they'll have to send the cursor {'lastId': 32} so the server knows where to start.
In practice, the cursors are usually encoded and appended to the URLs.
You can never calculate what page 2 should be, you have to know what the last item on page 1 was, because that dictates where to start on page 2.
In a nutshell, a table can be:
- Heap-based: the rows are in the unordered "heap", with any number of indexes (B-trees) pointing to them physically (by holding the row's physical address).
- Or clustered (aka. index-organized): the rows are in a primary/clustered index (B-tree), with any number of secondary indexes (also B-trees) pointing to them logically (by holding the copy of the primary key).
Fetching a row through any index in a heap-based table, or any secondary index in a clustered table, requires a separate step: physical heap access in the former case, or a clustered index seek in the later.
So...
- if you are using a heap-based table,
- or using a clustered table where pagination order differs from the clustering order
...then it makes sense to avoid the price of fetching the entire row for those rows that you are going to discard anyway. You just scan the index that matches the pagination order until you reach the correct page, and only then start fetching the rows.
It's still "offset" pagination, but can be much faster in practice than the naive implementation (for later pages).
OTOH, if you are paginating in the clustering order, then there is no separate step for fetching the row, so this method would be unlikely to bring any benefit.
---
A DBMS with a decent query planner should be able to do this kind of optimization automatically, through "predicate pushdown". But it may still be a good idea not to rely on it and make things blindingly obvious to the planner by structuring the SQL as shown in the article.
---
BTW, "cursor pagination" is also known as "keyset pagination" or "seek method". Curiously, actual database cursors can be used for pagination, assuming very tight transactional coordination between client and server (and very nasty consequences if that's lacking), but that's different from the "cursor pagination" as mentioned in the article.