Situation réelle
“La DB est lente.” Classique. Cette situation n’est pas une fatalité. Après avoir optimisé des dizaines de bases PostgreSQL en production, j’ai identifié les optimisations qui donnent les meilleurs résultats.
Ce que j’ai observé : les optimisations PostgreSQL les plus impactantes sont souvent les plus simples.
Top 5 optimisations par ROI :
Index manquants : -80% query time, 5 min de travail
VACUUM/ANALYZE : -60% fragmentation, automatisable
Connection pooling : -70% overhead connexions (PgBouncer)
Query rewriting : -50% load DB, refactor requêtes
Partitioning : -90% scan time pour tables >10M rows
Ces optimisations simples ont un impact énorme sur les performances.
Le faux problème
Le faux problème serait de croire qu’il faut une configuration PostgreSQL complexe dès le début. En réalité, les optimisations PostgreSQL les plus impactantes sont souvent les plus simples :
- Index manquants : -80% query time, 5 min de travail
- VACUUM/ANALYZE : -60% fragmentation, automatisable
- Connection pooling : -70% overhead connexions (PgBouncer)
- Query rewriting : -50% load DB, refactor requêtes
- Partitioning : -90% scan time pour tables >10M rows
Cette simplicité facilite l’adoption.
Un autre faux problème : penser qu’il faut tout optimiser d’un coup. En réalité, l’optimisation peut être progressive :
Semaine 1 :
- Ajouter index manquants critiques
- Configurer autovacuum si désactivé
- Mettre en place PgBouncer
Semaine 2 :
- Réécrire top 5 requêtes lentes
- Ajuster postgresql.conf mémoire
- Monitorer avec pg_stat_statements
Mois 1 :
- Partitionner tables >10M rows
- Optimiser queries N+1
- Automatiser maintenance VACUUM
Cette approche progressive facilite l’adoption.
Le vrai enjeu CTO
Le vrai enjeu est de comprendre comment optimiser PostgreSQL en production sans sur-ingénierie :
Index low-hanging fruit
Identifier index manquants :
SELECT schemaname, tablename, attname, n_distinct, correlation
FROM pg_stats
WHERE schemaname = 'public'
AND n_distinct > 100
AND correlation < 0.1
ORDER BY n_distinct DESC;
Cas réel :
AVANT : Query lente 2.3s
SELECT * FROM orders
WHERE user_id = 12345
AND created_at > '2025-01-01'
ORDER BY created_at DESC;
Index manquant sur user_id, created_at
APRÈS : Query rapide 45ms
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at DESC);
Gain : -98% query time
Index composites intelligents
Règle : Ordre colonnes = ordre filtrage
Mauvais ordre :
CREATE INDEX idx_bad ON orders (created_at, user_id)
WHERE user_id = X AND created_at > Y;
→ Index scan partiel seulement
Bon ordre :
CREATE INDEX idx_good ON orders (user_id, created_at)
WHERE user_id = X AND created_at > Y;
→ Index scan complet
Index partiels performance
Index seulement données récentes (90% queries) :
CREATE INDEX idx_orders_recent
ON orders (user_id, created_at DESC)
WHERE created_at > NOW() - INTERVAL '1 year';
Gain : -60% taille index, +40% write speed
Cette optimisation simple a un impact énorme.
VACUUM ANALYZE maintenance essentielle
Pourquoi critique :
Sans VACUUM :
- Tables gonflées (bloat) : +300% taille disque
- Queries lentes : +200% scan time
- Locks prolongés
Avec VACUUM régulier :
- Taille optimale
- Performance stable
- Pas locks bloquants
Configuration automatique dans postgresql.conf :
autovacuum = on
autovacuum_max_workers = 3
autovacuum_naptime = 1min
autovacuum_vacuum_threshold = 50
autovacuum_analyze_threshold = 50
autovacuum_vacuum_scale_factor = 0.2
autovacuum_analyze_scale_factor = 0.1
Cette maintenance automatique évite la fragmentation.
Connection pooling
Gain : -70% overhead connexions
PgBouncer configuration :
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
reserve_pool_size = 5
reserve_pool_timeout = 3.5
Cette optimisation réduit significativement l’overhead connexions.
Query rewriting
Gain : -50% load DB
**Pattern 1 : Éviter SELECT ***
SELECT * scanne toutes colonnes même inutiles :
SELECT * FROM users;
SELECT colonnes spécifiques scanne seulement colonnes nécessaires :
SELECT id, name, email FROM users;
Gain : -30% query time
Pattern 2 : LIMIT dans sous-requête
COUNT scanne toutes rows :
SELECT COUNT(*) FROM orders WHERE user_id = 123;
→ Scanne toutes orders
LIMIT dans sous-requête :
SELECT * FROM users
WHERE (
SELECT COUNT(*)
FROM (
SELECT * FROM orders
WHERE user_id = users.id
LIMIT 10
) sub
) > 10;
→ Scanne seulement 10 rows
Pattern 3 : Utiliser EXISTS au lieu COUNT
COUNT scanne toutes rows :
SELECT * FROM users
WHERE (
SELECT COUNT(*) FROM orders
WHERE orders.user_id = users.id
) > 10;
EXISTS s’arrête à la première match :
SELECT * FROM users
WHERE EXISTS (
SELECT 1 FROM orders
WHERE orders.user_id = users.id
LIMIT 11
)
AND (
SELECT COUNT(*) FROM orders
WHERE orders.user_id = users.id
) > 10;
Gain : -90% query time grandes tables
Cette réécriture réduit significativement la charge DB.
Partitioning : scalabilité horizontale
Quand partitionner :
- Table >10M rows
- Queries filtrent souvent par date
- Maintenance VACUUM devient lente
- Archive données anciennes nécessaire
Partitioning par date (cas classique)
Table parent :
CREATE TABLE orders (
id BIGSERIAL,
user_id BIGINT,
created_at TIMESTAMP,
amount DECIMAL
) PARTITION BY RANGE (created_at);
Partitions mensuelles :
CREATE TABLE orders_2025_01
PARTITION OF orders
FOR VALUES FROM '2025-01-01' TO '2025-02-01';
CREATE TABLE orders_2025_02
PARTITION OF orders
FOR VALUES FROM '2025-02-01' TO '2025-03-01';
Index sur partition :
CREATE INDEX idx_orders_2025_01_user
ON orders_2025_01 (user_id);
Résultat: - Query sur 1 mois scanne 1 partition au lieu toute table
- Gain : -95% scan time
- Maintenance VACUUM sur 1 partition = rapide
Partitioning automatique
Fonction créer partitions automatiquement :
CREATE OR REPLACE FUNCTION create_monthly_partition(
table_name TEXT,
start_date DATE
) RETURNS VOID AS $$
DECLARE
partition_name TEXT;
end_date DATE;
BEGIN
partition_name := table_name || '_' || TO_CHAR(start_date, 'YYYY_MM');
end_date := start_date + INTERVAL '1 month';
EXECUTE format(
'CREATE TABLE IF NOT EXISTS %I PARTITION OF %I FOR VALUES FROM %L TO %L',
partition_name,
table_name,
start_date,
end_date
);
END;
$$ LANGUAGE plpgsql;
Cette approche améliore la scalabilité long terme.
Cadre de décision
Voici les principes qui m’ont aidé à optimiser PostgreSQL en production :
1. Index low-hanging fruit
Identifier index manquants :
SELECT schemaname, tablename, attname, n_distinct, correlation
FROM pg_stats
WHERE schemaname = 'public'
AND n_distinct > 100
AND correlation < 0.1
ORDER BY n_distinct DESC;
Cas réel :
APRÈS : Query lente 2.3s, index manquant sur user_id, created_at
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at DESC);
APRÈS : Query rapide 45ms
Gain : -98% query time
Index composites intelligents : ordre colonnes = ordre filtrage
CREATE INDEX idx_good ON orders (user_id, created_at);
→ Index scan complet
Index partiels performance :
CREATE INDEX idx_orders_recent
ON orders (user_id, created_at DESC)
WHERE created_at > NOW() - INTERVAL '1 year';
Gain : -60% taille index, +40% write speed
Cette optimisation simple a un impact énorme.
2. VACUUM ANALYZE maintenance essentielle
Pourquoi critique :
Sans VACUUM :
- Tables gonflées (bloat) : +300% taille disque
- Queries lentes : +200% scan time
- Locks prolongés
Avec VACUUM régulier :
- Taille optimale
- Performance stable
- Pas locks bloquants
Configuration automatique dans postgresql.conf :
autovacuum = on
autovacuum_max_workers = 3
autovacuum_naptime = 1min
autovacuum_vacuum_threshold = 50
autovacuum_analyze_threshold = 50
autovacuum_vacuum_scale_factor = 0.2
autovacuum_analyze_scale_factor = 0.1
Cette maintenance automatique évite la fragmentation.
3. Connection pooling
Gain : -70% overhead connexions
PgBouncer configuration :
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
reserve_pool_size = 5
reserve_pool_timeout = 3.5
Cette optimisation réduit significativement l’overhead connexions.
4. Query rewriting
Gain : -50% load DB
**Pattern 1 : Éviter SELECT ***
SELECT *scanne toutes colonnes même inutilesSELECT id, name, emailscanne seulement colonnes nécessaires- Gain : -30% query time
Pattern 2 : LIMIT dans sous-requête
COUNTscanne toutes rowsLIMITdans sous-requête scanne seulement 10 rows
Pattern 3 : Utiliser EXISTS au lieu COUNT
COUNTscanne toutes rowsEXISTSs’arrête à la première match- Gain : -90% query time grandes tables
Cette réécriture réduit significativement la charge DB.
5. Partitioning : scalabilité horizontale
Quand partitionner :
- Table >10M rows
- Queries filtrent souvent par date
- Maintenance VACUUM devient lente
- Archive données anciennes nécessaire
Partitioning par date (cas classique) :
Table parent :
CREATE TABLE orders PARTITION BY RANGE (created_at);
Partitions mensuelles :
CREATE TABLE orders_2025_01
PARTITION OF orders
FOR VALUES FROM '2025-01-01' TO '2025-02-01';
Index sur partition :
CREATE INDEX idx_orders_2025_01_user ON orders_2025_01 (user_id);
Résultat: - Query sur 1 mois scanne 1 partition au lieu toute table
- Gain : -95% scan time
- Maintenance VACUUM sur 1 partition = rapide
Partitioning automatique : fonction créer partitions automatiquement
CREATE OR REPLACE FUNCTION create_monthly_partition(
table_name TEXT,
start_date DATE
);
Cette approche améliore la scalabilité long terme.
Retour terrain
Ce que j’ai observé dans différentes bases PostgreSQL :
Ce qui fonctionne
Index low-hanging fruit :
- Identifier index manquants avec
pg_stats - Cas réel : AVANT Query lente 2.3s → APRÈS Query rapide 45ms
- Gain : -98% query time
- Index composites intelligents : ordre colonnes = ordre filtrage
- Index partiels performance : index seulement données récentes
- Gain : -60% taille index, +40% write speed
Impact énorme.
VACUUM ANALYZE maintenance essentielle :
- Configuration automatique dans
postgresql.conf autovacuum = on,autovacuum_max_workers = 3,autovacuum_naptime = 1min- Évite fragmentation
Connection pooling :
PgBouncer configuration :
pool_mode = transaction,max_client_conn = 1000,default_pool_size = 25Gain : -70% overhead connexions
Query rewriting :
- Pattern 1 : Éviter SELECT * → Gain : -30% query time
- Pattern 2 : LIMIT dans sous-requête → Scanne seulement 10 rows
- Pattern 3 : Utiliser EXISTS au lieu COUNT → Gain : -90% query time grandes tables
- Gain global : -50% load DB
Partitioning :
- Quand partitionner : Table >10M rows, queries filtrent souvent par date
- Partitioning par date : Table parent
PARTITION BY RANGE (created_at), partitions mensuelles - Gain : -95% scan time
- Maintenance VACUUM sur 1 partition = rapide
- Améliore scalabilité long terme
Ce qui bloque
Pas d’index manquants
- Requêtes lentes sans index approprié
- Résultat: queries lentes 2.3s, performance dégradée
- Mieux vaut : identifier index manquants
CREATE INDEX idx_orders_user_created - Gain : -98% query time
Pas de VACUUM régulier
- Tables gonflées (bloat) : +300% taille disque
- Queries lentes : +200% scan time
- Locks prolongés
- Résultat: performance dégradée, locks fréquents
- Mieux vaut : VACUUM régulier avec configuration automatique autovacuum configuré
Checklist optimisation
Audit rapide 30 min :
- Identifier index manquants :
pg_stats - Vérifier cache hit ratio >95%
- Analyser top 10 requêtes lentes :
pg_stat_statements - Vérifier VACUUM récent :
pg_stat_user_tables - Compter connexions actives :
pg_stat_activity
Optimisations prioritaires :
Semaine 1 :
- Ajouter index manquants critiques
- Configurer autovacuum si désactivé
- Mettre en place PgBouncer
Semaine 2 :
- Réécrire top 5 requêtes lentes
- Ajuster postgresql.conf mémoire
- Monitorer avec pg_stat_statements
Mois 1 :
- Partitionner tables >10M rows
- Optimiser queries N+1
- Automatiser maintenance VACUUM
Cette checklist facilite l’adoption progressive.
Erreurs fréquentes
Pas d’index manquants
- Requêtes lentes sans index approprié
- Résultat: queries lentes 2.3s, performance dégradée
- Mieux vaut : identifier index manquants
CREATE INDEX idx_orders_user_created - Gain : -98% query time
Pas de VACUUM régulier
- Tables gonflées (bloat) : +300% taille disque
- Queries lentes : +200% scan time
- Locks prolongés
- Résultat: performance dégradée, locks fréquents
- Mieux vaut : VACUUM régulier avec configuration automatique autovacuum configuré
Pas de connection pooling
- Overhead connexions élevé
- Résultat: performance dégradée, connexions saturées
- Mieux vaut : Connection pooling avec PgBouncer
- Configuration :
pool_mode = transaction,max_client_conn = 1000,default_pool_size = 25
- Configuration :
- Gain : -70% overhead connexions
Queries non optimisées
SELECT *scanne toutes colonnesCOUNTscanne toutes rows- Résultat: load DB élevé, performance dégradée
- Mieux vaut : Query rewriting
- Pattern 1 : Éviter SELECT *
- Pattern 2 : LIMIT dans sous-requête
- Pattern 3 : Utiliser EXISTS au lieu COUNT
- Gain : -50% load DB
Si c’était à refaire
Avec le recul, voici ce que je ferais différemment :
Mettre en place index manquants dès le début
Plutôt que découvrir index manquants après coup, mettre en place index manquants dès le début :
SELECT schemaname, tablename, attname, n_distinct, correlation
FROM pg_stats
WHERE n_distinct > 100 AND correlation < 0.1;
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at DESC);
Gain : -98% query time
Cette optimisation simple a un impact énorme.
Mettre en place VACUUM automatique dès le début
Plutôt que VACUUM manuel, mettre en place VACUUM automatique dès le début dans postgresql.conf :
autovacuum = on
autovacuum_max_workers = 3
autovacuum_naptime = 1min
Cette maintenance automatique évite la fragmentation.
Mettre en place connection pooling dès le début
Plutôt que connexions directes, mettre en place connection pooling dès le début :
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
Gain : -70% overhead connexions
Cette optimisation réduit significativement l’overhead connexions.
Pour approfondir
Pour approfondir, tu peux aussi consulter les pages piliers du site ou les guides mis à disposition.