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.
Software Engineer @ iFood
3 aExcelente. Parabéns pelo artigo.
Product Owner / Back End / Suporte / Soluções / Integrações / Banco de dados
3 aBoa mano!