Para impedir que dois débitos simultâneos deixem uma conta com saldo negativo no Laravel, a verificação do saldo e o débito precisam acontecer numa única operação atômica dentro do banco. Existem dois caminhos: um UPDATE condicional (where balance >= ?), ou um SELECT ... FOR UPDATE com lockForUpdate() dentro de DB::transaction. Ler o saldo no PHP, comparar e depois chamar save() é uma condição de corrida, e em produção ela aparece mais cedo do que se imagina.
Em conta digital isso não é teórico. Saque via Pix, pagamento de boleto, tarifa automática e split de uma venda podem mexer no mesmo saldo no mesmo segundo, vindos de processos diferentes: uma requisição HTTP, um job do Horizon e um comando agendado. A seguir estão as decisões que eu tomo nesse tipo de código e o custo de cada uma.
Por que checar o saldo no PHP e depois salvar dá errado?
Uma condição de corrida (race condition) acontece quando o resultado depende da ordem em que dois processos concorrentes executam. O código abaixo parece correto e passa em qualquer teste sequencial:
$account = Account::find($accountId); if ($account->balance < $amount) { throw new SaldoInsuficiente(); } $account->balance -= $amount; $account->save();
O problema está na janela entre o find e o save. Imagine uma conta com R$ 100,00 e dois saques de R$ 80,00 chegando juntos:
- O processo A lê
balance = 10000(em centavos). - O processo B lê
balance = 10000. - A verifica que 10000 >= 8000 e grava
2000. - B verifica que 10000 >= 8000 e grava
2000.
O resultado são dois saques aprovados, R$ 160,00 saindo e um saldo final de R$ 20,00. Isso é um lost update, ou atualização perdida: a escrita de A foi sobrescrita por B, que calculou em cima de um valor velho. O dinheiro saiu duas vezes e o saldo não mostra isso.
Trocar o save() por $account->decrement('balance', $amount) resolve só metade. O decrement gera balance = balance - 8000, então nenhuma escrita se perde. Mas a checagem continua no PHP, e o saldo termina em -6000. Você trocou um saldo errado por um saldo negativo, que pelo menos é visível.
Como fazer o débito atômico com UPDATE condicional?
O jeito mais simples e mais rápido é levar a condição para dentro do UPDATE. O PostgreSQL trava a linha durante o update. Se outro processo já alterou a linha, ele reavalia o WHERE com o valor novo antes de aplicar. Ou o débito cabe e é aplicado, ou nenhuma linha é afetada.
final class DebitoAtomico { public function executar(int $accountId, int $amount): int { $row = DB::connection()->selectOne( 'update accounts set balance = balance - ?, updated_at = now() where id = ? and balance >= ? returning balance', [$amount, $accountId, $amount], useReadPdo: false, ); if ($row === null) { throw new SaldoInsuficiente($accountId, $amount); } return (int) $row->balance; } }
Alguns detalhes que importam:
RETURNING balancedevolve o saldo novo sem uma segunda query, e esse valor é coerente com o débito que você acabou de fazer. Ler o saldo depois, numa query separada, pode trazer um valor que já inclui o débito de outro processo.useReadPdo: falseé obrigatório se você usa separação de leitura e escrita.selectOnevai para a conexão de leitura por padrão, e umUPDATEmandado para a réplica falha (ou, pior, você acaba descobrindo que a réplica não estava realmente em modo somente leitura). Escrevi sobre essa armadilha em como lidar com o replication lag usando réplica de leitura no PostgreSQL com Laravel.- O saldo é inteiro em centavos. Comparação com float em
WHERE balance >= ?é pedir problema. Se ainda não migrou, veja como tratar dinheiro no Laravel com centavos em inteiro e cast de Money.
Se você não precisa do saldo de volta, o query builder resolve, porque decrement retorna o número de linhas afetadas:
$afetadas = DB::table('accounts') ->where('id', $accountId) ->where('balance', '>=', $amount) ->decrement('balance', $amount); if ($afetadas === 0) { throw new SaldoInsuficiente($accountId, $amount); }
O trade-off é que o UPDATE condicional só resolve regras que cabem num WHERE de uma linha. Quando a decisão depende de várias linhas, como limite diário somado de várias transações ou duas contas numa transferência, você precisa de lock explícito.
Quando usar lockForUpdate no Laravel?
O lock pessimista consiste em travar a linha antes de ler, para que ninguém mais escreva nela até sua transação terminar. No Laravel, lockForUpdate() gera SELECT ... FOR UPDATE. O ponto que mais vejo errado em code review é travar depois de já ter lido. O lock precisa estar na leitura que alimenta a decisão:
DB::transaction(function () use ($accountId, $amount) { $account = Account::whereKey($accountId)->lockForUpdate()->firstOrFail(); $sacadoHoje = $account->ledgerEntries() ->where('type', 'pix_out') ->where('created_at', '>=', today()) ->sum('amount'); if ($account->balance < $amount || $sacadoHoje + $amount > $account->daily_limit) { throw new DebitoRecusado(); } $account->decrement('balance', $amount); $account->ledgerEntries()->create(['type' => 'pix_out', 'amount' => $amount]); });
Repare que o lock na linha da conta serializa todos os débitos dessa conta. É isso que torna segura a soma do limite diário: nenhum outro débito da mesma conta consegue inserir lançamento enquanto você está decidindo. Isso só vale se todo código que debita passar pelo mesmo lock. Um caminho alternativo que faz decrement direto, sem travar, quebra a garantia.
O custo é contenção. Enquanto a transação está aberta, qualquer outro débito da mesma conta fica esperando. Por isso tenho uma regra que não negocio: nada de HTTP dentro de transação com lock. Chamar o gateway para enviar o Pix com a linha travada significa segurar o lock pelo tempo de resposta de um terceiro. Se ele tiver um timeout de 30 segundos, todos os débitos daquela conta ficam presos por 30 segundos. O fluxo certo é reservar o saldo na transação, fazer commit e só então chamar o gateway num job.
Como evitar deadlock ao travar duas contas numa transferência interna?
Deadlock é quando duas transações esperam uma pela outra em ciclo e nenhuma consegue avançar. Numa transferência interna entre contas, ele aparece assim: A transfere para B e trava A primeiro; ao mesmo tempo, B transfere para A e trava B primeiro. Cada uma fica esperando o lock que a outra segura. O PostgreSQL detecta o ciclo e aborta uma das duas com o erro 40P01.
A correção clássica é travar sempre na mesma ordem, por exemplo pelo id crescente:
public function transferir(int $fromId, int $toId, int $amount): void { DB::transaction(function () use ($fromId, $toId, $amount) { DB::statement("SET LOCAL lock_timeout = '3s'"); $contas = Account::whereIn('id', [$fromId, $toId]) ->orderBy('id') ->lockForUpdate() ->get() ->keyBy('id'); $origem = $contas[$fromId]; $destino = $contas[$toId]; if ($origem->balance < $amount) { throw new SaldoInsuficiente($fromId, $amount); } $origem->decrement('balance', $amount); $destino->increment('balance', $amount); LedgerEntry::insert([ ['account_id' => $fromId, 'amount' => -$amount, 'type' => 'transfer_out', 'created_at' => now()], ['account_id' => $toId, 'amount' => $amount, 'type' => 'transfer_in', 'created_at' => now()], ]); }, attempts: 3); }
No PostgreSQL, com ORDER BY e FOR UPDATE na mesma query, as linhas são travadas na ordem em que saem da ordenação. Assim, duas transferências cruzadas sempre disputam primeiro o menor id, e não existe ciclo.
Dois detalhes nesse código:
attempts: 3: oDB::transactiondo Laravel refaz o callback quando a exceção é de concorrência, como deadlock ou falha de serialização. Isso funciona como rede de segurança para deadlocks que você não previu, como os causados por índices ou foreign keys. Já oSaldoInsuficientenão é repetido, só faz rollback.SET LOCAL lock_timeout: limita quanto tempo a transação espera por um lock. Sem isso, uma transação travada por um processo lento fica pendurada até o timeout do worker. ComSET LOCAL, a configuração vale só para a transação corrente e não vaza para a conexão.
Como o callback pode rodar mais de uma vez, ele não pode ter efeito colateral fora do banco. Disparar job, mandar notificação ou publicar evento lá dentro gera duplicata no retry e publicação fantasma no rollback. Ou você usa afterCommit, ou grava o evento numa tabela na mesma transação, como descrevi no post sobre transactional outbox no Laravel.
Lock otimista com coluna version vale a pena para saldo?
No lock otimista, você não trava nada. Lê a linha junto com uma coluna version, e no update exige que a versão continue a mesma:
$afetadas = Account::whereKey($account->id) ->where('version', $account->version) ->update([ 'balance' => $account->balance - $amount, 'version' => $account->version + 1, ]); if ($afetadas === 0) { // alguém mexeu antes: recarregar e tentar de novo }
Funciona bem quando o conflito é raro, como na edição de cadastro ou na configuração de split de um lojista. Para saldo de conta movimentada, é a escolha errada: em momentos de pico, como o fechamento de lote de vendas da maquininha, os conflitos viram regra e você troca espera em lock por um loop de retries no PHP, que é pior.
| Estratégia | Quando uso | Custo |
|---|---|---|
| UPDATE condicional | Débito simples, regra cabe no WHERE | Quase nenhum; não cobre regras com várias linhas |
lockForUpdate |
Limites somados, transferência entre contas | Contenção por conta; exige ordem de locks |
Lock otimista (version) |
Dados com pouca disputa | Retries em pico; ruim para saldo quente |
pg_advisory_xact_lock |
Travar algo que ainda não tem linha | Fácil de esquecer em algum caminho do código |
O advisory lock do PostgreSQL (select pg_advisory_xact_lock(?)) é um lock por número, liberado automaticamente no fim da transação. Uso quando o recurso disputado ainda não existe como linha, por exemplo ao criar a conta-saldo de um lojista na primeira venda, quando dois webhooks chegam juntos.
Cache::lock no Redis não resolve?
Não como garantia de saldo. Cache::lock é útil para evitar trabalho duplicado, como dois workers processando o mesmo arquivo de conciliação, mas ele tem dois furos quando o assunto é dinheiro:
- Ele é voluntário. Protege só o código que lembra de pedir o lock. O comando que alguém escreve às pressas para estornar tarifas passa direto pelo banco.
- Ele expira. Se a transação demorar mais que o TTL, por causa de pausa de GC, banco lento ou lock_wait, o lock some e um segundo processo entra com a transação do primeiro ainda aberta.
A fonte da verdade do saldo é o PostgreSQL, então a garantia tem que estar no PostgreSQL. Redis pode ficar na frente como otimização, nunca como única proteção.
O que colocar no banco como última linha de defesa?
Mesmo com tudo acima, eu coloco uma constraint. Ela não deixa o bug passar em silêncio:
public function up(): void { DB::statement( 'ALTER TABLE accounts ADD CONSTRAINT accounts_balance_non_negative CHECK (balance >= 0)' ); }
Se algum caminho esquecido tentar deixar o saldo negativo, o UPDATE falha com violação de constraint, a transação faz rollback e você recebe um erro no Sentry em vez de descobrir o problema na conciliação do mês. Se a conta tem limite ou cheque especial, a regra vira balance >= -credit_limit, com o limite na mesma linha.
A outra defesa é o ledger, a tabela de lançamentos imutáveis em que cada movimentação vira uma linha. O saldo na tabela accounts é uma materialização que serve para consultar rápido. O ledger é o que explica o saldo. Gravar o lançamento na mesma transação do débito, como nos exemplos acima, permite rodar periodicamente um SUM(amount) por conta e comparar com balance. Divergência ali quer dizer que algum código está mexendo no saldo por fora.
Como testar concorrência de débito no Laravel?
Teste sequencial não pega race condition. É preciso processos de verdade batendo no mesmo banco ao mesmo tempo. Com o Process::pool do Laravel dá para fazer isso num teste Pest, desde que você observe duas coisas: o banco precisa ser PostgreSQL real (o SQLite em memória serializa tudo e esconde o problema) e você não pode usar RefreshDatabase, porque os processos filhos não enxergam dados de uma transação que nunca recebeu commit. Use DatabaseTruncation.
use Illuminate\Process\Pool; use Illuminate\Support\Facades\Process; it('não deixa o saldo negativo com saques simultâneos', function () { $conta = Account::factory()->create(['balance' => 10_000]); Process::pool(function (Pool $pool) use ($conta) { foreach (range(1, 20) as $i) { $pool->path(base_path()) ->command(['php', 'artisan', 'testing:debitar', $conta->id, '1000']); } })->start()->wait(); expect($conta->fresh()->balance)->toBe(0) ->and(LedgerEntry::where('account_id', $conta->id)->count())->toBe(10); });
O comando testing:debitar é registrado só no ambiente de teste e chama o mesmo serviço de débito da aplicação. Vinte processos tentam sacar R$ 10,00 de uma conta com R$ 100,00. O esperado é saldo zero e exatamente dez lançamentos. Rode esse teste contra a versão ingênua do primeiro exemplo e ele falha com frequência. Contra o UPDATE condicional, passa sempre. É o tipo de teste que vale manter no CI só para os serviços que mexem em saldo.
Conclusão: o que eu faço em todo serviço que debita saldo
Na prática, o checklist fica assim:
- Débito simples usa
UPDATE ... WHERE balance >= ? RETURNING balance, sempre pela conexão de escrita. - Regra que depende de várias linhas usa
lockForUpdate()na leitura que alimenta a decisão, dentro deDB::transactioncomattempts. - Quando há mais de uma conta, trava em ordem de
ide usaSET LOCAL lock_timeout. - Nada de HTTP nem dispatch de job dentro da transação; efeito colateral vai para depois do commit ou para o outbox.
- Toda tabela de saldo tem
CHECK (balance >= 0)e lançamento no ledger gravado na mesma transação. - Existe pelo menos um teste com processos paralelos contra PostgreSQL real.
Se for para escolher uma coisa só para fazer hoje, adicione a constraint CHECK. Ela não corrige a race condition, mas faz com que o pior caso seja um erro na tela em vez de dinheiro saindo de uma conta que não tinha saldo.