12

For a large dataset, paginating with an OFFSET is known to be slow and not the best way to paginate. A much better way to paginate is with a cursor, which is just a unique identifier on the row so we know where to continue paginating from where we last left off from the last cursor position.

When it comes to a cursor where it is an auto incrementing id value, it's fairly easily to implement:

SELECT * FROM users
WHERE id <= %cursor // cursor is the auto incrementing id, ex. 100000
ORDER BY id DESC
LIMIT %limit

What we're not certain about, is if instead of an auto incrementing id cursor, the only unique sequential identifiers for the cursor are uuid and created_at on the table rows.

We can certainly query based on the uuid to get the created_at, and then select all users that are <= created_at but the issue is what if there are multiple instances of the same created_at timestamp in the users table? Any idea how to query the userstable based on uuid/created_at cursor combination to ensure we get the correct datasets (just as if we were using auto incrementing id)? Again, the only unique field is uuid since created_at may be duplicate, but their combination would be unique per row.

Wonka
  • 145
  • 2
  • 2
  • 10
  • Your first paragraph is a nice explanation of why to use a 'cursor' instead of OFFSET. However it leaves out the bugs of skipping or duplicating rows as the user moves to the next page while rows are being inserted/deleted. (That also argues against OFFSET.) – Rick James May 15 '18 at 20:14
  • Do you want to paginate on chronological order? If so, is it created order? or Updated order? Or do you not care what order? – Rick James Feb 15 '24 at 06:34

2 Answers2

12
WHERE   created_at <= x
  AND ( created_at < x OR uuid < y )
ORDER BY created_at DESC,
         uuid       DESC

or this equivalent:

WHERE (     created_at < x
       OR ( created_at = x AND uuid < y )
      )
ORDER BY created_at DESC,
         uuid       DESC

This technique works for any pair of columns where the first one (created_at) could have duplicates and the second is unique (uuid or id).

And this is required:

INDEX(created_at, uuid)

Note that both parts of the WHERE are DESC. Mixing ASC and DESC will defeat the usability of the INDEX. (MySQL 8.0 can work around that.)

Note also, that this assumes you don't care what order the rows are in when created_at is duplicated, but you do want a consistent ordering. Note that uuid will be seemingly random, but still consistent. With that said, id (with or without Galera) and uuid work equally well.

(UUIDs suck, but that is a different discussion.)

Remember where you left

More on pagination without using OFFSET .

In your case, that is the combination of created_at and whatever the PRIMARY KEY is. The former gives the desired ordering; the latter handles what to do in the case of dup dates.

This technique further optimizes things:

PRIMARY KEY(create_at, id), -- or (created_at, UUID)
INDEX(id)                   -- or (UUID)
Rick James
  • 78,038
  • 5
  • 47
  • 113
  • 1
    I think in equivalent there should be WHERE created_at < x OR (created_at = x AND uuid < y) – Yavin May 14 '20 at 07:52
  • @Yavin - Thanks! (Geez -- 2 years and 4 upvotes before someone points out an error.) I fixed it. – Rick James May 14 '20 at 16:33
  • As a SQL noob, I love the content and format of your answer and especially your blog post. Question: would you pass only the uuid around (to/from client), and not the created_at/non-unique column since it could change? So query would be

    `SET @created_at = (SELECT created_at FROM users WHERE uuid=x);

    SELECT * FROM users WHERE created_at <= @created_at AND (created_at < @created_at OR uuid < y) ORDER BY created_at DESC, uuid DESC LIMIT page_size+1;`

    – onepiece Feb 14 '24 at 09:06
  • Also a question related to UUID_TO_BIN: it notes This moves the more rapidly varying part to the right and can improve indexing efficiency if the result is stored in an indexed column. How does reducing variance on the left increase index efficiency? This is basically asking the basics of indexing but I'm imagining the index as a prefix tree: high variance on the left means more keys (chars) to search through in the beginning (in earlier levels of tree), which takes longer. – onepiece Feb 14 '24 at 09:13
  • @onepiece - If (and only if) you usually access rows that are "hear" each other in time, the bit rearrangement "clusters" such rows together for more efficient access (less I/O, less cache space used, etc). See my old discussion of UUIDs – Rick James Feb 14 '24 at 19:10
  • Thanks, could you also please answer my first question (about whether to pass around the created_at/non-unique column? – onepiece Feb 14 '24 at 21:01
  • @onepiece - That was the pagination link. I added a little more about it. – Rick James Feb 14 '24 at 22:01
  • To clarify, my question is if I'm paginating by (uuid, created_at), should the user/client/pagination URL use just uuid, just created_at, or both? I might have missed it but it's still not clear, the closest thing I found that you wrote is we want to somehow remember where you left off as a pair of $impression, $id which implies passing around both fields. – onepiece Feb 14 '24 at 23:20
  • A related question is how to handle if created_at (or something more likely to change like updated_at that we use for pagination instead of created_at) changes for a uuid? Say while a client is on a certain page and updated_at for their uuid cursor has changed in DB. If we only use uuid from client, then their next page would skip because the associated updated_at skipped. – onepiece Feb 14 '24 at 23:24
  • I did some more reading. I think I understand the strategy is that the cursor is comprised of all the columns used (so (uuid, created_at)), and pass all that to client. If created_at for the uuid changes, it's fine because that uuid's record may be moved to an earlier/later page, and another record will take its place, maintaining the sorting by created_at based on updated data. User may never see a record (e.g. if updated to be on earlier page), but it's fine because it's in accordance with the sorting. – onepiece Feb 15 '24 at 03:18
  • For chronological order, the index (and WHERE) must have the date before the unique UUID). – Rick James Feb 15 '24 at 06:37
6

I will answer what you asked, but first let me tell you that I don't understand why you want to do that. An autoincremental id is very good for this task. But it is correct to also use a timestamp column, because it is a bad practice to rely on an id for sorting. Why? Because there are cases when its order might not be chronological - for example, if you use Galera cluster and you have failovers.

To do what you asked, first create this index:

ALTER TABLE users
    ADD INDEX idx_created_at_uuid (created_at, uuid);

The order columns is important. If you reverse it, the index will not be useful.

Now you just need to run a query like this:

SELECT some_columns
    FROM users
    WHERE (created_at, uuid) < (x, y)
    ORDER BY created_at DESC;

uuid is only needed because created_at is not unique. The expression above uses a syntax known as row constructors. It is intuitive: it's the same as created_at < x, unless these values are equal, in which case it compares uuidandy`.

If created_at is not the first column in the index, MySQL will have to read all rows and copy them to a temporary table (which could be in-memory or on-disk) to sort them.

If you decide to use the id, just keep the above snippets, but replace uuid with id.

EDIT 16 Nov 22: The WHERE clause was incorrect. I fixed it and I explained the syntax.

Federico Razzoli
  • 1,663
  • 8
  • 24
  • 1
    Thank you for your answer, I will certainly try and use the auto incrementing id instead as it seems to be much better, but will keep this for reference. – Wonka Apr 30 '18 at 19:39
  • 1
    Good. Note that I edited the answer, because there was a mistake in my query. – Federico Razzoli Apr 30 '18 at 20:14
  • 1
    @FedericoRazzoli - Your inequality test is inadequate. See my answer for a fix. – Rick James May 15 '18 at 20:12
  • how to create a request to "back" action? Thanks. – lexa Dec 24 '20 at 12:19
  • @FedericoRazzoli Is the query actually correct now? Please explain. Only uuid type 1 and 2 contains temporal part. Assume, that you processed half of 100 elements at given timestamp, and know that last uuid is type 4, random: 956ce1fb-066f-40a0-86bf-6de21d8023d2. If < comparison works, it probably yields random results. The uuid you hold does not anyhow identify which rows were processed and which not. No? – Martin Mucha Feb 21 '23 at 16:43
  • @MartinMucha The role of UUID here is simply to add a unique column to the comparison, so that the order is deterministic even if timestamp is not unique (which is almost always true if you don't use sub-second precision). – Federico Razzoli Nov 24 '23 at 02:50
  • @FedericoRazzoli I don't remember the query when I read it last time, but will uuid 1/2 be actually need not to be unique if you are unlucky enough – Martin Mucha Nov 24 '23 at 14:09
  • @MartinMucha The risk is purely theoretical. Asteroid storms are more likely (really!). But if you're worried, just build UNIQUE constraints on UUID columns (a common practice) and your UUIDs will surely be unique. – Federico Razzoli Nov 25 '23 at 10:54
  • @FedericoRazzoli with v4 sure. with v1 I'm not that sure, since most of it is based on time/mac address. But you might be correct, I'm not sure about it. – Martin Mucha Nov 26 '23 at 11:39
  • @MartinMucha MySQL uses UUID v1. But the MAC address is unique for a system, usually even with containers and VMs. The timestamp section has a precision of 10 millionths of a second. I never knew of a UUID non-uniqueness problem in MySQL. But if it ever happens to you, file a bug and suggest that they should implement ways to improve uniqueness that are suggested in RFC 4122 (such as using multiple node id's or replacing low bits with an incrementor if the clock precision is low). – Federico Razzoli Nov 27 '23 at 02:38
  • @FedericoRazzoli I just wrote to others to be aware of potential issue,how big it is depends on system. Anything less than millis precision is potentially problematic (on lots of machines/systems/sw the nano part is just made up or zero),while 1ms is still long enough time for lot of duplicates to emerge, even on single machine. What is the node part of UUID1 consisted of? Is it all random(some systems), or mac+random?How is random obtained? For example oracle sys_guid generates uuids which are very same,and it's based on sequence(fail).It might be completely safe,it might suprise one as well. – Martin Mucha Dec 01 '23 at 11:31
  • @MartinMucha I suggest you read the RFC 4122, which is much longer than the allowed 500 characters of SE comments. Probably most of the things I wrote will remain obscure until you do. Precision smaller than milliseconds is not made up. That said, again, the RFC contains some suggestions that MySQL might implement or not. If you encounter actual problems (on old machines or on WIndows) it's a good idea to open a feature request and ask the team to implement some of those suggestions. – Federico Razzoli Dec 11 '23 at 04:25
  • @MartinMucha (2) I just checked: Oracle SYS_GUID() function is completely safe. I don't know what it does internally, but it's trivial to test. Just write a script that composes a SELECT that calls SYS_GUID() 500 times in the same query. Send them to stdout separated by a newline char, and pipe them to uniq. All values will be different, unless your specific Oracle version, or a library used by it, has a bug. – Federico Razzoli Dec 11 '23 at 04:37