# Réplica de leitura no PostgreSQL com Laravel: como lidar com o replication lag

> Separar leitura e escrita no Laravel alivia o banco principal, mas cria leituras desatualizadas. Veja como decidir o que lê da réplica, garantir read-your-writes e vigiar o lag.

Por Lucas Roberto · Arquitetura · publicado em 2026-10-22 · 10 min de leitura
Versão web: https://lucasroberto.com/blog/replica-de-leitura-no-postgresql-com-laravel-como-lidar-com-o-replication-lag

Colocar uma réplica de leitura do PostgreSQL atrás do Laravel é simples: basta configurar `read` e `write` no `config/database.php`. A parte difícil é o replication lag, o atraso entre o commit no primário e o momento em que a réplica enxerga esse dado. O usuário faz um Pix e não vê a transação no extrato. Um job não encontra o registro que acabou de ser criado. A solução é classificar as leituras: o que decide dinheiro lê do primário, o que vem logo depois de uma escrita usa read-your-writes, e o lag é monitorado para a aplicação voltar ao primário quando a réplica atrasar.

É esse desenho que eu uso em sistema financeiro. Abaixo estão as decisões e onde cada uma custa caro.

## Como o Laravel separa leitura e escrita no PostgreSQL?

A configuração é a mesma de sempre, com os hosts divididos:

```php
// config/database.php
'pgsql' => [
    'driver' => 'pgsql',
    'read' => [
        'host' => [env('DB_READ_HOST')],
    ],
    'write' => [
        'host' => [env('DB_WRITE_HOST')],
    ],
    'sticky' => true,
    'port' => env('DB_PORT', '5432'),
    'database' => env('DB_DATABASE'),
    'username' => env('DB_USERNAME'),
    'password' => env('DB_PASSWORD'),
],
```

Com isso, `SELECT` vai para a conexão de leitura e `INSERT`, `UPDATE` e `DELETE` vão para a de escrita. Dentro de uma transação (`DB::transaction`), o Laravel lê pela conexão de escrita. Esse detalhe ajuda muito, e vou voltar a ele.

A opção `sticky` faz o seguinte: se a conexão já escreveu algo no ciclo atual, as leituras seguintes vão para o primário. O escopo é o estado da conexão dentro do processo. Isso cobre o caso clássico de criar um registro e ler de volta no mesmo request. Não cobre o request seguinte, que nasce com a conexão limpa. Também não cobre o job na fila, que roda em outro processo. Toda a dor está nesses dois buracos.

Um termo antes de seguir: na streaming replication, o primário envia o WAL (Write-Ahead Log, o log de todas as mudanças) para a réplica, que o reaplica. A posição nesse log é o LSN (Log Sequence Number). Comparar LSNs diz com precisão se a réplica já aplicou um commit específico.

## Onde o replication lag quebra um sistema de pagamentos?

Na prática, o lag costuma ficar em milissegundos. Sob carga, em vacuum pesado, numa migration grande ou com a réplica subdimensionada, ele chega a segundos. Os sintomas típicos:

1. **Redirect após escrita.** O usuário confirma uma transferência e cai na tela de extrato. É um request novo, lido da réplica, e a transação não aparece. Ele tenta de novo, e agora você precisa torcer para a idempotência estar certa.
2. **Job que não encontra o model.** O job é despachado depois do commit, o worker pega em poucos milissegundos e busca o registro na réplica. Resultado: `ModelNotFoundException`.
3. **Webhook seguido de verificação.** Um webhook atualiza o status do pagamento para `paid`. Em seguida, outro fluxo lê o status na réplica, recebe `pending` e toma a decisão errada. Se você usa uma [máquina de estados para status de pagamento](/blog/maquina-de-estados-para-status-de-pagamento-no-laravel-como-impedir-transicoes-invalidas), ela vai rejeitar transições com base num estado que já não existe.
4. **Saldo para autorizar débito.** Ler saldo da réplica para decidir se um Pix pode sair é um bug de dinheiro.

O quarto caso nem é sobre lag. É sobre regra.

## Quais leituras podem ir para a réplica e quais devem ir para o primário?

A primeira decisão é de classificação. Ela precisa estar escrita no repositório, para não depender da memória de quem escreve a query. Eu uso esta tabela:

| Tipo de leitura | Onde lê | Por quê |
|---|---|---|
| Saldo, limite, status usados para decidir uma ação | Primário, dentro de transação | A decisão precisa do dado atual e, em geral, de lock |
| Leitura logo após o usuário escrever | Primário por uma janela curta (read-your-writes) | O usuário espera ver o que acabou de fazer |
| Jobs que processam algo recém-criado | Primário | O worker é mais rápido que a replicação |
| Extrato histórico, relatórios, dashboards | Réplica | Segundos de atraso não mudam nada |
| Exportações e consultas analíticas pesadas | Réplica | É justamente a carga que você quer tirar do primário |

A primeira linha se resolve quase de graça. Toda operação que mexe em saldo já deveria rodar dentro de `DB::transaction` com `lockForUpdate`, como descrevi no post sobre [race condition no saldo da conta digital](/blog/como-evitar-race-condition-no-saldo-da-conta-digital-com-laravel-e-postgresql). Dentro da transação, o Laravel lê pelo primário. Se você já faz o certo para concorrência, a réplica não te atinge nesse ponto.

Para leituras críticas fora de transação, deixe isso explícito na query:

```php
$payment = Payment::query()
    ->useWritePdo()
    ->whereKey($paymentId)
    ->firstOrFail();
```

`useWritePdo()` força aquela query específica a usar a conexão de escrita. Eu prefiro isso a criar uma segunda conexão nomeada só para o primário, porque a intenção fica visível no ponto de uso e no code review.

## Como garantir read-your-writes entre requests no Laravel?

Read-your-writes é a garantia de que quem escreveu um dado consegue lê-lo de volta logo em seguida. O `sticky` dá isso dentro do request. Entre requests, eu estendo a ideia com uma janela por usuário. Se o usuário escreveu algo, os próximos segundos de requests dele leem do primário.

```php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;

class ReadYourWrites
{
    private const WINDOW_SECONDS = 5;

    public function handle(Request $request, Closure $next)
    {
        $key = $this->key($request);
        $connection = DB::connection();

        if ($key && Cache::has($key)) {
            $connection->useWriteConnectionWhenReading();
        }

        $response = $next($request);

        if ($key && $connection->hasModifiedRecords()) {
            Cache::put($key, true, now()->addSeconds(self::WINDOW_SECONDS));
        }

        return $response;
    }

    private function key(Request $request): ?string
    {
        $id = $request->user()?->getAuthIdentifier();

        return $id ? "db:primary-window:{$id}" : null;
    }
}
```

Algumas decisões aqui:

1. **A chave é por usuário, não global.** Uma escrita de um lojista não deve mandar todo mundo para o primário.
2. **A janela é fixa e curta.** Cinco segundos cobrem o lag normal com folga. Se o lag passar disso, quem resolve é o monitoramento, mais abaixo, e não a janela.
3. **O cache é o Redis, compartilhado entre instâncias.** Se o próximo request cair em outro servidor, a janela continua valendo.
4. **Se você roda Laravel Octane**, o estado da conexão sobrevive entre requests no mesmo worker. Garanta que `useWriteConnectionWhenReading(false)` seja chamado ao fim do request, senão um usuário "contamina" o worker para os seguintes.

O trade-off é carga. Fluxos com muita escrita, como um PDV registrando venda atrás de venda, vão ler quase tudo do primário. Isso é aceitável, porque o ganho da réplica está em relatórios e telas de consulta, não no caminho de escrita.

## Por que o job da fila não encontra o model que acabou de ser criado?

Esse é o bug que mais engana. A equipe já aprendeu que job disparado dentro de transação precisa de `afterCommit`, o problema que detalhei em [jobs disparados dentro de transação e o afterCommit](/blog/jobs-disparados-dentro-de-transacao-o-bug-silencioso-do-aftercommit-no-laravel). Corrige isso e o erro continua aparecendo, só que mais raro. O motivo é que `afterCommit` garante que o dado existe no primário, não na réplica.

Tem um detalhe pior. Com `SerializesModels`, o model é recarregado do banco quando o job é desserializado, antes do `handle()` e antes de qualquer job middleware. Então um middleware que força o primário não te salva nesse ponto, porque a query de restauração já foi feita.

Por isso, em jobs que processam algo recém-criado, eu passo o ID e carrego o registro explicitamente do primário:

```php
class SettlePayment implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public function __construct(public readonly int $paymentId)
    {
    }

    public function middleware(): array
    {
        return [new ReadFromPrimary()];
    }

    public function handle(SettlementService $service): void
    {
        $payment = Payment::query()
            ->useWritePdo()
            ->findOrFail($this->paymentId);

        $service->settle($payment);
    }
}
```

O middleware cobre as outras leituras do job, como relacionamentos carregados de forma lazy e queries dentro de services:

```php
namespace App\Jobs\Middleware;

use Closure;
use Illuminate\Support\Facades\DB;

class ReadFromPrimary
{
    public function handle(object $job, Closure $next): void
    {
        $connection = DB::connection();
        $connection->useWriteConnectionWhenReading(true);

        try {
            $next($job);
        } finally {
            $connection->useWriteConnectionWhenReading(false);
        }
    }
}
```

O `finally` não é opcional. O worker é um processo de longa duração, e sem ele o próximo job herda o modo primário sem ninguém perceber.

Resista à tentação de usar `$deleteWhenMissingModels = true` para "resolver" o `ModelNotFoundException`. Num sistema de pagamentos, isso troca um erro visível por um job de liquidação que some em silêncio.

## Como saber com precisão se a réplica já tem o meu commit?

A janela de tempo é uma aproximação. Quando preciso de garantia, por exemplo numa tela que mostra o comprovante e não pode exibir dado velho nem sobrecarregar o primário, uso consistência causal via LSN. Depois do commit, guardo a posição do WAL no primário. Antes de ler, pergunto à réplica se ela já passou daquele ponto.

```php
// logo após o commit, no primário
$lsn = DB::selectOne(
    'select pg_current_wal_lsn()::text as lsn',
    [],
    useReadPdo: false,
)->lsn;

Cache::put("db:lsn:{$userId}", $lsn, now()->addMinute());
```

```php
// antes de ler, no request seguinte
function replicaCaughtUp(?string $lsn): bool
{
    if ($lsn === null) {
        return true;
    }

    $row = DB::selectOne(
        'select coalesce(pg_last_wal_replay_lsn() >= ?::pg_lsn, false) as ok',
        [$lsn],
    );

    return (bool) $row->ok;
}

if (! replicaCaughtUp(Cache::get("db:lsn:{$userId}"))) {
    DB::connection()->useWriteConnectionWhenReading();
}
```

`pg_last_wal_replay_lsn()` devolve a última posição reaplicada na réplica. No primário, ela retorna `NULL`, e por isso o `coalesce` com `false` faz o código cair no primário, que é o comportamento seguro. O custo é uma query extra por request, barata mas não gratuita. Por isso eu aplico esse esquema só nas rotas em que read-your-writes exato importa, e deixo a janela de tempo para o resto.

Com várias réplicas, o Laravel escolhe um host de leitura quando abre a conexão e o mantém até o fim do processo. A checagem de LSN e a leitura seguinte vão para a mesma réplica, então a verificação vale.

## Como monitorar o replication lag e voltar para o primário automaticamente?

Todas as estratégias acima assumem lag pequeno. Quando ele cresce, até as leituras "tolerantes" ficam ruins: um extrato com minutos de atraso gera chamado no suporte e desconfiança. Por isso eu meço o lag a partir do primário e ligo uma flag de degradação:

```php
namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class CheckReplicationLag extends Command
{
    protected $signature = 'db:check-replication-lag';

    private const MAX_LAG_SECONDS = 10;

    public function handle(): int
    {
        $rows = DB::select(
            'select application_name,
                    coalesce(extract(epoch from replay_lag), 0) as lag
               from pg_stat_replication',
            [],
            useReadPdo: false,
        );

        $maxLag = collect($rows)->max('lag') ?? 0;
        $degraded = empty($rows) || $maxLag > self::MAX_LAG_SECONDS;

        Cache::put('db:replica-degraded', $degraded, now()->addMinutes(2));

        if ($degraded) {
            Log::warning('Replica degradada', ['max_lag' => $maxLag, 'replicas' => count($rows)]);
        }

        return self::SUCCESS;
    }
}
```

Agendo o comando a cada minuto com `withoutOverlapping()`. No mesmo middleware de request, e num listener do evento `JobProcessing` para os workers, verifico `Cache::get('db:replica-degraded')`. Se a flag estiver ligada, todas as leituras vão para o primário.

Três observações que aprendi apanhando:

1. **`replay_lag` fica `NULL` quando não há tráfego.** Por isso o `coalesce`. Uma réplica ociosa não está atrasada.
2. **Na réplica, `now() - pg_last_xact_replay_timestamp()` engana.** Sem escritas no primário, esse valor cresce sem haver atraso real. Medir pelo primário com `pg_stat_replication` é mais confiável.
3. **A flag expira sozinha.** Se o comando parar de rodar, o TTL de dois minutos faz a aplicação voltar ao comportamento normal. Se você preferir o lado conservador, inverta a lógica e trate a ausência da flag como degradado.

Antes de ligar esse fallback, confirme que o primário aguenta a carga total de leitura por alguns minutos. Se não aguenta, o fallback derruba o primário justamente no pior momento. Nesse caso, o melhor é degradar funcionalidade: desligar exportações e relatórios pesados enquanto a réplica se recupera.

## Vale a pena ter réplica de leitura num sistema financeiro?

Vale, desde que a motivação seja tirar do primário carga que não precisa estar lá: relatórios, dashboards do lojista, exportações de extrato, conciliação de períodos fechados. Se o gargalo é escrita, ou se as queries lentas são lentas por falta de índice, a réplica só adiciona uma classe nova de bug.

A ordem que eu sigo ao introduzir uma réplica:

1. Comece com `sticky` ligado e todas as operações de saldo dentro de `DB::transaction`. Isso protege o caminho do dinheiro desde o primeiro dia.
2. Marque com `useWritePdo()` toda leitura que decide uma ação fora de transação, e coloque isso no checklist de review.
3. Mude os jobs que processam registros recém-criados para receber ID e ler do primário, com o middleware `ReadFromPrimary`.
4. Adicione a janela de read-your-writes por usuário nos requests autenticados.
5. Coloque o monitoramento de lag com fallback antes de mandar qualquer tela importante para a réplica.
6. Só então mova relatórios e telas de consulta para a réplica, uma de cada vez, observando o primário aliviar.

O erro comum é inverter essa ordem: ligar a réplica, mandar tudo para ela e corrigir os bugs conforme aparecem. Em sistema financeiro, cada um desses bugs aparece como saldo errado na tela de alguém. Classifique as leituras primeiro e mova a carga depois.

---

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