Para evitar race condition no saldo de uma conta digital em Laravel, o débito precisa acontecer de forma atômica no banco. Isso pode ser feito com um UPDATE condicional (where balance >= valor) ou com lockForUpdate() dentro de DB::transaction. Nunca com o padrão ler o saldo, calcular em PHP e salvar. Por baixo de tudo, uma CHECK constraint no PostgreSQL garante que, mesmo com bug no código, o saldo não fica negativo.
Parece óbvio escrito assim. Mas o código que quebra costuma ser o mais natural de escrever, passa em todos os testes e só falha em produção, quando dois débitos chegam no mesmo milissegundo.
Por que ler, calcular e salvar o saldo quebra sob concorrência?
Race condition é quando o resultado depende da ordem em que operações concorrentes são executadas. O código abaixo tem uma:
$account = Account::findOrFail($accountId); if ($account->balance_cents < $amountCents) { throw new InsufficientFunds(); } $account->balance_cents -= $amountCents; $account->save();
Imagine uma conta com R$ 100 e dois débitos de R$ 80 chegando ao mesmo tempo. Pode ser um Pix e um pagamento de boleto, ou o mesmo request reenviado pelo app depois de um timeout. Os dois workers leem 10000, os dois passam na validação e os dois gravam 2000. O cliente gastou R$ 160 com R$ 100 de saldo, e o banco registra R$ 20 restantes.
Isso não depende de volume alto. Basta ter mais de um worker no Horizon (o painel e supervisor de filas Redis do Laravel) ou mais de um processo PHP-FPM atendendo a API. Em fintech, retry de cliente e de gateway é rotina, então duplicação concorrente é o caso normal.
Como usar lockForUpdate no Laravel para debitar saldo?
lockForUpdate() gera um SELECT ... FOR UPDATE, que trava a linha lida até o fim da transação. Qualquer outra transação que tente travar a mesma linha espera. A regra é: o lock só vale dentro de uma transação. Fora dela, o PostgreSQL libera a linha assim que o statement termina.
public function debit(int $accountId, int $amountCents, string $idempotencyKey): LedgerEntry { return DB::transaction(function () use ($accountId, $amountCents, $idempotencyKey) { $account = Account::whereKey($accountId) ->lockForUpdate() ->firstOrFail(); if ($account->balance_cents < $amountCents) { throw new InsufficientFunds($account->id, $amountCents); } $account->balance_cents -= $amountCents; $account->save(); return $account->entries()->create([ 'type' => 'debit', 'amount_cents' => $amountCents, 'balance_after_cents' => $account->balance_cents, 'idempotency_key' => $idempotencyKey, ]); }, attempts: 3); }
No cenário dos dois débitos de R$ 80, o segundo worker fica bloqueado no SELECT até o primeiro dar commit. Quando é liberado, lê 2000 e cai no InsufficientFunds.
O parâmetro attempts faz o Laravel repetir a closure quando o banco devolve erro de concorrência, como deadlock ou falha de serialização. Ele não ajuda em nada se o código não travar a linha, mas é barato e evita erro 500 em pico.
O lockForUpdate vale a pena quando a decisão depende de mais coisa além do saldo: limite diário, status da conta, bloqueio judicial, regra de tarifa. Tudo isso fica consistente porque você leu e decidiu com a linha travada.
Dá para evitar o lock com um UPDATE atômico condicional?
Dá, e para o débito simples é o que eu prefiro. Você move a validação para dentro do próprio UPDATE:
$affected = DB::table('accounts') ->where('id', $accountId) ->where('balance_cents', '>=', $amountCents) ->decrement('balance_cents', $amountCents); if ($affected === 0) { throw new InsufficientFunds($accountId, $amountCents); }
O decrement do query builder retorna o número de linhas afetadas. Se o saldo não cobre, a condição falha e nada é alterado. O banco já serializa updates na mesma linha, então não existe janela entre ler e escrever.
O trade-off é que você perde contexto. 0 linhas pode ser saldo insuficiente ou conta inexistente, e a mensagem de erro precisa de uma segunda consulta para ser precisa. Para pegar o saldo resultante e gravar no extrato, o PostgreSQL resolve com RETURNING, mas aí você sai do query builder e escreve SQL cru. Com regras de negócio mais ricas, o UPDATE vira um WHERE gigante, e nesse ponto o lockForUpdate fica mais legível.
Cache::lock do Redis serve para proteger saldo?
Cache::lock é um lock distribuído no Redis, com TTL. Ele é ótimo para impedir que o mesmo job rode duas vezes ao mesmo tempo, ou que dois processos façam a mesma chamada ao Pagar.me ou ao Asaas. Como única proteção do saldo, eu não confio nele, por três motivos:
- O TTL expira. Se o processo travar numa chamada HTTP lenta e passar do tempo do lock, outro processo entra enquanto o primeiro ainda vai escrever.
- Fica fora da transação. O lock do Redis pode ser liberado antes do commit do PostgreSQL. Outro worker entra e lê o saldo antigo.
- Nem todo caminho de escrita passa por ele. Um comando artisan de ajuste, um job de estorno ou um script de conciliação vão esquecer de pegar o lock mais cedo ou mais tarde. O lock de linha no banco protege todo mundo que escreve na tabela.
Uso Cache::lock como camada extra, por exemplo para serializar o processamento de webhooks da mesma transação antes de chegar no banco. A garantia de consistência do dinheiro fica no PostgreSQL.
Como evitar deadlock em transferência entre duas contas?
Deadlock acontece quando duas transações esperam uma pela outra. Na transferência interna, se a transação 1 trava A e depois B, enquanto a transação 2 trava B e depois A, o PostgreSQL detecta o ciclo e mata uma delas.
A solução clássica é sempre travar na mesma ordem, por exemplo pelo ID:
DB::transaction(function () use ($fromId, $toId, $amountCents) { $ids = [$fromId, $toId]; sort($ids); $locked = []; foreach ($ids as $id) { $locked[$id] = Account::whereKey($id)->lockForUpdate()->firstOrFail(); } $from = $locked[$fromId]; $to = $locked[$toId]; if ($from->balance_cents < $amountCents) { throw new InsufficientFunds($fromId, $amountCents); } $from->decrement('balance_cents', $amountCents); $to->increment('balance_cents', $amountCents); // lançamentos de débito e crédito no ledger aqui }, attempts: 3);
Travo uma conta por vez num loop, em vez de um whereIn(...)->lockForUpdate(), porque assim a ordem de aquisição fica explícita no código e não depende do plano de execução. O attempts: 3 continua ali para os deadlocks que ainda aparecem por outros caminhos.
Coluna de saldo ou saldo calculado pelo ledger?
Ledger é a tabela de lançamentos imutáveis (débitos e créditos) da qual o saldo pode ser derivado. Calcular o saldo com SUM a cada débito é a opção mais pura, mas não resolve concorrência sozinho: duas transações somam ao mesmo tempo, as duas veem saldo suficiente e as duas inserem. Você ainda precisa de um ponto de serialização, e a linha da conta é o candidato natural.
Por isso uso as duas coisas. A coluna balance_cents na tabela accounts é o ponto de lock e a leitura rápida. O ledger é a fonte para auditoria e conciliação, e um job periódico compara SUM(entries) com a coluna para detectar divergência. Cada lançamento guarda balance_after_cents, o que facilita muito investigar quando algo não bate.
Qual a última linha de defesa no banco?
Uma constraint que o código não consegue contornar:
Schema::table('accounts', function (Blueprint $table) { // ... }); DB::statement('ALTER TABLE accounts ADD CONSTRAINT balance_non_negative CHECK (balance_cents >= 0)'); Schema::table('ledger_entries', function (Blueprint $table) { $table->unique('idempotency_key'); });
A CHECK faz o PostgreSQL rejeitar qualquer escrita que deixe o saldo negativo, venha de onde vier. O unique na idempotency_key impede que o mesmo débito seja lançado duas vezes quando o cliente reenvia a requisição. Se a sua conta PJ permite cheque especial, a constraint passa a comparar com o limite, mas ela continua existindo.
Comparação das abordagens
| Abordagem | Garante consistência? | Quando usar |
|---|---|---|
| Ler, calcular, salvar | Não | Nunca para saldo |
UPDATE condicional |
Sim | Débito simples, alto volume |
lockForUpdate em transação |
Sim | Regras de negócio além do saldo, transferências |
Cache::lock (Redis) |
Não sozinho | Serializar jobs e chamadas externas |
CHECK constraint |
Sim, como barreira | Sempre, por baixo de tudo |
Como testar concorrência de saldo no Laravel?
O teste comum não pega esse bug. O trait RefreshDatabase envolve cada teste numa transação e usa uma única conexão, então não existe concorrência real. Passa sempre.
O que funciona é disparar processos de verdade contra um banco de teste migrado (com DatabaseMigrations ou truncando as tabelas, nunca dentro de transação). Com o Process::pool do Laravel dá para fazer isso direto no teste:
$results = Process::pool(function (Pool $pool) use ($account) { foreach (range(1, 10) as $i) { $pool->command("php artisan test:debit {$account->id} 8000 key-{$i}"); } })->start()->wait(); expect($account->fresh()->balance_cents)->toBe(2000); expect(LedgerEntry::where('account_id', $account->id)->count())->toBe(1);
Com saldo inicial de R$ 100 e dez débitos simultâneos de R$ 80, só um pode passar. Rode o mesmo teste contra a versão ingênua do código e veja a falha acontecer. Isso convence o time mais do que qualquer revisão de código.
O que eu faria amanhã num código de saldo existente
- Procurar todo
->save()ou->update()embalanceprecedido de leitura sem lock. Cada um é um bug esperando tráfego. - Adicionar a
CHECK (balance_cents >= 0)e ouniquena chave de idempotência antes de mexer em qualquer código. Isso protege enquanto você refatora. - Trocar débitos simples por
UPDATEcondicional e os fluxos com regra de negócio porlockForUpdatecomattempts. - Padronizar a ordem de lock em toda operação que envolve mais de uma conta.
- Criar o teste com processos paralelos e colocar no CI.
O Redis e as filas ajudam a organizar o fluxo, mas quem garante que o dinheiro não some nem aparece do nada é o banco. Deixe a consistência com o PostgreSQL e use o resto da stack para performance e orquestração.