DEV Community

Cristiano Rodrigues for Unhacked

Posted on

Streaming de JSON no ASP.NET Core: o endpoint de ingestão que para de estourar a memória

Receber um array de JSON parece a operação mais banal do mundo. Você declara um List<T> no parâmetro, decora com [FromBody], e o ASP.NET Core entrega a coleção pronta. Funciona em todo tutorial, funciona no Postman, funciona no ambiente de desenvolvimento. Funciona até o dia em que um cliente manda cem mil objetos numa única requisição e o seu processo de 8 GB cai com OutOfMemoryException.

Como um único POST derruba um servidor inteiro? E por que trocar três linhas de código resolve o problema sem mexer numa vírgula da sua regra de negócio? O System.Text.Json ganhou no .NET 6 um método pouco usado, DeserializeAsyncEnumerable, que muda o momento em que os objetos passam a existir na memória. Vamos entender por que isso importa, medir o ganho com cuidado e, principalmente, ver onde essa abordagem cobra o seu preço.

O cenário concreto

Imagine um endpoint de ingestão. Um sistema parceiro consolida cadastros e, uma vez por hora, despeja tudo de uma vez:

[
  { "id": 1, "name": "Ana",   "email": "ana@exemplo.dev" },
  { "id": 2, "name": "Bruno", "email": "bruno@exemplo.dev" },
  ...
]
Enter fullscreen mode Exit fullscreen mode

O modelo é trivial:

public sealed class Person
{
    public int Id { get; init; }

    [Required]
    [StringLength(120)]
    public string Name { get; init; } = string.Empty;

    [Required]
    [EmailAddress]
    public string Email { get; init; } = string.Empty;
}
Enter fullscreen mode Exit fullscreen mode

E o endpoint que todo mundo escreve primeiro é este:

[ApiController]
[Route("people")]
public sealed class PeopleController : ControllerBase
{
    private readonly IPersonRepository _repository;

    public PeopleController(IPersonRepository repository)
        => _repository = repository;

    [HttpPost]
    public async Task<IActionResult> Ingest([FromBody] List<Person> people, CancellationToken ct)
    {
        foreach (var person in people)
            await _repository.InsertAsync(person, ct);

        return Ok(new { count = people.Count });
    }
}
Enter fullscreen mode Exit fullscreen mode

Com mil objetos, ele voa. Com cem mil, ele engasga. Com um cliente mal comportado mandando lotes grandes em paralelo, ele leva o processo inteiro junto. A pergunta é por quê, e a resposta não é a óbvia.

O que realmente acontece com [FromBody]

Existe um mito de que o ASP.NET Core "recebe o corpo inteiro primeiro" e só depois começa a trabalhar. Não é bem assim, e a distinção importa.

O Kestrel lê o corpo da requisição direto do socket de forma incremental, em blocos, alimentando um PipeReader. Essa parte já é streaming desde sempre. O problema não está na leitura do socket.

O problema é o que vem depois. O input formatter de JSON chama JsonSerializer.DeserializeAsync<List<Person>> sobre esse stream. O desserializador consome o corpo em pequenos blocos, mas o objetivo dele é construir a List<Person> completa. Cada objeto do array vira uma instância viva, e todas permanecem vivas, porque a lista segura uma referência para cada uma delas. Só quando o último elemento é reconhecido e a lista está montada é que o seu método de action começa a rodar.

Resumindo a frase mais precisa que o seu mapa mental precisa guardar:

O corpo já é lido em streaming pelo servidor, mas o model binder precisa concluir a desserialização inteira e segurar a List<Person> completa na memória antes que a sua action execute.

O pico de memória é proporcional ao tamanho do payload, e você não tem nenhum controle sobre ele. Cem mil objetos viram cem mil instâncias vivas ao mesmo tempo, mais o array interno da lista, mais os buffers transitórios do parser. Multiplique isso por requisições concorrentes e você tem a receita do OutOfMemoryException.

Por que o streaming funciona

A virada de chave é JsonSerializer.DeserializeAsyncEnumerable<Person>. Ele lê exatamente o mesmo stream do corpo, em blocos, mas em vez de acumular tudo numa lista, ele devolve um IAsyncEnumerable<Person> e produz cada objeto conforme o parser o reconhece.

A diferença não está na lógica de negócio. Está no momento em que os objetos passam a existir na memória, e por quanto tempo eles permanecem vivos.

Quando você consome esse IAsyncEnumerable com await foreach e processa cada item sem guardá-lo, o objeto anterior fica elegível para coleta pelo GC no instante em que você avança para o próximo. Elegível não quer dizer coletado na hora, mas quer dizer que ele não impede mais a memória de ser reaproveitada. O conjunto de objetos vivos ao mesmo tempo deixa de crescer com o array e passa a ser um valor pequeno e aproximadamente constante.

Repare na palavra "aproximadamente". É honesto não vender mais do que se entrega. Mesmo em streaming, ao mesmo tempo existem na memória o buffer interno de leitura, o estado do Utf8JsonReader, os buffers do PipeReader, o objeto que está sendo construído e algumas strings temporárias. Não é literalmente um único objeto. É um número pequeno e constante de objetos vivos por vez, em vez de um número que cresce na proporção do payload. Essa é a propriedade que muda tudo.

Lado a lado, a diferença entre os dois caminhos fica visível. No tradicional, a List<Person> inteira precisa existir antes que a action comece:

Comparação entre o fluxo tradicional e o fluxo streaming de desserialização no ASP.NET Core. No fluxo tradicional com [FromBody] List<Person>, a requisição passa por Cliente, Kestrel, PipeReader e DeserializeAsync<List<Person>>, materializando os 100.000 objetos vivos numa List<Person> completa antes de a action executar, com a memória crescendo proporcionalmente ao payload. No fluxo streaming com DeserializeAsyncEnumerable<Person>, a mesma sequência termina em await foreach processando um item por vez, mantendo aproximadamente um lote vivo e memória constante independente do tamanho do payload.

À esquerda, todo o array vira objeto vivo antes do primeiro passo de processamento. À direita, cada objeto existe apenas durante o processamento daquele passo e, em seguida, torna-se elegível para coleta pelo GC.

Guarde esta frase, porque ela é a ponte para a seção de benchmark mais adiante: o número total de objetos criados é praticamente o mesmo nas duas abordagens. O que muda é quantos permanecem vivos ao mesmo tempo.

O detalhe que quase ninguém comenta: backpressure

O ganho de memória é o motivo mais citado, mas existe um segundo benefício que sustenta o primeiro: backpressure ponta a ponta.

Como o await foreach é pull-based, ninguém puxa o próximo item enquanto você não terminar de processar o atual. Quando o seu processamento aguarda algo lento, um await repository.InsertAsync(...), por exemplo, o desserializador simplesmente para de consumir o stream. Se ele para de consumir, o PipeReader para de ler, o Kestrel deixa de drenar o socket e a janela de recepção TCP fecha. O cliente é segurado na origem.

Diagrama de streaming com backpressure ponta a ponta no ASP.NET Core. A requisição percorre seis estágios em sequência: Cliente envia o JSON pela rede, Kestrel recebe os dados, PipeReader lê o corpo em blocos, DeserializeAsyncEnumerable parseia o JSON incrementalmente e produz cada item conforme é reconhecido, await foreach consome um item por vez, e await InsertAsync processa e persiste o item. Quando o InsertAsync demora, o await foreach não pede o próximo item, o parser pausa, o PipeReader não lê, o Kestrel não drena o socket, a janela TCP fecha e o cliente é desacelerado na origem. O resultado é menos memória, mais estabilidade sob picos de carga e escalabilidade que se adapta à velocidade do processamento e do banco.

Com [FromBody] List<Person>, esse mecanismo não existe. O cliente despeja o payload inteiro o mais rápido que conseguir, e o servidor é obrigado a materializar tudo independente da velocidade com que você processa. Com streaming, a velocidade do seu processamento regula naturalmente a velocidade da ingestão, e isso limita a memória de ponta a ponta, não só na hora de parsear.

A implementação em streaming

Em Minimal APIs você pode vincular o corpo da requisição direto a um Stream ou a um PipeReader, o que deixa a assinatura limpa:

app.MapPost("/people/stream", async (
    Stream body,
    IPersonRepository repository,
    CancellationToken ct) =>
{
    var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);

    var people = JsonSerializer.DeserializeAsyncEnumerable<Person>(body, options, ct);

    var count = 0;

    await foreach (var person in people)
    {
        if (person is null)
            continue;

        await repository.InsertAsync(person, ct);
        count++;
    }

    return Results.Ok(new { count });
});
Enter fullscreen mode Exit fullscreen mode

Três pontos merecem atenção. O DeserializeAsyncEnumerable<Person> devolve Person?, porque um elemento do array pode ser null no JSON, então o if (person is null) não é paranoia. O CancellationToken é passado adiante, de modo que um cliente que desconecta interrompe a desserialização e o processamento, sem deixar trabalho órfão rodando. E a memória dessa action é praticamente plana, dez ou cem mil objetos, o consumo é o mesmo.

O streaming só vale se o processamento também for streaming

Aqui mora a armadilha que apaga todo o ganho. Se em algum ponto você reconstrói a lista, voltou à estaca zero:

// Anula o ganho: reconstroi a colecao inteira na memoria
var todos = new List<Person>();

await foreach (var person in people)
    todos.Add(person!);

await repository.InsertManyAsync(todos, ct);
Enter fullscreen mode Exit fullscreen mode

Esse código usa DeserializeAsyncEnumerable, parece moderno, e não economiza um único byte de pico. Você trocou o List<Person> do model binder por um List<Person> montado na mão.

O caminho que mantém o ganho processa e descarta:

// Mantem o ganho: cada item e processado e descartado
await foreach (var person in people)
    await repository.InsertAsync(person!, ct);
Enter fullscreen mode Exit fullscreen mode

Na prática, nenhum dos dois extremos costuma ser o ideal. Um insert por item gera um round-trip de banco por objeto, e isso mata o throughput. A solução de produção é o meio termo: processar em lotes pequenos, com a memória limitada ao tamanho do lote.

const int batchSize = 500;
var batch = new List<Person>(batchSize);

await foreach (var person in people)
{
    if (person is null)
        continue;

    batch.Add(person);

    if (batch.Count == batchSize)
    {
        await repository.InsertManyAsync(batch, ct);
        batch.Clear();
    }
}

if (batch.Count > 0)
    await repository.InsertManyAsync(batch, ct);
Enter fullscreen mode Exit fullscreen mode

O pico de objetos vivos fica preso em quinhentos, não em cem mil, e você corta os round-trips de banco por um fator de quinhentos. Esse é o equilíbrio entre memória e throughput que você quer num endpoint de ingestão real.

Nota: o ganho do streaming só se sustenta se o seu repositório também não acumular entidades por baixo dos panos. O caso clássico é o EF Core, que rastreia toda entidade adicionada no ChangeTracker. Se você nunca limpa o contexto, ele segura referência a cada Person inserida e reconstrói o problema de memória que você veio resolver, mesmo com o await foreach perfeito. Use um DbContext de vida curta por lote, chame ChangeTracker.Clear() depois de cada SaveChanges, ou prefira inserção em massa fora do tracking. O mesmo vale para o NHibernate sem Session.Clear(). O streaming é só metade do contrato, a outra metade é a sua camada de persistência não segurar o que você acabou de soltar.

Validação passa a ser sua responsabilidade

Com [FromBody] e [ApiController], a validação por DataAnnotations roda automática e você recebe um ModelState pronto, mais um 400 limpo se algo estiver inválido. Ao desviar do model binding, você abre mão disso. A validação volta para as suas mãos, item a item:

app.MapPost("/people/stream", async (
    Stream body,
    IPersonRepository repository,
    CancellationToken ct) =>
{
    var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
    var people = JsonSerializer.DeserializeAsyncEnumerable<Person>(body, options, ct);

    var accepted = 0;
    var rejected = new List<object>();
    var index = -1;

    await foreach (var person in people)
    {
        index++;

        if (person is null)
        {
            rejected.Add(new { index, error = "objeto nulo" });
            continue;
        }

        var context = new ValidationContext(person);
        var results = new List<ValidationResult>();

        if (!Validator.TryValidateObject(person, context, results, validateAllProperties: true))
        {
            rejected.Add(new { index, errors = results.Select(r => r.ErrorMessage) });
            continue;
        }

        await repository.InsertAsync(person, ct);
        accepted++;
    }

    return Results.Ok(new { accepted, rejected });
});
Enter fullscreen mode Exit fullscreen mode

O mesmo padrão se aplica ao FluentValidation, trocando o Validator.TryValidateObject por await validator.ValidateAsync(person, ct).

Nota: repare que a lista rejected cresce com o número de itens inválidos. Se um cliente mandar um payload com tudo errado, você acabou de criar de novo o problema de memória que veio resolver. Em produção, limite o número de erros coletados, algo como parar de acumular depois de cem rejeições e responder com um resumo, em vez de devolver um relatório com cem mil entradas.

Há também uma tensão mais profunda. Com [FromBody], ou a requisição inteira é válida ou ela falha antes de qualquer efeito colateral, all-or-nothing. Em streaming, quando você descobre que o item 5000 é inválido, os 4999 anteriores já podem ter sido inseridos. Você precisa decidir conscientemente a semântica: pular inválidos e seguir, abortar tudo dentro de uma transação, ou aceitar inserções parciais com idempotência. Não existe resposta única, mas existe uma obrigação de escolher.

Medindo o ganho

Aqui é onde vale corrigir um erro comum, presente até em quem já domina o assunto. As duas abordagens alocam aproximadamente o mesmo total de objetos. Tanto faz se você materializa a lista ou processa item a item, cem mil objetos Person são criados nos dois casos. O ganho do streaming não está no total de alocações. Está no pico de memória viva, ou seja, em quantos objetos existem ao mesmo tempo.

Isso tem uma consequência prática direta no benchmark. Um [MemoryDiagnoser] do BenchmarkDotNet mede o total alocado, e por isso a coluna Allocated das duas abordagens vai parecer parecida. Ela não captura o que importa aqui.

[MemoryDiagnoser]
public class JsonIngestionBenchmark
{
    private byte[] _payload = Array.Empty<byte>();

    [Params(10_000, 100_000)]
    public int ItemCount { get; set; }

    [GlobalSetup]
    public void Setup()
    {
        var people = Enumerable.Range(0, ItemCount)
            .Select(i => new Person
            {
                Id = i,
                Name = $"Person {i}",
                Email = $"person{i}@unhacked.dev"
            })
            .ToList();

        _payload = JsonSerializer.SerializeToUtf8Bytes(people);
    }

    // Materializa a lista inteira antes de processar
    [Benchmark(Baseline = true)]
    public async Task<int> BufferAll()
    {
        await using var stream = new MemoryStream(_payload);
        var people = await JsonSerializer.DeserializeAsync<List<Person>>(stream);

        var sum = 0;
        foreach (var person in people!)
            sum += person.Name.Length;

        return sum;
    }

    // Processa um objeto por vez, sem acumular
    [Benchmark]
    public async Task<int> StreamItems()
    {
        await using var stream = new MemoryStream(_payload);

        var sum = 0;
        await foreach (var person in JsonSerializer.DeserializeAsyncEnumerable<Person>(stream))
            sum += person!.Name.Length;

        return sum;
    }
}
Enter fullscreen mode Exit fullscreen mode

Estimando o pico, que é o que importa

Para enxergar o que o Allocated esconde, dá para estimar o pico do heap gerenciado com um amostrador simples rodando em paralelo ao trabalho. Não é uma medição exata, é uma amostragem: o Thread.Sleep(1) e o GC.GetTotalMemory(false) capturam fotos periódicas, e o pico real pode acontecer entre duas fotos. Para a ordem de grandeza que nos interessa aqui, serve bem:

static long MeasurePeakHeap(Func<Task> work)
{
    var peak = 0L;
    var running = true;

    var sampler = new Thread(() =>
    {
        while (Volatile.Read(ref running))
        {
            var current = GC.GetTotalMemory(forceFullCollection: false);
            if (current > peak)
                peak = current;

            Thread.Sleep(1);
        }
    });

    sampler.Start();
    work().GetAwaiter().GetResult();
    Volatile.Write(ref running, false);
    sampler.Join();

    return peak;
}
Enter fullscreen mode Exit fullscreen mode

O teste que mais convence: carga real

E, porque o cenário real é um endpoint sob carga, o teste que mais convence é de carga de verdade. Suba a aplicação, dispare o payload com bombardier ou k6 e observe o working set do processo com dotnet-counters:

# Dispara 100 requisicoes concorrentes com um payload de 100 mil objetos
bombardier -c 100 -n 500 -m POST \
  -H "Content-Type: application/json" \
  -f payload-100k.json \
  https://localhost:5001/people

# Em outro terminal, acompanha GC Heap Size e Working Set
dotnet-counters monitor --process-id <PID> --counters System.Runtime
Enter fullscreen mode Exit fullscreen mode

A tabela abaixo é ilustrativa. Ela mostra a ordem de grandeza e o formato da curva, não resultados que você deve esperar reproduzir número a número. Trate-a como um molde a ser substituído pelos valores que você medir no seu ambiente, com payload de cem mil objetos por requisição (cerca de 45 MB de JSON) numa máquina de 4 vCPU e 8 GB:

Cenário Estratégia Pico de heap gerenciado Working set do processo
100k objetos, 1 requisição [FromBody] List<Person> dezenas a centenas de MB proporcional ao payload
100k objetos, 1 requisição DeserializeAsyncEnumerable poucos MB, aprox. constante baixo e estável
100k objetos, 100 concorrentes [FromBody] List<Person> cresce até estourar, risco de OutOfMemoryException pode derrubar o processo
100k objetos, 100 concorrentes DeserializeAsyncEnumerable limitado pelo lote e pela concorrência estável e previsível

Os números exatos dependem de hardware, do tamanho de cada objeto e do nível de concorrência. Em um workload o fator de redução pode ser 8x, em outro 12x ou 40x. O que não muda é o formato da curva: o pico do streaming permanece aproximadamente constante enquanto o do [FromBody] cresce com o payload e multiplica com a concorrência. Meça no seu ambiente e publique os seus próprios números antes de prometer qualquer coisa para alguém.

Quando escolher cada abordagem

Aspecto [FromBody] List<T> DeserializeAsyncEnumerable
Pico de memória proporcional ao payload aproximadamente constante, limitada ao tamanho do lote
Validação automática e ModelState sim não, manual
400 limpo para payload inválido sim difícil depois de começar a processar
Backpressure ponta a ponta não sim
Exige array JSON na raiz não sim
Reaproveita o pipeline de model binding sim não
Complexidade do código baixa média

Escolha [FromBody] List<T> quando:

  • O payload é pequeno e tem limite garantido.
  • Você precisa de validação all-or-nothing com um 400 limpo.
  • Simplicidade vale mais que o controle fino de memória.

Escolha DeserializeAsyncEnumerable quando:

  • O endpoint é de ingestão em lote, com payloads grandes ou sem limite claro.
  • Você processa item a item ou em pequenos lotes, sem acumular.
  • Memória previsível sob carga importa mais que um 400 perfeito para o payload inteiro.

Não é só para payloads gigantes

É fácil ler tudo isso e concluir que a técnica só vale para os tais cem mil objetos. Não é o caso. O que justifica o streaming não é apenas o número de elementos, é a combinação de tamanho, custo de processamento e concorrência. Um array de duzentos itens já se beneficia quando cada item é grande, quando o processamento de cada um é lento, ou quando muitas requisições assim chegam ao mesmo tempo. O gatilho não é "o array é enorme". É "o conjunto vivo na memória, multiplicado pela concorrência, é grande o suficiente para doer". Use o critério, não o número redondo.

Limitações que você precisa conhecer antes de produção

Nota: DeserializeAsyncEnumerable<T> espera um array JSON na raiz do corpo. Se o seu payload é um objeto que envolve o array, como { "items": [ ... ] }, ele não funciona direto. Você precisa navegar até o token do array com um Utf8JsonReader antes de iniciar a enumeração, ou ajustar o contrato.

Nota: ao desviar do model binding, você perde mais do que a validação automática. Perde o 400 automático do [ApiController], perde a integração natural com a negociação de conteúdo e assume a responsabilidade por limites que antes vinham de graça. Continue aplicando um teto de tamanho de corpo, seja pelo MaxRequestBodySize do Kestrel, seja por uma verificação própria, para não trocar um estouro de memória por uma porta aberta de ingestão ilimitada.

Nota: o tratamento de erro no meio do stream é seu. Se o objeto de número 5000 está malformado, a desserialização lança no meio do await foreach, e os 4999 anteriores já foram processados. Pense em idempotência, em lotes transacionais e em como reportar uma falha parcial de forma que o cliente consiga reenviar com segurança.

Nota: o streaming resolve o array com muitos elementos, não um objeto único gigante. Se um elemento individual for enorme, ele ainda precisa caber na memória enquanto é construído. O ganho está em não segurar todos os elementos ao mesmo tempo, não em fatiar um objeto grande.

Conclusão

DeserializeAsyncEnumerable é uma solução elegante para um problema que parece banal e quebra feio em escala. Você troca pouquíssimo código e ganha um pico de memória que para de crescer com o payload, mais o backpressure que segura o cliente quando o seu processamento desacelera.

A implementação não é difícil, mas os detalhes importam, e quase todos são fáceis de errar. O ganho some se você reacumular a lista. A validação e o tratamento de erro voltam a ser seus. A semântica de falha parcial precisa de uma decisão consciente, não de um descuido. E o número que você vai prometer só é verdade depois que você o mediu no seu ambiente.

A escolha não é universal. Para payloads pequenos e validação all-or-nothing, o bom e velho [FromBody] é mais simples e mais seguro. Para ingestão em lote, o streaming é a diferença entre um endpoint que escala e um que derruba o processo.

No fim, a lição cabe numa frase. Um endpoint de ingestão não escala quando aprende a processar mais rápido. Ele escala quando aprende a nunca segurar, ao mesmo tempo, mais do que precisa para dar o próximo passo.

Top comments (0)