Migrações de banco de dados com tempo de inatividade zero com Drizzle ORM
As migrações de banco de dados são a operação mais perigosa nas implantações de produção. Uma migração que bloqueia uma tabela, mesmo que por 200 ms, causa tempos limite de conexão que se transformam em 500 erros, que se transformam em tempestades de alertas às 2h. A maioria dos tutoriais de migração ORM mostra o caminho “fácil”: desligue o aplicativo, execute a migração e reinicie. Isso não é tempo de inatividade zero; isso é manutenção programada.
As migrações reais sem tempo de inatividade exigem um modelo mental diferente: seu esquema deve ser compatível com versões anteriores do código do aplicativo em execução no momento durante todo o processo de implantação. Isso significa que o código antigo e o novo devem funcionar simultaneamente durante a janela de implementação. O padrão de contrato expandido torna isso possível. A abordagem SQL-first do Drizzle ORM oferece o controle para implementá-la corretamente.
Principais conclusões
- Nunca execute
drizzle-kit pushem produção — gere e revise SQL e aplique com controle- O padrão de contrato de expansão: adicionar (expandir) → implantar novo código → remover antigo (contrato)
- Adicionar uma coluna anulável é seguro; adicionar NOT NULL sem um padrão é perigoso
- Renomear uma coluna requer: adicionar nova coluna → preencher → atualizar código → remover coluna antiga (4 etapas)
- Use PostgreSQL
CREATE INDEX CONCURRENTLYpara evitar bloqueios de tabela durante a criação do índice- Teste as migrações em uma cópia do seu banco de dados em tamanho de produção antes de aplicá-las à produção
- Sempre tenha um plano de rollback: para cada migração, escreva e teste o SQL de rollback
- Mudanças de esquema que exigem reescritas de tabela (ALTER TYPE em uma tabela grande) precisam de janelas de manutenção
Configuração do esquema ORM do Drizzle
// packages/db/src/schema/contacts.ts
import { pgTable, uuid, varchar, text, timestamp, pgEnum, index } from 'drizzle-orm/pg-core';
export const contactStatusEnum = pgEnum('contact_status', ['active', 'inactive', 'archived']);
export const contacts = pgTable('contacts', {
id: uuid('id').primaryKey().defaultRandom(),
organizationId: uuid('organization_id').notNull(),
name: varchar('name', { length: 255 }).notNull(),
email: varchar('email', { length: 255 }),
phone: varchar('phone', { length: 50 }),
status: contactStatusEnum('status').default('active').notNull(),
notes: text('notes'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
orgIdIdx: index('contacts_org_id_idx').on(table.organizationId),
emailIdx: index('contacts_email_idx').on(table.email),
statusIdx: index('contacts_status_idx').on(table.status),
}));
Gerar migrações:
# Generate SQL migration (never auto-apply to production)
npx drizzle-kit generate
# Review the generated SQL before applying
cat drizzle/0001_add_contact_status.sql
# Apply to development database
npx drizzle-kit migrate
# For production: apply via your deployment pipeline
O padrão de expansão-contrato
The core principle: every schema change is split into multiple backward-compatible deployments.
Fase 1: Expandir (Adicionar sem quebrar)
Implante alterações de esquema apenas aditivas – novas colunas com padrões, novas tabelas, novos índices.
-- Migration: 0010_expand_add_company.sql
-- Safe to apply while old code is running: nullable column with a default
ALTER TABLE contacts
ADD COLUMN IF NOT EXISTS company_id UUID,
ADD COLUMN IF NOT EXISTS company_name VARCHAR(255);
-- Old code ignores these columns; new code uses them
-- Both versions coexist during rolling deployment
// Drizzle schema after expansion
export const contacts = pgTable('contacts', {
// ... existing columns ...
companyId: uuid('company_id'), // nullable — old code ignores it
companyName: varchar('company_name', { length: 255 }), // nullable — safe
});
Fase 2: Implantar novo código de aplicativo
Implante a nova versão do aplicativo que lê e grava colunas novas e antigas. Durante a implantação contínua, instâncias antigas (sem suporte da empresa) e novas instâncias (suporte da empresa) são executadas simultaneamente — ambas funcionam porque a coluna é anulável.
Fase 3: Preenchimento
Preencha a nova coluna para linhas existentes em pequenos lotes para evitar bloqueios de tabela:
-- Migration: 0011_backfill_company_name.sql
-- Run in small batches to avoid locking
DO $$
DECLARE
batch_size INTEGER := 1000;
offset_val INTEGER := 0;
rows_updated INTEGER;
BEGIN
LOOP
UPDATE contacts
SET company_name = 'Unknown'
WHERE company_name IS NULL
AND id IN (
SELECT id FROM contacts
WHERE company_name IS NULL
ORDER BY id
LIMIT batch_size
OFFSET offset_val
);
GET DIAGNOSTICS rows_updated = ROW_COUNT;
EXIT WHEN rows_updated = 0;
offset_val := offset_val + batch_size;
-- Brief pause between batches to reduce I/O pressure
PERFORM pg_sleep(0.1);
END LOOP;
END $$;
Fase 4: Contrato (remover coluna antiga)
Depois que todo o código do aplicativo for atualizado e o preenchimento for concluído, remova a coluna ou restrição antiga.
Padrões de migração segura
Adicionando uma coluna NOT NULL
-- WRONG — will fail if table has rows (no default, no nullable)
ALTER TABLE contacts ADD COLUMN tier VARCHAR(20) NOT NULL;
-- WRONG — locks table while it writes the default to every row
ALTER TABLE contacts ADD COLUMN tier VARCHAR(20) NOT NULL DEFAULT 'free';
-- CORRECT — three-step approach
-- Step 1: Add nullable column
ALTER TABLE contacts ADD COLUMN tier VARCHAR(20);
-- Step 2: Backfill existing rows
UPDATE contacts SET tier = 'free' WHERE tier IS NULL;
-- Step 3 (next deployment): Add NOT NULL constraint (instant if no NULLs exist)
ALTER TABLE contacts ALTER COLUMN tier SET NOT NULL;
ALTER TABLE contacts ALTER COLUMN tier SET DEFAULT 'free';
Renomeando uma coluna (processo de 4 etapas)
-- Step 1: Add the new column
ALTER TABLE contacts ADD COLUMN full_name VARCHAR(255);
-- Step 2: Backfill + keep in sync with a trigger
UPDATE contacts SET full_name = name;
CREATE OR REPLACE FUNCTION sync_full_name() RETURNS trigger AS $$
BEGIN
NEW.full_name := NEW.name;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER contacts_sync_full_name
BEFORE INSERT OR UPDATE OF name ON contacts
FOR EACH ROW EXECUTE FUNCTION sync_full_name();
-- Step 3: Deploy new code that writes to full_name, reads from both
-- Step 4 (next deployment, after all instances updated):
DROP TRIGGER contacts_sync_full_name ON contacts;
DROP FUNCTION sync_full_name;
ALTER TABLE contacts DROP COLUMN name;
Alterando um tipo de enumeração
O PostgreSQL não permite a remoção de valores enum sem uma reescrita completa da tabela. Para alterações seguras de enumeração:
-- Adding a new enum value is safe (instant, no lock)
ALTER TYPE contact_status ADD VALUE IF NOT EXISTS 'pending';
-- Removing an enum value requires a full table rewrite
-- Use the expand-contract pattern: add a new enum type, migrate, drop old
CREATE TYPE contact_status_new AS ENUM ('active', 'inactive', 'archived', 'pending');
-- Migrate data
ALTER TABLE contacts
ALTER COLUMN status TYPE contact_status_new
USING status::text::contact_status_new;
DROP TYPE contact_status;
ALTER TYPE contact_status_new RENAME TO contact_status;
Criação de índice sem bloqueio
CREATE INDEX regular adquire um ShareLock que bloqueia todas as gravações durante o período. Em uma mesa grande, isso pode levar alguns minutos.
-- WRONG — locks writes during index creation
CREATE INDEX contacts_email_idx ON contacts(email);
-- CORRECT — concurrent index creation; no write lock
CREATE INDEX CONCURRENTLY IF NOT EXISTS contacts_email_idx
ON contacts(email)
WHERE email IS NOT NULL; -- Partial index for better performance
-- Drizzle note: drizzle-kit does not generate CONCURRENTLY by default
-- Edit the generated SQL migration to add CONCURRENTLY before applying
No arquivo de migração do Drizzle, adicione manualmente CONCURRENTLY após a geração:
-- drizzle/0012_add_email_index.sql (edited after generation)
CREATE INDEX CONCURRENTLY IF NOT EXISTS contacts_email_idx
ON contacts (email)
WHERE email IS NOT NULL;
Migração Drizzle em CI/CD de produção
Nunca use drizzle-kit push em produção — ele aplica migrações sem uma etapa de revisão. Use drizzle-kit migrate com arquivos SQL gerados com controle de versão:
# scripts/deploy-production.sh
echo "=== Running database migrations ==="
# Check if there are pending migrations
PENDING=$(npx drizzle-kit migrate --dry-run 2>&1 | grep "pending")
if [ -n "$PENDING" ]; then
echo "Pending migrations detected:"
echo "$PENDING"
# Apply migrations
npx drizzle-kit migrate
echo "Migrations applied successfully"
else
echo "No pending migrations"
fi
// Programmatic migration in main.ts (NestJS)
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
async function runMigrations() {
const migrationClient = postgres(process.env.DATABASE_URL!, { max: 1 });
const db = drizzle(migrationClient);
await migrate(db, {
migrationsFolder: join(__dirname, '..', '..', '..', 'drizzle'),
});
await migrationClient.end();
}
// In bootstrap(), before app.listen():
if (process.env.RUN_MIGRATIONS === 'true') {
await runMigrations();
}
Estratégias de reversão
Toda migração deve ter uma migração de reversão complementar escrita e testada antes da implantação:
// drizzle/0013_add_company_id.sql (forward migration)
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS company_id UUID;
CREATE INDEX CONCURRENTLY IF NOT EXISTS contacts_company_id_idx
ON contacts(company_id);
// drizzle/rollbacks/0013_rollback_add_company_id.sql
DROP INDEX CONCURRENTLY IF EXISTS contacts_company_id_idx;
ALTER TABLE contacts DROP COLUMN IF EXISTS company_id;
Para reversão de emergência:
# Emergency rollback script
# Run the rollback SQL, then redeploy the previous app version
psql "$DATABASE_URL" < drizzle/rollbacks/0013_rollback_add_company_id.sql
git checkout HEAD~1
pnpm build && pm2 restart ecosystem.config.cjs --update-env
Estratégia de teste de migração
// packages/db/src/tests/migration.spec.ts
import { describe, it, expect, beforeAll } from 'vitest';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/postgres-js';
describe('Database Migrations', () => {
let sql: ReturnType<typeof postgres>;
beforeAll(async () => {
sql = postgres(process.env.TEST_DATABASE_URL!);
});
it('all migrations apply cleanly on a fresh database', async () => {
const db = drizzle(sql);
await expect(migrate(db, { migrationsFolder: './drizzle' }))
.resolves.not.toThrow();
});
it('migrations are idempotent (can be applied twice safely)', async () => {
const db = drizzle(sql);
await migrate(db, { migrationsFolder: './drizzle' });
// Running again should be a no-op, not an error
await expect(migrate(db, { migrationsFolder: './drizzle' }))
.resolves.not.toThrow();
});
});
Operações perigosas que requerem janelas de manutenção
Algumas operações não podem ter tempo de inatividade zero em tabelas muito grandes (centenas de milhões de linhas):
| Operação | Risco | Mitigação |
|---|---|---|
ALTER TYPE USING em coluna grande | Reescrita completa da tabela, bloqueio longo | Janela de manutenção programada |
ADD COLUMN NOT NULL sem padrão | Falha imediata se existirem linhas | Use contrato de expansão em três etapas |
CREATE INDEX (sem CONCORRENTEMENTE) | Bloqueio de gravação por duração | Sempre use CONCURRENTLY |
| CÓDIGO0 | Instantâneo, mas quebra código antigo | Somente na fase de expansão após atualização do código |
| CÓDIGO0 | Irreversível | Tenha sempre um backup; Política de exclusão reversível de 30 dias |
| CÓDIGO0 | Bloqueio de tabela por duração | Use VACUUM regular + cronograma FULL durante a manutenção |
Perguntas frequentes
Qual é a diferença entre drizzle-kit push e drizzle-kit migrate?
push aplica diretamente seu esquema atual ao banco de dados sem gerar arquivos de migração — útil para desenvolvimento onde você não se importa com o histórico de migração. migrate aplica os arquivos SQL em seu diretório drizzle/ em ordem, rastreando quais foram aplicados. Sempre use migrate na produção para ter um histórico controlado, revisável e controlado por versão de cada alteração de esquema.
Como o Drizzle lida com a tabela de rastreamento drizzle_migrations?
Drizzle cria uma tabela drizzle_migrations (ou __drizzle_migrations) que registra quais arquivos de migração foram aplicados e quando. Antes de aplicar uma migração, verifique esta tabela. Não insira ou exclua manualmente linhas desta tabela — é o mecanismo de rastreamento de estado do Drizzle. Se você precisar reverter para um estado de migração anterior, execute o SQL de reversão e exclua manualmente a linha correspondente.
Como testar migrações em um banco de dados de produção?
Restaure uma cópia higienizada (removida de PII) do seu banco de dados de produção para um servidor temporário. Aplique a migração lá e meça: tempo de parede, tempo de espera de bloqueio (de pg_stat_activity), inchaço da tabela (de pg_stat_user_tables) e impacto no desempenho da consulta (de EXPLAIN ANALYZE). Se a migração demorar mais de cinco segundos para ser preparada, redesenhe-a.
Posso usar transações para migrações de banco de dados?
A maior parte do DDL no PostgreSQL é transacional - você pode agrupar ALTER TABLE, CREATE TABLE, CREATE INDEX (mas NÃO CREATE INDEX CONCURRENTLY) em uma transação e reverter se alguma etapa falhar. O Drizzle agrupa cada arquivo de migração em uma transação por padrão. Para migrações que usam CONCURRENTLY, você deve dividi-las em um arquivo de migração separado que é executado fora de uma transação.
Como faço para lidar com comparações de enum do Drizzle no TypeScript?
As colunas enum do Drizzle retornam o valor da string do banco de dados. Ao comparar, o TypeScript pode não restringir o tipo corretamente. Transmita explicitamente: if ((contact.status as ContactStatus) === 'active') ou use uma asserção de tipo em sua cláusula Drizzle where: where(eq(contacts.status, 'active' as ContactStatus)). Isso evita erros de tempo de execução enquanto mantém o TypeScript satisfeito.
Próximas etapas
As migrações de bancos de dados com tempo de inatividade zero exigem disciplina, mas a alternativa (tempo de inatividade, corrupção de dados, reversões de emergência às 3 da manhã) é muito pior. O padrão de contrato de expansão, CREATE INDEX CONCURRENTLY, e a preparação adequada de reversão tornam as alterações de esquema uma operação rotineira e segura.
ECOSIRE gerencia esquemas de banco de dados para aplicativos NestJS com Drizzle ORM, PostgreSQL 17 e um pipeline de implantação com tempo de inatividade zero testado em mais de 65 arquivos de esquema e mais de 300 migrações. Explore nossos serviços de engenharia de back-end para saber como lidamos com operações de banco de dados com segurança e em grande escala.
Escrito por
ECOSIRE Research and Development Team
Construindo produtos digitais de nível empresarial na ECOSIRE. Compartilhando insights sobre integrações Odoo, automação de e-commerce e soluções de negócios com IA.
Artigos Relacionados
AWS EC2 Deployment Guide for Web Applications
Complete AWS EC2 deployment guide: instance selection, security groups, Node.js deployment, Nginx reverse proxy, SSL, auto-scaling, CloudWatch monitoring, and cost optimization.
Drizzle ORM with PostgreSQL: Complete Guide
Complete guide to Drizzle ORM with PostgreSQL: schema design, migrations, type-safe queries, relations, transactions, and production patterns for TypeScript apps.
Enterprise Security for OpenClaw AI Deployments
Comprehensive guide to securing OpenClaw AI agent deployments in enterprise environments. Covers authentication, secrets management, network isolation, and compliance.