Introdução

Paginar resultados parece simples: adicione .Skip(offset).Take(pageSize) e pronto. Mas quando a tabela tem 10 milhões de registros, o usuário está na página 5.000, o banco é Oracle 11g, ou o cliente exige scroll infinito sem duplicatas — essa simplicidade desaparece rapidamente.

Paginação é uma das decisões arquiteturais mais impactantes em APIs REST, e a escolha errada pode significar queries de 8 segundos, resultados inconsistentes ou um backend que trava sob carga. Existem pelo menos cinco estratégias distintas, cada uma com trade-offs claros: Offset, Keyset (Seek), Cursor de banco, Token opaco e Time-based. Cada banco de dados — SQL Server, Oracle e PostgreSQL — implementa essas estratégias com sintaxes e comportamentos ligeiramente diferentes.

Neste artigo você vai entender em profundidade quando usar cada estratégia, como cada banco a implementa, e como codificar tudo com EF Core 8.0+ em C#. Se você quer uma visão mais ampla sobre gargalos de leitura e escrita em banco de dados, leia também o artigo Gargalo em Banco de Dados com C# e EF Core: Mensageria e Paginação.

Pré-requisitos: C# intermediário, EF Core básico, familiaridade com SQL. Recomenda-se também o artigo sobre programação assíncrona com C#.

📦 Código-fonte: A implementação completa deste artigo está no repositório blog-zocateli-sample no GitHub. Clone, explore e adapte ao seu contexto.


Por Que Paginação Errada Destrói a Performance

Antes de ver as estratégias, vale entender o que acontece internamente em cada banco quando você pagina:

O Problema do OFFSET Profundo

1
2
3
4
-- SQL Server: página 5000 com 20 itens por página
SELECT Id, Nome, Valor FROM Pedidos
ORDER BY DataCriacao
OFFSET 99980 ROWS FETCH NEXT 20 ROWS ONLY;

O banco não “pula” para a linha 99.981 magicamente. Ele precisa:

  1. Ordenar (ou usar o índice de ordenação)
  2. Varrer 99.980 linhas e descartá-las
  3. Retornar as próximas 20

O custo cresce linearmente com o offset. Para OFFSET 0 o custo é quase zero; para OFFSET 1.000.000 o custo pode ser segundos. Isso é o problema do late pagination, e se manifesta em qualquer banco.

Por Que Ordering Estável é Mandatório

1
2
3
4
5
6
7
8
9
// ❌ SEM OrderBy — resultado não determinístico
var dados = await context.Pedidos.Skip(40).Take(20).ToListAsync();

// ✅ COM OrderBy — resultado determinístico + índice pode ser usado
var dados = await context.Pedidos
    .OrderBy(p => p.DataCriacao)
    .ThenBy(p => p.Id)        // Desempate pelo campo único garante estabilidade
    .Skip(40).Take(20)
    .ToListAsync();

Sem OrderBy, o banco pode retornar os 20 itens em qualquer ordem, e você corre o risco de ver os mesmos registros em páginas diferentes ou pular registros. Isso é especialmente crítico no Oracle, que tem comportamento de ordenação menos previsível que o SQL Server por padrão.


Estratégia 1: Offset Pagination (SKIP / TAKE)

Quando Usar

  • UIs com número de páginas visível (página 1, 2, 3…)
  • Relatórios com totais exatos necessários
  • Volumes pequenos ou médios (até ~100k registros na tabela)
  • Quando o usuário precisa saltar diretamente para qualquer página

O SQL por Banco

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
-- SQL Server (2012+)
SELECT Id, ClienteId, Valor, DataCriacao
FROM Pedidos
ORDER BY DataCriacao, Id
OFFSET 40 ROWS FETCH NEXT 20 ROWS ONLY;

-- Oracle 12c+
SELECT Id, ClienteId, Valor, DataCriacao
FROM Pedidos
ORDER BY DataCriacao, Id
OFFSET 40 ROWS FETCH NEXT 20 ROWS ONLY;

-- Oracle 11g (sem suporte nativo — ROW_NUMBER workaround)
SELECT * FROM (
    SELECT p.*, ROWNUM AS rn
    FROM (
        SELECT Id, ClienteId, Valor, DataCriacao
        FROM Pedidos
        ORDER BY DataCriacao, Id
    ) p
    WHERE ROWNUM <= 60
)
WHERE rn > 40;

-- PostgreSQL
SELECT Id, ClienteId, Valor, DataCriacao
FROM Pedidos
ORDER BY DataCriacao, Id
LIMIT 20 OFFSET 40;

💡 Dica: O provider Oracle.EntityFrameworkCore detecta automaticamente a versão do Oracle e gera a sintaxe correta (com FETCH FIRST para 12c+ ou ROWNUM para 11g). Não precisa escrever SQL raw para isso.

Implementação com EF Core 8

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// Models reutilizáveis para toda a API
public record PaginacaoRequest(
    int Pagina      = 1,
    int TamanhoPagina = 20)
{
    public int TamanhoSeguro => Math.Clamp(TamanhoPagina, 1, 100);
    public int OffsetSeguro  => (Math.Max(Pagina, 1) - 1) * TamanhoSeguro;
}

public record PaginaResultado<T>(
    IReadOnlyList<T>  Dados,
    int               PaginaAtual,
    int               TamanhoPagina,
    long              TotalRegistros,
    int               TotalPaginas,
    bool              TemProxima,
    bool              TemAnterior);

public class PedidoOffsetService(AppDbContext context)
{
    public async Task<PaginaResultado<PedidoDto>> ListarAsync(
        PaginacaoRequest req,
        CancellationToken ct = default)
    {
        var query = context.Pedidos
            .AsNoTracking()
            .OrderBy(p => p.DataCriacao)
            .ThenBy(p => p.Id);

        // EF Core 8: ExecuteCount() separado sem materializar a coleção
        var total = await query.LongCountAsync(ct);

        var dados = await query
            .Skip(req.OffsetSeguro)
            .Take(req.TamanhoSeguro)
            .Select(p => new PedidoDto(p.Id, p.ClienteId, p.Valor, p.DataCriacao, p.Status))
            .ToListAsync(ct);

        var totalPaginas = (int)Math.Ceiling(total / (double)req.TamanhoSeguro);

        return new PaginaResultado<PedidoDto>(
            Dados:          dados,
            PaginaAtual:    req.Pagina,
            TamanhoPagina:  req.TamanhoSeguro,
            TotalRegistros: total,
            TotalPaginas:   totalPaginas,
            TemProxima:     req.Pagina < totalPaginas,
            TemAnterior:    req.Pagina > 1);
    }
}

⚠️ Atenção: Para tabelas com mais de 500k linhas, o LongCountAsync() por si só pode ser lento (table scan). Uma estratégia comum é cachear o total por 30–60 segundos, ou usar uma coluna de contagem materializada.


Estratégia 2: Keyset Pagination (Seek / WHERE-based)

Quando Usar

  • APIs REST com scroll infinito ou “next page” token
  • Tabelas com milhões de registros onde o OFFSET degrada
  • Feeds de dados em tempo real onde novos itens são inseridos constantemente
  • Exportação assíncrona de grandes volumes

Como Funciona

Em vez de dizer “pule N linhas”, você diz “me dê os registros após este ponto de referência”. O banco usa o índice diretamente para posicionar-se no cursor, sem varrer os registros anteriores.

1
2
3
4
5
6
-- SQL Server / Oracle 12c+ / PostgreSQL — keyset com chave composta
SELECT TOP(21) Id, ClienteId, Valor, DataCriacao
FROM Pedidos
WHERE DataCriacao > '2025-06-01T12:00:00'
   OR (DataCriacao = '2025-06-01T12:00:00' AND Id > 'abc-def-123')
ORDER BY DataCriacao ASC, Id ASC;

O TOP 21 (ou LIMIT 21) é intencional: busca-se um registro a mais para saber se há próxima página, sem fazer um COUNT.

Índice Obrigatório para Keyset

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- SQL Server — índice covering para a query de keyset
CREATE INDEX IX_Pedidos_Keyset
    ON Pedidos (DataCriacao ASC, Id ASC)
    INCLUDE (ClienteId, Valor, Status);

-- Oracle
CREATE INDEX IX_Pedidos_Keyset ON Pedidos (DataCriacao ASC, Id ASC);

-- PostgreSQL
CREATE INDEX IX_Pedidos_Keyset ON Pedidos (DataCriacao ASC, Id ASC)
    INCLUDE (ClienteId, Valor, Status);

Sem esse índice, o banco recai em um full scan mesmo com a cláusula WHERE do keyset.

Implementação com EF Core 8

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// Token de cursor — serializado em Base64 para o cliente (opaco)
public record KeysetCursor(Guid UltimoId, DateTime UltimaData)
{
    public string Encode() =>
        Convert.ToBase64String(
            JsonSerializer.SerializeToUtf8Bytes(this));

    public static KeysetCursor? Decode(string? token)
    {
        if (string.IsNullOrEmpty(token)) return null;
        try
        {
            return JsonSerializer.Deserialize<KeysetCursor>(
                Convert.FromBase64String(token));
        }
        catch { return null; }
    }
}

public record KeysetResultado<T>(
    IReadOnlyList<T> Dados,
    bool             TemProximaPagina,
    string?          ProximoToken);   // Token opaco para o cliente

public class PedidoKeysetService(AppDbContext context)
{
    public async Task<KeysetResultado<PedidoDto>> ListarAsync(
        string?           token,
        int               limite   = 20,
        CancellationToken ct       = default)
    {
        limite = Math.Clamp(limite, 1, 100);
        var cursor = KeysetCursor.Decode(token);

        // Expressão de keyset — funciona sem cursor (primeira página)
        // e com cursor (páginas seguintes)
        var query = context.Pedidos
            .AsNoTracking()
            .Where(p =>
                cursor == null ||
                p.DataCriacao > cursor.UltimaData ||
                (p.DataCriacao == cursor.UltimaData &&
                 p.Id.CompareTo(cursor.UltimoId) > 0))
            .OrderBy(p => p.DataCriacao)
            .ThenBy(p => p.Id)
            .Select(p => new PedidoDto(p.Id, p.ClienteId, p.Valor, p.DataCriacao, p.Status));

        // Busca limite+1 para detectar se há próxima página
        var dados = await query.Take(limite + 1).ToListAsync(ct);

        var temProxima = dados.Count > limite;
        if (temProxima) dados.RemoveAt(dados.Count - 1);

        string? proximoToken = null;
        if (temProxima && dados.Count > 0)
        {
            var ultimo = dados[^1];
            proximoToken = new KeysetCursor(ultimo.Id, ultimo.DataCriacao).Encode();
        }

        return new KeysetResultado<PedidoDto>(dados, temProxima, proximoToken);
    }
}

O token gerado é opaco para o cliente — ele não sabe o que está dentro, apenas passou para a próxima chamada. Isso permite mudar a implementação interna sem quebrar os clientes.


Estratégia 3: Cursor de Banco de Dados (Server-Side Cursor)

O Que é um Cursor de Banco e Quando Usar

Um cursor de banco de dados é diferente do keyset cursor. Aqui, o próprio banco mantém uma posição de leitura aberta entre as chamadas. O servidor reserva recursos (memória, locks ou um snapshot) para aquele conjunto de resultados enquanto o cliente vai buscando blocos.

É a estratégia ideal para:

  • Exportações longas onde o cliente consome os dados em blocos ao longo de segundos ou minutos
  • Processamento ETL linha por linha sem carregar tudo na memória
  • Streaming de dados via WebSocket ou Server-Sent Events
  • Situações onde o conjunto de resultados não pode mudar durante a leitura (snapshot consistency)

⚠️ Atenção: Cursores de banco consomem recursos do servidor enquanto estão abertos. Em SQL Server e Oracle, cursores mal gerenciados (esquecidos abertos) causam memory pressure e até bloqueios. PostgreSQL lida melhor com cursores em transações, mas ainda exige cuidado.

PostgreSQL: DECLARE CURSOR (o mais completo)

O PostgreSQL tem o suporte mais rico a cursores server-side. Para usá-los com EF Core, é necessário executar SQL raw dentro de uma transação, pois os cursores do PostgreSQL existem apenas no escopo de uma transação:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
public class PedidoCursorService(AppDbContext context)
{
    public async IAsyncEnumerable<PedidoDto> StreamAsync(
        int blocoPorFetch = 500,
        [EnumeratorCancellation] CancellationToken ct = default)
    {
        // Cursor PG exige transação ativa durante toda a leitura
        await using var transaction = await context.Database
            .BeginTransactionAsync(ct);

        // DECLARE abre o cursor no servidor
        await context.Database.ExecuteSqlRawAsync(
            "DECLARE pedidos_cursor NO SCROLL CURSOR FOR " +
            "SELECT p.\"Id\", p.\"ClienteId\", p.\"Valor\", p.\"DataCriacao\", p.\"Status\" " +
            "FROM \"Pedidos\" p ORDER BY p.\"DataCriacao\", p.\"Id\"",
            ct);

        try
        {
            while (!ct.IsCancellationRequested)
            {
                // FETCH busca um bloco por vez — nunca tudo na memória
                var bloco = await context.Database
                    .SqlQueryRaw<PedidoDto>(
                        $"FETCH {blocoPorFetch} FROM pedidos_cursor")
                    .ToListAsync(ct);

                if (bloco.Count == 0) break;   // Fim do cursor

                foreach (var item in bloco)
                    yield return item;

                if (bloco.Count < blocoPorFetch) break; // Último bloco parcial
            }
        }
        finally
        {
            // CLOSE libera recursos no servidor — SEMPRE executar
            await context.Database.ExecuteSqlRawAsync(
                "CLOSE pedidos_cursor", CancellationToken.None);

            await transaction.CommitAsync(CancellationToken.None);
        }
    }
}

SQL Server: FAST_FORWARD Cursor via Raw SQL

O SQL Server suporta cursores T-SQL, mas para APIs REST o padrão mais eficiente é usar IAsyncEnumerable com AsAsyncEnumerable() do EF Core ou um cursor via ADO.NET direto:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// SQL Server: streaming com IAsyncEnumerable do EF Core 8
// Internamente usa DataReader que consome linha por linha
public class PedidoStreamService(AppDbContext context)
{
    public async IAsyncEnumerable<PedidoDto> StreamComEfCoreAsync(
        DateTime? dataInicio = null,
        [EnumeratorCancellation] CancellationToken ct = default)
    {
        // AsAsyncEnumerable() não materializa a coleção —
        // mantém o DataReader aberto e lê sob demanda
        var query = context.Pedidos
            .AsNoTracking()
            .Where(p => dataInicio == null || p.DataCriacao >= dataInicio)
            .OrderBy(p => p.DataCriacao)
            .ThenBy(p => p.Id)
            .Select(p => new PedidoDto(p.Id, p.ClienteId, p.Valor, p.DataCriacao, p.Status));

        await foreach (var item in query.AsAsyncEnumerable().WithCancellation(ct))
        {
            yield return item;
        }
    }
}

💡 Dica: AsAsyncEnumerable() no EF Core é implementado como um DataReader que permanece aberto enquanto o IAsyncEnumerable está sendo consumido. Internamente, ele funciona como um cursor de banco implicit. A diferença para um cursor SQL explícito é que o DataReader não permite pausar a leitura e retomar mais tarde em uma nova conexão.

Oracle: REF CURSOR e SYS_REFCURSOR

No Oracle, o equivalente é o REF CURSOR, geralmente exposto via stored procedure. Com o provider Oracle.EntityFrameworkCore, você pode executá-lo assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Oracle: REF CURSOR via stored procedure
// A procedure retorna um SYS_REFCURSOR como parâmetro OUT
public class PedidoOracleService(AppDbContext context)
{
    public async Task<List<PedidoDto>> BuscarViaCursorAsync(
        DateTime dataInicio,
        int quantidade,
        CancellationToken ct = default)
    {
        var param_dataInicio = new OracleParameter("p_data",   OracleDbType.Date)
            { Value = dataInicio };
        var param_qtd        = new OracleParameter("p_qtd",    OracleDbType.Int32)
            { Value = quantidade };
        var param_cursor     = new OracleParameter("p_cursor", OracleDbType.RefCursor)
            { Direction = ParameterDirection.Output };

        // Chama a stored procedure que faz OPEN cursor FOR SELECT ...
        await context.Database.ExecuteSqlRawAsync(
            "BEGIN PKG_PEDIDOS.BUSCAR_PAGINADO(:p_data, :p_qtd, :p_cursor); END;",
            param_dataInicio, param_qtd, param_cursor, ct);

        // Lê o REF CURSOR retornado pelo Oracle
        var refCursor = (OracleRefCursor)param_cursor.Value;
        using var reader = refCursor.GetDataReader();

        var resultado = new List<PedidoDto>();
        while (await reader.ReadAsync(ct))
        {
            resultado.Add(new PedidoDto(
                Id:          reader.GetGuid(0),
                ClienteId:   reader.GetString(1),
                Valor:       reader.GetDecimal(2),
                DataCriacao: reader.GetDateTime(3),
                Status:      reader.GetString(4)));
        }
        return resultado;
    }
}

Estratégia 4: Time-based Pagination

Quando Usar

  • Dados que têm dimensão temporal natural (logs, eventos, transações)
  • APIs de auditoria ou histórico com filtros por janela de tempo
  • Quando o cliente quer dados de uma hora específica (e.g. “pedidos de ontem”)
  • Integração com sistemas de streaming (Kafka, Event Sourcing)

Implementação

A paginação time-based é uma forma especializada de keyset, com a coluna de data como cursor primário. A diferença é que o cliente escolhe a janela de tempo explicitamente:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public record TimePaginacaoRequest(
    DateTime  DataInicio,
    DateTime  DataFim,
    DateTime? UltimaDataVista = null,  // Cursor dentro da janela
    Guid?     UltimoIdVisto   = null,
    int       Limite          = 50);

public class PedidoTimeService(AppDbContext context)
{
    public async Task<KeysetResultado<PedidoDto>> ListarPorJanelaAsync(
        TimePaginacaoRequest req,
        CancellationToken    ct = default)
    {
        var limite = Math.Clamp(req.Limite, 1, 200);

        var query = context.Pedidos
            .AsNoTracking()
            // Janela de tempo fixa — mantém o conjunto estável
            .Where(p => p.DataCriacao >= req.DataInicio &&
                        p.DataCriacao <  req.DataFim)
            // Cursor dentro da janela (para paginação sequencial)
            .Where(p =>
                req.UltimaDataVista == null ||
                p.DataCriacao > req.UltimaDataVista ||
                (p.DataCriacao == req.UltimaDataVista &&
                 p.Id.CompareTo(req.UltimoIdVisto!.Value) > 0))
            .OrderBy(p => p.DataCriacao)
            .ThenBy(p => p.Id)
            .Select(p => new PedidoDto(p.Id, p.ClienteId, p.Valor, p.DataCriacao, p.Status));

        var dados = await query.Take(limite + 1).ToListAsync(ct);
        var temProxima = dados.Count > limite;
        if (temProxima) dados.RemoveAt(dados.Count - 1);

        string? proximoToken = null;
        if (temProxima && dados.Count > 0)
        {
            var ultimo = dados[^1];
            proximoToken = new KeysetCursor(ultimo.Id, ultimo.DataCriacao).Encode();
        }

        return new KeysetResultado<PedidoDto>(dados, temProxima, proximoToken);
    }
}

💡 Dica: A grande vantagem da paginação time-based sobre keyset puro é que o cliente pode escolher janelas imutáveis (e.g. “todo o dia 2025-01-01”), o que permite cache agressivo dessas janelas no servidor, já que o conteúdo não muda depois que a janela fecha.


Quando Usar

  • APIs públicas onde você não quer expor detalhes de implementação ao cliente
  • Quando a estratégia de paginação pode mudar sem quebrar os clientes
  • APIs que precisam de HATEOAS (links de próxima/anterior página na resposta)

O token opaco encapsula qualquer estratégia de cursor internamente:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// Resposta no padrão HATEOAS com links de navegação
public record PaginacaoHateoasResultado<T>(
    IReadOnlyList<T> Dados,
    PaginacaoLinks   Links,
    PaginacaoMeta    Meta);

public record PaginacaoLinks(
    string?  Primeiro,
    string?  Anterior,
    string?  Proximo,
    string?  Ultimo);

public record PaginacaoMeta(
    int  Limite,
    long TotalRegistros,
    bool TemProxima);

// Endpoint que monta a resposta HATEOAS
app.MapGet("/api/v1/pedidos", async (
    HttpContext          httpCtx,
    [FromQuery] string?  cursor,
    [FromQuery] int      limite = 20,
    PedidoKeysetService  service,
    CancellationToken    ct) =>
{
    var resultado = await service.ListarAsync(cursor, limite, ct);
    var baseUrl   = $"{httpCtx.Request.Scheme}://{httpCtx.Request.Host}/api/v1/pedidos";

    return Results.Ok(new PaginacaoHateoasResultado<PedidoDto>(
        Dados:  resultado.Dados,
        Links:  new PaginacaoLinks(
            Primeiro:  $"{baseUrl}?limite={limite}",
            Anterior:  null,  // Keyset não suporta voltar sem histórico
            Proximo:   resultado.TemProximaPagina
                           ? $"{baseUrl}?cursor={resultado.ProximoToken}&limite={limite}"
                           : null,
            Ultimo:    null),
        Meta: new PaginacaoMeta(
            Limite:          limite,
            TotalRegistros:  -1,  // Keyset não calcula total
            TemProxima:      resultado.TemProximaPagina)));
});

Comparativo: Qual Estratégia Usar em Cada Situação

CritérioOffsetKeyset / SeekCursor Server-SideTime-basedToken Opaco
Total de registros✅ Sim❌ Não❌ Não⚠️ Por janela❌ Não
Salto de página✅ Direto❌ Apenas sequencial❌ Apenas sequencial✅ Por janela❌ Apenas sequencial
Performance em pg. tardias❌ Degrada✅ Constante✅ Constante✅ Constante✅ Constante
Registros novos entre páginas❌ Drift✅ Consistente✅ Snapshot✅ Janela fixa✅ Consistente
Complexidade de impl.⭐ Simples⭐⭐ Média⭐⭐⭐ Alta⭐⭐ Média⭐⭐ Média
Recursos no servidorBaixoBaixo🔴 Alto (cursor aberto)BaixoBaixo
Suporte EF Core nativo✅ Completo✅ Com Where custom⚠️ Raw SQL / ADO✅ Com Where custom✅ Sobre keyset
SQL Server✅ OFFSET FETCH✅ WHERE seek✅ FAST_FORWARD cursor✅ WHERE range
Oracle✅ 12c+ / ROWNUM 11g✅ WHERE seek✅ REF CURSOR✅ WHERE range
PostgreSQL✅ LIMIT OFFSET✅ WHERE seek✅ DECLARE CURSOR✅ WHERE range
Melhor paraUI clássicaAPIs / mobileETL / streamingAuditoria / logsAPIs públicas

Comparativo de estratégias: Offset vs Keyset (gráfico de performance) e fluxo de Cursor Server-Side nos três bancos


Índices: A Fundação de Toda Paginação

Nenhuma estratégia de paginação funciona bem sem os índices certos. Para cada banco:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
-- ============================================
-- SQL Server — Índices para paginação
-- ============================================

-- Para Offset por DataCriacao
CREATE INDEX IX_Pedidos_DataCriacao
    ON Pedidos (DataCriacao ASC, Id ASC)
    INCLUDE (ClienteId, Valor, Status);

-- Para filtros frequentes + paginação
CREATE INDEX IX_Pedidos_ClienteId_Data
    ON Pedidos (ClienteId ASC, DataCriacao ASC, Id ASC)
    INCLUDE (Valor, Status);

-- ============================================
-- Oracle — Equivalentes
-- ============================================
CREATE INDEX IX_Pedidos_DataCriacao
    ON Pedidos (DataCriacao ASC, Id ASC);

-- Oracle: statistics importantes para o otimizador
EXECUTE DBMS_STATS.GATHER_TABLE_STATS('SCHEMA', 'PEDIDOS');

-- ============================================
-- PostgreSQL — Com partial index opcional
-- ============================================
CREATE INDEX IX_Pedidos_DataCriacao
    ON Pedidos (DataCriacao ASC, Id ASC)
    INCLUDE (ClienteId, Valor, Status);

-- Partial index para status frequente (ex.: apenas pedidos ativos)
CREATE INDEX IX_Pedidos_Ativos
    ON Pedidos (DataCriacao ASC, Id ASC)
    WHERE Status = 'Ativo';

⚠️ Atenção Oracle: O Oracle não suporta INCLUDE columns em índices regulares (diferente de SQL Server e PostgreSQL). Para covering indexes no Oracle, use Composite Indexes ou Index-Organized Tables (IOT) para tabelas de alto volume de leitura.


Middleware de Paginação Reutilizável para ASP.NET Core

Para APIs REST com múltiplos endpoints, criar um middleware de paginação evita duplicação:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// Extensions para registrar os serviços e configurar resposta padrão
public static class PaginacaoExtensions
{
    // Header padrão de paginação (common em APIs REST)
    public static IEndpointConventionBuilder ComPaginacao(
        this IEndpointConventionBuilder builder)
    {
        // Adiciona headers de documentação no OpenAPI
        builder.WithOpenApi(op =>
        {
            op.Parameters.Add(new OpenApiParameter
            {
                Name     = "cursor",
                In       = ParameterLocation.Query,
                Required = false,
                Schema   = new OpenApiSchema { Type = "string" },
                Description = "Token de cursor para próxima página (keyset pagination)"
            });
            op.Parameters.Add(new OpenApiParameter
            {
                Name     = "limite",
                In       = ParameterLocation.Query,
                Required = false,
                Schema   = new OpenApiSchema { Type = "integer", Default = new OpenApiInteger(20) }
            });
            return op;
        });
        return builder;
    }

    // Adiciona Link header HTTP padrão RFC 5988
    public static void AdicionarLinkHeader<T>(
        this HttpContext ctx,
        KeysetResultado<T> resultado,
        string baseUrl)
    {
        if (resultado.ProximoToken != null)
            ctx.Response.Headers.Append(
                "Link",
                $"<{baseUrl}?cursor={resultado.ProximoToken}>; rel=\"next\"");
    }
}

Dicas e Boas Práticas

  • Nunca retorne sem Take/Limit: Sempre aplique um limite máximo na camada de serviço, independente do que o cliente enviou. Exponha isso via validação de request.
  • Keyset exige chave composta estável: Use sempre (coluna_ordenacao, id_unico) como cursor composto. Apenas a coluna de ordenação pode ter duplicatas, o Id garante unicidade e estabilidade.
  • Cursor de banco: sempre feche: Para cursores server-side (PostgreSQL DECLARE, Oracle REF CURSOR), use try/finally para garantir o CLOSE. Um cursor esquecido aberto no Oracle ou SQL Server consome recursos do servidor indefinidamente.
  • Documente o tipo de paginação na OpenAPI: Indique no Swagger qual estratégia cada endpoint usa — clientes precisam saber se podem usar salto de página ou apenas avançar sequencialmente.
  • Versione a estratégia de paginação: Se você mudar de Offset para Keyset em um endpoint existente, versione a API (/v2/pedidos) para não quebrar clientes que dependem do campo totalPaginas.
  • Monitor de queries lentas: Ative o Query Store (SQL Server), AWR (Oracle) ou pg_stat_statements (PostgreSQL) para capturar queries de paginação que ultrapassam SLAs.
  • Prefira projeções com Select(): Nunca retorne a entidade completa em endpoints de listagem. Selecione apenas os campos necessários para reduzir I/O e alocação de memória.

Conclusão

Paginar dados em APIs REST com C# e EF Core 8 vai muito além de .Skip().Take(). Cada estratégia — Offset, Keyset, Cursor server-side, Time-based e Token opaco — existe por um motivo e serve a cenários distintos. A escolha errada resulta em queries lentas, inconsistências de dados ou consumo desnecessário de recursos no banco.

O caminho mais seguro é: comece com Offset para UIs simples com volumes controlados, migre para Keyset conforme a tabela cresce e o offset começa a degradar, use Cursor server-side apenas para streaming e ETL com cuidado no gerenciamento do ciclo de vida, e Time-based para dados com dimensão temporal natural.

SQL Server, Oracle e PostgreSQL suportam todas essas estratégias — as diferenças estão na sintaxe e nos detalhes de implementação, mas o EF Core 8 abstrai a maior parte delas. O que o EF Core não faz por você é criar os índices certos: essa é sempre sua responsabilidade.

Se você chegou aqui buscando entender como gargalos de banco afetam não só a leitura, mas também a escrita em massa, continue com o artigo Gargalo em Banco de Dados com C# e EF Core: Mensageria e Paginação, que aborda a solução via mensageria (RabbitMQ e Azure Service Bus) para o lado da escrita.


Leia Também


Referências