Trabalhando com transações

Trabalhando com transações

A utilização de transações é uma prática necessária para garantir a consistência dos dados que precisam ser salvos em mais de um local para compor uma informação de negócio coerente. É muito importante considerar cenários em que várias gravações são feitas, porém, algumas (a última de uma sequência, por exemplo) apresentam erros. Neste caso, se toda a sequência precisava ser realizada com sucesso para considerarmos que a informação está armazenada de forma coerente e consistente, devemos desfazer as gravações que ocorreram com sucesso. É importante que o desenvolvedor esteja atento a estes cenários, que podem causar problemas difíceis de se investigar (e é comum que tais cenários não ocorram no ambiente de desenvolvimento, e o desenvolvedor não perceba que o código que está entregando está com um bug).

Existem várias formas de se lidar com um cenário transacional. Aqui, vou falar do mais comum, o two-phase commit (ver imagem no topo do artigo), em que existe um coordenador para as transações que, após todos os participantes informarem que estão ok, dispara um comando para efetivar a alteração realizada (a segunda fase, do nome "two-phase"), e, enquanto espera este comando do coordenador, cada recurso mantém um bloqueio nos elementos que fizeram parte da alteração. Este cenário é comum mesmo em arquiteturas de microsserviços, já que, no nível mais interno de um serviço, é comum ter de salvar informações em mais de uma tabela, ou então salvar em uma tabela e postar em uma fila de mensageria, por exemplo.

Mais especificamente, neste artigo vou abordar transações no contexto do Entity Framework. Já adianto: não gosto (nada contra especificamente o EF; não gosto de nenhum ORM – acho o Dapper ok, mas por facilitar o mapeamento, ou seja, a consulta ainda está na responsabilidade do desenvolvedor). O alegado aumento de produtividade é dúbio: quantos bugs são criados em sistemas porque as facilidades de não se preocupar com a persistência obscureceram as reais necessidades de um sistema que vai enfrentar alta concorrência, ou cenários diferentes do “caminho feliz”? Na minha opinião, a persistência é parte do trabalho do desenvolvedor, portanto, se o banco utilizado é Sql Server, o desenvolvedor tem que saber escrever suas próprias queries. Porém, eu sei que, no mundo real, Entity Framework é amplamente utilizado, então, aqui vai...

O Entity Framework, por padrão, dá suporte a transações implicitamente, quando as alterações acontecem em um mesmo contexto (por isso que, em sistemas pequenos, com apenas um contexto, é provável que você nem precise se preocupar com transações. Mas saiba, não tem mágica!). Por exemplo:

using (var ctx = new DBContext()) {
    ctx.tabela1.Add(new tabela1 { campo = "X" });
    ctx.tabela2.Add(new tabela2 { campo2 = "Y" });
    await ctx.SaveChangesAsync(cancellationToken);
}

Na situação acima, a inserção em todas as tabelas acontecerá de forma transacional, no momento da execução de SaveChangesAsync(). Se houver um erro na última inserção (tabela2), inserções anteriores serão desfeitas.

Porém, temos situações em que há o envolvimento de outras formas de persistência.

Por exemplo, podemos combinar uma inserção pelo EF e outra ação utilizando Transact-SQL. Para executar diretamente um comando, precisaremos de um método que abre uma conexão com o banco de dados e executa o comando utilizando um SqlCommand. Porém, para utilizar transações, devemos reaproveitar a mesma conexão que já foi aberta pelo EF.

No exemplo abaixo, o objeto tran, um SqlTransaction, terá uma propriedade Connection, que pode ser utilizada pelo SqlCommand para a execução do comando. (ATENÇÃO: os exemplos abaixo não contêm a criação do SqlCommand e execução do comando - imaginemos que tudo isso está no método execSqlCommandAsync, pois o objetivo deste artigo é focar no EF, não no ADO, porém, realizar a ação descrita utilizando um SqlCommand é muito simples de se encontrar na Internet):

using (var ctx = new DBContext()){
    var tran = ctx.Database.BeginTransaction(IsolationLevel.ReadCommitted).UnderlyingTransaction as SqlTransaction;


    try
    {
        ctx.tabela1.Add(new tabela1 { campo = "X" });
        await ctx.SaveChangesAsync(cancellationToken);

        await execSqlCommandAsync("insert into tabela2 (campo) values ('Y')", cancellationToken, tran);
        tran.Commit();
    }
    catch
    {
        tran.Rollback();
        throw;
    }
}

Ps.: não concatene strings para montar os parâmetros de uma query. Utilize SqlParameter, com tipagem igual às colunas do banco de dados.

Nesta situação, o método execSqlCommandAsync não deve abrir uma nova conexão, mas utilizar a conexão do objeto transacional. É importante executar o Commit ou Rollback da transação o mais rápido possível, para liberar os bloqueios que são colocados nos elementos envolvidos!

Elevando o contexto

Os dois exemplos mostrados acima compreendem transações locais, gerenciadas pelo próprio banco de dados. Porém, há casos mais complexos, em que há a utilização de transações que demandam elevação do contexto transacional (em outras palavras, o coordenador da transação não é o banco de dados, mas um servidor “acima” dele). Isso acontece quando a transação envolve mais do que o banco de dados. Pode ser outro servidor, ou um sistema diferente, como uma mensageria, por exemplo. Vejamos:

using (var ts = new TransactionScope(TransactionScopeOption.Required, TransactionScopeAsyncFlowOption.Enabled))
using (var ctx = new db.banco1.DBContext())
using (var ctx2 = new db.banco2.DBContext())
{
    ctx.tabela1.Add(new tabela1 { campo = "X" });
    await ctx.SaveChangesAsync(cancellationToken);


    ctx2.tabela2.Add(new tabela2 { campo = "Y" });
    await ctx2.SaveChangesAsync(cancellationToken);


    await execSqlCommandAsync("insert into tabela3 (campo) values ('Z')", cancellationToken);


    ts.Complete();
}

Neste cenário, temos dois bancos de dados, que podem ser em servidores diferentes, e mais o execSqlCommand, que, sem um objeto de transação, vai ter de criar seu próprio SqlConnection. O TransactionScope garantirá que todas as conexões abertas dentro de seu contexto sejam processadas como uma única transação. Se o Dispose da instância do TransactionScope for chamado sem que o Complete tenha sido chamado, um rollback é executado automaticamente, desfazendo qualquer ação que tenha tido sucesso. Gravar em uma fila do Azure Service Bus também é um processo que pode ser feito transaccionalmente dentro do contexto do TransactionScope. Além disso, ele também é responsável por promover a elevação do contexto transacional, se for o caso.

Um ponto importante a se considerar no uso do EF: por padrão, qualquer execução contra o banco de dados que resulte em um erro estoura imediatamente uma Exception. Porém, é possível alterar este comportamento por meio de uma ExecutionStrategy. Para isso, podemos criar uma classe de configuração, que, ao ser colocada no mesmo assembly que o DBContext, irá ser instanciada pelo mecanismo de inversão de controle do próprio EF. É possível, então, criar uma estratégia de execução: são regras que o EF deverá seguir na execução dos comandos. Podemos, por exemplo, implementar retry no caso de alguns erros específicos.

Uma estratégia de execução que já vem no próprio EF, e é muito comum para quem utiliza Sql Server rodando em instâncias gerenciadas do Azure, é a SqlAzureExecutionStrategy. Esta classe irá prover mecanismos de retentativa em casos de erros comuns quando se trabalha com o Sql Server rodando no Azure (lembrando que as instâncias gerenciadas podem ser interrompidas e restauradas a qualquer momento, e isso pode ter efeitos colaterais no sistema, de modo que esta estratégia de execução ameniza esses efeitos colaterais).

A classe abaixo criará uma estratégia de execução que se encarregará de retries comuns no caso do Azure. Os parâmetros em questão informam que acontecerão até 5 tentativas, sendo que o máximo de delay entre uma e outra será 10 segundos (cada tentativa gerará um delay maior do que a tentativa anterior. Exemplo: a primeira tentativa após 1 erro pode ser depois de 1 segundo. Se houver outro erro, a próxima tentativa será depois de 5 segundos. Cada tentativa aumenta o tempo de espera. Esta é uma técnica para reduzir a pressão sobre o banco de dados, que, possivelmente, está passando por problemas).

public class MyDbConfiguration : DbConfiguration
{
    public MyDbConfiguration()
        => SetExecutionStrategy("System.Data.SqlClient", () => new SqlAzureExecutionStrategy(5, TimeSpan.FromSeconds(10)));
}

Mas o que isso tem a ver com transações, que é o assunto deste artigo?

Pois bem, a estratégia do Azure não é compatível com o TransactionScope, por uma simples razão: a estratégia de execução vale para um determinado DBContext individualmente, e esta estratégia trata de retries de execuções individuais. No caso de um erro em um contexto transacional, deveríamos refazer toda a transação? Ou talvez só retentar a última execução? E se a estratégia de outro DBContext envolvido na transação for diferente? E se a execução com erro contiver mais de um comando? Enfim, esta estratégia de realizar retries pode acabar gerando erros considerando um ambiente transacional, por isso a incompatibilidade.

A solução para isso é colocar todo o processo transacional para acontecer dentro de uma única estratégia.

await new SqlAzureExecutionStrategy(5, TimeSpan.FromSeconds(10)).ExecuteAsync(async () =>
{


    using (var ts = new TransactionScope(TransactionScopeOption.Required, TransactionScopeAsyncFlowOption.Enabled))
    using (var ctx = new db.banco1.DBContext())
    using (var ctx2 = new db.banco2.DBContext())
    {
        ctx.tabela1.Add(new tabela1 { campo = "X" });
        await ctx.SaveChangesAsync(cancellationToken);


        ctx2.tabela2.Add(new tabela2 { campo = "Y" });
        await ctx2.SaveChangesAsync(cancellationToken);


        await execSqlCommandAsync("insert into tabela3 (campo) values ('Z')", cancellationToken);

        ts.Complete();
    }



}, cancellationToken);

Acima, colocamos exatamente todo o método do exemplo anterior dentro de um Execute de uma estratégia específica. Isso vai fazer com que, em caso de erro, toda a transação seja abortada (por conta do TransactionScope), e seja reiniciada do zero, com uma nova instância de TransactionScope (aí, por conta da própria estratégia).

Podemos melhorar um pouco este código deixando a criação da estratégia em um ponto único:

public class MyDbConfiguration : DbConfiguration
{
    public static IDbExecutionStrategy GetExecutionStrategy() => new SqlAzureExecutionStrategy(5, TimeSpan.FromSeconds(10));
    public MyDbConfiguration() => SetExecutionStrategy("System.Data.SqlClient", GetExecutionStrategy);
}

E, nas execuções, ao invés de

await new SqlAzureExecutionStrategy(5, TimeSpan.FromSeconds(10)).ExecuteAsync

Utilizamos

await MyDbConfiguration.GetExecutionStrategy().ExecuteAsync

Uma forma de deixar o código mais limpo e com a configuração de retentativas centralizada em um único ponto! DRY - Don't repeat yourself!

Concluindo

Transações são MUITO importantes! Independentemente de utilizar ou não ORMs, considere-as durante o desenvolvimento de seu sistema. Desenvolver uma lógica considerando apenas o caminho feliz, onde tudo dá certo, é, talvez, a forma mais ingênua de criar problemas para você mesmo no futuro.

Thiago Zulato

Software Engineer @ iFood

3 a

Excelente. Parabéns pelo artigo.

Marcos Ramos

Product Owner / Back End / Suporte / Soluções / Integrações / Banco de dados

3 a

Boa mano!

Entre para ver ou adicionar um comentário

Outros artigos de André Ricardo Pontes da Silva

  • Cuidado com o hype - parte 2

    Cuidado com o hype - parte 2

    IAs não são inteligentes. Repita quantas vezes for necessário até isso entrar na sua cabeça.

    1 comentário
  • Não existe almoço grátis

    Não existe almoço grátis

    Nem caminho fácil. Também não existem garantias.

    5 comentários
  • Home office: foi apenas um sonho

    Home office: foi apenas um sonho

    É, está claro que o home office está acabando, não é? Essa conversa de "híbrido" é só pra amortecer o golpe, porque não…

    23 comentários
  • API Gateway, mais uma bala de prata

    API Gateway, mais uma bala de prata

    Não sei se foi uma grande coincidência, ou se saiu alguma coisa sobre na imprensa ou entre influenciadores de…

    6 comentários
  • Links da apresentação do VS Summit 2021

    Links da apresentação do VS Summit 2021

    Este é um artigo especial, pois foi criado apenas como uma "bibliografia" da minha apresentação realizada no VS Summit…

    2 comentários
  • Sincronicidade e paralelismo

    Sincronicidade e paralelismo

    Porque devemos evitar o uso de Task.Run? (estamos falando, obviamente, de um contexto de throughput alto - que é o caso…

    7 comentários
  • Route to Code (e outras histórias)

    Route to Code (e outras histórias)

    O .Net 5 e o C# 9 estão batendo à porta.

    7 comentários
  • Mais performance, e gratuitamente?

    Mais performance, e gratuitamente?

    Quer uma grande melhora de performance sem precisar mudar NADA (ou quase) no seu código? Prepare-se para o .net 5!…

    3 comentários
  • 1, 2, 3, Testando... 1, 2, 3 Testndo?

    1, 2, 3, Testando... 1, 2, 3 Testndo?

    Vocês têm dificuldade com testes unitários? Eu tinha, até desistir da busca pela cobertura. Existe um conceito, que…

  • O futuro do desenvolvimento é não ter desenvolvedores?

    O futuro do desenvolvimento é não ter desenvolvedores?

    Primeiro pensamento quando leio "low-code/no-code": de novo esse papinho furado pra enganar gestores esperançosos e…

    28 comentários

Outras pessoas também visualizaram

Conferir tópicos