# Como evitar race condition no saldo da conta digital com Laravel e PostgreSQL

> Debitar saldo com ler-calcular-salvar quebra sob concorrência. Quando usar lockForUpdate, update atômico condicional, Cache::lock e constraint no banco.

Por Lucas Roberto · Laravel · publicado em 2026-10-09 · 7 min de leitura
Versão web: https://lucasroberto.com/blog/como-evitar-race-condition-no-saldo-da-conta-digital-com-laravel-e-postgresql

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:

```php
$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.

```php
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`:

```php
$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:

```php
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:

```php
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:

```php
$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.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
