Lab 18 — Migrar schema em produção sem parar
O problema, e a empresa que o tem
A Cadência resolveu o deploy: desde o L03, publicar não derruba ninguém, e a equipe de duas pessoas passou a implantar várias vezes por dia. O efeito colateral apareceu num incidente que ninguém tinha previsto — porque ele mora exatamente na peça que o L03 deixou como dívida declarada.
O time precisava trocar `valor_total` — `numeric`, em reais, com os problemas de arredondamento que ponto flutuante decimal já carrega — por `total_centavos`, `bigint`, inteiro. A migration rodava em 5 milissegundos isolada. Em produção, o deploy travou a aplicação inteira por quatro minutos, e o log da API não mostrava nada de errado: o processo estava vivo, só não respondia.
A causa não foi a migration em si. Foi que ela rodava na partida do contêiner (o que o L01 faz), e o rolling update do L03 sobe duas tasks novas ao mesmo tempo — as duas tentando alterar a mesma tabela, enquanto a versão velha ainda atendia pedido real com o schema antigo por baixo dela mudando. O incidente juntou três coisas que, sozinhas, ninguém teria notado: tabela grande, deploy concorrente e uma consulta de relatório que, por acaso, estava aberta havia três minutos quando o deploy começou.
O que este laboratório NÃO é
Não é um tutorial geral de "zero-downtime schema change" para qualquer banco. O mecanismo aqui é especificamente do PostgreSQL — os modos de lock, o comportamento de fila, o `CONCURRENTLY` — e não se transporta sem adaptação para MySQL ou para um banco não relacional. Também não é sobre decidir ENTRE tráfego dividido (blue/green, canário): isso é o L39, que depende deste porque pressupõe schema já compatível para trás.
O que você vai conseguir fazer
Objetivos verificáveis: cada um se prova com um comando na seção de implantação.
- Explicar por que uma migration de 5 ms consegue derrubar a produção por minutos, nomeando a fila de bloqueio como causa — não a duração do comando.
- Distinguir, para cada forma de `ALTER TABLE`, se ela reescreve a tabela ou é só catálogo.
- Configurar `lock_timeout` e `statement_timeout` como padrão da instância, e explicar por que eles defendem da fila e não do comando.
- Rodar `CREATE INDEX CONCURRENTLY` a partir de uma migration do EF Core, e recuperar de um índice `INVALID`.
- Executar as três fases de expand/contract, e derivar por que a ordem entre elas não é negociável.
- Escrever um backfill em lotes que respeita o atraso da réplica de leitura do L07.
- Reconhecer o caso de renomear coluna como uma sequência de quatro implantações, não três.
- Provar, com `pg_locks`, quem está esperando quem durante uma migração.
O que a certificação cobra disto
| Conceito | Certificação | Como aparece aqui | O que dominar |
|---|---|---|---|
| Níveis de bloqueio do ALTER TABLE | DVA-C02, DOP-C02 | ADD COLUMN aditivo vs. formas que reescrevem a tabela | quais formas exigem varredura completa e quais são só catálogo |
| CREATE INDEX CONCURRENTLY | DVA-C02, DOP-C02 | índice sem bloquear escrita, fora de transação | o custo de duas varreduras, e o que fazer com um índice INVALID |
| Fila de bloqueio (lock queue) | DOP-C02 | transação longa segura o ALTER, que segura todo SELECT novo | por que um ACCESS EXCLUSIVE pendente bloqueia até leitura compatível com o que já roda |
| lock_timeout e statement_timeout | DOP-C02 | defesa de sessão contra fila indefinida | a diferença entre os dois parâmetros e o que cada um aborta |
| Expand/contract e compatibilidade para frente | DOP-C02, SAP-C02 | as três a quatro fases deste laboratório | por que parar de escrever no antigo precede apagá-lo, sempre |
| Backfill em lotes e atraso de réplica | SAA-C03, DOP-C02 | UPDATE em lotes monitorando pg_stat_replication | o efeito de uma transação longa no WAL e na réplica do L07 |
| Estratégia de deploy vs. estratégia de dados | DOP-C02 | por que o rolling update (L03) não resolve migration destrutiva sozinho | diferenciar zero-downtime de código de zero-downtime de schema |
| Migrations customizadas do EF Core | DVA-C02 | SQL cru fora de transação para CONCURRENTLY | quando a migration builder não basta, e por quê |
Onde isto costuma ser cobrado errado
A pergunta clássica dá uma tabela pequena, um `ALTER TABLE` que "deveria ser instantâneo" e um relato de produção com minutos de indisponibilidade, pedindo a causa. A resposta certa é a fila: uma transação aberta segurando um lock incompatível, com o `ALTER` esperando indefinidamente e todo `SELECT` novo esperando atrás dele. A resposta errada mais escolhida é "a tabela era grande demais" — mas o enunciado nunca diz o tamanho da tabela, só a duração do incidente.
Requisitos, e como cada um muda o desenho
Requisito que não aparece numa linha de configuração é intenção. A coluna da direita é onde cada um deixou marca.
| Requisito | Valor declarado | O que ele decide no desenho |
|---|---|---|
| Erro visível ao cliente durante a migração | zero | proíbe migration única destrutiva; obriga expand/contract em fases |
| Duas versões da app convivem (herdado do L03) | minutos, a cada deploy | toda mudança de schema tem de servir a v1 E a v2 ao mesmo tempo |
| Tamanho da tabela `pedidos` | 2,1 milhões de linhas | decide backfill em lotes, não um `UPDATE` único na tabela toda |
| Réplica de leitura em uso (herdado do L07) | existe, atende consulta real | obriga medir atraso de réplica como porta de avanço do backfill |
| Equipe | duas pessoas, sem plantão noturno | decide fases pequenas e reversíveis; nada que exija vigília contínua |
| Rollback continua possível (herdado do L03) | obrigatório | proíbe apagar a coluna antiga enquanto a v1 for destino de rollback |
| Horário da migração | sem prazo rígido, mas não pode competir com o pico | decide backfill fora do horário de pico e sob teto de atraso de réplica |
| Auditabilidade de quem mudou o schema | obrigatória | decide task de migração com papel IAM próprio e log group dedicado |
| Nenhuma janela de manutenção anunciada | requisito herdado do L03 | decide que as quatro implantações rodam com a aplicação sempre no ar |
Requisito que não muda nada no desenho é enfeite
Cada linha desta tabela existe porque aponta para uma decisão concreta mais adiante — o tamanho do lote, o teto de espera, o papel IAM separado. Se um requisito não consegue apontar para nenhuma peça do desenho, ele não pertence a esta tabela: pertence à conversa que ainda não terminou de decidir o que o sistema precisa fazer.
Arquitetura mínima: a migration que trava a tabela
Este é o desenho que a Cadência tinha, herdado do L01, e ele é legítimo como ponto de partida: a migration roda na partida do contêiner, funciona sem passo extra, e ninguém nota nada — até existirem duas tasks concorrentes e uma transação de relatório aberta no momento errado.
- → update-service com a imagem nova
- → sobe a primeira task nova
- → sobe a segunda ao mesmo tempo
- → ALTER TABLE: pede ACCESS EXCLUSIVE
- → a mesma migration, concorrente e bloqueada
- → SELECT que entra na fila atrás do ALTER
- → HTTPS 443 durante o deploy
- → tráfego real na versão velha
- Fora da AWS
- Rede e entrega
- Compute
- Banco de dados
Este desenho funciona há dois anos, e é por isso que ninguém o questiona: uma linha no arranque mantém o schema em dia sem passo extra. Percorra os passos e repare que o defeito não é a linha — é que ela transforma "subir uma task" em "mudar o schema", e o rolling update do L03 sobe duas tasks ao mesmo tempo.
- O schema muda porque um contêiner subiu. Uma linha no arranque — `db.Database.Migrate()` — acopla duas coisas que não têm a mesma natureza: implantar código, que é reversível e acontece muitas vezes por dia, e alterar schema, que é global e quase sempre irreversível. Enquanto havia uma task e uma tabela vazia, a diferença não aparecia.
- O rolling update sobe DUAS tasks, e as duas migram. O excedente que o L03 configurou para não haver janela é o que produz a concorrência aqui: com `desired_count = 2` e máximo de 200%, o ECS cria as duas tasks novas praticamente juntas. Ambas executam a mesma migration; uma ganha o bloqueio, a outra fica parada até o `startPeriod` acabar e o ECS a matar.
- ACCESS EXCLUSIVE conflita com tudo, inclusive leitura. A documentação do PostgreSQL é direta: `ALTER TABLE` adquire `ACCESS EXCLUSIVE` a menos que a forma específica diga outra coisa, e esse modo conflita com todos os oito modos — incluindo o `ACCESS SHARE` que um `SELECT` simples pega. É o único modo que bloqueia leitura.
- A versão velha ainda está no ar, e o schema mudou sob ela. É a dívida que o L03 declarou e não pagou. Durante o rollout, o código antigo atende requisição real. Se a migration removeu ou renomeou coluna, esse código passa a consultar algo que não existe mais e responde 500 — e o erro aparece na versão que ninguém acabou de mexer.
- A fila de bloqueio é o que transforma 5 ms em minutos. O `ALTER` só executa quando ganha o bloqueio. Se há uma transação de relatório aberta, ele espera — e cada `SELECT` novo entra atrás dele, porque o PostgreSQL não deixa um pedido compatível furar a fila na frente de um pedido incompatível que já está esperando. A tabela fica ilegível pelo tempo da transação mais longa, não pelo tempo do comando.
- Por que alguém publica assim. Porque é uma linha, funciona em desenvolvimento com uma task e uma tabela de trezentas linhas, e evita um passo no pipeline. O defeito é invisível até existirem duas tasks, tabela grande e transação concorrente — ou seja, até a produção. O remendo social é o de sempre: avisar no canal e escolher a madrugada, que é exatamente a janela que o L03 eliminou.
A fila de bloqueio, não a duração do DDL, é o que derruba
O comando que a Cadência rodou levava 5 ms isolado. O que consumiu quatro minutos foi a espera pelo bloqueio de uma transação de relatório já aberta, mais o efeito de que nenhum `SELECT` novo furava a fila na frente do `ALTER` que já esperava. Confundir "migration rápida" com "migration segura" é o erro de raciocínio central deste laboratório — e é o que mais fonte secundária sobre o assunto erra.
Arquitetura para produção
Cada peça nova abaixo rastreia a uma linha da tabela de requisitos. A diferença estrutural em relação ao desenho anterior não é uma caixa a mais: é que alterar schema deixa de acontecer dentro da aplicação e vira um passo com identidade, ordem e critério de avanço próprios.
- → run-task de uma fase por vez
- → aborta o DDL antes de a fila crescer
- → DDL aditivo, curto e sob lock_timeout
- → só depois da fase 1: implanta a v2
- → sobe a versão nova sem parar a velha
- → mantém a velha até o rollout terminar
- → escreve no esquema antigo, que continua existindo
- → escreve nas duas colunas, lê da nova
- → UPDATE de 5 mil linhas por lote, com pausa
- → WAL do backfill: lote grande alonga o atraso
- → HTTPS 443 durante as quatro implantações
- → parte do tráfego na versão velha
- → parte do tráfego na versão nova
- → amostra sessões em espera de bloqueio
- → atraso ou espera alta: a fase seguinte não começa
- Gestão e governança
- Fora da AWS
- Compute
- Conceito de arquitetura
- Banco de dados
- Rede e entrega
A mudança estrutural não é uma caixa a mais: é que alterar schema deixa de acontecer dentro da aplicação e passa a ser um passo com identidade, ordem e critério de avanço próprios. Percorra os passos e repare que o que autoriza ir para a fase seguinte é medição — atraso de réplica e bloqueio esperando — e não o fim do comando anterior.
- Alterar schema deixa de ser efeito colateral de subir código. A mesma imagem passa a ter dois entrypoints: um serve HTTP, o outro aplica uma fase da migração e termina. A task de migração roda UMA vez, com uma conexão, sob um papel do banco com tempos-limite próprios. Nenhuma das tasks da API toca em DDL — e é isso que elimina a corrida entre elas.
- Fase 1 — expandir: só o que é aditivo e barato. Colunas novas anuláveis, sem `NOT NULL` e sem default volátil; constraint como `NOT VALID`; índice com `CONCURRENTLY`. Ao fim da fase 1 o schema serve as duas versões, e a v1 continua no ar sem saber que algo mudou. É a fase que compra o direito de implantar código.
- lock_timeout defende da fila, não do comando. Com `lock_timeout = 3s`, um `ALTER` que não consegue o bloqueio em três segundos aborta — e a fila que estava se formando atrás dele se desfaz. Sem isso, a espera pelo bloqueio é indefinida e leva a tabela inteira com ela. Migration que falha e você repete é um inconveniente; fila de bloqueio é indisponibilidade.
- Fase 2 — a v2 escreve nos dois lugares enquanto a v1 vive. A escrita dupla é o que torna o backfill convergente: enquanto ele varre o passado, o presente já nasce correto nas duas colunas. Sem escrita dupla, cada lote processado é imediatamente invalidado por gravações novas, e o backfill nunca termina — só parece terminar.
- O backfill é em lotes por causa do WAL e da réplica. Um `UPDATE` único na tabela toda é uma transação longa: gera uma versão nova de cada linha (bloat), escreve tudo no WAL de uma vez e alonga o atraso da réplica do L07. Lotes de alguns milhares de linhas, com pausa entre eles, dão ao autovacuum e à replicação tempo de acompanhar.
- Fase 3 — contrair, e só depois de a v1 sair de cena. Remover a coluna antiga é a única operação irreversível da sequência, e ela espera duas condições: nenhuma task da v1 no ar e a v1 já descartada como destino de rollback. Enquanto o disjuntor do L03 puder devolver a v1, contrair é apagar o plano de volta.
- Quem autoriza a fase seguinte é medição, não relógio. Duas portas: atraso de réplica abaixo do limiar e nenhuma sessão em espera de bloqueio no Performance Insights. Uma terceira, específica desta mudança: contagem de linhas divergentes igual a zero. Avançar por tempo decorrido é o que faz alguém contrair com 40 mil linhas ainda sem valor na coluna nova.
A parcela que você realmente controla
Das quatro parcelas da fórmula, três dependem do tamanho dos dados ou de quem mais está usando o banco no momento — fora do seu controle direto. `lock_timeout` é a única que é decisão pura: você escolhe o teto de paciência, e ele transforma uma espera indefinida em uma falha rápida e repetível.
O caminho de uma migração, ponta a ponta
O caso concreto é trocar `valor_total` (reais, `numeric`) por `total_centavos` (`bigint`) — uma mudança de TIPO, que é o caso mais difícil do padrão porque não existe coluna que sirva às duas versões sem que uma delas seja, temporariamente, redundante.
A pergunta que a certificação mais cobra errado é sobre a fila. A documentação do PostgreSQL confirma o mecanismo: um `ACCESS EXCLUSIVE` pendente não é furado por um pedido de `ACCESS SHARE` que chega depois — mesmo que os dois pedidos, isolados, não tivessem conflito nenhum entre si. É a forma como o gerenciador de locks evita que um escritor espere para sempre, e o efeito colateral é que todo leitor que chega depois também espera.
-- A consulta que transforma "parece travado" em "está esperando ESTE pid".
-- Roda contra a instancia PRINCIPAL, nunca a replica.
SELECT
blocked.pid AS bloqueado,
blocked.query AS o_que_espera,
blocking.pid AS bloqueador,
blocking.query AS o_que_segura_o_lock,
now() - blocking.xact_start AS ha_quanto_tempo_a_transacao_esta_aberta
FROM pg_locks blocked_locks
JOIN pg_stat_activity blocked ON blocked.pid = blocked_locks.pid
JOIN pg_locks blocking_locks ON blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.relation = blocked_locks.relation
AND blocking_locks.pid != blocked_locks.pid
JOIN pg_stat_activity blocking ON blocking.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;A consulta encontra o pid; encerrar a sessão é o passo seguinte
A consulta acima diz QUEM está segurando o lock. Terminar a transação ofensora é `SELECT pg_terminate_backend(<pid_bloqueador>);`, e é uma ação drástica — ela mata a conexão, não só a transação, e qualquer trabalho não commitado daquela sessão some. Use `pg_cancel_backend` quando bastar cancelar a consulta em curso sem fechar a conexão inteira.
Saida da consulta de convergencia (implantar-fases.sh), publicada como metrica customizada a cada execucao. E o payload que decide se a fase 3 pode comecar.
{
"medido_em": "2026-08-07T14:05:00Z",
"tabela": "pedidos",
"linhas_totais": 2134982,
"linhas_divergentes": 0,
"medicao_anterior_ha_5min": 0,
"atraso_da_replica_segundos": 1.8,
"decisao": "AVANCAR"
}As decisões, e o que se perde em cada uma
📋 Uma API .NET 8 em ECS Fargate com rolling update (L03) e RDS PostgreSQL com réplica (L07) precisa trocar o tipo de uma coluna numa tabela de 2,1 milhões de linhas, sem janela e sem plantão noturno, com duas pessoas no time.
A restrição real não é o comando SQL: é que o rolling update faz duas versões do código conviverem por minutos, e um schema só pode estar em um estado. Expand/contract resolve isso invertendo o que é rígido: em vez de exigir que as duas versões concordem, exige que o schema sirva às duas. O custo é organizacional — quatro implantações em vez de uma, e um schema temporariamente redundante — e é um custo que cabe numa equipe de duas pessoas, porque cada passo é pequeno, reversível e verificável isoladamente.
Alt: Janela de manutenção anunciada — Resolve de verdade, e é honesto reconhecer: com a aplicação parada, migration destrutiva é trivial. O que ela cobra é o que o L03 acabou de eliminar — e, pior, ensina a equipe a agrupar mudanças de schema para "aproveitar a janela", o que aumenta o tamanho do lote e a chance de erro.
Alt: Blue/green com dois bancos e sincronização — Duas frotas e dois schemas ao mesmo tempo. Parece eliminar o problema, mas apenas o move: a escrita que acontece no banco velho durante a troca precisa chegar ao novo, e você acaba escrevendo a mesma lógica de escrita dupla — sem a vantagem de poder voltar. É o L39 para tráfego, não para dados.
Alt: Migration na partida com bloqueio de coordenação — Um `pg_advisory_lock` no arranque impede a corrida entre as duas tasks, e é melhor que nada. Não resolve nada do resto: o DDL continua acontecendo no meio do rollout, continua podendo formar fila e continua destrutivo enquanto a v1 atende.
Alt: Nova tabela e troca de nome no fim — Copiar para `pedidos_novo` e renomear parece limpo e é o caminho de ferramentas como pg_repack. O `ALTER TABLE ... RENAME` final pega `ACCESS EXCLUSIVE` e, sobretudo, a escrita que ocorreu durante a cópia precisa de gatilho para chegar à cópia. Vale quando a tabela precisa ser reescrita de todo modo — não para acrescentar uma coluna.
| Decisão | Escolha | Alternativas | Motivo | O que se perde |
|---|---|---|---|---|
| Onde a migração roda | task avulsa, fora do arranque do contêiner | `Migrate()` na partida (o L01); job cron separado | elimina a corrida entre tasks concorrentes do rolling update | mais uma peça de infraestrutura — task definition e papel IAM próprios |
| Teto de espera pelo bloqueio | `lock_timeout = 3s` na migration, 5s de padrão na instância | sem teto (padrão do PostgreSQL); teto muito alto (30s+) | troca espera indefinida por falha rápida e repetível | a migration pode falhar sob carga adversa e precisar ser repetida |
| Índice novo | `CREATE INDEX CONCURRENTLY` | índice padrão (bloqueia escrita); sem índice até a contração | não bloqueia escrita, ao custo de duas varreduras da tabela | não pode rodar dentro da transação da migration — exige SQL cru com suppressTransaction |
| Tamanho do lote do backfill | 5 mil linhas, com pausa de 200 ms e teto de atraso de réplica | lote único (tabela toda); lote maior sem checar réplica | mantém o atraso da réplica do L07 dentro do tolerável | o backfill de 2,1 milhões de linhas leva minutos a mais que um UPDATE único |
| Coluna nova | anulável, sem `NOT NULL` na criação | `NOT NULL DEFAULT` direto na criação | evita ACCESS EXCLUSIVE com varredura da tabela inteira na fase 1 | o schema fica temporariamente permissivo — a fase 3 fecha isso com `CHECK NOT VALID` |
| Momento da contração | só após divergência zero medida duas vezes, e v1 fora do plano de rollback | contrair assim que o backfill "parece" terminar | a única operação irreversível da sequência espera prova, não estimativa | a migração leva mais tempo de ponta a ponta do que uma contração otimista |
Contrair antes de a v1 sair de cena apaga o plano de volta
Enquanto o disjuntor de implantação do L03 puder reverter para a v1, a coluna antiga é o caminho de volta dela. `DROP COLUMN` não reescreve a tabela — é rápido — mas o dado fica inacessível a partir dali. Se a v1 ainda estiver no ar, ou ainda for o destino de um rollback automático, ela vai consultar uma coluna que não existe mais e responder 500 para todo cliente. Não há como desfazer isso com uma migration reversa: o caminho de volta passa a ser restauração pelo `pg_dump`/PITR do L10, com o tempo de RTO que ele custar.
Construir: RDS com tempos-limite como padrão, e a task de migração
Os tempos-limite viram propriedade da INSTÂNCIA, não gambiarra de sessão isolada — assim toda conexão nova, inclusive uma que ninguém pensou em proteger, já nasce com teto. A migração pede um valor ainda mais estrito por cima, via `SET LOCAL`.
# migration.tf — tempos-limite como padrão da instância, e a task de migração
# como cidadão de primeira classe, fora do servico da API
# lock_timeout e statement_timeout sao parametros DINAMICOS (contexto "user" no
# PostgreSQL): mudam sem reiniciar o RDS, e qualquer sessao pode sobrescreve-los
# com SET LOCAL. Aqui eles viram padrao da INSTANCIA — nao gambiarra ad hoc — e a
# migracao pede um teto ainda mais estrito (3 s) via SET LOCAL na propria migration.
resource "aws_db_parameter_group" "postgres" {
name = "${var.projeto}-postgres16"
family = "postgres16"
parameter {
name = "lock_timeout"
value = "5000" # ms; qualquer sessao que nao redefina espera no maximo 5 s por um lock
apply_method = "immediate"
}
parameter {
name = "statement_timeout"
value = "30000" # ms; protege QUALQUER sessao de rodar para sempre — migration ou nao
apply_method = "immediate"
}
parameter {
# Registra no log toda espera de lock mais longa que deadlock_timeout (1 s,
# padrao). E o que torna a fila do diagrama de producao observavel sem
# precisar reproduzir o incidente para ve-la.
name = "log_lock_waits"
value = "1"
apply_method = "immediate"
}
}
resource "aws_db_instance" "principal" {
# ... (recursos ja existentes do L01/L07; aqui so o que este laboratorio muda)
parameter_group_name = aws_db_parameter_group.postgres.name
}
# A task de migracao usa a MESMA imagem da API, com outro entrypoint (ver
# Program.cs). Nao e um servico: nao tem desired_count, nao tem grupo de
# destino, nao recebe trafego. Ela roda com `run-task`, faz UMA coisa e termina.
resource "aws_ecs_task_definition" "migracao" {
family = "${var.projeto}-migracao"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = 256
memory = 512
execution_role_arn = aws_iam_role.execucao.arn
task_role_arn = aws_iam_role.migracao.arn # papel PROPRIO, nao o da API
container_definitions = jsonencode([{
name = "migracao"
image = "${aws_ecr_repository.api.repository_url}:${var.sha_da_imagem}"
essential = true
# A mesma imagem da API que atende trafego real (L03) — nunca um build
# "quase igual" compilado a parte. O argumento decide a fase; o pipeline
# passa um valor por vez.
command = ["dotnet", "Api.dll", "--migrate", var.fase_da_migracao]
secrets = [{
name = "ConnectionStrings__Migracao"
valueFrom = aws_secretsmanager_secret.banco_migracao.arn
}]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.migracao.name
"awslogs-region" = var.regiao
"awslogs-stream-prefix" = "migracao"
}
}
}])
}
# Papel PROPRIO da migracao, separado do task role da API (aws_iam_role.task no
# L03). A API nunca precisa de DDL; a migracao nunca precisa das rotas de
# negocio. Separar os dois e o que faz uma credencial vazada da API nao servir
# para alterar schema — e vice-versa.
resource "aws_iam_role" "migracao" {
name = "${var.projeto}-migracao"
assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
}
data "aws_iam_policy_document" "migracao_permite" {
statement {
effect = "Allow"
actions = ["secretsmanager:GetSecretValue"]
# Recurso especifico: o secret da migracao, nunca o da API. Nao ha "*" aqui
# porque a acao aceita ARN, e nao existe motivo para nao restringir.
resources = [aws_secretsmanager_secret.banco_migracao.arn]
}
}
resource "aws_iam_role_policy" "migracao" {
role = aws_iam_role.migracao.id
policy = data.aws_iam_policy_document.migracao_permite.json
}
# Metrica CUSTOM, publicada pelo pipeline (ver observabilidade.sh) apos cada
# consulta a pg_locks. Nao existe metrica pronta do AWS/RDS para "sessoes
# esperando bloqueio" — Performance Insights tem o dado, mas via API propria,
# nao como dimensao de alarme do CloudWatch. Publicar a metrica e o que torna
# possivel alarmar sobre ela.
resource "aws_cloudwatch_metric_alarm" "sessoes_bloqueadas" {
alarm_name = "${var.projeto}-sessoes-bloqueadas-por-ddl"
namespace = "FFV/Migracao"
metric_name = "SessoesEsperandoBloqueio"
statistic = "Maximum"
period = 30
evaluation_periods = 2
threshold = 5
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [aws_sns_topic.alertas.arn]
}
# ReplicaLag e metrica nativa do RDS PostgreSQL, em segundos, medida na
# instancia de REPLICA (L07). E a porta de avanco do backfill: lote demais e
# ela sobe antes de qualquer erro aparecer para o cliente.
resource "aws_cloudwatch_metric_alarm" "atraso_da_replica" {
alarm_name = "${var.projeto}-atraso-replica-backfill"
namespace = "AWS/RDS"
metric_name = "ReplicaLag"
statistic = "Maximum"
period = 60
evaluation_periods = 2
threshold = 10 # segundos; acima disso o backfill pausa (ver backfill.sh)
comparison_operator = "GreaterThanThreshold"
dimensions = {
DBInstanceIdentifier = aws_db_instance.replica.id
}
alarm_actions = [aws_sns_topic.alertas.arn]
}
O `*` que NÃO aparece aqui, de propósito
A política do papel de migração tem `Resource` específico — o ARN do secret dela, nada além disso. Diferente do `ecr:GetAuthorizationToken` do L03 (que é operação de conta e não aceita recurso), `secretsmanager:GetSecretValue` aceita ARN e não há motivo para não restringir. Regra prática: `*` só quando a AÇÃO não suporta recurso — nunca por economia de linha.
Construir: a migration aditiva, e a que é irreversível
A fase 1 e a fase 3 são as duas pontas da sequência, e é onde o mecanismo de bloqueio do PostgreSQL aparece com mais clareza: uma é catálogo puro, quase instantânea; a outra apaga dado de verdade.
// Migrations/20260807120000_Fase1ExpandirTotalCentavos.cs
// Fase 1 — EXPANDIR. So o que e aditivo e barato. A v1 continua no ar sem
// saber que isto rodou: nenhuma coluna virou obrigatoria, nada foi removido.
public partial class Fase1ExpandirTotalCentavos : Migration
{
protected override void Up(MigrationBuilder mb)
{
// SET LOCAL vale so para esta transacao: mais estrito que o padrao de
// 5 s da instancia (migration.tf), porque aqui preferimos a migration
// FALHAR rapido e ser repetida a formar fila atras dela.
mb.Sql("SET LOCAL lock_timeout = '3s';");
// Sem DEFAULT (ou com DEFAULT nao-volatil) e sem NOT NULL: e catalogo
// puro, ACCESS EXCLUSIVE por milissegundos. Um DEFAULT volatil aqui
// reescreveria a tabela inteira — e e exatamente o que este laboratorio
// ensina a NAO fazer numa tabela de 2,1 milhoes de linhas.
mb.AddColumn<long>(
name: "total_centavos",
table: "pedidos",
type: "bigint",
nullable: true);
// CREATE INDEX CONCURRENTLY nao pode rodar dentro de um bloco de
// transacao — e as migrations do EF Core sao executadas dentro de uma
// transacao por padrao. suppressTransaction: true tira ESTA operacao
// do BEGIN/COMMIT da migration, sem tirar a migration inteira. Custa
// duas varreduras da tabela em vez de uma, e nao bloqueia escrita.
mb.Sql(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_pedidos_total_centavos " +
"ON pedidos (total_centavos) WHERE total_centavos IS NOT NULL;",
suppressTransaction: true);
}
protected override void Down(MigrationBuilder mb)
{
mb.Sql("DROP INDEX CONCURRENTLY IF EXISTS ix_pedidos_total_centavos;",
suppressTransaction: true);
mb.DropColumn(name: "total_centavos", table: "pedidos");
}
}
A armadilha do EF Core com CONCURRENTLY
Uma migration do EF Core roda inteira dentro de uma transação, por padrão — e `CREATE INDEX CONCURRENTLY` recusa rodar dentro de transação nenhuma. Sem `suppressTransaction: true` no `Sql()`, o comando falha com um erro do PostgreSQL sobre bloco de transação, não com um erro do EF Core — o que confunde quem não sabe que o parâmetro existe. Isso custa uma migration inteira reescrita a quem descobre isso lendo o stack trace em produção.
// Migrations/20260814090000_Fase3ContrairValorTotal.cs
// Fase 3 — CONTRAIR. So roda depois de duas portas medidas: divergencia zero
// (backfill.sh) e nenhuma task da v1 no ar nem no plano de rollback do L03.
// A partir daqui, voltar para a v1 exige restauracao (L10), nao redeploy.
public partial class Fase3ContrairValorTotal : Migration
{
protected override void Up(MigrationBuilder mb)
{
mb.Sql("SET LOCAL lock_timeout = '3s';");
// NOT VALID nao escaneia a tabela: e so catalogo, ACCESS EXCLUSIVE por
// milissegundos. A validacao (que exige o scan) vira o passo seguinte,
// com um lock mais fraco.
mb.Sql(
"ALTER TABLE pedidos ADD CONSTRAINT total_centavos_not_null " +
"CHECK (total_centavos IS NOT NULL) NOT VALID;");
// VALIDATE CONSTRAINT pede SHARE UPDATE EXCLUSIVE, nao ACCESS
// EXCLUSIVE: escrita concorrente continua liberada enquanto ele varre
// a tabela inteira para confirmar que nenhuma linha viola a regra.
// E o motivo de nao ter feito isso com um NOT NULL direto na fase 1.
mb.Sql("ALTER TABLE pedidos VALIDATE CONSTRAINT total_centavos_not_null;");
// A operacao irreversivel da sequencia inteira. DROP COLUMN nao
// reescreve a tabela — e so catalogo — mas o dado fica inacessivel a
// partir daqui. Sem backup ou PITR (L10), nao ha volta.
mb.DropColumn(name: "valor_total", table: "pedidos");
}
// Sem Down(): contrair nao tem downgrade que devolva o dado. O caminho de
// volta desta fase e restauracao, nao migration reversa — e e por isso que
// a porta de avanco (divergencia zero, medida duas vezes) existe.
}
// Program.cs — o mesmo binario, dois caminhos: servir HTTP ou aplicar uma fase
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContextPool<AppDb>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("Padrao")));
// `--migrate <fase>` e o argumento que a task_definition.migracao passa
// (migration.tf). Fora deste caminho, o binario sobe o servidor HTTP normal —
// e essa e a garantia de que a migracao roda o MESMO codigo que atende trafego.
if (args.Contains("--migrate"))
{
var fase = args[Array.IndexOf(args, "--migrate") + 1];
using var app = builder.Build();
using var escopo = app.Services.CreateScope();
var db = escopo.ServiceProvider.GetRequiredService<AppDb>();
// Cada fase e uma migration nomeada do EF Core. Rodar `Migrate()` sem alvo
// aplicaria TODAS as pendentes de uma vez — o oposto do que este
// laboratorio ensina, que e uma fase por implantacao.
var migrador = db.GetInfrastructure().GetRequiredService<IMigrator>();
migrador.Migrate(fase); // ex.: "Fase1ExpandirTotalCentavos"
Console.WriteLine($"fase {fase} aplicada");
return; // a task termina aqui; nao ha `app.Run()` neste caminho
}
var app2 = builder.Build();
app2.MapHealthChecks("/health/live");
app2.MapHealthChecks("/health/ready");
app2.Run();
Construir: a v2 escreve nos dois lugares
A escrita dupla é o que torna o backfill convergente: sem ela, cada lote processado é imediatamente invalidado por uma escrita nova que só tocou a coluna antiga.
// PedidoService.cs — a v2 escreve nos dois lugares e le com recuo
public class PedidoService(AppDb db)
{
public async Task<Guid> CriarPedidoAsync(NovoPedidoDto dto, CancellationToken ct)
{
var centavos = (long)Math.Round(dto.ValorTotal * 100m, MidpointRounding.AwayFromZero);
var pedido = new Pedido
{
Id = Guid.NewGuid(),
// Escreve nos DOIS lugares. Enquanto a v1 conviver com a v2
// durante o rollout do L03, o dado tem de estar correto nos dois
// esquemas ao mesmo tempo — e sao consultas DIFERENTES que leem
// cada um.
ValorTotal = dto.ValorTotal,
TotalCentavos = centavos,
};
db.Pedidos.Add(pedido);
await db.SaveChangesAsync(ct);
return pedido.Id;
}
public async Task<decimal> ObterValorTotalAsync(Guid id, CancellationToken ct)
{
// Projecao explicita das DUAS colunas — nunca `SELECT *` / `FindAsync`
// sem projecao aqui. Se a v1 (que nao conhece total_centavos) ainda
// estiver mapeando a mesma entidade sem a coluna nova, um `SELECT *`
// corre o risco de o EF Core tentar materializar um campo que a
// versao antiga do modelo nao declara — e e exatamente esse acoplamento
// implicito que expand/contract existe para evitar.
var pedido = await db.Pedidos
.Where(x => x.Id == id)
.Select(x => new { x.TotalCentavos, x.ValorTotal })
.SingleAsync(ct);
// Le da coluna NOVA; recua para a antiga so enquanto o backfill nao
// convergiu. Depois da fase 3 (contrair), TotalCentavos nunca sera
// nulo, e este recuo vira codigo morto que a fase 3 seguinte remove.
return pedido.TotalCentavos.HasValue
? pedido.TotalCentavos.Value / 100m
: pedido.ValorTotal;
}
}
Por que `SELECT *` é a armadilha, com precisão
Não é sintaticamente proibido — é que ele acopla a consulta ao formato inteiro da tabela no momento em que ela roda. Uma versão antiga que não conhece `total_centavos` e uma consulta sem projeção explícita formam uma dependência implícita em "o schema não muda", que é exatamente o que este laboratório existe para quebrar. Projeção explícita — nomear as colunas que a consulta usa — é o que deixa a v1 indiferente à coluna nova.
Construir: o backfill, em lotes e de olho na réplica
Um `UPDATE` na tabela toda é uma transação longa: gera bloat proporcional ao total de linhas, escreve o WAL de uma vez e alonga o atraso da réplica do L07. Lotes pequenos com pausa trocam velocidade total por previsibilidade — e é a previsibilidade que este laboratório está comprando.
// Backfill.cs — task avulsa, mesma imagem, entrypoint `--backfill`. Roda uma
// vez, fora de horario de pico por escolha (nao por necessidade: e
// interrompivel e retomavel pelo cursor).
const int TamanhoDoLote = 5_000;
const int PausaEntreLotesMs = 200;
const double AtrasoMaximoDaReplicaSegundos = 10;
var cursor = Guid.Empty;
while (true)
{
// Checa a replica ANTES de cada lote, nao so no inicio: um lote grande
// alonga o atraso, e o PROXIMO lote precisa saber disso antes de rodar.
var atraso = await MedirAtrasoDaReplicaAsync(conexao);
if (atraso > TimeSpan.FromSeconds(AtrasoMaximoDaReplicaSegundos))
{
log.LogWarning("atraso de replica em {Atraso}s, acima do limite; pausando 10s", atraso.TotalSeconds);
await Task.Delay(TimeSpan.FromSeconds(10));
continue;
}
// UPDATE por LOTE, nunca na tabela toda: um lote de 5 mil linhas e uma
// transacao curta, gera bloat proporcional ao lote (nao ao total) e
// escreve WAL em pedacos que a replica absorve entre um lote e outro.
var afetadas = await conexao.ExecuteAsync(
"""
UPDATE pedidos SET total_centavos = ROUND(valor_total * 100)
WHERE id IN (
SELECT id FROM pedidos
WHERE total_centavos IS NULL AND id > @cursor
ORDER BY id
LIMIT @tamanho
)
RETURNING id
""",
new { cursor, tamanho = TamanhoDoLote });
if (afetadas == 0)
break; // convergiu: nenhuma linha com total_centavos nula acima do cursor
cursor = await ObterMaiorIdDoLoteAsync(conexao, cursor, TamanhoDoLote);
log.LogInformation("lote aplicado: {Linhas} linhas, cursor em {Cursor}", afetadas, cursor);
// A pausa da tempo ao autovacuum de processar as versoes mortas do lote
// anterior antes do proximo UPDATE gerar mais. Sem pausa, o bloat cresce
// mais rapido do que o autovacuum consegue reciclar.
await Task.Delay(PausaEntreLotesMs);
}
// pg_stat_replication e visao do lado do PRIMARIO: mostra, por replica
// conectada, o atraso de reproducao em tempo real. Roda na conexao com a
// instancia principal, nunca na replica.
static async Task<TimeSpan> MedirAtrasoDaReplicaAsync(NpgsqlConnection conexao) =>
TimeSpan.FromSeconds(await conexao.ExecuteScalarAsync<double>(
"SELECT EXTRACT(EPOCH FROM replay_lag) FROM pg_stat_replication LIMIT 1;"));
| Parâmetro | Valor aqui | Por quê |
|---|---|---|
| Tamanho do lote | 5.000 linhas | grande o bastante para terminar em minutos; pequeno o bastante para não competir com o WAL de escrita real |
| Pausa entre lotes | 200 ms | dá tempo ao autovacuum de reciclar as versões mortas do lote anterior |
| Teto de atraso de réplica | 10 s | acima disso, uma leitura na réplica do L07 devolve dado desatualizado com folga perceptível |
| Cursor | por chave primária, não por offset | interrompível e retomável sem reprocessar linha já convergida, mesmo se a task morrer no meio |
Cursor por `OFFSET` é o erro que só aparece em tabela grande
Um cursor por `OFFSET` reprocessa do início se a task morrer no meio, porque o `OFFSET` conta posição, não identidade — e cada `UPDATE` de lote muda quais linhas ainda satisfazem o filtro. Cursor por chave primária retoma exatamente de onde parou, porque a posição está no dado, não numa contagem que o próprio backfill invalida a cada lote.
Implantar as quatro fases, e provar com número
#!/usr/bin/env bash
# implantar-fases.sh — quatro implantacoes, cada uma so avanca com a anterior confirmada
set -euo pipefail
PROJETO="${PROJETO:?defina PROJETO}"
REGIAO="${REGIAO:-us-east-1}"
SHA="$(git describe --always --dirty --match='' --abbrev=12)"
rodar_fase() {
local fase="$1"
echo "== fase ${fase}: run-task =="
TASK_ARN=$(aws ecs run-task --cluster "$PROJETO" \
--task-definition "${PROJETO}-migracao" \
--launch-type FARGATE \
--overrides "{\"containerOverrides\":[{\"name\":\"migracao\",\"command\":[\"dotnet\",\"Api.dll\",\"--migrate\",\"${fase}\"]}]}" \
--network-configuration "awsvpcConfiguration={subnets=[$PRIVADA_A],securityGroups=[$SG_MIGRACAO]}" \
--query 'tasks[0].taskArn' --output text)
aws ecs wait tasks-stopped --cluster "$PROJETO" --tasks "$TASK_ARN"
# Exit code != 0 e falha silenciosa de DDL se ninguem checar. O `wait` acima
# so espera a task PARAR — nao diz se ela terminou bem.
EXIT_CODE=$(aws ecs describe-tasks --cluster "$PROJETO" --tasks "$TASK_ARN" \
--query 'tasks[0].containers[0].exitCode' --output text)
[[ "$EXIT_CODE" == "0" ]] || { echo "fase ${fase} falhou (exit ${EXIT_CODE})" >&2; exit 1; }
}
# ── Fase 1: expandir ──────────────────────────────────────────────────────────
rodar_fase "Fase1ExpandirTotalCentavos"
# ── Implantação 2: a v2 sobe com escrita dupla, sem parar a v1 (rolling do L03) ─
terraform apply -var="sha_da_imagem=${SHA}" -auto-approve
aws ecs wait services-stable --cluster "$PROJETO" --services "${PROJETO}-api"
# ── Backfill, fora do pipeline de deploy — roda uma vez, pode ser retomado ────
aws ecs run-task --cluster "$PROJETO" --task-definition "${PROJETO}-backfill" \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[$PRIVADA_A],securityGroups=[$SG_MIGRACAO]}"
# ── Porta de avanço: divergência zero, medida DUAS vezes com 5 min de intervalo ─
medir_divergencia() {
psql "$DATABASE_URL_SOMENTE_LEITURA" -tAc \
"SELECT count(*) FROM pedidos WHERE total_centavos IS NULL OR total_centavos <> ROUND(valor_total * 100);"
}
D1=$(medir_divergencia); sleep 300; D2=$(medir_divergencia)
if [[ "$D1" != "0" || "$D2" != "0" ]]; then
echo "divergência não convergiu (D1=${D1} D2=${D2}); NÃO prossiga para a implantação 3" >&2
exit 1
fi
# ── Implantação 3: a v3 para de escrever no antigo ────────────────────────────
terraform apply -var="sha_da_imagem=${SHA}-v3" -auto-approve
aws ecs wait services-stable --cluster "$PROJETO" --services "${PROJETO}-api"
# ── Confirma que a v1 já não é mais alvo de rollback antes de contrair ────────
REVISAO_ATIVA=$(aws ecs describe-services --cluster "$PROJETO" --services "${PROJETO}-api" \
--query 'services[0].taskDefinition' --output text)
echo "revisão ativa: ${REVISAO_ATIVA} — confirme manualmente que não é mais a v1 antes de continuar"
read -r -p "prosseguir para a fase 3 (contrair)? [s/N] " CONFIRMA
[[ "$CONFIRMA" == "s" ]] || { echo "abortado antes da operação irreversível"; exit 1; }
# ── Fase 3: contrair — irreversível a partir daqui ────────────────────────────
rodar_fase "Fase3ContrairValorTotal"
echo "as quatro implantações concluíram"
Cinco provas. Nenhuma aceita "parece que funcionou" como resultado — a prova 2 é a que mais gente pula, e é a que mostra o mecanismo central do módulo em vez de descrevê-lo.
# provas.sh — cinco medições; nenhuma conclusão vem de "parece que funcionou"
PROJETO=ffv-lab; REGIAO=us-east-1
# ── Prova 1: zero erro do cliente durante as quatro implantações ─────────────
URL="https://$(terraform output -raw dominio)/api/pedidos/health"
( while true; do curl -s -o /dev/null -w '%{http_code} ' "$URL"; sleep 0.2; done ) & LACO=$!
./implantar-fases.sh
kill $LACO
# Medido na Cadência: 14.208 requisições, 0 diferente de 200, ao longo das
# quatro implantações e do backfill. Qualquer não-200 aqui reprova o rollout.
# ── Prova 2: a fila de bloqueio, observada — não decorada ────────────────────
# Abra uma transação de relatório de propósito, dispare um ALTER concorrente, e
# consulte quem espera quem. É a prova do mecanismo central deste módulo.
psql "$DATABASE_URL" -c "BEGIN; SELECT * FROM pedidos LIMIT 1;" & # segura ACCESS SHARE, sem COMMIT
psql "$DATABASE_URL" -c "ALTER TABLE pedidos ADD COLUMN teste_prova int;" &
psql "$DATABASE_URL" -c "
SELECT blocked.pid AS bloqueado, blocking.pid AS bloqueador, blocked.query AS espera
FROM pg_locks blocked_locks
JOIN pg_stat_activity blocked ON blocked.pid = blocked_locks.pid
JOIN pg_locks blocking_locks ON blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.relation = blocked_locks.relation AND blocking_locks.pid != blocked_locks.pid
JOIN pg_stat_activity blocking ON blocking.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;"
# Esperado: uma linha mostrando o ALTER como bloqueado pela transação de
# relatório — a prova de que a fila, não o comando, é a causa da espera.
# ── Prova 3: lock_timeout defende da fila, e o erro é o esperado ─────────────
psql "$DATABASE_URL" -c "SET lock_timeout = '3s'; ALTER TABLE pedidos ADD COLUMN teste_prova2 int;"
# Com a transação de relatório ainda aberta: falha em ~3s com SQLSTATE 55P03
# ("lock_not_available"), não trava indefinidamente. Reverta a transação de
# relatório com ROLLBACK antes de seguir.
# ── Prova 4: um CREATE INDEX CONCURRENTLY interrompido vira índice INVALID ───
psql "$DATABASE_URL" -c "SELECT indexrelid::regclass, indisvalid FROM pg_index WHERE NOT indisvalid;"
# Esperado em operação normal: zero linhas. Uma linha aqui é índice que falhou
# no meio — consome espaço e overhead de escrita sem servir consulta nenhuma;
# a correção é DROP INDEX e repetir o CONCURRENTLY.
# ── Prova 5: o tamanho do lote muda o pico de atraso da réplica ──────────────
# Repita o backfill em ambiente de teste com TamanhoDoLote = 50000 (sem pausa) e
# depois com 5000 + 200 ms de pausa, medindo o pico de ReplicaLag em cada corrida.
# Medido na Cadência: pico de 41 s de atraso com lote de 50 mil sem pausa,
# contra pico de 2 s com lote de 5 mil e pausa — quase 20× de diferença para o
# mesmo total de linhas processadas.
aws cloudwatch get-metric-statistics --namespace AWS/RDS --metric-name ReplicaLag \
--dimensions Name=DBInstanceIdentifier,Value="${PROJETO}-replica" \
--start-time "$(date -u -d '-1 hour' +%FT%TZ)" --end-time "$(date -u +%FT%TZ)" \
--period 60 --statistics Maximum --region "$REGIAO"
| Prova | Comando | Resultado que aprova | O que reprova, e o que significa |
|---|---|---|---|
| 1 · Cliente não vê erro nas 4 implantações | laço de `curl` durante o script inteiro | fileira contínua de 200 (medido: 14.208 requisições, 0 erro) | qualquer não-200 indica que alguma fase quebrou compatibilidade para trás |
| 2 · A fila de bloqueio, observada | consulta em `pg_locks` com transação de relatório aberta de propósito | o `ALTER` aparece como bloqueado pela transação | se nenhuma linha aparece, a transação de teste não estava realmente segurando o lock certo |
| 3 · lock_timeout aborta em vez de esperar | `ALTER` com `lock_timeout=3s` e a mesma transação aberta | falha em ~3s com SQLSTATE 55P03 | se o comando ficar pendurado além de 3s, o parâmetro não foi aplicado na sessão |
| 4 · Índice CONCURRENTLY íntegro | `SELECT ... FROM pg_index WHERE NOT indisvalid` | zero linhas | uma linha é índice que falhou no meio — DROP e repita o CONCURRENTLY |
| 5 · O lote certo protege a réplica | comparar pico de `ReplicaLag` entre lote de 50 mil e de 5 mil | pico visivelmente menor com lote pequeno e pausa (medido: 41s vs. 2s) | se o pico não cai, o gargalo pode ser outro — CPU da réplica, não o tamanho do lote |
Quebrar de propósito: três falhas e o diagnóstico
As três acontecem de verdade, e a primeira é a que mais equipe reproduz sem querer, porque o sintoma demora a aparecer.
| Falha | Como provocar | Sintoma | Onde olhar | Correção |
|---|---|---|---|---|
| Escrita dupla esquecida | implante a v2 escrevendo só em `total_centavos`, sem tocar `valor_total` | o backfill parece convergir e depois diverge de novo, sem padrão aparente | a métrica de divergência oscila em vez de cair a zero e ficar | corrigir a v2 para escrever nas duas colunas; reiniciar o backfill do cursor |
| lock_timeout ausente na sessão da migração | rode a fase 1 sem `SET LOCAL lock_timeout`, com uma transação de relatório aberta | a migration fica pendurada, e minutos depois o time inteiro vê 500 no cliente | `pg_locks` mostra o ALTER esperando, e a lista de sessões bloqueadas atrás dele cresce | aplicar o timeout no parameter group e por sessão; nunca deixar como opcional |
| Contração antes da hora | rode a Fase 3 logo após o backfill zerar uma única vez, sem confirmar a v1 fora de cena | a v1 (ou o disjuntor de rollback do L03) responde 500 ao tentar ler a coluna apagada | log da task antiga citando `column "valor_total" does not exist` | restaurar via PITR (L10); a partir daqui não há migration reversa que resolva |
A falha que a fila de bloqueio produz é indisponibilidade total, não parcial
Diferente de uma task individual falhando, uma transação segurando o lock que o `ALTER` espera derruba a leitura de TODA a aplicação ao mesmo tempo — inclusive das tasks que não têm nada a ver com o deploy. Numa loja com movimento real, cada minuto de fila é pedido não fechado. É por isso que `lock_timeout` não é uma otimização opcional neste módulo: é a diferença entre um incidente de segundos, visível e diagnosticável, e um de minutos que parece aleatório.
Uma tabela `pedidos` recebe `ALTER TABLE pedidos ADD COLUMN total_centavos bigint;` — sem valor padrão, e isolado o comando executa em cerca de 5 ms. Em produção, essa mesma migration deixou a aplicação respondendo 500 por quatro minutos. Qual é a explicação mais provável?
Segurança: o que muda quando o schema também tem pipeline
A migração ganha um caminho próprio de execução, e isso desloca o risco: a superfície deixa de ser só "quem pode subir código" e passa a incluir "quem pode alterar o formato dos dados de todo mundo".
| Risco | Probabilidade | Impacto | Prevenção | Detecção | Resposta |
|---|---|---|---|---|---|
| Papel da task de migração com privilégio amplo demais no banco | média | alto | papel do PostgreSQL específico para migração, sem `DROP DATABASE` nem superusuário | auditoria de `GRANT`/`REVOKE` e uso real do papel | revogar e recriar o papel com o mínimo medido |
| Credencial da migração compartilhada com a da API | baixa | alto | secret dedicado (`banco_migracao`), nunca reaproveitar o `ConnectionStrings__Padrao` do L03 | CloudTrail: dois ARNs de secret distintos, cada um usado só pelo papel esperado | rotacionar os dois secrets separadamente |
| Fila de bloqueio derruba a leitura para todos os clientes ao mesmo tempo | média | alto | `lock_timeout` como padrão da instância, nunca opcional por sessão | alarme na métrica customizada de sessões bloqueadas, mais `log_lock_waits` | abortar a sessão ofensora (`pg_terminate_backend`); investigar de onde veio a transação longa |
| Coluna antiga apagada com dado não migrado | baixa, se a porta de avanço existir | alto e irreversível | fase 3 exige divergência zero medida duas vezes, e v1 fora do plano de rollback | contagem de divergência antes de cada fase, publicada como métrica | restaurar via PITR (L10) para o ponto antes do DROP |
| Task de migração disparada fora do pipeline | baixa | médio | orquestração via `implantar-fases.sh`, nunca `psql` interativo direto em produção | CloudTrail em `RunTask` para a task definition de migração, fora da execução esperada | interromper a task; nenhuma fase seguinte avança sem a anterior confirmada |
| Segredo do banco vazando em log da aplicação | baixa | alto | connection string por Secrets Manager, nunca logada; log estruturado sem parâmetro cru | busca por padrão de credencial no grupo de logs de migração | rotacionar o secret e corrigir o ponto de log |
Observabilidade: as perguntas que o painel tem de responder
Um painel de migração tem uma função estreita, e diferente da do L03: dizer se é seguro AVANÇAR para a próxima fase — não só se o deploy está saudável.
| Pergunta | Métrica ou consulta | O que significa mudar | Limiar inicial |
|---|---|---|---|
| Existe sessão esperando bloqueio agora? | métrica customizada `FFV/Migracao` a partir de `pg_locks` | fila se formando antes de virar incidente visível | > 0 sustentado por mais que o lock_timeout |
| A réplica está acompanhando o backfill? | `ReplicaLag` (CloudWatch, RDS) | lote grande demais para o volume de WAL gerado | > 10 s |
| A divergência entre as colunas está caindo? | contagem customizada de linhas divergentes | convergência real do backfill, não estimativa | deve tender a zero; estagnado por 30 min é alerta |
| A fase atual do pipeline avançou? | log do orquestrador / estado da execução | fase presa esperando uma porta que não abre | > 15 min numa fase é alerta |
| O cliente viu erro durante alguma implantação? | `HTTPCode_Target_5XX_Count` (herdado do L03) | versão nova ou schema incompatível | qualquer valor > 0 durante uma fase |
| A task de migração terminou com sucesso? | exit code da task ECS avulsa | falha silenciosa de DDL que o `wait` sozinho não captura | exit code != 0 |
| Alguém rodou DDL fora do pipeline? | CloudTrail em `RunTask` fora da lista esperada | mudança de schema não auditada | qualquer identidade fora da esperada |
A métrica que só existe porque alguém a publicou
Diferente do `ReplicaLag`, não existe métrica nativa do CloudWatch para "sessões esperando bloqueio" — Performance Insights tem o dado, mas via consulta própria, não como dimensão de alarme padrão. A métrica customizada em `FFV/Migracao` existe porque o pipeline a publica a cada fase; sem essa publicação, a única forma de saber é abrir o `psql` e perguntar, o que ninguém faz durante um incidente às 3 da manhã.
Escala: 10, 10 mil, 1 milhão de linhas
| Volume | O que acontece com a migração | O que passa a doer | O que fazer |
|---|---|---|---|
| 10 linhas | expandir, backfill e contrair terminam em segundos | nada; até migration única destrutiva passaria despercebida | nada |
| 10 mil linhas | backfill em 2 a 3 lotes; atraso de réplica desprezível | nada ainda, mas é onde a disciplina de lotes começa a valer a pena | manter o padrão de lotes mesmo sem necessidade real, para não reaprender sob pressão |
| 1 milhão de linhas | backfill em ~200 lotes; dezenas de minutos de execução total | o atraso de réplica passa a ser mensurável, não mais teórico | monitorar `ReplicaLag` durante toda a execução, não só no fim |
| 2,1 milhões de linhas (o caso da Cadência) | ~430 lotes; a execução compete com o horário comercial | a janela de execução do backfill precisa ser escolhida, mesmo sem exigir parada | rodar fora do pico por escolha operacional, com o teto de atraso como guarda-corpo |
| Pico de tráfego durante o backfill | escrita real e backfill competem pelo mesmo WAL | o atraso de réplica sobe mais rápido que o esperado pelo volume do lote sozinho | pausar o backfill automaticamente quando o alarme de `ReplicaLag` disparar |
| Falha de AZ durante uma fase | a réplica pode ser promovida a primária no meio do backfill | a conexão do worker de backfill cai, e o cursor precisa sobreviver ao reinício | cursor persistido fora da memória do processo; o worker retoma do último lote confirmado |
O que não muda com a escala: a mecânica do lock
Uma tabela de 10 linhas e uma de 1 milhão pedem o MESMO modo de lock para o mesmo comando — a diferença de escala está em quanto tempo esse lock fica retido e em quantos lotes o backfill precisa. O gerenciador de locks do PostgreSQL não tem um modo "cauteloso" para tabela grande; a defesa continua sendo `lock_timeout`, do mesmo jeito, em qualquer volume.
Custo: o que este laboratório acrescenta à fatura
É barato em recurso novo — quase tudo aqui é orquestração e disciplina, não infraestrutura adicional. O custo real que ele evita não aparece na fatura da AWS.
| Cenário | Volume | O que acrescenta | Tendência | Otimização |
|---|---|---|---|---|
| Protótipo | migration mensal, tabela pequena | minutos de Fargate para a task de migração | desprezível | nenhuma; otimizar aqui é gastar atenção onde não há dinheiro |
| Produção pequena | migration quinzenal, tabela de centenas de milhares de linhas | minutos de Fargate por fase, mais o backfill rodando por dezenas de minutos | baixa e previsível | nenhuma ação especial — o padrão de lotes já é eficiente |
| Alta escala | migration em tabela de dezenas de milhões de linhas | o backfill pode levar horas, e cada hora é tempo de engenharia acompanhando | o tempo de execução vira o item que mais pesa, não o Fargate | considerar ferramenta de reescrita online (pg_squeeze/pg_repack) — ver evolução em níveis |
| Dimensão | Cobra por | Cuidado |
|---|---|---|
| Fargate da task de migração | vCPU-segundo e GB-segundo | minutos por fase; desprezível na maioria dos casos |
| Fargate do backfill | vCPU-segundo e GB-segundo | proporcional ao número de lotes, não ao total de linhas isoladamente |
| Armazenamento inflado (bloat) | GB-mês do volume do RDS | lote grande sem pausa cria mais versões mortas do que o autovacuum recicla a tempo |
| CloudWatch Logs da migração | GB ingerido e retido | log de cada fase; retenção curta em ambiente de teste |
| Métrica customizada e alarmes | por métrica e por alarme-mês | valor pequeno e fixo; não é onde se economiza |
O custo que este módulo evita não está em nenhuma linha da AWS
Quatro minutos de indisponibilidade numa loja com movimento real é pedido não fechado e cliente que não volta na hora. Esse custo não aparece na fatura de infraestrutura — aparece na receita, e é maior do que qualquer economia de Fargate discutida nesta seção.
Well-Architected nos seis pilares
| Pilar | Situação ao fim deste laboratório | Risco que fica | Melhoria | Prioridade |
|---|---|---|---|---|
| Excelência operacional | migração roteirizada, com porta de avanço medida e prova de convergência | a confirmação de "v1 fora de cena" antes da contração ainda é manual | checagem automática de qual revisão está ativa antes de liberar a fase 3 | alta |
| Segurança | papel de banco e credencial próprios para a migração, separados da API | a política do papel de PostgreSQL em si não é revisada automaticamente | auditoria periódica de `GRANT` com Access Analyzer equivalente (L41) | média |
| Confiabilidade | compatibilidade para frente garantida em cada fase; rollback preservado até a contração | a contração continua irreversível por natureza — nenhuma automação remove esse risco | ensaiar restauração por PITR (L10) antes de qualquer fase 3 real | alta |
| Eficiência de performance | backfill em lotes dimensionado pelo atraso de réplica medido | o tamanho do lote é fixo; não se adapta ao tráfego do momento | lote dinâmico por métrica de `ReplicaLag` em tempo real | média |
| Otimização de custos | sem recurso novo além de minutos de Fargate por fase | tabelas muito maiores tornariam o backfill um custo de tempo de engenharia relevante | avaliar ferramenta de reescrita online para tabelas acima de dezenas de milhões de linhas | baixa |
| Sustentabilidade | lotes pequenos reduzem bloat e o trabalho extra do autovacuum | migração feita errado (lote único) desperdiça I/O e reprocessamento | o padrão de lotes já cobre; reforçar em revisão de código | baixa |
Evolução em níveis: o que muda, e o que passa a doer
A terceira arquitetura não é um desenho: é a resposta a QUANDO trocar de desenho. Cada nível resolve um risco e compra outro.
Migration na partida do contêiner (o L01), sem tempos-limite explícitos. Legítimo em ambiente de desenvolvimento, com tabela pequena e uma task só.Task de migração fora do arranque, `lock_timeout`/`statement_timeout` como padrão, expand/contract em três a quatro fases, backfill em lotes.As fases entram no mesmo pipeline com credencial federada (L54), com porta de avanço automatizada e aprovação humana só na contração.Quando a mudança exige reescrever a tabela inteira (não só adicionar coluna), ferramentas como `pg_squeeze`/`pg_repack` ou replicação lógica evitam o `ACCESS EXCLUSIVE` prolongado que um `ALTER` de reescrita pediria.Revisão obrigatória de migration em CI: um linter recusa `DROP COLUMN` e `ALTER ... NOT NULL` direto sem a marcação de fase, e a contração exige aprovação de duas pessoas.O histórico de fases, tempos de fila observados e picos de `ReplicaLag` por migração vira dado consultável, para prever o tamanho de lote e a janela mais segura da PRÓXIMA migração parecida.A ordem não é negociável, e o motivo é concreto
Reescrita online (nível 4) só faz sentido quando o padrão do nível 2 — fases pequenas, aditivas, com porta de avanço medida — já é hábito da equipe. Quem tenta a ferramenta externa sem ter internalizado por que a ordem entre fases importa monta a mecânica sem entender quando ela é necessária, e usa reescrita online para o caso simples de `ADD COLUMN` que nunca precisou dela.
Onde IA entra, e onde não entra
O mecanismo central deste laboratório — qual lock cada forma de `ALTER TABLE` pede, e como o gerenciador de locks do PostgreSQL ordena a fila — é comportamento determinístico e documentado, não um problema de previsão. IA não decide se um `ADD COLUMN` reescreve a tabela; a resposta está fixa na documentação, e é isso que este módulo ensina a ler.
Onde IA poderia agregar, e por que este laboratório não a usa
Uma tarefa adjacente e real é revisar o DIFF de uma migration antes do merge, sinalizando operações potencialmente destrutivas — `DROP COLUMN`, `ALTER ... SET NOT NULL` sem `NOT VALID`, renomeação sem as quatro fases — antes que cheguem a produção. Isso é um classificador de padrão sobre texto, com dado de entrada claro (o diff) e um fallback óbvio (revisão humana, sempre, antes de aplicar em produção). Nenhum laboratório deste catálogo cobre isso ainda; forçar a peça aqui, num módulo cujo problema real é mecânica de lock, seria o hype que esta escola existe para não vender.
Anti-padrões
| Antipadrão | Por que alguém faz | Sintoma em produção | Forma correta |
|---|---|---|---|
| Migration na partida do contêiner (o L01) | é uma linha, e funciona em desenvolvimento com uma task e tabela pequena | corrida entre tasks concorrentes do rolling update, travando a tabela | task de migração fora do arranque, uma fase por vez (fases 1 e 3 deste módulo) |
| `UPDATE` único na tabela inteira para o backfill | parece mais simples do que escrever um laço de lotes | bloat proporcional ao total de linhas, WAL disparado e réplica atrasando minutos | lotes de milhares de linhas com pausa, monitorando `ReplicaLag` |
| Coluna `NOT NULL DEFAULT` direto na criação | "já economiza uma etapa depois" — parece produtivo | ACCESS EXCLUSIVE com varredura ou reescrita da tabela inteira, travando escrita | coluna anulável na fase 1; `CHECK NOT VALID` + `VALIDATE CONSTRAINT` na fase 3 |
| Apagar a coluna antiga assim que o backfill "parece" ter terminado | pressa de limpar um schema que ficou temporariamente feio | 500 na versão que ainda lê a coluna, incluindo o alvo de rollback do L03 | medir divergência zero duas vezes, e confirmar a v1 fora do plano de rollback antes |
| `CREATE INDEX` sem `CONCURRENTLY` em tabela de produção | é a forma "padrão" que todo tutorial mostra primeiro | escrita bloqueada pelo tempo de construção do índice inteiro | `CONCURRENTLY`, fora de transação, aceitando o custo de duas varreduras |
| Ignorar `lock_timeout` porque "nunca deu problema antes" | ausência de incidente prévio é lida como ausência de risco | incidente de fila de bloqueio descoberto só quando o cliente liga | tempos-limite como padrão da instância, não como configuração ad hoc |
Quando algo não funciona
| Sintoma | Causa provável | Como investigar | Log ou métrica | Correção |
|---|---|---|---|---|
| Pipeline trava na fase 1, o `ALTER` nunca conclui | transação longa (relatório, sessão de `psql` esquecida) segurando um lock conflitante | `pg_locks` junto com `pg_stat_activity`, pelo pid bloqueador | alarme customizado de sessões bloqueadas | encerrar a transação ofensora, ou deixar o `lock_timeout` abortar a migration e repetir |
| 500 na v1 durante ou depois da fase 3 | coluna antiga apagada com a v1 ainda no ar ou como destino de rollback | `describe-services` pela revisão ativa; log da v1 citando a coluna ausente | `HTTPCode_Target_5XX_Count` do alvo antigo | restaurar via PITR (L10) — não há migration reversa que traga o dado de volta |
| Backfill nunca zera a divergência | a v2 não está escrevendo nos dois lugares (escrita dupla ausente) | revisar o código de escrita da v2; a métrica de divergência oscila em vez de cair | métrica de divergência plana ou instável ao longo do tempo | corrigir a escrita dupla; reiniciar o backfill a partir do cursor salvo |
| `ReplicaLag` dispara durante o backfill | lote grande demais para o volume de WAL que a réplica absorve por segundo | comparar o pico de `ReplicaLag` com o tamanho do lote em uso | `ReplicaLag` (CloudWatch, RDS) | reduzir o tamanho do lote e aumentar a pausa entre eles |
| Índice novo não é usado mesmo depois de `CREATE INDEX CONCURRENTLY` | o comando falhou no meio e deixou um índice `INVALID` | `SELECT ... FROM pg_index WHERE NOT indisvalid` | log da migration task com erro de deadlock ou violação de unicidade | `DROP INDEX` do índice inválido e repetir o `CONCURRENTLY` |
| CI acusa "pending model changes" depois da fase 1 | o modelo do EF Core (Fluent API) não foi atualizado junto com a migration | `dotnet ef migrations has-pending-model-changes` | falha do passo de build/teste no pipeline | atualizar o modelo na MESMA migration, nunca numa posterior |
Não reproduza a fila de bloqueio direto em produção
As falhas desta seção e da seção anterior são seguras de reproduzir num ambiente de teste com volume de dados equivalente. Testar `lock_timeout` segurando um lock de propósito em produção é o mesmo incidente que o módulo inteiro ensina a evitar — a diferença entre ensaio e incidente real é só a intenção de quem abriu a transação.
Limpeza
#!/usr/bin/env bash
# limpar.sh — ordem exata; o que o destroy NAO leva está na tabela ao lado
set -euo pipefail
PROJETO="${PROJETO:?defina PROJETO}"
# 1. Nenhuma task avulsa deve estar rodando (migração ou backfill).
aws ecs list-tasks --cluster "$PROJETO" --family "${PROJETO}-migracao" --query 'taskArns' --output text
aws ecs list-tasks --cluster "$PROJETO" --family "${PROJETO}-backfill" --query 'taskArns' --output text
# 2. O parameter group só pode ser removido depois da instância RDS.
terraform destroy -target=aws_db_parameter_group.postgres -target=aws_db_instance.principal \
-target=aws_db_instance.replica 2>/dev/null || true
# 3. O restante do Terraform, na ordem inversa de dependência.
terraform destroy -auto-approve
# 4. O que sobrevive ao destroy e cobra parado — confira manualmente.
aws logs describe-log-groups --log-group-name-prefix "/ecs/${PROJETO}-migracao" \
--query 'logGroups[].logGroupName' --output text
| Recurso | O `destroy` remove? | Cuidado |
|---|---|---|
| Task de migração / backfill em execução | não — precisa estar parada antes | confirme com `list-tasks` antes de destruir a infraestrutura de rede sob ela |
| Parameter group do RDS | só se nenhuma instância o estiver usando | destrua a instância e a réplica antes, ou o Terraform falha com dependência pendente |
| Log group da migração | sim, mas a retenção configurada pode manter dados por dias | confira `describe-log-groups` se a política de retenção não estiver curta em teste |
| Snapshot ou backup usado num ensaio de PITR | não | snapshot manual do RDS sobrevive ao `destroy` e continua sendo cobrado |
O que o `destroy` não desfaz de jeito nenhum
Se você chegou a rodar a fase 3 (contração) num ambiente de ensaio, os dados da coluna antiga já foram apagados antes de qualquer `terraform destroy`. Limpar a infraestrutura não devolve esse dado — só um `pg_dump` anterior ou uma restauração por PITR (L10) devolveriam, e nenhum dos dois é automático.
Resumo
| Problema | Peça | Motivo |
|---|---|---|
| Migration destrutiva quebra a versão velha durante o rollout do L03 | expand/contract em fases | o schema serve as duas versões ao mesmo tempo, em vez de exigir que concordem |
| `ALTER TABLE` pode esperar indefinidamente por um lock | `lock_timeout` como padrão da instância | troca espera indefinida por falha rápida e repetível |
| `CREATE INDEX` padrão bloqueia escrita durante a construção | `CREATE INDEX CONCURRENTLY` | não bloqueia escrita, ao custo de duas varreduras e de rodar fora de transação |
| `UPDATE` na tabela toda gera bloat e atrasa a réplica | backfill em lotes, com pausa e teto de atraso | mantém o WAL e o atraso da réplica dentro do tolerável |
| Apagar a coluna antiga cedo demais quebra quem ainda a lê | porta de avanço com divergência zero medida duas vezes | só contrai depois de provar que ninguém depende mais do estado antigo |
| Falha | Proteção |
|---|---|
| Transação longa segurando o lock que o `ALTER` espera | `lock_timeout` aborta em vez de deixar a fila crescer |
| `CREATE INDEX CONCURRENTLY` interrompido no meio | checagem de `pg_index.indisvalid` antes de confiar no índice |
| Escrita dupla esquecida na v2 | métrica de divergência acusa antes de a contração ser tentada |
| Contração antes da hora | confirmação manual de que a v1 não é mais alvo de rollback |
- Fase 1: expandir — coluna nova anulável e índice CONCURRENTLY, sem tocar código.
- Implantação 2: a v2 sobe escrevendo nos dois lugares, sem parar a v1 (rolling do L03).
- Backfill: lotes de 5 mil linhas, com pausa, respeitando o atraso da réplica do L07.
- Porta de avanço: divergência zero medida duas vezes, com 5 minutos de intervalo.
- Implantação 3: a v3 para de escrever na coluna antiga.
- Confirmação: nenhuma task da v1 no ar, e ela não é mais alvo do disjuntor de rollback.
- Fase 3: contrair — `CHECK NOT VALID` + `VALIDATE CONSTRAINT`, depois `DROP COLUMN`.
- Prova: laço de requisições sem erro do início ao fim das quatro implantações.
Perguntas frequentes
❓ Por que uma migration de 5 ms consegue derrubar a produção por minutos?
❓ `ADD COLUMN` sempre trava a tabela inteira no PostgreSQL?
❓ Preciso de `CREATE INDEX CONCURRENTLY` mesmo numa tabela pequena?
❓ Por que renomear uma coluna exige quatro implantações e não uma?
❓ `lock_timeout` e `statement_timeout` são a mesma coisa?
❓ Posso rodar o backfill inteiro de uma vez, já que ele roda fora do horário de pico?
❓ O EF Core não resolve migration sem downtime sozinho?
❓ O que acontece se eu apagar a coluna antiga antes de terminar o backfill?
Fixando
No caso da Cadência, por que apagar a coluna `valor_total` (a fase de contração) não pode acontecer logo depois de a v2 começar a escrever em `total_centavos`?
Por que a migration da fase 1 precisa rodar `CREATE INDEX CONCURRENTLY` como SQL cru com `suppressTransaction: true`, em vez de usar `migrationBuilder.CreateIndex(...)` normal do EF Core?
Conhecimentos, próximo módulo e documentação
| Item | Conteúdo |
|---|---|
| Conhecimentos anteriores necessários | L03 (rollout sem parar, disjuntor de implantação) e L07 (réplica de leitura, pool) no ar; EF Core; SQL básico de PostgreSQL |
| Conhecimentos adquiridos | níveis de bloqueio do `ALTER TABLE`; a fila de bloqueio como causa real de indisponibilidade; `lock_timeout`/`statement_timeout`; `CREATE INDEX CONCURRENTLY` e sua incompatibilidade com transação; as fases de expand/contract e por que a ordem entre elas não é negociável; backfill em lotes limitado pelo atraso de réplica |
| Limitação que fica | a confirmação de que a v1 saiu de cena antes da contração ainda é um passo manual no script; e tabelas muito maiores que a da Cadência exigiriam ferramenta de reescrita online, fora do escopo deste módulo |
| Próximo exemplo recomendado | L39 — blue/green e canário no ECS. Ele pressupõe schema já compatível para trás entre versões, que é exatamente o que expand/contract garante |
| Também habilitado por este módulo | qualquer laboratório futuro que precise alterar contrato de dados sem parar a aplicação herda o mesmo padrão de três a quatro fases |
| Data da última validação técnica | 7 de agosto de 2026 |
Documentação oficial consultada: PostgreSQL — ALTER TABLE — os níveis de bloqueio por forma do comando e o que exige reescrita da tabela; PostgreSQL — CREATE INDEX — o comportamento de `CONCURRENTLY`, as duas varreduras e o índice `INVALID` em caso de falha; PostgreSQL — Explicit Locking — a matriz de conflito entre modos de lock e a confirmação de que só `ACCESS EXCLUSIVE` bloqueia um `SELECT` simples, além do comportamento de fila que evita que um escritor em espera seja continuamente ultrapassado por leitores compatíveis. Os valores de preço não aparecem neste módulo por decisão: use o AWS Pricing Calculator, porque preço varia por região e envelhece mais rápido que o conteúdo.
O que não foi verificado, e você deve conferir na sua conta
Os números de tempo citados — 5 ms para o `ADD COLUMN` isolado, quatro minutos de incidente, 41 s contra 2 s de pico de `ReplicaLag` entre tamanhos de lote — são os medidos na aplicação de exemplo, com o hardware e o volume dela, e servem como ordem de grandeza, não como referência. O tamanho de lote e o teto de atraso de réplica devem ser calibrados pela SUA medição: o volume real da sua tabela e a capacidade real da sua réplica, não o valor deste módulo copiado.
Terminou de ler?
Marcar como concluído registra o XP, mantém sua sequência e coloca 3 cartas deste módulo na fila de revisão espaçada.
Próximos passos sugeridos
Temas deste módulo
Discussão
Carregando comentários…