EF Core com Desempenho: N+1, AsNoTracking e o SQL por Trás de Cada Consulta

EF Core com Desempenho: N+1, AsNoTracking e o SQL por Trás de Cada Consulta

Como enxergar e corrigir o SQL que o EF Core gera: ToQueryString e TagWith, o problema N+1, explosão cartesiana e AsSplitQuery, AsNoTracking e projeções, paginação por chave, ExecuteUpdate, FromSql seguro, consultas compiladas e concorrência otimista na venda do último exemplar.
Linguagem C#

• • 21 min de leitura

O Entity Framework gera SQL sozinho, e é justamente por isso que ele pode gerar SQL ruim sem que você perceba: desempenho com ORM começa por enxergar o que ele manda ao banco.

"EF Core: Relacionamentos e Migrations" terminou com um alerta. A mesma facilidade que permite escrever consultas em LINQ e gravar objetos sem SQL esconde o custo de cada operação. Um laço inocente pode disparar cem consultas onde uma bastaria. Uma listagem somente leitura pode gastar memória rastreando objetos que ninguém vai alterar. E duas vendas simultâneas do último exemplar podem ser gravadas as duas, porque o ORM grava o valor que calculou, e não a condição que o banco verificaria. Nesta aula aprendemos a enxergar o SQL que o EF Core gera e as técnicas que fazem a diferença entre um sistema que responde em milissegundos e um que engasga com a primeira centena de clientes.

Primeiro, enxergar

Toda otimização começa por uma medição, e com um ORM a primeira medição é ler o SQL. Já usamos o LogTo, que imprime cada comando executado. Há duas ferramentas complementares:

IQueryable<Produto> consulta = contexto.Produtos
    .TagWith("Listagem do catálogo: tela inicial")
    .Where(p => p.Preco < 40m)
    .OrderBy(p => p.Titulo);

Console.WriteLine(consulta.ToQueryString());

ToQueryString() devolve o SQL que seria executado, sem executá-lo: é ótimo para investigar uma consulta específica ou para um teste que verifica se uma consulta continua eficiente. TagWith acrescenta um comentário ao SQL gerado, e esse comentário aparece nos logs do banco de dados, o que permite a quem administra o banco encontrar, entre milhares de consultas, qual trecho do código a gerou.

Um hábito que vale para o curso inteiro: durante o desenvolvimento, mantenha o log de SQL ligado e conte as consultas de cada operação. Se abrir uma tela gera quarenta consultas, há algo errado, mesmo que ela pareça rápida com dez registros de teste.

O problema N+1

O problema de desempenho mais comum com ORMs tem nome próprio. Veja este relatório, que lista os autores com a quantidade de livros de cada um:

List<Autor> autores = await contexto.Set<Autor>().ToListAsync();   // 1 consulta

foreach (Autor autor in autores)
{
    await contexto.Entry(autor).Collection(a => a.Produtos).LoadAsync();   // 1 consulta POR autor
    Console.WriteLine($"{autor.Nome}: {autor.Produtos.Count}");
}

O código corrige o erro do exercício 2 da aula anterior, a navegação vazia, carregando explicitamente os produtos de cada autor. E funciona. Mas o log revela o custo: uma consulta para os autores e mais uma para cada autor. Com 500 autores, são 501 idas ao banco. Esse padrão, 1 consulta seguida de N consultas, é o problema N+1.

Com o banco na mesma máquina, cada ida leva frações de milissegundo, e o problema passa despercebido. Com o banco num servidor, a latência de rede de cada ida (vamos supor 2 ms) vira um segundo inteiro de espera para uma tela que deveria ser instantânea. E o custo cresce com os dados: o relatório que era rápido no lançamento fica lento seis meses depois, sem nenhuma mudança no código.

O N+1 é ainda mais traiçoeiro com o carregamento preguiçoso (lazy loading), um recurso opcional do EF Core em que acessar uma navegação não carregada dispara uma consulta automaticamente. O código fica idêntico ao do exercício 2 da aula anterior, com autor.Produtos.Count dentro do laço, e parece não fazer nada de especial, mas cada iteração vai ao banco em silêncio. É por isso que o carregamento preguiçoso vem desligado e que muitas equipes preferem mantê-lo assim: ele esconde exatamente o custo que precisamos enxergar.

As correções são as duas ferramentas da aula anterior. Com Include, uma única consulta traz autores e produtos juntos:

List<Autor> autores = await contexto.Set<Autor>()
    .Include(a => a.Produtos)
    .ToListAsync();   // 1 consulta, com JOIN

E, quando só a contagem importa, a projeção traz apenas os números:

var contagens = await contexto.Set<Autor>()
    .Select(a => new { a.Nome, Livros = a.Produtos.Count })
    .ToListAsync();   // 1 consulta, com subconsulta de contagem, sem carregar produtos

A explosão cartesiana e as consultas divididas

O Include tem seu próprio risco. Imagine carregar pedidos com duas coleções ao mesmo tempo, os itens e, digamos, o histórico de mudanças de status. Um único JOIN entre as três tabelas produz uma linha para cada combinação de item e registro de histórico: um pedido com 10 itens e 10 registros de histórico gera 100 linhas, com os dados do pedido repetidos em todas. Com mais coleções e mais registros, o volume transferido explode. Isso é a explosão cartesiana.

Para esses casos, AsSplitQuery() instrui o EF Core a dividir o carregamento em uma consulta por coleção, e a montar os objetos na memória:

var pedidos = await contexto.Set<Pedido>()
    .Include(p => p.Itens)
    .Include(p => p.Historico)
    .AsSplitQuery()
    .ToListAsync();   // 3 consultas, sem repetição de dados

É uma troca: mais idas ao banco, porém muito menos dados. E há uma sutileza. Como são consultas separadas, se outro usuário alterar os dados entre uma e outra, os objetos montados podem não ser perfeitamente coerentes entre si, a menos que tudo rode numa transação. Para uma única coleção, a consulta única é quase sempre a melhor escolha. Para várias coleções grandes, meça as duas formas.

Somente leitura: AsNoTracking

Em "Entity Framework Core: Objetos no Banco Sem Escrever SQL" vimos que o contexto rastreia cada entidade carregada, guardando uma cópia dos valores originais para detectar mudanças. Numa tela que só exibe dados, como o catálogo, uma listagem ou um relatório, esse trabalho é puro desperdício de memória e processamento. AsNoTracking() o desliga:

List<Produto> catalogo = await contexto.Produtos
    .AsNoTracking()
    .OrderBy(p => p.Titulo)
    .ToListAsync();

Em consultas que devolvem muitas entidades, o ganho é considerável. Quando um contexto é usado só para leitura, o padrão pode ser mudado para todas as consultas dele: contexto.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;.

Melhor ainda que não rastrear entidades é não carregar entidades: uma projeção com Select traz só as colunas usadas e não rastreia nada, porque tipos anônimos e records de projeção não são entidades.

var linhas = await contexto.Produtos
    .OrderBy(p => p.Titulo)
    .Select(p => new { p.Id, p.Titulo, Autor = p.Autor.Nome, p.Preco })
    .ToListAsync();

Repare que p.Autor.Nome dentro da projeção não precisa de Include: o EF Core percebe a navegação e gera o JOIN sozinho. Para telas de listagem, a projeção é quase sempre a forma mais eficiente.

Paginação que não piora com o tempo

Em "Genéricos" e em "LINQ, Parte I" paginamos com Skip e Take, que o EF Core traduz para LIMIT e OFFSET. Funciona bem nas primeiras páginas, mas esconde um custo: para entregar a página 5.000, o banco precisa percorrer e descartar todas as linhas das 4.999 páginas anteriores. Quanto mais fundo, mais lento.

A alternativa para listagens longas, como um histórico de vendas percorrido página a página, é a paginação por chave (keyset pagination): em vez de "pule N linhas", pede-se "as próximas linhas depois da última que você viu":

List<Venda> proximaPagina = await contexto.Set<Venda>()
    .AsNoTracking()
    .Where(v => v.Id > ultimoIdVisto)
    .OrderBy(v => v.Id)
    .Take(50)
    .ToListAsync();

Com um índice na coluna de ordenação (e a chave primária sempre tem um), o banco vai direto à posição certa, e a página 5.000 custa o mesmo que a primeira. A troca é que não dá para saltar diretamente para "a página 5.000": só é possível avançar a partir de onde se está, o que é exatamente o comportamento de uma rolagem infinita.

Operações em lote: ExecuteUpdate e ExecuteDelete

Suponha que chegou uma remessa e todos os livros impressos de um autor ganham cinco exemplares. Com o rastreamento de mudanças, seria preciso carregar todos esses livros, alterar cada um e salvar: uma consulta grande e depois um UPDATE por livro. Desde o EF Core 7, uma operação em lote vira um único comando no banco, sem carregar nada:

int atualizados = await contexto.Produtos
    .OfType<LivroImpresso>()
    .Where(l => l.AutorId == autorId)
    .ExecuteUpdateAsync(s => s.SetProperty(l => l.Estoque, l => l.Estoque + 5));
UPDATE produtos SET estoque = estoque + 5
WHERE tipo = 'impresso' AND autor_id = @autorId

Da mesma forma, ExecuteDeleteAsync() apaga as linhas que satisfazem o filtro com um único DELETE.

A honestidade exige um alerta forte: essas operações contornam o domínio. Nenhum objeto é carregado, então nenhuma validação do Repor roda, nenhum evento EstoqueBaixo é disparado, e as entidades que já estiverem rastreadas no contexto não são atualizadas na memória, ficando defasadas em relação ao banco. Use-as para operações de manutenção em massa, cujas regras você conhece e garante no próprio comando, e não para operações de negócio que dependem das regras do domínio.

SQL quando o LINQ não basta

Algumas consultas são mais claras, ou só são possíveis, em SQL: relatórios complexos, funções específicas do banco. O EF Core permite escrevê-las sem abrir mão da segurança, com interpolação que vira parâmetro:

string termo = "sertão";
List<Produto> encontrados = await contexto.Produtos
    .FromSql($"SELECT * FROM produtos WHERE lower(titulo) LIKE '%' || {termo} || '%'")
    .AsNoTracking()
    .ToListAsync();

À primeira vista, isso parece a concatenação perigosa de "ADO.NET: Conexões, Comandos Parametrizados e o Perigo do SQL Injection". Não é. FromSql recebe um FormattableString, uma string interpolada que ainda não foi montada, e o EF Core transforma cada valor interpolado num parâmetro de verdade. O termo nunca vira código. O perigo está no irmão, FromSqlRaw, que aceita uma string já montada: use-o só com texto constante e parâmetros explícitos. E o resultado de FromSql continua sendo um IQueryable: dá para compor Where, OrderBy e Include por cima.

Para resultados que não são entidades, como as linhas de um relatório, existe Database.SqlQuery<T>:

var faturamento = await contexto.Database
    .SqlQuery<FaturamentoPorAutor>($"""
        SELECT a.nome AS autor, SUM(v.quantidade * p.preco_centavos) AS centavos
        FROM vendas v JOIN produtos p ON p.id = v.produto_id JOIN autores a ON a.id = p.autor_id
        GROUP BY a.nome
        """)
    .ToListAsync();

public sealed record FaturamentoPorAutor(string Autor, long Centavos);

É o papel que o Dapper cumpria, agora dentro do EF Core, com o mesmo contexto e a mesma conexão.

Consultas compiladas e pool de contextos

Cada vez que uma consulta LINQ é executada, o EF Core precisa analisar a árvore de expressão e produzir o SQL. Ele guarda esse trabalho num cache, mas mesmo encontrar a consulta no cache tem um custo. Para consultas executadas milhares de vezes por segundo, como "obter um produto pelo Id" numa API muito acessada, uma consulta compilada faz a tradução uma única vez:

private static readonly Func<LivrariaContext, int, Task<Produto?>> ObterPorId =
    EF.CompileAsyncQuery((LivrariaContext c, int id) => c.Produtos.FirstOrDefault(p => p.Id == id));

Produto? produto = await ObterPorId(contexto, 42);

O ganho é pequeno por chamada e só aparece em caminhos realmente quentes. Não saia compilando todas as consultas: meça antes, com as ferramentas da Fase 10. Na mesma linha, o pool de contextos reaproveita instâncias de DbContext em vez de criar uma nova a cada requisição. Ele é configurado com uma linha na injeção de dependência da API, como veremos na Fase 6.

Concorrência otimista: a venda do último exemplar, de novo

Voltamos a um problema que já resolvemos duas vezes. Em "Concorrência" usamos um lock, que só vale dentro de um processo. Em "ADO.NET", uma condição dentro do UPDATE, que o banco verifica. Com o EF Core, o problema reaparece de forma sutil. Dois caixas, cada um com seu próprio contexto, carregam Grande Sertão com 1 exemplar em estoque. Cada um chama Vender(1) no seu objeto, que passa na validação, porque cada um vê 1 exemplar, e muda o estoque para zero. Cada um salva, e o EF Core gera, duas vezes:

UPDATE produtos SET estoque = 0 WHERE id = 6;

As duas gravações têm sucesso. O estoque termina em zero, correto por coincidência, mas duas vendas do único exemplar foram confirmadas. É a atualização perdida (lost update): o segundo gravou um valor calculado a partir de um dado que já estava velho.

A solução do EF Core se chama concorrência otimista. Uma propriedade é marcada como token de concorrência, e o EF Core passa a incluí-la na condição de todo UPDATE, com o valor que tinha quando a entidade foi carregada. Para o estoque, o próprio estoque é o token ideal:

public void Configure(EntityTypeBuilder<LivroImpresso> builder)
{
    builder.Ignore(l => l.LimiteEstoqueBaixo);
    builder.Property(l => l.Estoque).IsConcurrencyToken();
}

Agora cada caixa gera:

UPDATE produtos SET estoque = 0 WHERE id = 6 AND estoque = 1;

O primeiro afeta uma linha. O segundo encontra o estoque já em zero, a condição falha, e ele afeta zero linhas. O EF Core percebe que a gravação não aconteceu e lança DbUpdateConcurrencyException. É exatamente a condição do UPDATE de "ADO.NET", gerada automaticamente. O nome "otimista" vem da estratégia: não se trava nada ao ler, na aposta de que conflitos são raros, e o conflito é detectado na gravação, quando existe.

Tratar o conflito é decisão de negócio. Para uma venda, o natural é recarregar o dado atual e tentar de novo, e a nova tentativa vai esbarrar corretamente na regra de estoque do domínio:

static async Task VenderComNovaTentativaAsync(LivrariaContext contexto, int produtoId, int quantidade)
{
    for (int tentativa = 1; tentativa <= 3; tentativa++)
    {
        var livro = await contexto.Produtos.OfType<LivroImpresso>().SingleAsync(l => l.Id == produtoId);
        livro.Vender(quantidade);   // lança EstoqueInsuficienteException se não houver mais
        try
        {
            await contexto.SaveChangesAsync();
            return;
        }
        catch (DbUpdateConcurrencyException)
        {
            contexto.ChangeTracker.Clear();   // descarta a versão velha e tenta com dados novos
        }
    }
    throw new InvalidOperationException("Não foi possível concluir a venda por excesso de concorrência.");
}

Para entidades sem uma propriedade natural como o estoque, o padrão é uma coluna de versão, um número incrementado a cada gravação ou, no SQL Server, uma coluna rowversion mantida pelo próprio banco. Ela funciona como token de concorrência para a linha inteira.

Juntando tudo: o catálogo rápido

Um serviço de leitura para a vitrine da Livraria, que reúne as técnicas da aula:

public sealed record ItemVitrine(int Id, string Titulo, string Autor, string Formato, decimal Preco, bool Disponivel);

public sealed class Vitrine(LivrariaContext contexto)
{
    public Task<List<ItemVitrine>> PaginaAsync(int ultimoIdVisto, int tamanho, CancellationToken ct = default) =>
        contexto.Produtos
            .TagWith("Vitrine: página por chave")
            .Where(p => p.Id > ultimoIdVisto)
            .OrderBy(p => p.Id)
            .Take(tamanho)
            .Select(p => new ItemVitrine(
                p.Id,
                p.Titulo,
                p.Autor.Nome,
                p is LivroImpresso ? "impresso" : p is Ebook ? "e-book" : "audiolivro",
                p.Preco,
                !(p is LivroImpresso) || ((LivroImpresso)p).Estoque > 0))
            .ToListAsync(ct);
}

Uma única consulta, com o JOIN do autor gerado pela navegação. Nenhuma entidade carregada nem rastreada. Só as seis colunas exibidas trafegam. A paginação por chave custa o mesmo em qualquer ponto do catálogo. E a marca TagWith identifica a consulta nos logs do banco. Repare que a projeção usa is e uma conversão explícita em vez de Formato e Disponivel, as propriedades calculadas do domínio: elas são código C#, que o EF Core não consegue traduzir. Aqui, ao contrário do Where de "Entity Framework Core", usá-las não dá erro: na projeção final o EF Core aceita avaliar no cliente, mas, para calcular uma propriedade da entidade, traz todas as colunas dela (medido: o SELECT passa a incluir tamanho_mb, peso_gramas e o resto), justamente o que a projeção queria evitar. Expressar a lógica com construções que o provedor conhece mantém o trabalho no banco e o tráfego no mínimo.

Até aqui, toda a Fase 5 usou o SQLite, um banco num arquivo, perfeito para aprender e para aplicações locais, mas limitado para um sistema com muitos usuários: ele serializa as gravações e não tem acesso pela rede, usuários e permissões. O próximo passo é um banco de dados servidor, com tipos que o SQLite não conhece, como um decimal exato, listas e documentos JSON dentro das colunas. E, com ele, a Livraria finalmente se livra da conversão para centavos.

Fontes e leituras recomendadas

Exercícios

Exercício 1

O trecho abaixo gera um relatório de pedidos com o nome de cada produto vendido. Com o log ligado, conte as consultas para 100 pedidos e reescreva-o com uma única consulta.

var pedidos = await contexto.Set<Pedido>().ToListAsync();
foreach (var pedido in pedidos)
    foreach (var item in pedido.Itens)
    {
        var produto = await contexto.Produtos.FindAsync(item.ProdutoId);
        Console.WriteLine($"{pedido.Numero}: {produto!.Titulo}");
    }
Ver resposta

✓ Resposta: Há uma consulta para os pedidos (os itens vêm junto, por serem tipos próprios) e mais uma por item, porque FindAsync consulta o banco sempre que o produto ainda não está rastreado. Com 100 pedidos de 3 itens, seriam até 301 consultas, um N+1 aninhado. (FindAsync evita a ida ao banco quando o produto já foi carregado antes pelo mesmo contexto, o que atenua, mas não resolve.) Neste caso, a correção mais simples nem precisa de junção: o item já guarda o título como fotografia, desde a aula anterior.

var linhas = await contexto.Set<Pedido>()
    .AsNoTracking()
    .SelectMany(p => p.Itens.Select(i => new { p.Numero, i.Titulo }))
    .ToListAsync();

foreach (var linha in linhas)
    Console.WriteLine($"{linha.Numero}: {linha.Titulo}");

Uma única consulta, somente leitura, trazendo só as duas colunas usadas. Se o relatório precisasse de dados atuais do produto (o preço de hoje, por exemplo), a projeção faria a junção com os produtos pelo ProdutoId, ainda numa única consulta. A lição dupla: desconfie de qualquer consulta dentro de um laço, e aproveite as decisões de modelagem, como a fotografia do título, que tornam consultas mais baratas.

Exercício 2

Use ToQueryString() na consulta abaixo e explique o que acontece com o valor da variável limite no SQL gerado.

decimal limite = 40m;
var consulta = contexto.Produtos.Where(p => p.Preco < limite).OrderBy(p => p.Preco);
Ver resposta

✓ Resposta: O SQL mostra algo como WHERE p.preco_centavos < @limite, precedido de uma declaração do parâmetro com o valor 4000 (os 40 reais já convertidos em centavos pelo conversor). O EF Core percebe que limite é uma variável capturada pela lambda, a closure de "Delegates, Lambdas e Eventos", e a transforma num parâmetro, e não num valor fixo colado no texto. Isso tem duas consequências boas. Segurança: o valor nunca é interpretado como SQL. Eficiência: a mesma consulta com limite = 50 produz o mesmo texto SQL com outro valor de parâmetro, e tanto o cache de consultas do EF Core quanto o plano de execução do banco são reaproveitados. Se o valor fosse escrito como constante no LINQ (p.Preco < 40m), ele apareceria fixo no SQL. Isso é correto, mas cada valor diferente gera uma consulta diferente para o banco analisar.

Exercício 3

Apague, com uma única operação no banco, todas as vendas anteriores a 1º de janeiro de 2025. Que cuidados essa operação exige em relação ao domínio e ao contexto?

Ver resposta

✓ Resposta:

var corte = new DateOnly(2025, 1, 1);
int removidas = await contexto.Set<Venda>()
    .Where(v => v.Data < corte)
    .ExecuteDeleteAsync();
Console.WriteLine($"{removidas} vendas antigas removidas.");

O EF Core envia um único DELETE ... WHERE data < @corte, sem carregar nenhuma venda. Os cuidados: primeiro, a operação contorna o domínio, então nenhuma regra, validação ou evento associado à remoção de uma venda é executado. Se houver regras (registrar em auditoria, impedir a remoção de vendas de um período fiscal fechado), elas precisam ser garantidas de outra forma, no próprio filtro ou numa etapa anterior. Segundo, entidades de venda que já estiverem rastreadas no mesmo contexto continuam na memória como se existissem, e um SaveChanges posterior que as altere falharia ou produziria resultados confusos. O ideal é executar operações em lote num contexto novo ou limpar o rastreamento com ChangeTracker.Clear(). Terceiro, é uma remoção definitiva de dados históricos: numa situação real, faça-a dentro de uma transação, depois de conferir a contagem com um CountAsync sobre o mesmo filtro, o mesmo hábito do SELECT antes do DELETE de "SQL Essencial com SQLite".

Exercício 4

Simule os dois caixas da aula com dois contextos sobre o mesmo banco, cada um vendendo o último exemplar de Grande Sertão. Mostre o comportamento sem e com IsConcurrencyToken() no estoque.

Ver resposta

✓ Resposta:

await using var caixa1 = new LivrariaContext(opcoes);
await using var caixa2 = new LivrariaContext(opcoes);

var livro1 = await caixa1.Produtos.OfType<LivroImpresso>().SingleAsync(l => l.Titulo == "Grande Sertão: Veredas");
var livro2 = await caixa2.Produtos.OfType<LivroImpresso>().SingleAsync(l => l.Titulo == "Grande Sertão: Veredas");
// suponha estoque = 1 nos dois

livro1.Vender(1);
livro2.Vender(1);   // passa: este objeto ainda vê 1 exemplar

await caixa1.SaveChangesAsync();
try
{
    await caixa2.SaveChangesAsync();
    Console.WriteLine("Segunda venda gravada (atualização perdida!)");
}
catch (DbUpdateConcurrencyException)
{
    Console.WriteLine("Conflito detectado: o estoque mudou desde a leitura.");
}

Sem o token, os dois UPDATEs são SET estoque = 0 WHERE id = 6, os dois afetam uma linha, e a mensagem "Segunda venda gravada" aparece: duas vendas confirmadas para um único exemplar. Com IsConcurrencyToken(), o segundo UPDATE é ... WHERE id = 6 AND estoque = 1. Como o estoque já é zero, ele afeta zero linhas, e o EF Core lança DbUpdateConcurrencyException. O caixa 2 pode então recarregar o livro, e o Vender(1), agora sobre o estoque real de zero, lança a EstoqueInsuficienteException do domínio, com a mensagem correta para o cliente. A regra de negócio e a proteção de concorrência se complementam: o domínio decide se a venda é válida, e o token garante que essa decisão foi tomada sobre dados atuais.

Exercício 5

Explique por que Skip(250_000).Take(50) fica cada vez mais lento à medida que a tabela de vendas cresce, e quando a paginação por chave não é uma boa substituta.

Ver resposta

✓ Resposta: Skip vira OFFSET 250000, e o banco não tem como saltar diretamente para a posição 250.000 de um resultado ordenado. Ele precisa produzir as linhas na ordem, contar e descartar as 250.000 primeiras, e só então entregar as 50 seguintes. O custo cresce linearmente com o deslocamento: a página 5.000 é milhares de vezes mais cara que a primeira, e fica mais cara conforme a tabela cresce, mesmo que ninguém mude o código. A paginação por chave (WHERE id > @ultimo ORDER BY id LIMIT 50) usa o índice para ir direto à posição, com custo constante. Ela não substitui bem o deslocamento quando o usuário precisa saltar para uma página arbitrária ("ir para a página 37") ou ver o número total de páginas, porque só sabe avançar a partir de um ponto conhecido. Também exige uma ordenação por uma chave única e estável: ordenar por preço, que se repete, exige combinar preço e Id na chave (WHERE (preco, id) > (@preco, @id)). A escolha prática: paginação por chave para rolagem contínua e históricos longos, e deslocamento para listagens curtas em que a navegação por número de página importa.

Comentários

Mais em Linguagem C#

Tempo real com SignalR: quando o servidor toma a iniciativa
Tempo real com SignalR: quando o servidor toma a iniciativa

A Livraria passa a avisar quem está conectado: polling, SSE e WebSockets, hubs…

Operadores, Texto e Conversa com o Usuário
Operadores, Texto e Conversa com o Usuário

Operadores e texto em C#: a divisão inteira que perde a vírgula, o resto com…

Input, física e colisões: fazendo o jogo parecer justo
Input, física e colisões: fazendo o jogo parecer justo

O que faz um jogo parecer justo: ações no lugar de teclas, pulo com buffer…