0. 背景:为什么"数据主权"是硬需求
云笔记把数据放在它家服务器,用的是"使用权"。对一款本地优先 的笔记应用,核心命题是:默认数据就在本机,离线也能完整用,格式开放可迁移。这决定了存储层要优先考虑:
- 可移植:数据能从本机拷走、能导出;
- 可控:不依赖账号/云,服务停了数据也不丢;
- 可离线:全文检索、附件、备份都要在本机闭环。
本文拆解 ShuyoNote 的存储层设计(本地优先笔记),核心是 「权威数据源 + 派生索引」双写 ,以及空间隔离 + 内容寻址附件。
1. 整体存储布局:meta.db + spaces/
数据分两层:
bash
{app-data}/
├── meta.db # 应用级:工作空间清单 / 模板 / 插件状态 / 同步配置
└── spaces/
└── {ws_id}/ # 每个工作空间一个独立的 SQLite 库(物理隔离)
└── .db # 该空间的所有页面/块/附件索引/关系
attachments/ # 全局内容寻址附件(跨空间去重)
为什么每空间独立库(物理隔离):
- 隔离:一个空间的数据损坏/迁移不影响其它空间;
- 导出/导入:单空间能打成自包含 zip(含其附件子集)搬走,不污染其它空间;
- 多空间并存 :应用级状态(工作空间清单、模板、同步配置)放
meta.db,空间级数据互不干扰。
meta.db 管"有哪些空间、模板、插件、同步配置";spaces/{id}/ 管"这个空间里有什么内容"。两者职责清晰。
2. 数据模型:页 = 一份文档,块是一等公民
把"页"抽象为一棵文档树,块是树的节点:
bash
pages(id, parent_id, kind, title, content, ...) # 页/文件夹,parent_id 构成树
blocks(id, page_id, block_id, ...) # 块索引:块 -> 页 反查
backlinks(id, src, dst, kind, ...) # 页面级 + 块级引用关系
- 一页 = 一份文档 :编辑器(Lexical)把页内容序列化存储,块是 Lexical 根级节点,每个顶层块带稳定的
blockId。 - 块可引用/反链 :
((块ID))块引用 →blocks反查;blocks表维护「块→页」,backlinks记录页面+块级引用关系,支撑块级反链、关系图。 - 为什么块独立寻址:块是"可复用"的最小单元(引用、嵌入、反链都要定位到块级),所以给块一个稳定 ID,并建反向索引。
3. 附件:内容寻址(SHA-256)
附件不走"路径-文件名",而是内容寻址:附件按内容哈希存储,相同内容只存一份。
bash
attachments/
ab/ # 桶:取内容 sha256 的前 2 个十六进制字符,避免单目录文件过多
3f2a8e... # 文件名 = 内容的完整 sha256(64 位 hex),内容即地址
- 去重:跨文件夹/空间,同一张图、同一个文件只存一份(内容寻址天然去重,省空间)。
- 引用 :页面/块里存
hash引用,而不是复制字节;引用到页面时用"文件卡片 + 下载"。 - 可靠:内容即地址,篡改会造成 hash 不匹配(可校验),也是同步增量传输的基础(只传变化的部分)。
代价 :需要一层「hash → 附件元数据」的映射(attachments 表:hash、size、mime、引用计数),并处理孤立附件清理(以引用集为准回收未被引用的字节)。
4. 全文检索:SQLite FTS5 + trigram 做中文
本地要在本机就能搜,不联网。用 SQLite FTS5:
sql
CREATE VIRTUAL TABLE post_search USING fts5(title, body, tags, tokenize='trigram');
-- 写入时同步索引;查询时 MATCH + LIKE 兜底
- trigram 分词 :中文按 3-gram 切分,支持子串检索(中文不靠空格分词,trigram 能覆盖"中文子串")。
- 可重建 :FTS 是派生索引,可由正文(权威数据源)重建,坏了/旧了能重新生成。
- 兜底 :短词(不足 3 个字符)FTS 匹配不到,用
LIKE '%xxx%'兜底(中文常见场景)。
关键原则:权威数据源(正文)+ 派生索引(FTS)分离,索引可丢可重建,数据不因索引损坏丢失。
5. 离线 / 多端:WAL + 备份 + 加密
- WAL 模式 :日志先行,写入快、崩溃安全,且读不阻塞写(可做读写分离 + 只读连接池)。
- 自动保存:防抖写入,无"保存"按钮。
- 版本历史:每次保存前快照,可回滚(本地数据的安全网)。
- 回收站:软删除 + 恢复 + 彻底删除。
- 整库备份:导出/导入 zip(数据库一致性快照 + 附件目录)。
- 端到端加密:口令 → Argon2id → 会话内存密钥(不落盘)→ 加密空间库与附件(XChaCha20-Poly1305);锁定态不读。
6. 一段实现(rusqlite + 幂等迁移)
rust
// SQLite 连接 + 迁移
let conn = Connection::open(db_path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.execute_batch(r#"
CREATE TABLE IF NOT EXISTS pages(id INTEGER PRIMARY KEY, parent_id INTEGER, title TEXT, kind TEXT, content TEXT);
CREATE TABLE IF NOT EXISTS attachments(hash TEXT PRIMARY KEY, size INTEGER, mime TEXT);
CREATE VIRTUAL TABLE IF NOT EXISTS post_search USING fts5(title, body, tags, tokenize='trigram');
"#)?;
要点:幂等迁移 (IF NOT EXISTS,版本化);外键 + WAL + pragma 初始化 ;派生索引与权威源分离。
7. 取舍与坑
- 双写(正文 + FTS)的一致性:FTS 索引写失败要能重建,别让索引成为单点。
- 附件内容寻址的引用管理:删页要处理"引用计数",孤立附件靠后台清理,别一把梭删字节。
- 每空间独立库 vs 全库一个 db :独立库隔离好、迁移简单,但全空间搜索要跨库合并(对多库做 UNION/FTS 聚合并结果)。
- 中文检索:纯 FTS 对不足 3 个字符的中文不友好,必须配 LIKE 兜底。
- 备份一致性 :SQLite WAL 下要拿到一致快照(
VACUUM INTO/ 在线 backup API),别直接拷文件。
8. 结论
本地优先笔记的存储层,核心是权威数据源 + 派生索引分离 、空间物理隔离 、附件内容寻址 、FTS5 中文检索 、WAL + 备份 + 加密。这套设计让"数据真正在自己手里"落地:
- 数据在本地、格式可导出(能成 Markdown);
- 索引可重建、备份可还原;
- 附件去重省空间、内容可校验。
(本文为 ShuyoNote 存储层工程笔记,欢迎评论区交流取舍。)