9.2 Desnormalização
TL;DR Desnormalização adiciona redundância para acelerar a leitura. Tipos: duplicar colunas (evitar JOIN), counter cache (agregações pré-computadas), summary tables, JSONB. Trade-off: SELECT mais rápido, UPDATE mais lento, risco de inconsistency. Solução: Observers para sincronizar sozinho, scheduled jobs para checar. Use em workload read-heavy. Evite em write-heavy.
Conteúdo
- O que é
- Tipos de desnormalização
- Exemplos práticos
- Riscos da desnormalização
- Quando desnormalizar
- Quando NÃO desnormalizar
- Boas práticas
- Verificar consistency
- Exercícios práticos
O que é
Desnormalização: Você adiciona redundância de propósito numa base já normalizada. Objetivo: leitura mais rápida.
Para quê:
- Menos JOIN (SELECT mais rápido)
- Agregações pré-computadas
- Menos carga no banco
Trade-off:
- ✅ SELECT mais rápido
- ❌ INSERT/UPDATE mais lento
- ❌ Risco de inconsistency (precisa sincronizar)
Tipos de desnormalização
1. Duplicar colunas
Problema: JOIN em todo request
// Normalized (3NF)
$orders = Order::with('customer')->get();
foreach ($orders as $order) {
echo $order->customer->name; // JOIN toda vez
}Solução: duplicar customer_name
// Migration
Schema::table('orders', function (Blueprint $table) {
$table->string('customer_name')->after('customer_id');
});
// Na criação do pedido
Order::create([
'customer_id' => $customer->id,
'customer_name' => $customer->name, // duplicação
'total' => 100,
]);
// Query SEM JOIN
$orders = Order::all();
foreach ($orders as $order) {
echo $order->customer_name; // sem JOIN!
}Sincronizar quando muda:
class Customer extends Model
{
protected static function booted()
{
static::updated(function ($customer) {
// Atualiza customer_name em todos os pedidos
Order::where('customer_id', $customer->id)
->update(['customer_name' => $customer->name]);
});
}
}2. Agregações pré-computadas
Problema: COUNT/SUM em todo request
// Normalized: conta toda vez
class User extends Model
{
public function getOrdersCountAttribute()
{
return $this->orders()->count(); // SELECT COUNT(*)
}
}Solução: guardar o counter
// Migration
Schema::table('users', function (Blueprint $table) {
$table->integer('orders_count')->default(0);
$table->decimal('total_spent', 10, 2)->default(0);
});
// Observer para atualizar sozinho
class OrderObserver
{
public function created(Order $order)
{
$order->user->increment('orders_count');
$order->user->increment('total_spent', $order->total);
}
public function deleted(Order $order)
{
$order->user->decrement('orders_count');
$order->user->decrement('total_spent', $order->total);
}
}
// Registrar o observer
Order::observe(OrderObserver::class);
// Uso (SEM query)
$user = User::find(1);
echo $user->orders_count; // sem SELECT COUNT(*)!
echo $user->total_spent;3. Tabelas resumo (summary tables)
Problema: agregações pesadas
-- Calcular toda vez (lento)
SELECT
DATE_TRUNC('day', created_at) as date,
COUNT(*) as orders_count,
SUM(total) as revenue
FROM orders
WHERE created_at >= NOW() - INTERVAL '30 days'
GROUP BY DATE_TRUNC('day', created_at);Solução: summary table
// Migration
Schema::create('daily_stats', function (Blueprint $table) {
$table->date('date')->primary();
$table->integer('orders_count')->default(0);
$table->decimal('revenue', 12, 2)->default(0);
$table->timestamps();
});
// Job para atualizar (hourly)
class UpdateDailyStats extends Command
{
public function handle()
{
$today = now()->toDateString();
$stats = Order::whereDate('created_at', $today)
->selectRaw('COUNT(*) as orders_count, SUM(total) as revenue')
->first();
DailyStat::updateOrCreate(
['date' => $today],
[
'orders_count' => $stats->orders_count,
'revenue' => $stats->revenue,
]
);
}
}
// Scheduler
$schedule->command('stats:update-daily')->hourly();
// Uso (rápido!)
$stats = DailyStat::where('date', '>=', now()->subDays(30))->get();4. Materialized Views
Ver o tópico 9.7 Materialized Views
5. JSONB para campos flexíveis
Problema: muitos campos opcionais
-- Normalized (muitos NULL)
CREATE TABLE products (
id INT PRIMARY KEY,
name VARCHAR(255),
color VARCHAR(50), -- NULL se não for roupa
size VARCHAR(10), -- NULL se não for roupa
cpu VARCHAR(50), -- NULL se não for eletrônico
ram VARCHAR(20), -- NULL se não for eletrônico
storage VARCHAR(50) -- NULL se não for eletrônico
);Solução: JSONB
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->decimal('price', 10, 2);
$table->jsonb('attributes'); // campos flexíveis
});
// Clothing
Product::create([
'name' => 'T-Shirt',
'attributes' => [
'color' => 'blue',
'size' => 'L',
'material' => 'cotton',
],
]);
// Electronics
Product::create([
'name' => 'Laptop',
'attributes' => [
'brand' => 'Dell',
'cpu' => 'Intel i7',
'ram' => '16GB',
],
]);Exemplos práticos
1. Posts com counter cache
Problema:
// COUNT toda vez (lento)
class Post extends Model
{
public function comments()
{
return $this->hasMany(Comment::class);
}
}
$posts = Post::all();
foreach ($posts as $post) {
echo $post->comments()->count(); // N+1 queries!
}Solução:
// Migration
Schema::table('posts', function (Blueprint $table) {
$table->integer('comments_count')->default(0);
});
// Observer
class CommentObserver
{
public function created(Comment $comment)
{
$comment->post->increment('comments_count');
}
public function deleted(Comment $comment)
{
$comment->post->decrement('comments_count');
}
}
// Uso (SEM queries)
$posts = Post::all();
foreach ($posts as $post) {
echo $post->comments_count; // 0 queries!
}2. E-commerce: totais do pedido
Problema:
// Calcular total toda vez
class Order extends Model
{
public function getTotalAttribute()
{
return $this->items()->sum('price * quantity'); // SUM query
}
}Solução:
// Migration
Schema::table('orders', function (Blueprint $table) {
$table->decimal('total', 10, 2)->default(0);
});
// Observer
class OrderItemObserver
{
public function created(OrderItem $item)
{
$this->recalculateTotal($item->order);
}
public function updated(OrderItem $item)
{
$this->recalculateTotal($item->order);
}
public function deleted(OrderItem $item)
{
$this->recalculateTotal($item->order);
}
private function recalculateTotal(Order $order)
{
$total = $order->items()
->selectRaw('SUM(price * quantity) as total')
->value('total');
$order->update(['total' => $total ?? 0]);
}
}
// Uso
$order = Order::find(1);
echo $order->total; // sem SUM query!3. Full-text search com desnormalização
Problema:
-- Busca em title + body + tags (JOIN + concat)
SELECT posts.*
FROM posts
LEFT JOIN tags ON posts.id = tags.post_id
WHERE
posts.title ILIKE '%keyword%' OR
posts.body ILIKE '%keyword%' OR
tags.name ILIKE '%keyword%';Solução: search_vector
// Migration
Schema::table('posts', function (Blueprint $table) {
$table->text('search_text'); // busca desnormalizada
$table->index('search_text', null, 'gin');
});
// Observer
class PostObserver
{
public function saved(Post $post)
{
// Junta todos os campos searchable
$searchText = implode(' ', [
$post->title,
$post->body,
$post->tags->pluck('name')->implode(' '),
]);
$post->updateQuietly(['search_text' => $searchText]);
}
}
// Uso (busca rápida)
Post::whereRaw("search_text ILIKE ?", ["%$keyword%"])->get();Riscos da desnormalização
1. Data Inconsistency
Problema:
// Se esquecer de atualizar o campo desnormalizado
Order::where('customer_id', 1)->update([
'total' => 200,
// Esqueceu de atualizar customer.total_spent!
]);Solução: Observers
class OrderObserver
{
public function updated(Order $order)
{
// Recalcula sozinho
if ($order->isDirty('total')) {
$this->recalculateCustomerTotalSpent($order->customer);
}
}
}2. WRITE lento
Problema:
// Cada INSERT atualiza o counter
Comment::create([...]); // + UPDATE posts.comments_countSolução: Queue para bulk updates
// Em vez de update imediato
class Comment extends Model
{
protected static function booted()
{
static::created(function ($comment) {
// Adia o update do counter
UpdatePostCommentsCount::dispatch($comment->post_id)->delay(60);
});
}
}Quando desnormalizar
✓ Workload read-heavy (SELECT >> INSERT/UPDATE)
✓ JOIN caro
✓ Agregações pesadas (COUNT, SUM)
✓ Dashboards, reports
✓ Full-text search
✓ Performance críticaQuando NÃO desnormalizar
❌ Workload write-heavy
❌ Strong consistency é crítica
❌ Dados mudam o tempo todo
❌ Banco pequeno (sem problema de performance)Boas práticas
✓ Desnormalização = trade-off (velocidade vs consistency)
✓ Use Observers para sincronizar sozinho
✓ Só desnormalize DEPOIS de perfilar (não cedo demais)
✓ Documente os campos desnormalizados
✓ Scheduled jobs para checar consistency
✓ Logue divergência (monitoring)
✓ Materialized Views para agregações pesadasVerificar consistency
// Comando Artisan para checar
class CheckCountersConsistency extends Command
{
public function handle()
{
$users = User::all();
foreach ($users as $user) {
$actualCount = $user->orders()->count();
$cachedCount = $user->orders_count;
if ($actualCount !== $cachedCount) {
$this->error("User {$user->id}: esperado {$actualCount}, veio {$cachedCount}");
// Fix
$user->update(['orders_count' => $actualCount]);
}
}
}
}
// Scheduler: checa uma vez por dia
$schedule->command('check:counters-consistency')->daily();Exercícios práticos
Exercício 1: Implementar counter cache
Enunciado: Você tem um blog com posts e comentários. Toda vez que lista os posts, roda N+1 para contar comentários. Otimize com desnormalização.
Solução
// Migration: adicionar counter cache
Schema::table('posts', function (Blueprint $table) {
$table->integer('comments_count')->default(0)->after('body');
$table->index('comments_count');
});
// Observer para atualizar sozinho
class CommentObserver
{
public function created(Comment $comment)
{
$comment->post->increment('comments_count');
}
public function deleted(Comment $comment)
{
$comment->post->decrement('comments_count');
}
public function restored(Comment $comment)
{
$comment->post->increment('comments_count');
}
}
// No AppServiceProvider
public function boot()
{
Comment::observe(CommentObserver::class);
}
// ANTES: N+1 queries
$posts = Post::all();
foreach ($posts as $post) {
echo $post->comments()->count(); // SELECT COUNT(*)
}
// DEPOIS: 1 query
$posts = Post::all();
foreach ($posts as $post) {
echo $post->comments_count; // 0 queries!
}
// Comando para recalcular (se dessincronizar)
class RecalculateCommentsCount extends Command
{
protected $signature = 'posts:recalculate-comments';
public function handle()
{
Post::query()->chunkById(100, function ($posts) {
foreach ($posts as $post) {
$count = $post->comments()->count();
$post->update(['comments_count' => $count]);
}
});
$this->info('Contagem de comentários recalculada!');
}
}Pontos-chave:
- Evitou o N+1
- Lista de posts rápida
- Dá para ordenar por popularidade
Exercício 2: Desnormalizar para evitar JOIN
Enunciado: A tabela de pedidos sempre mostra o nome do cliente. Todo request faz JOIN. Otimize.
Solução
// Migration: adicionar customer_name
Schema::table('orders', function (Blueprint $table) {
$table->string('customer_name')->after('customer_id');
$table->index(['customer_id', 'customer_name']);
});
// Observer para sincronizar
class CustomerObserver
{
public function updated(Customer $customer)
{
// Se o nome mudou, atualiza todos os pedidos
if ($customer->isDirty('name')) {
Order::where('customer_id', $customer->id)
->update(['customer_name' => $customer->name]);
}
}
}
// Na criação do pedido
class CreateOrderAction
{
public function execute(Customer $customer, array $items)
{
return Order::create([
'customer_id' => $customer->id,
'customer_name' => $customer->name, // duplicamos
'total' => $this->calculateTotal($items),
]);
}
}
// ANTES: JOIN em todo request
$orders = Order::with('customer')->get();
foreach ($orders as $order) {
echo $order->customer->name; // JOIN
}
// DEPOIS: sem JOIN
$orders = Order::all();
foreach ($orders as $order) {
echo $order->customer_name; // sem JOIN!
}
// Endpoint de API (mais rápido)
Route::get('/orders', function () {
return Order::select('id', 'customer_name', 'total', 'created_at')
->latest()
->paginate(20);
// Sem JOIN com customers!
});Trade-offs:
- ✅ SELECT mais rápido (sem JOIN)
- ✅ Menos carga no banco
- ❌ Ocupa mais espaço
- ❌ Precisa sincronizar no UPDATE de customer.name
Exercício 3: Summary table para analytics
Enunciado: Você precisa de um dashboard com estatística diária de vendas do último ano. GROUP BY em milhões de pedidos toda vez é lento demais. Otimize.
Solução
// Migration: summary table
Schema::create('daily_sales_stats', function (Blueprint $table) {
$table->date('date')->primary();
$table->integer('orders_count')->default(0);
$table->decimal('revenue', 12, 2)->default(0);
$table->decimal('avg_order_value', 10, 2)->default(0);
$table->integer('new_customers')->default(0);
$table->timestamps();
});
// Job para atualizar a estatística
class UpdateDailySalesStats implements ShouldQueue
{
public function handle()
{
$yesterday = now()->subDay()->toDateString();
// Junta a estatística de ontem
$stats = Order::whereDate('created_at', $yesterday)
->selectRaw('
COUNT(*) as orders_count,
SUM(total) as revenue,
AVG(total) as avg_order_value
')
->first();
$newCustomers = Customer::whereDate('created_at', $yesterday)->count();
DailySalesStat::updateOrCreate(
['date' => $yesterday],
[
'orders_count' => $stats->orders_count ?? 0,
'revenue' => $stats->revenue ?? 0,
'avg_order_value' => $stats->avg_order_value ?? 0,
'new_customers' => $newCustomers,
]
);
}
}
// Scheduler: roda toda noite
protected function schedule(Schedule $schedule)
{
$schedule->job(new UpdateDailySalesStats)->dailyAt('01:00');
}
// Controller: dashboard rápido
class DashboardController extends Controller
{
public function index()
{
// ANTES: query lenta em milhões de pedidos
// $stats = Order::where('created_at', '>=', now()->subYear())
// ->groupBy(DB::raw('DATE(created_at)'))
// ->select(...)
// ->get();
// DEPOIS: query rápida na summary table
$stats = DailySalesStat::where('date', '>=', now()->subYear())
->orderBy('date')
->get();
return view('dashboard', compact('stats'));
}
}
// Endpoint de API
Route::get('/api/stats/monthly', function () {
return DailySalesStat::selectRaw('
DATE_TRUNC(\'month\', date) as month,
SUM(orders_count) as total_orders,
SUM(revenue) as total_revenue
')
->where('date', '>=', now()->subYear())
->groupBy('month')
->get();
// Super rápido!
});Pontos-chave:
- Dashboard carrega na hora
- Sem carga na tabela principal orders
- Dá para acrescentar métricas
- Histórico fica guardado
Na entrevista
"Desnormalização é adicionar redundância para leitura mais rápida. Tipos: duplicar colunas (evitar JOIN), agregações pré-computadas (counter cache), summary tables, JSONB para campos flexíveis. Trade-off: SELECT mais rápido, INSERT/UPDATE mais lento, risco de inconsistency. Solução: Observers para sincronizar sozinho, scheduled jobs para checar consistency. Quando usar: workload read-heavy, JOIN caro, dashboards. Quando não: write-heavy, strong consistency crítica. Best practices: perfilar antes de desnormalizar, documentar, monitorar consistency."
Parte do PHP/Laravel Interview Handbook | Feito com ❤️ pela equipe CodeMate