← ASP.NET Core: aplicações, contratos e operação
05 / 10 · 60 MIN

Persistência e concorrência

Delimita a unidade de trabalho, a transação e a versão dos dados antes de decidir se uma escrita pode ser aceite ou repetida.

Uma unidade, uma utilização delimitada

AddDbContext usa scoped por predefinição. Numa API HTTP simples, isso permite uma instância por pedido e uma unidade de trabalho sequencial. Não significa que duas consultas iniciadas em paralelo no mesmo pedido sejam seguras. DbContext não suporta operações concorrentes na mesma instância, incluindo consultas assíncronas iniciadas antes de aguardar a anterior.

Aguarda uma operação antes da seguinte ou usa contextos separados quando o paralelismo é intencional. Contextos separados não garantem, por si, uma fotografia coerente dos dados entre consultas. Se a unidade não coincidir com o pedido, uma factory pode criá-la explicitamente; quem cria a instância desta forma deve também libertá-la.

Tracking não é confirmação nem frescura

Uma entidade obtida numa consulta tracking passa a ser acompanhada pelo contexto. Alterações podem ser detetadas quando SaveChanges é chamado. Uma atribuição C# não é imediatamente uma escrita SQL. Com AsNoTracking, a consulta não coloca o objeto nesse acompanhamento; mudar uma propriedade e chamar SaveChanges, sem o associar ao contexto, não implica persistência dessa alteração.

Tracking também tem um limite de frescura: se a entidade já estiver no contexto, uma consulta pode reutilizar a mesma instância sem sobrepor os valores atuais e originais. Uma leitura separada sem tracking ou uma recarga explícita pode ser necessária, conforme a intenção. Não uses um contexto longo como prova de que os dados locais ainda são atuais.

A atomicidade tem uma fronteira

Quando o provider suporta transações e não se altera o comportamento predefinido, uma chamada SaveChanges agrupa as suas alterações numa transação. Se uma alteração falhar, as alterações dessa chamada são revertidas. Duas chamadas no mesmo contexto, sem transação explícita comum, não recebem automaticamente a mesma garantia conjunta.

Uma gravação da encomenda pode ficar confirmada antes de falhar a gravação do recibo. Um POST enviado entre ambas também não entra automaticamente na transação da base de dados. O desenho deve decidir quais escritas locais precisam de atomicidade e como reconciliar efeitos externos, sem afirmar que rollback local desfaz trabalho remoto confirmado.

Comparar a versão que foi lida

Um concurrency token acrescenta a versão original à condição da atualização ou eliminação. Se o registo ainda tiver essa versão, a escrita pode avançar. Se outra sessão o alterou e renovou o token, a condição pode afetar zero linhas e SaveChanges sinaliza DbUpdateConcurrencyException. A deteção não decide qual alteração deve prevalecer.

O exemplo usa SQLite com um Guid gerido pela aplicação. ConcurrencyCheck marca a propriedade, mas não gera o valor seguinte. Cada escrita relevante deve renovar Version. Isso difere de uma coluna rowversion gerida pelo SQL Server. Também não se deve classificar automaticamente uma violação de chave única numa inserção como este tipo de conflito de atualização.

Resolver implica decidir de novo

Num conflito existem valores originais, valores propostos pela aplicação e valores atuais da base de dados. Compará-los ajuda a decidir entre rejeitar, recarregar ou fazer um merge permitido pelas regras. Copiar apenas o token atual para impor valores calculados a partir de uma versão antiga pode perder a alteração que se acabou de detetar.

No caso do stock, a leitura inicial de 8 permite propor a retirada de cinco. Se a quantidade atual já for 4, a mesma decisão deixa de ser válida. O ramo conflict-reload do exemplo comunica a necessidade de revisão; não executa uma repetição cega. O chamador ainda precisa de um contrato de erro e de uma política delimitada para novas tentativas.

Savepoints dependem do provider e da configuração

Quando SaveChanges é chamado dentro de uma transação já aberta, EF pode usar um savepoint para recuperar o estado anterior à tentativa de gravação. Isso não significa voltar a qualquer commit antigo nem desfazer chamadas HTTP. A fronteira continua a ser a transação local e o ponto nela criado.

Existe uma exceção concreta no SQL Server: com MARS ativado, EF não cria esses savepoints, mesmo que MARS não esteja a ser usado ativamente. Depois de um erro, a transação pode ficar em estado desconhecido. A recuperação deve considerar essa configuração antes de decidir continuar a usar a mesma transação como se tivesse sido restaurada.

Ler o serviço e os seus limites

O excerto pressupõe provider SQLite EF Core 10, tabela inicializada e factory registada pelo host. Reserve cria um contexto, aguarda a leitura, verifica a quantidade e tenta uma escrita condicional. O contexto é libertado no fim da unidade. Cancelamento e outras exceções não são convertidos por este método em sucesso; só o conflito de concorrência tem o retorno específico mostrado.

A sequência não é um serviço de reservas completo. Não apresenta autorização, contrato HTTP, migração ou recuperação de efeitos externos. As perguntas usam as premissas e estados ilustrativos para escolher resultados e decisões. Não é necessário executar o excerto, criar uma base de dados ou repetir laboratórios para realizar a avaliação.

// Service/model excerpt for EF Core 10; not a complete host.
// Preconditions: SQLite EF Core 10 provider, initialized Items table,
// and IDbContextFactory<StockDb> registered by the host.
// Each writer renews the application-managed Version for stock changes.
using System;
using System.Threading;
using System.Threading.Tasks;
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;

public sealed class StockService(IDbContextFactory<StockDb> factory)
{
    public async Task<string> Reserve(int id, int units, CancellationToken ct)
    {
        if (units <= 0) return "invalid-units";
        await using var db = await factory.CreateDbContextAsync(ct);
        var item = await db.Items.SingleOrDefaultAsync(x => x.Id == id, ct);
        if (item is null) return "missing";
        if (item.Available < units) return "insufficient";
        item.Available -= units;
        item.Version = Guid.NewGuid();
        try
        {
            await db.SaveChangesAsync(ct);
            return "saved";
        }
        catch (DbUpdateConcurrencyException)
        {
            return "conflict-reload";
        }
    }
}
public sealed class StockDb(DbContextOptions<StockDb> options) : DbContext(options)
{
    public DbSet<StockItem> Items => Set<StockItem>();
}
public sealed class StockItem
{
    public int Id { get; set; }
    public int Available { get; set; }
    [ConcurrencyCheck]
    public Guid Version { get; set; }
}
NA PRÁTICA

Parte de Available=8 e Version=A. Sem escrita concorrente, reservar cinco unidades pode guardar 3 com nova Version. Se outra sessão guardar 4 com Version=B antes da atualização condicional, esta tentativa encontra conflito. O ramo conflict-reload não repete a proposta antiga.

Armadilhas comuns

Usar Task.WhenAll no mesmo contexto; confundir a vida do contexto com uma transação comum; esperar persistência automática de objetos sem tracking; renovar apenas o token original para impor valores antigos; assumir rowversion automático em SQLite.

Tópicos relacionados: Dependências, ciclos de vida e configuração · HTTP de saída, cache e resiliência · Âmbito de testes e evidência de integração

Leva esta ideia contigo

Um contexto coordena objetos de uma unidade, uma transação delimita alterações atómicas e um token deteta versões concorrentes. Cada fronteira precisa de uma decisão própria; nenhuma delas abrange automaticamente um efeito HTTP remoto.

Criar conta

Referência: DbContext operation boundaries · ASP.NET Core 10.0 / .NET 10 / C# 14