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 :

  1. Index manquants : -80% query time, 5 min de travail

  2. VACUUM/ANALYZE : -60% fragmentation, automatisable

  3. Connection pooling : -70% overhead connexions (PgBouncer)

  4. Query rewriting : -50% load DB, refactor requêtes

  5. 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 inutiles
  • SELECT id, name, email scanne seulement colonnes nécessaires
  • Gain : -30% query time

Pattern 2 : LIMIT dans sous-requête

  • COUNT scanne toutes rows
  • LIMIT dans sous-requête scanne seulement 10 rows

Pattern 3 : Utiliser EXISTS au lieu COUNT

  • COUNT scanne toutes rows
  • EXISTS s’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 = 25

  • Gain : -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
  • Gain : -70% overhead connexions

Queries non optimisées

  • SELECT * scanne toutes colonnes
  • COUNT scanne 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.