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:

// 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, 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. 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:

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

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. 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:

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:

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.

// 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());
// 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:

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.