Vinicius Quinafelex Alves

🌐English version

[Conceito] Commitment ordering

Linguagens orientadas a objetos costumam gerenciar registros de banco de dados através classes e objetos que representam a estrutura do banco. Eric Evans, em seu livro de domain-driven design, classifica estas classes como classes de Entidade.

Assim, regras de negĂłcios podem usar esses objetos para fazer checagens e alterar seus valores, e posteriormente os novos dados podem ser persistidos no banco de dados de uma forma consistente.

CenĂĄrios com concorrĂȘncia

Em ambientes com execuçÔes em paralelismo, como web APIs, um Ășnica entidade pode ser acessada por mĂșltiplas threads ao mesmo tempo, carregando objetos distintos, sendo que cada thread pode alterar os valores do seu objeto de acordo com a regra de negĂłcio correspondente.

PorĂ©m, tentar persistir todos os objetos dessa entidade em um Ășnico registro do banco de dados pode gerar conflitos e dados podem ser sobreescritos, quebrando a consistĂȘncia.

Exemplo de inconsistĂȘncia

O cĂłdigo abaixo demonstra um cenĂĄrio aonde diversas threads tentam incrementar o valor de uma Ășnica entidade 10.000 vezes em paralelo.

var userId = 1;

// Setup
var userRepository = new UserRepository();
var parallelOptions = new ParallelOptions() 
{ 
    MaxDegreeOfParallelism = 10 
};

// Execution
Parallel.For(0, 10_000, parallelOptions, (attempt) => 
{
    var user = userRepository.GetByID(userId);
    user!.IncrementalValue++;

    userRepository.Update(user);
});
// UserRepository Class
public void Update(User user)
{
    using(var conn = new MySqlConnection(ConnectionString))
    using(var command = conn.CreateCommand())
    {
        command.CommandText = "UPDATE User SET IncrementalValue = @IncrementalValue WHERE ID = @ID;";
        command.Parameters.AddWithValue("@ID", user.ID);
        command.Parameters.AddWithValue("@IncrementalValue", user.IncrementalValue);

        conn.Open();
        command.ExecuteNonQuery();
    }
}

Como cada execução incrementa o valor em 1, Ă© esperado que depois de 10.000 execuçÔes o IncrementalValue esteja com 10.000. Mas como a função UPDATE apenas sobreescreve os dados cegamente em um cenĂĄrio de alta concorrĂȘncia, na prĂĄtica o valor final serĂĄ muito menor, em alguns testes chegando a valores menores que 2.000.

Em um ambiente de produção, isso indica que potencialmente 80% das operaçÔes gerem resultados inconsistentes.

Mantendo a consistĂȘncia

Este tipo de cenĂĄrio precisa implementar algum tipo de controle de concorrĂȘncia, de forma que uma thread nĂŁo sobreescreva os resultados das demais

Uma forma popular de evitar concorrĂȘncias Ă© atravĂ©s do uso de locks. Assim uma thread nĂŁo pode utilizar um recurso, ou acessar um trecho de cĂłdigo, enquanto outro processo mantiver um lock nele.

Porém, locks oferecem algumas desvantagens, como a necessidade de gestão de deadlocks, granularidade eficiente para evitar excesso de contenção e tolerùncia à falhas em cenårios com sistemas distribuídos.

AlĂ©m do lock, existem diversas outras formas e implementaçÔes de controle de concorrĂȘncia, aonde cada solução Ă© mais apropriada para diferentes tipos de cenĂĄrios.

Commitment ordering

Commitment ordering Ă© um mĂ©todo de controle de concorrĂȘncia otimista sem uso de locks.

Neste método, um objeto só pode ser persistido se o banco de dados ainda estiver com os mesmos dados que foram utilizados para criar o objeto. Uma forma de realizar essa verificação é através do versionamento do registro, em que qualquer atualização do registro também deve alterar a versão dele.

Quando ocorre uma falha por divergĂȘncia de versĂŁo, o tratamento pode ser feito baseado no caso de uso. Por exemplo, se a função Ă© disparada por usuĂĄrios, seria possĂ­vel emitir um erro indicando que os dados originais foram alterados e recarregar os dados exibidos. Dessa forma, o usuĂĄrio pode decidir se ainda faz sentido executar a função em cima do novo conjunto de dados.

Esta Ă© uma solução de controle de concorrĂȘncia relativamente simples para ser implementado em sistemas distribuidos ou containerizados, aonde o uso de locks externos pode ser complexo.

Implementação

Alguns bancos de dados e bibliotecas jĂĄ oferecem funcionalidades de commit ordering. Por exemplo, o SQL Server possui o tipo de coluna rowversion e a biblioteca de ORM Entity Framework possui concurrency tokens. Mesmo assim, a lĂłgica nĂŁo Ă© difĂ­cil de implementar na maioria dos bancos SQL.

O exemplo abaixo demonstra uma implementação simplificada, adotando a estratégia de, quando houver um conflito de versão, deve reiniciar o processo do começo, recarregando todos os objetos com os dados mais recentes.

var userId = 1;

// Setup
var userRepository = new UserRepository();
var parallelOptions = new ParallelOptions() 
{ 
    MaxDegreeOfParallelism = 10 
};

// Execution
Parallel.For(0, 10_000, parallelOptions, (attempt) => 
{
    var successful = false;

    do
    {
        var user = repo.GetByID(id);
        user!.IncrementalValue++;

        successful = repo.TryUpdateWithToken(user);
    } while(!successful);
});
// UserRepository Class
public bool TryUpdateWithToken(User user)
{
    var newVersion = Guid.NewGuid();

    using(var conn = new MySqlConnection(ConnectionString))
    using(var command = conn.CreateCommand())
    {
        command.CommandText = "UPDATE User SET total = @total, version = @newVersion WHERE id = @id AND version = @oldVersion;";
        command.Parameters.AddWithValue("@id", user.ID);
        command.Parameters.AddWithValue("@total", user.IncrementalValue);
        command.Parameters.AddWithValue("@oldVersion", user.Version);
        command.Parameters.AddWithValue("@newVersion", newVersion);

        conn.Open();
        var changedRowCount = command.ExecuteNonQuery();
        var hasChanged = changedRowCount > 0;

        if(hasChanged)
            user.Version = newVersion;

        return hasChanged;
    }
}

Após a execução, o IncrementalValue corretamente termina com o valor 10.000. Porém, esta estratégia de tratamento é mais adequada para situaçÔes aonde apenas uma pequena quantidade de threads tenta atualizar a mesma entidade. Caso contrårio, as repetidas tentativas de atualização podem ser computacionalmente caras.

Para esta demonstração, em mĂ©dia cada iteração passou por 9 divergĂȘncias de versĂŁo antes de conseguir atualizar o registro com sucesso, o que totaliza cerca de 90.000 tentativas de execução de UPDATE.