Database a grafo incorporabili oltre SQLite
Perché i database a grafo diventano incorporabili, cosa porta Rust sul tavolo e quando un modello a grafo batte davvero il relazionale per il tuo caso d'uso.

SQLite è ovunque. Sta nel tuo telefono, nel browser, nella smart TV, probabilmente nell'auto. Ha risolto un problema fondamentale, cioè dare alle applicazioni un database SQL completo senza dover far girare un server separato, e lo ha fatto così bene che «database incorporato» e «SQLite» sono diventati quasi sinonimi. Però il modello relazionale di SQLite non è sempre la scelta giusta. Se i tuoi dati ruotano attorno alle relazioni, come connessioni sociali, grafi di dipendenze, reti di conoscenza o problemi di routing, forzarli in tabelle con chiavi esterne produce query o terribilmente complesse o dolorosamente lente.
Una nuova ondata di database a grafo incorporabili prova a fare per i dati a grafo ciò che SQLite ha fatto per quelli relazionali: offrire un database veloce, senza dipendenze e in-process, che parli il linguaggio di query giusto per i dati connessi. Diversi di questi sono scritti in Rust, che si rivela un'ottima scelta per questo problema. Vediamo perché i database a grafo stanno diventando incorporabili, cosa porta l'ecosistema Rust e quando dovresti davvero considerarne uno.
Il punto cieco del modello relazionale
I database relazionali gestiscono bene la maggior parte dei pattern di dati. Uno-a-molti? Chiave esterna. Molti-a-molti? Tabella di join. Ricerche semplici, aggregazioni, filtri: SQL è nato per questo. I problemi iniziano quando le tue query si preoccupano di percorsi, profondità e connettività.
Prendiamo un risolutore di dipendenze. Hai dei pacchetti, ognuno dipende da altri pacchetti, e ciascuno ha vincoli di versione. Devi rispondere a domande come: «Se installo il pacchetto X, qual è l'intero albero delle dipendenze transitive? Ci sono dipendenze circolari? Ci sono requisiti di versione in conflitto a qualsiasi profondità?». In SQL questo richiede CTE ricorsive (common table expression), che sono prolisse, difficili da ottimizzare e diventano esponenzialmente più lente man mano che il grafo si approfondisce.
-- 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.
In un database a grafo, questo è il pattern di query nativo. Non combatti contro il modello dei dati, ci lavori insieme. Attraversare archi, seguire percorsi, rilevare cicli: sono operazioni di prima classe, non ricorsione aggiunta a posteriori.
Perché l'incorporabilità conta
Neo4j è il database a grafo dominante da oltre un decennio, ed è davvero eccellente. Ma è un server. Lo avvii come processo separato, ti connetti tramite un protocollo di rete, gestisci la memoria della sua JVM e ti occupi del suo overhead operativo. Per un'applicazione in produzione con un team ops dedicato va benissimo. Per uno strumento a riga di comando, un'app desktop, un sistema di build o un dispositivo embedded, è assurdo.
L'intuizione di SQLite vale anche qui: molti casi d'uso hanno bisogno di query su grafi senza l'overhead di un server. Uno strumento di analisi del codice che costruisce un call graph. Un game engine che memorizza le relazioni tra entità. Un'app locale-first per prendere appunti con link bidirezionali. Un analizzatore di topologie di rete. Tutti vogliono query su grafi, nessuno vuole chiedere ai propri utenti di installare e configurare un server di database.
I database a grafo incorporabili girano nel tuo processo, salvano i dati in file locali ed espongono un'API di libreria invece di un protocollo di rete. La tua applicazione si collega a loro come farebbe con SQLite. Niente server, niente porte, niente autenticazione, niente complessità di deployment.
Cosa porta Rust ai database a grafo
Rust è diventato il linguaggio di riferimento per un numero sproporzionato di nuovi progetti di database, e i motivi vanno oltre il solito discorso su «sicurezza della memoria senza garbage collector».
- Latenza prevedibile. Le traversate di grafi sono sensibili alla latenza. Ogni salto in una traversata è un accesso alla memoria, e le traversate profonde ne fanno milioni. Una pausa del GC a metà di una traversata di 10 salti fa esplodere la latenza di coda. Il modello di ownership di Rust ti dà una gestione della memoria deterministica senza pause del GC, fondamentale per prestazioni di query costanti.
- Concorrenza sicura. I database a grafo traggono enormi benefici dalla traversata parallela. Esplorare più percorsi contemporaneamente può trasformare una query da 100 ms in una da 10 ms. Il sistema di tipi di Rust previene le data race in fase di compilazione, il che significa che puoi parallelizzare in modo aggressivo senza il timore di corrompere i dati del grafo.
- Binario piccolo, nessun runtime. Per un database incorporabile, la dimensione del deployment conta. Un database a grafo in Rust compila in una singola libreria nativa senza dipendenze runtime. Confrontalo con una soluzione basata su JVM che richiede un runtime da 200 MB, o con una soluzione in Go che include un garbage collector che non hai chiesto.
- FFI verso C. La capacità di Rust di esporre un'API compatibile con C significa che il database può essere usato praticamente da qualsiasi linguaggio. Scrivilo in Rust, richiamalo da Python, JavaScript, Go, Swift o da qualunque altro linguaggio che parli C.
Il modello a grafo delle proprietà
La maggior parte dei database a grafo incorporabili usa il modello a grafo delle proprietà, che vale la pena capire se non hai mai lavorato con database a grafo. Il modello si basa su tre primitive:
- Nodi: entità con un'etichetta e proprietà chiave-valore. Pensali come righe di una tabella, ma senza schema fisso.
- Archi: connessioni dirette tra nodi, anch'esse con un'etichetta e proprietà. L'etichetta descrive il tipo di relazione (DEPENDS_ON, AUTHORED_BY, LINKS_TO).
- Traversate: query che seguono gli archi da nodo a nodo, eventualmente filtrando per proprietà, aggregando risultati o cercando percorsi.
// 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()?;
Il modello a grafo delle proprietà è più flessibile degli schemi relazionali per dati che evolvono rapidamente. Non devi definire lo schema in anticipo né eseguire migrazioni quando aggiungi un nuovo tipo di relazione. Crei semplicemente archi con nuove etichette. Questa flessibilità senza schema è un'arma a doppio taglio, perché perdi le garanzie di sicurezza di uno schema ben definito, ma per applicazioni in cui la struttura del grafo evolve (basi di conoscenza, social network, tracciamento delle dipendenze) è un compromesso pragmatico.
Quando usare davvero un database a grafo
I database a grafo vengono raccomandati in situazioni in cui sono superflui, e trascurati in situazioni in cui potrebbero aiutare davvero. Ecco la mia valutazione onesta su dove brillano e dove no.
Caso ideale: risoluzione delle dipendenze, controllo degli accessi (chi può accedere a cosa attraverso quali appartenenze a gruppi), rilevamento delle frodi (trovare connessioni tra entità), motori di raccomandazione (chi ha messo like a X ha messo like anche a Y), analisi della topologia di rete, knowledge graph e strumenti di analisi del codice. Il filo comune: le tue query esprimono in modo naturale percorsi e connettività.
Caso sconsigliato: semplici applicazioni CRUD, dati di serie temporali, carichi di analytics e aggregazione, tutto ciò in cui le query sono principalmente «recupera i record che soddisfano la condizione X». Se scrivi SELECT * FROM users WHERE country = 'US' ORDER BY created_at, un database relazionale è lo strumento giusto. Non usare un database a grafo solo perché suona interessante: usalo perché le tue query hanno davvero una forma a grafo.
Il test decisivo che uso: disegna il tuo modello dei dati su una lavagna. Se è fatto per lo più di riquadri in righe (entità con attributi), usa un database relazionale. Se sono riquadri collegati da frecce e le frecce contano quanto i riquadri, valuta un database a grafo.
Motori di storage e compromessi
Sotto il cofano, i database a grafo incorporabili affrontano decisioni interessanti sul motore di storage. Gli approcci più comuni sono:
- Storage a lista di adiacenza. Ogni nodo memorizza la lista dei suoi archi uscenti. Veloce per le traversate locali (trovare i vicini di un nodo) ma lento per le query globali (trovare tutti gli archi con etichetta X). La maggior parte dei database a grafo incorporabili usa questo approccio perché le traversate locali sono l'operazione più comune.
- Storage a lista di archi. Gli archi sono memorizzati in una struttura ordinata separata, indicizzata per sorgente, destinazione o etichetta. Migliore per query globali e operazioni in blocco, ma aggiunge un livello di indirezione alle traversate locali.
- Approcci ibridi. Alcuni database usano liste di adiacenza per le traversate e mantengono indici secondari su etichette degli archi o proprietà dei nodi per le query filtrate. È l'approccio più flessibile, ma occupa più spazio e rende le scritture più costose.
Molti database a grafo basati su Rust si appoggiano a store chiave-valore incorporabili già esistenti, come RocksDB o sled. È una scelta pragmatica: ottieni persistenza collaudata, recupero dai crash e compattazione senza sforzo. Il livello a grafo traduce nodi e archi in operazioni chiave-valore. Lo svantaggio è che il tetto prestazionale è vincolato alle caratteristiche dello store chiave-valore, e le ottimizzazioni specifiche per i grafi (come memorizzare gli archi di un nodo in modo contiguo su disco per una traversata favorevole alla cache) possono essere più difficili da implementare.
Linguaggi di query: la questione aperta
SQL è il linguaggio universale per i database relazionali. I database a grafo non hanno un equivalente con lo stesso consenso. Cypher (di Neo4j), Gremlin (di Apache TinkerPop), SPARQL (per i grafi RDF) e il nuovo standard GQL si contendono l'attenzione.
La maggior parte dei database a grafo incorporabili aggira il problema offrendo un'API con pattern builder nel linguaggio host invece di un linguaggio di query. Costruisci le traversate con catene di metodi, il che è ergonomico nei linguaggi tipizzati ed evita la complessità di analizzare e ottimizzare un linguaggio di query. Il compromesso è che le tue query non sono portabili tra database: passare da un database a grafo incorporabile a un altro significa riscrivere il codice delle query.
GQL (Graph Query Language) è lo standard ISO che dovrebbe unificare le query su grafi. Prende molto da Cypher e sta guadagnando adozione gradualmente. Se oggi scegli un database a grafo incorporabile, vale la pena verificare se il supporto a GQL è nella roadmap: i linguaggi di query standardizzati tendono a vincere nel lungo periodo, anche se quelli proprietari sono inizialmente più rifiniti.
Considerazioni pratiche
Se stai valutando un database a grafo incorporabile per il tuo progetto, ecco cosa conta davvero nella pratica:
- Misura con il tuo carico di lavoro. I benchmark sui database a grafo sono notoriamente fuorvianti. Un database che eccelle in traversate superficiali e ampie (amico-di-amico in un social network) può faticare con quelle profonde e strette (risoluzione di catene di dipendenze). Prototipa con i tuoi pattern di query reali.
- Controlla la sicurezza in caso di crash. I database incorporati vivono e muoiono in base alle loro garanzie di durabilità. Il database usa un write-ahead log? È resistente ai crash? Può recuperare da un'interruzione di corrente senza perdere dati? «Corruzione in caso di spegnimento imprevisto» non è una risposta accettabile per la produzione.
- Guarda il modello di memoria. Alcuni database a grafo incorporabili mappano in memoria l'intero grafo, il che funziona benissimo finché il grafo non supera la RAM disponibile. Altri usano un approccio disk-first con cache. Sappi quale modello usa il database che scegli e se il tuo grafo ci sta.
- Considera la qualità dei binding. Se il database è scritto in Rust ma lo usi da Python, i binding Python contano più degli internals Rust. Verifica che i binding siano mantenuti, documentati e performanti, e non un ripensamento.
- Pensa alle migrazioni. Senza schema non significa senza cambiamenti. Quando il modello del tuo grafo evolve, come gestisci i dati esistenti? Alcuni database supportano script di migrazione o schemi versionati. Altri lasciano tutto a te.
Lo spazio dei database a grafo incorporabili è ancora giovane rispetto al mondo relazionale. SQLite è stato affinato per oltre due decenni. La maggior parte dei database a grafo incorporabili ha meno di cinque anni. Questo significa spigoli più ruvidi, meno risorse della community e più rischi. Ma la proposta di valore di fondo, cioè query su grafi senza overhead di server, è solida. Per il caso d'uso giusto, un database a grafo incorporabile può sostituire centinaia di righe di SQL ricorsivo con poche righe di codice di traversata, girare più velocemente nel frattempo e far sì che il tuo modello dei dati corrisponda davvero al problema che stai risolvendo.


