holt-cj 介绍
一个小图库的边界清单
holt-cj 是基于 badger-cj 的嵌入式图数据库。
它只有 720 行实现代码,所以本文不打算把它讲成一个大项目——
而是把它的能力和边界一条条列清楚,方便你判断能不能用。
一、它是什么
一句话:嵌入式图数据库,顶点 + 边 + 属性 + 遍历。
import holt.*
import jsonvalue.*
let g = Holt.openInMemory()
// let g = Holt.open("/data/graph")
// 建两个顶点(多标签 + JsonValue 属性)
let alice = g.createVertex(
ArrayList<String>(["Person"]),
HashMap<String, JsonValue>([
("name",
JsonValue.String("Alice")),
("age", JsonValue.Number(30))
])
)
let bob = g.createVertex(
ArrayList<String>(["Person"]), ...
)
// 建一条边
g.createEdge("knows", alice.id, bob.id,
HashMap<String, JsonValue>([
("since", JsonValue.Number(2020))
]))
数据模型很直接:
- 顶点:ULID 标识 + 多标签(
Array<String>)+ 属性(HashMap<String, JsonValue>) - 边:ULID 标识 + 谓词(label)+ 起点 + 终点 + 属性
- 属性值:
JsonValue,可以是字符串/数字/布尔/数组/对象
二、核心 API
2.1 顶点与边
| 操作 | 方法 |
|---|---|
| 建/读写顶点 | createVertex getVertex updateVertex deleteVertex(cascade) |
| 建/读写边 | createEdge getEdge updateEdge deleteEdge |
| 按标签查 | has(label) |
| 邻接 | getOutgoingEdges getIncomingEdges getNeighbors(dir) |
deleteVertex(id, cascade: true) 会级联删掉关联的边——这个设计是必要的,否则会留下悬空的边。
2.2 遍历:Gremlin 风格
这是它最有意思的部分。遍历是惰性构建的:每一步只是往列表里追加动作,toList() 才真正执行。
let result = g.V()
// 按标签过滤
.has("Person")
// 按属性过滤
.has("age", JsonValue.Number(30))
// 沿出边走到邻居
.out(["knows"])
.limit(10)
.toList()
支持的步骤:
| 步骤 | 作用 |
|---|---|
V() / V(ids) |
全量起点 / 指定起点 |
hasLabel(label) |
按标签过滤 |
has(key, value) |
按属性过滤 |
out(labels) / in(labels) / both(labels) |
沿边走出去 |
limit(n) |
截断 |
toList() / next() |
求值 |
注意:目前不需要先 V()——V(ids) 和 V() 都能直接开始。
2.3 事务与运维
// 显式事务
g.transaction({ txn =>
txn.createVertex(...)
txn.createEdge(...)
})
// 备份/恢复(流式)
g.backup(writer)
g.restore(reader)
// 统计
let stats = g.getStats()
外加 registerSignalShutdown() / registerShutdownHook()——和 sunku 一样从 badger-cj 继承的优雅退出能力。
三、它是怎么存的
看懂存储布局,才能理解它的性能特征。key 前缀一共五种:
| 前缀 | 存什么 |
|---|---|
v:<vid> |
顶点(标签 + 属性) |
e:<eid> |
边(谓词 + outVid + inVid + 属性) |
eout:<vid>:<label>:<eid> |
出边索引(按「顶点 + 谓词」) |
ein:<vid>:<label>:<eid> |
入边索引 |
el:<label>:<eid> |
边标签索引 |
这五个前缀是实打实的,所以这两种查询是真正的索引查找:
// 一次前缀扫描
g.getOutgoingEdges(vid)
// 更窄的前缀
g.getOutgoingEdges(vid, "knows")
// 走 el: 前缀
g.has("Person")
这也是为什么「按谓词找边」「找某顶点的邻居」在这个库上是快的。
四、边界:三件还没做好的事
这一节是本文的重点。买之前先知道哪里会撞墙。
4.1 属性索引是空的
这是最重要的一条。createIndex() 的方法体是这样的:
public func createIndex(kind: IndexType,
label: String,
keys: ArrayList<String>): Unit {
// 边标签索引始终启用(el: 前缀)
// 属性索引直接依赖 PropValue 类型,
// 不需要额外操作
}
方法体里只有两行注释,没有实际动作。 getIndexes() 则直接返回硬编码的空列表:
public func getIndexes():
ArrayList<IndexMeta> {
ArrayList<IndexMeta>()
}
所以 createIndex() 调了也不会报错,但不会有任何索引被创建。
4.2 属性过滤走内存
因为属性索引不存在,.has(key, value) 的实现是往过滤器列表里塞一个闭包:
public func has(key: String,
value: JsonValue)
: VertexTraversal {
filters.add({ v: Vertex =>
let got = v.props.get(key)
if (got.isNone()) { false }
else {
jsonEq(got.getOrThrow(),
value)
}
})
this
}
它做的是:把顶点读进内存,然后逐个判断属性。
后果:
has(label)走el:索引,快has(key, value)不走索引,是逐顶点过滤- 数据量大时,
.has("age", 30)的代价与「顶点总数 × 属性比较」成正比
4.3 V() 是全量扫描
没有 V(ids) 指定起点时,execute() 会扫 v: 前缀把所有顶点 ID 读出来,再逐个 getVertex:
// 全量扫描
let ids = holt.db.view({ txn =>
// 扫 v: 前缀拿所有 ID
let r = ArrayList<String>()
let opt =
IteratorOptions(
prefix: strb("v:"))
let iter = txn.newIterator(opt: opt)
iter.rewind()
while (iter.valid()) {
... r.add(...) ...
}
r
})
for (id in ids) {
// ↓ N 次单点读
let v = holt.getVertex(id)
if (v.isSome()) {
vertices.add(v.getOrThrow())
}
}
先扫一遍 key 拿全量 ID,再逐个读音——顶点多了会很慢。所以实际使用时,尽量从 V(ids) 或已有顶点出发,而不是从 V() 开始。
五、和「一个完整图数据库」差多远
| 能力 | 一个完整图库(Neo4j / Dgraph) | holt-cj |
|---|---|---|
| 顶点 + 边 + 属性 | ✅ | ✅ |
| 多标签 | ✅ | ✅ |
| 邻接查询(按谓词) | ✅ | ✅(eout:/ein: 索引) |
| Gremlin/Cypher 风格遍历 | ✅ | ⚠️ 基本步骤有,无 where/aggregate/order |
| 属性索引 | ✅ | ❌ 未实现 |
变长路径(*1..3) |
✅ | ❌ |
| 最短路径算法 | ✅ | ❌ |
| 图算法(PageRank 等) | ✅ | ❌ |
| 分布式 | ✅ | ❌(嵌入式单机) |
| 事务 | ✅ | ✅(复用 LSM 事务) |
定位很清楚:它是「能用 Gremlin 风格写遍历的嵌入式小图库」,不是「轻量版 Neo4j」。
六、什么场景合适
✅ 合适
- 数据量中等、关系简单的本地图:几千到几万个顶点,查询主要靠「邻接」而不是「属性过滤」
- 嵌入式场景:CLI 工具、单机应用、离线分析
- 不想为图关系写一堆关联表:用
eout:/ein:表达「谁指向谁」,比 SQL 中间表直观 - 测试替身:写图算法测试时,不用起 Neo4j
- 关系数据的一次性分析:加载、遍历、导出
⚠️ 不合适
- 按属性找顶点(这是最大的限制——
has(key, value)是全量过滤) - 需要路径算法(最短路径、变长路径)
- 顶点数量到十万级以上(
V()全量扫描会拖慢) - 需要持久化图算法结果 / 图计算框架
- 多进程共享图
七、一个实际例子:依赖关系图
用「项目依赖」来演示它合适的样子——这类图的特点是关系比属性重要:
// 每个项目一个顶点
let libs = ["badger-cj", "storm-cj",
"tsdb-cj", "sunku-cj"]
let vs = HashMap<String, Vertex>()
for (name in libs) {
let v = g.createVertex(
ArrayList<String>(["Library"]),
HashMap<String, JsonValue>([
("name",
JsonValue.String(name))
])
)
vs.add(name, v)
}
// 依赖边:storm 依赖 badger
g.createEdge("depends",
vs["storm-cj"].id,
vs["badger-cj"].id,
HashMap<String, JsonValue>())
// 问:storm-cj 直接依赖谁?
// 走 eout: 索引,快
let direct = g.getOutgoingEdges(
vs["storm-cj"].id, "depends")
for (e in direct) {
let target = g.getVertex(e.inVid)
println(target.getOrThrow().props)
}
// 问:谁依赖 badger-cj?
// 走 ein: 索引,快
let users = g.getIncomingEdges(
vs["badger-cj"].id, "depends")
// 遍历:从 storm-cj 出发,
// 找它依赖的依赖
let deps = g.V([vs["storm-cj"].id])
.out(["depends"])
.out(["depends"])
.toList()
这段代码里所有查询都走索引——因为它们都是邻接查询。这正是这个库擅长的。
反过来,如果你要问「哪些库的版本号是 1.6.20」,那就是属性过滤,会退化成全量扫描。
八、小结
- 720 行(4 个源文件),实现小而完整
- 真的做了的:顶点/边/多标签/JsonValue 属性、三种邻接索引(
eout:/ein:/el:)、Gremlin 风格惰性遍历、事务、备份恢复、级联删除 - 还没做的:属性索引(
createIndex是空操作)、变长路径、图算法、分布式 - 合适的场景:邻接查询为主的嵌入式图、中等数据量、单进程
想对比一下上面提到的 LSM 底座,见从 Go 移植 BadgerDB 到仓颉 ;
想看在它之上的另一个库,见 sunku-cj 。