前两篇我们一直在盯它的向量检索。但"三位一体"的另外两条路——文档查询和图遍历——到底能不能真的用起来?我用 1000 多个测试、65 项故障注入、5 分钟长稳和 100 万节点规模跑了一遍,并对图遍历的结论做了 5 种图型的交叉验证。
本文基于 TriviumDB v0.8.2,Windows 11 / x86_64 / Rust stable,全部数据都是本地实跑出来的。
之前对TDB的探索聊的都是向量检索,缺少非向量场景下的稳定性实测
这次我们只测非向量库的稳定性
用法 | 对应能力 | 本文关注点 |
|---|---|---|
文档数据库 | TQL FIND,类 MongoDB 过滤语法 | 过滤语义是否正确、能撑到什么规模 |
图数据库 | TQL MATCH,类 Cypher 模式匹配 | 多跳遍历、可变长路径、环检测 |
事务型嵌入式库 | TQL DML + 事务 WAL | 写入原子性、崩溃后数据是否还在 |
嵌入式基础设施 | WAL / compaction / 只读代际 | 断电、位翻转、扇区撕裂之后能不能活 |
如果你的场景根本没有向量,纯用它当文档库或图库,会有问题吗?
TriviumDB 的 FIND 语法是 MongoDB 风格,支持等值、比较、$in/$nin、$exists/$size/$all/$type、$and/$or 组合、排序分页,还能对属性建二级索引。
通过构造 10k / 100k / 1M 三档节点,每档跑三类典型查询(单条件等值、等值+范围组合、$or 逻辑组合),每类重复采样并统计尾延迟来测试。
规模 | 查询 | avg | p95 | p99 | QPS |
|---|---|---|---|---|---|
10k | FIND {type: "person"} | 0.091 ms | 0.104 ms | 0.125 ms | ~10,988 |
10k | FIND {region: "cn", age: {$gte: 30}} | 0.121 ms | 0.129 ms | 0.140 ms | ~8,280 |
10k | FIND {$or: [{type: "event"}, {age: {$lt: 10}}]} | 0.082 ms | 0.084 ms | 0.114 ms | ~12,213 |
100k | FIND {type: "person"} | 0.275 ms | 0.330 ms | 0.359 ms | ~3,639 |
100k | FIND {region: "cn", age: {$gte: 30}} | 0.291 ms | 0.278 ms | 0.280 ms | ~3,439 |
100k | FIND {$or: [...]} | 0.252 ms | 0.277 ms | 0.294 ms | ~3,971 |
1M | FIND {type: "person"} | 7.51 ms | 6.05 ms | 6.05 ms | ~133 |
1M | FIND {region: "cn", age: {$gte: 30}} | 7.36 ms | 5.97 ms | 5.97 ms | ~136 |
1M | FIND {$or: [...]} | 5.88 ms | 5.82 ms | 5.82 ms | ~170 |
全部带
LIMIT 100。测试代码在tests/non_vector_stability.rs。
几个观察:
延迟随规模近似线性增长。10k → 100k 涨约 3 倍,100k → 1M 涨约 26 倍。最后这一跳涨幅偏大,说明 1M 规模下全表扫描的成本开始主导。
尾延迟很稳。p99 与 avg 的比值在 10k 档是 1.1–1.4 倍,1M 档反而缩小到 0.8–1.0 倍。没有出现那种"平均 5ms 但偶尔飙到 50ms"的长尾。
8 维向量 + 1M 节点下,QPS 约 130–170。但要意识到这是无索引全表过滤。上文的 create_index() 是另一条路——上游 benches/bench_queries.rs 里专门有一组 "index vs no index" 的对比,10k 规模下建索引后 FIND 有量级差异。
结论:10 万节点以内当文档库用,单查询亚毫秒,完全够用。百万级也能跑,但如果查询模式固定,记得建索引。
MATCH 走的是类 Cypher 语法,支持节点/边模式、多标签、可变长路径 *1..2、方向 INCOMING/BOTH,还有环检测和步数熔断。
先测试构造 root → mid*M → leaf*L 的星形扇出图,测 1 跳和 2 跳(两条查询都带 {type:"root"} 锚点):
规模 | 查询 | avg | QPS |
|---|---|---|---|
10k | MATCH (a {type:"root"})-[:NEXT]->(b)(1 跳) | 0.745 ms | ~1,342 |
10k | MATCH (a {type:"root"})-[:NEXT]->(b)-[:NEXT]->(c)(2 跳) | 0.428 ms | ~2,338 |
100k | 1 跳 | 14.09 ms | ~71 |
100k | 2 跳 | 9.22 ms | ~108 |
1M | 1 跳 | 141.0 ms | ~7 |
1M | 2 跳 | 140.5 ms | ~7 |
不过上面这张表全部来自同一种图型(星形),不足以支撑普适结论——星形的扇出极度不均,而真实业务的图拓扑千差万别。
所以我补了对照:统一 5 万节点、统一只有 1 个锚点,只改拓扑结构。
图型 | 平均出度 | 锚点扫描 | 1 跳 | 2 跳 | 3 跳 |
|---|---|---|---|---|---|
星形 | 248.75 | 4.27 ms | 6.00 ms | 4.36 ms | 6.30 ms(0 行) |
链式 | 1.00 | 4.32 ms | 4.30 ms | 4.43 ms | 3.78 ms |
随机图 G(n,k=4) | 4.00 | 4.13 ms | 4.47 ms | 4.05 ms | 4.51 ms |
无标度 BA | 4.00 | 4.57 ms | 4.13 ms | 4.64 ms | 4.05 ms |
二维网格 | 1.99 | 4.72 ms | 4.44 ms | 4.22 ms | 4.68 ms |
除星形外,四种拓扑完全不同的图(出度 1 / 4 / 4 / 2),1、2、3 跳的耗时都几乎等于锚点扫描本身的耗时——跳数增加带来的成本被锚点扫描完全盖住了。
星形是明确的边界情况:它的平均出度 248,2 跳就要访问海量边,所以波动明显;而且 3 跳返回 0 行(图上只有两层),却花了 6.30 ms——无结果的深遍历要穷尽整个搜索空间,反而更贵。
这也顺带解释了开头那张表里"2 跳比 1 跳快"的现象:星形图里 1 跳返回的是高扇出的 mid(每个 1000 条出边),2 跳返回的是零出边的 leaf,成本差异来自访问到的边数,不是跳数本身。换到上面其他四种拓扑,这个差异就消失了。
只测 1 跳和 2 跳显然不够——图查询的成本受很多因素影响。所以我又补测了五个维度(随机图 5 万节点、单锚点、LIMIT 100):
维度 | 对比项 | 结果 |
|---|---|---|
锚点数量 | 1 / 10 / 100 / 1000 个 | 1 跳耗时 3.68 → 3.82 ms,几乎不变 |
遍历方向 | 出边 -> vs 入边 <- | 3.55 vs 3.56 ms,无差异 |
遍历深度 | 1 / 2 / 3 / 5 跳,以及可变长 *1..8 | 3.56 → 3.75 ms,只增加 5% |
图中是否有环 | 双向边构成的环形图 | 各跳耗时正常,环路检测没有引发异常 |
边标签 | 指定 [:NEXT] / 通配 [] / 多标签 [:NEXT|OTHER] | 3.58 / 3.58 / 3.64 ms,无差异 |
几个值得单独说的点:
锚点数量从 1 涨到 1000,耗时几乎不变。因为锚点定位是全表扫描一遍,成本跟"扫出多少条"无关,只跟总节点数有关。多出来的锚点只增加展开次数,而 LIMIT 100 把展开成本封顶了。
深度到 5 跳、可变长到 8 跳,也只涨了 5%。 这比前面 1–3 跳的结论更有说服力:访问面从几条边扩到几百条边,成本仍被锚点扫描盖住。
唯一明显影响成本的是结果行数。 锚点 FIND 返回 1 行时 3.70 ms,返回 1000 行时涨到 4.72 ms——多出来的约 1 ms 就是结果物化的成本,跟遍历无关。
顺带一条能力边界:MATCH 目前只支持显式有向模式((a)-[:X]->(b) 或 (a)<-[:X]-(b)),OUTGOING / INCOMING / BOTH 这三个关键字是 SEARCH ... EXPAND 的能力,MATCH 侧在官方文档里标注为「后续扩展」。要查入边,得把箭头反过来写。
MATCH 成本 = 锚点属性全表扫描(主导) + 实际访问到的边数 × 单位成本 + 结果物化。跳数本身不直接计费,但跳数增加通常会扩大访问面。
这就是为什么链式、网格、随机图里看不出跳数差异(每跳只访问个位到几十条边),而星形里 2 跳就开始吃力(要访问几万条边)。
锚点定位在没有索引时是全表扫描——1M 节点就是 100 万次 payload 匹配,这是 7 QPS 的根源。
优化方向也因此非常明确:给锚点属性建索引。上游的 create_index() / find_by_property_index() 就是干这个的,benches/bench_queries.rs 里有专门的"索引 vs 无索引"对比组。如果你的图查询模式固定(比如总是从某类节点出发),建索引的收益是量级级别的。
反过来说,如果需要"从任意节点出发做多跳扩散"、节点量又在百万级,当前版本要谨慎评估 —— 但瓶颈是锚点扫描,不是扩散本身。另外要留意高扇出节点:星形图那 6 ms 的教训说明,扇出大的节点会显著放大遍历成本。
TriviumDB 自带的故障注入测试套件相当完整,用一个统一脚本就能跑完 9 个子套件:
结果:65 passed / 0 failed / 0 ignored,输出 FAULT_INJECTION_SUITE_OK。
子套件 | 测试数 | 模拟的故障 |
|---|---|---|
recovery | 17 | 各种崩溃后的恢复路径 |
io_fault | 13 | 磁盘 IO 错误、文件锁、权限 |
hw_crash | 11 | 进程硬崩溃(非优雅退出) |
hw_intrusion | 7 | 数据被外部篡改/入侵 |
wal_midwrite | 6 | WAL 写到一半断电 |
emi_bitflip | 4 | 电磁干扰导致比特翻转 |
power_cycle | 4 | 逐字节断电模拟 |
oom_intercept | 2 | 内存分配失败 |
sector_tearing | 1 | 扇区撕裂(部分写入) |
这几个套件的设计质量让我有点意外。wal_midwrite 不是简单地在"写之前"或"写之后"中断,而是在 WAL 记录的任意字节位置切断,验证恢复时能否正确识别不完整记录。sector_tearing 模拟的是磁盘只写了一半扇区这种物理层故障。emi_bitflip 直接翻转文件中的比特位,验证 CRC 能否检出。
65 项全通过->在写入过程的任何一个时点断电,重启后数据库要么完整恢复到某个一致状态,要么明确报错——不会出现"看起来打开了,但数据是半新半旧"的静默损坏。
对嵌入式场景来说,这个保证了稳定性
嵌入式库经常要跑在长时间不重启的进程里,内存泄漏和文件膨胀是两个慢性病。
结果:2 passed / 0 failed,耗时 302.91 秒。
测试 | 内容 | 结果 |
|---|---|---|
SOAK_01 | 连续混合写删查 4536 轮 | RSS 4 MB → peak 9 MB → final 9 MB |
SOAK_02 | 500 轮 flush / compact | 文件 41 KB → 61 KB(1.49×) |
判定阈值是 RSS 不超过初始值的 3 倍(12 MB),实际稳定在 9 MB。文件膨胀 1.49 倍,远低于 5 倍阈值。
需要注意口径:这是 8 维向量 + 小规模 payload 的场景,绝对内存占用很小。它证明的是"没有泄漏趋势",不是"大负载下内存占用低"。如果你的真实场景是 768 维向量 + 大 JSON,绝对数值会完全不同,但观察趋势的方法是一样的。
TriviumDB 的内核是 Rust 写的,对外提供三种用法,也就是三套绑定(binding):
用法 | 形式 | 说明 |
|---|---|---|
Rust | triviumdb::Database | 直接调用,没有中间层 |
Python | import triviumdb | 通过 PyO3 调用 Rust |
Node.js | require("triviumdb") | 通过 napi-rs 调用 Rust |
后两种语言要调用 Rust 代码,得先跨过语言边界,这层就是 FFI(Foreign Function Interface,外部函数接口)。每次跨边界都要做参数转换(比如 Python 的 list 转成 Rust 的 Vec)、调用、再把结果转回来(Rust 结构转成 Python dict / JS 对象)。这部分成本是每次调用固定要付的,跟查询本身多重无关,习惯上叫它 FFI 税。
我用同样的 100k / 1M 数据规模和同样的三类查询,在三套接口上都跑了一遍:
接口 | 100k avg | 100k QPS | 1M avg | 1M QPS |
|---|---|---|---|---|
Rust 核心 | 0.252–0.291 ms | 3,439–3,971 | 5.88–7.51 ms | 130–170 |
Python | 0.316–0.343 ms | 2,919–3,164 | 5.90–6.08 ms | 165–170 |
Node.js | 0.529–0.596 ms | 1,679–1,890 | 6.82–7.69 ms | 130–147 |
在 100k 规模,Python 比 Rust 慢约 20%,Node.js 慢约 100%。差距明显,因为查询本身只要 0.25 ms,FFI 开销占比很高。
但到了 1M 规模,查询本身涨到 6–7 ms,三者的差距几乎消失了。
FFI 开销是固定的几微秒到几十微秒,查询越重,它被摊得越薄。如果你的查询都是毫秒级以上的,用 Python 绑定完全没问题;如果是大量微秒级的点查询,绑定层开销就需要考虑了。
把这些数据放在一起,我的判断是:
适合用的场景
一个实用建议:如果你的查询有固定的锚点模式(比如总是从某个类型的节点出发),先建索引。从 MATCH 的数据看,锚点全表扫描才是主要成本,建索引的收益是量级级别的。
文中所有性能数字都是 v0.8.2 的单次采集绝对值,未做跨版本对比。不同机器之间不可横向比较。
测试环境:Windows 11 / x86_64 / Rust stable / 16 逻辑处理器。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。