Como tratar conflitos de simultaneidade

Dica

Veja o exemplo deste artigo no GitHub.

Na maioria dos cenários, os bancos de dados são usados simultaneamente por várias instâncias de aplicativo, cada uma executando modificações nos dados independentemente umas das outras. Quando os mesmos dados são modificados ao mesmo tempo, podem ocorrer inconsistências e corrupção de dados, por exemplo, quando dois clientes modificam colunas diferentes na mesma linha que estão relacionadas de alguma forma. Essa página discute mecanismos para garantir que seus dados permaneçam consistentes diante dessas alterações simultâneas.

Simultaneidade otimista

O EF Core implementa a simultaneidade otimista, que pressupõe que conflitos de simultaneidade são relativamente raros. Em contraste com abordagens pessimistas, que bloqueiam os dados antecipadamente e só depois prossigam para modificá-los, a simultaneidade otimista não usa bloqueios, mas organiza a modificação de dados para falhar ao salvar se os dados forem alterados desde que foram consultados. Essa falha de simultaneidade é relatada ao aplicativo, que lida com ele adequadamente, possivelmente repetindo toda a operação nos novos dados.

No EF Core, a simultaneidade otimista é implementada configurando uma propriedade como um token de simultaneidade. O token de simultaneidade é carregado e rastreado quando uma entidade é consultada, assim como qualquer outra propriedade. Quando uma operação de atualização ou exclusão é disparada por SaveChanges(), o valor do token de simultaneidade no banco de dados é comparado com o valor original lido pelo EF Core.

Para entender como isso funciona, vamos supor que estamos no SQL Server e definir um tipo de entidade de pessoa típico com uma propriedade Version especial:

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }

    [Timestamp]
    public byte[] Version { get; set; }
}

No SQL Server, isso configura um token de simultaneidade que é alterado automaticamente no banco de dados sempre que a linha é alterada (mais detalhes estão disponíveis abaixo). Com essa configuração em vigor, vamos examinar o que acontece com uma operação de atualização simples:

var person = context.People.Single(b => b.FirstName == "John");
person.FirstName = "Paul";
context.SaveChanges();
  1. Na primeira etapa, uma pessoa é carregada do banco de dados; isso inclui o token de simultaneidade, que agora é acompanhado como de costume pelo EF, juntamente com o restante das propriedades.
  2. A instância Pessoa é modificada de alguma forma: alteramos a propriedade FirstName.
  3. Em seguida, instruímos o EF Core a persistir a modificação. Como um token de simultaneidade está configurado, o EF Core envia o seguinte SQL para o banco de dados:
UPDATE [People] SET [FirstName] = @p0
WHERE [PersonId] = @p1 AND [Version] = @p2;

Observe que, além do PersonId na cláusula WHERE, o EF Core também adicionou uma condição Version. Isso só modifica a linha se a coluna Version não tiver sido alterada desde o momento em que a consultamos.

No caso normal ("otimista"), nenhuma atualização simultânea ocorre e UPDATE é concluído com êxito, modificando a linha. O banco de dados relata ao EF Core que uma linha foi afetada por UPDATE, conforme o esperado. No entanto, se uma atualização simultânea tiver ocorrido, UPDATE não consegue encontrar linhas e relatórios correspondentes que zero foram afetados. Como resultado, o SaveChanges() do EF Core lança um DbUpdateConcurrencyException, que o aplicativo deve capturar e manipular adequadamente. As técnicas para fazer isso estão detalhadas abaixo, em Resolver conflitos de simultaneidade.

Enquanto os exemplos acima discutiram atualizações para entidades existentes. O EF também gera DbUpdateConcurrencyException ao tentar excluir uma linha que foi modificada simultaneamente. No entanto, essa exceção nunca é gerada ao adicionar entidades. eEmbora o banco de dados possa gerar uma violação de restrição exclusiva se linhas com a mesma chave estiverem sendo inseridas, isso resultará em uma exceção específica do provedor sendo gerada e não DbUpdateConcurrencyException.

Tokens de simultaneidade gerados pelo banco de dados nativo

No código acima, usamos o atributo [Timestamp] para mapear uma propriedade para uma coluna rowversion do SQL Server. Como rowversion é alterado automaticamente quando a linha é atualizada, ela é muito útil como um token de simultaneidade de esforço mínimo que protege toda a linha. A configuração de uma coluna rowversion do SQL Server como um token de simultaneidade é feita da seguinte maneira:

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }

    [Timestamp]
    public byte[] Version { get; set; }
}

O tipo rowversion mostrado acima é um recurso específico do SQL Server; os detalhes sobre como configurar um token de simultaneidade de atualização automática diferem entre bancos de dados e alguns bancos de dados não dão suporte a eles (por exemplo, SQLite). Consulte a documentação do provedor para obter os detalhes precisos.

Tokens de simultaneidade gerenciados pelo aplicativo

Em vez de fazer com que o banco de dados gerencie o token de simultaneidade automaticamente, você pode gerenciá-lo no código do aplicativo. Isso permite usar a simultaneidade otimista em bancos de dados, como SQLite, em que não existe nenhum tipo de atualização nativa automaticamente. No entanto, mesmo no SQL Server, um token de simultaneidade gerenciado pelo aplicativo pode fornecer controle refinado sobre exatamente quais alterações de coluna fazem com que o token seja gerado novamente. Por exemplo, você pode ter uma propriedade que contém algum valor armazenado em cache ou sem importância e não deseja que uma alteração nessa propriedade dispare um conflito de simultaneidade.

O exemplo a seguir configura uma propriedade GUID para ser um token de simultaneidade:

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }

    [ConcurrencyCheck]
    public Guid Version { get; set; }
}

Como essa propriedade não é gerada pelo banco de dados, você deve atribuí-la no aplicativo sempre que persistir as alterações:

var person = context.People.Single(b => b.FirstName == "John");
person.FirstName = "Paul";
person.Version = Guid.NewGuid();
context.SaveChanges();

Se você quiser que um novo valor GUID sempre seja atribuído, faça isso por meio de um SaveChanges interceptor. No entanto, uma vantagem de gerenciar manualmente o token de simultaneidade é que você pode controlar precisamente quando ele é regenerado, para evitar conflitos de simultaneidade desnecessários.

Como resolver conflitos de simultaneidade

Independentemente de como o token de simultaneidade está configurado, para implementar a simultaneidade otimista, seu aplicativo deve lidar corretamente com o caso em que ocorre um conflito de simultaneidade e DbUpdateConcurrencyException é gerado. Isso é chamado de resolver um conflito de simultaneidade.

Uma opção é simplesmente informar ao usuário que a atualização falhou devido a alterações conflitantes. O usuário pode carregar os novos dados e tentar novamente. Ou se o aplicativo estiver executando uma atualização automatizada, ele poderá simplesmente fazer loop e tentar novamente imediatamente, depois de consultar novamente os dados.

Uma maneira mais sofisticada de resolver conflitos de simultaneidade é mesclar as alterações pendentes com os novos valores no banco de dados. Os detalhes precisos de quais valores são mesclados dependem do aplicativo e o processo pode ser direcionado por uma interface do usuário, em que ambos os conjuntos de valores são exibidos.

Há três conjuntos de valores disponíveis para ajudar a resolver um conflito de simultaneidade:

  • Valores atuais são os valores que o aplicativo estava tentando gravar no banco de dados.
  • Valores originais são os valores que foram originalmente recuperados do banco de dados, antes de todas as edições feitas.
  • Valores do banco de dados são os valores armazenados atualmente no banco de dados.

A abordagem geral para lidar com um conflito de simultaneidade é:

  1. Capturar DbUpdateConcurrencyException durante SaveChanges.
  2. Use DbUpdateConcurrencyException.Entries para preparar um novo conjunto de alterações para as entidades afetadas.
  3. Atualize os valores originais do token de simultaneidade para refletir os valores atuais no banco de dados.
  4. Tente novamente o processo até o conflito ocorrer.

No exemplo a seguir, Person.FirstName e Person.LastName são configurados como tokens de simultaneidade. Há um comentário // TODO: no local onde você inclui a lógica específica para o aplicativo para escolher o valor a ser salvo.

using var context = new PersonContext();
// Fetch a person from database and change phone number
var person = context.People.Single(p => p.PersonId == 1);
person.PhoneNumber = "555-555-5555";

// Change the person's name in the database to simulate a concurrency conflict
context.Database.ExecuteSqlRaw(
    "UPDATE dbo.People SET FirstName = 'Jane' WHERE PersonId = 1");

var saved = false;
while (!saved)
{
    try
    {
        // Attempt to save changes to the database
        context.SaveChanges();
        saved = true;
    }
    catch (DbUpdateConcurrencyException ex)
    {
        foreach (var entry in ex.Entries)
        {
            if (entry.Entity is Person)
            {
                var proposedValues = entry.CurrentValues;
                var databaseValues = entry.GetDatabaseValues();

                foreach (var property in proposedValues.Properties)
                {
                    var proposedValue = proposedValues[property];
                    var databaseValue = databaseValues[property];

                    // TODO: decide which value should be written to database
                    // proposedValues[property] = <value to be saved>;
                }

                // Refresh original values to bypass next concurrency check
                entry.OriginalValues.SetValues(databaseValues);
            }
            else
            {
                throw new NotSupportedException(
                    "Don't know how to handle concurrency conflicts for "
                    + entry.Metadata.Name);
            }
        }
    }
}

Usando níveis de isolamento para controle de simultaneidade

A simultaneidade otimista por meio de tokens de simultaneidade não é a única maneira de garantir que os dados permaneçam consistentes diante de alterações simultâneas.

Um mecanismo para garantir a consistência é o nível de isolamento de transação de leituras repetíveis. Na maioria dos bancos de dados, esse nível garante que uma transação veja os dados no banco de dados como eram quando a transação foi iniciada, sem ser afetada por nenhuma atividade simultânea subsequente. Usando nosso exemplo básico acima, quando consultamos Person para atualizá-lo de alguma forma, o banco de dados deve garantir que nenhuma outra transação interfira nessa linha de banco de dados até que a transação seja concluída. Dependendo da implementação do banco de dados, isso acontece de duas maneiras:

  1. Quando a linha é consultada, a transação usa um bloqueio compartilhado nela. Qualquer transação externa que tentar atualizar a linha será bloqueada até que a transação seja concluída. Essa é uma forma de bloqueio pessimista e é implementada pelo nível de isolamento "leitura repetível" do SQL Server.
  2. Em vez de bloquear, o banco de dados permite que a transação externa atualize a linha, mas quando sua própria transação tentar fazer a atualização, um erro de "serialização" será gerado, indicando que ocorreu um conflito de simultaneidade. Essa é uma forma de bloqueio otimista, não diferente do recurso de token de simultaneidade do EF, e é implementada pelo nível de isolamento de instantâneo do SQL Server, bem como pelo nível de isolamento de leituras repetíveis do PostgreSQL.

Observe que o nível de isolamento "serializável" fornece as mesmas garantias que a leitura repetível (e adiciona outras), de modo que funciona da mesma forma em relação ao acima.

Usar um nível de isolamento mais alto para gerenciar conflitos de simultaneidade é mais simples, não requer tokens de simultaneidade e fornece outras vantagens. Por exemplo, leituras repetíveis garantem que sua transação sempre veja os mesmos dados entre consultas dentro da transação, evitando inconsistências. No entanto, essa abordagem tem suas desvantagens.

Primeiro, se a implementação do banco de dados usar o bloqueio para implementar o nível de isolamento, outras transações que tentam modificar a mesma linha deverão ser bloqueadas durante toda a transação. Isso pode ter um efeito adverso no desempenho simultâneo (lembre-se de manter a transação curta), mas observe que o mecanismo do EF gera uma exceção e força você a tentar novamente, o que também tem um impacto. Isso se aplica ao nível de leitura repetível do SQL Server, mas não ao nível do instantâneo, que não bloqueia linhas consultadas.

Mais importante, essa abordagem requer uma transação para abranger todas as operações. Se você, digamos, consultar Person para exibir seus detalhes para um usuário e aguardar que o usuário faça alterações, a transação deverá permanecer ativa por um tempo potencialmente longo, o que deve ser evitado na maioria dos casos. Como resultado, esse mecanismo geralmente é apropriado quando todas as operações contidas são executadas imediatamente e a transação não depende de entradas externas que podem aumentar sua duração.

Recursos adicionais

Consulte Detecção de conflitos no EF Core para obter uma amostra do ASP.NET Core com detecção de conflitos.