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:
- 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.
- 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. - Webhook seguido de verificação. Um webhook atualiza o status do pagamento para
paid. Em seguida, outro fluxo lê o status na réplica, recebependinge 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. - 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:
- A chave é por usuário, não global. Uma escrita de um lojista não deve mandar todo mundo para o primário.
- 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.
- O cache é o Redis, compartilhado entre instâncias. Se o próximo request cair em outro servidor, a janela continua valendo.
- 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:
replay_lagficaNULLquando não há tráfego. Por isso ocoalesce. Uma réplica ociosa não está atrasada.- 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 compg_stat_replicationé mais confiável. - 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:
- Comece com
stickyligado e todas as operações de saldo dentro deDB::transaction. Isso protege o caminho do dinheiro desde o primeiro dia. - Marque com
useWritePdo()toda leitura que decide uma ação fora de transação, e coloque isso no checklist de review. - Mude os jobs que processam registros recém-criados para receber ID e ler do primário, com o middleware
ReadFromPrimary. - Adicione a janela de read-your-writes por usuário nos requests autenticados.
- Coloque o monitoramento de lag com fallback antes de mandar qualquer tela importante para a réplica.
- 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.