Assim como falei sobre Double-Entry recentemente, hoje quero falar sobre um outro pilar fundamental na construção de um sistema financeiro confiável, o Race Condition.
Race conditions acontecem quando dois ou mais processos tentam acessar e modificar o mesmo recurso simultaneamente, podendo levar a resultados inconsistentes ou comportamentos inesperados no sistema.
Por exemplo, quando dois usuários tentam atualizar o mesmo registro em um banco de dados ao mesmo tempo, o resultado final pode não refletir corretamente as alterações de ambos os processos. Essa situação é particularmente crítica em sistemas que lidam com dados sensíveis ou transações financeiras na modalidade débito.
Imagine uma conta com saldo de R$ 100, onde duas operações de débito chegam exatamente no mesmo segundo — acontece e muito! — uma de R$ 50 e outra de R$ 80. No total deveria debitar da conta R$ 130, porém como só há R$ 100, ficaria R$ 30 negativos.
Essa situação pode causar sérios problemas de consistência de dados e prejuízos financeiros gigantes, já que o sistema pode processar ambas as transações sem a devida validação do saldo disponível.
Possíveis soluções
Existem várias abordagens para prevenir race conditions em sistemas financeiros, como por exemplo implementar o conceito de serialização de transações, onde as operações são processadas em uma ordem específica e controlada.
Além disso, a utilização de controle de concorrência otimista (OCCs - Optimistic Concurrency Control) pode ser uma alternativa viável, especialmente em sistemas com baixa probabilidade de conflitos. Em suma, ela permite que múltiplas transações ocorram simultaneamente, verificando apenas no momento da confirmação (commit) se houve algum conflito. Se um conflito for detectado, a transação é revertida e pode ser tentada novamente. Esta abordagem é especialmente eficiente em ambientes onde os conflitos são raros, pois evita o overhead de bloqueios constantes.
Outra solução eficaz e amplamente difundida é a utilização de locks, ou semáforos, que garantem acesso exclusivo aos recursos compartilhados durante uma transação.
Funcionamento dos Locks
Locks são mecanismos de sincronização que impedem que múltiplos processos acessem simultaneamente um recurso compartilhado. Em um sistema financeiro, quando uma transação precisa modificar o saldo de uma conta, ela primeiro adquire um lock exclusivo sobre aquele registro, garantindo que nenhuma outra transação possa alterá-lo ao mesmo tempo. Somente após a conclusão da primeira transação e a liberação do lock, outras operações podem acessar e modificar o mesmo recurso.
Implementando locks com Redis
O Redis é uma excelente opção para implementar locks distribuídos devido à sua natureza atômica e alta performance. Um exemplo simples de implementação seria utilizar o comando SETNX (SET if Not eXists) para criar um lock:
func acquireLock(lockKey string, timeout time.Duration) (bool, error) {
ctx := context.Background()
acquired, err := rdb.SetNX(ctx, lockKey, time.Now().Unix(), timeout).Result()
if err != nil {
return false, err
}
return acquired, nil
}
Este código cria um lock atômico que expira automaticamente após um determinado período, evitando deadlocks caso o processo que adquiriu o lock falhe antes de liberá-lo.
É crucial garantir sua liberação após a conclusão da operação, mesmo em caso de erros. Isso pode ser feito utilizando um bloco try-finally:
func performOperation() error {
lockKey := "account:123:lock"
acquired, err := acquireLock(lockKey, 10)
if err != nil {
return err
}
if acquired {
defer redis.Del(ctx, lockKey).Err()
// Execute operation
}
return nil
}
Esta abordagem garante que o lock será sempre liberado, permitindo que outras operações prossigam normalmente.
Voltando ao nosso exemplo anterior da conta com 100 reais e duas transações simultâneas, a implementação de locks garantiria que apenas uma transação fosse processada por vez. Assim, se a primeira transação de 50 reais adquirisse o lock, ela seria processada completamente antes que a segunda transação de 80 reais pudesse acessar o saldo, evitando o problema de consistência.
Neste cenário, a primeira transação seria bem-sucedida (reduzindo o saldo para 50 reais) e a segunda seria rejeitada por saldo insuficiente, mantendo a integridade dos dados.
Diferença entre Pessimistic e Optimistic Lock?
Enquanto o lock pessimista, ou Pessimistic Lock, realiza o lock do registro tanto para ESCRITA quanto para LEITURA, impedindo qualquer acesso ao recurso até que o processamento seja concluído. O Optimistic Lock, ou lock otimista, realiza o lock do registro apenas para ESCRITA, mantendo a leitura liberada. Utiliza versionamento como mecanismo de validação no momento da escrita.
Qual o melhor modelo de lock para transações financeiras
Para transações financeiras, o Pessimistic Lock é geralmente considerado mais adequado devido às suas características de segurança mais rigorosas. Isso se deve a alguns fatores importantes:
- Consistência garantida: Ao bloquear tanto leitura quanto escrita, garante-se que nenhuma operação seja realizada com dados potencialmente desatualizados
- Prevenção de conflitos: Elimina a possibilidade de conflitos antes que ocorram, em vez de detectá-los posteriormente
- Segurança em transações críticas: Em operações financeiras, onde a precisão é crucial, é preferível ter um controle mais rigoroso mesmo que isso implique em uma pequena redução de performance
No entanto, é importante notar que o Pessimistic Lock pode impactar a performance do sistema devido ao maior tempo de bloqueio dos recursos. Por isso, sua implementação deve ser cuidadosamente planejada, considerando aspectos como timeout do lock e estratégias de deadlock prevention.
Qual débito acontece primeiro?
A ordem de processamento das transações em um sistema distribuído geralmente é determinada por vários fatores, incluindo o timestamp da transação, a latência da rede e a implementação específica do mecanismo de locks.
Na prática, a primeira transação que conseguir adquirir o lock será processada primeiro, seguindo um princípio de "primeiro a chegar, primeiro a ser servido" (FCFS - First Come, First Served). No entanto, é importante notar que em sistemas distribuídos, nem sempre é possível garantir uma ordem totalmente determinística devido a fatores como diferenças de clock entre servidores.
O código em produção
Como exemplo prático temos o Midaz, um ledger open source mantido pela Lerian que utiliza essa pessimist lock para garantir a integridade das transações financeiras, isso nos permite visualizar o código produtivo de como é feito esse lock que segue o mesmo princípio acima.
Realizando o lock do saldo
Vimos anteriormente como operações financeiras são criadas, o processo do lock acontece uma etapa antes até mesmo da criação da transação, na validação das contas que chama o método LockBalanceVersion
.
func (uc *UseCase) LockBalanceVersion(ctx context.Context,
organizationID,
ledgerID uuid.UUID,
keys []string,
accounts []*account.Account) (bool, error) {
// Cria um mapa de contas (usando ID ou Alias como chave) para facilitar a busca durante o processamento.
accountsMap := make(map[string]*account.Account)
for _, acc := range accounts {
accountsMap[acc.Id] = acc // Adiciona a conta com ID como chave
accountsMap[acc.Alias] = acc // Adiciona a conta com Alias como chave
}
// Itera sobre todas as chaves para tentar bloquear a versão do saldo.
for _, key := range keys {
// Verifica se a chave corresponde a uma conta existente no mapa.
if acc, exists := accountsMap[key]; exists {
// Cria uma chave interna para bloquear a versão do saldo da conta no Redis.
internalKey := pkg.LockVersionInternalKey(organizationID, ledgerID, key, strconv.FormatInt(acc.Version, 10))
// Tenta configurar o bloqueio no Redis (SetNX) e define um tempo de expiração.
isSuccess, err := uc.RedisRepo.SetNX(ctx, internalKey, "0", constant.TimeSetLockBalance)
if err != nil {
// Caso ocorra um erro, registra no telemetry e retorna o erro.
return false, err
}
// Incrementa o contador de tentativas de bloqueio no Redis.
total := uc.RedisRepo.Incr(ctx, internalKey)
// Se o número de tentativas exceder o limite, libera o bloqueio e retorna erro.
if total > constant.RedisTimesRetry {
err = uc.RedisRepo.Del(ctx, internalKey) // Libera o bloqueio
if err != nil {
// Se ocorrer erro ao liberar o bloqueio, loga o erro.
}
return false, pkg.ValidateBusinessError(constant.ErrLockVersionAccountBalance, "LockBalanceVersion")
}
// Se o bloqueio não foi adquirido, espera um tempo e tenta novamente.
if !isSuccess {
time.Sleep(constant.LockRetry * time.Millisecond)
return true, nil
}
}
}
// Retorna false, indicando que não conseguiu bloquear, sem erros.
return false, nil
}
Removendo os locks
Após o processamento das transações, é chamado o método AllKeysUnlocked
para liberar todas as keys e permitir que outras operações possam ser executadas. O Midaz implementa um sistema de liberação de locks utilizando goroutines para processar múltiplas chaves simultaneamente, melhorando a performance do sistema. Veja como isso é feito no código:
func (uc *UseCase) AllKeysUnlocked(ctx context.Context, organizationID, ledgerID uuid.UUID, keys []string, hash string) {
// Cria um WaitGroup para sincronizar as goroutines.
var wg sync.WaitGroup
// Canal para receber os resultados das goroutines de verificação de bloqueios.
resultChan := make(chan bool, len(keys))
// Itera sobre todas as chaves fornecidas para verificar o status de bloqueio de cada uma.
for _, key := range keys {
// Cria uma chave interna para verificar o bloqueio no Redis.
internalKey := pkg.LockInternalKey(organizationID, ledgerID, key)
// Incrementa o contador do WaitGroup para acompanhar a goroutine.
wg.Add(1)
// Inicia uma goroutine para verificar e liberar o bloqueio da chave.
go uc.checkAndReleaseLock(ctx, &wg, internalKey, hash, resultChan)
}
// Espera todas as goroutines finalizarem.
wg.Wait()
// Fecha o canal de resultados.
close(resultChan)
}
Para facilitar a leitura, removi loggers e tracers e alguns outros detalhes, mas você pode analisar na íntegra o código no GitHub da criação de uma transação e dos comandos de lock do race condition. Coloque nos comentários caso queria um artigo destrinchando e explicando como é feito o código de uma transação financeira por completo.
Conclusão
A implementação correta de mecanismos de controle de concorrência é crucial para garantir a integridade e confiabilidade das transações financeiras, esta é uma das vantagens de se utilizar um ledger para lidar com transações, pois ele já possui mecanismos nativos de controle de concorrência e locks integrados.
Isso significa que ao utilizar um ledger, você não precisa se preocupar em implementar esses controles manualmente, pois a própria arquitetura do sistema garante a integridade das transações. Uma das ferramentas que implementa estes conceitos é o Midaz, um ledger open source mantido pela Lerian, que você pode explorar o código e utilizar em seus projetos ou na sua empresa.
Você já enfrentou desafios de race conditions em seus projetos? Compartilhe suas experiências nos comentários e não se esqueça de compartilhar este artigo com outros desenvolvedores que possam se beneficiar deste conhecimento!
Top comments (2)
Muito interessante e didático o artigo, bem explicado sobre essa questão de concorrência. No trabalho utilizamos o redis tb mas num contexto totalmente diferente, gostei dessa abordagem para o setor financeiro
Tenho estudado o tema recentemente e achei a apresentação aqui muito didática, parabéns!!!