Start Debugging

Как реализовать keyset-пагинацию (cursor pagination) в EF Core 11

Замените Skip/Take на WHERE, который перескакивает за последнюю увиденную строку. Сортируйте по полностью уникальному ключу, переносите значения последней строки как курсор, и EF Core 11 превратит следующую страницу в поиск по индексу вместо сканирования с OFFSET.

Краткий ответ: перестаньте листать с помощью Skip(n).Take(pageSize) и начните листать с помощью WHERE. Keyset-пагинация (её также называют cursor- или seek-пагинацией) запоминает значения сортировки последней строки на только что показанной странице, а затем запрашивает у базы данных строки, которые сортируются после неё: OrderBy(x => x.CreatedAt).ThenBy(x => x.Id).Where(x => x.CreatedAt > lastDate || (x.CreatedAt == lastDate && x.Id > lastId)).Take(pageSize). При наличии индекса по столбцам сортировки каждая страница - это поиск по индексу постоянной стоимости вместо OFFSET, который заново сканирует и отбрасывает каждую строку перед страницей. Единственное жёсткое требование: сортировать по чему-то полностью уникальному, что на практике означает реальный ключ сортировки плюс первичный ключ в качестве тай-брейкера.

В этой статье используется Microsoft.EntityFrameworkCore 11.0.0 на .NET 11 с C# 14 против SQL Server 2025. Всё здесь работает так же на PostgreSQL и SQLite; единственное замечание, специфичное для провайдера, приведено в конце. Если вы когда-нибудь наблюдали, как страница 500 в таблице загружается в десять раз дольше страницы 1, это и есть решение.

Почему Skip/Take замедляется по мере углубления в страницы

Offset-пагинация - это очевидное первое, что все пишут. Размер страницы 20, страница 30, пропустить 580 строк:

// .NET 11, EF Core 11.0.0 - offset pagination, the slow way
var page = 30;
var pageSize = 20;

var posts = await context.Posts
    .OrderBy(p => p.PostId)
    .Skip((page - 1) * pageSize)
    .Take(pageSize)
    .ToListAsync();

EF Core транслирует Skip/Take в SQL OFFSET/FETCH (или LIMIT/OFFSET на PostgreSQL и SQLite):

SELECT [p].[PostId], [p].[Title], [p].[CreatedAt]
FROM [Posts] AS [p]
ORDER BY [p].[PostId]
OFFSET 580 ROWS FETCH NEXT 20 ROWS ONLY;

Проблема в том, что OFFSET 580 делает на самом деле. База данных не перепрыгивает к строке 581. Она производит все 600 строк по порядку, отсчитывает первые 580, выбрасывает их и возвращает последние 20. Объём работы масштабируется с величиной смещения, а не с размером страницы, поэтому глубокие страницы становятся всё дороже. На активно используемой таблице это прямо противоположно тому, чего ожидают пользователи: чем дальше они прокручивают, тем медленнее становится.

Есть и второй, более тихий баг. Offset-пагинация нестабильна при конкурентной записи. Официальное руководство по пагинации EF Core разъясняет это: если строка вставляется или удаляется между двумя запросами страниц, весь набор результатов сдвигается на один, и пользователь, переходящий со страницы 2 на страницу 3, либо видит строку дважды, либо полностью её пропускает. В административной таблице этого никто не заметит. В ленте с бесконечной прокруткой, куда строки постоянно добавляются сверху, это видимый, воспроизводимый дефект.

Что вместо этого делает keyset-запрос

Keyset-пагинация отбрасывает саму идею смещения. Вместо “пропустить 580 строк” вы говорите “дай мне строки, которые идут после этой конкретной строки, которая у меня уже есть”. Вы запоминаете значения сортировки последней строки, и следующая страница - это WHERE, который перескакивает прямо за них:

// .NET 11, EF Core 11.0.0 - keyset pagination, single unique key
var pageSize = 20;
int? lastPostId = 580; // the PostId of the last row on the previous page; null for page 1

var query = context.Posts.OrderBy(p => p.PostId).AsQueryable();

if (lastPostId is int cursor)
{
    query = query.Where(p => p.PostId > cursor);
}

var posts = await query.Take(pageSize).ToListAsync();

Это транслируется в:

SELECT TOP(20) [p].[PostId], [p].[Title], [p].[CreatedAt]
FROM [Posts] AS [p]
WHERE [p].[PostId] > 580
ORDER BY [p].[PostId];

При наличии индекса по PostId (кластеризованный первичный ключ уже им является) база данных переходит напрямую к первой строке, большей чем 580, и читает 20 строк. Никакого сканирования-с-отбрасыванием. Страница 1 и страница 10 000 стоят одинаково. И поскольку курсор - это значение, а не позиция, вставка или удаление в другом месте таблицы не может сдвинуть ваше окно: вы всегда продолжаете с той самой строки, которую видели последней.

Подвох в самом названии: keyset-пагинации нужен ключ. Столбец (или столбцы), по которым вы сортируете, должны давать строгий, полный порядок по всем строкам. Если две строки могут совпасть по ключу сортировки, сравнение > не может сообщить базе данных, на какой стороне границы находится совпавшая строка, и вы будете молча пропускать или повторять строки. PostId уникален, поэтому он работает в одиночку. Временная метка CreatedAt почти никогда не уникальна, поэтому она не работает, и именно здесь живёт большинство реальных запросов.

Сортировка по неуникальному столбцу: добавьте тай-брейкер

Реалистичный случай - это “сначала новые”, сортировка по CreatedAt, который может совпадать вплоть до миллисекунды. Исправление, на которое документация указывает в предупреждении в верхней части страницы о пагинации, - сделать сортировку полностью уникальной, добавив уникальный столбец, почти всегда первичный ключ:

// .NET 11, EF Core 11.0.0 - keyset over (CreatedAt DESC, PostId DESC)
var pageSize = 20;

// Cursor carried from the last row of the previous page (null on page 1).
DateTime? lastCreatedAt = previousCursor?.CreatedAt;
int? lastPostId = previousCursor?.PostId;

var query = context.Posts
    .OrderByDescending(p => p.CreatedAt)
    .ThenByDescending(p => p.PostId)
    .AsQueryable();

if (lastCreatedAt is DateTime ca && lastPostId is int id)
{
    // Rows that sort strictly after the cursor in (CreatedAt DESC, PostId DESC).
    query = query.Where(p =>
        p.CreatedAt < ca || (p.CreatedAt == ca && p.PostId < id));
}

var posts = await query.Take(pageSize).ToListAsync();

Условие WHERE - это весь фокус, так что прочитайте его внимательно. Вы сортируете по убыванию, поэтому “после курсора” означает меньше. Строка попадает на следующую страницу, если её CreatedAt строго старше, чем у курсора (p.CreatedAt < ca), либо если её CreatedAt совпадает в точности, а её PostId разрешает совпадение в том же направлении (p.CreatedAt == ca && p.PostId < id). Именно ветку с == люди пропускают, и пропуск её - это в точности то, как строки с общей временной меткой пропускаются на границах страниц. Направление сравнения в WHERE должно точно зеркалить направление OrderBy: восходящий порядок использует >, нисходящий - <. Перепутаете их - и ваши страницы будут либо накладываться, либо оставлять пробелы.

Сгенерированный SQL - это единственный поиск:

SELECT TOP(20) [p].[PostId], [p].[Title], [p].[CreatedAt]
FROM [Posts] AS [p]
WHERE [p].[CreatedAt] < @ca OR ([p].[CreatedAt] = @ca AND [p].[PostId] < @id)
ORDER BY [p].[CreatedAt] DESC, [p].[PostId] DESC;

Собираем всё воедино

Вот полный цикл: закодировать курсор, вернуть его вместе со страницей, декодировать при следующем запросе. Шаги одинаковы независимо от того, едет ли курсор в строке запроса или в теле ответа API.

  1. Выберите полностью уникальную сортировку. Осмысленный столбец сортировки плюс первичный ключ в качестве финального тай-брейкера. Порядок столбцов здесь - это порядок, которому должно следовать всё остальное.
  2. Определите индекс, который точно соответствует сортировке. Составной индекс по (CreatedAt DESC, PostId DESC) позволяет поиску читать строки уже в нужном порядке. Без него база данных сортирует всю таблицу на каждой странице, и выигрыш испаряется.
  3. Постройте WHERE из значений последней строки. По одной ветке OR на каждый столбец сортировки, с направлением сравнения, соответствующим направлению сортировки каждого столбца.
  4. Возьмите pageSize строк. При необходимости pageSize + 1, чтобы вы могли определить, существует ли следующая страница, без второго запроса.
  5. Сформируйте курсор из последней возвращённой строки и передайте его вызывающему, чтобы отправить со следующим запросом.

Минимальная конечная точка, которая возвращает страницу плюс непрозрачный курсор:

// .NET 11, EF Core 11.0.0, C# 14 - minimal API keyset endpoint
app.MapGet("/posts", async (string? cursor, AppDbContext db) =>
{
    const int pageSize = 20;

    var query = db.Posts
        .AsNoTracking()
        .OrderByDescending(p => p.CreatedAt)
        .ThenByDescending(p => p.PostId)
        .AsQueryable();

    if (Cursor.TryDecode(cursor, out var ca, out var id))
    {
        query = query.Where(p =>
            p.CreatedAt < ca || (p.CreatedAt == ca && p.PostId < id));
    }

    // Fetch one extra row to detect whether a further page exists.
    var rows = await query.Take(pageSize + 1).ToListAsync();

    var hasMore = rows.Count > pageSize;
    var page = rows.Take(pageSize).ToList();

    var next = hasMore && page.Count > 0
        ? Cursor.Encode(page[^1].CreatedAt, page[^1].PostId)
        : null;

    return Results.Ok(new { items = page, nextCursor = next });
});

Вспомогательный класс Cursor просто упаковывает два значения в URL-безопасный токен, чтобы вызывающие воспринимали его как непрозрачный и не могли вмешаться в семантику листания:

// .NET 11, C# 14 - opaque cursor encode/decode
static class Cursor
{
    public static string Encode(DateTime createdAt, int id) =>
        Convert.ToBase64String(
            Encoding.UTF8.GetBytes($"{createdAt.Ticks}:{id}"));

    public static bool TryDecode(string? token, out DateTime createdAt, out int id)
    {
        createdAt = default;
        id = default;
        if (string.IsNullOrEmpty(token)) return false;

        var parts = Encoding.UTF8
            .GetString(Convert.FromBase64String(token))
            .Split(':');
        if (parts.Length != 2) return false;

        createdAt = new DateTime(long.Parse(parts[0]), DateTimeKind.Utc);
        id = int.Parse(parts[1]);
        return true;
    }
}

Обратите внимание на AsNoTracking() в запросе. Это строки списка только для чтения, поэтому нет причин платить за трекер изменений; если вы не уверены, когда это имеет значение, см. AsNoTracking vs AsNoTrackingWithIdentityResolution в EF Core 11. Для активно используемой конечной точки со списком этот запрос также является сильным кандидатом на скомпилированный запрос, поскольку его форма никогда не меняется между запросами.

Индекс не опционален

Keyset-пагинация быстра только в том случае, если база данных может выполнить поиск. Для этого требуется индекс, столбцы ключа и направления которого точно соответствуют вашему OrderBy:

// .NET 11, EF Core 11.0.0 - composite index matching the page order
modelBuilder.Entity<Post>()
    .HasIndex(p => new { p.CreatedAt, p.PostId })
    .IsDescending(true, true);

Официальное руководство прямолинейно об этом в разделе об индексах: ваш индекс должен соответствовать порядку вашей пагинации. Если вы сортируете по (CreatedAt DESC, PostId DESC), но индексируете (CreatedAt ASC, PostId ASC), многие базы данных всё ещё могут сканировать индекс в обратном направлении, но как только вы добавите третий столбец или несоответствующее направление, планировщик откатится к сортировке по всему отфильтрованному набору, и ваша страница постоянной стоимости пропала. Направление индекса - это часть контракта, а не деталь. Это тот же класс проблем “план запроса делает то, о чём вы не просили”, что и случайный запрос N+1: LINQ выглядит нормально, но план рассказывает реальную историю, поэтому проверьте фактический план выполнения один раз перед выпуском.

Почему не синтаксис кортежей, который вы видели в сыром SQL

Если вы писали keyset-пагинацию в SQL вручную, вы, вероятно, использовали сравнение строковых значений: WHERE (CreatedAt, PostId) < (@ca, @id). Это более чистый способ выразить ту же границу, большинство реляционных баз данных его поддерживают, и он, как правило, производит лучший план, чем развёрнутая цепочка OR. Плохая новость для EF Core 11: его всё ещё нельзя написать в LINQ. Документация отмечает это явно, и это отслеживается в dotnet/efcore#26822, который остаётся открытым по состоянию на EF Core 11.0.0. Так что ручное разворачивание OR выше - это не временное решение, которое вы выбросите в следующей версии; это текущий поддерживаемый подход.

Если вы сортируете по трём или более столбцам, цепочка OR быстро растёт и становится подверженной ошибкам. Шаблон обобщается механически: для ключей сортировки a, b, c предикат - это a > a0 || (a == a0 && b > b0) || (a == a0 && b == b0 && c > c0). Как только у вас больше двух ключей, возьмите поддерживаемый помощник, такой как MR.EntityFrameworkCore.KeysetPagination, который строит это дерево выражений за вас из того же определения OrderBy и держит WHERE синхронизированным с сортировкой. Написание вручную цепочек OR глубиной в четыре - это то, как ветка == теряется.

Листание назад и другие крайние случаи

Несколько вещей кусают людей, как только счастливый путь заработал:

Offset-пагинация не всегда неправа. Для небольшой административной таблицы или любой таблицы, где пользователи действительно кликают по номерам страниц, Skip/Take проще, а разница в производительности невидима. В тот момент, когда таблица большая, активно дополняемая или глубоко прокручиваемая, keyset - это вариант, который остаётся быстрым и остаётся корректным. Сортируйте по уникальному ключу, постройте WHERE так, чтобы он точно ему соответствовал, индексируйте эти столбцы в том же направлении, и ваша самая глубокая страница будет стоить столько же, сколько первая.

Связанные материалы

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад