Partilhar via


Tratamento de conflitos de concorrência

Sugestão

Você pode visualizar a amostra do deste artigo no GitHub.

Na maioria dos cenários, os bancos de dados são usados simultaneamente por várias instâncias de aplicativos, 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. Esta página discute mecanismos para garantir que seus dados permaneçam consistentes diante dessas alterações simultâneas.

Concorrência otimista

O EF Core implementa simultaneidade otimista, o que pressupõe que os conflitos de simultaneidade são relativamente raros. Em contraste com as abordagens pessimistas - que bloqueiam os dados antecipadamente e só então procedem à sua modificação - a simultaneidade otimista não requer bloqueios, mas organiza para que a modificação de dados falhe ao salvar se os dados tiverem sido alterados desde que foram consultados. Essa falha de simultaneidade é relatada ao aplicativo, que lida com ela de acordo, possivelmente tentando novamente 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. Em seguida, quando uma operação de atualização ou exclusão é executada durante SaveChanges(), o valor do token de concorrência 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 Person típico com uma propriedade especial Version :

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 muda 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 simples operação de atualização:

var person = await context.People.SingleAsync(b => b.FirstName == "John");
person.FirstName = "Paul";
await context.SaveChangesAsync();
  1. Na primeira etapa, uma Pessoa é carregada da base de dados; isto inclui o token de simultaneidade, que agora é monitorizado como habitualmente pelo EF, juntamente com o resto das propriedades.
  2. A instância Person é então modificada de alguma forma - alteramos a FirstName propriedade.
  3. Em seguida, instruímos o EF Core a persistir a modificação. Como um token de simultaneidade é 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 de PersonId na cláusula WHERE, o EF Core adicionou uma condição para Version também; isso só modifica a linha se a coluna Version não tiver mudado desde o momento em que a consultámos.

No caso normal ("otimista"), nenhuma atualização simultânea ocorre e a ATUALIZAÇÃO é concluída com êxito, modificando a linha; o banco de dados informa ao EF Core que uma linha foi afetada pela ATUALIZAÇÃO, conforme o esperado. No entanto, se uma atualização simultânea ocorrer, a ATUALIZAÇÃO não consegue encontrar nenhuma linha correspondente e relata que nenhuma linha foi afetada. Como resultado, o EF Core SaveChanges() lança um DbUpdateConcurrencyException, que o aplicativo deve capturar e manusear adequadamente. As técnicas para fazer isso são detalhadas abaixo, em Resolução de conflitos de simultaneidade.

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

Tokens de simultaneidade gerados por banco de dados nativo

No código acima, usamos o [Timestamp] atributo para mapear uma propriedade para uma coluna do SQL Server rowversion . Como rowversion muda automaticamente quando a linha é atualizada, é muito útil como um token de simultaneidade de esforço mínimo que protege toda a linha. A configuração de uma coluna do SQL Server rowversion 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 rowversion tipo mostrado acima é um recurso específico do SQL Server, os detalhes sobre a configuração de um token de simultaneidade de atualização automática diferem entre bancos de dados, e alguns bancos de dados não oferecem suporte a eles (por exemplo, SQLite). Consulte a documentação do seu fornecedor para obter os detalhes precisos.

Tokens de concorrência geridos por aplicações

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 o uso de simultaneidade otimista em bancos de dados - como SQLite - onde não existe nenhum tipo nativo de atualização automática. Mas mesmo no SQL Server, um token de simultaneidade gerenciado por aplicativo pode fornecer controle refinado sobre exatamente quais alterações de coluna fazem com que o token seja regenerado. Por exemplo, você pode ter uma propriedade contendo algum valor armazenado em cache ou sem importância e não deseja que uma alteração nessa propriedade desencadeie um conflito de simultaneidade.

O seguinte 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 por banco de dados, você deve atribuí-la no aplicativo sempre que houver alterações persistentes:

var person = await context.People.SingleAsync(b => b.FirstName == "John");
person.FirstName = "Paul";
person.Version = Guid.NewGuid();
await context.SaveChangesAsync();

Se você quiser que um novo valor GUID seja sempre atribuído, você pode fazer isso por meio de um SaveChanges intercetador. 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.

Resolução de conflitos de concorrência

Independentemente de como o seu token de simultaneidade está configurado, para implementar a simultaneidade otimista, a sua aplicação deve lidar adequadamente com o caso em que ocorre um conflito de simultaneidade e DbUpdateConcurrencyException é lançado; isto é chamado de resolução de 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 então carregar os novos dados e tentar novamente. Ou, se o seu aplicativo estiver executando uma atualização automatizada, ele pode 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, onde ambos os conjuntos de valores são exibidos.

Há três conjuntos de valores disponíveis para ajudar a resolver um conflito de concorrência.

  • Os valores atuais são os valores que o aplicativo estava tentando gravar no banco de dados.
  • Os valores originais são os valores que foram originalmente recuperados do banco de dados, antes de qualquer edição ser feita.
  • Os valores do banco de dados são os valores atualmente armazenados no banco de dados.

A abordagem geral para lidar com um conflito de concorrência é:

  1. Captura 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. Repita o processo até que não ocorram conflitos.

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

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

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

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

                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 mudanças simultâneas.

Um mecanismo para garantir a consistência é o nível de isolamento de transações 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 estavam quando a transação foi iniciada, sem ser afetada por qualquer atividade simultânea subsequente. Tomando nosso exemplo básico de cima, quando consultamos o Person para atualizá-lo de alguma forma, o banco de dados deve certificar-se de que nenhuma outra transação interfira com essa linha de banco de dados até que a transação seja concluída. Dependendo da implementação do seu banco de dados, isso acontece de duas maneiras:

  1. Quando a linha é consultada, sua transação tem um bloqueio compartilhado nela. Qualquer transação externa que tente 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 de "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 tenta fazer a atualização, um erro de "serialização" será gerado, indicando que ocorreu um conflito de simultaneidade. Esta é uma forma de bloqueio otimista - não muito diferente da funcionalidade de token de simultaneidade do EF - e é implementada pelo nível de isolamento por instantâneo do SQL Server, assim 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 adicionais), portanto, ele funciona da mesma maneira 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 oferece outras vantagens; Por exemplo, leituras repetíveis garantem que sua transação sempre veja os mesmos dados em consultas dentro da transação, evitando inconsistências. No entanto, esta abordagem tem as 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 tentarem modificar a mesma linha deverão ser bloqueadas durante a totalidade da transação. Isso pode ter um efeito adverso no desempenho simultâneo (mantenha sua transação curta!), embora observe que o mecanismo do EF lança 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 de instantâneo, que não bloqueia as linhas consultadas.

Mais importante ainda, 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, em seguida, esperar que o usuário faça alterações, então a transação deve permanecer ativa por um tempo potencialmente longo, o que deve ser evitado na maioria dos casos. Como resultado, este 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 Deteção de conflitos no EF Core para obter um exemplo de ASP.NET Core com deteção de conflitos.