Des articles approfondis sur les technologies qui façonnent l'avenir.

Bases de données graphes embarquables au-delà de SQLite

Pourquoi les bases graphes deviennent embarquables, ce que Rust apporte et quand un modèle graphe surpasse vraiment le relationnel pour votre cas d'usage.

Petit cube de nœuds connectés lumineux niché dans une carcasse mécanique rouillée

SQLite est partout. Il se trouve dans votre téléphone, votre navigateur, votre télévision connectée, et probablement dans votre voiture. Il a résolu un problème fondamental, offrir aux applications une base de données SQL complète sans faire tourner un serveur séparé, et l'a fait si bien que « base de données embarquée » et « SQLite » sont devenus presque synonymes. Mais le modèle relationnel de SQLite n'est pas toujours le bon choix. Si vos données parlent avant tout de relations (connexions sociales, graphes de dépendances, réseaux de connaissances, problèmes de routage), les forcer dans des tables avec des clés étrangères produit des requêtes soit terriblement complexes, soit douloureusement lentes.

Une nouvelle vague de bases de données graphes embarquables tente de faire pour les données en graphe ce que SQLite a fait pour les données relationnelles : vous offrir une base rapide, sans dépendances, qui s'exécute dans le processus et parle le bon langage de requête pour les données connectées. Plusieurs d'entre elles sont écrites en Rust, ce qui s'avère être un excellent choix pour ce problème. Voyons pourquoi les bases graphes deviennent embarquables, ce que l'écosystème Rust apporte, et quand vous devriez vraiment envisager l'une d'elles.

L'angle mort du modèle relationnel

Les bases relationnelles gèrent bien la plupart des schémas de données. Un-à-plusieurs ? Clé étrangère. Plusieurs-à-plusieurs ? Table de jonction. Recherches simples, agrégations, filtres : SQL a été conçu pour ça. Les ennuis commencent quand vos requêtes portent sur les chemins, la profondeur et la connectivité.

Prenons un résolveur de dépendances. Vous avez des paquets, chacun dépendant d'autres paquets, chacun avec des contraintes de version. Vous devez répondre à : « Si j'installe le paquet X, quel est l'arbre complet des dépendances transitives ? Y a-t-il des dépendances circulaires ? Y a-t-il des exigences de version contradictoires à n'importe quelle profondeur ? » En SQL, cela demande des CTE récursives (expressions de table communes), verbeuses, difficiles à optimiser, et qui deviennent exponentiellement plus lentes à mesure que le graphe s'approfondit.

-- Finding all transitive dependencies in SQL
-- This works, but gets ugly fast
WITH RECURSIVE deps AS (
-- Base case: direct dependencies
SELECT dependency_id, 1 as depth
FROM package_dependencies
WHERE package_id = 'my-package'
UNION ALL
-- Recursive case: dependencies of dependencies
SELECT pd.dependency_id, d.depth + 1
FROM package_dependencies pd
JOIN deps d ON pd.package_id = d.dependency_id
WHERE d.depth < 50  -- safety limit to prevent infinite loops
)
SELECT DISTINCT dependency_id, MIN(depth) as min_depth
FROM deps
GROUP BY dependency_id
ORDER BY min_depth;
-- Now try detecting circular dependencies.
-- Or finding the shortest path between two packages.
-- Or querying all packages within 3 hops that match a version constraint.
-- Each query gets progressively more painful.

Dans une base graphe, c'est le modèle de requête natif. Vous ne luttez pas contre le modèle de données, vous travaillez avec lui. Parcourir des arêtes, suivre des chemins, détecter des cycles : ce sont des opérations de premier ordre, pas une récursion ajoutée après coup.

Pourquoi l'embarquable compte

Neo4j est la base graphe dominante depuis plus de dix ans, et elle est vraiment excellente. Mais c'est un serveur. Vous le lancez comme un processus séparé, vous vous connectez via un protocole réseau, vous gérez sa mémoire JVM et sa charge opérationnelle. Pour une application en production avec une équipe ops dédiée, c'est acceptable. Pour un outil en ligne de commande, une application de bureau, un système de build ou un appareil embarqué, c'est absurde.

L'intuition de SQLite s'applique ici : beaucoup de cas d'usage ont besoin de requêtes de graphe sans la lourdeur d'un serveur. Un outil d'analyse de code qui construit un graphe d'appels. Un moteur de jeu qui stocke les relations entre entités. Une application locale de prise de notes avec des liens bidirectionnels. Un analyseur de topologie réseau. Tous veulent des requêtes de graphe, et aucun ne veut demander à ses utilisateurs d'installer et configurer un serveur de base de données.

Les bases graphes embarquables s'exécutent dans votre processus, stockent les données dans des fichiers locaux et exposent une API de bibliothèque au lieu d'un protocole réseau. Votre application se lie à elles comme elle le ferait avec SQLite. Pas de serveur, pas de port, pas d'authentification, pas de complexité de déploiement.

Ce que Rust apporte aux bases de données graphes

Rust est devenu le langage de prédilection d'un nombre disproportionné de nouveaux projets de bases de données, et les raisons vont au-delà de l'argument habituel « sécurité mémoire sans ramasse-miettes ».

  • Latence prévisible. Les parcours de graphe sont sensibles à la latence. Chaque saut d'un parcours est un accès mémoire, et les parcours profonds en font des millions. Une pause du GC au milieu d'un parcours de 10 sauts fait exploser votre latence de queue. Le modèle d'ownership de Rust offre une gestion mémoire déterministe, sans pauses GC, ce qui est essentiel pour des performances de requête constantes.
  • Concurrence sûre. Les bases graphes profitent énormément du parcours parallèle. Explorer plusieurs chemins simultanément peut transformer une requête de 100 ms en une requête de 10 ms. Le système de types de Rust empêche les data races à la compilation, ce qui permet de paralléliser agressivement sans risquer de corrompre les données du graphe.
  • Binaire léger, sans runtime. Pour une base embarquable, la taille du déploiement compte. Une base graphe en Rust se compile en une seule bibliothèque native, sans dépendance à un runtime. Comparez cela à une solution JVM qui nécessite un runtime de 200 Mo, ou à une solution Go qui embarque un ramasse-miettes que vous n'avez pas demandé.
  • FFI C. La capacité de Rust à exposer une API compatible C signifie que la base peut être utilisée depuis presque n'importe quel langage. Écrivez-la en Rust, appelez-la depuis Python, JavaScript, Go, Swift ou tout autre langage qui parle C.

Le modèle de graphe à propriétés

La plupart des bases graphes embarquables utilisent le modèle de graphe à propriétés, qui mérite d'être compris si vous n'avez jamais travaillé avec des bases graphes. Ce modèle repose sur trois primitives :

  • Nœuds — des entités avec un label et des propriétés clé-valeur. Pensez-les comme des lignes de table, mais sans schéma figé.
  • Arêtes — des connexions orientées entre nœuds, elles aussi avec un label et des propriétés. Le label décrit le type de relation (DEPENDS_ON, AUTHORED_BY, LINKS_TO).
  • Parcours — des requêtes qui suivent les arêtes de nœud en nœud, en filtrant éventuellement par propriétés, en agrégeant des résultats ou en recherchant des chemins.
// Conceptual API for an embeddable graph database
let db = GraphDB::open("my_graph.db")?;
// Create nodes
let alice = db.create_node("Person", props! {
"name" => "Alice",
"role" => "engineer"
})?;
let project = db.create_node("Project", props! {
"name" => "Auth Service",
"language" => "Rust"
})?;
// Create edges
db.create_edge(alice, project, "WORKS_ON", props! {
"since" => "2025-01"
})?;
// Traverse: find all projects worked on by Alice's teammates
let results = db.traverse()
.from(alice)
.follow("WORKS_ON")        // Alice -> projects
.reverse("WORKS_ON")       // projects <- other people
.follow("WORKS_ON")        // other people -> their projects
.filter(|node| node.label() == "Project")
.collect()?;

Le modèle de graphe à propriétés est plus souple que les schémas relationnels pour des données qui évoluent rapidement. Pas besoin de définir le schéma à l'avance ni de lancer des migrations quand vous ajoutez un nouveau type de relation. Il suffit de créer des arêtes avec de nouveaux labels. Cette souplesse sans schéma est une arme à double tranchant : vous perdez les garanties de sécurité d'un schéma bien défini. Mais pour les applications dont la structure de graphe évolue (bases de connaissances, réseaux sociaux, suivi de dépendances), c'est un compromis pragmatique.

Quand utiliser réellement une base graphe

On recommande les bases graphes dans des situations où elles sont inutiles, et on les néglige dans des situations où elles seraient vraiment utiles. Voici mon avis honnête sur les domaines où elles brillent et ceux où elles ne brillent pas.

Bon choix : résolution de dépendances, contrôle d'accès (qui peut accéder à quoi via quels groupes), détection de fraude (trouver des liens entre entités), moteurs de recommandation (les utilisateurs qui ont aimé X ont aussi aimé Y), analyse de topologie réseau, graphes de connaissances et outils d'analyse de code. Le point commun : vos requêtes expriment naturellement des chemins et de la connectivité.

Mauvais choix : applications CRUD simples, données de séries temporelles, charges analytiques et d'agrégation, et tout ce qui se résume à « récupérer les enregistrements qui correspondent à la condition X ». Si vous écrivez SELECT * FROM users WHERE country = 'US' ORDER BY created_at, une base relationnelle est le bon outil. N'utilisez pas une base graphe parce que ça semble intéressant : utilisez-la parce que vos requêtes ont vraiment une forme de graphe.

Le test que j'utilise : dessinez votre modèle de données sur un tableau blanc. Si ce sont surtout des boîtes alignées en lignes (entités avec attributs), utilisez une base relationnelle. Si ce sont des boîtes reliées par des flèches, et que les flèches comptent autant que les boîtes, envisagez une base graphe.

Moteurs de stockage et compromis

Sous le capot, les bases graphes embarquables affrontent des choix de moteur de stockage intéressants. Les approches les plus courantes :

  • Stockage par liste d'adjacence. Chaque nœud stocke la liste de ses arêtes sortantes. Rapide pour les parcours locaux (trouver les voisins d'un nœud), mais lent pour les requêtes globales (trouver toutes les arêtes avec le label X). La plupart des bases graphes embarquables l'utilisent, car les parcours locaux sont l'opération la plus fréquente.
  • Stockage par liste d'arêtes. Les arêtes sont stockées dans une structure triée séparée, indexée par source, cible ou label. Meilleur pour les requêtes globales et les opérations en masse, mais ajoute une indirection aux parcours locaux.
  • Approches hybrides. Certaines bases utilisent des listes d'adjacence pour le parcours et maintiennent des index secondaires sur les labels d'arêtes ou les propriétés de nœuds pour les requêtes filtrées. C'est la solution la plus souple, mais elle consomme plus d'espace et rend les écritures plus coûteuses.

De nombreuses bases graphes en Rust reposent sur des stores clé-valeur embarqués existants comme RocksDB ou sled. C'est un choix pragmatique : vous obtenez une persistance éprouvée, la récupération après crash et la compaction sans effort. La couche graphe traduit les nœuds et les arêtes en opérations clé-valeur. L'inconvénient est que le plafond de performance est limité par les caractéristiques du store clé-valeur, et que les optimisations spécifiques aux graphes (comme stocker les arêtes d'un nœud de façon contiguë sur disque pour un parcours favorable au cache) peuvent être plus difficiles à implémenter.

Langages de requête : une question non tranchée

SQL est le langage universel des bases relationnelles. Les bases graphes n'ont pas d'équivalent consensuel. Cypher (de Neo4j), Gremlin (d'Apache TinkerPop), SPARQL (pour les graphes RDF) et le standard GQL émergent se disputent toujours la préférence.

La plupart des bases graphes embarquables contournent le problème en proposant une API en pattern builder dans le langage hôte plutôt qu'un langage de requête. Vous construisez les parcours par chaînage de méthodes, ce qui est ergonomique dans les langages typés et évite la complexité d'analyser et d'optimiser un langage de requête. Le compromis : vos requêtes ne sont pas portables d'une base à l'autre. Passer d'une base graphe embarquable à une autre signifie réécrire votre code de requête.

GQL (Graph Query Language) est la norme ISO censée unifier l'interrogation de graphes. Elle emprunte beaucoup à Cypher et gagne progressivement du terrain. Si vous choisissez aujourd'hui une base graphe embarquable, vérifiez si elle prévoit le support de GQL dans sa feuille de route : les langages de requête standardisés finissent généralement par s'imposer sur le long terme, même si les solutions propriétaires sont plus abouties au départ.

Considérations pratiques

Si vous évaluez une base graphe embarquable pour votre projet, voici ce qui compte vraiment en pratique :

  1. Mesurez avec votre charge de travail. Les benchmarks de bases graphes sont notoirement trompeurs. Une base qui excelle sur des parcours superficiels et larges (ami d'ami sur un réseau social) peut peiner sur des parcours profonds et étroits (résolution de chaînes de dépendances). Prototypez avec vos vrais schémas de requêtes.
  2. Vérifiez la sécurité en cas de crash. Les bases embarquées vivent et meurent selon leurs garanties de durabilité. La base utilise-t-elle un journal d'écriture anticipée (write-ahead log) ? Est-elle résistante aux crashs ? Peut-elle récupérer après une coupure de courant sans perte de données ? « Corruption en cas d'arrêt inattendu » n'est pas une réponse acceptable en production.
  3. Regardez le modèle mémoire. Certaines bases graphes embarquables mappent tout le graphe en mémoire, ce qui fonctionne très bien jusqu'à ce que le graphe dépasse la RAM disponible. D'autres adoptent une approche disque d'abord avec du cache. Sachez quel modèle utilise la base choisie et si votre graphe y tient.
  4. Évaluez la qualité des bindings. Si la base est écrite en Rust mais que vous l'utilisez depuis Python, les bindings Python comptent plus que les entrailles Rust. Vérifiez que les bindings sont maintenus, documentés et performants, et non une réflexion après coup.
  5. Anticipez les migrations. Sans schéma ne signifie pas sans changement. Quand votre modèle de graphe évolue, comment gérez-vous les données existantes ? Certaines bases prennent en charge des scripts de migration ou des schémas versionnés. D'autres vous laissent tout gérer.

L'écosystème des bases graphes embarquables est encore jeune comparé au monde relationnel. SQLite a été affiné pendant plus de deux décennies. La plupart des bases graphes embarquables ont moins de cinq ans. Cela implique des aspérités, moins de ressources communautaires et plus de risques. Mais la proposition de valeur centrale, des requêtes de graphe sans la lourdeur d'un serveur, est solide. Pour le bon cas d'usage, une base graphe embarquable peut remplacer des centaines de lignes de SQL récursif par quelques lignes de code de parcours, s'exécuter plus vite au passage, et faire en sorte que votre modèle de données colle vraiment au problème que vous résolvez.