A-t-on vraiment besoin d’une base de données ?
(dbpro.app)- Toutes les bases de données ne sont au fond qu’un ensemble structuré de fichiers sur un système de fichiers ; pour une application en phase initiale, gérer directement des fichiers peut offrir des performances suffisantes
- En implémentant le même serveur en Go, Bun et Rust pour comparer trois approches — scan de fichiers, map en mémoire et recherche binaire sur disque —, on peut obtenir un débit élevé même avec un simple accès fichier
- L’approche avec map en mémoire offre les meilleures performances (jusqu’à 169k req/s), tandis que SQLite reste stable à 25k req/s mais avec un certain surcoût
- La plupart des services peuvent atteindre jusqu’à 90 millions de DAU avec un simple fichier SQLite, ce qui rend une base de données séparée inutile au début d’un produit
- Il devient nécessaire d’introduire une base de données lorsque le jeu de données dépasse la RAM ou quand on a besoin de jointures, recherches multicritères, écritures concurrentes et transactions
A-t-on vraiment besoin d’une base de données ?
- Une base de données n’est au fond qu’un ensemble de fichiers : SQLite est un fichier unique, PostgreSQL repose sur un répertoire et des processus
- Toutes les bases de données lisent et écrivent sur le système de fichiers, et fonctionnent de la même manière qu’un appel à
open()dans le code - L’enjeu n’est donc pas de savoir s’il faut « écrire dans des fichiers », mais utiliser les fichiers de la base de données ou les gérer soi-même
- Pour beaucoup d’applications en phase initiale, une gestion directe peut suffire en termes de performances
- Toutes les bases de données lisent et écrivent sur le système de fichiers, et fonctionnent de la même manière qu’un appel à
Configuration de l’expérience
- Le même serveur HTTP a été implémenté en Go, Bun (TypeScript) et Rust, avec comparaison de deux stratégies de stockage
- Utilisation de trois fichiers JSONL :
users.jsonl,products.jsonl,orders.jsonl - Création via
POST /users, consultation viaGET /users/:id - Seul le chemin de lecture (GET) a été retenu pour le benchmark
- Utilisation de trois fichiers JSONL :
-
Approche 1 : lire le fichier à chaque requête
- À chaque requête, le fichier est ouvert, toutes les lignes sont parcourues, le JSON est parsé puis l’ID est comparé
- En moyenne, il faut lire la moitié du fichier, soit une complexité en O(n)
- Plus les données grossissent, plus la vitesse de traitement chute fortement
-
Approche 2 : tout charger en mémoire
- Au démarrage, le fichier complet est lu et stocké dans une table de hachage indexée par ID
- Les écritures sont répercutées à la fois dans la map et dans le fichier ; les lectures se font par un simple accès à la map en O(1)
- Le fichier joue le rôle de stockage persistant, la map celui d’index
- En Go,
sync.RWMutex, et en Rust,RwLock, permettent les lectures parallèles
-
Approche 3 : recherche binaire sur disque
- Une solution intermédiaire pour obtenir des lectures rapides sans tout charger en RAM
- Création d’un fichier de données trié par ID et d’un fichier d’index à largeur fixe (58 octets/enregistrement)
- L’index est parcouru en O(log n) avec
ReadAt, puis un seul enregistrement est lu à l’offset correspondant - L’ajout de nouveaux enregistrements casse l’ordre de tri, ce qui impose une reconstruction périodique de l’index ou une fusion
- Ce schéma de fusion ressemble au fonctionnement d’un LSM-tree
Environnement de benchmark
- Taille des jeux de données : 10k, 100k, 1M enregistrements
- Outil de charge : wrk, avec requêtes GET aléatoires pendant 10 secondes, sur 4 threads et 50 connexions concurrentes
- Tests réalisés sur la même machine (Apple M1 Mac mini, macOS 15) avec Go 1.26, Bun 1.3 et Rust 1.94
- En Go, comparaison supplémentaire avec la recherche binaire (sur disque) et SQLite (modernc.org/sqlite)
Principaux résultats
- Dégradation des performances en scan linéaire : à 1M d’enregistrements, Go tombe à 23 req/s et Bun à 19 req/s
- Recherche binaire (sur disque) : de 10k à 1M d’enregistrements, le débit ne baisse que de 15 %, de 45k à 38k req/s
- Grâce au cache de pages de l’OS, les parties hautes de l’index restent toujours en mémoire
- SQLite : 25k req/s avec une latence moyenne de 2 ms, pour des performances constantes
- La recherche binaire est environ 1,7× plus rapide que SQLite, qui garde un surcoût sur une simple lecture par clé primaire
- L’approche avec map en mémoire est la plus performante : 97k à 169k req/s, avec une latence inférieure à 0,5 ms
- Bun est plus rapide que Go : Bun à 106k req/s contre 97k req/s pour Go
- Bun s’appuie sur JavaScriptCore + Zig(uWebSockets) et contourne libuv
- Rust domine largement en scan linéaire : 3 à 6 fois plus rapide que Go, probablement grâce à l’efficacité du parsing JSON et des E/S
-
Meilleur choix selon le cas d’usage
- Débit absolu maximal : map en mémoire Rust (169k req/s)
- Meilleur sans chargement en RAM : recherche binaire Go (~40k req/s)
- Si SQL est nécessaire : SQLite (25k req/s)
- Implémentation la plus simple : scan linéaire Go (~20 lignes de code)
Ce que signifient 25 000 req/s
- Le trafic web classique repose sur une hypothèse de rapport pic:moyenne = 2:1
- 12 500 req/s en moyenne → 25 000 req/s au pic
- En supposant qu’un utilisateur actif effectue 10 consultations par heure et qu’au pic 10 % des utilisateurs sont connectés simultanément
- Formule des requêtes de pointe : DAU × 0.000278
- Résultat du calcul du DAU à saturation pour chaque approche
- Scan linéaire Go : 2.8M
- Recherche binaire Go : 144M
- SQLite : 90M
- Map en mémoire Go : 349M
- Map en mémoire Bun : 381M
- Map en mémoire Rust : 608M
- La plupart des produits n’atteignent jamais ces chiffres
- Exemples : 10 000 clients SaaS → 3 req/s, application à 100 000 DAU → 30 req/s
- En conclusion, la plupart des produits en phase initiale n’ont pas besoin de base de données
- Et si besoin, un simple fichier SQLite peut tenir jusqu’à 90 millions de DAU
Quand une base de données devient nécessaire
-
Quand le jeu de données ne tient plus en RAM
- Au-delà de plusieurs dizaines de millions d’enregistrements, rien que les index peuvent nécessiter plusieurs Go
- Il faut alors paginer les données, ce qu’une base de données gère automatiquement
-
Quand il faut interroger sur d’autres champs que l’ID
- Les recherches multicritères imposent soit un scan de fichiers, soit des maps supplémentaires
- Maintenir plusieurs maps revient en pratique à réimplémenter soi-même un moteur de requêtes
-
Quand des jointures sont nécessaires
- Il faut lire et combiner plusieurs fichiers, et SQL devient plus efficace
-
En cas d’écritures concurrentes depuis plusieurs processus
- Les maps en mémoire de chaque instance sont séparées, ce qui fait perdre la cohérence
- Il faut une source externe unique de vérité → le rôle d’une base de données
-
Lorsqu’il faut des écritures atomiques entre entités
- Il faut garantir que la création d’une commande et la décrémentation du stock réussissent ou échouent ensemble
- Cela nécessite d’implémenter un journal de transactions séparé, alors qu’une base de données le gère via ACID
- Les outils internes, side projects et produits en phase initiale qui n’ont pas ces contraintes
- peuvent fonctionner correctement dans la RAM d’un seul serveur
- Les fichiers JSONL peuvent ensuite être facilement migrés vers une base de données
Annexe et code fourni
- Code serveur Go, Bun et Rust inclus
- Fourniture séparée des données de seed et du script d’exécution du benchmark (
run_bench.sh) - Le fichier ZIP contient
go-server/,bun-server/,rust-server/,seed.ts - Le script initialise les données aux trois tailles, lance les tests de charge avec wrk, puis s’arrête
Informations sur DB Pro
-
DB Pro** est un client de base de données pour Mac, Windows et Linux**
- Il intègre requêtes, exploration et administration
- Avec plateforme web collaborative et IA intégrée
- La dernière version prend en charge la connexion aux bases SQLite de Val Town
- La v1.3.0 ajoute la création de bases, un éditeur multi-requêtes et la connexion à PlanetScale Vitess
Aucun commentaire pour le moment.