Caching : PostgreSQL vs Valkey vs In-Memory

Analyse Comparative de Performance : PostgreSQL face à Valkey (Redis) et à l'In-Memory

Résumé
PostgreSQL propose des performances très compétitives en lecture lorsque son Working Set réside en RAM. S'il ne remplace pas un moteur In-Memory dédié sous des charges d'écriture extrêmes, il permet d'éviter l'ajout d'une brique Redis/Valkey pour la majorité des applications web, simplifiant ainsi l'architecture tout en garantissant l'invalidation transactionnelle.

1. Contexte & Problématique Architecturales

L'ajout d'une brique de cache clé-valeur distribuée en mémoire vive (ex: Valkey, Redis) est devenu un réflexe d'architecture dans les systèmes modernes. Si ce modèle offre une faible latence, il introduit un coût opérationnel et des contraintes concrètes :

  • Exploitation & Observabilité : Déploiement, monitoring, patching et gestion de la haute disponibilité (failover, réplication) d'un cluster supplémentaire.
  • Saut Réseau (Network Hop) : Latence inter-processus TCP, gestion des pools de connexions et surcoût de sérialisation.
  • Cohérence des Données : Complexité d'invalidation, gestion des fenêtres de désynchronisation (stale cache) et risques de race conditions.

D'un point de vue strict d'ingénierie, la question se pose :

Dans quelle mesure un serveur PostgreSQL configuré à l'état de l'art peut-il absorber un workload de cache clé-valeur à haut débit et quelles sont ses limites physiques explicites ?

Pour apporter une réponse factuelle, le banc de test benchmark-caching évalue le débit (RPS), la mémoire et la distribution des percentiles de latence (P50, P90, P95, P99) sur quatre architectures de stockage sous une charge soutenue de 250 workers concurrents.

Important

Code Source & Dépôt Officiel du Benchmark :
L'intégralité du banc de test automatisé (runner Go fasthttp, sérialisation vtprotobuf, configurations systemd/cgroups et scripts Vegeta) est accessible sur GitHub : github.com/Chroq/benchmark-caching.

Lectures associées dans la base de connaissances :


2. Architecture du Banc de Test & Isolation Système

Le runner du benchmark exécute un microservice Go optimisé (fasthttp), exposant des endpoints d'API REST standardisés (/get, /set) reliés aux différents backends :

  • ⚡ Injection de Charge (Vegeta • 250 Workers)
    • 🚀 Serveur d'Exécution (Go fasthttp • Port 8080)
      • 💡 In-Memory (Otter v2 • In-Process)
      • 🔴 Valkey 9.1 (Fork Redis • Pool TCP)
      • 🐘 Standard Postgres (UUIDv7 • 10M Lignes)
      • ⚡ Optimized Postgres (UNLOGGED • VTProto)
Note

Ce benchmark analyse des performances relatives sur un matériel donné et ne constitue pas un jugement de valeur absolu.
La table PostgreSQL d'évaluation contient 10M de lignes (simulant un jeu de données réel de ~1,3 Go distribué chronologiquement) afin de forcer le traversage d'index B-Tree sous pression mémoire.

Isolation Système & Contraintes CGroups Linux

Afin de garantir des mesures étanches et éliminer la variabilité due à la contention CPU/RAM :

  • Serveur d'Exécution Go : Bridé via CGroups systemd à 4 CPU (GOMAXPROCS=4) et 10 Go de RAM (GOMEMLIMIT=10GiB).
  • PostgreSQL & Valkey : Confinés dans des tranches systemd distinctes (CPUQuota=200%, MemoryMax=4G).
  • Tuning Réseau Kernel Linux : Réutilisation rapide des sockets TCP (net.ipv4.tcp_tw_reuse=1) et extension de la plage de ports éphémères (1024-65535) pour éviter l'épuisement des sockets en état TIME_WAIT.

3. Détail des 4 Configurations Analysées

  1. 💡 In-Memory (Otter v2)

    • Mécanisme : Cache applicatif in-process utilisant github.com/maypok86/otter/v2 (S3-FIFO, verrous minimisés, TTL automatique).
    • Rôle : Établit la borne supérieure théorique de la machine (zéro appel réseau, zéro I/O).
  2. 🔴 Valkey 9.1 Key-Value Store

    • Mécanisme : Instance Valkey standalone sollicitée via un pool de 2 500 connexions TCP réutilisables.
    • Payload & Clés : Payloads binaires Protobuf sérialisés via vtprotobuf et clés ULID Base32 (github.com/oklog/ulid/v2).
  3. 🐘 Standard PostgreSQL (UUID v7)

    • Mécanisme : Table relationnelle classique (users_standard) de 10 000 000 de lignes (~1,3 Go) dont le Working Set réside dans les shared_buffers et le Page Cache OS.
    • Indexation : Clé primaire UUID v7 séquentielle de 16 octets. Écritures synchrones avec WAL.
  4. ⚡ Optimized PostgreSQL (UNLOGGED / VTProto)

    • Mécanisme : Table non journalisée (UNLOGGED) de 100 000 clés volatiles actives, supprimant l'écriture du journal de transaction (WAL).
    • Payload & Optimisations : Données sérialisées en bytea Protobuf, réserve d'espace de page (fillfactor = 70) favorisant les mises à jour Heap-Only Tuples (HOT) et nettoyage asynchrone (FOR UPDATE SKIP LOCKED).

4. Stratégies d'Optimisation Bas Niveau & Décryptage Technique

A. Sérialisation Zéro Allocation (vtprotobuf)

Le recours standard à la réflexion de encoding/json ou du package proto officiel génère des allocations sur le Heap à chaque appel, provoquant de la pression sur le Garbage Collector (GC) sous forte charge.

Astuce

Pourquoi vtprotobuf évite-t-il la saturation processeur ?
vtprotobuf génère du code de sérialisation direct à la compilation (MarshalVT / UnmarshalVT). Sans réflexion à l'exécution, la sérialisation s'effectue à allocation mémoire quasi-nulle (zero-alloc), libérant des cycles CPU pour la gestion I/O du réseau.

B. Configuration Système de PostgreSQL (postgresql.conf)

Pour maximiser l'efficacité du moteur en mode cache, la configuration a été ajustée :

  • shared_buffers = 25% de la RAM : Allocation du cache de blocs principal de Postgres.
  • effective_cache_size = 50% à 75% de la RAM : Indication au planificateur comptabilisant les tampons Postgres et le Page Cache du noyau Linux.
  • work_mem = 16MB à 64MB : Mémoire par opération de tri/hachage pour prévenir les débordements sur disque.
  • max_wal_size = 4GB à 16GB & checkpoint_completion_target = 0.9 : Étalement des écritures d'arrière-plan pour éviter les pics de latence P99 lors des checkpoints.
  • Tables UNLOGGED & HOT Updates (fillfactor=70) : Suppression des fsync du WAL et optimisation du placement des n-uplets.
Astuce

Pourquoi le fillfactor = 70 préserve-t-il les index B-Tree ?
Avec le réglage par défaut (fillfactor = 100), une page de 8 Ko remplie nécessite un nouvel emplacement dans une autre page lors d'un UPDATE, obligeant la mise à jour de l'index B-Tree. À fillfactor = 70, 30 % de la page reste libre : la nouvelle version de la ligne est écrite dans la même page (Heap-Only Tuple - HOT), annulant la réécriture d'index et la fragmentation.

C. Configuration de Valkey (valkey.conf)

  • maxmemory-policy = allkeys-lru : Éviction automatique selon l'algorithme des clés les moins récemment utilisées.
  • io-threads = 2 à 4 & io-threads-do-reads = yes : Déchargement du parsing RESP et de la lecture socket sur des threads secondaires.
  • Désactivation de la persistance : save "" (pas de snapshots RDB) et appendonly no (pas de AOF) pour évaluer le moteur en mémoire pure.

5. Résultats Mesurés : Endurance en Lecture GET (10 Min / 10M Lignes)

Endurance soutenue pendant 10 minutes sous 250 workers concurrents. Écarts mesurés par rapport à la référence PostgreSQL Standard.

Débit Comparatif sous Charge (RPS)

Moteur & Configuration Volume Données Débit (RPS) Écart / Postgres Latence P50 Latence P99 Écart P99 Succès
In-Memory (Otter) 100k clés 106 658 req/s 🟢 ▲ +100 % 1,48 ms 7,68 ms 🟢 ▲ -10 % 100 %
Valkey 9.1 100k clés 77 122 req/s 🟢 ▲ +45 % 2,80 ms 6,78 ms 🟢 ▲ -21 % 100 %
Optimized Postgres (UNLOGGED) 100k clés 53 749 req/s 🟢 ▲ +0,8 % 4,22 ms 9,23 ms 🔴 ▼ +7 % 100 %
Standard Postgres (UUIDv7) 10M lignes (~1,3 Go) 53 319 req/s ➖ Baseline 4,31 ms 8,58 ms ➖ Baseline 100 %

Analyse Factuelle des Lectures

  • Hiérarchie des débits : Le cache in-process Otter v2 atteint le plafond de la machine (106k req/s). Valkey le suit à 77k req/s. PostgreSQL s'établit à ~54 000 req/s, soit 70 % des capacités de Valkey.
  • Impact de la taille de la table : Passer d'une table dédiée de 100k clés à une table relationnelle de 10M de lignes (~1,3 Go) ne dégrade pratiquement pas le débit de Postgres (53,7k vs 53,3k req/s).
  • Comportement des percentiles : L'ensemble des architectures maintient une latence P99 sous la barre des 10 ms.
Astuce

Pourquoi Postgres conserve-t-il ce débit sur 10 millions de lignes ?
Une fois la table et ses index B-Tree chargés en RAM (shared_buffers / Page Cache Linux), PostgreSQL résout les requêtes en $O(\log N)$ instructions CPU sans effectuer la moindre lecture sur le disque physique. La différence de débit avec Valkey provient principalement du surcoût du protocole SQL et de la gestion du pool de processus.


6. Résultats Mesurés : Écritures SET (Seeding 100 000 Clés)

Insertion et mutation massives de 100 000 clés. Écarts mesurés par rapport à la référence PostgreSQL Standard.

Distribution des Latences P50 & P99

Moteur & Configuration Débit (RPS) Écart / Postgres Durée Latence P50 Latence P99 Écart P99 Succès
In-Memory (Otter) 94 029 req/s 🟢 ▲ x34,1 1,06 s 1,66 ms 8,42 ms 🟢 ▲ -95 % 100 %
Valkey 9.1 68 980 req/s 🟢 ▲ x25,0 1,45 s 3,02 ms 7,84 ms 🟢 ▲ -95 % 100 %
Optimized Postgres (UNLOGGED) 29 405 req/s 🟢 ▲ x10,6 3,40 s 6,38 ms 32,46 ms 🟢 ▲ -81 % 100 %
Standard Postgres (UUID v7) 2 754 req/s ➖ Baseline 36,39 s 89,22 ms 173,84 ms ➖ Baseline 100 %

Analyse Factuelle des Écritures

  • Le goulot d'étranglement de la persistance : En mode classique avec WAL synchrone (fsync), PostgreSQL est limité à 2 754 req/s avec une latence P99 de 173,8 ms, illustrant le coût de la durabilité ACID complète sur disque SSD.
  • Rôle du mode UNLOGGED (+10,6x) : En désactivant le journal de transaction pour les données volatiles, PostgreSQL atteint 29 405 req/s et ramène sa latence P99 à 32,5 ms.
  • Écart persistant avec le Key-Value : Malgré l'option UNLOGGED, Valkey conserve un débit d'écriture 2,3x supérieur (68,9k req/s) grâce à son architecture in-memory sans gestion de verrous de lignes (row locks) complexes.
Astuce

Pourquoi le mode UNLOGGED accélère-t-il les écriture par 10,6x ?
Chaque écriture standard impose un appel système fsync bloquant pour garantir l'écriture dans le WAL. Le mode UNLOGGED supprime cette étape : la mutation s'effectue directement dans les shared_buffers en RAM, transformant l'écriture synchrone sur disque en une simple écriture mémoire.


7. Matrice Comparative des 4 Solutions Principales

Critère d'Ingénierie 💡 In-Memory (Otter) 🔴 Valkey 9.1 ⚡ Optimized Postgres (UNLOGGED) 🐘 Standard Postgres (UUIDv7)
Cas d'usage principal Mono-instance / Plafond physique Cache distribué multi-services Cache temporaire haute écriture Stockage relationnel de référence
Débit GET (10M lignes) 106 658 req/s (+100%) 77 122 req/s (+45%) 53 749 req/s (+0,8%) 53 319 req/s (Baseline)
Latence P99 GET 7,68 ms 6,78 ms 9,23 ms 8,58 ms
Débit SET (100k écriture) 94 029 req/s (x34,1) 68 980 req/s (x25,0) 29 405 req/s (x10,6) 2 754 req/s (Baseline)
Latence P99 SET 8,42 ms 7,84 ms 32,46 ms 173,84 ms
Persistance / WAL ❌ Volatile (RAM pure) ⚠️ Optionnelle (RDB/AOF) ⚠️ Effacé au crash (UNLOGGED) ✅ Persistant synchrone (WAL)
Interface de Requête Code Go (In-Process) Protocole Key-Value (RESP) SQL Standard SQL Standard
Arbitrage d'Ingénierie Idéal pour microservices isolés Nécessaire pour > 75k GET/s ou multi-apps Idéal pour unifier la stack sans Redis Référence pour la cohérence métier

8. Recommandations d'Architecture & Typologies d'Usage

Typologie d'Architecture Complexité Infra Risque d'Incohérence Recommandation & Cadre d'Application
Typologie 1 : Direct PostgreSQL (Sans Cache) 🟢 Minimale (1 SGBD) 🟢 Nul (Transactions ACID) Option par défaut pour < 50k req/s. Supprime le surcoût de déploiement, la maintenance réseau et la gestion des rétentions de clés obsolètes.
Typologie 2 : Cache Postgres (UNLOGGED) 🟢 Faible (SGBD unique) ⚠️ Moyen (Vidée au crash BDD) Accélération des données volatiles nécessitant un fort débit en écriture (gain 10,6x en SET) sans déployer de cluster dédié.
Typologie 3 : Cache Distribué (Valkey / Redis) 🔴 Élevée (Cluster + Proxies) 🔴 Élevé (Réseau TCP / Desync) Incontournable pour les très fortes charges (> 75k req/s en lecture, > 30k req/s en écriture) ou pour des clusters multi-services partagés.
Typologie 4 : In-Memory Applicatif (Otter v2) 🟢 Nulle (In-Process) ⚠️ Variable (Synchro inter-nœuds) Micro-caching L1 ultra-rapide pour variables chaudes, métadonnées statiques et données de configuration au niveau du nœud applicatif.

9. Conclusion

Les données chiffrées apportent un éclairage pragmatique sur l'organisation des architectures de stockage :

  1. Efficience des lectures : Sur un Working Set dimensionné en RAM (10M de lignes / 1,3 Go), PostgreSQL fournit ~54 000 req/s (70 % des performances de Valkey) avec des latences de queue similaires (P99 de 8,58 ms vs 6,78 ms).
  2. Écritures et limites physiques : Face à des rafales d'écritures volatiles, PostgreSQL avec WAL standard constitue un goulot d'étranglement (2,7k req/s). L'option UNLOGGED permet d'atteindre 29,4k req/s, mais reste en dessous d'un moteur in-memory dédié (68,9k req/s pour Valkey).
  3. Arbitrage d'architecture : Pour les applications dont la charge en lecture ne dépasse pas 50 000 req/s, interroger directement une base PostgreSQL correctement indexée permet de réduire la complexité système, d'éviter la maintenance d'un cluster secondaire et de garantir la cohérence des données. L'introduction d'une brique comme Valkey se justifie dès lors que les seuils de trafic réseau ou d'écritures massives dépassent les limites physiques du SGBD.

10. Annexe : Pistes Évaluées & Moteurs Non Retenus

Au cours de la campagne de benchmarks, d'autres approches techniques ont été implémentées et mesurées. Bien qu'intéressantes, elles n'ont pas été retenues pour l'architecture cible en raison de leurs compromis opérationnels.

A. Identifiants TSID 64-bit (bigint) vs UUID v7

Le TSID (Time-Sorted Identifier) encode un identifiant unique horodaté sur 64 bits (bigint), contre 128 bits pour l'UUID v7.

  • Hypothèse : Réduire la taille de la clé primaire de moitié pour doubler la densité des pages d'index B-Tree de 8 Ko et optimiser l'occupation dans les shared_buffers.
  • Mesures :
    • En lecture (GET) : Légère hausse de 53 319 req/s (UUID v7) à 53 991 req/s (TSID), soit un gain de +1,3 %.
    • En écriture (SET) : Contention sur la bordure d'index générant un pic de latence P99 à 253,8 ms (contre 173,8 ms pour l'UUID v7).
  • Décision : Le gain marginal en lecture ne compense pas la dégradation en écriture. L'UUID v7 (normalisé RFC 9562 et natif depuis PostgreSQL 17) reste le choix le plus équilibré.

B. Tables Partitionnées PostgreSQL (Declarative Partitioning)

Découpage de la table de cache en sous-partitions (par plages de hachage ou temporelles) afin de réduire la profondeur des index B-Tree.

  • Hypothèse : Accélérer les recherches et simplifier la purge de données périmées via des opérations DROP TABLE de partitions.
  • Mesures : Une fois la mémoire préchauffée, le planificateur résout les index non partitionnés en $O(\log N)$. Aucun gain de débit significatif n'a été mesuré en lecture comme en écriture.
  • Décision : Le partitionnement ajoute une complexité de maintenance DDL (gestion dynamique des partitions, surcharge de planification) sans gain de performance sur un jeu de données tenant en RAM.

C. Cache In-Memory Go basique via sync.Map

Utilisation de la structure concurrente sync.Map de la bibliothèque standard Go.

  • Hypothèse : Bénéficier d'une map concurrente sans gestion explicite de verrous pour les accès fréquents en lecture.
  • Mesures : Excellent débit en lecture, mais absence native de politique d'éviction (LRU/LFU) et de gestion d'expiration (TTL).
  • Décision : Le développement d'un moteur d'éviction sur mesure autour de sync.Map introduit des verrous globaux et des goroutines de nettoyage complexes. L'utilisation d'une bibliothèque éprouvée comme Otter v2 (basée sur S3-FIFO) offre de meilleures garanties d'utilisation mémoire.