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:

  1. 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.
  2. 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.
  3. 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

  1. Procurar todo ->save() ou ->update() em balance precedido de leitura sem lock. Cada um é um bug esperando tráfego.
  2. Adicionar a CHECK (balance_cents >= 0) e o unique na chave de idempotência antes de mexer em qualquer código. Isso protege enquanto você refatora.
  3. Trocar débitos simples por UPDATE condicional e os fluxos com regra de negócio por lockForUpdate com attempts.
  4. Padronizar a ordem de lock em toda operação que envolve mais de uma conta.
  5. 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.