Buzeli
buzeliSoluções Digitais
Custos

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:

Copiar
-- 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: 51481

Tempo 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':

Copiar
-- 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:

Copiar
| 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):

Copiar
| 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):

Copiar
| 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):

Copiar
| 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.

Copiar
| 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):

Copiar
| 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.

Custos3 de maio de 2026

R$1.800/mês em egress desnecessário: como identificar clientes bypassando o CDN com auditoria de Security Groups

Em fevereiro, um único cliente de uma plataforma WordPress multi-tenant foi responsável por 31% de todo o egress da infraestrutura — $342 de $1.088 totais. O domínio estava configurado no GoCache CDN, mas 3.803 GB saíram diretamente do EC2. O Security Group revelou o motivo: o SG World (0.0.0.0/0) estava anexado junto com o SG CDN, mantendo a origin acessível diretamente.

Ler artigo