Como projetar schemas de banco de dados para alta leitura
1. Fundamentos da modelagem orientada a leitura
Projetar schemas para alta leitura exige uma mudança fundamental de mentalidade. Em sistemas OLTP tradicionais, a normalização é priorizada para evitar anomalias de escrita. Já em cenários de leitura intensa — como dashboards analíticos, feeds de conteúdo ou APIs de alto tráfego — o objetivo é minimizar o número de acessos ao disco e reduzir a complexidade das consultas.
Identificação de padrões de acesso é o primeiro passo. É necessário mapear quais consultas respondem por 80% da carga do sistema. Por exemplo, em um sistema de e-commerce, a consulta "buscar produtos por categoria com preço e estoque" pode ocorrer 10.000 vezes por minuto, enquanto a inserção de um novo produto ocorre 10 vezes por hora.
A diferença entre esquemas normalizados (3FN) e desnormalizados pode ser ilustrada com um exemplo prático:
-- Esquema normalizado (3FN)
CREATE TABLE pedidos (
id INT PRIMARY KEY,
cliente_id INT,
data_pedido DATE
);
CREATE TABLE itens_pedido (
id INT PRIMARY KEY,
pedido_id INT,
produto_id INT,
quantidade INT,
preco_unitario DECIMAL(10,2)
);
CREATE TABLE produtos (
id INT PRIMARY KEY,
nome VARCHAR(100),
categoria VARCHAR(50)
);
-- Consulta para obter total por pedido (2 JOINs)
SELECT p.id, SUM(ip.quantidade * ip.preco_unitario) AS total
FROM pedidos p
JOIN itens_pedido ip ON p.id = ip.pedido_id
JOIN produtos pr ON ip.produto_id = pr.id
WHERE p.data_pedido >= '2024-01-01'
GROUP BY p.id;
-- Esquema desnormalizado para leitura
CREATE TABLE pedidos_com_totais (
id INT PRIMARY KEY,
cliente_id INT,
data_pedido DATE,
total_pedido DECIMAL(12,2),
quantidade_itens INT,
categorias TEXT -- 'eletronicos,livros'
);
-- Consulta sem JOINs
SELECT id, total_pedido FROM pedidos_com_totais
WHERE data_pedido >= '2024-01-01';
Métricas de desempenho relevantes incluem:
- Latência de query: tempo entre envio e recebimento dos dados
- Throughput: queries por segundo (QPS)
- Taxa de cache hit: percentual de dados servidos do buffer pool sem acesso a disco
2. Estratégias de desnormalização controlada
A desnormalização não significa abandonar toda estrutura. Trata-se de duplicação seletiva de colunas que são frequentemente acessadas juntas.
Duplicação seletiva de colunas para evitar JOINs críticos:
-- Em vez de JOIN com tabela de clientes a cada consulta
CREATE TABLE pedidos_com_cliente (
id INT PRIMARY KEY,
cliente_id INT,
cliente_nome VARCHAR(100),
cliente_email VARCHAR(200), -- duplicado
data_pedido DATE,
valor_total DECIMAL(12,2)
);
Tabelas de resumo (aggregate tables) são essenciais para consultas agregadas:
CREATE TABLE resumo_vendas_diarias (
data DATE PRIMARY KEY,
total_vendas DECIMAL(14,2),
quantidade_pedidos INT,
ticket_medio DECIMAL(10,2)
);
-- Atualização programada a cada 5 minutos
INSERT INTO resumo_vendas_diarias (data, total_vendas, quantidade_pedidos, ticket_medio)
SELECT
DATE(data_pedido),
SUM(valor_total),
COUNT(*),
AVG(valor_total)
FROM pedidos
WHERE data_pedido >= CURDATE() - INTERVAL 1 DAY
GROUP BY DATE(data_pedido)
ON DUPLICATE KEY UPDATE
total_vendas = VALUES(total_vendas),
quantidade_pedidos = VALUES(quantidade_pedidos),
ticket_medio = VALUES(ticket_medio);
Materialized views com refresh periódico são ideais para dados que não precisam de atualização em tempo real:
CREATE MATERIALIZED VIEW mv_vendas_por_categoria AS
SELECT
pr.categoria,
DATE(p.data_pedido) AS data_venda,
SUM(ip.quantidade * ip.preco_unitario) AS receita
FROM pedidos p
JOIN itens_pedido ip ON p.id = ip.pedido_id
JOIN produtos pr ON ip.produto_id = pr.id
GROUP BY pr.categoria, DATE(p.data_pedido)
WITH DATA;
-- Refresh a cada hora
REFRESH MATERIALIZED VIEW mv_vendas_por_categoria;
3. Indexação avançada para alta performance
Índices compostos devem ser projetados para cobrir consultas específicas:
-- Consulta frequente: busca por status + data + cliente
CREATE INDEX idx_pedidos_status_data_cliente
ON pedidos (status, data_pedido, cliente_id)
INCLUDE (valor_total, quantidade_itens); -- covering index
Índices parciais para filtros de alta cardinalidade:
-- Apenas 5% dos pedidos estão com status 'pendente'
CREATE INDEX idx_pedidos_pendentes
ON pedidos (data_pedido, prioridade)
WHERE status = 'pendente';
Estratégias de manutenção devem ser programadas em janelas de baixa carga:
-- Reorganizar índice (menos intensivo)
ALTER INDEX idx_pedidos_data REORGANIZE;
-- Reconstruir índice (mais intensivo, requer bloqueio)
ALTER INDEX idx_pedidos_data REBUILD WITH (ONLINE = ON);
4. Particionamento e sharding de dados
Particionamento horizontal por range para consultas por data:
CREATE TABLE logs_acesso (
id BIGINT,
usuario_id INT,
data_acesso DATETIME,
pagina VARCHAR(200),
PRIMARY KEY (id, data_acesso)
) PARTITION BY RANGE (YEAR(data_acesso)) (
PARTITION p2022 VALUES LESS THAN (2023),
PARTITION p2023 VALUES LESS THAN (2024),
PARTITION p2024 VALUES LESS THAN (2025),
PARTITION p_futuro VALUES LESS THAN MAXVALUE
);
-- Partition pruning automático
SELECT * FROM logs_acesso
WHERE data_acesso BETWEEN '2024-01-01' AND '2024-03-31';
-- Acessa apenas partição p2024
Sharding geográfico para redução de latência:
-- Aplicação roteia para o shard correto
-- Shard 1: servidor em São Paulo (clientes SP, RJ, MG)
-- Shard 2: servidor em Fortaleza (clientes NE)
-- Shard 3: servidor em Manaus (clientes NO)
-- Roteamento por hash do ID do cliente
shard_id = cliente_id % 3 + 1;
5. Uso de caches e camadas de leitura intermediárias
Cache de resultados frequentes com Redis:
-- Verificar cache antes de consultar banco
function buscarProduto(produtoId) {
cacheKey = "produto:" + produtoId;
produto = redis.get(cacheKey);
if (produto == null) {
produto = db.query("SELECT * FROM produtos WHERE id = ?", [produtoId]);
redis.setex(cacheKey, 3600, JSON.stringify(produto)); // TTL de 1 hora
}
return JSON.parse(produto);
}
Cache de read models em vez de queries brutas:
-- Read model pré-formatado para a API
cacheKey = "dashboard:vendas:2024-03";
dashboardData = redis.get(cacheKey);
if (dashboardData == null) {
dashboardData = db.query("SELECT * FROM resumo_vendas_diarias WHERE data BETWEEN ? AND ?",
["2024-03-01", "2024-03-31"]);
redis.setex(cacheKey, 300, JSON.stringify(dashboardData)); // TTL de 5 minutos
}
Estratégias de invalidação:
- Write-through: atualiza cache e banco simultaneamente
- Write-behind: atualiza cache primeiro, banco de forma assíncrona
- TTL adaptativo: itens mais acessados têm TTL mais longo
6. Otimização de queries e plano de execução
Análise de EXPLAIN para identificar scans sequenciais:
EXPLAIN ANALYZE
SELECT p.id, p.valor_total
FROM pedidos p
WHERE p.data_pedido >= '2024-01-01'
AND p.status = 'entregue';
-- Saída esperada:
-- -> Index Range Scan on idx_pedidos_status_data (cost=12.3 rows=5000)
-- Index Cond: ((status = 'entregue') AND (data_pedido >= '2024-01-01'))
Uso de CTEs para reduzir processamento repetido:
WITH top_clientes AS (
SELECT cliente_id, SUM(valor_total) as total_gasto
FROM pedidos
WHERE data_pedido >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY cliente_id
ORDER BY total_gasto DESC
LIMIT 100
)
SELECT c.nome, tc.total_gasto
FROM top_clientes tc
JOIN clientes c ON tc.cliente_id = c.id;
Ajuste de parâmetros no MySQL:
-- Aumentar buffer pool para 80% da RAM disponível
SET GLOBAL innodb_buffer_pool_size = 8589934592; -- 8GB
-- Aumentar sort buffer para consultas com ORDER BY
SET GLOBAL sort_buffer_size = 4194304; -- 4MB
7. Monitoramento e evolução contínua do schema
Métricas-chave a monitorar:
-- Cache hit ratio no MySQL
SHOW STATUS LIKE 'Innodb_buffer_pool_read_requests';
SHOW STATUS LIKE 'Innodb_buffer_pool_reads';
-- Cálculo: (read_requests - reads) / read_requests * 100
-- Ideal: > 99%
Identificação de slow queries:
-- Ativar slow query log no MySQL
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- queries acima de 1 segundo
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow-queries.log';
-- Analisar com pt-query-digest
pt-query-digest /var/log/mysql/slow-queries.log
Migração de schema sem downtime:
-- Passo 1: Adicionar nova coluna como NULL
ALTER TABLE pedidos ADD COLUMN novo_status VARCHAR(20) NULL;
-- Passo 2: Preencher dados em lote (janela de baixa carga)
UPDATE pedidos SET novo_status = status WHERE novo_status IS NULL LIMIT 10000;
-- Passo 3: Criar trigger para manter sincronizado
CREATE TRIGGER sync_status
BEFORE INSERT ON pedidos
FOR EACH ROW
SET NEW.novo_status = NEW.status;
-- Passo 4: Migrar aplicação para usar novo_status
-- Passo 5: Remover coluna antiga (em manutenção programada)
ALTER TABLE pedidos DROP COLUMN status;
Referências
- PostgreSQL Materialized Views Documentation — Documentação oficial sobre criação e refresh de materialized views no PostgreSQL
- MySQL Indexing Best Practices — Guia completo do MySQL sobre otimização de índices, covering indexes e índices parciais
- Redis Caching Patterns — Padrões de cache com Redis, incluindo write-through e write-behind
- Database Sharding Explained — Conceitos de sharding horizontal, particionamento por range e hash
- Performance Schema in MySQL — Ferramenta de profiling e monitoramento de queries no MySQL
- Online Schema Change with pt-online-schema-change — Ferramenta da Percona para migrações de schema sem downtime
- EXPLAIN Command Documentation — Como interpretar planos de execução no PostgreSQL
- InnoDB Buffer Pool Optimization — Ajuste de buffer pool para alta performance de leitura no MySQL