Para rodar migrations no Laravel sem derrubar a aplicação em uma tabela grande do PostgreSQL, você precisa de quatro coisas. A primeira é um lock_timeout curto em toda migration. A segunda é criar índices com CREATE INDEX CONCURRENTLY fora de transação. A terceira é adicionar NOT NULL e foreign keys em duas etapas, com NOT VALID e depois VALIDATE. A quarta é tirar o backfill de dados de dentro da migration. Mudanças que quebram contrato, como renomear ou trocar o tipo de uma coluna, seguem o padrão expand/contract, distribuído em mais de um deploy.
Aprendi isso do jeito comum em fintech. A tabela de transações cresce todo dia, e um $table->index() inocente que roda em milissegundos no ambiente local trava escrita em produção enquanto a maquininha continua tentando registrar venda. O problema não é o Laravel. O Schema Builder gera SQL correto. O que ele não faz é escolher por você o SQL que convive bem com tráfego.
Por que uma migration simples trava a tabela em produção?
Quase todo ALTER TABLE no PostgreSQL pede um lock ACCESS EXCLUSIVE, o lock mais forte que existe. Ele bloqueia leitura e escrita na tabela enquanto estiver pendente ou ativo. Se a operação for só de metadados, esse lock dura microssegundos e ninguém percebe. O problema aparece em dois cenários.
- A operação reescreve ou varre a tabela inteira. Exemplos: trocar
integerporbigint, adicionarNOT NULLdireto, criar índice semCONCURRENTLY. O lock fica ativo durante toda a varredura. - A fila de locks. O seu
ALTERchega enquanto existe uma transação longa lendo a tabela, como um relatório, um job de conciliação ou umSELECTesquecido aberto no psql. Ele fica esperando. Toda query que chega depois, inclusiveSELECT, entra na fila atrás dele. A operação podia levar 1 ms, mas a tabela fica parada pelo tempo que a transação longa demorar.
O segundo cenário é o mais traiçoeiro, porque acontece até com operações "seguras". Se você já lidou com SELECT ... FOR UPDATE para evitar race condition no saldo da conta digital, conhece a mecânica: o lock em si não é o vilão, o vilão é quem fica esperando por ele.
Quais operações de schema são seguras no PostgreSQL?
Esta é a tabela que consulto antes de escrever qualquer migration em tabela grande (PostgreSQL 12 ou superior):
| Operação | Custo real | Como fazer com segurança |
|---|---|---|
ADD COLUMN nullable, sem default |
Só metadados | Direto, com lock_timeout |
ADD COLUMN com default constante |
Só metadados (PG 11+) | Direto, com lock_timeout |
ADD COLUMN com default volátil (now(), gen_random_uuid()) |
Reescreve a tabela | Adicionar nullable e fazer backfill |
CREATE INDEX |
Bloqueia escrita durante a criação | CREATE INDEX CONCURRENTLY |
SET NOT NULL |
Varre a tabela com lock exclusivo | CHECK ... NOT VALID + VALIDATE |
ADD FOREIGN KEY |
Varre a tabela e trava as duas | NOT VALID + VALIDATE |
Aumentar varchar(n) ou trocar por text |
Só metadados | Direto |
integer para bigint, mudanças de tipo em geral |
Reescreve a tabela | Expand/contract |
RENAME COLUMN |
Só metadados, mas quebra o código antigo | Expand/contract |
DROP COLUMN |
Só metadados, mas quebra o código antigo | Remover do código primeiro |
Repare que "só metadados" não quer dizer "sem risco". Continua existindo o lock ACCESS EXCLUSIVE, e com ele a fila de locks. Por isso a primeira regra vale para tudo.
Como configurar lock_timeout nas migrations do Laravel?
lock_timeout é o tempo máximo que uma statement espera para conseguir um lock antes de falhar. Com ele, o ALTER que encontra uma transação longa no caminho desiste em poucos segundos em vez de congelar a tabela. A migration falha, o deploy para, você investiga e roda de novo. É muito melhor do que um incidente.
No PostgreSQL, o Laravel roda cada migration dentro de uma transação por padrão. Então dá para usar SET LOCAL, que vale só para aquela transação e não vaza para a conexão. Isso importa se você usa PgBouncer em modo transaction.
return new class extends Migration { public function up(): void { DB::statement("SET LOCAL lock_timeout = '3s'"); Schema::table('transactions', function (Blueprint $table) { $table->string('external_reference')->nullable(); }); } };
Para não depender da memória de ninguém, centralizo isso. Uma opção é uma classe base SafeMigration que o time estende. A outra é configurar o timeout no próprio role usado pelo deploy: ALTER ROLE deploy SET lock_timeout = '3s'. Gosto da segunda quando o deploy roda com um usuário separado do usuário da aplicação, porque assim a regra não depende de código.
Um detalhe: lock_timeout não é statement_timeout. O primeiro limita a espera pelo lock. O segundo limita a execução inteira. Em migration, quero o primeiro curto e o segundo desligado ou generoso, porque uma validação de constraint pode levar minutos legitimamente.
Como criar índice sem travar a tabela no Laravel?
CREATE INDEX CONCURRENTLY constrói o índice sem bloquear escrita. Ele faz duas passadas na tabela e espera as transações em andamento terminarem, então demora mais, mas a aplicação continua escrevendo. A restrição é que ele não roda dentro de transação. Por isso a migration precisa desligar o wrapper com $withinTransaction = false:
return new class extends Migration { public $withinTransaction = false; public function up(): void { DB::statement('DROP INDEX CONCURRENTLY IF EXISTS transactions_external_reference_idx'); DB::statement( 'CREATE INDEX CONCURRENTLY transactions_external_reference_idx ON transactions (external_reference)' ); } public function down(): void { DB::statement('DROP INDEX CONCURRENTLY IF EXISTS transactions_external_reference_idx'); } };
O DROP antes do CREATE está lá de propósito. Se o CONCURRENTLY falhar no meio, por deadlock, por violação de unicidade num índice UNIQUE ou por timeout, o PostgreSQL deixa para trás um índice marcado como INVALID. Esse índice não serve para leitura, mas continua custando em toda escrita. Sem o DROP, a segunda tentativa da migration falha porque o nome já existe. Para achar índices inválidos esquecidos:
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;
Dois cuidados. Primeiro, como a migration não está em transação, SET LOCAL não funciona nela. Ou você usa o timeout no role, ou usa SET lock_timeout de sessão e assume que a conexão é dedicada ao processo de migrate. Segundo, se a tabela for particionada, CONCURRENTLY não funciona na tabela pai. Você cria o índice em cada partição com CONCURRENTLY, cria o índice no pai com ON ONLY e anexa cada um com ALTER INDEX ... ATTACH PARTITION. Detalhei o contexto disso no post sobre particionamento no PostgreSQL com Laravel para a tabela de transações.
Como adicionar NOT NULL e foreign key em tabela grande sem bloquear?
SET NOT NULL direto varre a tabela inteira segurando ACCESS EXCLUSIVE. O truque é usar uma constraint NOT VALID, que é uma constraint aplicada só às linhas novas, sem checar as existentes. Depois você valida separadamente, com um lock mais fraco (SHARE UPDATE EXCLUSIVE) que não bloqueia leitura nem escrita. A partir do PostgreSQL 12, SET NOT NULL aproveita uma CHECK (col IS NOT NULL) já validada e pula a varredura.
Faço em duas migrations, em deploys diferentes ou pelo menos em execuções diferentes:
// Migration 1: rápida, só metadados DB::statement("SET LOCAL lock_timeout = '3s'"); DB::statement( 'ALTER TABLE transactions ADD CONSTRAINT transactions_external_reference_not_null CHECK (external_reference IS NOT NULL) NOT VALID' );
// Migration 2: depois do backfill terminar public $withinTransaction = false; public function up(): void { DB::statement('ALTER TABLE transactions VALIDATE CONSTRAINT transactions_external_reference_not_null'); DB::statement('ALTER TABLE transactions ALTER COLUMN external_reference SET NOT NULL'); DB::statement('ALTER TABLE transactions DROP CONSTRAINT transactions_external_reference_not_null'); }
Foreign key segue a mesma lógica: ADD CONSTRAINT ... FOREIGN KEY ... NOT VALID e depois VALIDATE CONSTRAINT. Aqui vale lembrar que adicionar FK trava as duas tabelas, a que referencia e a referenciada. Se a referenciada for accounts, a tabela mais lida do sistema, o lock_timeout é obrigatório.
O trade-off é que o Schema Builder não expressa nada disso. Você abre mão de $table->foreign() e escreve SQL cru. Para mim compensa: em tabela pequena uso o Blueprint normalmente, em tabela grande uso SQL explícito, e o code review deixa claro qual é qual.
Como fazer backfill de milhões de linhas sem derrubar o banco?
Backfill é o preenchimento de uma coluna nova com dados derivados das linhas existentes. O erro clássico é fazer isso dentro da migration com um único UPDATE transactions SET .... Isso gera uma transação gigante. Ela segura lock de linha em tudo, incha o WAL, atrasa réplicas e, se falhar no minuto 40, desfaz tudo.
O backfill vai para um job que processa lotes pequenos e se reagenda. Uso paginação por chave (keyset), ou seja, filtro id > último_id em vez de OFFSET, para cada lote custar o mesmo do primeiro ao último:
class BackfillExternalReference implements ShouldQueue { use Queueable; public function __construct(public int $lastId = 0) {} public function handle(): void { $ids = DB::table('transactions') ->where('id', '>', $this->lastId) ->whereNull('external_reference') ->orderBy('id') ->limit(2000) ->pluck('id'); if ($ids->isEmpty()) { return; } DB::table('transactions') ->whereIn('id', $ids) ->update(['external_reference' => DB::raw("provider_payload->>'id'")]); self::dispatch($ids->last()) ->onQueue('maintenance') ->delay(now()->addSecond()); } }
Decisões que importam aqui:
- O cursor avança mesmo que o valor derivado seja nulo. Se usasse só
whereNull, linhas semprovider_payload->>'id'seriam selecionadas para sempre e o job entraria em loop. - Lote pequeno e pausa entre lotes. Cada lote é uma transação curta. A pausa dá fôlego para o autovacuum e para a replicação. O tamanho certo depende da largura da linha. Comece pequeno e meça.
- Fila separada. O backfill roda em
maintenance, com poucos workers, para não disputar com webhooks e cobranças. É o mesmo raciocínio do bulkhead de filas no Laravel Horizon. - O código novo já grava a coluna antes do backfill começar. Senão o backfill termina e linhas novas continuam nascendo nulas.
Dispare o job por um comando Artisan, não pela migration. Assim você controla quando ele começa, pode pausar e acompanha pelo Horizon.
Como renomear ou trocar o tipo de uma coluna sem downtime?
Aqui não existe SQL mágico. Durante o deploy, código antigo e código novo rodam ao mesmo tempo: workers que ainda não reiniciaram, requests em andamento, servidores atualizando em sequência. Um RENAME COLUMN é instantâneo no banco e mesmo assim quebra o código antigo na hora. A saída é o padrão expand/contract: primeiro você expande o schema para suportar o antigo e o novo, depois contrai removendo o antigo. Cada passo vai em um deploy:
- Expand: adicionar a coluna nova (
amount_cents bigint, nullable). - Escrita dupla: o código passa a gravar nas duas colunas.
- Backfill: job em lotes preenche a coluna nova nas linhas antigas.
- Trocar a leitura: o código passa a ler só da coluna nova. Esse é o passo em que vale comparar as duas colunas por alguns dias.
- Parar de escrever na antiga: o código esquece a coluna antiga.
- Contract: migration remove a coluna antiga.
Para a escrita dupla, existem duas opções com trade-offs diferentes:
protected static function booted(): void { static::saving(function (Transaction $transaction) { if ($transaction->isDirty('amount')) { $transaction->amount_cents = $transaction->amount; } }); }
O evento do Eloquent é simples e fica no código, mas não pega DB::table()->update() nem updates em massa via query builder. Um trigger no PostgreSQL pega tudo, mas é lógica escondida no banco, que alguém precisa lembrar de remover no passo 6. Em tabela financeira, onde existem jobs de conciliação e correções que escrevem direto pelo query builder, costumo preferir o trigger e deixar a remoção dele explícita na migration de contract.
Em que ordem rodar migrations e deploy no Laravel?
No fluxo típico com Forge, Envoyer ou pipelines parecidos, php artisan migrate --force roda antes da nova release entrar no ar. Na prática, toda migration precisa ser compatível com o código que está rodando agora, não só com o código que vai entrar. Isso dá três regras práticas:
- Adicionar: migration e código podem ir juntos. O código antigo ignora a coluna nova.
- Remover: primeiro sai o código que usa a coluna, num deploy. A migration de
DROPvai no deploy seguinte. - Renomear ou mudar tipo: expand/contract, nunca em um deploy só.
Se a migration roda em mais de um servidor, use php artisan migrate --force --isolated. Ele pega um lock atômico no cache antes de rodar, e duas máquinas não tentam o mesmo ALTER ao mesmo tempo. Antes de aprovar uma migration em tabela grande, rodo php artisan migrate --pretend para ver o SQL exato que o Schema Builder vai gerar. Mais de uma vez isso revelou um ALTER COLUMN TYPE escondido atrás de um ->change().
Conclusão: o checklist que aplico antes de cada migration
Não trato toda migration como perigosa. Trato como perigosa toda migration em tabela que recebe escrita contínua, que em fintech quer dizer transações, lançamentos, webhooks e contas. Para essas, o review responde sempre às mesmas perguntas:
- Tem
lock_timeoutcurto, viaSET LOCALou via role do deploy? - Índice novo usa
CONCURRENTLY, com$withinTransaction = falseeDROP INDEX CONCURRENTLY IF EXISTSantes? NOT NULLe foreign key estão em duas etapas, comNOT VALIDeVALIDATE?- Algum
UPDATEde dados ficou dentro da migration? Se sim, vira job em lotes numa fila própria. - A migration funciona com o código que está em produção agora?
- Renomeação ou mudança de tipo está quebrada em passos de expand/contract?
Se tudo isso estiver certo, o pior cenário vira uma migration que falha em três segundos por lock_timeout e roda de novo cinco minutos depois. É exatamente o tipo de falha que você quer ter num sistema que movimenta dinheiro.