Lab 19 — Busca no catálogo: LIKE até onde?
O problema, e a empresa que o tem
A Cadência do L14 já separa leitura de escrita com réplica dedicada, mas um problema diferente cresceu no mesmo período: o catálogo de produtos passou de algumas dezenas de milhares de itens para 2,1 milhões de SKUs, alimentado por integração contínua com fornecedores. A busca de produto — usada em quase toda tela do aplicativo do lojista — sempre fez WHERE descricao ILIKE '%' || @termo || '%' direto na tabela, e até uns meses atrás isso bastava.
Não basta mais. Um termo de duas palavras leva quase dois segundos para responder, e a consulta consome CPU do mesmo banco em que a integração de fornecedores está gravando preço e estoque o dia inteiro — os dois competem pelo mesmo recurso. E há uma queixa recorrente do time de suporte: lojista digita "tenes" ou "camizeta", a busca não encontra nada, e o produto está lá, cadastrado, com o nome certo.
O instinto mais comum é "trocar por um banco de busca de verdade". Este laboratório resiste a esse instinto até ele se justificar: mede exatamente por que a consulta atual é lenta, mostra até onde o próprio Postgres resolve — sem peça nova na conta — e só então introduz OpenSearch, para o pedaço do problema que o Postgres genuinamente não cobre de graça.
O que este laboratório NÃO é
Não é busca semântica nem recuperação híbrida — aqui a relevância inteira é léxica (termo bate com termo, ponderado por frequência). Combinar busca por palavra-chave com busca vetorial é o L85; usar um modelo para interpretar a intenção da consulta é o L94. Este módulo é sobre a fronteira mais barata: até onde um índice invertido clássico resolve antes de precisar de IA.
O que você vai conseguir fazer
Objetivos verificáveis: cada um se prova com um comando na seção de implantação.
- Explicar, a partir da estrutura de um índice-B, por que curinga à esquerda o torna inútil.
- Medir com EXPLAIN ANALYZE a troca de Seq Scan por Bitmap Index Scan no mesmo banco.
- Criar uma coluna tsvector gerada e um índice GIN sem bloquear escrita concorrente.
- Escrever uma consulta com ranking por relevância (ts_rank_cd) e explicar o que ele pondera.
- Nomear o índice invertido (lexema → linhas) e o custo de mantê-lo sincronizado com a origem.
- Configurar tolerância a erro de digitação no OpenSearch e dizer o que o Postgres não oferece de graça para isso.
- Montar um pipeline de captura de mudança do RDS ao OpenSearch sem escrita dupla no código da aplicação.
- Medir o atraso entre uma escrita confirmada no RDS e sua presença buscável no OpenSearch.
- Decidir, a partir dos requisitos declarados, até onde o full-text do Postgres basta antes de introduzir OpenSearch.
O que a certificação cobra disto
| Conceito | Certificação | Como aparece aqui | O que dominar |
|---|---|---|---|
| Índice-B e padrões de LIKE | DVA-C02, DBS-C01 | curinga à esquerda invalida o índice existente | por que a ordenação de um índice-B não ajuda quando a posição do termo é desconhecida |
| Full-text search no RDS PostgreSQL | DBS-C01, DVA-C02 | coluna tsvector gerada + índice GIN | até onde ele basta antes de precisar de um motor de busca dedicado |
| OpenSearch Service: quando compensa | SAA-C03, DVA-C02 | tolerância a erro de digitação e desacoplamento de carga | reconhecer o requisito que justifica o custo e a operação extra |
| AWS DMS e captura de mudança | DBS-C01, SAA-C03 | sincronização assíncrona via log de replicação lógica | ler o log, não a tabela — e o que acontece quando o slot fica órfão |
| Relevância: ranking por frequência | DVA-C02 | ts_rank_cd de um lado, BM25 do outro | por que a ordem pode divergir entre os dois motores sem ser bug |
| Índice invertido | DVA-C02, SAA-C03 | estrutura termo → lista de documentos | o trade-off de espaço e de atualização contra um índice-B comum |
Onde isto costuma ser cobrado errado
A pergunta clássica dá uma coluna com índice e uma consulta `LIKE '%termo%'` lenta, e pede por que o índice "não funciona". A resposta certa não é "falta índice" — é que o índice existe e é do tipo errado para aquele padrão de busca. Confundir "sem índice" com "índice que o padrão de consulta não permite usar" é o erro mais comum do tema.
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 |
|---|---|---|
| Latência de busca ao usuário | p95 abaixo de 150 ms | exclui varredura completa; obriga índice de verdade — no mínimo GIN sobre tsvector |
| Tolerância a erro de digitação | meta desejável, não obrigatória no estágio 1 | licencia começar só com RDS/GIN e adiar OpenSearch até o requisito virar obrigatório |
| Volume do catálogo | 2,1 milhões de SKUs, crescendo ~180 mil/mês | o índice precisa crescer sem bloquear escrita: `CREATE INDEX CONCURRENTLY`, sempre |
| Carga de escrita concorrente com busca | integração de fornecedores grava o dia inteiro | motiva desacoplar a leitura de busca do banco de escrita quando a contenção for medida |
| Consistência da busca após escrita | atraso de poucos segundos é aceitável | licencia sincronização assíncrona por captura de mudança em vez de escrita dupla síncrona |
| Relevância por importância, não só filtro | obrigatório | exige ranking por frequência de termo — `ts_rank_cd` no estágio 1, BM25 no estágio 2 |
| Orçamento e equipe | equipe pequena; sem infra nova até necessidade comprovada | obriga esgotar o estágio 1 (RDS/GIN, sem custo novo) antes de introduzir OpenSearch e DMS |
Arquitetura mínima: o mesmo banco, dois planos de execução
O primeiro desenho não acrescenta serviço nenhum. Ele mostra por que a consulta atual é lenta e o que muda quando a MESMA tabela, no MESMO banco, é consultada de um jeito que o planejador consegue otimizar.
- → termo de busca livre, sem forma canônica
- → consulta parametrizada com o termo do usuário
- → bitmap index scan — só quando o filtro usa a coluna tsvector
- Fora da AWS
- Compute
- Banco de dados
- Conceito de arquitetura
Nenhum recurso novo entra neste desenho — a diferença inteira mora em qual índice o Postgres consegue usar. Percorra os passos: o quarto é onde a busca para de varrer 2,1 milhões de linhas a cada tecla digitada.
- O termo chega sem estrutura. O lojista digita "tenis azul" sem saber se o cadastro tem acento, plural ou a ordem das palavras. A API parametriza a consulta — evita injeção — mas ainda não decide COMO comparar o termo com 2,1 milhões de descrições.
- A consulta ingênua não deixa opção ao planejador. A versão original faz `WHERE descricao ILIKE '%' || @termo || '%'`. O curinga à ESQUERDA é o problema: existe um índice-B comum na coluna, criado para ordenação, e ele não serve aqui — o otimizador nem tenta usá-lo.
- Ordenação não ajuda quando a posição é desconhecida. Um índice-B é uma estrutura ORDENADA: rápido para "começa com foo", porque a ordem alfabética localiza o intervalo. Para "contém foo em qualquer posição", a ordem não elimina candidato nenhum — sobra varrer linha por linha.
- O Postgres já tem um índice de verdade embutido. A extensão de busca textual nativa transforma cada descrição num `tsvector` — lista normalizada de lexemas — e o índice GIN mapeia lexema → linhas que o contêm. É a mesma ideia estrutural de um índice invertido de motor de busca.
- O plano muda de Seq Scan para Bitmap Index Scan. Trocando o filtro para `busca @@ websearch_to_tsquery('portuguese', @termo)`, o planejador escolhe o GIN em vez de varrer a tabela. `EXPLAIN ANALYZE` prova isso pelo nome do nó do plano, não por impressão de velocidade.
- Relevância entra pela primeira vez. `ts_rank` pondera frequência do termo e proximidade entre lexemas — não é só filtro binário "contém ou não contém". É o primeiro degrau de ordenar por importância, e ele já está aqui, sem peça nova.
- Onde este desenho já esbarra. Digitar "tenes" (erro real de digitação) não casa com nenhum lexema de "tênis" — `tsvector` normaliza plural e derivação da MESMA raiz, não erro de teclado. É a fronteira que justifica olhar para o próximo desenho.
A migration que trava a tabela inteira se você pular uma palavra
`CREATE INDEX` sem `CONCURRENTLY` pede bloqueio `ACCESS EXCLUSIVE` na tabela inteira até terminar de construir o índice. Em 2,1 milhões de linhas, isso não é uma pausa curta — é minutos em que a integração de fornecedores não consegue gravar preço nem estoque, e o catálogo fica visivelmente desatualizado. `CONCURRENTLY` constrói o índice em duas varreduras sem bloqueio exclusivo; o custo é demorar mais e exigir que não haja outra migration DDL na mesma tabela ao mesmo tempo.
O estágio 1 não entra na fatura
Coluna gerada e índice GIN são schema e armazenamento dentro do RDS que já existe — não há instância nova, nó novo nem serviço novo para provisionar. É por isso que ele vem primeiro: resolve o problema medido hoje sem pedir aprovação de orçamento.
Arquitetura para produção: quando GIN não basta
Esta topologia só se justifica pelos dois requisitos que o estágio 1 deixa em aberto: tolerância a erro de digitação e desacoplar a carga de busca do banco de escrita. Cada peça nova abaixo rastreia a um desses dois — se você não consegue apontar qual, ela é adorno.
- → termo com erro de digitação tolerado
- → escrita de preço e estoque — sem mudança de contrato
- → consulta de busca com fuzziness
- → WAL decodificado pelo slot de replicação lógica
- → documento indexado, com atraso próprio da fila
- → métrica de atraso entre origem e destino
- Fora da AWS
- Compute
- Banco de dados
- Conceito de arquitetura
- Analytics
- Gestão e governança
A aplicação não ganhou nenhuma linha de código de sincronização. O que muda é que a busca passa a ler de um sistema diferente de quem grava, e a ponte entre os dois é um pipeline assíncrono que lê o log de escrita, não a tabela. Percorra os passos: o terceiro é a peça que a maioria das explicações de CDC omite.
- A escrita não muda. A API continua fazendo `INSERT`/`UPDATE` só no RDS, exatamente como antes. Não existe transação distribuída nem chamada síncrona ao OpenSearch no caminho de escrita — é a diferença central em relação a escrever nos dois lugares.
- O DMS lê o log, não a tabela. Com `rds.logical_replication = 1` no grupo de parâmetros, o Postgres decodifica cada mudança de linha no WAL e a expõe por um slot de replicação lógica. O DMS consome esse slot — é leitura de log, não consulta que compete com o OLTP.
- A propagação é assíncrona, e o atraso tem nome. Entre o commit e o documento aparecer no OpenSearch existe uma fila de aplicação. A AWS expõe isso como métrica (`CDCLatencySource`, `CDCLatencyTarget`) — o atraso não é escondido, é medido, e cresce sob pico de escrita.
- A leitura de busca sai do caminho do banco de escrita. Toda consulta de busca — que antes competia por CPU e I/O com a integração de fornecedores gravando no RDS — passa a bater no OpenSearch. O RDS deixa de fazer o trabalho de busca; continua fazendo só o de registro.
- A consulta ganha tolerância a erro de digitação. A consulta `match` com `fuzziness: AUTO` aceita distância de edição Damerau-Levenshtein: até 5 caracteres tolera 1 edição, acima disso tolera 2. É o que o `tsvector` do desenho anterior não oferece de graça.
- A relevância passa a usar BM25. O termo que aparece muitas vezes NUM produto pesa mais; o termo comum a quase todo produto do catálogo pesa menos. É frequência do termo saturada contra raridade no corpus — mais sofisticado que o `ts_rank` do RDS, e é o motivo de a ordem dos resultados poder divergir entre os dois motores sem que isso seja bug.
- O atraso é observável, não ausente. O alarme em `CDCLatencyTarget` avisa quando a fila cresce além do aceitável — por exemplo, durante uma carga em lote do fornecedor. Sem essa métrica, o único jeito de descobrir atraso é um lojista reclamar que "o produto não aparece".
A aplicação não ganhou complexidade nova
É o ponto mais fácil de perder de vista: nenhuma linha do código de escrita mudou. A sincronização inteira mora em infraestrutura — parâmetro do banco, instância de replicação, tarefa do DMS — e não em lógica que alguém precisa manter e que pode esquecer de chamar.
Como funciona, ponta a ponta
Escrita e leitura seguem caminhos que não se cruzam no código. Isso é desconfortável na primeira leitura — "cadê a chamada que sincroniza?" — e é exatamente o ponto: não existe essa chamada.
// Requisicao de busca que a API monta para o OpenSearch, e a resposta que volta.
// O campo "fuzziness" e a diferenca central em relacao a consulta no RDS.
{
"requisicao": {
"query": {
"match": {
"descricao": {
"query": "tenes azul",
"fuzziness": "AUTO",
"operator": "and"
}
}
},
"size": 20,
"from": 0
},
"resposta_resumida": {
"took_ms": 18,
"hits": {
"total": { "value": 34 },
"hits": [
{
"_id": "sku-00481932",
// _score vem do BM25: frequencia do termo NO documento,
// penalizada pela frequencia do termo NO corpus inteiro.
"_score": 11.284,
"_source": { "nome": "Tenis azul corrida", "preco": 289.9 }
}
]
}
}
}Por que _score pode divergir entre os dois motores
`ts_rank_cd` no RDS pondera densidade de termos no documento. BM25 no OpenSearch pondera frequência do termo NO documento, saturada (o décimo primeiro "tênis" no texto pesa pouco a mais que o décimo), contra a raridade do termo NO corpus inteiro — um termo raro no catálogo, quando aparece, pesa mais do que um termo comum. São modelos de relevância diferentes; a mesma consulta pode legitimamente ordenar diferente nos dois motores sem que nenhum dos dois esteja errado.
A normalização precisa ser a mesma dos dois lados
A coluna `tsvector` do RDS passa por `unaccent`; o campo indexado no OpenSearch precisa do mesmo tratamento de acento antes de chegar ao analisador, ou "tenis" (sem acento) encontra resultado num motor e não no outro para o MESMO termo digitado. Divergência de normalização entre os dois caminhos é o tipo de bug que só aparece em teste manual, porque teste automatizado costuma usar termo já acentuado corretamente.
As decisões, e o que se perde em cada uma
📋 Catálogo de 2,1 milhões de SKUs, atualizado continuamente por integração com fornecedores; busca hoje faz `ILIKE '%termo%'` direto na tabela do RDS — lenta sob carga e sem tolerância a erro de digitação. Equipe pequena, sem orçamento para operar um cluster de busca só porque parece mais profissional.
O estágio 1 não pede recurso novo na fatura e resolve o problema que hoje é real e medido: consulta lenta por varredura completa. Ele NÃO resolve tolerância a erro de digitação nem tira carga de busca do banco de escrita — e é honesto reconhecer isso em vez de forçar OpenSearch cedo demais só porque parece a solução "de verdade". Quando a queixa de "não encontra com erro de digitação" virar requisito de negócio, ou quando a contenção entre busca e escrita for medida (não suposta), o estágio 2 se paga: ele resolve exatamente os dois requisitos que o primeiro deixou em aberto, sem reescrever nada do que já funciona.
Alt: Elasticsearch autogerenciado em EC2 — Mesma capacidade de busca do OpenSearch, mas a equipe passa a aplicar patch de segurança, gerenciar cluster e planejar capacidade — trabalho operacional que o serviço gerenciado da AWS absorve.
Alt: SaaS de busca terceirizado — Tolerância a erro e relevância prontas em minutos, mas o catálogo — preço incluso — passa a existir fora da conta AWS, com um provedor a mais no caminho crítico e cobrança por volume de indexação.
Alt: pg_trgm no lugar de tsvector/GIN — Dá similaridade por trigrama e tolera erro de digitação leve sem sair do Postgres, mas não pondera frequência de termo como `ts_rank_cd` ou BM25 — é comparação de sobreposição de trigramas, não relevância. Serve como paliativo de curto prazo, não substitui a decisão deste módulo.
Alt: Escrita dupla síncrona no código da aplicação — Parece mais simples de entender — "eu controlo a sincronização" — mas acopla duas transações que deveriam ser independentes: se o OpenSearch estiver fora do ar, ou a escrita no RDS falha junto, ou o índice fica divergente sem ninguém saber. É o antipadrão que a captura de mudança existe para evitar.
| Decisão | Escolha | Alternativas | Motivo | O que se perde |
|---|---|---|---|---|
| Tipo de índice no RDS | GIN sobre tsvector gerado | GIN sobre pg_trgm; índice-B comum (não serve para conter) | índice invertido de verdade, com ranking por relevância embutido | reindexação em cada escrita da coluna indexada — pequeno por linha, soma em lote |
| Normalização do texto | unaccent + configuração "simple" | "portuguese" com stemming completo | "simple" evita juntar "tênis" com "tenista"; unaccent resolve acento sem afetar plural | não expande sinônimo nem variação morfológica agressiva — troque para "portuguese" se isso virar requisito |
| Criação do índice em produção | `CREATE INDEX CONCURRENTLY` | `CREATE INDEX` simples | não bloqueia escrita concorrente durante a construção | demora mais e não pode rodar junto com outra migration DDL na mesma tabela |
| Sincronização RDS → OpenSearch | AWS DMS em modo CDC, lendo o WAL | escrita dupla síncrona; polling periódico da tabela inteira | zero código de sincronização na aplicação; não compete com o OLTP por SELECT | atraso de propagação, que precisa ser medido e alarmado — não é zero |
| Tolerância a erro de digitação | fuzziness AUTO no OpenSearch | pg_trgm no RDS; nenhuma tolerância | distância de edição de verdade, calibrada pelo tamanho do termo | consultas fuzzy custam mais CPU por requisição que match exato |
Construir: full-text nativo no RDS
O estágio 1 inteiro é uma coluna gerada e um índice — nenhum recurso novo, nenhuma mudança de contrato para quem já escreve na tabela.
-- migration_gin_busca.sql — o índice invertido dentro do próprio RDS
-- unaccent normaliza "tênis" e "tenis" para o mesmo lexema. Sem isto, a busca
-- textual nativa continuaria sensível a acento, que não é o mesmo problema
-- que curinga à esquerda, mas frustra o usuário do mesmo jeito.
CREATE EXTENSION IF NOT EXISTS unaccent;
-- Coluna GERADA: recalculada pelo próprio Postgres a cada escrita, nunca pela
-- aplicação. "simple" em vez de "portuguese" evita stemming agressivo demais
-- para nome de produto (stemming juntaria "tênis" e "tenista", por exemplo).
ALTER TABLE produtos ADD COLUMN busca tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('simple', unaccent(coalesce(nome, ''))), 'A') ||
setweight(to_tsvector('simple', unaccent(coalesce(descricao, ''))), 'B')
) STORED;
-- CONCURRENTLY é a linha que mais importa nesta migration: sem ela, criar o
-- índice pede ACCESS EXCLUSIVE na tabela inteira, e a integração de
-- fornecedores para de escrever até o índice terminar de ser construído.
-- Em 2,1 milhões de linhas isso não é segundos — é minutos de escrita travada.
CREATE INDEX CONCURRENTLY idx_produtos_busca ON produtos USING GIN (busca);
-- Sem isto o planejador decide com estatística desatualizada e pode preferir
-- Seq Scan mesmo com o índice pronto — é a causa mais comum de "criei o
-- índice e continua lento".
ANALYZE produtos;
Coluna gerada tem custo de escrita, e ele é do tamanho do texto
Toda vez que `nome` ou `descricao` muda, o Postgres recalcula o `tsvector` inteiro da linha antes de confirmar a transação. Para uma descrição de produto isso é imperceptível; para um campo de texto longo — um manual, uma bula — a recomputação pode passar a aparecer na latência de escrita. Meça se o seu texto indexado for grande.
O peso A/B não é decoração, é prioridade de campo
`setweight(..., 'A')` no nome e `setweight(..., 'B')` na descrição fazem `ts_rank_cd` pontuar mais alto um produto cujo NOME contém o termo do que um cujo termo aparece só na descrição longa. Sem essa distinção, os dois pesam igual, e uma descrição extensa com o termo citado uma vez de passagem competiria de igual para igual com o nome exato.
// ProdutoBuscaRepository.cs — a consulta que usa o índice invertido do RDS
public sealed class ProdutoBuscaRepository
{
private readonly NpgsqlDataSource _dataSource; // pool; nunca uma conexão por requisição
public ProdutoBuscaRepository(NpgsqlDataSource dataSource) => _dataSource = dataSource;
public async Task<IReadOnlyList<ProdutoResultado>> BuscarAsync(string termo, int limite = 20)
{
// websearch_to_tsquery aceita entrada de usuário sem quebrar: trata
// aspas, "e"/"ou" e hífen sem lançar erro de sintaxe, ao contrário de
// to_tsquery, que espera operadores já formatados.
const string sql = """
SELECT id, nome, preco,
-- ts_rank_cd pondera DENSIDADE dos termos no documento, não
-- só a contagem — dois termos próximos pesam mais que dois
-- termos espalhados no fim de uma descrição longa.
ts_rank_cd(busca, websearch_to_tsquery('simple', @termo)) AS relevancia
FROM produtos
WHERE busca @@ websearch_to_tsquery('simple', @termo)
ORDER BY relevancia DESC
LIMIT @limite;
""";
await using var cmd = _dataSource.CreateCommand(sql);
cmd.Parameters.AddWithValue("termo", unaccentar(termo));
cmd.Parameters.AddWithValue("limite", limite);
var resultado = new List<ProdutoResultado>();
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
resultado.Add(new ProdutoResultado(
reader.GetGuid(0), reader.GetString(1), reader.GetDecimal(2), reader.GetFloat(3)));
}
return resultado;
}
// A normalização acontece nos dois lados: a coluna gerada já passa por
// unaccent(), e o termo de busca precisa do mesmo tratamento, senão
// "tênis" no banco nunca encontra "tenis" digitado sem acento.
private static string unaccentar(string termo) =>
termo.Normalize(NormalizationForm.FormD)
.Where(c => CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark)
.Aggregate(new StringBuilder(), (sb, c) => sb.Append(c)).ToString();
}
Construir: o pipeline de captura de mudança e o domínio OpenSearch
O parâmetro do banco, a instância de replicação, os dois endpoints e a tarefa formam a ponte inteira. Repare que nenhum recurso aqui é código de aplicação.
# cdc.tf — a ponte entre RDS e OpenSearch, sem escrita dupla no código
# O interruptor que liga a captura de mudança. É parâmetro ESTÁTICO: exige
# reboot da instância para valer. Ao aplicá-lo, o próprio RDS ajusta
# wal_level, max_wal_senders, max_replication_slots e max_connections — não
# defina esses quatro manualmente, ou a mudança automática pode conflitar.
resource "aws_db_parameter_group" "postgres_cdc" {
name = "${var.projeto}-postgres-cdc"
family = "postgres16"
parameter {
name = "rds.logical_replication"
value = "1"
apply_method = "pending-reboot"
}
}
resource "aws_dms_replication_subnet_group" "principal" {
replication_subnet_group_id = "${var.projeto}-dms"
replication_subnet_group_description = "Sub-redes privadas para a instância de replicação"
subnet_ids = aws_subnet.privada[*].id
}
resource "aws_dms_replication_instance" "principal" {
replication_instance_id = "${var.projeto}-dms"
replication_instance_class = "dms.t3.medium" # dimensione pelo volume de escrita, não pelo de leitura
allocated_storage = 50
vpc_security_group_ids = [aws_security_group.dms.id]
replication_subnet_group_id = aws_dms_replication_subnet_group.principal.id
publicly_accessible = false
}
resource "aws_dms_endpoint" "origem_rds" {
endpoint_id = "${var.projeto}-origem-rds"
endpoint_type = "source"
engine_name = "postgres"
server_name = aws_db_instance.principal.address
port = 5432
database_name = "cadencia"
username = "dms_replicador" # usuário dedicado, só com REPLICATION e SELECT em produtos
password = data.aws_secretsmanager_secret_version.dms_credencial.secret_string
}
resource "aws_dms_endpoint" "destino_opensearch" {
endpoint_id = "${var.projeto}-destino-opensearch"
endpoint_type = "target"
engine_name = "opensearch"
elasticsearch_settings {
endpoint_uri = "https://${aws_opensearch_domain.busca.endpoint}"
service_access_role_arn = aws_iam_role.dms_para_opensearch.arn
# Percentual de erro de FULL LOAD tolerado antes de a tarefa parar sozinha
# — protege contra indexar um catálogo pela metade sem ninguém notar.
full_load_error_percentage = 10
error_retry_duration = 300
}
}
resource "aws_dms_replication_task" "sincronizar_produtos" {
replication_task_id = "${var.projeto}-sync-produtos"
replication_instance_arn = aws_dms_replication_instance.principal.replication_instance_arn
source_endpoint_arn = aws_dms_endpoint.origem_rds.endpoint_arn
target_endpoint_arn = aws_dms_endpoint.destino_opensearch.endpoint_arn
# cdc-only pressupõe que o catálogo atual já foi carregado uma vez; para
# popular do zero, use "full-load-and-cdc" na primeira execução.
migration_type = "cdc"
table_mappings = jsonencode({
rules = [{
rule-type = "selection"
rule-id = "1"
rule-name = "produtos"
object-locator = { schema-name = "public", table-name = "produtos" }
rule-action = "include"
}]
})
}
resource "aws_opensearch_domain" "busca" {
domain_name = "${var.projeto}-busca"
engine_version = "OpenSearch_2.15"
cluster_config {
instance_type = "r6g.large.search"
instance_count = 2 # 2 nós, 1 réplica por shard — sobrevive à perda de 1 nó
}
vpc_options {
subnet_ids = [aws_subnet.privada[0].id]
security_group_ids = [aws_security_group.opensearch.id]
}
encrypt_at_rest { enabled = true }
node_to_node_encryption { enabled = true }
# Sem isto o domínio aceita requisição sem autenticação de usuário dentro da
# própria VPC — o security group limita QUEM alcança a porta, não QUEM pode
# ler cada documento depois de alcançá-la.
advanced_security_options {
enabled = true
internal_user_database_enabled = false
master_user_options {
master_user_arn = aws_iam_role.opensearch_admin.arn
}
}
}
# O papel que o DMS assume para escrever no OpenSearch. Ação restrita ao
# domínio específico — não existe "ESHttpPost em qualquer domínio" aqui.
resource "aws_iam_role_policy" "dms_para_opensearch" {
role = aws_iam_role.dms_para_opensearch.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["es:ESHttpPost", "es:ESHttpPut", "es:ESHttpGet", "es:ESHttpHead"]
Resource = "${aws_opensearch_domain.busca.arn}/*"
}]
})
}
Slot de replicação órfão enche o disco do RDS, e não avisa sozinho
Enquanto existe um slot de replicação lógica ativo, o Postgres NÃO PODE descartar segmentos de WAL que o slot ainda não confirmou ter lido — mesmo que ninguém esteja consumindo. Se a tarefa do DMS for apagada sem que o slot seja explicitamente dropado, o WAL cresce indefinidamente até o armazenamento do RDS se esgotar. Isso é `perda de disponibilidade do banco de escrita inteiro`, não só da busca — trate a remoção do slot como parte obrigatória de desligar a tarefa, nunca como opcional.
O `*` que não existe nesta política, de propósito
A política do papel do DMS restringe `Resource` ao ARN do domínio OpenSearch específico. Diferente do `ecr:GetAuthorizationToken` do L03, as ações `es:ESHttp*` aceitam recurso específico — então não há justificativa para usar `*` aqui, e usá-lo autorizaria o DMS a escrever em qualquer domínio da conta.
Repovoar do zero é um comando, não um projeto
Como o RDS continua sendo a fonte da verdade, apagar o índice do OpenSearch e rodar a tarefa de novo com `full-load-and-cdc` reconstrói o catálogo inteiro de busca. É o que torna o índice derivado seguro de operar: o pior caso de erro humano no OpenSearch tem recuperação conhecida, ao contrário de perder dado no RDS.
Construir: a consulta com tolerância a erro
O código de leitura muda de motor, não de forma: ainda é "recebe termo, devolve lista ordenada por relevância" — só que a implementação de relevância é outra.
// BuscaOpenSearchService.cs — a mesma pergunta, motor diferente, resposta que pode divergir em ordem
public sealed class BuscaOpenSearchService
{
private readonly OpenSearchClient _cliente;
public BuscaOpenSearchService(OpenSearchClient cliente) => _cliente = cliente;
public async Task<IReadOnlyList<ProdutoResultado>> BuscarAsync(string termo, int limite = 20)
{
var resposta = await _cliente.SearchAsync<ProdutoDocumento>(s => s
.Index("produtos")
.Size(limite)
.Query(q => q
.Match(m => m
.Field(f => f.Descricao)
.Query(termo)
// AUTO: 0-2 caracteres exige igualdade exata; 3-5 tolera
// 1 edição; acima de 5, tolera 2. É a distância de edição
// Damerau-Levenshtein, e é o que o tsvector do RDS não tem.
.Fuzziness(Fuzziness.Auto)
.Operator(Operator.And))));
if (!resposta.IsValid)
{
// Falha aqui NÃO significa catálogo vazio — pode ser domínio
// indisponível. Devolver lista vazia silenciosamente faria o
// lojista concluir "não temos esse produto" por um erro de infra.
throw new BuscaIndisponivelException(resposta.DebugInformation);
}
// _score vem do BM25 padrão do OpenSearch: frequência do termo NO
// documento, saturada, contra a frequência do termo NO corpus inteiro
// — por isso a ordem pode diferir do ts_rank do RDS sem ser bug.
return resposta.Hits
.Select(h => new ProdutoResultado(h.Source.Id, h.Source.Nome, h.Source.Preco, h.Score ?? 0))
.ToList();
}
}
Implantar, e provar com número
Cinco provas. Nenhuma aceita "parece mais rápido" como resultado.
# provas.sh — cinco medições; nenhuma conclusão vem de "parece mais rápido"
PROJETO=ffv-lab-busca
# ── Prova 1: o plano muda de Seq Scan para Bitmap Index Scan ─────────────────
psql -c "EXPLAIN ANALYZE SELECT id FROM produtos WHERE descricao ILIKE '%tenis azul%';"
# Esperado ANTES: "Seq Scan on produtos ... rows=2100000 ... Execution Time: ~1800 ms"
psql -c "EXPLAIN ANALYZE SELECT id FROM produtos
WHERE busca @@ websearch_to_tsquery('simple', 'tenis azul');"
# Esperado DEPOIS: "Bitmap Heap Scan ... Bitmap Index Scan on idx_produtos_busca
# ... Execution Time: ~8 ms"
# Reprova: se o segundo comando ainda mostrar Seq Scan, rode ANALYZE produtos
# — estatística desatualizada faz o planejador ignorar um índice existente.
# ── Prova 2: tolerância a erro de digitação, RDS não tem; OpenSearch tem ─────
psql -c "SELECT count(*) FROM produtos
WHERE busca @@ websearch_to_tsquery('simple', 'tenes');"
# Esperado: 0 — "tenes" não é lexema de "tênis", é erro de digitação, não
# variação gramatical, e o tsvector não corrige isso.
curl -s -X GET "https://$OS_ENDPOINT/produtos/_search" -H 'Content-Type: application/json' -d '
{"query":{"match":{"descricao":{"query":"tenes","fuzziness":"AUTO"}}}}' \
| jq '.hits.total.value'
# Esperado: > 0 — o mesmo termo com erro encontra resultado no OpenSearch.
# ── Prova 3: o atraso de sincronização é mensurável, não hipotético ──────────
psql -c "UPDATE produtos SET estoque = estoque - 1 WHERE id = '$SKU_TESTE';"
T0=$(date +%s%3N)
until curl -s "https://$OS_ENDPOINT/produtos/_doc/$SKU_TESTE" | jq -e '.found' >/dev/null; do
sleep 0.2
done
echo "atraso observado: $(( $(date +%s%3N) - T0 )) ms"
# Na base de exemplo, sob carga normal: 400-900 ms. Sob carga em lote do
# fornecedor: até alguns segundos. MEÇA no seu ambiente — depende do volume
# de escrita concorrente e da classe da instância de replicação.
# ── Prova 4: a leitura de busca saiu do caminho do RDS ───────────────────────
aws cloudwatch get-metric-statistics --namespace AWS/RDS \
--metric-name CPUUtilization --statistics Average --period 300 \
--start-time "$(date -u -d '-1 hour' +%FT%TZ)" --end-time "$(date -u +%FT%TZ)" \
--dimensions Name=DBInstanceIdentifier,Value=${PROJETO} --output table
# Esperado: CPU do RDS estável durante um teste de carga de busca — se subir
# junto com o teste, a busca ainda está batendo no banco relacional.
# ── Prova 5: o índice do OpenSearch reconstrói a partir do RDS ───────────────
# Prova de que OpenSearch é derivado, não fonte da verdade: apague o índice e
# recarregue com uma tarefa full-load-and-cdc nova. Se o RDS não bastasse para
# reconstruir, a arquitetura teria um problema mais sério que atraso.
curl -s -X DELETE "https://$OS_ENDPOINT/produtos"
aws dms start-replication-task --replication-task-arn "$TASK_ARN" \
--start-replication-task-type reload-target
# Esperado: contagem de documentos volta a bater com "SELECT count(*) FROM produtos"
| Prova | Comando | Resultado que aprova | O que reprova, e o que significa |
|---|---|---|---|
| 1 · Plano de execução mudou | `EXPLAIN ANALYZE` antes e depois do índice | Seq Scan vira Bitmap Index Scan; tempo cai de ~1,8 s para dezenas de ms | se continuar Seq Scan com o índice criado, rode `ANALYZE produtos` — estatística desatualizada |
| 2 · Tolerância a erro de digitação | busca por "tenes" nos dois motores | zero no RDS (esperado); maior que zero no OpenSearch | zero também no OpenSearch indica fuzziness desligado ou campo errado na consulta |
| 3 · Atraso de sincronização é medível | escrever e cronometrar até aparecer no índice | centenas de ms sob carga normal, na base de exemplo | atraso crescente e não estabilizando indica fila de apply saturada — reveja a classe da instância de replicação |
| 4 · Busca saiu do caminho do RDS | CPU do RDS durante teste de carga de busca | CPU do RDS estável, sem correlação com o teste | CPU subindo junto indica que a busca ainda está caindo no RDS, não no OpenSearch |
| 5 · OpenSearch é derivado, não fonte | apagar índice e recarregar via DMS | contagem de documentos volta a bater com a contagem no RDS | se não bater, a tarefa de full load falhou parcialmente — cheque `full_load_error_percentage` |
Quebrar de propósito: três falhas e o diagnóstico
| Falha | Como provocar | Sintoma | Onde olhar | Correção |
|---|---|---|---|---|
| Índice sem CONCURRENTLY em produção | rode `CREATE INDEX` (sem `CONCURRENTLY`) na tabela de produção durante o expediente | integração de fornecedores para de gravar; alerta de timeout de escrita | `pg_stat_activity` mostra a sessão do CREATE INDEX segurando lock ACCESS EXCLUSIVE | cancele a migration, recrie com `CONCURRENTLY`, aceite que ela demora mais |
| Slot de replicação abandonado | apague a tarefa do DMS sem antes parar a tarefa e dropar o slot | armazenamento do RDS cresce sem explicação nos dias seguintes | `pg_replication_slots` mostra o slot com `active = false` e `restart_lsn` parado | `SELECT pg_drop_replication_slot(...)` e refazer o desligamento na ordem certa |
| Fuzziness desligado por padrão | use `match` sem `fuzziness` explícito e busque um termo com erro de digitação real | busca com erro de digitação devolve zero resultado; lojista acha que o produto não existe | a query enviada ao OpenSearch não tem o campo `fuzziness` | defina `fuzziness: "AUTO"` explicitamente; não confie em padrão implícito |
A combinação que apaga o catálogo de busca sem apagar o catálogo de verdade
Excluir o índice do OpenSearch por engano não é irreversível — o RDS continua sendo a fonte de dado — mas, enquanto o índice não é reconstruído, TODA busca no aplicativo volta a zero resultado, para 2,1 milhões de produtos, ao mesmo tempo. É o tipo de incidente que parece pequeno no painel de infraestrutura e é enorme na tela do lojista.
Uma tabela de produtos tem um índice-B comum na coluna `descricao`. Mesmo assim, `WHERE descricao ILIKE '%tenis%'` faz uma varredura completa da tabela. Por quê?
Segurança: dois sistemas com o mesmo dado, dois raios de exposição
Duplicar o catálogo em outro serviço duplica também a superfície de quem pode lê-lo. O RDS já tinha essa disciplina; o OpenSearch precisa da mesma, do zero.
| Risco | Probabilidade | Impacto | Controle preventivo | Detecção | Resposta |
|---|---|---|---|---|---|
| Domínio OpenSearch acessível sem controle de acesso refinado | média | alto | `advanced_security_options` com usuário mestre; nunca `internal_user_database_enabled` solto | CloudTrail de chamadas `es:*`; auditoria de política do domínio | restringir a política, rotacionar credencial do usuário mestre |
| Slot de replicação lógica exposto a credencial de longa duração | média | médio | usuário `dms_replicador` dedicado, só com `REPLICATION` e `SELECT` na tabela necessária | CloudTrail em `CreateEndpoint`; auditoria de conexões ao slot | revogar e recriar a credencial no Secrets Manager |
| Slot de replicação órfão esgota armazenamento do RDS | baixa | alto | checklist de desligamento: parar tarefa → dropar slot → só então remover endpoint | alarme de `FreeStorageSpace` do RDS e de `pg_replication_slots` inativo | dropar o slot manualmente; escalar armazenamento como contenção imediata |
| Dado de catálogo desatualizado no índice de busca engana o cliente sobre estoque | média | médio | alarme de `CDCLatencyTarget`; SLA interno de atraso máximo aceitável | comparar contagem e amostra entre RDS e OpenSearch periodicamente | pausar venda do item afetado até o índice reconciliar, se o atraso for crítico |
Por que o usuário do DMS não é o usuário da aplicação
A aplicação usa um papel de execução com permissão só de leitura e escrita nas tabelas de negócio. O usuário `dms_replicador` precisa, além disso, do privilégio `REPLICATION`, que autoriza consumir slots lógicos — um privilégio que a aplicação em si nunca deveria ter. Separar os dois usuários limita o estrago de uma credencial vazada a um papel só.
Observabilidade: as perguntas que o painel tem de responder
| Pergunta | Métrica ou consulta | O que significa mudar | Limiar inicial |
|---|---|---|---|
| A busca ainda varre a tabela inteira? | `EXPLAIN` do plano em produção (amostragem) | Seq Scan reaparecendo indica estatística desatualizada ou índice corrompido | qualquer Seq Scan em produção |
| A captura de mudança está funcionando? | DMS `CDCLatencySource` e `CDCLatencyTarget` | crescimento contínuo indica fila acumulando mais rápido que o apply | > 10 s sustentado por 5 min |
| O slot de replicação está sendo consumido? | `pg_replication_slots.active` | `false` persistente é o sinal do slot órfão que enche o disco | qualquer `false` por mais de alguns minutos |
| O RDS ainda faz o trabalho de busca? | `CPUUtilization` do RDS correlacionado com tráfego de busca | correlação indica que a migração de leitura para o OpenSearch não está completa | correlação visível no gráfico |
| O índice de busca está saudável? | OpenSearch `_cluster/health` (status) | `yellow` indica réplica de shard não alocada; `red` indica shard primário perdido | diferente de `green` |
| O catálogo de busca bate com a origem? | contagem periódica RDS vs. OpenSearch | divergência sustentada indica falha silenciosa na tarefa do DMS | diferença > 0,1% do total |
A métrica que some quando mais precisa dela
Sob pico de escrita — uma carga em lote do fornecedor — `CDCLatencyTarget` é exatamente a métrica que sobe, e é exatamente quando ninguém está olhando o painel de replicação, porque a atenção vai para o painel de negócio. Alarme automático nesta métrica substitui a vigilância manual que a equipe pequena não tem.
Escala: 10, 10 mil, 1 milhão de buscas por dia
| Volume | O que acontece | O que passa a doer | O que fazer |
|---|---|---|---|
| 10 buscas/dia, catálogo de 2,1 M | GIN no RDS responde em poucos ms; nem precisa de OpenSearch | nada | nada — o estágio 1 já é suficiente e o estágio 2 seria adorno |
| 10 mil buscas/dia | GIN ainda responde bem; a contenção com escrita começa a aparecer em pico | CPU do RDS compartilhada entre busca e integração de fornecedores | medir a contenção antes de decidir — não migrar por suposição |
| 1 milhão de buscas/dia | RDS sozinho satura; é o volume que justifica o estágio 2 | busca e OLTP competindo de verdade pelo mesmo recurso | OpenSearch com réplica de leitura; considerar mais de 2 nós para tolerância a falha de AZ |
| Pico de escrita em lote do fornecedor | a fila de apply do DMS cresce mais rápido que o normal | atraso de sincronização visível no aplicativo — produto "sumido" temporariamente da busca | alarmar `CDCLatencyTarget`; se recorrente, redimensionar a instância de replicação |
| Falha de AZ | nó do OpenSearch na AZ afetada some; réplica de shard assume | com `instance_count = 2` numa única AZ, a busca fica indisponível até substituição | distribuir os 2+ nós em AZs diferentes — é o mesmo raciocínio de Multi-AZ do RDS |
Custo: o que cada estágio acrescenta à fatura
É a razão de o desenho ter dois estágios em vez de um: o primeiro literalmente não acrescenta recurso novo.
| Cenário | Volume | O que acrescenta | Tendência | Otimização |
|---|---|---|---|---|
| Estágio 1 — GIN no RDS | qualquer volume até a contenção aparecer | espaço de armazenamento do índice (proporcional ao texto indexado) e CPU de reindexação por escrita | baixa e previsível | nenhuma; é o estágio mais barato possível |
| Estágio 2 — OpenSearch + DMS, catálogo pequeno | 2,1 M documentos, tráfego moderado | instância de replicação do DMS + 2 nós do domínio OpenSearch, ligados o tempo todo | fixa por hora, independente do volume de busca | dimensionar a instância de replicação pelo pico de escrita, não pela média |
| Estágio 2 — alta escala | catálogo maior, múltiplos shards e réplicas | mais nós no domínio, mais storage de índice (o índice invertido soma ao dado original) | cresce com volume de documentos e de réplica | ajustar contagem de shards ao tamanho real; shard demais também custa |
| Dimensão | Cobra por | Cuidado |
|---|---|---|
| Instância de replicação do DMS | hora ligada, pela classe escolhida | fica ligada o tempo todo em modo CDC contínuo — não é cobrança pontual de migração |
| Domínio OpenSearch | hora ligada por nó + storage por GB | nó em VPC ainda cobra por hora mesmo sem nenhuma busca acontecendo |
| Armazenamento do índice invertido | GB do índice, adicional ao dado original | um índice de texto pode pesar próximo do tamanho do dado que indexa |
| Transferência entre RDS e domínio | geralmente de graça na mesma AZ/VPC | atravessar AZ para o domínio soma taxa de transferência entre zonas |
O ganho que não aparece na fatura da AWS
O tempo de suporte gasto respondendo "por que a busca não encontra meu produto" some quando fuzziness resolve o erro de digitação — isso não é linha de infraestrutura, é redução de chamado, e foi o motivo de negócio real por trás do estágio 2 na Cadência.
Well-Architected nos seis pilares
| Pilar | Situação ao fim deste laboratório | Risco que fica | Melhoria | Prioridade |
|---|---|---|---|---|
| Excelência operacional | atraso de sincronização medido e alarmado; slot de replicação com checklist de desligamento | checklist ainda é manual — depende de alguém seguir a ordem certa | automação que dropa o slot como parte do pipeline de destruição do ambiente | média |
| Segurança | papel do DMS restrito ao domínio; usuário de replicação com privilégio mínimo | controle de acesso refinado do OpenSearch ainda usa usuário mestre único | IAM Identity Center federado no lugar de usuário mestre fixo | alta |
| Confiabilidade | índice reconstrói do zero a partir do RDS; RDS continua sendo a fonte da verdade | 2 nós numa única AZ não sobrevivem à falha dela | distribuir nós do domínio em pelo menos 2 AZs | alta |
| Eficiência de performance | busca saiu do caminho do OLTP; consulta usa índice de verdade nos dois motores | nenhum cache de consulta frequente ainda | cache de resultado para os termos mais buscados | média |
| Otimização de custos | estágio 1 sem custo novo; estágio 2 só introduzido com requisito medido | instância de replicação do DMS fica ligada mesmo fora de pico | avaliar DMS Serverless para o padrão de carga variável | média |
| Sustentabilidade | um único pipeline de sincronização em vez de escrita duplicada em código | dois sistemas guardando o mesmo dado é redundância física, ainda que justificada | revisar periodicamente se o requisito que justificou o estágio 2 continua verdadeiro | baixa |
Evolução em níveis: o que muda, e o que passa a doer
A terceira arquitetura não é um desenho novo: é a resposta a QUANDO trocar de desenho. Cada nível resolve um risco e compra outro.
`ILIKE '%termo%'` direto na tabela. É onde a Cadência estava, e continua legítimo em catálogo pequeno ou ambiente de desenvolvimento.Coluna tsvector gerada, índice GIN, `ts_rank_cd` para ordenar por relevância.OpenSearch com fuzziness, sincronizado por captura de mudança via DMS.Múltiplos shards e réplicas distribuídos em AZs, cache de consulta frequente, instância de replicação dimensionada pelo pico de escrita.Busca como serviço compartilhado entre catálogos e times diferentes, com contrato de SLA de atraso de indexação e observabilidade própria.Busca híbrida: BM25 léxico combinado com similaridade vetorial, e reranqueamento por modelo — é o L85 e o L94.A ordem não é negociável, e o motivo é concreto
Busca híbrida no nível 6 pressupõe um índice léxico funcionando — é o texto que ancora a comparação com o resultado vetorial. Quem tenta embeddings antes de ter a busca por palavra-chave resolvida está resolvendo o problema mais difícil primeiro, e sem forma de saber se o resultado é melhor do que o caminho mais simples que ainda não tentou.
Onde IA entra nesta arquitetura, e onde não entra
Neste módulo, IA não entra em lugar nenhum do mecanismo central, e forçá-la seria o antipadrão que a própria série critica. `ts_rank_cd` e BM25 são estatística clássica de recuperação de informação — frequência de termo, ponderada — não aprendizado de máquina. Nada aqui precisa de modelo.
O lugar honesto onde IA agregaria é diferente do problema deste laboratório: encontrar produto por INTENÇÃO em vez de por PALAVRA. "algo para correr no frio" não bate com nenhum lexema literal de "tênis térmico", por melhor que seja o índice invertido — isso exige comparação semântica (embeddings), que é um mecanismo genuinamente diferente, não uma versão mais esperta do BM25.
| Pergunta | Resposta honesta para este módulo |
|---|---|
| Qual problema a IA resolveria? | casar intenção do lojista com produto quando não há sobreposição literal de palavra |
| Por que BM25 melhor calibrado não bastaria? | porque o limite é semântico, não de peso: nenhum ajuste de frequência faz "correr no frio" casar com "térmico" sem entender os dois conceitos |
| De onde viriam os dados? | o próprio catálogo, para gerar embeddings de produto — reaproveitando o pipeline de sincronização deste módulo |
| Qual o risco? | relevância deixa de ser 100% explicável, e latência/custo por busca sobem — cada consulta passa a chamar um modelo |
| Por que não agora? | porque o catálogo ainda não tem busca léxica resolvida em produção — IA sobre um índice que não existe ainda é decoração |
O uso de IA que parece atraente e é armadilha aqui
Pedir a um modelo para "entender o que o lojista quis dizer" e reescrever a consulta antes de mandar para o índice troca um mecanismo determinístico e barato — normalização de acento e stemming — por um probabilístico e mais lento, para resolver exatamente o problema que `unaccent` e `to_tsvector` já resolvem de graça. IA sobre texto tem lugar quando o problema é semântico, e "tenes" para "tênis" não é: é digitação.
Anti-padrões deste laboratório
| Anti-padrão | Por que alguém faz | Por que é problema | Sintoma em produção | Forma correta | Quando é aceitável |
|---|---|---|---|---|---|
| `ILIKE '%termo%'` direto na tabela de produção | é a primeira coisa que funciona, e funciona bem com poucos dados em desenvolvimento | curinga à esquerda impede QUALQUER índice-B; o custo cresce com o tamanho do catálogo | busca lenta que piora mês a mês, sem nenhuma mudança de código | coluna tsvector gerada + índice GIN | catálogo pequeno (poucos milhares de linhas) ou ambiente de teste |
| `CREATE INDEX` sem `CONCURRENTLY` em produção | é o comando que aparece primeiro em qualquer tutorial, e roda mais rápido | pede lock ACCESS EXCLUSIVE na tabela inteira até terminar de construir | escrita travada por minutos, sem erro explícito — parece "banco lento" | `CREATE INDEX CONCURRENTLY` | janela de manutenção com escrita já pausada por outro motivo |
| Escrita dupla síncrona no código para manter os dois índices iguais | parece mais fácil de raciocinar do que "confiar num pipeline que ninguém vê" | acopla duas transações independentes; falha de uma trava ou corrompe a outra | gravação no RDS falhando por causa de timeout do OpenSearch, ou os dois divergindo em silêncio | captura de mudança assíncrona via log de replicação | nunca em produção; aceitável só em protótipo descartável |
| Tratar o OpenSearch como fonte de verdade do catálogo | parece redundante manter o dado em dois lugares, então "por que não ler e escrever só ali" | perde as garantias transacionais do RDS; OpenSearch não foi desenhado para ser sistema de registro | inconsistência de preço/estoque sem transação para investigar | RDS como fonte da verdade; OpenSearch como índice derivado, reconstruível | nunca — nem em protótipo, porque hábito é difícil de desfazer depois |
| Apagar a tarefa do DMS sem dropar o slot de replicação | a tarefa "some" da lista, então parece que está tudo limpo | o Postgres retém WAL indefinidamente enquanto o slot existir, mesmo sem consumidor | armazenamento do RDS crescendo sem explicação óbvia nos dias seguintes | parar a tarefa, confirmar `active = false`, dropar o slot explicitamente, só então remover o endpoint | nunca é aceitável pular a ordem — o custo é sempre o mesmo |
Quando algo não funciona
| Sintoma | Causa provável | Como investigar | Onde olhar | Correção |
|---|---|---|---|---|
| Busca continua lenta mesmo com o índice GIN criado | estatística desatualizada faz o planejador preferir Seq Scan | compare o plano com e sem `SET enable_seqscan = off` | `EXPLAIN ANALYZE` mostrando Seq Scan apesar do índice existir | `ANALYZE produtos`; se persistir, `REINDEX INDEX CONCURRENTLY idx_produtos_busca` |
| Produto recém-cadastrado não aparece na busca do OpenSearch | atraso normal de sincronização, ou tarefa do DMS parada | confira o status da tarefa e a métrica de atraso | console do DMS → status da tarefa; `CDCLatencyTarget` | se parada, reinicie a tarefa; se só atrasada, aguarde ou escale a instância de replicação |
| Armazenamento do RDS crescendo sem explicação | slot de replicação lógica órfão retendo WAL | liste os slots e confira `active` e `restart_lsn` | `SELECT * FROM pg_replication_slots;` | dropar o slot órfão com `pg_drop_replication_slot` |
| Escrita no RDS ficou mais lenta depois de habilitar CDC | overhead de gerar e transmitir WAL lógico, maior que o físico | compare `WAL generation rate` antes e depois de ligar `rds.logical_replication` | CloudWatch: `WriteIOPS` e taxa de geração de WAL | considere instância maior, ou reduza a frequência de commit em lote de escrita |
| Busca com erro de digitação não encontra nada, mesmo no OpenSearch | consulta sem `fuzziness` explícito, ou campo errado | confira o corpo da consulta enviada, não só o resultado | log da aplicação com o JSON da requisição ao OpenSearch | definir `fuzziness: "AUTO"` explicitamente no campo certo |
| Cluster OpenSearch em status "yellow" | réplica de shard não alocada, geralmente por nó insuficiente ou única AZ | consulte `_cluster/health` e a distribuição de shards por nó | `GET _cluster/health?level=shards` | aumentar contagem de nós ou distribuir em mais de uma AZ |
A pergunta que resolve metade destes casos
Antes de mexer em parâmetro, pergunte: o problema está na ESCRITA (RDS, WAL, slot) ou na LEITURA (consulta, índice, motor)? As duas famílias de sintoma se parecem — "a busca não funciona" — mas apontam para metades diferentes do desenho, e mexer no lado errado não muda nada.
Limpeza: o que o destroy não leva
A ordem de desligamento importa mais aqui do que em qualquer outro laboratório da série: inverter a sequência deixa um slot de replicação órfão consumindo armazenamento.
# 1. Pare a tarefa do DMS ANTES de tocar em qualquer outra coisa.
aws dms stop-replication-task --replication-task-arn "$TASK_ARN"
aws dms wait replication-task-stopped --replication-task-arn "$TASK_ARN"
# 2. Confirme e derrube o slot de replicacao logica no PROPRIO Postgres.
# O Terraform NAO administra isto — o slot e criado pelo DMS dentro do banco,
# nao e um recurso da AWS que o destroy enxergue.
psql -c "SELECT slot_name, active FROM pg_replication_slots;"
psql -c "SELECT pg_drop_replication_slot('nome_do_slot');"
# 3. So agora derrube a infraestrutura administrada pelo Terraform.
terraform destroy -auto-approve
# 4. Confirme que o dominio OpenSearch realmente sumiu — ele NAO tem
# "protecao contra delecao" ligada por padrao, mas confirme mesmo assim,
# porque e a linha mais cara desta lista.
aws opensearch describe-domain --domain-name ffv-lab-busca 2>&1 | head -5
# 5. Reverta o parametro no grupo de parametros do RDS, se o L14 nao for
# continuar usando replicacao logica para outra coisa.
aws rds describe-db-parameters --db-parameter-group-name ffv-lab-postgres-cdc \
--query "Parameters[?ParameterName=='rds.logical_replication']"
# 6. Prova final: nada com o nome do projeto de pe.
aws resourcegroupstaggingapi get-resources \
--tag-filters Key=Projeto,Values=ffv-lab-busca --output table| Recurso | Sai no destroy? | Cobra parado? | Por que fica |
|---|---|---|---|
| Slot de replicação lógica | não — não é recurso da AWS | sim, indiretamente: WAL retido | vive dentro do Postgres; só some com `pg_drop_replication_slot` explícito |
| Instância de replicação do DMS | sim | sim, por hora, enquanto existe | cobrança para assim que criada, independente de estar processando algo |
| Domínio OpenSearch | sim | sim, por nó-hora + storage | sem proteção contra deleção por padrão — confirme, não presuma |
| Coluna tsvector e índice GIN no RDS | sim, se a migration for revertida | não isoladamente | faz parte do schema; sai junto se a tabela for descartada, não com o `terraform destroy` do estágio 2 |
| Grupo de parâmetros com `rds.logical_replication` | sim, se removido do Terraform | não | reverter exige outro reboot da instância — planeje a janela |
terraform destroy não sabe que o slot existe
O slot de replicação lógica não é um recurso do Terraform: ele foi criado pelo DMS dentro do próprio Postgres, e o `state` do Terraform nunca soube dele. Rodar `destroy` primeiro e verificar o slot depois inverte a ordem que este laboratório insiste em repetir — e é assim que a maioria dos slots órfãos desta série nasce.
Resumo: problema, peça e motivo
| Problema | Peça | Por que ela, e não outra |
|---|---|---|
| Curinga à esquerda impede o índice-B | coluna tsvector gerada + índice GIN | índice invertido de verdade: lexema aponta direto para a linha, sem varrer nada |
| Índice novo trava escrita concorrente | `CREATE INDEX CONCURRENTLY` | constrói sem lock exclusivo — custa tempo, não disponibilidade |
| Sem tolerância a erro de digitação | OpenSearch com `fuzziness: AUTO` | distância de edição de verdade, calibrada pelo tamanho do termo |
| Busca compete com OLTP por CPU | leitura de busca movida para o OpenSearch | o RDS volta a fazer só o trabalho de registro |
| Sincronizar sem escrita dupla no código | AWS DMS lendo o WAL em modo CDC | lê o log, não a tabela; zero código de sincronização para manter |
| Atraso de sincronização escondido vira incidente | alarme em `CDCLatencyTarget` | torna o atraso uma métrica visível, não uma surpresa do lojista |
| Falha | O que a protege | O que ela NÃO protege |
|---|---|---|
| Varredura completa a cada busca | índice GIN sobre tsvector | erro de digitação — GIN não tolera isso, só variação gramatical da mesma raiz |
| Escrita travada ao criar índice | `CREATE INDEX CONCURRENTLY` | duas migrations DDL concorrentes na mesma tabela — ainda conflitam |
| Busca sem resultado por erro de digitação | fuzziness AUTO no OpenSearch | erro semântico — "algo para correr no frio" não encontra "tênis térmico" |
| Slot de replicação órfão | checklist de desligamento em ordem | esquecimento humano — nada aqui é automático até o nível 5 |
- A escrita do fornecedor grava só no RDS, exatamente como antes de qualquer coisa deste módulo.
- O Postgres registra a mudança no WAL antes de confirmar a transação — mecanismo de durabilidade, reaproveitado.
- O slot de replicação lógica decodifica a mudança e o DMS a consome continuamente.
- O DMS transforma a linha em documento e o envia ao domínio OpenSearch.
- Em paralelo, o lojista digita um termo — com ou sem erro de digitação.
- A API roteia a consulta ao OpenSearch, nunca ao RDS, para o caminho de busca.
- A consulta com `fuzziness: AUTO` tolera até 2 edições de digitação, conforme o tamanho do termo.
- BM25 ordena por frequência do termo no documento contra a raridade no corpus.
- O resultado paginado volta ao lojista, ordenado por relevância — não por ordem de cadastro.
- O alarme de `CDCLatencyTarget` teria avisado se a fila de sincronização tivesse acumulado.
Perguntas frequentes
❓ Por que LIKE '%termo%' não usa o índice mesmo existindo um na coluna?
❓ GIN sobre tsvector já resolve tolerância a erro de digitação?
❓ Preciso trocar o RDS por OpenSearch para ter busca rápida?
❓ Como o Postgres sincroniza com o OpenSearch sem eu escrever código de sincronização?
❓ O que é BM25 e por que ele ordena melhor do que apenas filtrar?
❓ Qual é o risco de esquecer de dropar o slot de replicação lógica?
❓ Um resultado pode demorar para aparecer na busca depois de cadastrado?
❓ O índice invertido duplica os dados do meu catálogo?
Fixando
A aplicação da Cadência nunca teve nenhuma linha de código escrita para sincronizar com o OpenSearch, e ainda assim o índice de busca acompanha as escritas do RDS. Como isso é possível?
Uma busca por "tênis corrida" devolve os mesmos 40 produtos no RDS (com tsvector/GIN) e no OpenSearch, mas em ORDENS diferentes. Isso indica um bug em algum dos dois?
Conhecimentos, próximo módulo e documentação
| Item | Conteúdo |
|---|---|
| Conhecimentos anteriores necessários | L14 concluído (réplica de leitura no RDS), SQL intermediário, índice e EXPLAIN à vontade |
| Conhecimentos adquiridos | por que curinga à esquerda invalida índice-B; tsvector/GIN como índice invertido nativo do Postgres; captura de mudança via log de replicação lógica; fuzziness e BM25 no OpenSearch; até onde full-text nativo basta antes de justificar um motor de busca dedicado |
| Limitação que fica | a relevância inteira, nos dois motores, é léxica — nenhum dos dois encontra produto por intenção quando não há sobreposição literal de termo |
| Próximo exemplo recomendado | L85 — recuperação híbrida, combinando busca léxica (o que este módulo constrói) com similaridade vetorial para cobrir a lacuna de intenção |
| Também habilitado por este módulo | L94 (busca com IA) depende de já existir um índice léxico confiável para comparar contra; o pipeline de captura de mudança construído aqui é reaproveitável para qualquer índice derivado futuro, não só busca |
| Data da última validação técnica | 7 de agosto de 2026 |
Documentação oficial consultada: PostgreSQL — Index Types e PostgreSQL — Full Text Search — a base do porquê de curinga à esquerda invalidar índice-B e do funcionamento de tsvector/tsquery/GIN; Amazon OpenSearch Service — Fuzzy query — a definição de distância de edição Damerau-Levenshtein e o comportamento do valor `AUTO`; e AWS DMS — Using a PostgreSQL database as a source e AWS DMS — Using Amazon OpenSearch Service as a target — o requisito de `rds.logical_replication` e o funcionamento de OpenSearch como destino de captura de mudança. 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 tempos de execução citados — cerca de 1,8 s para a varredura completa, dezenas de ms com o índice GIN, centenas de ms de atraso de sincronização — vêm de uma medição de exemplo com 2,1 milhões de linhas, e servem como ordem de grandeza, não como referência. O atraso de sincronização em especial depende do seu volume de escrita concorrente e da classe da instância de replicação escolhida: meça no SEU ambiente, sob a SUA carga de pico, antes de definir o limiar do alarme.
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…