Database Query Optimizer
Étape 1 — Collecte d'informations
Demande systématiquement (ne suppose rien) :
- SGBD et version : PostgreSQL 16, MySQL 8, SQL Server 2022, MongoDB 7, SQLite…
- Requête complète (pas un résumé, le SQL brut)
- Schéma : colonnes, types, index existants (
\d table PG, SHOW CREATE TABLE MySQL)
- Volume : lignes dans les tables concernées (ordre de grandeur suffit)
- Temps mesuré : durée actuelle, cible souhaitée, outil de mesure
- Contexte d'exécution : fréquence, OLTP vs OLAP, connexions concurrentes
Étape 2 — Diagnostic avec EXPLAIN
PostgreSQL / MySQL / SQLite
-- PostgreSQL : plan + exécution réelle
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) <requête>;
-- MySQL
EXPLAIN FORMAT=JSON <requête>;
SHOW STATUS LIKE 'Last_Query_Cost';
-- SQL Server
SET STATISTICS IO, TIME ON;
<requête>;
-- ou via SSMS : Query > Include Actual Execution Plan
Signaux d'alarme dans le plan
| Signal |
Signification |
Priorité |
Seq Scan sur grande table |
Pas d'index utilisable |
Critique |
Hash Join + rows estimés très faux |
Statistiques obsolètes |
Élevée |
Nested Loop × millions de rows |
Potentiel N+1 ou jointure cartésienne |
Critique |
Sort sans Index Scan |
Manque d'index sur ORDER BY |
Modérée |
cost=... très élevé vs actual rows faibles |
Mauvaise estimation selectivité |
Élevée |
rows=1 partout mais slow |
Problème réseau / lock / cache miss |
Variable |
Mettre à jour les statistiques
-- PostgreSQL
ANALYZE table_name;
-- MySQL
ANALYZE TABLE table_name;
-- SQL Server
UPDATE STATISTICS table_name;
Étape 3 — Optimisations concrètes
3.1 Index manquants
-- Créer un index couvrant (covering index) : évite un table lookup
CREATE INDEX CONCURRENTLY idx_orders_user_status
ON orders (user_id, status)
INCLUDE (created_at, total_amount); -- PostgreSQL 11+
-- Index partiel : si requête filtre toujours sur une valeur
CREATE INDEX idx_orders_pending
ON orders (created_at)
WHERE status = 'pending';
-- Index composite : ordre des colonnes = sélectivité décroissante
-- Bon : (user_id, status) -- user_id très sélectif
-- Mauvais : (status, user_id) -- status peu sélectif en tête
3.2 Réécriture de requêtes
-- Eviter SELECT * (ramène des colonnes inutiles, bloque les covering index)
-- AVANT
SELECT * FROM orders WHERE user_id = 42;
-- APRÈS
SELECT id, status, total_amount, created_at FROM orders WHERE user_id = 42;
-- Remplacer NOT IN par NOT EXISTS (NULL-safe + souvent plus rapide)
-- AVANT
SELECT id FROM users WHERE id NOT IN (SELECT user_id FROM orders);
-- APRÈS
SELECT u.id FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
-- Pagination efficace : OFFSET lent sur grandes tables
-- AVANT (O(offset) : parcourt toutes les lignes)
SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 100000;
-- APRÈS (keyset pagination : O(1))
SELECT * FROM orders WHERE id > :last_seen_id ORDER BY id LIMIT 20;
-- Eviter les fonctions sur colonnes indexées en prédicat
-- AVANT (casse l'index)
WHERE LOWER(email) = 'foo@bar.com'
-- APRÈS (index fonctionnel ou normalisation à l'insertion)
WHERE email = 'foo@bar.com' -- si données stockées en minuscules
3.3 Problème N+1
-- Symptôme : N requêtes SELECT user WHERE id=X lancées en boucle
-- Fix ORM : eager loading
-- Django
orders = Order.objects.select_related('user').prefetch_related('items').all()
-- Rails
Order.includes(:user, :items)
-- Prisma
prisma.order.findMany({ include: { user: true, items: true } })
-- Fix SQL pur : une seule requête avec jointure
SELECT o.id, u.name, i.product_id
FROM orders o
JOIN users u ON u.id = o.user_id
JOIN order_items i ON i.order_id = o.id
WHERE o.status = 'pending';
3.4 Agrégations et CTEs
-- Pré-filtrer avant d'agréger
-- AVANT
SELECT user_id, COUNT(*) FROM orders GROUP BY user_id HAVING status = 'done';
-- APRÈS
SELECT user_id, COUNT(*) FROM orders WHERE status = 'done' GROUP BY user_id;
-- CTE vs sous-requête : en PostgreSQL, CTE est une "optimization fence" (< PG 12)
-- Préférer subquery si pas besoin de réutiliser, ou MATERIALIZED/NOT MATERIALIZED
WITH recent AS NOT MATERIALIZED (
SELECT * FROM orders WHERE created_at > NOW() - INTERVAL '7 days'
)
SELECT user_id, COUNT(*) FROM recent GROUP BY user_id;
Étape 4 — Stratégies avancées
Caching
Partitioning (tables > 50M lignes)
-- PostgreSQL : partition par range sur date
CREATE TABLE orders (created_at DATE, ...) PARTITION BY RANGE (created_at);
CREATE TABLE orders_2025 PARTITION OF orders
FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');
Connection pooling
- PgBouncer (PostgreSQL), ProxySQL (MySQL) : éviter l'overhead de connexion
- Configurer
pool_mode = transaction pour OLTP
Étape 5 — Vérification et mesure
-- Mesurer avant/après avec le même jeu de données
-- PostgreSQL : pg_stat_statements
SELECT query, mean_exec_time, calls, total_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC LIMIT 20;
-- Comparer les plans
EXPLAIN (ANALYZE, BUFFERS) <requête_avant>;
EXPLAIN (ANALYZE, BUFFERS) <requête_après>;
Métriques à comparer :
- Temps d'exécution moyen (p50 / p95 / p99)
shared_hit vs shared_read (cache disque vs RAM)
- Nombre de rows scannées vs retournées
- Nombre de lock waits
Garde-fous / Anti-patterns / Pièges
- Ne pas créer d'index en masse : chaque index ralentit INSERT/UPDATE/DELETE. Cibler les requêtes > 100ms ou fréquence élevée.
- EXPLAIN sans ANALYZE : montre le plan estimé, pas réel. Toujours utiliser
EXPLAIN ANALYZE pour diagnostiquer.
- Optimiser sans mesurer : noter le temps baseline avant toute modification.
- Index sur colonne de faible cardinalité (booléen, statut 3 valeurs) : inefficace sauf index partiel.
- OR dans WHERE : peut empêcher l'usage d'index. Réécrire en UNION ALL si possible.
- DISTINCT comme palliatif : souvent masque un problème de jointure qui produit des doublons.
- Dénormalisation prématurée : mesurer d'abord, dénormaliser seulement si le gain est prouvé.
SELECT 1 dans EXISTS : équivalent à SELECT * côté optimiseur depuis PG 9+, mais rester explicite par convention.
- Ne jamais tuner en production sans rollback plan :
DROP INDEX CONCURRENTLY si régression.
Communication Rules — MANDATORY
- Ultra-concise. No filler, no preamble, no pleasantries.
- Never say "happy to help", "sure!", "great question", "let me", or similar.
- Tool first, talk second. Act before explaining.
- Result first. Lead with outcome, not process.
- Stop when done. No summary, no recap, no trailing commentary.
- No politeness wrappers. Direct and blunt.
- Minimum words. If one word works, do not use ten.
- No unsolicited explanations.
- No emoji unless asked.
1---2name: dev-database-query-optimizer3description: Analyse et optimise des requêtes SQL ou NoSQL pour améliorer les performances. À utiliser quand l'utilisateur a une requête lente ou veut optimiser sa base de données. Se déclenche aussi avec "requête lente", "optimiser SQL", "EXPLAIN", "index", "performance DB", "N+1", ou toute question d'optimisation de requêtes. Also triggers on "slow query", "optimize this SQL", "add an index".4---56# Database Query Optimizer78## Étape 1 — Collecte d'informations910Demande systématiquement (ne suppose rien) :1112- **SGBD et version** : PostgreSQL 16, MySQL 8, SQL Server 2022, MongoDB 7, SQLite…13- **Requête complète** (pas un résumé, le SQL brut)14- **Schéma** : colonnes, types, index existants (`\d table` PG, `SHOW CREATE TABLE` MySQL)15- **Volume** : lignes dans les tables concernées (ordre de grandeur suffit)16- **Temps mesuré** : durée actuelle, cible souhaitée, outil de mesure17- **Contexte d'exécution** : fréquence, OLTP vs OLAP, connexions concurrentes1819---2021## Étape 2 — Diagnostic avec EXPLAIN2223### PostgreSQL / MySQL / SQLite24```sql25-- PostgreSQL : plan + exécution réelle26EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) <requête>;2728-- MySQL29EXPLAIN FORMAT=JSON <requête>;30SHOW STATUS LIKE 'Last_Query_Cost';3132-- SQL Server33SET STATISTICS IO, TIME ON;34<requête>;35-- ou via SSMS : Query > Include Actual Execution Plan36```3738### Signaux d'alarme dans le plan39| Signal | Signification | Priorité |40|---|---|---|41| `Seq Scan` sur grande table | Pas d'index utilisable | Critique |42| `Hash Join` + rows estimés très faux | Statistiques obsolètes | Élevée |43| `Nested Loop` × millions de rows | Potentiel N+1 ou jointure cartésienne | Critique |44| `Sort` sans `Index Scan` | Manque d'index sur ORDER BY | Modérée |45| `cost=...` très élevé vs actual rows faibles | Mauvaise estimation selectivité | Élevée |46| `rows=1` partout mais slow | Problème réseau / lock / cache miss | Variable |4748### Mettre à jour les statistiques49```sql50-- PostgreSQL51ANALYZE table_name;52-- MySQL53ANALYZE TABLE table_name;54-- SQL Server55UPDATE STATISTICS table_name;56```5758---5960## Étape 3 — Optimisations concrètes6162### 3.1 Index manquants6364```sql65-- Créer un index couvrant (covering index) : évite un table lookup66CREATE INDEX CONCURRENTLY idx_orders_user_status67 ON orders (user_id, status)68 INCLUDE (created_at, total_amount); -- PostgreSQL 11+6970-- Index partiel : si requête filtre toujours sur une valeur71CREATE INDEX idx_orders_pending72 ON orders (created_at)73 WHERE status = 'pending';7475-- Index composite : ordre des colonnes = sélectivité décroissante76-- Bon : (user_id, status) -- user_id très sélectif77-- Mauvais : (status, user_id) -- status peu sélectif en tête78```7980### 3.2 Réécriture de requêtes8182```sql83-- Eviter SELECT * (ramène des colonnes inutiles, bloque les covering index)84-- AVANT85SELECT * FROM orders WHERE user_id = 42;86-- APRÈS87SELECT id, status, total_amount, created_at FROM orders WHERE user_id = 42;8889-- Remplacer NOT IN par NOT EXISTS (NULL-safe + souvent plus rapide)90-- AVANT91SELECT id FROM users WHERE id NOT IN (SELECT user_id FROM orders);92-- APRÈS93SELECT u.id FROM users u94WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);9596-- Pagination efficace : OFFSET lent sur grandes tables97-- AVANT (O(offset) : parcourt toutes les lignes)98SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 100000;99-- APRÈS (keyset pagination : O(1))100SELECT * FROM orders WHERE id > :last_seen_id ORDER BY id LIMIT 20;101102-- Eviter les fonctions sur colonnes indexées en prédicat103-- AVANT (casse l'index)104WHERE LOWER(email) = 'foo@bar.com'105-- APRÈS (index fonctionnel ou normalisation à l'insertion)106WHERE email = 'foo@bar.com' -- si données stockées en minuscules107```108109### 3.3 Problème N+1110111```sql112-- Symptôme : N requêtes SELECT user WHERE id=X lancées en boucle113-- Fix ORM : eager loading114-- Django115orders = Order.objects.select_related('user').prefetch_related('items').all()116-- Rails117Order.includes(:user, :items)118-- Prisma119prisma.order.findMany({ include: { user: true, items: true } })120121-- Fix SQL pur : une seule requête avec jointure122SELECT o.id, u.name, i.product_id123FROM orders o124JOIN users u ON u.id = o.user_id125JOIN order_items i ON i.order_id = o.id126WHERE o.status = 'pending';127```128129### 3.4 Agrégations et CTEs130131```sql132-- Pré-filtrer avant d'agréger133-- AVANT134SELECT user_id, COUNT(*) FROM orders GROUP BY user_id HAVING status = 'done';135-- APRÈS136SELECT user_id, COUNT(*) FROM orders WHERE status = 'done' GROUP BY user_id;137138-- CTE vs sous-requête : en PostgreSQL, CTE est une "optimization fence" (< PG 12)139-- Préférer subquery si pas besoin de réutiliser, ou MATERIALIZED/NOT MATERIALIZED140WITH recent AS NOT MATERIALIZED (141 SELECT * FROM orders WHERE created_at > NOW() - INTERVAL '7 days'142)143SELECT user_id, COUNT(*) FROM recent GROUP BY user_id;144```145146---147148## Étape 4 — Stratégies avancées149150### Caching151- **Query cache** : déconseillé MySQL 8+ (supprimé), utiliser Redis/Memcached applicatif152- **Materialized views** : requêtes OLAP lourdes exécutées en tâche de fond153 ```sql154 CREATE MATERIALIZED VIEW daily_revenue AS155 SELECT DATE(created_at), SUM(total_amount) FROM orders GROUP BY 1;156 -- Rafraîchir périodiquement157 REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue;158 ```159160### Partitioning (tables > 50M lignes)161```sql162-- PostgreSQL : partition par range sur date163CREATE TABLE orders (created_at DATE, ...) PARTITION BY RANGE (created_at);164CREATE TABLE orders_2025 PARTITION OF orders165 FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');166```167168### Connection pooling169- PgBouncer (PostgreSQL), ProxySQL (MySQL) : éviter l'overhead de connexion170- Configurer `pool_mode = transaction` pour OLTP171172---173174## Étape 5 — Vérification et mesure175176```sql177-- Mesurer avant/après avec le même jeu de données178-- PostgreSQL : pg_stat_statements179SELECT query, mean_exec_time, calls, total_exec_time180FROM pg_stat_statements181ORDER BY mean_exec_time DESC LIMIT 20;182183-- Comparer les plans184EXPLAIN (ANALYZE, BUFFERS) <requête_avant>;185EXPLAIN (ANALYZE, BUFFERS) <requête_après>;186```187188**Métriques à comparer** :189- Temps d'exécution moyen (p50 / p95 / p99)190- `shared_hit` vs `shared_read` (cache disque vs RAM)191- Nombre de rows scannées vs retournées192- Nombre de lock waits193194---195196## Garde-fous / Anti-patterns / Pièges197198- **Ne pas créer d'index en masse** : chaque index ralentit INSERT/UPDATE/DELETE. Cibler les requêtes > 100ms ou fréquence élevée.199- **EXPLAIN sans ANALYZE** : montre le plan estimé, pas réel. Toujours utiliser `EXPLAIN ANALYZE` pour diagnostiquer.200- **Optimiser sans mesurer** : noter le temps baseline avant toute modification.201- **Index sur colonne de faible cardinalité** (booléen, statut 3 valeurs) : inefficace sauf index partiel.202- **OR dans WHERE** : peut empêcher l'usage d'index. Réécrire en UNION ALL si possible.203- **DISTINCT comme palliatif** : souvent masque un problème de jointure qui produit des doublons.204- **Dénormalisation prématurée** : mesurer d'abord, dénormaliser seulement si le gain est prouvé.205- **`SELECT 1` dans EXISTS** : équivalent à `SELECT *` côté optimiseur depuis PG 9+, mais rester explicite par convention.206- **Ne jamais tuner en production sans rollback plan** : `DROP INDEX CONCURRENTLY` si régression.207208209## Communication Rules — MANDATORY210211- Ultra-concise. No filler, no preamble, no pleasantries.212- Never say "happy to help", "sure!", "great question", "let me", or similar.213- Tool first, talk second. Act before explaining.214- Result first. Lead with outcome, not process.215- Stop when done. No summary, no recap, no trailing commentary.216- No politeness wrappers. Direct and blunt.217- Minimum words. If one word works, do not use ten.218- No unsolicited explanations.219- No emoji unless asked.