返回更新列表
新发布Aug 5, 2026

slater v0.24.4

低内存图数据库,支持 Bolt+TLS、静态加密与向量,专为本地副本图(local replica graph)使用场景设计。

分享

Slater

CI Release

当前版本:v0.24.4所有版本.

一句话: Slater 通过标准 Bolt 协议服务无法放进内存的图——数亿个节点、数十亿条边,仅需几百 MB 内存——因此任何 neo4j 驱动都能直接使用;图旁边就是磁盘原生的向量搜索,而且它还接受实时、持久的写入,同时不会放弃上述特性。常驻内存由你选择的缓存预算决定,而不是由图的大小决定。


快捷方式

Slater 存在的原因

图数据库将数据存储为事物(节点)以及它们之间的关系(边),并把关系视为一等公民。当你的问题关注的是连接而不是行时,这正是你需要的——例如“谁在距离这个账户三跳以内?”、“这个构建背后的完整依赖链是什么?”、“哪些账户共享设备、地址和银行卡?”——这些查询在 SQL 中会成为递归连接的泥潭,但在图中却能自然得到答案。

关于图数据库最常见的抱怨是,它们无法扩展到超出内存所能容纳的规模。 其中许多(如 neo4j、Memgraph、FalkorDB 等)让整个图常驻内存:一个 40 GB 的图就需要 40 GB 的内存——每个实例。想要每个区域、每个租户或每个 Pod 都有一个副本?账单就得成倍增加。而且超过一定规模,它们根本加载不起来:例如有 9000 万节点 / 15 亿边的 Wikidata 图需要约 64–128 GiB 常驻内存,因此内存引擎根本无法打开它。

Slater 正是对这一问题的反驳。它不是将图加载到内存,而是在离线状态下一次性编译slater-build 将你的数据转换成内容寻址的不可变磁盘镜像,然后任意数量的 Slater 服务器通过 Bolt 协议服务该镜像(因此你现有的 neo4j 驱动可以直接使用),按需分页加载块,并只保持一个固定的缓存预算常驻。正是这样,同一个 9000 万节点图只需几百 MB 内存即可服务——图的大小和内存开销被解耦了。一个 4 GB 的图和一个 400 GB 的图提供服务的 RAM 成本相同,因此你可以轻松扩展廉价的无状态读副本,让存储而不是堆来持有图。

这使它天然适合作为 RAG 背后的知识图谱、推荐图和身份图、依赖图——任何大型且互联、你想廉价而频繁查询的东西。磁盘原生的向量搜索就位于图旁边,因此同一个引擎也是嵌入向量的检索层。

不过,一次性编译并不意味着冻结。该镜像是一个基础,而不是最终状态:在它之上有一个可选启用的写入层,因此可以纠正和扩展在线图,而无需重新构建任何东西。

读取与写入

核心是不可变的;图不是。启用可写层(delta.enabled)后,你就可以通过 Bolt 写入——纠正一个属性、添加一个节点、撤销一条边——更改会持久落盘,且无需重建镜像。让读取侧保持廉价的原因在于写入存放在哪里

写入累积在位于不可变核心之上的日志结构合并(LSM)层中:一个预写日志和一张内存表,溢出到不可变的增量段,再由定期的合并折叠回新的核心。这给你带来:

  • 对未写入图的读取成本与之前完全相同。 空增量是一个可预测的单一分支,而不是合并——无论是否启用可写层,读取路径都逐字节相同。
  • 写入的读取成本随增量的大小扩展,而不是图的大小。 全图统计——count(*)、标签和关系类型的边际统计——即使在有待处理写入时也仍然是元数据读取:增量维护自己的计数器,因此,对一个 9160 万节点的核心执行 count(*),即使有 50 万条待处理写入,也只需几十毫秒即可返回,且不触及任何块。
  • 已确认即持久。 单个写入者排空队列,并且只在覆盖该写入的 fsync 之后才返回 SUCCESS。将写入分组后它们就很廉价——一次 write-UNWIND 每个批次只提交一次 fsync,而不是每行一次。
  • 两种方言都支持业务键写入。 MERGE / MATCH … SET / DELETE(以及 CREATE / REMOVE、detach delete、关系写入)以节点的身份属性为键——或者使用等效的 ISO GQL 数据修改语句(INSERT / SET / REMOVE / DELETE),它们会归约到同一条路径上。对节点和边进行纠正、插入、upsert 和撤销,并按照你数据已有的方式寻址。

当该层关闭(默认状态)时,Slater 只服务纯不可变核心,并拒绝写入。完整模型见可写层

关于名字。 Slater 以 Archer(一部很棒的剧)中的 CIA 特工命名, 他坚持只用一个名字——“Just… Slater”——也是我在剧中 最喜欢的角色之一。请参阅 角色维基页面

你能获得什么

  • 内存由你的缓存预算决定,而不是图的大小 —— 你可以随心所欲地扩展任意数量的读副本;图永远不需要放进内存。
  • 可直接替代现有图 —— 使用 Bolt 协议,因此任何标准 neo4j 驱动(JS、Python、Go…)都无需修改即可工作。它是 Cypher(外加一部分 ISO GQL,支持读写);无需学习新东西。
  • 实时、持久的写入 —— 不可变核心之上可选启用的 LSM 层:对节点和边进行业务键 MERGE / SET / DELETE,组提交并 fsync 持久化,由合并折叠回新核心。读取不会为此付出代价。
  • 通过文件交换部署 —— 离线构建新的内容哈希generation,原子地翻转 current 指针,服务器就会拾取它。每个块都有校验和,因此半复制的镜像会被拒绝,而不是被服务。
  • 内置向量搜索 —— 磁盘原生的近似最近邻(cosine、L2 或 dot KNN)就位于你的图旁边,适用于该引擎作为 RAG 管道背后的检索层;并且嵌入向量可以就地写入——添加或修改向量无需离线重建。
  • 设计上即锁定 —— 读和写授权相互独立,外加可选的静态加密、TLS Bolt、argon2id 哈希 ACL,以及用于读副本的只读容器根文件系统。配置主密钥后,磁盘上的镜像既被认证又被加密——其清单带有一个密钥化 MAC,因此对数据目录有写权限但没有密钥的攻击者无法伪造服务器会接受的清单。没有密钥,你仍然能得到内容哈希,它能发现半复制的或损坏的镜像——但发现不了故意篡改的。哪种配置能获得什么

功能特性

功能对你意味着什么
有界、可预测的内存常驻内存跟随设定的三个缓存预算,且每项与分配器开销有界——它不会随图的大小增长;你调整性能/内存权衡,而不是为整个图做资源规划。带后台清理的 jemalloc 分配器在大量查询突发后将释放的内存归还给操作系统,因此常驻大小会回落到空闲基线,而不是停留在突发后的高水位。
开箱即用的多租户一台服务器托管多个图,并提供按用户的读取授权——这是大多数图数据库保留给付费/企业版的多数据库隔离。
静态与传输加密逐块 XChaCha20-Poly1305 封装(密钥从不写入磁盘)外加可选 TLS(bolt+s://)。天然符合 GDPR。加密还能带来可认证的完整性:构建器用带密钥的 MAC 封装清单,持有密钥的服务器会验证它,并拒绝服务清单被伪造、篡改或 MAC 被剥离的 generation。无密钥(明文)镜像仅由无密钥的内容哈希保护——只能防不完整和损坏,不能防篡改。参见每种配置下完整性的含义
极小的安装包基于 distroless glibc(无 shell/apt)的小型 stripped 二进制——多架构(amd64/arm64)镜像拉取约 22 MB,仅服务器的 slater:latest-lite 标签则约 12 MB;纯 Rust TLS,无 OpenSSL。拉取即运行。
为定期发布而构建离线构建图,以不可变方式服务,然后零停机原子地切换新版本——非常适合数据仓库/定时刷新工作负载。
负载下依然坚固服务器和离线构建器都使用 #![forbid(unsafe_code)] 编译——引擎中唯一的 unsafe 位于经过审计的 jemalloc 分配器 crate 中。核心不可变,因此读取不拿锁,也永远不会等待写入者;单个写入者只在写入路径后面串行化变更。没有 GC 暂停,没有数据竞争。一个坏查询无法拖垮服务器。
兼容你的 neo4j 工具支持 Bolt 5.4 / 4.4 / 4.1 协议——使用标准 neo4j 驱动(JS、Python、Go、Java…)、cypher-shell 或图浏览器,无需修改。
丰富的 Cypher 查询面广泛的读取面:MATCH/WHERE/WITH/UNIONCALL {…} 子查询、70+ 函数和聚合、时间与地理空间值,以及正则表达式。
实时、持久的写入不可变核心之上可选启用的单写入者 LSM 层(delta.enabled):对节点和关系进行业务键 MERGE / SET / DELETE / CREATE / REMOVE,批量 write-UNWIND(每个批次一次 fsync),以及 CALL slater.consolidate()——组提交、fsync 持久化,并由合并折叠回新核心。当增量为空时,读取路径逐字节相同。
ISO GQL,读写兼备在同一个 Bolt 连接上支持 ISO GQL(ISO/IEC 39075)的一个子集——量化路径、路径限制器、最短路径选择器、标签/类型布尔表达式、FORCAST、可选的 GQL/CYPHER 方言前缀——并且,启用可写层后,GQL 的数据修改语句(INSERT / SET / REMOVE / [DETACH] DELETE)会归约到同一条持久化写入路径上。Cypher 和 GQL,读取和写入,都在一个引擎中。
向量 + 图,一个引擎面向 embeddings/RAG 的磁盘原生 ANN 向量搜索(Vamana + PQ;cosine / L2 / dot),外加图算法(PageRank、BFS、介数、WCC…)——即便有数百万个向量,内存依然有界。嵌入向量是可写的(FreshDiskANN 风格的写入阶梯):插入/更新/删除向量,立即对 KNN 可见,并无需重建即可折叠到基础中。
网络存储安全每个文件都用 BLAKE3 内容哈希并在打开时验证;撕裂或半复制的镜像会被拒绝,而不是被服务。专为 NFS/远程卷设计(没有 mmap 意外)。
可插拔存储后端从本地文件系统、S3(兼容 S3)存储桶 Google Cloud Storage 存储桶提供相同的 generation 格式——发布一次,分发到无状态副本——并可在对象存储前提供可选的本地 SSD 缓存层。参见存储后端

两个二进制文件组成整个工作区:

二进制文件作用
slater在线 Bolt 服务器(容器 ENTRYPOINT):服务读取,并在启用 delta.enabled 时提供单写入者持久化写入路径。
slater-build离线编译器:将基础 Cypher 转储转换为不可变、内容哈希的 generation 目录。

Slater 将批量构建服务分离:slater-build 在离线状态下完成繁重工作——摄取你的数据并将其编译为不可变的 generation——因此冷图绝不会在服务热路径上组装。在服务器内部,读取面回答一个广泛的 Cypher 子集——模式匹配、WITH/UNION/CALL {…} 子查询、70+ 标量与聚合函数、时间与地理空间值、图算法(algo.*),以及磁盘原生向量 KNN(db.idx.vector.queryNodes)——而可写层的增量覆盖位于该表面之下,为空时零成本,因此读取永远不会携带写入侧的机制。你可以通过两种方式更新图:通过 Bolt 实时写入(见可写层),或离线构建新的 generation 并原子地切换 current 指针,运行中的服务器通过其 generation 守卫(见Generation 守卫)拾取它。

文档

完整的用户手册位于 docs/manual/ —— 这是逐功能指南,解释每项能力是什么、为什么存在以及如何使用,并配有可对内置示例图运行的实战示例。任何超出本概述的内容,都请从那里开始。

使用 Docker 运行

Slater 设计为以 Docker 部署方式运行——这是它的预期使用方式。预构建的多架构镜像(linux/amd64 + linux/arm64)发布到 Docker Hubhikarisystems/slater, 每次发布都会打上 :latest:vX.Y.Z 标签:```sh docker pull hikarisystems/slater:latest

Docker 命令专用用法、配置和操作指南位于
[`DOCKERHUB.md`](https://github.com/hikari-systems/slater/blob/HEAD/DOCKERHUB.md) 中(并镜像到 Docker Hub 概览页面)——
**如果你正在进行部署,请从那里开始。** 简而言之:```sh
# Build a graph generation with the offline writer:
docker run --rm -v slater-data:/data -v "$PWD/dumps:/dumps:ro" \
  --entrypoint /app/slater-build hikarisystems/slater:latest \
  --input /dumps/people.cypher --graph people --data-dir /data

# Serve it over Bolt on 7687 (read-only unless `delta.enabled`):
docker run -d --name slater -p 7687:7687 \
  -v slater-data:/data:ro -v "$PWD/acl.json:/config/acl.json:ro" \
  hikarisystems/slater:latest

若要改为在本地构建镜像(例如用于开发):```sh

Build the image (both binaries).

docker compose build

Serve (expects generations under the slater-data volume / your /data mount).

docker compose up slater

Build a generation with the offline writer (profile build):

docker compose run --rm builder
--input /dumps/people.cypher --graph people --data-dir /data

构建阶段为 rustls 的 `aws-lc-rs` 后端安装 `cmake`、`clang` 和 `libclang-dev`;`git`(基础镜像中已有)是 `hs-utils` git+tag 依赖所必需的,`.cargo/config.toml` 通过 git CLI 获取该依赖。

以下各节介绍磁盘格式、配置、ACL 以及一个本地(非 Docker)的实际示例。

## 工作原理```
            slater-build                         slater (Bolt server)
   dump.cypher ──────────▶ /data/<graph>/<uuid>/ ──────────▶ neo4j driver
   (offline, atomic)        MANIFEST.json, *.blk,            (bolt / bolt+s)
                            range/*.isam, vector/*.{vamana,pq},
                            current → <uuid>
  • 是一个不可变目录:包含 MANIFEST.json(符号表、 索引描述符、可选加密头)、列式块文件 (node_props.blknode_labels.blkedge_props.blktopology.csr.blkvectors.f32.blk)、范围索引(range/<name>.isam)、超阈值 ANN 索引(vector/<label>.<prop>.{vamana,pq})以及一个 current 文本指针。
  • 每个块都经 zstd 压缩并带有 BLAKE3 校验和;使用 --encrypt 时,每个 块还额外以 XChaCha20-Poly1305 封装(静态 AEAD)。
  • 服务器通过对每个文件重新哈希并与清单比对来打开一代, 因此半复制/截断的镜像——即复制到数据目录(可能是远程/网络存储)时产生的 残缺副本——会被拒绝而不会被提供。
  • 读取流经三个有界缓存池——解压块 LRU、向量索引池(常驻 PQ 编码 + Vamana 块 LRU)和结果 LRU —— 每个池都有各自的字节预算。每个池都会计量自身持有内容,并通过逐出 保持在预算之下,因此 RSS 跟踪预算时仅在有界的逐项与分配器开销 范围内波动, 而不会随图增长。

可写层

启用 delta.enabled 后,不可变代成为小型日志结构合并树的完全压缩的底层 (“核心”),实时写入则叠加在 其上:``` write (Bolt) read (Bolt) │ │ ▼ ▼ ┌──────────────┐ flush ┌──────────────┐ ┌──────────────────────┐ │ WAL + active │ ───────▶ │ L0 delta │ │ a query pins one │ │ memtable │ │ segments │ │ (core, delta) view │ └──────────────┘ └──────┬───────┘ │ and reads the merge │ (fsync = ack) │ └──────────────────────┘ consolidation │ (folds core + delta → fresh core) ▼ ┌─────────────┐ │ new core │ (atomic current swap) └─────────────┘

* **持久性底线——WAL。** 每次变更都会在每图对应的单个写入器之后串行化,追加到每图的预写日志中,并在返回 Bolt `SUCCESS` 之前执行 `fsync`——因此 *已确认 ⇒ 已持久*,撕裂的日志尾部会在重放时被丢弃。批量写入 `UNWIND` 会追加其行,并为整个批次提交**一次** `fsync`。WAL **仅位于本地磁盘**(不会经过存储后端),这使 *写入* 节点成为有状态节点:它需要在 `delta.walDir` 处有一个持久的本地卷。只读副本保持无状态。
* **Memtable → L0 → 合并。** 写入累积在内存中的 memtable 中(受 `delta.memtableBytes` 限制);当它填满时,会刷写为不可变的 L0 delta 段。**合并**通过将合并后的视图重新经 `slater-build` 序列化并原子地交换 `current`,将 `{core + delta}` 折叠为一个全新的 core——与任何已发布代次相同的内容哈希保护。可以通过 `CALL slater.consolidate()` 手动触发,在达到 core 大小的 `delta.deltaCorePercent` 时自动触发(可选地限制在非高峰的 `delta.consolidateWindow` 内),或让 `delta.deltaHardBytes` 节流作为失控增长的兜底。
* **覆盖层位于读取表面之下。** 执行器通过 `ReadView` 读取,它要么是裸 core(delta 始终为空),要么是合并后的 `(core, delta)` 视图;引擎对其单态化,因此空的 delta 会编译成单个可预测的分支,只读路径字节级一致。全图计数器(`count(*)`、标签/关系类型边际计数)由 delta 自身的实时计数器提供,因此即使有写入挂起,它们仍是元数据读取。
* **查询看到的是稳定快照。** 它在整个生命周期内固定一个 `(core, delta)` 元组。没有多语句事务,也没有回滚——写入是持久的、以业务键寻址的修正,而不是 OLTP 事务。

确切的写入语法和旋钮见下方的 [配置](#environment--configuration) 表(`delta.*`)和 [工作示例](#worked-example)。

### 范围索引(ISAM)

范围索引(`range/<name>.isam`,每个被索引的 `(label, property)` 一个)使 `MATCH (n:Label {prop: v})` 或 `WHERE n.prop <op> v` 能够解析到匹配的节点 ID,**而无需扫描整个标签**。它是一种 **[ISAM](https://en.wikipedia.org/wiki/ISAM)**(索引顺序访问方法,Indexed Sequential Access Method)结构——经典的 *静态、有序、块结构* 索引,恰好适合不可变代次的形态:没有需要重新平衡的插入,因此 ISAM 的简洁性带来的好处,恰恰是 B-tree 的变更机制只会复杂化的东西。

* 条目 `(value, entity_id)` 按值排序,并与其他所有内容一样打包到 zstd 压缩的 256 KiB 块中。
* 一个小型**常驻顶层**保存每个块的第一个键(稀疏索引)。查找时,在该内存顶层中二分搜索,找到键可能所在的*那一个*块,读取并解压该块,然后扫描它——因此等值查找是**一次块读取**,范围扫描则遍历其跨越的连续块区间。(这就是为什么索引了 `meshUi` 的查找只需个位数毫秒,而对未索引属性做相同匹配却要扫描整个标签。)
* 规划器通过 `NodeScan::RangeEq` / `RangeRange` 选择它;未索引的谓词回退到标签扫描或全扫描,无论哪种方式,执行器都会重新检查每个谓词。

### 向量搜索(Vamana + PQ)——cosine、L2 和 dot,读*与*写

向量 KNN(`db.idx.vector.queryNodes`)运行在 **cosine、L2 或点积(MIPS)** 索引上。基础索引离线构建,有两条执行路径,由 `--ann-threshold`(默认 50 000 个向量)按索引选择:

* **低于阈值——暴力搜索。** 完整的 `f32` 向量存放在 `vectors.f32.blk` 中;查询扫描该索引的组,并以该索引的度量计算精确距离。简单且精确;在向量集较小时很合适。
* **达到或超过阈值——Vamana + PQ**,即磁盘原生的 ANN 路径,无论向量有多少,都能将常驻内存限制在有界范围内:
  * **[Vamana](https://arxiv.org/pdf/2401.11324)** 是 DiskANN 系列工作中的图索引:一个单一近邻图,其边经过剪枝(`--vamana-r` 出度和 `--vamana-alpha` 长边因子),因此 *贪心束搜索*——从 medoid 开始,反复向查询方向跳跃,维护一个宽度为 `vectorQuery.beamWidth` 的候选列表——能在少数几步内到达节点的真实近邻,即每次查询只需**少量随机块读取**。图块(`vector/<label>.<prop>.vamana`)通过向量缓存按页调入,而不是整体常驻。
  * **[乘积量化(PQ)](https://medium.com/aiguys/product-quantization-k-nn-for-big-datasets-12431d764c4e)** 将每个向量压缩为短编码(`--pq-subspaces` × `--pq-bits`):维度被划分为多个子空间,每个子空间独立进行 k-means 聚类,向量则存储为最近质心 ID 的元组。这些编码(`vector/<label>.<prop>.pq`)足够小,可以**常驻**内存,因此束搜索从 RAM 中对候选进行评分,只有少数被选中的完整向量会从磁盘读取。正是这些常驻 PQ 集由 `cache.vectorCacheBytes` 内存池固定。

**可写嵌入——向量写入阶梯([FreshDiskANN](https://arxiv.org/abs/2105.09613) 风格)。**
索引后的嵌入是一等可写值。`SET n.embedding = vecf32([…])`(以及 `REMOVE`)会落入写入 delta 中,并且**立即可被 KNN 以精确排名看到**,然后存续经过段刷新、合并和合并。查询最多合并三层——密封的基础索引、密封的逐段索引,以及内存中的 **RW 索引**(基于写入 delta 的实时可变 Vamana)——因此延迟在写入累积时保持平稳,而不是随着待处理写入数量而增长。删除会留下一个*空洞*:该节点不再被返回,但会作为导航途经点保留,直到后台的**删除合并(delete-consolidation)**将其从图中拼接移除,因此删除不再消耗查询 IO。由于磁盘上的图通过布局位置而非节点 ID 寻址其邻居,`CALL slater.consolidate()` 会**按引用**携带 Vamana——硬链接、字节一致——并且只重写一个小的 id 列,从而将向量写入折叠进基础,**无需** O(N·R·L) 图重建。实测数字(附注意点)见[性能报告](https://github.com/hikari-systems/slater/blob/HEAD/docs/PERF-REPORT.md)。

## 存储后端(文件系统 / S3 / GCS)

每个代次文件都通过 **`ObjectStore`** 抽象打开,而不是直接使用 `std::fs`,因此*相同*的磁盘字节格式——块、索引、manifest、`current` 指针——在任何后端上都不变地提供;只有*字节来源*不同,读取器、查询引擎或完整性检查从未不同。热路径是定位读取(`read_exact_at`),它映射到本地文件上的 `pread` 和对象存储上的 HTTP 字节范围请求——Slater 从不使用 mmap,因此显式、有界的读取模型在任何地方都是相同的。

**三个一等后端**,由 `dataBackend.kind` 选择。文件系统是简单的默认项;**Amazon S3 和 Google Cloud Storage 是地位等同、完全受支持的对象存储后端**——发布镜像内置了两者,因此每种都只需配置,一次构建出的代次可以从其中任何一个提供(甚至可迁移 `fs` → S3 → GCS)而无需重建。

| `dataBackend.kind` | 定位读取 | 打开时完整性 | 凭据 |
| --- | --- | --- | --- |
| `fs` *(默认)* | `pread` | 每个文件的完整 BLAKE3 重新哈希 | — |
| `s3` | HTTP `Range` GET | 服务器通过 `HEAD` 提供的 **SHA-256**(若缺失则 → BLAKE3 正文重新哈希) | 配置密钥、AWS 链或 IAM 角色 |
| `gcs` | HTTP 范围读取 | 服务器通过 `get_object` 提供的 **CRC32C**(若缺失则 → BLAKE3 正文重新哈希) | ADC / Workload Identity,或服务账号 JSON |

两个对象存储都根据**存储本身已计算并保留的校验和**来验证完整性,该校验和作为对象元数据获取:`slater-build` 在上传时发送校验和(存储会针对该校验和验证字节并将其保存),服务器在打开时读回并与 manifest 比较——每个文件一次元数据请求,无需下载正文。它是内容级的,并且 S3(SHA-256)和 GCS(CRC32C)在精神上一致。当对象**没有**服务端存储的校验和(通过带外复制,或以不同默认值上传)时,服务器会**将对象正文与 manifest BLAKE3 重新哈希**,而不是信任其字节长度——请求的完整性检查绝不会被静默降级为大小比较。Slater 发布的代次始终携带校验和,因此它们保持在廉价的元数据路径上。

在每种后端上,这一列检查的都是文件**与 manifest 匹配**。manifest 本身是否可被信任是另一个问题,而主密钥正是回答它的关键:配置了密钥后,manifest 会携带一个带密钥的 MAC,服务器在信任任何字段(包括这些哈希)之前都会先验证它,因此被重写以描述篡改文件的 manifest 会被拒绝;没有密钥时,比较全程无密钥,能够写入数据目录的人可以同时重写文件和 manifest。参见[每种配置中完整性的含义](https://github.com/hikari-systems/slater/blob/HEAD/THREAT_MODEL.md#what-integrity-means-in-each-configuration)。检查本身可以通过 `dataBackend.verifyIntegrity: false` 关闭,以换取更快的打开速度。

### 文件系统(`fs`)

默认后端,根目录为 `dataBackend.fs.dir`。对大多数部署来说是正确选择:本地 SSD(或 NFS/EBS 挂载)上的代次以只读方式提供。完整性是在打开时对每个文件进行完整的 BLAKE3 重新哈希。

### Amazon S3(`s3`)

一个 S3 或兼容 S3 的存储桶(AWS、MinIO、localstack)。凭据**首先**来自配置(`dataBackend.s3.awsAccessKey` / `awsSecretKey`,以及用于临时 STS 凭据的 `awsSessionToken`),留空时回退到标准 AWS 链(`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` 环境变量、共享配置文件,或实例/IRSA 角色)。```sh
# serve from S3 (env-var form; see the config table for every key)
dataBackend__kind=s3
dataBackend__s3__bucket=slater
dataBackend__s3__region=eu-west-2
dataBackend__s3__awsAccessKey=…        # omit to use the AWS chain / instance role
dataBackend__s3__awsSecretKey=…
# S3-compatible (e.g. MinIO): also set
dataBackend__s3__endpoint=http://minio:9000
dataBackend__s3__pathStyle=true        # required by most S3-compatible servers
# publish a generation into the bucket (remote `current` pointer written last)
slater-build --input people.cypher --graph people --data-dir /data \
  --publish-s3-bucket slater --publish-s3-region eu-west-2 --publish-s3-prefix prod
#   MinIO: add  --publish-s3-endpoint http://localhost:9000 --publish-s3-path-style

Google Cloud Storage (gcs)

一个通过 JSON API 访问的 GCS 存储桶。授权采用 GCP 原生方式:默认情况下,它会解析 Application Default Credentials(应用默认凭据)——即 GKE Workload Identity、GCE 元数据服务器,或 gcloud / GOOGLE_APPLICATION_CREDENTIALS 密钥。可设置 dataBackend.gcs.credentialsPath(服务账号 JSON 密钥文件)或内联 credentialsJson 以使用显式密钥。dataBackend.gcs.endpoint 指向 fake-gcs-server 模拟器,而 dataBackend.gcs.anonymous=true 则启用未认证访问——仅限该模拟器——切勿针对真实 GCS 使用。```sh

serve from GCS (env-var form; see the config table for every key)

dataBackend__kind=gcs dataBackend__gcs__bucket=slater dataBackend__gcs__prefix=prod dataBackend__gcs__credentialsPath=/secrets/sa.json # omit for ADC / Workload Identity

```sh
# publish a generation into the bucket (remote `current` pointer written last)
slater-build --input people.cypher --graph people --data-dir /data \
  --publish-gcs-bucket slater --publish-gcs-prefix prod
#   explicit key: add  --publish-gcs-credentials /secrets/sa.json

In all cases slater-build writes the finished generation to --data-dir first (its local staging area) and additionally uploads it to the bucket; the remote current pointer is written last, so a serving node never sees a half-published generation.

在所有情况下,slater-build 都会先将完成的版本写入 --data-dir(其本地暂存区域),并且额外将其上传到存储桶;远程的 current 指针最后写入,因此服务节点永远不会看到发布了一半的版本。

When to use an object store (S3 or GCS)

Reach for s3 or gcs when you want generations in durable, central object storage rather than on a node's disk — typically: publish once and fan out to many stateless, disk-less server replicas that all read the same bucket; decouple the build host from the serve hosts; or lean on the store's durability/versioning/lifecycle instead of managing volumes. The trade-off is latency: a cold block is a network round-trip (~10–50 ms) instead of a local read (~0.1 ms). Slater hides most of it with the in-memory block cache, concurrent read-ahead, and the optional disk cache below. If your generations already sit on fast local storage and you don't need the central-bucket model, fs is simpler and faster.

何时使用对象存储(S3 或 GCS)

当你希望将版本存放在持久、集中的对象存储中,而不是节点的磁盘上时,请使用 s3gcs——典型场景是:只发布一次,然后扇出到多个无状态、无磁盘的服务器副本,它们都读取同一个存储桶;将构建主机与服务主机解耦;或者依赖存储的持久性/版本管理/生命周期,而不是管理卷。权衡在于延迟:冷块是一次网络往返(约 10–50 ms),而不是本地读取(约 0.1 ms)。Slater 通过内存块缓存、并发预读以及下面的可选磁盘缓存隐藏了大部分延迟。如果你的版本已经位于快速的本地存储上,并且不需要集中存储桶模型,那么 fs 更简单且更快。

Local-disk block cache (object-store second tier)

The in-memory BlockCache is deliberately small (bounded RSS is the headline guarantee), so on a working set larger than RAM the same blocks would be re-fetched from the object store on every spill. An optional local-SSD second cache tier fixes that: a block evicted from RAM is served from local disk (~0.1 ms) instead of a fresh object GET, surviving in-memory eviction and cutting object-store request count/cost — bringing an object-store-backed node close to local-filesystem performance once warm. It is opt-in for both s3 and gcs, enabled by setting dataBackend.<s3|gcs>.diskCacheBytes > 0 and a writable diskCacheDir.

本地磁盘块缓存(对象存储第二层)

内存中的 BlockCache 有意设计得很小(有界 RSS 是首要保证),因此当工作集大于 RAM 时,同一批块会在每次溢出时从对象存储重新获取。可选的本地 SSD 第二缓存层解决了这个问题:从 RAM 驱逐的块从本地磁盘提供(约 0.1 ms),而不是重新发起对象 GET,从而在内存驱逐后仍然存活,并减少对象存储的请求数量/成本——使对象存储支持的后端在预热后接近本地文件系统的性能。对于 s3gcs,它都是可选启用的,通过设置 dataBackend.<s3|gcs>.diskCacheBytes > 0 和可写的 diskCacheDir 来开启。

  • It caches the sealed bytes exactly as fetched — already compressed, and (for --encrypt generations) still AEAD-sealed — below decrypt/decompress. The cache layer never holds the encryption key and never re-encrypts, so at-rest status is preserved for free: an encrypted generation lands on disk still sealed.

  • Writes are write-behind: a miss returns the fetched bytes to the query immediately, then a background thread does the disk write and LRU trim, so the query path never blocks on disk I/O. Eviction keeps the cache within its byte budget; a per-file checksum verified on every read self-heals a corrupt cache file to a miss (→ refetch from the object store).

  • diskCacheDir must point at a real writable volume — never tmpfs (tmpfs is RAM and would defeat the bounded-RSS guarantee). The in-memory index that tracks it costs a little RAM (~tens of bytes per cached block), which counts against your RSS ceiling — size the directory ≫ the in-memory block cache.

  • The tier's other RAM cost is the write-behind queue, which stages blocks on their way to disk. It is bounded at blockCacheBytes / 8 (floored by diskCacheBytes) — 8 MiB at the default — and sheds rather than grows, so a cold scan cannot inflate it; a shed block simply refetches on its next miss. It needs no configuration: it scales with blockCacheBytes, so the disk tier adds no new number to the RSS budget beyond its index.

  • 它缓存密封后的字节,与获取时完全一致——已压缩,并且(对于使用 --encrypt 的版本)仍为 AEAD 密封——位于解密/解压之下。缓存层从不持有加密密钥,也从不重新加密,因此静态加密状态被免费保留:加密的版本落盘时仍处于密封状态。

  • 写入是写后置:一次未命中会立即将获取到的字节返回给查询,然后由后台线程执行磁盘写入和 LRU 清理,因此查询路径永远不会在磁盘 I/O 上阻塞。驱逐机制将缓存保持在字节预算之内;每次读取时都会验证每个文件的校验和,从而将损坏的缓存文件自愈为未命中(→ 从对象存储重新获取)。

  • diskCacheDir 必须指向真实可写的卷——绝不能是 tmpfs(tmpfs 是内存,会破坏有界 RSS 保证)。跟踪它的内存索引会消耗少量 RAM(每个缓存块约几十字节),这会计入你的 RSS 上限——请将目录大小设置为远大于(≫)内存块缓存。

  • 该层的另一项 RAM 开销是写后队列,它在块写入磁盘的途中暂存这些块。其上界为 blockCacheBytes / 8(受 diskCacheBytes 下限约束)——默认情况下为 8 MiB——并且只丢弃而不会增长,因此冷扫描不会使其膨胀;被丢弃的块只会在下一次未命中时重新获取。它无需配置:它与 blockCacheBytes 一起伸缩,因此磁盘层除其索引外,不会为 RSS 预算增加新的数字。

Mounts

A read replica runs with a read-only root filesystem and a non-root user (appuser:1000) — everything it needs is mounted read-only. A writer (delta.enabled) additionally needs one durable, writable volume for its WAL.

挂载

只读副本只读根文件系统和非 root 用户(appuser:1000)运行——它所需的一切都以只读方式挂载。写入者delta.enabled)还需要一个持久、可写的卷来存放其 WAL。

PathPurposeNotes
/dataThe graph generations (<graph>/<uuid>/… + current).Read-only for replicas; produced by slater-build. May live on remote/network storage (e.g. NFS), so reads are not assumed to be fast local-SSD latencies.
/sandboxPer-environment config overlay + secrets./sandbox/config.json is deep-merged over the baked-in config.json; also holds acl.json, TLS PEM material, the at-rest key file.
/tmp, /runScratch (tmpfs).A read replica never writes to disk by default.
(writer) delta.walDirThe write-ahead log + L0 delta segments, when delta.enabled.Writable, and a durable, real volume — never tmpfs (it is the durability floor). A relative path resolves under the data dir; give a writer its own persistent volume here.
(optional) disk cacheThe local-disk block cache, when dataBackend.s3.diskCacheBytes / dataBackend.gcs.diskCacheBytes > 0.Writable, and a real volume — not tmpfs. Used by the s3 and gcs backends; see Storage backends.
路径用途备注
/data图的版本(<graph>/<uuid>/… + current)。对副本为只读;由 slater-build 生成。可能位于远程/网络存储(如 NFS)上,因此读取不被假定为快速的本地 SSD 延迟。
/sandbox每环境的配置覆盖层 + 机密。/sandbox/config.json 会深度合并到内置的 config.json 之上;还包含 acl.json、TLS PEM 材料、静态加密密钥文件。
/tmp, /run临时空间(tmpfs)。只读副本默认从不写入磁盘。
(写入者) delta.walDirdelta.enabled 时,存放预写日志 + L0 delta 段。可写,且为持久、真实的卷——绝不能是 tmpfs(它是持久性的底线)。相对路径在数据目录下解析;请在此处为写入者提供其自己的持久卷。
(可选) 磁盘缓存dataBackend.s3.diskCacheBytes / dataBackend.gcs.diskCacheBytes > 0 时,本地磁盘块缓存。可写,且为真实卷——而非 tmpfs。由 s3gcs 后端使用;参见 存储后端

Environment / configuration

Config is loaded by the house-standard layered loader: the baked-in config.json, then /sandbox/config.json deep-merged over it, then KEY__sub environment overrides (double underscore for nesting; keys match the camelCase config).

Every configuration knob — its camelCase key, the KEY__sub environment override, its default, and what it does — is tabulated in the Configuration reference. The most-tuned knobs are the cache budgets (cache.*), the query guards (query.*), the connection caps (server.*), the storage backend (dataBackend.*), and the writable layer (delta.*).

Resident memory tracks blockCacheBytes + vectorCacheBytes + resultCacheBytes to within bounded per-entry and allocator overhead — each pool weighs its own contents (strings and containers by allocated capacity) and evicts to stay under budget, but the per-entry bookkeeping and the allocator's size-class rounding sit on top of the number you set — plus a small fixed overhead (and up to degreeColumnBytes for the lazy degree column, once the degree-sum count(endpoint) fast path is exercised). It is independent of graph size — that is the headline guarantee, exercised by the rss_stays_bounded_under_sustained_knn_load integration test, which holds peak-vs-warm RSS growth well inside the summed budgets. Per-connection buffers live outside the cache budgets, so the guarantee holds under adversarial load only because server.maxConnections bounds how many can exist at once.

环境 / 配置

配置由内部标准的分层加载器加载:内置的 config.json,然后在其上深度合并 /sandbox/config.json,然后是 KEY__sub 环境变量覆盖(双下划线表示嵌套;键与 camelCase 配置匹配)。

每个配置旋钮——其 camelCase 键、KEY__sub 环境变量覆盖、默认值及其作用——都列在 配置参考 的表格中。最常调整的旋钮是缓存预算(cache.*)、查询保护(query.*)、连接上限(server.*)、存储后端(dataBackend.*)和可写层(delta.*)。

常驻内存blockCacheBytes + vectorCacheBytes + resultCacheBytes 为基准,超出的部分仅限于有界的逐条目和分配器开销——每个池都会对自己的内容进行称重(字符串和容器按分配的容量计算),并驱逐内容以保持在预算之内,但逐条目簿记和分配器的大小类舍入会叠加在你设置的数值之上——再加上少量固定开销(以及对于 lazy 度数列,最多 degreeColumnBytes,一旦度数和 count(endpoint) 快速路径被执行)。它与图大小无关——这是首要保证,由 rss_stays_bounded_under_sustained_knn_load 集成测试验证,该测试将峰值相对温热态的 RSS 增长严格控制在汇总预算之内。每连接缓冲区位于缓存预算之外,因此该保证在对抗性负载下依然成立,仅仅是因为 server.maxConnections 限制了同时存在的连接数量。

Network posture

Slater is a read replica handle; the primary connection-security control is the network, not the binary. Bind it to a private interface, restrict source ranges at the network layer (security groups / NetworkPolicy), and — if it faces anything but trusted clients — front it with a connection-limiting L4 proxy (HAProxy maxconn + a per-source stick-table, or nftables connlimit + hashlimit). That sits before the file descriptor is ever handed to the process, so it is the most robust limit.

网络态势

Slater 是只读副本的接入点;主要的连接安全控制是网络,而非二进制程序。将其绑定到私有接口,在网络层限制源范围(安全组 / NetworkPolicy),并且——如果它面对的不只是受信任的客户端——在其前面放置一个限制连接的 L4 代理(HAProxy maxconn + 基于来源的 stick-table,或 nftables connlimit + hashlimit)。这位于文件描述符交给进程之前,因此它是最稳健的限制。

The in-binary limits above (maxConnections, maxPreAuthConnections, maxConnectionsPerIp, the differential byte caps, and loginTimeoutMs) are defence-in-depth: they default on and generous so they are invisible to a legitimate client population, but they make the bounded-RSS guarantee hold even when the proxy is forgotten. See docs/HARDENING.md for the full defensive posture, and THREAT_MODEL.md / SECURITY_WORKLIST.md for the canonical detail.

上述二进制内限制(maxConnectionsmaxPreAuthConnectionsmaxConnectionsPerIp、差分字节上限和 loginTimeoutMs)是纵深防御:它们默认启用且宽松,因此对合法客户端群体不可见,但它们使得有界 RSS 保证即使在忘记代理的情况下也成立。完整的防御态势见 docs/HARDENING.md,规范细节见 THREAT_MODEL.md / SECURITY_WORKLIST.md

Generation guard

Slater polls each graph's current pointer every generationPollMs (poll, not inotify — the data dir may be remote/network storage like NFS, where filesystem change events are unreliable). When it changes:

  • reloadStrategy=exit (default): the server logs fatal and exits non-zero so the orchestrator restarts it cleanly against the new generation.
  • reloadStrategy=swap: the server opens and validates the new generation (same content-hash guard as boot), atomically swaps it in, and lets in-flight queries finish on the old one. A corrupt/incomplete new image is refused and the old generation keeps serving.

版本守卫

Slater 每隔 generationPollMs 轮询每个图的 current 指针(轮询,而非 inotify——数据目录可能位于远程/网络存储(如 NFS)上,其中文件系统变更事件不可靠)。当它发生变化时:

  • reloadStrategy=exit(默认):服务器记录致命错误并以非零状态退出,以便编排器针对新版本干净地重启它。
  • reloadStrategy=swap:服务器打开并验证新版本(与启动时相同的内容哈希守卫),原子地换入新版本,并让进行中的查询在旧版本上完成。损坏/不完整的新映像被拒绝,旧版本继续提供服务。

ACL

acl.json maps users to argon2id password hashes and per-graph read / write grants. Mint a hash (never store cleartext) with:

ACL

acl.json 将用户映射到 argon2id 密码哈希以及每个图的 read / write 授权。使用以下方式生成哈希(切勿存储明文):```sh slater hash-password 's3cret' # prints a $argon2id$… string for acl.json

仓库根目录附带一个起始 `acl.json`;其结构如下:```json
{
  "users": {
    "reporting": {
      "passwordArgon2id": "$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>",
      "grants": {
        "people": ["read"],
        "products": ["read", "write"]
      }
    }
  }
}
  • users — 每个登录一个条目,以用户名作为键。

  • passwordArgon2id — 来自 slater hash-password$argon2id$… 字符串 (绝不以明文存储;该文件本身是纯 JSON,位于共享存储上)。

  • grants — 每个图谱的能力列表。有两个权限是有意义的:

    • read — 查询图谱。若某个图谱不在用户的授权中,则对其不可见。
    • write — 通过可写层(delta.enabled)修改图谱: MERGE / SET / DELETE 语句和 CALL slater.consolidate()

    这两者是**相互独立的:read 授权不授予任何写权限。**因此,启用可写层 并不会把你现有的读者提升为写者。写者需要 同时具备两者 — ["read", "write"] — 因为解析要写入的业务键本身是一次读取。 无法识别的权限字符串将被忽略(它们不授予任何权限)。

以只读方式挂载到 aclPath 指定的路径(默认 /config/acl.json)。 服务器在每次 generation 热切换时重新加载该文件,并且静态 ACL 印章会 在每次重新加载时被重新检查(见 requireAclStamp)。

健康检查

slater 二进制文件同时充当自身的存活探针:slater healthcheck [host] [port] 与服务器执行 Bolt 握手(而非 HTTP 请求),并且 若协商出协议版本则退出 0,否则退出 1 — 默认使用 localhost 和配置的 Bolt 端口。这正是容器 HEALTHCHECK 运行的内容,因此编排器看到的是一个真正 Bolt 就绪的服务器,而不仅仅是 一个开放的套接字:```sh slater healthcheck localhost 7687 # exit 0 = healthy docker exec slater /app/slater healthcheck # inside the container

## 一次性查询

对于脚本编写、CI 检查和快速查找,`slater query` 挂载图的当前世代,在进程内运行单个只读 Cypher 查询,将结果打印为 JSON 对象,然后退出——无需服务器,无需 Bolt 连接。它遵循与服务器相同的配置(存储后端、加密密钥、查询预算):```sh
# GRAPH defaults to `defaultGraph`. Without -q, normal datestamped logging
# (config, "opened generation", …) is written to stdout alongside the result.
slater query mygraph 'MATCH (n) RETURN count(n) AS c'

# -q/--quiet ⇒ logging suppressed, so stdout is *only* the compact result JSON
slater query mygraph -q 'MATCH (c:Company) RETURN c.ticker AS t LIMIT 3' | jq
# {"columns":["t"],"rows":[["AUPH"],["KYMR"],["MREO"]]}

节点和关系会展开为其标签/类型和属性。当你想要机器可解析的输出时使用 -q(结果 JSON 是 stdout 上唯一的内容);省略它则以面向操作员的运行方式输出日志。不使用 -q 时,每次运行后会记录仅包含指标的摘要——例如```text INFO query executed cost=2389 resultCount=10 execMs=441 limitRowCount=10

carrying the query `cost` (计费的元素), `resultCount`, `execMs`, 以及
`limitRowCount`(仅当查询指定了 `LIMIT` 时)——绝不包含查询文本
或任何结果值。退出状态为 `0` 表示成功,`1` 表示解析/打开/执行
错误(信息在 stderr 上)。

## 导出图(`slater dump`)

`slater dump` 从**正在运行**的服务器将图导出为业务键 `MERGE`
Cypher——即 `slater-build` 所接收的同一方言——从而使图可以往返
(dump → `slater-build` → 新生成)以用于迁移或文本备份。与
`slater query` 不同,它通过 **Bolt** 连接,进行身份验证,并遵循每个图的
ACL,因此无需访问服务器的磁盘。密码从
`SLATER_DUMP_PASSWORD` 或 stdin 读取(绝不用标志传递,以避免出现在 `ps`/历史记录中)。```sh
# List the graphs the authenticated user may read.
SLATER_DUMP_PASSWORD=pw slater dump --list -u reporting

# Dump a graph to a file (identity keys inferred from range indexes).
SLATER_DUMP_PASSWORD=pw slater dump people -u reporting -o people.cypher

# Rebuild it into a fresh generation.
slater-build --input people.cypher --graph people --data-dir ./data

每个标签的身份键是其范围索引所携带的属性;可通过 --key Label=prop(可重复)或全局 --pk <field> 覆盖。首先发出 CREATE INDEX DDL,以便重建时重新创建索引。多标签节点会保留所有标签——它被生成为 MERGE (n:Ident:Other {key: v}),身份标签(提供业务键的那个)排在首位,其余标签按序排列;该 MERGE 仅以身份标签为键,因此尾随标签会被写入该节点,而不会创建新节点。包含特殊字符的标签、关系类型和属性键在生成时会被反引号引起来,因此不常见的名称能忠实往返,且无法在重建时注入 Cypher。向量(以及其他没有 Cypher 字面量拼写形式的值)不能随 MERGE 转储输出,会被丢弃并在 stderr 上给出警告。退出状态为:成功时 0,出错时 1

完整示例

完整、可运行的演练——构建图、提供服务、使用 neo4j JavaScriptPython 驱动程序连接并写入数据——位于手册的 快速入门写入数据 页面中,使用了 docs/manual/examples/ 中捆绑的示例图。

开发```sh

export PATH="$HOME/.cargo/bin:$PATH" cargo build cargo test # unit + the bounded-RSS headline integration test cargo clippy --all-targets -- -D warnings cargo fmt --all -- --check

### 对象存储后端为可选的 cargo 功能

普通的 `cargo build` 生成的是 **仅文件系统** 的二进制 — `s3` 和 `gcs`
后端由 cargo 功能控制,因此默认构建保持精简(无 AWS
或 Google SDK,也没有异步运行时)。请在 `slater`(serve)和 `slater-build`(publish)
**两者** 上启用你所需的功能:```sh
# S3 only / GCS only / both
cargo build -p slater -p slater-build --features s3
cargo build -p slater -p slater-build --features gcs
cargo build -p slater -p slater-build --features s3,gcs

每个 crate 都暴露了匹配的 s3 / gcs feature,转发到 graph-format/{s3,gcs}。如果在运行时请求某个后端(dataBackend.kind=s3|gcs,或 slater-build --publish-{s3,gcs}-*)但其 feature 未编译进去,会快速失败并给出清晰的"未以……feature 构建"错误。发布的 Docker 镜像启用了两者(Dockerfile 的 CARGO_FEATURES),因此预构建镜像无需额外标志——这只在从源码构建时才需要关注。集成测试同样受 feature 门控:--features s3 --test s3_minio--features gcs --test gcs_emulator(一个 fake-gcs-server)和 --features gcs --test gcs_real(通过 ADC 访问真实 GCS);除非设置了对应的 SLATER_* 环境变量,否则每个测试都会跳过。

设计、里程碑账本和决策日志分别见 docs/PLAN.mddocs/PROGRESS.mddocs/DECISIONS.md

性能

最多六个引擎、一个单客户端测试套件,图规模从 62k 节点的玩具图到 Wikidata 91.6M 节点 / 1.5B 边。每个引擎都隔离测量(其他容器全部停止——RSS 和延迟都是其自身的占用)。下面的延迟表是在 Slater 0.21.0(可写版本)上重新测量的:小/中图(MeSH、EU-AI-Act)为新测,91.6M 图则是全新的同机、共享锚点 slater 对 Neo4j 对照(见该表)。常驻内存数据沿用早先的测量(通过容器 cgroup 测量;读取路径与可写层空闲时逐字节一致)。其他引擎的数字来自既定的跨引擎测试(它们的版本/性能未变化)。所有数字均为中位数(ms)或峰值常驻内存(MiB)。各处均为越低越好;粗体 = 行内最佳。 slater 运行在其本地文件系统(fs)后端上;S3 和 GCS 后端用对象存储往返延迟换取本地读取延迟(由内存缓存和可选本地磁盘缓存层缓解),因此这些数字刻画的是引擎本身,而非网络存储部署。

引擎类别内存边界
slater磁盘支持、分页query.maxIntermediate 自动限制工作集
Neo4j 5磁盘支持、JVM~2 GiB 堆 + 堆外,无论查询如何都会占用
Memgraph · FalkorDB内存型整图驻留 RAM
ArcadeDB内存型、JVM整图驻留;最重
LadybugDB嵌入式、列式手动缓冲池,必须超过查询需求

从磁盘分页的三个引擎——slater、Neo4j 5 和 LadybugDB——能加载全部五个图。内存三剑客(Memgraph · FalkorDB · ArcadeDB)根本无法容纳 1.5B 边的图(需要约 64–128 GiB 常驻内存),ArcadeDB 的导入器也无法完成导入。

常驻内存(MiB)——随图增长约 1,500 倍而有界

每个数字都是已提交的工作内存——即操作系统无法回收的部分。除 slater 外的每个引擎都将其图保存在已提交的匿名内存中(自有的堆、Neo4j 的堆外页缓存或缓冲池),因此其峰值 RSS 就是其已提交占用。唯有 slater 从磁盘存储中可回收的 OS 页缓存提供服务,所以其数字是匿名工作集;存储的页缓存(在压力下可被驱逐——slater 仍能继续服务)被排除在外,并以 total 形式显示在 91.6M 图的括号中。粗体 = 最低。

图(节点 / 边)slaterNeo4j 5MemgraphFalkorDBArcadeDBLadybugDB
pole — 62k / 106k117461141401,556198
MeSH — 341k / 469k631,0833584551,631121
EU-AI-Act — 21k / 45k(+55 MiB 向量)997292293121,948286
Wikidata — 91.6M / 1.5B584 (总计 4,595)~2,900无法加载无法加载无法加载~652 †

slater 在每个规模上都是最低的,在图增长约 1,500 倍时仅增长约 50 倍——其占用跟随查询工作集而非图本身(全程空闲时约 16–71 MiB)。内存三剑客增长约线性,无法加载 1.5B 图;Neo4j 无论查询如何都提交约 2 GiB 堆。(† LadybugDB 仅在受限形状上——其在 1.5B 边上的 hub / 变长 / shortestPath 遍历需要其读池提升到 ≥2 GiB,而 slater 的 maxIntermediate 上限是自动的。)构建时的值→计数直方图增加的常驻内存可忽略不计——低基数索引列仅几 KB,而像 Wikidata 这样的唯一键图则为零wikidata_id 超过直方图基数上限,因此不存储)——所以这些数字不受该 feature 影响。

延迟(中位数 ms)——图可装入 RAM(MeSH,341k / 469k)

形状slaterNeo4j 5MemgraphFalkorDBArcadeDBLadybugDB
count(*) 所有节点0.4115.023.816.482.02.2
标签计数0.424.220.71.14.44.3
索引点查询0.433.90.480.480.658.8
idx-eq 计数0.424.95.02.03812.5
1 跳(索引锚点)1.285.81.214.13904.9
2 跳(无锚点)1.405.68.516.74446.4
group-by / count(DISTINCT)0.4547–5163–6431–394115.3
全扫描 CONTAINS0.435.424.11.716.34.1

slater 包揽了元数据 / 索引 / 扫描类形状(count、label、idx-eq、scan——约 0.4 ms,是服务引擎的 10–200 倍)、索引点查询(0.43 ms,现已逼近内存双雄的 0.48 ms)、无锚点多跳(2 跳 1.40 ms,借助关系类型扫描,全场最快)以及——借助索引分组键上构建时的值→计数直方图——全标签 group-by / count(DISTINCT)(0.45 ms,领先 LadybugDB 列式的 5.3 ms)。内存型服务器只在原始 1 跳上领先(Memgraph 1.21 ms 对 slater 的 1.28 ms)。(pole 62k/106k 情况相同:slater 在 count/scan 上独享最快约 0.4 ms,跳数上约 1.3–2.6 ms。)

延迟(中位数 ms)——向量(EU-AI-Act kNN,15k × 1024 维)

形状slaterNeo4j 5MemgraphFalkorDBLadybugDB
kNN top-10 Concept2.98.61.91.22.8
kNN top-10 Chunk2.45.71.91.53.2

slater 用精确暴力扫描回答 kNN(这些集合低于其 50k 向量的 ANN 阈值),而其他引擎使用近似的常驻 HNSW——因此 slater 的结果是精确的(召回率 1.0)。一个 SIMD 距离内核 + 一个常驻、预归一化的向量矩阵将 Concept 从约 23 ms 降到约 2.9 ms、Chunk 从约 10 ms 降到约 2.4 ms,因此 slater 现在超过 Neo4j 和 LadybugDB,与 Memgraph 的差距在约 1.4 倍以内,仅落后于 FalkorDB——而且是精确的。

向量写入阶梯——无需重建即可插入 / 更新 / 删除

上面的表是跨引擎的比较。向量路径(在静态 Vamana 基础索引之上的 FreshDiskANN 风格写入阶梯)没有跨引擎对照——这里没有其他引擎能做磁盘原生、可写的 ANN——所以下面的数字是单引擎组件基准,基于合成、类嵌入的测试夹具(一个低秩流形,维度 768,范数不等),代码提交于 crates/slater/benches/,并在 docs/PERF-REPORT.md 中完整论述——包括方法论和所有注意事项。召回率始终以活集合上的精确暴力为基准衡量,绝不是一个索引对比另一个索引。此处的规模具有代表性,且仅在指标与规模线性相关的地方进行外推。

属性测量结果为什么重要
KNN 延迟 vs 待处理写入RW 索引 约 1.5–2 ms,保持平稳直至 5 万待处理;基于预建索引的暴力叠加层 1.9 → 115 ms(随增量线性增长)——在 5 万时 61×查询延迟不会因写入在两次合并之间堆积而劣化
嵌入插入每向量 约 1.5–2 ms 进入活索引写入对 KNN 立即可见;增量重建预算 ≈ 2 ms × 增量上限
等召回率下的删除 IO删除 67 % 时每次查询的节点读取 减少 2.9×,80 % 时 5.2×(召回率 ≥ 0.90)合并后的图不会为已删除向量支付读取税
合并,纯置换O(1)——.vamana 是硬链接且字节一致,仅重写 id 列将向量写入融入基础索引可跳过 O(N·R·L) 重建
整个阶梯的召回率cosine、L2 和 dot 下合并后 ≥ 基础索引写入阶梯在每一级都保持召回率

唯一需要专门性能盒子的数字是慢路径合并重写的吞吐量——当一次合并携带删除或新向量而非纯置换时,它是一个受单线程 zstd 和本地磁盘限制的串行重压缩,因此绝对 MiB/s 是环境相关的(报告展示了形状并解释了环境范围)。

延迟(中位数 ms)——图 ≫ RAM(Wikidata 91.6M / 1.5B)

内存型引擎(Memgraph / FalkorDB / ArcadeDB)根本无法加载此图(约 64–128 GiB 常驻)。只有 slater 和 Neo4j 5 可以。这是一次全新的同机、同日对照,针对共享的固定锚点集——每个查询在两个引擎上都命中相同的节点,因此正面对决是苹果对苹果(一个共同的 wikidata_id 池,取中度锚点;关于其重要性的说明见下文)。slater 以两种 fanout 显示(query.maxFanout 1 = 吞吐默认值,8 = 与冷块读取重叠的延迟旋钮)。粗体 = 行内最佳。

形状slater(fan 1)slater(fan 8)Neo4j 5
count(*) 所有节点0.410.413606
点查询(索引)0.720.496.3
度(1 跳计数)0.430.446.0
1 跳邻居9.84.510.1
2 跳372334.5
3 跳322574
变长 *1..2 distinct985105647

诚实的图景:slater 主导元数据 / 索引形状——count(*) 由元数据服务(0.41 ms 对 Neo4j 的 3.6 s 磁盘扫描,约 8800×),点查询 / 度 / 3 跳快约 2–10 倍——在 1–2 跳上与 Neo4j 持平(fanout 8 在冷读取上领先),但var-length *1..2 distinct 上明显落败(约 1 s 对 Neo4j 的 47 ms):slater 的变长 distinct 扩展在这里明显更慢,这是一个值得专门调查的真实弱点。而这一切都发生在几百 MB RSS 对比 Neo4j 已提交约 2 GiB 堆的情况下。

关于锚点。 这些遍历数字在很大程度上取决于你从哪些节点出发——一个离 Wikidata 巨型 hub("human"、"country")一跳的节点,其 2 跳邻域有数百万之众,因此变长 / 跳数成本会随锚点选择摆动几个数量级。该表的早期版本采样的是每个引擎自己的"扫描前 N 个",既不稳定也不可比;本次对照为两个引擎固定了一个共享的、度受限的锚点集。(本次对照省略了 shortestPath——在两个任意锚点之间,它取决于路径是否存在,方差太高,无法有意义地取中位数。)

多跳 count(*)——内存与结果大小解耦

无上限的多跳 RETURN count(*) 在扩展期间计数,而不是物化匹配的行。在 91.6M 图上使用相同的 hub 锚点,maxIntermediate=20M

91.6M 上的 3 跳 count(*)fanout=1fanout=8
延迟 / 峰值工作集554 ms / 0.66 GiB298 ms / 1.9 GiB

计数只持有 O(1) 行。计费机制不变,因此巨型 hub 计数仍会在计算(邻接读取)上触发 maxIntermediate,上限与之前相同。

每查询并行度(maxFanout

提高 query.maxFanout 可将查询的冷、I/O 受限块读取跨核重叠——它有助于大型冷工作集、磁盘受限的形状,在热形状上则持平。在 1.5B 图上:shortestPath ≤6 918 → 608 ms(1.5×,最大搜索 6,269 → 2,350 ms,2.7×);3 跳计数 547 → 298 msmaxFanout=1 是默认值(面向吞吐);8 是延迟旋钮,以更多瞬时工作线程内存为代价。

slater 的胜处 / 劣势

维度slater领域最佳结论
任意规模的常驻内存11–584 MiB(62k → 91.6M)内存型 1.5–2.7 GiB;无法加载 1.5Bslater
count / 元数据 / 扫描约 0.4 ms服务引擎 5–80 msslater(10–200×)
索引点查询0.43 ms(MeSH)Memgraph · FalkorDB 0.48 msslater(险胜内存双雄)
无锚点多跳(行)1.40 ms(MeSH 2 跳)Neo4j 5.6 msslater(关系类型扫描)
聚合(group-by / DISTINCT)0.45 msLadybugDB 5 ms(列式)slater(构建时直方图)
kNN2.4–2.9 ms(精确)FalkorDB 1.2 ms(HNSW)超过 Neo4j/Ladybug;距 Memgraph 约 1.4×;精确
91.6M 元数据 / 点 / 度 / 3 跳0.4–32 msNeo4j 6–3,600 msslater(2–8800×)
91.6M 1–2 跳4.5–23 ms(fan 8)Neo4j 10–35 ms约持平
91.6M 变长 *1..2 distinct约 1 sNeo4j 47 msNeo4j(slater 的真正弱点)
大规模多跳 count(*)0.3–0.6 GiB内存型引擎会物化行集slater,有界

完整的逐引擎表(pole、MeSH、EU-AI-Act + blockCacheBytes RAM↔延迟旋钮、Wikidata 1M 和 91.6M)见 perf/cross-engine-hs/README.md;全新的纯 slater 对照(两种 fanout、每个数据集)见 perf/PERF_CURRENT_STATUS.md

并发与降级(负载测试)

上面的基准是单客户端的。互补的维度——许多并发客户端下的行为——有自己独立的测试工具,perf/loadtest/:一个基于 Bolt 的 Locust 驱动,外加一个协调器,它逐步增加负载、读取 CALL slater.diagnostics()、找到容量拐点并指出限制因素(完整方法见 docs/LOAD-TESTING.md)。从 Wikidata-1M 图上、256 MiB 缓存的一次运行(单台 16 核机器)得出的要点:

结果测量
可承受 1000 个并发客户端,零失败吞吐量峰值约 2.5k rps;延迟拐点约在 750 个客户端时出现(p99 51 → 750 ms)——核心争用下的排队,而非硬上限(单次运行,WSL2)
块缓存 有界且有效100% 命中率、0 次驱逐、缓存适配工作集时驻留 50 MB
持续负载下 RSS 保持稳定jemalloc 分配器在 100→500 客户端的 wiki_cache_churn 爬坡中把 RSS 保持在约 0.6 GB——缓存受限且稳定,无需任何 MALLOC_* 调优(此前的 MALLOC_ARENA_MAX=2 + trim 阈值已退役);其后台清除也会使突发后的高水位回落,而不是将其钉住
总内存有界服务器级 query.maxIntermediateGlobal + 邻接计费的扩展在 1000 客户端下顶住了 wiki_budget 的 2 跳洪泛而不 OOM(RSS 约 0.6 GB;守卫将约 60% 的 hub 查询作为可重试的预算错误丢弃)

负载测试暴露的两个内存问题现已关闭;全部记录在负载测试文档中。

许可证

根据 Apache License 2.0 版授权。完整文本见 LICENSE,署名见 NOTICE。除非你明确声明,否则按 Apache 2.0 许可证的定义,任何有意提交以纳入本工作的贡献,均应按上述条款授权,不附加任何额外条款或条件。

SPDX-License-Identifier: Apache-2.0

分类