超越 SQLite 的可嵌入图数据库
为什么图数据库正走向可嵌入化,Rust 能带来什么,以及什么场景下图模型真正优于关系型数据库。

SQLite 无处不在。它在你的手机里、浏览器里、智能电视里,说不定还在你的汽车里。它解决了一个根本问题:让应用无需运行独立服务器就能拥有完整的 SQL 数据库。它做得非常出色,以至于「嵌入式数据库」几乎成了 SQLite 的代名词。但 SQLite 的关系模型并不总是最合适的选择。如果你的数据本质上围绕关系展开,比如社交关系、依赖图、知识网络、路由问题,那么强行把它塞进带外键的表里,得到的查询要么复杂到令人发指,要么慢到让人崩溃。
新一波可嵌入图数据库正在尝试为图数据做 SQLite 为关系型数据所做的事:提供一个快速、无外部依赖、运行在进程内的数据库,并使用真正适合关联数据的查询语言。其中好几个是用 Rust 编写的,这与这个问题非常契合。接下来我们看看图数据库为什么走向可嵌入化,Rust 生态能带来什么,以及你什么时候应该认真考虑使用它。
关系模型的盲区
关系型数据库能很好地处理大多数数据模式。一对多?外键。多对多?中间表。简单查询、聚合、过滤,SQL 就是为这些设计的。麻烦始于你的查询开始关心路径、深度和连通性的时候。
以依赖解析器为例。你有许多包,每个包依赖其他包,每个依赖又带有版本约束。你需要回答:「如果安装包 X,完整的传递依赖树是什么?是否存在循环依赖?在任意深度上是否存在冲突的版本要求?」在 SQL 中,这需要递归 CTE(公共表表达式)。它们冗长、难以优化,而且随着图的加深,速度呈指数级下降。
-- 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.
在图数据库中,这正是原生的查询模式。你不是在与数据模型对抗,而是顺着它来工作。遍历边、沿路径查找、检测环,这些都是一等操作,而不是后期拼接上去的递归。
为什么可嵌入如此重要
Neo4j 十多年来一直是主流的图数据库,它确实非常优秀。但它是一个服务器。你需要把它作为独立进程运行,通过网络协议连接,管理它的 JVM 内存,还要承担它的运维开销。对于有专门运维团队的生产应用来说,这没问题。但对于 CLI 工具、桌面应用、构建系统或嵌入式设备来说,这就太荒唐了。
SQLite 的思路在这里同样适用:很多场景需要图查询能力,却不想承担服务器的开销。比如一个构建调用图的代码分析工具,一个存储实体关系的游戏引擎,一个支持双向链接的本地优先笔记应用,或者一个网络拓扑分析器。它们都需要图查询,却没有一个希望让用户去安装和配置数据库服务器。
可嵌入图数据库运行在你的进程内,将数据存储在本地文件中,并提供库 API 而不是网络协议。你的应用像链接 SQLite 一样链接它们。没有服务器,没有端口,没有认证,也没有部署上的复杂性。
Rust 为图数据库带来了什么
Rust 已经成为大量新数据库项目的首选语言,其原因远不止那套常见的「无需垃圾回收的内存安全」说辞。
- 可预测的延迟。图遍历对延迟非常敏感。遍历中的每一跳都是一次内存访问,深度遍历会做数百万次这样的访问。在一次 10 跳的遍历中途发生 GC 停顿,就会毁掉你的尾延迟。Rust 的所有权模型提供了无 GC 停顿的确定性内存管理,这对保持查询性能的稳定至关重要。
- 安全的并发。图数据库能从并行遍历中获益良多。同时探索多条路径,可以把一个 100ms 的查询变成 10ms。Rust 的类型系统在编译期就能防止数据竞争,这意味着你可以放心地激进并行化,而不必担心破坏图数据。
- 体积小,无运行时。对于可嵌入数据库来说,部署体积很重要。一个 Rust 图数据库会编译成单个原生库,没有运行时依赖。相比之下,基于 JVM 的方案需要一个 200MB 的运行时,而 Go 方案则捆绑了一个你并没有要求的垃圾回收器。
- C FFI。Rust 能够暴露与 C 兼容的 API,这意味着该数据库几乎可以在任何语言中使用。用 Rust 编写,然后从 Python、JavaScript、Go、Swift 或任何支持 C 的语言中调用即可。
属性图模型
大多数可嵌入图数据库采用属性图模型。如果你之前没接触过图数据库,它值得了解一下。该模型有三个基本要素:
- 节点:带有标签和键值属性的实体。可以把它们看作表中的行,只是没有固定的模式。
- 边:节点之间的有向连接,同样带有标签和属性。标签描述关系类型,例如 DEPENDS_ON、AUTHORED_BY、LINKS_TO。
- 遍历:沿着边从一个节点走到另一个节点的查询,可以选择按属性过滤、聚合结果或查找路径。
// 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()?;
对于快速演进的数据,属性图模型比关系模式更灵活。你无需预先定义模式,添加新的关系类型时也不必执行迁移,只需用新标签创建边即可。这种无模式的灵活性是把双刃剑:你会失去定义良好的模式所带来的安全保障。但对于图结构不断演变的应用,比如知识库、社交网络、依赖追踪,这是一个务实的权衡。
什么时候真正该用图数据库
图数据库常被推荐到根本用不上它的场景里,却又在真正能从中受益的场景中被忽视。以下是我对它适用与不适用场景的坦率判断。
适合的场景:依赖解析、访问控制(谁通过哪些用户组能访问什么)、欺诈检测(发现实体之间的关联)、推荐引擎(喜欢 X 的用户也喜欢 Y)、网络拓扑分析、知识图谱以及代码分析工具。它们的共同点是:查询天然地表达了路径和连通性。
不适合的场景:简单的 CRUD 应用、时序数据、分析和聚合型负载,以及主要查询形式为「获取满足条件 X 的记录」的任何场景。如果你写的是 SELECT * FROM users WHERE country = 'US' ORDER BY created_at,那关系型数据库才是正确的工具。不要因为图数据库听起来很酷就去用它,只有当你的查询确实具有图的形态时才应该使用。
我自己常用的判断标准是:在白板上画出你的数据模型。如果它大多是一行行的方框,也就是带属性的实体,那就用关系型数据库。如果它是由箭头连接的方框,而且箭头和方框同样重要,那就可以考虑图数据库。
存储引擎与权衡
在底层,可嵌入图数据库会面临一些有趣的存储引擎决策。最常见的几种方案如下:
- 邻接表存储。每个节点保存一份其出边的列表。对局部遍历(查找节点的邻居)很快,但对全局查询(查找所有带标签 X 的边)较慢。大多数可嵌入图数据库采用这种方式,因为局部遍历是最常见的操作。
- 边列表存储。边保存在一个单独的有序结构中,按源节点、目标节点或标签建立索引。更适合全局查询和批量操作,但会给局部遍历增加一层间接访问。
- 混合方案。一些数据库用邻接表做遍历,同时在边标签或节点属性上维护二级索引,以支持过滤查询。这种方案最灵活,但占用更多存储,写入成本也更高。
许多基于 Rust 的图数据库都构建在现成的嵌入式键值存储之上,比如 RocksDB 或 sled。这是一个务实的选择:你可以免费获得经过实战检验的持久化、崩溃恢复和压缩能力。图层负责把节点和边映射为键值操作。缺点在于,性能上限受制于底层键值存储的特性,而且一些图专属的优化(比如将节点的边在磁盘上连续存放,以实现对缓存友好的遍历)会更难实现。
查询语言:一个尚无定论的问题
SQL 是关系型数据库的通用语言,而图数据库领域并没有对应的共识。Cypher(来自 Neo4j)、Gremlin(来自 Apache TinkerPop)、SPARQL(用于 RDF 图)以及正在兴起的 GQL 标准,都在争夺关注度。
大多数可嵌入图数据库绕开了这个问题,转而在宿主语言中提供构建器模式的 API,而不是查询语言。你用方法链来构造遍历,这在静态类型语言中用起来很顺手,也避免了解析和优化查询语言的复杂性。代价是你的查询无法在不同数据库之间移植。从一个可嵌入图数据库切换到另一个,意味着要重写查询代码。
GQL(Graph Query Language)是旨在统一图查询的 ISO 标准,它大量借鉴了 Cypher,并正在逐步获得采用。如果你现在要选择一个可嵌入图数据库,不妨看看它的路线图上是否有 GQL 支持。标准化的查询语言往往能笑到最后,即便私有方案在初期更为成熟。
实践中的考量
如果你正在为项目评估一个可嵌入图数据库,以下几点在实践中真正重要:
- 用你的负载来测量。图数据库的基准测试往往极具误导性。擅长浅而宽的遍历(比如社交网络中的好友的好友)的数据库,可能在深而窄的遍历(比如依赖链解析)上表现吃力。请用你实际的查询模式做原型验证。
- 检查崩溃安全性。嵌入式数据库的生死取决于其持久性保证。数据库是否使用预写日志?是否具备崩溃安全性?能否在断电后恢复且不丢失数据?「异常关机就会损坏数据」对生产环境来说是不可接受的。
- 了解内存模型。一些可嵌入图数据库会将整个图做内存映射,在图超出可用内存之前一切都很好。另一些则采用磁盘优先加缓存的方式。要清楚你所选数据库使用的是哪种模型,以及你的图能否装得下。
- 关注绑定质量。如果数据库是用 Rust 编写的,但你是从 Python 中使用它,那么 Python 绑定比 Rust 内部实现更重要。检查这些绑定是否有人维护、是否有文档、性能是否良好,还是只是事后补充的东西。
- 考虑迁移。无模式并不意味着无需变更。当你的图模型演进时,如何处理已有数据?有些数据库支持迁移脚本或带版本的模式,有些则完全交给你自己处理。
与关系型世界相比,可嵌入图数据库领域仍然很年轻。SQLite 已经打磨了二十多年,而大多数可嵌入图数据库的历史还不到五年。这意味着更粗糙的边角、更少的社区资源,以及更高的风险。但其核心价值主张,也就是无需服务器开销的图查询,是站得住脚的。对于合适的场景,一个可嵌入图数据库可以用几行遍历代码取代数百行递归 SQL,运行得更快,还能让你的数据模型真正贴合要解决的问题。


