Banco de Dados em Produção

Connection Pooling: PgBouncer

POR QUE CONNECTION POOLING É OBRIGATÓRIO:

Sem pooler:
  App (100 workers) → 100 conexões → PostgreSQL
  Cada conexão PostgreSQL = 1 processo fork (~10MB RAM)
  100 conexões × 10MB = 1GB só para manter conexões
  PostgreSQL padrão: max_connections = 100

Com PgBouncer:
  App (100 workers) → 100 conexões → PgBouncer → 20 conexões → PostgreSQL
  PgBouncer multiplica: reutiliza conexões ociosas entre workers
  Overhead do PgBouncer: ~2KB por conexão (vs ~10MB do PostgreSQL)

MODOS DE POOLING:
- session:     conexão dedicada por sessão (menos eficiente, mais compatível)
- transaction: conexão compartilhada, alocada por transação (recomendado)
- statement:   conexão compartilhada por statement (não suporta transações multi-statement)
; pgbouncer.ini
[databases]
myapp = host=127.0.0.1 port=5432 dbname=myapp

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt

; Modo transaction: conexão devolvida ao pool após cada transação
pool_mode = transaction

; Pool sizing:
default_pool_size = 20        ; Conexões por par user/database
min_pool_size = 5             ; Manter 5 conexões sempre abertas
reserve_pool_size = 5         ; Pool reserva para picos
reserve_pool_timeout = 3      ; Segundos antes de usar reserva
max_client_conn = 400         ; Máximo de clientes simultâneos
max_db_connections = 50       ; Máximo de conexões reais ao PostgreSQL

; Timeouts:
server_idle_timeout = 600     ; Fechar conexões ociosas após 10min
query_timeout = 30            ; Timeout de query (segundos)
client_idle_timeout = 0       ; 0 = sem timeout para clientes ociosos

; Logging:
log_connections = 1
log_disconnections = 1
log_pooler_errors = 1
stats_period = 60             ; Estatísticas a cada 60 segundos

; IMPORTANTE para transaction mode:
; - NÃO usar SET statements (ex: SET timezone)
; - NÃO usar LISTEN/NOTIFY
; - NÃO usar prepared statements nomeados
; - NÃO usar cursores WITH HOLD
; Alternativa: usar session mode para essas operações
# Fórmula para calcular pool_size ideal:
# pool_size = (num_cores * 2) + num_disks
# Para SSD: pool_size = num_cores * 2 + 1
# Exemplo: 4 cores, SSD → pool_size = 9
# Começar conservador e aumentar monitorando pg_stat_activity

# Monitorar o PgBouncer:
psql -h localhost -p 6432 -U pgbouncer pgbouncer
SHOW POOLS;
# database | user | cl_active | cl_waiting | sv_active | sv_idle
# myapp    | app  |    45     |     0      |    12     |   8
# cl_waiting > 0 frequentemente → aumentar pool_size

SHOW STATS;
# Mostra: total_requests, avg_query_time, bytes_sent, etc.

SHOW CLIENTS;
# Lista todos os clientes conectados ao PgBouncer

Migrations em Produção: Zero-Downtime

REGRA FUNDAMENTAL:
Em produção com deploy rolling (múltiplas versões rodando simultaneamente),
migrations DEVEM ser backwards compatible.

A versão ANTIGA do código precisa funcionar com o schema NOVO.
A versão NOVA do código precisa funcionar com o schema ANTIGO.

PADRÃO EXPAND-CONTRACT:

Exemplo: renomear coluna "name" para "full_name"

ERRADO (causa downtime):
  ALTER TABLE users RENAME COLUMN name TO full_name;
  → Versão antiga do código quebra imediatamente: SELECT name FROM users

CORRETO (expand-contract em 4 deploys):

Deploy 1 — EXPAND: adicionar nova coluna
  ALTER TABLE users ADD COLUMN full_name VARCHAR(255);
  -- Trigger para sincronizar dados durante transição:
  CREATE OR REPLACE FUNCTION sync_name_to_full_name()
  RETURNS TRIGGER AS $$
  BEGIN
    NEW.full_name = COALESCE(NEW.full_name, NEW.name);
    RETURN NEW;
  END; $$ LANGUAGE plpgsql;

  CREATE TRIGGER trg_sync_name
    BEFORE INSERT OR UPDATE ON users
    FOR EACH ROW EXECUTE FUNCTION sync_name_to_full_name();

  -- Backfill dados existentes:
  UPDATE users SET full_name = name WHERE full_name IS NULL;

Deploy 2 — MIGRATE: código lê de full_name, escreve em ambas
  SELECT full_name FROM users;  -- Lê da nova
  UPDATE users SET name = $1, full_name = $1 WHERE id = $2;  -- Escreve em ambas

Deploy 3 — CONTRACT: código usa apenas full_name
  SELECT full_name FROM users;
  UPDATE users SET full_name = $1 WHERE id = $2;

Deploy 4 — CLEANUP: remover coluna antiga e trigger
  DROP TRIGGER trg_sync_name ON users;
  DROP FUNCTION sync_name_to_full_name();
  ALTER TABLE users DROP COLUMN name;
-- OPERAÇÕES SEGURAS vs PERIGOSAS em PostgreSQL:

-- SEGURO (não bloqueia reads/writes):
ALTER TABLE users ADD COLUMN bio TEXT;                -- Sem DEFAULT
ALTER TABLE users ADD COLUMN active BOOLEAN DEFAULT true;  -- PG 11+ (não reescreve tabela)
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);  -- Não bloqueia writes
DROP INDEX CONCURRENTLY idx_old_index;

-- PERIGOSO (bloqueia a tabela — ACCESS EXCLUSIVE LOCK):
ALTER TABLE users ADD COLUMN score INT DEFAULT 0;    -- PG < 11 (reescreve tabela inteira!)
ALTER TABLE users ALTER COLUMN name SET NOT NULL;    -- Full table scan + lock
ALTER TABLE users ALTER COLUMN name TYPE TEXT;       -- Reescreve tabela se mudar tipo
CREATE INDEX idx_users_email ON users(email);        -- Sem CONCURRENTLY = bloqueia writes

-- NOT NULL seguro (PG 12+):
ALTER TABLE users ADD CONSTRAINT users_name_not_null
  CHECK (name IS NOT NULL) NOT VALID;  -- Não verifica dados existentes
ALTER TABLE users VALIDATE CONSTRAINT users_name_not_null;  -- Valida sem bloquear

-- Timeout para DDL (não ficar travado esperando lock):
SET lock_timeout = '5s';
-- Se não conseguir o lock em 5s, aborta em vez de bloquear a fila

-- FERRAMENTAS DE MIGRATION SEGURAS:
-- pgroll — migrations reversíveis com expand-contract automático
-- pg-osc — online schema change (como pt-osc do MySQL)
-- reshape — migrations com zero downtime para PostgreSQL
-- squawk — linter de migrations PostgreSQL (detecta operações perigosas)

Backups: pg_dump, pg_basebackup e PITR

# 3 NÍVEIS DE BACKUP:

# 1. LOGICAL BACKUP — pg_dump (SQL ou custom format)
pg_dump -Fc -Z 9 -j 4 -d myapp -f /backups/myapp_$(date +%Y%m%d).dump
# -Fc: custom format (comprimido, permite restore seletivo)
# -Z 9: compressão máxima
# -j 4: 4 jobs paralelos (tabelas em paralelo)

# Restore:
pg_restore -j 4 -d myapp /backups/myapp_20240115.dump
# Restore seletivo (apenas uma tabela):
pg_restore -j 4 -d myapp -t orders /backups/myapp_20240115.dump

# Limitações do pg_dump:
# - Snapshot no momento do início (pode perder dados entre dump e crash)
# - Tempo proporcional ao tamanho do banco
# - Lock leve mas pode afetar performance (ACCESS SHARE)
# - NÃO serve para bancos > 100GB (muito lento)

# 2. PHYSICAL BACKUP — pg_basebackup (cópia dos arquivos de dados)
pg_basebackup -D /backups/base -Ft -z -P -X stream
# -Ft: formato tar
# -z: comprimido
# -P: progresso
# -X stream: inclui WAL necessário durante o backup

# Mais rápido que pg_dump para bancos grandes
# Mas restaura o banco INTEIRO (não seletivo)

# 3. WAL ARCHIVING + PITR (Point-In-Time Recovery)
# O método mais robusto — combinação de base backup + WAL contínuo

# postgresql.conf:
# wal_level = replica              # Mínimo para archiving
# archive_mode = on                # Habilitar archiving
# archive_command = 'cp %p /wal_archive/%f'  # Comando para copiar WAL
# Produção: usar pgBackRest, WAL-G ou barman em vez de cp

# PITR — restaurar para um ponto específico no tempo:
# 1. Restaurar base backup
# 2. Replay WAL até o momento desejado

# recovery.conf (ou postgresql.auto.conf no PG 12+):
# restore_command = 'cp /wal_archive/%f %p'
# recovery_target_time = '2024-01-15 14:30:00'
# recovery_target_action = 'promote'

# FERRAMENTAS PROFISSIONAIS DE BACKUP:
# pgBackRest: backup incremental, paralelo, encryption, S3/GCS support
# WAL-G: backup para cloud (S3, GCS, Azure), compressão, encryption
# Barman: backup manager completo (2ndQuadrant/EDB)

# pgBackRest exemplo:
pgbackrest --stanza=myapp --type=full backup
pgbackrest --stanza=myapp --type=diff backup   # Diferencial
pgbackrest --stanza=myapp --type=incr backup   # Incremental

# Restore com PITR:
pgbackrest --stanza=myapp --type=time \
  --target="2024-01-15 14:30:00" restore

# REGRA 3-2-1 DE BACKUPS:
# 3 cópias dos dados
# 2 tipos diferentes de mídia (disco local + cloud)
# 1 cópia offsite (outra região/provedor)

# TESTE DE RESTORE (o mais importante!):
# Backup que nunca foi testado NÃO é backup.
# Automatizar teste semanal: restaurar em ambiente isolado e validar dados.

Replicação PostgreSQL

# STREAMING REPLICATION (física) — réplica bit-a-bit:
# Primary envia WAL records em tempo real para standby(s)

# No primary (postgresql.conf):
# wal_level = replica
# max_wal_senders = 3              # Máximo de réplicas
# wal_keep_size = 1GB              # WAL retido para réplicas que atrasam

# Criar usuário de replicação:
CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'secret';

# No standby:
pg_basebackup -h primary -U replicator -D /var/lib/postgresql/data -P -X stream

# standby.signal (criar arquivo vazio):
touch /var/lib/postgresql/data/standby.signal

# postgresql.auto.conf no standby:
# primary_conninfo = 'host=primary port=5432 user=replicator password=secret'

# Monitorar replication lag:
SELECT client_addr, state, sent_lsn, write_lsn, flush_lsn, replay_lsn,
  pg_wal_lsn_diff(sent_lsn, replay_lsn) AS replication_lag_bytes
FROM pg_stat_replication;

# SYNCHRONOUS vs ASYNCHRONOUS:
# Async (padrão): melhor performance, possível perda de dados no failover
# Sync: zero data loss, mas latência de commit aumenta (espera confirmação)
# synchronous_commit = remote_apply  # Mais seguro
# synchronous_standby_names = 'standby1'

# LOGICAL REPLICATION (PG 10+) — replicação por tabela/operação:
# Permite replicar tabelas específicas (não o banco inteiro)
# Permite replicação entre versões diferentes do PostgreSQL
# Ideal para: upgrade com zero downtime, data warehouse, multi-DC

# No publisher:
CREATE PUBLICATION my_pub FOR TABLE orders, users;

# No subscriber:
CREATE SUBSCRIPTION my_sub
  CONNECTION 'host=primary port=5432 dbname=myapp user=replicator'
  PUBLICATION my_pub;

# READ REPLICAS — escalar leituras:
# Aplicação envia writes para primary, reads para réplicas
# Cuidado com replication lag: leitura pode retornar dado desatualizado
# Padrão: ler do primary logo após write (read-your-own-writes)

Alta Disponibilidade: Patroni e Failover

# PATRONI — HA (High Availability) para PostgreSQL:
# Gerencia failover automático usando consensus (etcd, ZooKeeper, Consul)

# Arquitetura:
# ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐
# │ Node 1          │  │ Node 2          │  │ Node 3          │
# │ PostgreSQL      │  │ PostgreSQL      │  │ PostgreSQL      │
# │ (PRIMARY)       │  │ (STANDBY)       │  │ (STANDBY)       │
# │ Patroni agent   │  │ Patroni agent   │  │ Patroni agent   │
# └────────┬────────┘  └────────┬────────┘  └────────┬────────┘
#          └─────────────┬──────┴──────────────┬─────┘
#                  ┌─────┴─────┐         ┌─────┴─────┐
#                  │   etcd    │         │ HAProxy/  │
#                  │ cluster   │         │ PgBouncer │
#                  └───────────┘         └───────────┘

# patroni.yml:
scope: myapp-cluster
name: node1

restapi:
  listen: 0.0.0.0:8008
  connect_address: node1:8008

etcd3:
  hosts: etcd1:2379,etcd2:2379,etcd3:2379

bootstrap:
  dcs:
    ttl: 30
    loop_wait: 10
    retry_timeout: 10
    maximum_lag_on_failover: 1048576  # 1MB — máximo lag para failover
    postgresql:
      use_pg_rewind: true
      parameters:
        max_connections: 200
        shared_buffers: 4GB
        wal_level: replica
        max_wal_senders: 5
        synchronous_commit: 'on'
        synchronous_standby_names: '*'

  initdb:
    - encoding: UTF8
    - data-checksums  # Detectar corrupção silenciosa

postgresql:
  listen: 0.0.0.0:5432
  connect_address: node1:5432
  data_dir: /var/lib/postgresql/data
  authentication:
    replication:
      username: replicator
      password: secret
    superuser:
      username: postgres
      password: secret
# Comandos Patroni:
patronictl -c /etc/patroni.yml list
# +--------+--------+----------+--------+---------+----+-----------+
# | Member | Host   | Role     | State  | TL      | Lag| Tags      |
# +--------+--------+----------+--------+---------+----+-----------+
# | node1  | node1  | Leader   | running| 3       |    |           |
# | node2  | node2  | Replica  | running| 3       | 0  |           |
# | node3  | node3  | Replica  | running| 3       | 0  |           |
# +--------+--------+----------+--------+---------+----+-----------+

# Failover manual:
patronictl -c /etc/patroni.yml switchover --master node1 --candidate node2

# Reinicializar configuração:
patronictl -c /etc/patroni.yml reload myapp-cluster

Performance Tuning do PostgreSQL

# PARÂMETROS ESSENCIAIS (postgresql.conf):

# shared_buffers: cache de páginas do PostgreSQL em memória
# Regra: 25% da RAM total (não mais que 8GB em geral)
shared_buffers = 4GB  # Para servidor com 16GB RAM

# effective_cache_size: estimativa de memória disponível para cache de disco
# Regra: 50-75% da RAM total (inclui OS page cache)
effective_cache_size = 12GB

# work_mem: memória por operação de sort/hash (POR QUERY, não global!)
# Cuidado: 100 queries simultâneas × 64MB = 6.4GB!
# Regra: RAM / (max_connections * 2) para workloads OLTP
work_mem = 32MB

# maintenance_work_mem: memória para VACUUM, CREATE INDEX, ALTER TABLE
maintenance_work_mem = 1GB

# wal_buffers: buffer para WAL antes de flush
wal_buffers = 64MB

# checkpoint_completion_target: spread checkpoints para reduzir I/O spikes
checkpoint_completion_target = 0.9

# random_page_cost: custo relativo de I/O aleatório (para planner)
# SSD: 1.1 (quase igual a sequential I/O)
# HDD: 4.0 (padrão, I/O aleatório é muito mais caro)
random_page_cost = 1.1

# effective_io_concurrency: operações de I/O em paralelo
# SSD: 200  | HDD: 2
effective_io_concurrency = 200

# max_parallel_workers_per_gather: paralelismo de queries
max_parallel_workers_per_gather = 4

# ENABLE pg_stat_statements (obrigatório em produção):
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.track = all
-- pg_stat_statements — encontrar queries lentas:
SELECT
  calls,
  round(total_exec_time::numeric / 1000, 2) AS total_time_sec,
  round(mean_exec_time::numeric, 2) AS avg_time_ms,
  round(stddev_exec_time::numeric, 2) AS stddev_ms,
  rows,
  query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
-- Otimize as queries do TOP 20 — representam a maioria do tempo

-- Identificar queries que mais consomem I/O:
SELECT
  calls,
  shared_blks_hit + shared_blks_read AS total_blocks,
  round(100.0 * shared_blks_hit / NULLIF(shared_blks_hit + shared_blks_read, 0), 2) AS cache_hit_pct,
  query
FROM pg_stat_statements
ORDER BY shared_blks_read DESC
LIMIT 20;
-- cache_hit_pct < 95% → índice faltando ou work_mem pequeno

-- EXPLAIN ANALYZE — entender o plano de execução:
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.id, u.name, o.total
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.created_at > '2024-01-01'
ORDER BY o.total DESC
LIMIT 10;

-- Pontos de atenção no EXPLAIN:
-- Seq Scan em tabela grande → índice faltando
-- Nested Loop com tabela grande → considerar Hash Join
-- Sort com "Sort Method: external merge" → work_mem pequeno
-- Buffers: shared read alto → dados não estão em cache

Vacuum e Autovacuum

POR QUE VACUUM EXISTE:
PostgreSQL usa MVCC (Multi-Version Concurrency Control).
UPDATE não modifica a linha — cria uma nova e marca a antiga como "morta".
DELETE marca a linha como morta mas não remove fisicamente.
VACUUM remove as linhas mortas e libera espaço para reutilização.

SEM VACUUM:
- Tabela cresce indefinidamente (table bloat)
- Índices ficam ineficientes (index bloat)
- Risco de transaction ID wraparound (corrupção de dados!)

AUTOVACUUM — processo automático que roda VACUUM:
# Configuração do autovacuum (postgresql.conf):
autovacuum = on                              # NUNCA desativar em produção!
autovacuum_max_workers = 5                   # Workers simultâneos
autovacuum_naptime = 30s                     # Intervalo entre verificações

# Quando autovacuum inicia para uma tabela:
# dead_tuples > autovacuum_vacuum_threshold + (autovacuum_vacuum_scale_factor × table_rows)
# Padrão: 50 + (0.2 × table_rows) — 20% das linhas precisam estar mortas
# Para tabelas grandes (>1M linhas), ajustar por tabela:
autovacuum_vacuum_scale_factor = 0.01        # 1% em vez de 20%
autovacuum_vacuum_threshold = 1000

# Tuning por tabela (tabelas muito atualizadas):
ALTER TABLE orders SET (
  autovacuum_vacuum_scale_factor = 0.01,
  autovacuum_vacuum_threshold = 500,
  autovacuum_analyze_scale_factor = 0.005
);

# VACUUM FULL vs VACUUM regular:
# VACUUM: marca espaço como reutilizável (não reduz tamanho do arquivo)
# VACUUM FULL: reescreve a tabela inteira (reduz tamanho, mas ACCESS EXCLUSIVE LOCK!)
# Em produção: NUNCA usar VACUUM FULL em horário de pico
# Alternativa: pg_repack (reescrita online sem lock exclusivo)
-- Monitorar bloat e vacuum:
-- Tabelas com mais dead tuples:
SELECT
  schemaname || '.' || relname AS table,
  n_live_tup,
  n_dead_tup,
  round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
  last_vacuum,
  last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC;

-- Progresso do VACUUM em execução:
SELECT * FROM pg_stat_progress_vacuum;

-- Transaction ID age (risco de wraparound):
SELECT
  datname,
  age(datfrozenxid) AS xid_age,
  round(100.0 * age(datfrozenxid) / 2147483647, 2) AS pct_to_wraparound
FROM pg_database
ORDER BY age(datfrozenxid) DESC;
-- Se pct_to_wraparound > 50% → problema sério iminente!
-- PostgreSQL vai entrar em modo single-user para forçar VACUUM FREEZE

-- Estimar bloat de tabela (aproximado):
SELECT
  schemaname || '.' || tablename AS table,
  pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) AS total_size,
  pg_size_pretty(pg_table_size(schemaname || '.' || tablename)) AS table_size,
  pg_size_pretty(pg_indexes_size(schemaname || '.' || tablename)) AS indexes_size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname || '.' || tablename) DESC;

Monitoramento de Banco em Produção

-- QUERIES ATIVAS (o que está rodando agora?):
SELECT
  pid,
  now() - query_start AS duration,
  state,
  wait_event_type,
  wait_event,
  left(query, 100) AS query
FROM pg_stat_activity
WHERE state != 'idle'
  AND pid != pg_backend_pid()
ORDER BY duration DESC;

-- LOCKS (quem está bloqueando quem?):
SELECT
  blocked.pid AS blocked_pid,
  blocked.query AS blocked_query,
  blocking.pid AS blocking_pid,
  blocking.query AS blocking_query,
  now() - blocked.query_start AS blocked_duration
FROM pg_stat_activity blocked
JOIN pg_locks bl ON bl.pid = blocked.pid
JOIN pg_locks lock_on ON lock_on.locktype = bl.locktype
  AND lock_on.database IS NOT DISTINCT FROM bl.database
  AND lock_on.relation IS NOT DISTINCT FROM bl.relation
  AND lock_on.pid != bl.pid
JOIN pg_stat_activity blocking ON blocking.pid = lock_on.pid
WHERE NOT bl.granted;

-- CACHE HIT RATIO (deve ser > 99% em produção):
SELECT
  sum(heap_blks_hit) AS cache_hits,
  sum(heap_blks_read) AS disk_reads,
  round(100.0 * sum(heap_blks_hit) / NULLIF(sum(heap_blks_hit) + sum(heap_blks_read), 0), 2) AS ratio
FROM pg_statio_user_tables;
-- Se < 99%: aumentar shared_buffers ou a instância não tem RAM suficiente

-- INDEX USAGE (índices não utilizados desperdiçam espaço e I/O):
SELECT
  schemaname || '.' || relname AS table,
  indexrelname AS index,
  pg_size_pretty(pg_relation_size(indexrelid)) AS size,
  idx_scan AS scans,
  idx_tup_read AS tuples_read
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND indexrelname NOT LIKE '%_pkey'
ORDER BY pg_relation_size(indexrelid) DESC;
-- Índices com 0 scans → candidatos para remoção

-- CONNECTION USAGE:
SELECT
  count(*) AS total,
  count(*) FILTER (WHERE state = 'active') AS active,
  count(*) FILTER (WHERE state = 'idle') AS idle,
  count(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_transaction,
  max_conn.setting::int AS max_connections
FROM pg_stat_activity, pg_settings max_conn
WHERE max_conn.name = 'max_connections'
GROUP BY max_conn.setting;
-- idle in transaction: perigo! Transação aberta sem fazer nada (segura locks)

Disaster Recovery: RTO e RPO

RTO (Recovery Time Objective) — quanto tempo pode ficar fora do ar?
RPO (Recovery Point Objective) — quantos dados pode perder?

ESTRATÉGIAS POR NÍVEL:

RPO: horas     | RTO: horas     → pg_dump diário + restore manual
RPO: minutos   | RTO: 30min     → WAL archiving + PITR
RPO: segundos  | RTO: minutos   → Streaming replication + failover manual
RPO: zero      | RTO: segundos  → Synchronous replication + Patroni (auto-failover)
RPO: zero      | RTO: zero*     → Multi-region active-active (complexidade extrema)

PLANO DE DR (Disaster Recovery):

1. DEFINIR RTO/RPO com o negócio (não é decisão técnica)
2. IMPLEMENTAR backups automáticos com verificação
3. TESTAR restore regularmente (simulação mensal)
4. DOCUMENTAR runbook passo a passo
5. PRATICAR failover (game days)

RUNBOOK DE RESTORE (exemplo):
1. Identificar o ponto no tempo alvo (antes do incidente)
2. Parar aplicação (evitar novas escritas)
3. Provisionar novo servidor PostgreSQL
4. Restaurar base backup mais recente
5. Aplicar WAL até o ponto alvo (PITR)
6. Validar dados (queries de verificação)
7. Redirecionar aplicação para novo servidor
8. Monitorar métricas intensivamente por 24h

Upgrade do PostgreSQL

# DOIS MÉTODOS DE UPGRADE:

# 1. pg_upgrade (in-place, mais rápido):
# Converte data files diretamente. Downtime necessário.
pg_upgrade \
  --old-datadir=/var/lib/postgresql/15/data \
  --new-datadir=/var/lib/postgresql/16/data \
  --old-bindir=/usr/lib/postgresql/15/bin \
  --new-bindir=/usr/lib/postgresql/16/bin \
  --link  # Hard links em vez de copiar (muito mais rápido)

# Passos:
# 1. Instalar nova versão do PostgreSQL
# 2. Parar o cluster antigo
# 3. Executar pg_upgrade
# 4. Iniciar o cluster novo
# 5. Rodar ANALYZE para atualizar estatísticas
# 6. Remover cluster antigo

# Tempo de downtime: minutos (com --link) a horas (sem --link)

# 2. LOGICAL REPLICATION (zero-downtime upgrade):
# Mais complexo, mas sem downtime.

# Passos:
# 1. Provisionar novo cluster com PG 16
# 2. Criar schema no PG 16 (pg_dump --schema-only)
# 3. Configurar logical replication PG 15 → PG 16
# 4. Esperar sincronização completa
# 5. Validar dados no PG 16
# 6. Redirecionar aplicação para PG 16 (switch no DNS/PgBouncer)
# 7. Derrubar replicação e desativar PG 15

# Validação pós-upgrade:
# - Comparar contagem de linhas em tabelas críticas
# - Executar queries de negócio e comparar resultados
# - Verificar pg_stat_statements para regressão de performance
# - Monitorar cache hit ratio (vai começar frio após upgrade)

Referencias e Fontes