RDS com CPU em 94%: como índices errados na tabela errada custam $566/mês — e como medir antes de fazer rightsizing
Publicado em 1 de maio de 2026
O ponto de partida: 94% de CPU com 8 vCPUs
O RDS de um cliente fintech estava num db.r6g.2xlarge — 8 vCPUs, 64 GB de RAM, $747/mês em On-Demand. Na semana antes da intervenção, o pico de CPU chegou a 94.1% numa quinta-feira. Outros dias da semana: 27%, 43%, picos frequentes acima de 20%.
A memória estava em 57.4 GB livres de 64 GB — 90% ociosa. Conexões: média 1.6 a 2.9, máximo 15 em circunstâncias normais. O banco não estava com pressão de conexões nem de memória. Só de CPU.
CPU alta com memória ociosa e conexões baixas em banco relacional normalmente aponta para uma causa: queries fazendo trabalho que índices deveriam fazer. O banco está varrendo tabelas inteiras para encontrar linhas que um índice encontraria em microssegundos.
A auditoria: a tabela mais consultada sem nenhum índice
O script de índices deixado pelo time de desenvolvimento no repositório cobria 13 tabelas — sessão, analytics, cookies, usuário, planos, afiliação, comissão, carrinho abandonado, nota fiscal. Nenhuma delas era a tabela 'transactions'.
A tabela 'transactions' era a mais consultada do sistema inteiro. Cada operação que importava passava por ela:
Relatórios de revendedores: dezenas de queries por página com filtros em revendedor + status_pedido
Cron de rastreio (EventBridge a cada 15 min): full scan com filtros em pago + codigo_rastreamento
Exportações: ORDER BY data_ultimo_evento DESC sem índice = filesort em 48k+ linhas
Rejeição de duplicatas no checkout: lookup por comprador + status em cada nova transação
Com 52.678 linhas na tabela no momento da auditoria, cada query sem índice varria as ~48k linhas ativas. O cron de rastreio disparava a cada 15 minutos, enfileirando ~1.480 jobs — cada job fazia uma query de seleção na tabela 'transactions' sem índice.
O benchmark ANTES: números que justificam o trabalho
Antes de criar qualquer índice, as queries críticas foram executadas com EXPLAIN e medição de tempo real. Todas as queries na tabela 'transactions' retornavam 'type=ALL, key=NULL' no EXPLAIN — full table scan confirmado:
-- Cron de rastreio: 34.48ms, 48.858 linhas varridas
EXPLAIN SELECT * FROM orders
WHERE codigo_rastreamento IS NOT NULL
AND pad = 1
AND (codigo_status_descricao IS NULL
OR codigo_status_descricao NOT IN (
'Status final 1', 'Status final 2'
))
AND (codigo_rastreamento IS NULL
OR codigo_rastreamento
BETWEEN (NOW() - INTERVAL 1 MONTH) AND (NOW() - INTERVAL 30 MINUTE));
-- type: ALL | key: NULL | rows: 48858 | Extra: Using where
-- Exportação: 31.17ms + filesort
EXPLAIN SELECT * FROM orders ORDER BY data_ultimo_evento DESC LIMIT 100;
-- type: ALL | key: NULL | rows: 48858 | Extra: Using filesort
-- Autenticação: 20.76ms
EXPLAIN SELECT * FROM usuario WHERE login = 'email@example.com';
-- type: ALL | key: NULL | rows: 51481Tempo total das 15 queries do benchmark ANTES: ~624ms. Com os índices, o alvo era abaixo de 200ms para o mesmo conjunto.
Os índices: design das escolhas
A tabela 'transactions' recebeu 7 índices. As escolhas foram guiadas pelas queries reais do código, não por critério de 'colunas com muitos valores':
-- CRÍTICOS: queries de relatório (filtros frequentes)
CREATE INDEX idx_tx_revendedor_status
ON orders (revendedor, status_pedido) ALGORITHM=INPLACE LOCK=NONE;
CREATE INDEX idx_tx_fornecedor_status
ON orders (fornecedor, status_pedido) ALGORITHM=INPLACE LOCK=NONE;
-- CRÍTICO: cron de rastreio a cada 15 minutos
CREATE INDEX idx_tx_rastreamento
ON orders (pago, codigo_rastreamento) ALGORITHM=INPLACE LOCK=NONE;
-- ALTO: rejeição de duplicatas no checkout
CREATE INDEX idx_tx_comprador_status
ON orders (comprador, status_pedido) ALGORITHM=INPLACE LOCK=NONE;
-- ALTO: exportações com ORDER BY
CREATE INDEX idx_tx_data_ultimo_evento
ON orders (data_ultimo_evento) ALGORITHM=INPLACE LOCK=NONE;
-- ALTO: queries por período
CREATE INDEX idx_tx_created_at
ON orders (created_at) ALGORITHM=INPLACE LOCK=NONE;
-- ALTO: filtros por status isolados (AppProcessos)
CREATE INDEX idx_tx_status
ON orders (status_pedido) ALGORITHM=INPLACE LOCK=NONE;O 'ALGORITHM=INPLACE LOCK=NONE' é obrigatório em tabelas de produção ativas no MariaDB 10.11+. Sem ele, a criação do índice adquire um lock de metadados que bloqueia leitura e escrita na tabela durante toda a operação. Com INPLACE, a tabela permanece disponível.
Por que índices compostos em vez de simples? A query 'revendedor IS NOT NULL AND status_pedido IN (4, 17)' se beneficia muito mais de um índice composto (revendedor, status_pedido) do que de dois índices simples separados. O otimizador usa o índice composto para filtrar diretamente — com dois índices simples, ele escolhe um e usa o outro como filter, reduzindo o ganho.
A execução: 23 índices, 3.91 segundos
Os 23 índices foram criados em produção via script PHP executado diretamente no container. Tempo total: 3.91 segundos. Zero erros. Zero downtime.
O benchmark DEPOIS, executado imediatamente após a criação:
| Query | ANTES | DEPOIS | Ganho |
|-------------------------------|----------------|-------------------------------------|--------|
| transactions ORDER BY data_evento | ALL 31.17ms | index/idx_tx_data_ultimo_evento 0.71ms | 43.9x |
| usuario login | ALL 20.76ms | ref/idx_usuario_login 0.75ms | 27.7x |
| transactions created_at 30d | ALL 30.91ms | range/idx_tx_created_at 1.82ms | 17x |
| analytics created_at 7d | ALL 120.25ms | range/idx_analytics_created_at 11.38ms | 10.6x |
| sessao created_at 30d | ALL 88.83ms | range/idx_sessao_created_at 9.06ms | 9.8x |
| transactions status_pedido | ALL 32.23ms | range/idx_tx_status 5.49ms | 5.9x |
| cookies created_at 30d | ALL 130.33ms | range/idx_cookies_created_at 31.34ms | 4.2x |Redução geral: 75% no tempo total das 15 queries do benchmark. 12 de 13 queries que faziam full table scan eliminaram o ALL.
O impacto no CloudWatch: CPU peak 94.1% → 26%
Na semana anterior aos índices (03-07/mar, db.r6g.2xlarge):
| Dia | Dia semana | Média | Pico máx |
|---------|------------|-------|----------|
| 03/mar | seg | 2.52% | 10.6% |
| 04/mar | ter | 2.57% | 43.6% |
| 05/mar | qua | 2.50% | 27.3% |
| 06/mar | qui | 2.54% | 94.1% |
| 07/mar | sex | 1.74% | 19.0% |
| Média | | 2.37% | 38.9% |Na semana após os índices (10-14/mar, mesma instância db.r6g.2xlarge):
| Dia | Dia semana | Média | Pico máx |
|---------|------------|-------|----------|
| 10/mar | seg | 2.30% | 14.0% |
| 11/mar | ter | 2.94% | 26.0% |
| 12/mar | qua | 2.42% | 11.7% |
| 13/mar | qui | 2.17% | 8.3% |
| 14/mar | sex | 1.51% | 8.1% |
| Média | | 2.27% | 13.6% |Pico absoluto: de 94.1% para 26.0%. Média dos picos: de 38.9% para 13.6% — redução de 65%. O único pico acima de 20% na segunda semana (26% na terça) foi causado por um evento operacional de deploy, não por carga do banco.
A sequência correta: índices primeiro, rightsize depois
Com os índices validados por uma semana comercial completa, o rightsizing foi possível com dados reais. A lógica é simples: se você faz o rightsizing antes dos índices, você está dimensionando a instância para uma carga anormal — com full scans que não deveriam existir.
Fazer RI antes do rightsizing é o erro mais caro: você trava 1 ou 3 anos em um tamanho que vai ser necessariamente reduzido após os índices. Uma Reserved Instance num db.r6g.2xlarge paga adiantada e que precisa ser descartada para um xlarge representa capital perdido.
A sequência correta:
1. Índices: eliminar full scans, medir impacto real em CPU
2. Aguardar uma semana comercial completa: coletar métricas de CPU com carga real de produção
3. Rightsizing: dimensionar para a carga pós-índices, não pré-índices
4. Aguardar outra semana comercial completa: validar que a instância menor comporta a carga
5. Reserved Instance: só depois que o tamanho correto está confirmado por dados reais
O rightsizing: db.r6g.2xlarge → xlarge
Com os dados da semana 10-14/mar confirmando pico máximo de 26% (e esse pico sendo evento operacional, não carga real), a projeção para db.r6g.xlarge (4 vCPUs) era direta: percentuais dobram com metade dos vCPUs.
O rightsizing foi agendado com '--no-apply-immediately' (aplicado na próxima janela de manutenção, 01:00 BRT do sábado). O RDS ficou indisponível por ~10-15 minutos durante a janela.
Na primeira semana comercial completa com db.r6g.xlarge (17-21/mar):
| Dia | Dia semana | Média | Pico máx |
|---------|------------|-------|----------|
| 17/mar | seg | 5.00% | 31.6% |
| 18/mar | ter | 4.52% | 39.4% |
| 19/mar | qua | 4.75% | 40.9% |
| 20/mar | qui | 4.18% | 20.1% |
| 21/mar | sex | 2.76% | 14.4% |
| Média | | 4.24% | 29.3% |Pico máximo de 40.9% numa quarta-feira — e esse pico ocorreu num único minuto às 15h BRT (burst de tráfego social). A média da hora foi 8.2%. Memória: 30 GB livres de 32 GB — confortável. O rightsizing estava validado.
Redução adicional para db.r6g.large (2 vCPUs) foi descartada: projeção de pico máximo ~82% sem margem de segurança para spikes imprevisíveis. A economia adicional de $213/mês não compensava o risco.
A Reserved Instance: $193/mês de economia adicional
Com o tamanho correto validado por uma semana completa, a Reserved Instance foi comprada: db.r6g.xlarge, 1 ano, No Upfront, Single-AZ, MariaDB, us-east-1.
| Modalidade RI | Upfront | Recorrente/h | Custo/mês equiv | Economia vs OD |
|-----------------|---------|-------------|-----------------|----------------|
| On-Demand | $0 | $0.519 | $374/mês | — |
| 1a No Upfront | $0 | $0.248 | $181/mês | $193/mês (52%) |
| 1a Partial | $1.035 | $0.118 | $172/mês | $202/mês (54%) |
| 1a All Upfront | $2.028 | $0 | $169/mês | $205/mês (55%) |Escolha: RI 1 ano No Upfront ($0 de upfront, $0.248/h). Motivo: a diferença entre No Upfront e All Upfront é $12/mês — não justifica travar $2.028 de capital. Com RI 3 anos, a diferença para o No Upfront de 1 ano é $65/mês a mais de economia, mas exige certeza de 36 meses de permanência.
A conta final: $566/mês, $6.792/ano
O ciclo completo de otimização do banco de dados, sem RI de EC2 (que é outro post):
| Etapa | Economia/mês | Acumulado |
|------------------------------------|-------------|------------|
| Base: db.r6g.2xlarge On-Demand | — | $747/mês |
| Após índices + rightsizing (xlarge)| $373/mês | $374/mês |
| Após RI 1a No Upfront | $193/mês | $181/mês |
| Total economizado | $566/mês | $6.792/ano |A tabela 'transactions' não tinha nenhum índice. Esse é o detalhe que mais importa neste caso. Não era uma questão de tuning avançado de banco — era a ausência do básico na tabela que mais importava. O RDS pagava $747/mês para fazer trabalho que índices teriam feito de graça.
Como replicar este ciclo
Os cinco passos que transformam 'RDS com CPU alta' em 'banco otimizado com custo correto':
1. Auditoria de queries reais: coletar as 10-20 queries mais frequentes do código (não da estimativa), executar com EXPLAIN, identificar full scans nas tabelas de maior volume.
2. Índices com ALGORITHM=INPLACE LOCK=NONE: em tabelas de produção ativas, sempre. Sem isso, a criação do índice pode bloquear a tabela por minutos.
3. Benchmark antes e depois: documentar tipo de acesso (ALL vs ref vs range), linhas varridas e tempo de execução para cada query crítica. Sem benchmark, você não sabe o impacto real.
4. Rightsize após uma semana comercial: nunca dimensionar com base em métricas de CPU pré-índices. Aguardar os dados reais pós-otimização.
5. Reserved Instance após rightsize validado: só reservar quando o tamanho correto está confirmado. RI no tamanho errado é capital travado que vai ser descartado.
