Faster offset pagination for Rails apps
planetscale.com
planetscale.com
The referenced “Deferred join technique” however does a single query using an inner join to achieve the same result.
So, if I’m reading this right, “fast_page” could be made even faster by doing an inner join instead, removing the additional query. I’m not sure why they wouldn’t have done that? It would also ensure both queries were run in the same transaction so you don’t experience race conditions. I’m not a rail dev so maybe that’s a given within a request/response process.
As someone else said, I’m surprised the query planner doesn’t account for this already.
These “deferred joins” where you join in your application code are useful for n+1 type issues. The Django orm has a .prefetch_related method [0] enabling you to optimise down to a single additional query where you would have had a secondary query for each row.
0: https://docs.djangoproject.com/en/4.1/ref/models/querysets/#...
> ERROR 1235 (42000): This version of MySQL doesn't yet support 'LIMIT & IN/ALL/ANY/SOME subquery'
See https://dev.mysql.com/doc/refman/5.7/en/subquery-restriction...
with page_numbers as (
select id from docs order by id limit 25 offset 50
)
select * from docs where id in (
select id from page_numbers
)[1] https://hakibenita.com/be-careful-with-cte-in-postgre-sql
This article is intended for PostgreSQL versions 11 and prior. Starting at version 12, PostgreSQL changed the way it treats CTE to prevent the issues described in this article.
I think for my package (the Laravel one) I'll have to hold off on that because CTEs only work with MySQL 8.0 and up. Also the Laravel query builder doesn't have methods to add CTEs, unfortunately.
I'm gonna noodle on this though, there's something nice about it.
https://dev.mysql.com/doc/refman/8.0/en/lateral-derived-tabl...
Prior to this you can emulate it with ROW NUMBER Window function in subquery, and this is what LATERAL gets turned into in many DB engines I believe
https://stackoverflow.com/questions/73127487/aws-athena-v2-p...
I made that choice for mine because I didn't want to pollute the `select` part of the developer's query to exclude the joined data. If we were writing the whole query from scratch we could, but not in the scenario where the package is actually used, unfortunately.
select * from Car
inner join Color on Car.ColorId = Color.Id
where Color.Name = 'Red'
You can't get the correct paginated list of car id's without doing the join in the first query. Even more wrong if you're ordering off the joined table.If you're OFFSET-ing, say, 1000 records, I believe the database needs to load those 1000 records in some way, to exclude them from the result set (it's possible mysql does this differently than postgres). With a ranged query and a cursor (e.g. select * from tweets where created_at > CURSOR LIMIT 20) is, generally, more efficient. But cursored range queries don't make sense in a lot of cases and often come with some additional complexity in the code.
The linked blog post seems to indicate that you can satisfy an OFFSET purely using an index, which generally isn't the case. If you have ORDER BY id LIMIT 10 OFFSET 100000, the planner has to fetch 100010 rows in most databases that I know of.
Edit: Indeed, this technique is seemingly for converting a regular query to a self-join, ie. a LIMIT m OFFSET n => (a LIMIT m OFFSET n) JOIN a, which is allowed as long as you have a unique constraint on the column you're joining on. I had assumed it actually wanted to convert something that was already a join.
If the index had cardinality metadata, it could jump directly to the leaf node containing the first row in the result set.
It's difficult to maintain cardinality metadata in the face of MVCC, even for the table as a whole. Page-level or similar on an index would be even harder.
- https://twitter.com/mdavis1982/status/1482429071288066054
- https://twitter.com/joecampo/status/1483550610028957701
- https://twitter.com/max_eckel/status/1483764319372333057?s=2...
- https://twitter.com/max_eckel/status/1483852300414337032?s=2...
- https://twitter.com/1ralphmorris/status/1484242437618941957?...
- https://twitter.com/julioelpoeta/status/1549524738980077568?...
If you're using Laravel, there's a package that you can use to achieve the same effect: https://github.com/hammerstonedev/fast-paginate
It supports length aware and simple.
[0]: https://gist.github.com/ezekg/5ff5e965410885501198235d6a779c...
* The gain depends on low-level details of your storage, can easily be negative, and is not necessarily easy to calculate precisely
* It requires adding an extra join, which can be expensive (depending on a bazillion factors) and generally slows down your planner
* The classic System R join optimization framework generally does not support adding or removing joins as part of cost-based optimization (although a pure Cascades-based optimizer probably would)
* It's for something you shouldn't do in the first place (large OFFSET)
Of course, the self-join is a hack. You could envision a cleaner approach where you separate out the index-only scan from a “fetch the real row” node, which can then be pulled up in certain cases (it does require you to track column sets, but isn't impossible), even past joins. However, see the last point. Also, MySQL's storage interface doesn't support it (you can push a WHERE clause down to be done before the full row fetch, and you can ask for the full row fetch to never be done at all, but you cannot ask for an explicit row fetch from an index lookup; that functionality is not exposed).
It's fruit, but it definitely isn't low-hanging.
This. Building query planners is incredibly hard and doing too much magic can get really can trip up users.
Somewhat related I also came across this: https://www.datadoghq.com/blog/100x-faster-postgres-performa...
I wonder a bit about the actual time for queries in the submitted article - seconds for a few thousands of lines of limit..offset seems terribly slow? (any query over tens/50ms I would generally call slow - in this context - displaying some data to a human)?