Migração PostgreSQL com Zero Downtime
Runbook operacional das sete estratégias de replicação e migração de PostgreSQL com
indisponibilidade zero ou mínima: replicação física com switchover,
replicação lógica nativa, pg_createsubscriber,
pg_upgrade, pglogical, CDC com Debezium e as ferramentas
gerenciadas de AWS, Google Cloud e Azure. Cada etapa traz os comandos, a validação esperada e os erros mais
comuns com a respectiva mitigação.
PAUSE e RESUME. Esse é o downtime real que você vai comunicar.$OLD, $NEW, $PGB, $DB). Blocos marcados ORIGEM/DESTINO indicam onde executar.Decisão da Estratégia
Escolher o caminho certo antes de digitar o primeiro comando
A estratégia é definida por quatro perguntas: a versão major muda? A plataforma muda (on-prem → cloud gerenciada)? Qual o tamanho do banco e a taxa de escrita? Qual a janela máxima de indisponibilidade aceita pelo negócio?
| Cenário | Estratégia | Downtime típico | Fase |
|---|---|---|---|
| Mesma versão major; troca de hardware, storage, DC ou SO | A · Física + switchover (manual ou Patroni) | 5–30 s | 02 |
| Upgrade major, banco pequeno/médio (até ~2 TB) | B · Replicação lógica nativa + PgBouncer | 10–60 s | 03 |
| Upgrade major, banco de vários TB, origem PG 17+ | C · pg_createsubscriber + pg_upgrade | 10–60 s | 04 |
| Upgrade major, janela de minutos aceitável | D · pg_upgrade --link / --swap | 1–10 min | 05 |
| Origem legada 9.4–9.6 | E · pglogical | 10–60 s | 06 |
| Mudança de schema / destino heterogêneo / fan-out | F · CDC com Debezium + Kafka | segundos | 07 |
| Destino RDS/Aurora, Cloud SQL/AlloyDB ou Azure Flexible | G · Blue/Green ou DMS do provedor | < 1 min | 08 |
01 (preparação) e 09 (cutover, validação e rollback) valem para todas as estratégias. Execute a fase 01, depois apenas a fase da estratégia escolhida, e feche com a fase 09.Todos os comandos usam as variáveis abaixo. Exporte-as no shell do operador antes de começar; assim os blocos podem ser copiados sem edição.
# Hosts e banco export OLD=old-primary.internal # origem (produção atual) export NEW=new-primary.internal # destino export PGB=pgbouncer.internal # pooler na frente da aplicação export DB=app # banco a migrar export REPL=replicator # usuário de replicação export APP_ROLE=app_rw # role usada pela aplicação # Diretórios de dados (ajuste para sua distro) export PGDATA_OLD=/var/lib/postgresql/14/main export PGDATA_NEW=/var/lib/postgresql/18/main export BIN_OLD=/usr/lib/postgresql/14/bin export BIN_NEW=/usr/lib/postgresql/18/bin # Senhas via ~/.pgpass (nunca em linha de comando) # formato: host:porta:banco:usuario:senha chmod 600 ~/.pgpass
postgres (ou o dono do diretório de dados). Comandos marcados -- ORIGEM e -- DESTINO indicam onde rodar.Preparação Comum
Assessment, rede, parâmetros e pooler — pré-requisitos de qualquer estratégia
O assessment revela, antes da migração, tudo que pode quebrar a replicação ou o cutover. Salve a saída de cada consulta no ticket da mudança.
Comandos-- ORIGEM: versão, tamanho e bancos SELECT version(); SELECT datname, pg_size_pretty(pg_database_size(datname)) AS tamanho FROM pg_database WHERE datallowconn ORDER BY pg_database_size(datname) DESC; -- 20 maiores tabelas (dimensiona carga inicial) SELECT relid::regclass AS tabela, pg_size_pretty(pg_total_relation_size(relid)) AS total, n_live_tup FROM pg_stat_user_tables ORDER BY pg_total_relation_size(relid) DESC LIMIT 20; -- Extensões (todas precisam existir no destino, na mesma versão ou superior) SELECT extname, extversion FROM pg_extension ORDER BY 1; -- Tabelas SEM chave primária (bloqueiam UPDATE/DELETE na replicação lógica) SELECT c.oid::regclass AS tabela, c.relreplident FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind IN ('r','p') AND n.nspname NOT IN ('pg_catalog','information_schema') AND NOT EXISTS (SELECT 1 FROM pg_constraint k WHERE k.conrelid = c.oid AND k.contype = 'p'); -- Large objects (não replicam logicamente) SELECT count(*) AS large_objects FROM pg_largeobject_metadata; -- Unlogged (u), matviews (m) e foreign tables (f) — não replicam logicamente SELECT c.relkind, c.relpersistence, count(*) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname NOT IN ('pg_catalog','information_schema') AND c.relkind IN ('r','p','m','f') GROUP BY 1,2; -- Collation (PG15+: compara versão registrada com a da biblioteca atual) SELECT datname, datcollate, datctype, datcollversion, pg_database_collation_actual_version(oid) AS versao_atual FROM pg_database WHERE datallowconn; -- Transações longas (atrasam criação de slots e snapshots) SELECT pid, usename, now() - xact_start AS duracao, state, left(query, 80) FROM pg_stat_activity WHERE xact_start < now() - interval '5 minutes' ORDER BY xact_start;
# Taxa de geração de WAL (amostra de 5 min extrapolada para 1 h) L1=$(psql -h $OLD -d $DB -Atc "SELECT pg_current_wal_lsn()") sleep 300 psql -h $OLD -d $DB -Atc "SELECT pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), '$L1') * 12) AS wal_por_hora" # Versão da glibc (mudança de glibc 2.28+ altera ordenação de texto) ldd --version | head -1
max_slot_wal_keep_size), a banda necessária entre origem e destino e o tempo que o destino leva para alcançar a origem depois da carga inicial. Se o link entre os ambientes não suporta com folga a taxa de pico, nenhuma estratégia baseada em streaming vai convergir.extension "xyz" is not availableSELECT name, default_version FROM pg_available_extensions no destino. Substitua ou remova a extensão antes da migração; em cloud, consulte a lista de extensões suportadas.WARNING: database "app" has a collation version mismatchREINDEX dos índices de texto e ALTER DATABASE app REFRESH COLLATION VERSION.Tabelas sem PK listadas no inventárioREPLICA IDENTITY FULL / USING INDEX antes de criar a publicação (etapa B2).Crie um usuário dedicado à replicação, com o mínimo de privilégios, e libere o acesso apenas a partir do IP do destino.
-- ORIGEM CREATE ROLE replicator WITH LOGIN REPLICATION PASSWORD 'troque-esta-senha'; -- Necessário para a cópia inicial da replicação lógica GRANT USAGE ON SCHEMA public TO replicator; GRANT SELECT ON ALL TABLES IN SCHEMA public TO replicator; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO replicator;
# pg_hba.conf da ORIGEM — física usa a pseudo-base "replication"; lógica conecta no banco
hostssl replication replicator 10.0.2.0/24 scram-sha-256
hostssl app replicator 10.0.2.0/24 scram-sha-256psql -h $OLD -U postgres -c "SELECT pg_reload_conf();" # Teste de conexão física e lógica a partir do DESTINO psql "host=$OLD user=$REPL replication=true sslmode=require" -c "IDENTIFY_SYSTEM;" psql "host=$OLD user=$REPL dbname=$DB replication=database sslmode=require" -c "IDENTIFY_SYSTEM;"
no pg_hba.conf entry for replication connection from host ...replication (física) ou a linha com o nome do banco (lógica).pg_reload_conf(); confira em SELECT * FROM pg_hba_file_rules WHERE error IS NOT NULL.password authentication failed / SCRAM authentication requires libpq version 10SET password_encryption = 'scram-sha-256' e redefina a senha com \password replicator; atualize a libpq do destino.connection timed outpg_isready -h $OLD.A replicação lógica exige wal_level = logical, que só vale após restart. Agende esse restart antes da janela de migração, pois é a única parada obrigatória fora do cutover.
-- ORIGEM ALTER SYSTEM SET wal_level = 'logical'; -- restart ALTER SYSTEM SET max_replication_slots = 20; -- restart ALTER SYSTEM SET max_wal_senders = 20; -- restart ALTER SYSTEM SET wal_log_hints = on; -- restart; necessário para pg_rewind ALTER SYSTEM SET max_slot_wal_keep_size = '200GB'; -- PG13+; protege o disco ALTER SYSTEM SET wal_keep_size = '4GB'; -- PG13+ ALTER SYSTEM SET idle_in_transaction_session_timeout = '10min'; -- DESTINO (também precisa de wal_level=logical para a replicação reversa) ALTER SYSTEM SET wal_level = 'logical'; ALTER SYSTEM SET max_replication_slots = 20; ALTER SYSTEM SET max_wal_senders = 20; ALTER SYSTEM SET max_worker_processes = 32; ALTER SYSTEM SET max_logical_replication_workers = 16; ALTER SYSTEM SET max_sync_workers_per_subscription = 6; -- tabelas copiadas em paralelo ALTER SYSTEM SET max_parallel_apply_workers_per_subscription = 4; -- PG16+ -- Parâmetros pendentes de restart SELECT name, setting, pending_restart FROM pg_settings WHERE pending_restart;
sudo systemctl restart postgresql@14-main # origem, na janela combinada psql -h $OLD -Atc "SHOW wal_level;" # esperado: logical
rds.logical_replication = 1 no parameter group; no Cloud SQL, as flags cloudsql.logical_decoding e cloudsql.enable_pglogical; no Azure Flexible, wal_level = logical nos server parameters. Todos exigem reboot.logical decoding requires wal_level >= logicalSHOW wal_level nos dois lados antes da janela.all replication slots are in usemax_replication_slots insuficiente — cada subscription usa 1 slot permanente mais slots temporários durante a cópia inicial.max_sync_workers_per_subscription + standbys + folga. Exige restart.could not write to file "pg_wal/...": No space left on devicemax_slot_wal_keep_size invalida o slot antes de lotar o disco (custo: recriar a replicação). Monitore wal_status em pg_replication_slots.O PgBouncer é o que transforma o cutover em segundos: PAUSE espera as transações em andamento terminarem e segura as novas; após repontar o host, RESUME libera a fila. A aplicação vê apenas latência.
; /etc/pgbouncer/pgbouncer.ini [databases] app = host=old-primary.internal port=5432 dbname=app [pgbouncer] listen_addr = 0.0.0.0 listen_port = 6432 pool_mode = transaction ; PAUSE em modo session espera clientes desconectarem admin_users = pgbouncer_admin query_wait_timeout = 120 ; tempo máximo que um cliente espera na fila (cobre o cutover) server_reset_query = DISCARD ALL server_lifetime = 3600
# Ensaio (homologação): medir quanto tempo o PAUSE leva time psql -h $PGB -p 6432 -U pgbouncer_admin pgbouncer -c "PAUSE app;" psql -h $PGB -p 6432 -U pgbouncer_admin pgbouncer -c "SHOW POOLS;" psql -h $PGB -p 6432 -U pgbouncer_admin pgbouncer -c "RESUME app;"
PAUSE não retorna (fica esperando)idle in transaction segurando uma conexão de servidor; ou pool_mode = session.SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='app' AND state='idle in transaction' AND xact_start < now()-interval '30s'. Use pool_mode = transaction. Defina um tempo máximo de espera no runbook (ex.: 60 s) e aborte com RESUME.query_wait_timeout / clientes recebendo erro durante a janelaquery_wait_timeout ou que o timeout do driver da aplicação.query_wait_timeout e os timeouts do pool da aplicação (HikariCP connectionTimeout, etc.) acima da duração ensaiada do cutover.prepared statement "S_1" does not existmax_prepared_statements > 0, ou desabilitar server-side prepares no driver (prepareThreshold=0 no JDBC).Capture o comportamento atual para comparar após o cutover e documente quem decide o go/no-go e o rollback.
-- ORIGEM: top 50 queries por tempo total (requer pg_stat_statements) \copy (SELECT queryid, calls, round(total_exec_time) AS total_ms, round(mean_exec_time::numeric,2) AS media_ms, rows, left(query,200) AS query FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 50) TO 'baseline_origem.csv' CSV HEADER -- Throughput de referência SELECT datname, xact_commit, xact_rollback, tup_inserted, tup_updated, tup_deleted, now() FROM pg_stat_database WHERE datname = 'app';
pg_basebackup ou snapshot de disco)Estratégia A — Replicação Física + Switchover
Mesma versão major: troca de hardware, storage, datacenter ou SO
O destino recebe uma cópia binária do cluster inteiro e passa a aplicar o WAL da origem em tempo real. Exige mesma versão major, mesma arquitetura de CPU e, idealmente, a mesma versão de glibc.
# DESTINO: diretório vazio, mesmo major da origem sudo systemctl stop postgresql@14-main sudo -u postgres rm -rf $PGDATA_NEW/* sudo -u postgres pg_basebackup -h $OLD -U $REPL -D $PGDATA_NEW \ -X stream -C -S standby_new -R -P \ --checkpoint=fast --max-rate=300M # -C -S : cria o slot físico "standby_new" na origem (evita perder WAL) # -R : grava standby.signal e primary_conninfo em postgresql.auto.conf # --max-rate : limita I/O e rede para não impactar a produção sudo -u postgres cat $PGDATA_NEW/postgresql.auto.conf # conferir primary_conninfo/primary_slot_name sudo systemctl start postgresql@14-main
-- DESTINO: deve retornar true SELECT pg_is_in_recovery();
requested WAL segment 0000000100000A2B000000C1 has already been removed-C -S) ou -X stream. Se o slot aparece com wal_status = 'lost', refaça o basebackup.could not connect ... FATAL: number of requested standby connections exceeds max_wal_sendersmax_wal_senders insuficiente (o -X stream usa 2 conexões).max_wal_senders na origem (restart).Basebackup lento degradando a produção--max-rate, execute fora do pico, ou restaure de backup (pgBackRest/Barman) e complete via streaming.Índices com ordenação incorreta no novo servidorREINDEX dos índices de texto após o switchover e valide com a extensão amcheck.-- ORIGEM SELECT application_name, client_addr, state, sync_state, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS lag_bytes, write_lag, flush_lag, replay_lag FROM pg_stat_replication; SELECT slot_name, active, wal_status, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retido FROM pg_replication_slots WHERE slot_type = 'physical'; -- DESTINO SELECT pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn(), now() - pg_last_xact_replay_timestamp() AS atraso_replay;
atraso_replay cresce mesmo sem lag real (não há transações novas). Use a diferença de LSN como métrica principal.replay_lag crescendo continuamentepg_stat_activity no standby; ajuste max_standby_streaming_delay; valide IOPS do destino.canceling statement due to conflict with recoveryhot_standby_feedback = on (com cuidado com bloat na origem).-m fast ou smart) grava um checkpoint de desligamento e espera o walsender enviá-lo aos standbys conectados. Se o standby aplicou esse registro, ele tem exatamente os mesmos dados que o primário. Isso garante zero perda de dados sem depender de replicação síncrona.# T-5 min: confirmar lag ~0 (A2) e encerrar transações longas # T-0 (1) Congelar a aplicação psql -h $PGB -p 6432 -U pgbouncer_admin pgbouncer -c "PAUSE app;" # (2) Checkpoint prévio encurta o shutdown psql -h $OLD -U postgres -c "CHECKPOINT;" # (3) Parar a origem de forma limpa e impedir restart automático sudo systemctl stop postgresql@14-main sudo systemctl disable postgresql@14-main sudo -u postgres $BIN_OLD/pg_controldata $PGDATA_OLD | grep -E "cluster state|Latest checkpoint location" # Database cluster state: shut down # Latest checkpoint location: 2B/C1000028 <-- anote # (4) No DESTINO, o replay tem que ter passado desse LSN psql -h $NEW -U postgres -Atc "SELECT pg_last_wal_replay_lsn() >= '2B/C1000028'::pg_lsn;" # esperado: t # (5) Promover psql -h $NEW -U postgres -c "SELECT pg_promote(wait => true, wait_seconds => 60);" psql -h $NEW -U postgres -Atc "SELECT pg_is_in_recovery();" # esperado: f # (6) Repontar o pooler e liberar sudo sed -i "s/host=$OLD/host=$NEW/" /etc/pgbouncer/pgbouncer.ini psql -h $PGB -p 6432 -U pgbouncer_admin pgbouncer -c "RELOAD;" -c "RECONNECT app;" -c "RESUME app;"
pg_last_wal_replay_lsn() menor que o Latest checkpoint locationsystemctl start), RESUME no pooler e investigue. É o ponto de abort natural.Split-brain: aplicação escrevendo nos dois servidoresstandby.signal, ou cliente conectando direto sem passar pelo pooler.systemctl disable na origem, crie standby.signal no diretório antigo imediatamente, remova a linha da aplicação do pg_hba.conf antigo. Localize conexões diretas em pg_stat_activity.client_addr antes da janela.pg_promote retorna false / timeoutwait_seconds maior; verifique o log do destino.Aplicação lenta após o switchoverSELECT pg_prewarm('public.pedidos') (extensão pg_prewarm), ou use autoprewarm.Adicione o novo servidor como réplica do cluster Patroni (mesmo scope no patroni.yml). O Patroni cria o standby, e o switchover vira um comando.
patronictl -c /etc/patroni/patroni.yml list # + Cluster: pg-app ----+---------+-----------+----+-----------+ # | Member | Host | Role | State | TL | Lag in MB | # | pg-old | 10.0.1.10 | Leader | running | 7 | | # | pg-new | 10.0.2.10 | Replica | streaming | 7 | 0 | # Congelar a aplicação é opcional com Patroni + HAProxy, mas reduz erros visíveis patronictl -c /etc/patroni/patroni.yml switchover pg-app \ --leader pg-old --candidate pg-new --scheduled now --force patronictl -c /etc/patroni/patroni.yml list # pg-new = Leader; pg-old volta como Replica
candidate pg-new is lagging / not healthymaximum_lag_on_failover ou tag nofailover: true no nó.patroni.yml e o patronictl show-config.DCS is not accessibleetcdctl endpoint health.Old leader falha ao voltar como réplicause_pg_rewind desabilitado.use_pg_rewind: true e wal_log_hints/checksums; em último caso patronictl reinit pg-app pg-old.Após o switchover, a antiga origem pode voltar como standby do novo primário. Assim o rollback vira outro switchover de segundos.
# ANTIGA ORIGEM (parada) sudo -u postgres $BIN_OLD/pg_rewind \ --target-pgdata=$PGDATA_OLD \ --source-server="host=$NEW port=5432 user=postgres dbname=postgres" \ --write-recovery-conf --progress # Ajustar slot (criar no novo primário e referenciar) psql -h $NEW -U postgres -c "SELECT pg_create_physical_replication_slot('standby_old');" echo "primary_slot_name = 'standby_old'" | sudo -u postgres tee -a $PGDATA_OLD/postgresql.auto.conf sudo systemctl enable --now postgresql@14-main psql -h $NEW -U postgres -c "SELECT application_name, state FROM pg_stat_replication;"
target server needs to use either data checksums or "wal_log_hints = on"pg_basebackup (A1). Para o futuro: wal_log_hints = on.could not find previous WAL record / required WAL missingrestore_command apontando para o arquivo de WAL, ou refaça via basebackup.Estratégia B — Replicação Lógica Nativa
Upgrade major e migração para cloud com publication/subscription (origem PG 10+)
A replicação lógica não replica DDL: o schema precisa existir no destino. Para acelerar a carga inicial, aplique as tabelas e só as PKs/UNIQUE antes da cópia. Índices secundários, FKs e triggers entram depois.
# Roles/globais (senhas não são exportáveis em RDS: use --no-role-passwords e redefina) pg_dumpall -h $OLD -U postgres --roles-only --no-role-passwords -f roles.sql psql -h $NEW -U postgres -v ON_ERROR_STOP=1 -f roles.sql # Schema dividido por seção pg_dump -h $OLD -U postgres -d $DB --schema-only --section=pre-data -f pre.sql pg_dump -h $OLD -U postgres -d $DB --schema-only --section=post-data -f post.sql createdb -h $NEW -U postgres -O app_owner -T template0 -E UTF8 $DB psql -h $NEW -U postgres -d $DB -v ON_ERROR_STOP=1 -f pre.sql
# split_post.py — separa PK/UNIQUE (antes da carga) do restante (depois da carga) import re sql = open('post.sql').read() stmts = [s.strip() + ';' for s in sql.split(';\n') if s.strip() and not s.strip().startswith('--')] keys = [s for s in stmts if re.search(r'ADD CONSTRAINT \S+ (PRIMARY KEY|UNIQUE) ', s)] rest = [s for s in stmts if s not in keys] open('post_keys.sql', 'w').write('\n\n'.join(keys) + '\n') open('post_rest.sql', 'w').write('\n\n'.join(rest) + '\n') print(len(keys), 'chaves |', len(rest), 'demais objetos')
python3 split_post.py psql -h $NEW -U postgres -d $DB -v ON_ERROR_STOP=1 -f post_keys.sql # post_rest.sql (índices, FKs, triggers, policies) será aplicado na etapa B4
post_rest.sql antes de aplicar: troque CREATE INDEX por CREATE INDEX CONCURRENTLY se o destino já estiver recebendo leitura, e confira triggers que não devem existir no destino durante a replicação.role "app_owner" does not existroles.sql primeiro; em cloud, remova atributos proibidos (SUPERUSER, REPLICATION) do arquivo.could not open extension control file ... postgis.controlpostgresql-18-postgis-3, etc.) antes de aplicar pre.sql.pg_dump: error: aborting because of server version mismatchpg_dump da versão mais nova (a do destino): $BIN_NEW/pg_dump.cannot update table ... because it does not have a replica identity. Resolva todas as tabelas do inventário antes do CREATE PUBLICATION.-- ORIGEM: tabelas sem PK ALTER TABLE public.log_eventos ADD PRIMARY KEY (id); -- ideal (bloqueia a tabela durante a validação) ALTER TABLE public.auditoria REPLICA IDENTITY USING INDEX auditoria_uk; -- índice UNIQUE sobre colunas NOT NULL ALTER TABLE public.staging_raw REPLICA IDENTITY FULL; -- último recurso: UPDATE/DELETE fazem seq scan no destino -- Publicação (requer superuser para FOR ALL TABLES) CREATE PUBLICATION mig_pub FOR ALL TABLES; -- PG15+: por schema -- CREATE PUBLICATION mig_pub FOR TABLES IN SCHEMA public, vendas; -- Tabelas particionadas com estrutura diferente no destino: -- CREATE PUBLICATION mig_pub FOR ALL TABLES WITH (publish_via_partition_root = true); SELECT schemaname, tablename FROM pg_publication_tables WHERE pubname = 'mig_pub' ORDER BY 1,2;
DEFAULT usa a PK. USING INDEX usa um índice único. FULL grava a linha inteira: o WAL fica maior e, no subscriber, cada UPDATE vira uma busca por todas as colunas. A partir do PG16 o subscriber pode usar qualquer índice B-tree disponível para essa busca.cannot update table "x" because it does not have a replica identity and publishes updatesFOR ALL TABLES).ALTER TABLE x REPLICA IDENTITY FULL (resolve na hora). Definitivo: criar PK. Previna com revisão de DDL durante a migração.must be superuser to create FOR ALL TABLES publicationFOR TABLE t1, t2, ... como owner das tabelas, ou o role administrativo do provedor (rds_superuser, azure_pg_admin).-- DESTINO CREATE SUBSCRIPTION mig_sub CONNECTION 'host=old-primary.internal port=5432 dbname=app user=replicator sslmode=require' PUBLICATION mig_pub WITH (copy_data = true, slot_name = 'mig_sub', binary = true, -- PG14+; cópia inicial binária a partir do PG16 streaming = parallel, -- PG16+ (em PG14/15 use 'on') disable_on_error = true); -- PG15+: para em erro em vez de entrar em loop -- Progresso por tabela: i=init, d=copiando, f=copiado, s=sincronizando, r=pronto SELECT srsubstate, count(*) FROM pg_subscription_rel GROUP BY 1; SELECT srrelid::regclass, srsubstate FROM pg_subscription_rel WHERE srsubstate <> 'r'; -- COPY em andamento (PG14+) SELECT relid::regclass, bytes_processed, bytes_total, tuples_processed FROM pg_stat_progress_copy; -- Erros acumulados (PG15+; PG18 inclui contadores de conflito) SELECT * FROM pg_stat_subscription_stats;
Para bancos grandes ou origem muito carregada, carregue em lotes: a publicação começa com parte das tabelas e cresce aos poucos. Cada lote segura um snapshot só pelo tempo da sua própria cópia.
-- ORIGEM: publicação inicial parcial CREATE PUBLICATION mig_pub FOR TABLE public.clientes, public.produtos; -- ... após o lote ficar 'r' no destino: ALTER PUBLICATION mig_pub ADD TABLE public.pedidos, public.itens_pedido; -- DESTINO: sincroniza apenas as tabelas novas ALTER SUBSCRIPTION mig_sub REFRESH PUBLICATION WITH (copy_data = true);
CREATE SUBSCRIPTION não retornaSELECT pid, now()-xact_start, query FROM pg_stat_activity WHERE backend_xid IS NOT NULL OR backend_xmin IS NOT NULL ORDER BY xact_start — encerre a transação antiga.duplicate key value violates unique constraint "pedidos_pkey"disable_on_error a subscription desabilita. Para recomeçar: DROP SUBSCRIPTION, TRUNCATE das tabelas de destino e recriar. Para pular uma transação conflitante (PG15+): ALTER SUBSCRIPTION mig_sub SKIP (lsn = '0/14C0378') com o LSN do log, sabendo que ela será perdida.out of logical replication worker slotsmax_logical_replication_workers/max_worker_processes insuficientes no destino.Bloat e lentidão na origem durante a cargareplication slot "mig_sub" ... wal_status = lostmax_slot_wal_keep_size (subscription parada tempo demais).DROP SUBSCRIPTION, TRUNCATE no destino e recarga completa. Monitore o lag para agir antes.# Somente após todas as tabelas em 'r' psql -h $NEW -d $DB -Atc "SELECT count(*) FROM pg_subscription_rel WHERE srsubstate <> 'r';" # esperado: 0 # Índices e constraints restantes (em paralelo por arquivo, se desejar) psql -h $NEW -U postgres -d $DB -v ON_ERROR_STOP=1 -f post_rest.sql # Estatísticas vacuumdb -h $NEW -U postgres -d $DB --analyze-only --jobs=8
-- DESTINO: índices inválidos (CONCURRENTLY interrompido) SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid; -- FKs criadas como NOT VALID podem ser validadas sem lock exclusivo ALTER TABLE public.itens_pedido VALIDATE CONSTRAINT itens_pedido_pedido_id_fkey;
session_replication_role = replica: triggers comuns e as FKs (implementadas como triggers de sistema) não disparam para as mudanças replicadas. Só disparam triggers marcadas ENABLE REPLICA ou ENABLE ALWAYS. Revise triggers que gravam em outras tabelas para não duplicar efeitos.index "idx_x" is invalidCREATE INDEX CONCURRENTLY falhou (deadlock, cancelamento).DROP INDEX CONCURRENTLY idx_x e recrie.insert or update on table violates foreign key constraintNOT VALID lá).NOT VALID no destino e trate os órfãos antes do VALIDATE.-- ORIGEM: lag por slot lógico SELECT s.slot_name, s.active, s.wal_status, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), s.confirmed_flush_lsn)) AS lag, r.write_lag, r.flush_lag, r.replay_lag FROM pg_replication_slots s LEFT JOIN pg_stat_replication r ON r.pid = s.active_pid WHERE s.slot_type = 'logical'; -- DESTINO SELECT subname, pid, received_lsn, latest_end_lsn, latest_end_time FROM pg_stat_subscription;
REFRESH PUBLICATION no destino.-- 1. DESTINO primeiro ALTER TABLE public.clientes ADD COLUMN segmento text; -- 2. ORIGEM depois ALTER TABLE public.clientes ADD COLUMN segmento text; -- Tabela nova: criar nos dois lados e então, no DESTINO: ALTER SUBSCRIPTION mig_sub REFRESH PUBLICATION;
logical replication target relation "public.clientes" is missing replicated column: "segmento"ALTER no destino; o apply worker retoma sozinho (ou ALTER SUBSCRIPTION mig_sub ENABLE se estiver com disable_on_error).logical replication target relation "public.nova" does not existFOR ALL TABLES.REFRESH PUBLICATION.Lag alto e constante com CPU 100% em um apply workerstreaming = parallel (PG16+); evite batches gigantes na origem durante a migração; ajuste logical_decoding_work_mem.Sequência cronometrada do T-0. Ensaie em homologação até o tempo entre o PAUSE e o RESUME ficar estável, tipicamente entre 10 e 60 s.
set -euo pipefail PGBA="psql -h $PGB -p 6432 -U pgbouncer_admin pgbouncer" # (1) Congelar escrita: pooler + fencing da role da aplicação na origem $PGBA -c "PAUSE app;" psql -h $OLD -U postgres -d $DB -c "ALTER ROLE $APP_ROLE SET default_transaction_read_only = on;" psql -h $OLD -U postgres -d $DB -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DB' AND usename = '$APP_ROLE';" # (2) Esperar o subscriber alcançar o LSN atual da origem LSN=$(psql -h $OLD -U postgres -d $DB -Atc "SELECT pg_current_wal_lsn()") until [ "$(psql -h $OLD -U postgres -d $DB -Atc \ "SELECT confirmed_flush_lsn >= '$LSN' FROM pg_replication_slots WHERE slot_name = 'mig_sub'")" = "t" ]; do sleep 1 done echo "Destino alcançou $LSN" # (3) Sincronizar sequências (script da etapa 09 · Sequências) psql -h $OLD -U postgres -d $DB -Atq -f gera_setval.sql > seq_sync.sql psql -h $NEW -U postgres -d $DB -v ON_ERROR_STOP=1 -f seq_sync.sql # (4) Encerrar o fluxo origem -> destino (mantém o slot para auditoria) psql -h $NEW -U postgres -d $DB -c "ALTER SUBSCRIPTION mig_sub DISABLE;" # (5) Replicação reversa ANTES de liberar escrita (caminho de rollback) psql -h $NEW -U postgres -d $DB -c "CREATE PUBLICATION rev_pub FOR ALL TABLES;" psql -h $OLD -U postgres -d $DB -c "CREATE SUBSCRIPTION rev_sub CONNECTION 'host=$NEW port=5432 dbname=$DB user=$REPL sslmode=require' PUBLICATION rev_pub WITH (copy_data = false, origin = none);" # origin: PG16+ # (6) Repontar e liberar sudo sed -i "s/host=$OLD/host=$NEW/" /etc/pgbouncer/pgbouncer.ini $PGBA -c "RELOAD;" -c "RECONNECT app;" -c "RESUME app;"
default_transaction_read_only é aplicado na role da aplicação, não no banco, para não bloquear o apply worker da replicação reversa. É uma proteção contra acidente, não um controle de segurança: quem tem privilégio pode desfazer com SET.Loop de espera do LSN não terminapg_stat_subscription_stats. Critério de abort (ex.: 3 min): ALTER ROLE ... RESET default_transaction_read_only na origem e RESUME app. O serviço volta para a origem sem perda.duplicate key value violates unique constraint "pedidos_pkey" (após o cutover)max(id)). Veja a etapa 09 · Sequências.cannot execute INSERT in a read-only transactionclient_addr/application_name em pg_stat_activity da origem e reaponte. Mapeie isso antes da janela.could not create replication slot "rev_sub": logical decoding requires wal_level >= logicalwal_level = logical.origin = none não reconhecidomig_sub foi desabilitada no passo (4); crie rev_sub sem a opção origin.-- DESTINO (após o período de rollback, ex.: 7 dias) DROP SUBSCRIPTION mig_sub; -- também remove o slot remoto na origem DROP PUBLICATION rev_pub; -- ORIGEM antiga DROP SUBSCRIPTION rev_sub; DROP PUBLICATION mig_pub; SELECT slot_name FROM pg_replication_slots; -- esperado: vazio
could not connect to the publisher / could not drop replication slotALTER SUBSCRIPTION mig_sub DISABLE; ALTER SUBSCRIPTION mig_sub SET (slot_name = NONE); DROP SUBSCRIPTION mig_sub; e remova manualmente na origem (se ainda existir): SELECT pg_drop_replication_slot('mig_sub');Disco da origem crescendo após a migraçãoSELECT slot_name, active FROM pg_replication_slots e drop dos slots inativos.Estratégia C — pg_createsubscriber + pg_upgrade
Bancos de vários TB: cópia física rápida convertida em replicação lógica (PG17+)
pg_createsubscriber (PG17+) pega um standby físico parado e: cria publicações e slots lógicos na origem; define um recovery_target_lsn igual ao ponto de consistência do slot; promove o standby exatamente nesse ponto; cria as subscriptions começando dali, sem copiar dados; e troca o system identifier. A carga inicial vira um pg_basebackup, várias vezes mais rápido que o COPY lógico e sem snapshot longo na origem.wal_level = logical e max_replication_slots/max_wal_senders com folga para 1 slot por bancomax_logical_replication_workers ≥ número de bancos e max_worker_processes compatível# Standby físico criado como em A1 (mesmo major 17) sudo -u postgres pg_basebackup -h $OLD -U $REPL -D /var/lib/postgresql/17/main \ -X stream -C -S standby_conv -R -P --checkpoint=fast sudo systemctl start postgresql@17-main # ... aguardar lag ~0 (A2) e então parar sudo systemctl stop postgresql@17-main
# Simulação: valida todos os pré-requisitos sem alterar nada sudo -u postgres /usr/lib/postgresql/17/bin/pg_createsubscriber --dry-run --verbose \ -D /var/lib/postgresql/17/main \ -P "host=$OLD port=5432 user=postgres dbname=$DB" \ -d $DB \ --publication=mig_pub --subscription=mig_sub \ --subscriber-port=50432 --recovery-timeout=600 # Execução real (mesma linha sem --dry-run) sudo -u postgres /usr/lib/postgresql/17/bin/pg_createsubscriber --verbose \ -D /var/lib/postgresql/17/main \ -P "host=$OLD port=5432 user=postgres dbname=$DB" \ -d $DB --publication=mig_pub --subscription=mig_sub \ --subscriber-port=50432 --recovery-timeout=600 # Vários bancos: repita -d/--publication/--subscription (PG18: --all converte todos) sudo systemctl start postgresql@17-main
-- DESTINO: agora é um primário com subscription ativa SELECT pg_is_in_recovery(); -- f SELECT subname, subenabled FROM pg_subscription; -- mig_sub | t SELECT srsubstate, count(*) FROM pg_subscription_rel GROUP BY 1; -- tudo 'r' -- ORIGEM: slot lógico ativo; o slot físico do standby não deve mais existir SELECT slot_name, slot_type, active FROM pg_replication_slots;
standby server is running / target server must be stoppedsystemctl stop e repita.primary server does not have enough replication slots / wal sendersmax_replication_slots/max_wal_senders na origem (restart) e refaça o dry-run.recovery timed out--recovery-timeout e garanta lag ~0 antes. Se falhou após a promoção, restaure o snapshot ou refaça o basebackup.Slot físico standby_conv continua na origem retendo WALSELECT pg_drop_replication_slot('standby_conv');Outros bancos do cluster ficaram sem replicação-d recebem subscription; os demais ficam congelados no ponto da promoção.--all no PG18) ou remova os que não serão migrados.A partir do PG17, o pg_upgrade preserva as subscriptions e o ponto de replicação de um subscriber. O destino sobe de versão enquanto a origem continua retendo o WAL no slot, e depois o destino retoma de onde parou.
-- DESTINO (17): pré-condições SELECT srsubstate, count(*) FROM pg_subscription_rel GROUP BY 1; -- somente 'r' (ou 'i') ALTER SUBSCRIPTION mig_sub DISABLE;
# Novo cluster 18 com MESMAS opções de locale/encoding/checksums (ver Estratégia D) # PG18: max_active_replication_origins >= número de subscriptions (padrão 10) sudo systemctl stop postgresql@17-main sudo -u postgres $BIN_NEW/pg_upgrade \ -b /usr/lib/postgresql/17/bin -B $BIN_NEW \ -d /var/lib/postgresql/17/main -D $PGDATA_NEW \ -o "-c config_file=/etc/postgresql/17/main/postgresql.conf" \ -O "-c config_file=/etc/postgresql/18/main/postgresql.conf" \ --link --jobs=$(nproc) --check # sem --check para executar; depois: sudo systemctl start postgresql@18-main psql -h $NEW -U postgres -d $DB -c "ALTER SUBSCRIPTION mig_sub ENABLE;"
A partir daqui o fluxo é idêntico à Estratégia B: monitoramento (B5), cutover (B6) e descomissionamento (B7).
Erros possíveis e mitigaçãopg_upgrade: subscription ... has tables not in 'i' or 'r' stater antes de desabilitar e atualizar.max_active_replication_origins / max_replication_slots too lowpostgresql.conf do cluster novo e rode o --check de novo.Origem com disco crescendo durante o upgrademax_slot_wal_keep_size.Estratégia D — pg_upgrade In-Place
Upgrade major com minutos de parada, praticamente independente do tamanho
# Debian/Ubuntu (repositório PGDG) — instale também as extensões usadas na origem sudo apt install -y postgresql-18 postgresql-18-postgis-3 postgresql-18-pg-stat-kcache # Conferir configurações da origem sudo -u postgres $BIN_OLD/pg_controldata $PGDATA_OLD | grep -i checksum # 0 = desabilitado psql -U postgres -Atc "SELECT datname, pg_encoding_to_char(encoding), datcollate, datctype FROM pg_database;" # Criar o cluster novo espelhando a origem sudo -u postgres $BIN_NEW/initdb -D $PGDATA_NEW \ --encoding=UTF8 --locale=pt_BR.UTF-8 \ --no-data-checksums # PG18 habilita checksums por padrão; espelhe a origem!
initdb passou a habilitar data checksums por padrão. Se a origem não usa checksums, o pg_upgrade recusa a execução. Crie o cluster novo com --no-data-checksums, ou habilite checksums na origem antes com pg_checksums --enable (com o cluster parado).lc_collate values for database "postgres" do not matchinitdb --locale igual à origem (ou --lc-collate/--lc-ctype). Se o locale mudar de propósito, use a Estratégia B.old cluster does not use data checksums but the new one doesinitdb --no-data-checksums.sudo -u postgres bash -c "cd /tmp && $BIN_NEW/pg_upgrade \ -b $BIN_OLD -B $BIN_NEW -d $PGDATA_OLD -D $PGDATA_NEW \ -o '-c config_file=/etc/postgresql/14/main/postgresql.conf' \ -O '-c config_file=/etc/postgresql/18/main/postgresql.conf' \ --link --jobs=$(nproc) --check" # Saída esperada: *Clusters are compatible* # Em caso de falha, os detalhes ficam em $PGDATA_NEW/pg_upgrade_output.d/
Your installation references loadable libraries that are missing from the new installationloadable_libraries.txt).DROP EXTENSION) se não for usada.Your installation contains the "reg*" data types in user tablesregproc/regclass etc. (OIDs mudam no upgrade).text na origem antes do upgrade.Your installation contains tables declared WITH OIDSWITH OIDS.ALTER TABLE ... SET WITHOUT OIDS na origem.The source cluster contains prepared transactionsPREPARE TRANSACTION pendentes.SELECT * FROM pg_prepared_xacts e COMMIT PREPARED/ROLLBACK PREPARED.logical replication slot ... has not consumed all the WAL# (0) Snapshot/backup: com --link, a origem NÃO pode mais ser iniciada depois que o novo cluster subir # (1) Parar a aplicação (PgBouncer PAUSE) e o cluster antigo psql -h $PGB -p 6432 -U pgbouncer_admin pgbouncer -c "PAUSE app;" sudo systemctl stop postgresql@14-main # (2) Upgrade sudo -u postgres bash -c "cd /tmp && $BIN_NEW/pg_upgrade \ -b $BIN_OLD -B $BIN_NEW -d $PGDATA_OLD -D $PGDATA_NEW \ -o '-c config_file=/etc/postgresql/14/main/postgresql.conf' \ -O '-c config_file=/etc/postgresql/18/main/postgresql.conf' \ --link --jobs=$(nproc)" # --clone : XFS/Btrfs com reflink — rápido e a origem continua íntegra # --swap : PG18 — move diretórios; mais rápido que --link em muitos arquivos # (3) Subir o novo (mesma porta da antiga) e liberar sudo systemctl start postgresql@18-main psql -h $PGB -p 6432 -U pgbouncer_admin pgbouncer -c "RESUME app;"
could not create hard link ... Invalid cross-device link$PGDATA_NEW no mesmo filesystem ou use o modo padrão (--copy), mais lento.Falha no meio do pg_upgrade --linkglobal/pg_control.old de volta para global/pg_control, conforme a documentação). Caso contrário, restaure do snapshot do passo 0.Porta em uso ao subir o cluster 18systemctl disable postgresql@14-main e ajuste a porta no postgresql.conf do 18.# PG18 preserva as estatísticas do otimizador; só completa as ausentes (ex.: estatísticas estendidas) sudo -u postgres $BIN_NEW/vacuumdb --all --analyze-in-stages --missing-stats-only --jobs=8 # Versões anteriores ao 18: ANALYZE completo em estágios # sudo -u postgres $BIN_NEW/vacuumdb --all --analyze-in-stages --jobs=8 # Scripts gerados pelo pg_upgrade (no diretório onde foi executado) sudo -u postgres psql -f /tmp/update_extensions.sql # Após o período de rollback: sudo -u postgres /tmp/delete_old_cluster.sh
Queries lentas logo após o upgradevacuumdb --analyze-in-stages imediatamente; compare com a baseline de pg_stat_statements e investigue regressões com EXPLAIN (ANALYZE, BUFFERS).extension "x" has version "1.2" but the library is "1.4"update_extensions.sql ou ALTER EXTENSION x UPDATE.Procedimento documentado no pg_upgrade para atualizar standbys sem copiar os dados de novo. Todos os nós precisam estar parados e o novo primário ainda não iniciado.
# 1. Conferir que o standby aplicou o checkpoint final da origem (valores idênticos) sudo -u postgres $BIN_OLD/pg_controldata $PGDATA_OLD | grep "Latest checkpoint location" ssh standby1 "sudo -u postgres $BIN_OLD/pg_controldata $PGDATA_OLD | grep 'Latest checkpoint location'" # 2. No PRIMÁRIO, a partir do diretório pai dos clusters cd /var/lib/postgresql rsync --archive --delete --hard-links --size-only --no-inc-recursive \ 14 18 standby1:/var/lib/postgresql # 3. No standby: standby.signal + primary_conninfo no cluster 18 e iniciar ssh standby1 "sudo -u postgres touch /var/lib/postgresql/18/main/standby.signal"
Checkpoint locations diferentes entre primário e standbypg_basebackup a partir do novo primário.Standby corrompido após rsyncpg_basebackup.Estratégia E — pglogical
Origens legadas 9.4–9.6 ou recursos que a replicação nativa não oferece
# postgresql.conf — ORIGEM e DESTINO (restart) wal_level = logical shared_preload_libraries = 'pglogical' max_worker_processes = 16 max_replication_slots = 10 max_wal_senders = 10 track_commit_timestamp = on # necessário para resolução de conflitos last_update_wins
-- ORIGEM (provider) CREATE EXTENSION pglogical; SELECT pglogical.create_node(node_name := 'provider', dsn := 'host=old-primary.internal port=5432 dbname=app user=replicator'); SELECT pglogical.replication_set_add_all_tables('default', ARRAY['public']); SELECT pglogical.replication_set_add_all_sequences('default', ARRAY['public']); -- tabelas sem PK: apenas INSERT SELECT pglogical.replication_set_add_table('default_insert_only', 'public.log_eventos'); -- DESTINO (subscriber) — schema aplicado antes (B1) CREATE EXTENSION pglogical; SELECT pglogical.create_node(node_name := 'subscriber', dsn := 'host=new-primary.internal port=5432 dbname=app user=replicator'); SELECT pglogical.create_subscription( subscription_name := 'mig_sub', provider_dsn := 'host=old-primary.internal port=5432 dbname=app user=replicator', replication_sets := ARRAY['default','default_insert_only','ddl_sql'], synchronize_structure := false, synchronize_data := true); SELECT pglogical.wait_for_subscription_sync_complete('mig_sub');
pglogical is not in shared_preload_librariestable ... cannot be added to replication set ... does not have primary keydefault (que replica UPDATE/DELETE).default_insert_only e trate UPDATE/DELETE à parte.synchronize_structure falha com erro de pg_dumppg_dump incompatíveis entre os nós.synchronize_structure := false e aplique o schema manualmente (B1).-- DESTINO SELECT subscription_name, status, provider_node, slot_name FROM pglogical.show_subscription_status(); SELECT * FROM pglogical.local_sync_status WHERE sync_status <> 'r'; -- ORIGEM: DDL replicado para os subscribers pelo set ddl_sql SELECT pglogical.replicate_ddl_command('ALTER TABLE public.clientes ADD COLUMN segmento text');
status = downSELECT pglogical.alter_subscription_enable('mig_sub', true).Divergência após DDL manualreplicate_ddl_command.replicate_ddl_command durante a migração.-- Com escrita congelada (B6 passos 1–2): -- ORIGEM: força a sincronização de todas as sequências replicadas SELECT pglogical.synchronize_sequence(seqoid) FROM pglogical.sequence_state; -- DESTINO: após o cutover SELECT pglogical.drop_subscription('mig_sub'); SELECT pglogical.drop_node('subscriber'); DROP EXTENSION pglogical; -- ORIGEM SELECT pglogical.drop_node('provider');
confirmed_flush_lsn do slot pgl_app_provider_mig_sub (confira o nome em pg_replication_slots).Estratégia F — CDC com Debezium + Kafka
Transformações, schema diferente, destino heterogêneo ou validação longa
-- ORIGEM CREATE ROLE debezium WITH LOGIN REPLICATION PASSWORD 'troque-esta-senha'; GRANT USAGE ON SCHEMA public TO debezium; GRANT SELECT ON ALL TABLES IN SCHEMA public TO debezium; CREATE PUBLICATION dbz_pub FOR ALL TABLES; -- Heartbeat: faz o slot avançar mesmo quando as tabelas capturadas não recebem escrita CREATE TABLE public.dbz_heartbeat (id int PRIMARY KEY, ts timestamptz); INSERT INTO public.dbz_heartbeat VALUES (1, now()); GRANT UPDATE ON public.dbz_heartbeat TO debezium; -- Sinais: snapshots incrementais sob demanda CREATE TABLE public.dbz_signal (id varchar(42) PRIMARY KEY, type varchar(32) NOT NULL, data varchar(2048)); GRANT INSERT, SELECT ON public.dbz_signal TO debezium;
{
"name": "pg-source-app",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "old-primary.internal",
"database.port": "5432",
"database.user": "debezium",
"database.password": "${file:/opt/secrets/pg.properties:password}",
"database.dbname": "app",
"topic.prefix": "app",
"plugin.name": "pgoutput",
"slot.name": "dbz_app",
"publication.name": "dbz_pub",
"publication.autocreate.mode": "disabled",
"snapshot.mode": "initial",
"table.include.list": "public.*",
"heartbeat.interval.ms": "10000",
"heartbeat.action.query": "UPDATE public.dbz_heartbeat SET ts = now() WHERE id = 1",
"signal.data.collection": "public.dbz_signal",
"decimal.handling.mode": "precise",
"tombstones.on.delete": "true"
}
}curl -sS -X POST -H "Content-Type: application/json" --data @pg-source.json http://connect:8083/connectors curl -sS http://connect:8083/connectors/pg-source-app/status | jq '.connector.state, .tasks[].state' # Snapshot incremental de uma tabela (sem bloquear a origem) psql -h $OLD -d $DB -c "INSERT INTO public.dbz_signal VALUES ('snap-pedidos-1','execute-snapshot', '{\"data-collections\": [\"public.pedidos\"], \"type\": \"incremental\"}');"
{
"name": "pg-sink-app",
"config": {
"connector.class": "io.debezium.connector.jdbc.JdbcSinkConnector",
"topics.regex": "app\\.public\\.(?!dbz_).*",
"connection.url": "jdbc:postgresql://new-primary.internal:5432/app",
"connection.username": "app_writer",
"connection.password": "${file:/opt/secrets/pg.properties:target_password}",
"insert.mode": "upsert",
"primary.key.mode": "record_key",
"delete.enabled": "true",
"schema.evolution": "none",
"tasks.max": "4",
"transforms": "route",
"transforms.route.type": "org.apache.kafka.connect.transforms.RegexRouter",
"transforms.route.regex": "app\\.public\\.(.*)",
"transforms.route.replacement": "$1"
}
}curl -sS -X POST -H "Content-Type: application/json" --data @pg-sink.json http://connect:8083/connectors # Lag do consumidor (sink) — deve tender a 0 kafka-consumer-groups.sh --bootstrap-server kafka:9092 --describe --group connect-pg-sink-app
confirmed_flush_lsn do slot dbz_app ≥ pg_current_wal_lsn()), e o sink precisa ter consumido todos os tópicos (lag do consumer group = 0). Só depois vêm sequências e o reponte do pooler, como em B6.Disco da origem enchendo com o banco quase ociosoheartbeat.action.query (configurado em F2) e max_slot_wal_keep_size.Valor __debezium_unavailable_value no destinoALTER TABLE ... REPLICA IDENTITY FULL nas tabelas afetadas, ou tratar o placeholder no sink (ignorar a coluna).Eventos perdidos após indisponibilidade longa do Kafka Connectdbz_signal.Slot dbz_app perdido após failover da origemsync_replication_slots no standby) e a opção de failover do conector na sua versão do Debezium; senão, recriar o slot e fazer snapshot.Diferenças em numeric/timestamp no destinodecimal.handling.mode, time.precision.mode).decimal.handling.mode=precise e validação por hash por tabela (etapa 09).Estratégia G — Ferramentas Gerenciadas
AWS, Google Cloud e Azure: o mesmo princípio com orquestração do provedor
O RDS cria um ambiente green sincronizado com o blue (produção). Em upgrade major, a sincronização é por replicação lógica. No switchover, o RDS congela escrita no blue, espera o green alcançar e troca os endpoints, então a aplicação mantém o mesmo hostname.
# 1. Replicação lógica no parameter group do BLUE (reboot) aws rds modify-db-parameter-group --db-parameter-group-name pg14-blue \ --parameters "ParameterName=rds.logical_replication,ParameterValue=1,ApplyMethod=pending-reboot" aws rds reboot-db-instance --db-instance-identifier app-prod # 2. Criar o green já na versão-alvo aws rds create-blue-green-deployment \ --blue-green-deployment-name app-pg-upgrade \ --source arn:aws:rds:sa-east-1:123456789012:db:app-prod \ --target-engine-version <versao-alvo> \ --target-db-parameter-group-name pg-green-params # Aurora: --source com o ARN do cluster e --target-db-cluster-parameter-group-name # 3. Acompanhar até AVAILABLE aws rds describe-blue-green-deployments --blue-green-deployment-identifier bgd-xxxxxxxx \ --query 'BlueGreenDeployments[0].[Status,SwitchoverDetails]' # 4. Switchover (timeout em segundos; se estourar, o RDS desfaz e o blue continua) aws rds switchover-blue-green-deployment \ --blue-green-deployment-identifier bgd-xxxxxxxx --switchover-timeout 300 # 5. Após validar, remover a relação blue/green (o antigo blue fica como "-old1") aws rds delete-blue-green-deployment --blue-green-deployment-identifier bgd-xxxxxxxx
Criação do blue/green falha na validaçãords.logical_replication desligado, versão sem caminho de upgrade direto, ou recursos não suportados (ex.: certas extensões).describe-events; ajuste o parameter group e reinicie o blue.Replicação do green com erro após DDLSwitchover timed outAplicação continua falando com o antigo após o switchovernetworkaddress.cache.ttl=5 na JVM; reinício gradual do pool; ou AWS Advanced JDBC Wrapper com plugin de blue/green.duplicate key em tabelas com serial após o switchover{
"TargetMetadata": {
"SupportLobs": true,
"FullLobMode": false,
"LimitedSizeLobMode": true,
"LobMaxSize": 64
},
"FullLoadSettings": { "TargetTablePrepMode": "DO_NOTHING" },
"ValidationSettings": { "EnableValidation": true, "ThreadCount": 8 },
"Logging": { "EnableLogging": true }
}aws dms create-replication-task \ --replication-task-identifier app-full-cdc \ --source-endpoint-arn arn:aws:dms:...:endpoint:SRC \ --target-endpoint-arn arn:aws:dms:...:endpoint:TGT \ --replication-instance-arn arn:aws:dms:...:rep:INST \ --migration-type full-load-and-cdc \ --table-mappings file://table-mappings.json \ --replication-task-settings file://task-settings.json aws dms start-replication-task --replication-task-arn <arn> --start-replication-task-type start-replication aws dms describe-table-statistics --replication-task-arn <arn> # ValidationState por tabela
Colunas text/jsonb/bytea truncadasLimitedSizeLobMode com LobMaxSize menor que o maior valor.max(octet_length(col)) na origem e ajuste, ou use FullLobMode nas tabelas afetadas.Destino sem índices secundários, FKs e defaultsTargetTablePrepMode = DO_NOTHING.UPDATE/DELETE não aplicados em algumas tabelasREPLICA IDENTITY FULL na origem antes de iniciar a task.# Origem: pglogical instalado em TODOS os bancos + wal_level=logical + shared_preload_libraries psql -h $OLD -d $DB -c "CREATE EXTENSION IF NOT EXISTS pglogical;" gcloud database-migration connection-profiles create postgresql src-app \ --region=southamerica-east1 --host=203.0.113.10 --port=5432 \ --username=migration_admin --password=*** gcloud database-migration migration-jobs create app-to-cloudsql \ --region=southamerica-east1 --type=CONTINUOUS \ --source=src-app --destination=dst-cloudsql gcloud database-migration migration-jobs verify app-to-cloudsql --region=southamerica-east1 gcloud database-migration migration-jobs start app-to-cloudsql --region=southamerica-east1 gcloud database-migration migration-jobs describe app-to-cloudsql --region=southamerica-east1 # Cutover (com escrita congelada na origem e lag ~0): gcloud database-migration migration-jobs promote app-to-cloudsql --region=southamerica-east1
verify falha: pglogical extension not installedpostgres).CREATE EXTENSION pglogical em cada banco e reinicie com shared_preload_libraries.Tabelas sem PK com UPDATE/DELETE perdidosUsuários/roles ausentes após promote{
"properties": {
"SourceDBServerResourceId": "10.0.0.5:5432@postgres",
"SourceType": "OnPremises",
"SslMode": "Require",
"SecretParameters": {
"AdminCredentials": { "SourceServerPassword": "***", "TargetServerPassword": "***" }
},
"DBsToMigrate": ["app"],
"OverwriteDBsInTarget": "true"
}
}az postgres flexible-server migration create \ --subscription <sub-id> --resource-group rg-db \ --server-name pgflex-target --name mig-app-01 \ --migration-mode online --properties "migration.json" # Somente validação prévia: acrescente --migration-option Validate az postgres flexible-server migration show \ --resource-group rg-db --server-name pgflex-target --name mig-app-01 # Cutover (após lag ~0 e escrita congelada) az postgres flexible-server migration update \ --resource-group rg-db --server-name pgflex-target --name mig-app-01 --cutover
Validação falha: wal_level / replication slotswal_level = logical, max_replication_slots e max_wal_senders ajustados e reinício.Extensões não permitidas no destinoazure.extensions.azure.extensions no Flexible Server antes da migração.Cutover, Validação e Rollback
Comum a todas as estratégias: go/no-go, sequências, dados, fencing e retorno
r (pg_subscription_rel) — estratégias lógicasANALYZE aplicados no destino (B4); nenhum índice inválidowal_level = logical no destino (replicação reversa pronta)-- Verificação rápida em uma única consulta (DESTINO, estratégias lógicas) SELECT (SELECT count(*) FROM pg_subscription_rel WHERE srsubstate <> 'r') AS tabelas_nao_prontas, (SELECT count(*) FROM pg_index WHERE NOT indisvalid) AS indices_invalidos, (SELECT setting FROM pg_settings WHERE name = 'wal_level') AS wal_level, (SELECT max(now() - last_analyze) FROM pg_stat_user_tables) AS analyze_mais_antigo;
serial e GENERATED ... AS IDENTITY. No destino, elas continuam no valor inicial. O script abaixo lê a origem com escrita congelada e gera os setval para o destino, com uma margem de segurança que respeita o sinal do increment_by.-- gera_setval.sql (executar na ORIGEM como superuser/owner; saída = script para o DESTINO) SELECT CASE WHEN last_value IS NULL THEN format('SELECT setval(%L, %s, false);', format('%I.%I', schemaname, sequencename), start_value) ELSE format('SELECT setval(%L, %s, true);', format('%I.%I', schemaname, sequencename), last_value + 1000 * increment_by) END FROM pg_sequences ORDER BY schemaname, sequencename;
psql -h $OLD -U postgres -d $DB -Atq -f gera_setval.sql > seq_sync.sql psql -h $NEW -U postgres -d $DB -v ON_ERROR_STOP=1 -q -f seq_sync.sql # Conferência no DESTINO: sequência nunca pode estar atrás do max() da coluna que ela alimenta psql -h $NEW -U postgres -d $DB -f check_seq.sql
-- check_seq.sql — serial (deptype a) e identity (deptype i); \gexec executa cada consulta gerada SELECT format('SELECT %L AS sequencia, (SELECT last_value FROM %s) AS valor_seq, ' '(SELECT coalesce(max(%I),0) FROM %s) AS max_coluna', s.oid::regclass, s.oid::regclass, a.attname, t.oid::regclass) FROM pg_class s JOIN pg_depend d ON d.objid = s.oid AND d.classid = 'pg_class'::regclass AND d.deptype IN ('a','i') JOIN pg_class t ON t.oid = d.refobjid JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid WHERE s.relkind = 'S' \gexec
permission denied for sequence ...pg_sequences.last_value.pg_read_all_data, PG14+).value ... is out of bounds for sequencemax_value (ex.: sequência integer perto do limite).bigint.Execute as mesmas consultas nos dois lados, com a escrita congelada no cutover (ou por amostragem durante a replicação), e compare com diff.
-- contagem_exata.sql — contagem exata de todas as tabelas sem PL/pgSQL SELECT table_schema || '.' || table_name AS tabela, (xpath('/row/c/text()', query_to_xml(format('SELECT count(*) AS c FROM %I.%I', table_schema, table_name), false, true, '')))[1]::text::bigint AS linhas FROM information_schema.tables WHERE table_type = 'BASE TABLE' AND table_schema NOT IN ('pg_catalog','information_schema') ORDER BY 1;
for H in $OLD $NEW; do psql -h $H -U postgres -d $DB -Atq -f contagem_exata.sql > contagem_$H.txt done diff contagem_$OLD.txt contagem_$NEW.txt && echo "CONTAGENS IGUAIS" # Hash por faixa de PK nas tabelas críticas (mesmas configurações de sessão nos dois lados!) Q="SET TimeZone='UTC'; SET extra_float_digits=3; SET DateStyle='ISO'; SELECT md5(string_agg(md5(t::text), '' ORDER BY id)) FROM public.pedidos t WHERE id BETWEEN 1 AND 1000000;" diff <(psql -h $OLD -d $DB -Atq -c "$Q") <(psql -h $NEW -d $DB -Atq -c "$Q") && echo "HASH OK"
Hash diferente com contagens iguaisTimeZone, DateStyle, extra_float_digits, ordem de chaves em jsonb (igual) vs json (texto) ou colunas em ordem diferente.t::text; investigue a faixa divergente dividindo-a ao meio (busca binária).Contagem diferente em tabelas de logpg_publication_tables; valide apenas com escrita congelada.-- ORIGEM (antiga), logo após o reponte ALTER ROLE app_rw SET default_transaction_read_only = on; REVOKE CONNECT ON DATABASE app FROM PUBLIC; -- mantém replicator/postgres com CONNECT explícito GRANT CONNECT ON DATABASE app TO replicator; SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'app' AND usename NOT IN ('replicator','postgres') AND pid <> pg_backend_pid(); -- Quem ainda tenta conectar? (log_connections = on) SELECT client_addr, usename, application_name, count(*) FROM pg_stat_activity WHERE datname = 'app' GROUP BY 1,2,3;
# pg_hba.conf da ORIGEM antiga — rejeitar a aplicação explicitamente (primeira linha que casa vence)
host app app_rw 0.0.0.0/0 rejectALTER ROLE ... RESET default_transaction_read_only na origem, reponte do pooler de volta e RESUME. Nenhum dado foi escrito no destino.rev_sub alcançar, sincronizar sequências do destino para a origem, repontar, RESUME.# Rollback após o cutover (replicação reversa ativa) PGBA="psql -h $PGB -p 6432 -U pgbouncer_admin pgbouncer" $PGBA -c "PAUSE app;" psql -h $NEW -U postgres -d $DB -c "ALTER ROLE $APP_ROLE SET default_transaction_read_only = on;" psql -h $NEW -U postgres -d $DB -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='$DB' AND usename='$APP_ROLE';" LSN=$(psql -h $NEW -U postgres -d $DB -Atc "SELECT pg_current_wal_lsn()") until [ "$(psql -h $NEW -U postgres -d $DB -Atc \ "SELECT confirmed_flush_lsn >= '$LSN' FROM pg_replication_slots WHERE slot_name='rev_sub'")" = "t" ]; do sleep 1; done psql -h $NEW -U postgres -d $DB -Atq -f gera_setval.sql > seq_back.sql psql -h $OLD -U postgres -d $DB -v ON_ERROR_STOP=1 -q -f seq_back.sql psql -h $OLD -U postgres -d $DB -c "ALTER ROLE $APP_ROLE RESET default_transaction_read_only;" psql -h $OLD -U postgres -d $DB -c "GRANT CONNECT ON DATABASE $DB TO PUBLIC;" # desfaz o fencing # remover a linha "reject" do pg_hba.conf da origem e: SELECT pg_reload_conf(); sudo sed -i "s/host=$NEW/host=$OLD/" /etc/pgbouncer/pgbouncer.ini $PGBA -c "RELOAD;" -c "RECONNECT app;" -c "RESUME app;"
-- DESTINO: confirmar que as escritas chegam no novo banco SELECT datname, xact_commit, tup_inserted, tup_updated FROM pg_stat_database WHERE datname = 'app'; -- Regressões de performance vs. baseline (compare com baseline_origem.csv) SELECT queryid, calls, round(mean_exec_time::numeric,2) AS media_ms, left(query,120) FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 50; -- Slots esquecidos (em TODOS os servidores envolvidos) SELECT slot_name, slot_type, active, wal_status FROM pg_replication_slots;
pg_stat_statements com a baseline; ajustes de índices e parâmetrosTroubleshooting Rápido
Mensagens de erro mais comuns e onde está a solução
| Mensagem / sintoma | Mitigação rápida | Etapa |
|---|---|---|
cannot update table ... does not have a replica identity | ALTER TABLE ... REPLICA IDENTITY FULL imediatamente; PK depois | B2 |
logical decoding requires wal_level >= logical | Ajustar wal_level e reiniciar | 01 |
all replication slots are in use | Aumentar max_replication_slots (restart) | 01 |
requested WAL segment ... has already been removed | Refazer standby com slot | A1 |
wal_status = lost no slot | Recriar a replicação do zero (truncate + recarga) | B3 |
duplicate key value violates unique constraint (carga) | Destino não vazio: truncate e recriar / SKIP (lsn) | B3 |
duplicate key value (após cutover) | Sincronizar sequências | 09 |
is missing replicated column | Aplicar DDL no destino | B5 |
out of logical replication worker slots | Aumentar workers no destino | B3 |
CREATE SUBSCRIPTION travado | Encerrar transação longa na origem | B3 |
| PAUSE do PgBouncer não retorna | Encerrar idle in transaction; abortar após limite | 01 |
cannot execute INSERT in a read-only transaction | Cliente conectando direto na origem: reapontar | B6 |
pg_rewind exige checksums/wal_log_hints | Refazer como standby via basebackup | A5 |
lc_collate values ... do not match | initdb com o mesmo locale | D1 |
old cluster does not use data checksums | initdb --no-data-checksums (PG18) | D1 |
Invalid cross-device link | Mesmo filesystem ou --copy | D3 |
__debezium_unavailable_value | REPLICA IDENTITY FULL na tabela | F3 |
Switchover RDS timed out | Reduzir carga/transações longas e repetir | G1 |
tail -F aberto nos logs de origem e destino e habilite log_replication_commands = on e log_connections = on antes do T-0.