本地优先的笔记应用,数据到底怎么管?——SQLite + 空间隔离 + 内容寻址附件的工程拆解

0. 背景:为什么"数据主权"是硬需求

云笔记把数据放在它家服务器,用的是"使用权"。对一款本地优先 的笔记应用,核心命题是:默认数据就在本机,离线也能完整用,格式开放可迁移。这决定了存储层要优先考虑:

  1. 可移植:数据能从本机拷走、能导出;
  2. 可控:不依赖账号/云,服务停了数据也不丢;
  3. 可离线:全文检索、附件、备份都要在本机闭环。

本文拆解 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. 取舍与坑

  1. 双写(正文 + FTS)的一致性:FTS 索引写失败要能重建,别让索引成为单点。
  2. 附件内容寻址的引用管理:删页要处理"引用计数",孤立附件靠后台清理,别一把梭删字节。
  3. 每空间独立库 vs 全库一个 db :独立库隔离好、迁移简单,但全空间搜索要跨库合并(对多库做 UNION/FTS 聚合并结果)。
  4. 中文检索:纯 FTS 对不足 3 个字符的中文不友好,必须配 LIKE 兜底。
  5. 备份一致性 :SQLite WAL 下要拿到一致快照(VACUUM INTO / 在线 backup API),别直接拷文件。

8. 结论

本地优先笔记的存储层,核心是权威数据源 + 派生索引分离空间物理隔离附件内容寻址FTS5 中文检索WAL + 备份 + 加密。这套设计让"数据真正在自己手里"落地:

  • 数据在本地、格式可导出(能成 Markdown);
  • 索引可重建、备份可还原;
  • 附件去重省空间、内容可校验。

(本文为 ShuyoNote 存储层工程笔记,欢迎评论区交流取舍。)

相关推荐
Brilliantwxx2 小时前
【STM32】 初认识USART串口
linux·stm32·单片机·嵌入式硬件·架构
PFFstronger4 小时前
从 0 到 1 搭建接口自动化测试框架:分层架构 + 数据驱动 + 接口依赖编排
python·架构·自动化
萧瑟余晖5 小时前
ORM 通用原理与阻抗失配详解
架构
156002548406 小时前
基于3U VPX总线架构的VU37P FPGA高带宽HBM缓存数据处理卡(缓存带宽480GB/s)
fpga开发·架构
吴佳浩 Alben6 小时前
构建企业级 DevOps 排错 Agent:从日志告警到自动化修复 PR
大数据·人工智能·语言模型·架构·自动化·ai编程·devops
FII工业富联科技服务6 小时前
三维世界模型驱动机器人操作:概念解析、技术挑战与Omniverse全栈架构深度拆解
大数据·架构·机器人
重庆小透明6 小时前
Kafka 完全指南:从基础组件到核心原理(包含面试题)
java·分布式·微服务·架构·kafka
王解6 小时前
AG-35_DeepSeek Harness 发布:一切皆插件的 Agent 框架
架构·agent