Analyse Comparative de Performance : PostgreSQL face à Valkey (Redis) et à l'In-Memory
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.
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 :
- PostgreSQL for everything : le bon sens architectural — Rationalisation de l'infrastructure autour d'un SGBD unique.
- Performance : Au-delà des moyennes (p50, p95, p99) — Analyse de la latence de queue sous charge.
- Optimisation Go : Recyclage de mémoire et sync.Pool — Réduction de la pression sur le Garbage Collector.
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)
-
🚀 Serveur d'Exécution (Go fasthttp • Port 8080)
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 étatTIME_WAIT.
3. Détail des 4 Configurations Analysées
-
💡 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).
- Mécanisme : Cache applicatif in-process utilisant
-
🔴 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
vtprotobufet clés ULID Base32 (github.com/oklog/ulid/v2).
-
🐘 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 lesshared_bufferset le Page Cache OS. - Indexation : Clé primaire UUID v7 séquentielle de 16 octets. Écritures synchrones avec WAL.
- Mécanisme : Table relationnelle classique (
-
⚡ 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
byteaProtobuf, réserve d'espace de page (fillfactor = 70) favorisant les mises à jour Heap-Only Tuples (HOT) et nettoyage asynchrone (FOR UPDATE SKIP LOCKED).
- Mécanisme : Table non journalisée (
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.
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 desfsyncdu WAL et optimisation du placement des n-uplets.
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) etappendonly 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.

| 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.
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.

| 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.
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 :
- 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).
- É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
UNLOGGEDpermet d'atteindre 29,4k req/s, mais reste en dessous d'un moteur in-memory dédié (68,9k req/s pour Valkey). - 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).
- En lecture (
- 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 TABLEde 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.Mapintroduit des verrous globaux et des goroutines de nettoyage complexes. L'utilisation d'une bibliothèque éprouvée commeOtter v2(basée sur S3-FIFO) offre de meilleures garanties d'utilisation mémoire.