SQLite+空间隔离+内容寻址:本地优先笔记的存储层架构实践
开头先说明一下,别嫌我啰嗦:本地优先的笔记应用,这几年确实火,但大家讨论的重点大多在“离线可用”“数据主权”“端到端加密”这些产品层概念上,真正落到工程层面、把“数据到底怎么管”讲清楚的很少。我自己做了一款本地优先的笔记工具,从最早的 JSON 文件存储,到后面切换到 SQLite,再到把附件改成内容寻址,中间踩了不少坑,也重构过两轮。这篇文章我会以 SQLite + 空间隔离 + 内容寻址附件三个关键词为主线,把存储层的整体设计、表结构细节、附件去重方案、Windows 环境下的工具链,以及我实际遇到的各种诡异问题一次讲透。
这套方案适合谁参考?如果你正在做本地优先的笔记、知识库、写作工具、个人档案管理类应用,或者你只是想知道一个“本地优先”的应用在数据层到底是怎么组织数据、怎么保证不崩、怎么处理附件不重复存储的,那这篇文章应该能给你省下不少试错的时间。
1. 项目背景与核心需求拆解
1.1 本地优先到底在解决什么问题
先聊清楚“本地优先”这个概念的落点。很多人把本地优先等同于本地存储,其实不对。云笔记应用也可以把数据缓存在本地,但它的系统架构是以云端服务器为权威数据源,本地的缓存随时可能被覆盖、重置、清掉。
本地优先应用的权威数据源在用户自己的设备上——笔记本、手机、平板——云端最多是一个可选的中转同步通道。这个架构带来的直接好处有三个:一是离线可用,没有网络也能完整读写;二是隐私边界清晰,用户的笔记不经过你的服务器;三是长期可访问性,只要用户手里的文件还在,十年后依然能打开。
但坏处也很直接:数据在本地,就意味着你要自己扛下所有数据管理的责任。格式弄坏了,用户的笔记全没了;版本升级迁移出错,用户几十年的积累就毁了;附件存放策略不科学,时间长了磁盘空间爆炸。
所以做本地优先应用,存储层设计几乎决定了产品的上限。我一开始图省事,直接用一个大 JSON 文件存所有笔记,另一个文件夹放附件,用 GUID 命名。前期两三百条笔记时还很爽,到了 5000 条笔记、几百张图片之后就崩了:每次保存要重写整个 JSON,启动要解析全部数据,附件重复存放,重复图片出现十几份。这个阶段让我彻底明白了一件事:本地优先的数据管理,不是选一个数据库那么简单,而是要把文本数据、附件文件、目录结构、并发机制、生命周期管理当成一个完整的系统来设计。
1.2 为什么存储层选了 SQLite
当时的可选方案我认真比对过,主要是三类:JSON/自定义二进制文件、SQLite、LevelDB/Badger 这类嵌入式 KV。
JSON 文件的问题不用多说,全量读写的性能瓶颈和并发安全问题很难绕开。LevelDB 这类 KV 存储写入性能和压缩方面的确优秀,但对笔记这种“结构化查询”需求不太友好。笔记数据绝不是简单的键值对,我需要按空间列、按更新时间排序、按标签筛选、做全文搜索,这些场景用 SQL 表达都是几个简单语句,用 KV 就得自己维护各种二级索引,工程复杂度直接上一个台阶。
SQLite 最终胜出的核心原因有四条。第一,单文件存储,一个库就是用户数据目录里的一个 .db 文件,备份、迁移、复制都极其简单,这跟本地优先的“数据主权”理念天然吻合。第二,它支持 ACID 事务和行级锁(严格说 SQLite 是整库锁加 WAL 优化,但事务安全是实打实的),用官方说法,SQLite 的可靠性经过了几十亿设备验证。第三,它不是客户端/服务器架构,不需要用户装任何服务,直接嵌入到应用进程里,对个人工具类产品再合适不过。第四,生态极度成熟,官方命令行工具、DB Browser for SQLite、SQLiteStudio、DBeaver 等查看工具一抓一大把,用户出了问题也容易排查。
当然 SQLite 也不是没有缺点。写入并发差,同一时刻只能一个写事务,多进程并发写会出现 SQLITE_BUSY;强一致性的代价是写性能的上限比 LevelDB 这类 LSM-Tree 结构低。但对笔记应用来说,用户的写入频率远没到数据库瓶颈,这一点后面我会讲怎么通过 WAL 和连接管理规避。
1.3 整体架构:文本、附件、空间三层分离
在设计存储层时,我把数据分成了三个域,每个域用不同的技术方案处理,避免把鸡蛋放在一个篮子里。
第一层是结构化数据,包括笔记正文、标题、标签、创建修改时间、空间归属,这些全部放进 SQLite 的多个表里,用外键和索引关联。第二层是附件二进制数据,包括图片、PDF、音频、视频,这些文件体积大、二进制形态不适合塞进数据库,放到独立的对象目录,用内容寻址(content addressing)的方式管理。第三层是空间隔离,一个用户可能有多个笔记空间(比如“工作”“生活”“项目A”),每个空间的数据物理隔离,避免互相干扰,也方便单独导出和清理。
这三层方案的设计原则是:能放进数据库的放进数据库(文本元数据),不适合放数据库的放到文件系统(大附件),需要隔离的物理隔离(空间)。这样设计的好处是,数据库不会因附件膨胀而变得巨大,大文件的读取不需要经过数据库缓存,空间维度上的删除和迁移也变成了简单的文件目录操作。后面每一章我会把这几个域的细节全部展开。
2. 数据库设计与空间隔离方案
2.1 空间隔离的本质:一个用户到底有几套数据
“空间隔离”这个概念,表面上看起来像是在讨论“怎么把数据库按文件夹分开”,但本质上是回答一个问题:一个用户的笔记数据,是应该整体存放在一个统一的数据库里,还是按空间/笔记本拆分到多个数据库?
这个决定会影响后续的所有功能开发。拆得太碎,跨空间搜索、全局标签、最近笔记列表都变得麻烦;拆得太粗,空间删除、空间级备份、空间级导入导出又很痛苦。
我最终采用的方案是混合式:公共元数据一个库(app.db),每个笔记空间一个独立的库文件(space_xxx.db),附件对象目录全局统一。
为什么这么选?先说公共库 app.db,它存的是全局配置、用户偏好、所有空间的登记信息、全局标签表。这部分数据量不大,但所有空间都要查询,单独一个库很合适。再说每个空间一个库,核心好处有三个:一是空间级的备份和恢复变成复制一个文件;二是删除空间直接删文件,不用再去执行 DELETE FROM notes WHERE space_id=xxx 这种大事务,效率和安全都更好;三是空间之间的数据天然隔离,一个空间的数据损坏不会波及其他空间。坏处也有,跨空间的“全部笔记”查询需要遍历多个数据库,但处理办法也不复杂,我在公共库里维护了一个轻量的全局索引表,只存笔记的 ID、标题、空间 ID、更新时间,用于全局搜索和最近列表,深层内容再按需进对应空间库查。
2.2 表结构设计与索引策略
具体到单个空间库,我实际在用的建表结构经过了三次迭代,下面这份是稳定版本,可以直接参考。
首先是笔记主表 notes,核心字段我贴出来:
有几个细节我想特别说一下。ID 用的是应用生成的 UUID 字符串,而不是自增整数。原因很简单:本地优先应用的数据会在多个设备之间同步,如果用自增 ID,两台设备同时插入笔记会生成相同的 ID,冲突处理极其麻烦。UUID 虽然看起来大,但查询性能影响很小,反正主键索引是 B-Tree,字符串有序,插入性能可接受。
时间字段我用的是 INTEGER 类型,存 Unix 毫秒时间戳,而不是 ISO 字符串。好处是排序和范围查询直接走整数比较,不需要字符串解析,性能好,而且在 SQLite 客户端里一眼就能看出时间大小。
deleted_at 字段是软删除标记。注意,笔记删除我统一走软删除,等同步完成之后再由清理任务物理删除。这是本地优先应用的通用做法,避免用户反悔、避免同步时把“删错”传播到其他设备。查询时统一过滤 deleted_at IS NULL,所以在更新时间列和删除标记列上建了复合索引:
索引策略上有一条重要经验:索引不是越多越好。我第二版设计里给 content、title、created_at、updated_at 全建了索引,结果写入慢、数据库文件大、查询提升却有限。后来砍到只剩最常用的查询路径——按空间按更新时间排序、软删除过滤——性能反而更稳。索引的目的是服务具体查询,不要凭空建。
附件引用表 attachments 的设计跟内容寻址强相关,下一章展开。这里只给表结构:
storage_key 存储的是对象目录中的相对路径,比如 ab/abcdef123456...png,这个字段是内容和物理文件之间的桥梁。file_hash 既用来去重,也用来校验文件完整性。注意它在附件去重场景下必须有索引,否则每次插入都要全表扫描。
2.3 WAL 模式与并发控制
SQLite 默认的回滚日志模式在写入时要锁整个数据库文件,读操作会被写阻塞,这对笔记应用的多窗口、多进程场景非常不友好。我的做法是启动时立刻执行这几个 PRAGMA:
WAL(Write-Ahead Logging)模式是 SQLite 官方推荐的并发方案。开启后,读操作不再阻塞写操作,写操作也只是追加到 -wal 文件,后台自动 checkpoint 合并到主库。实测下来,我的笔记应用多开窗口、同时后台索引线程读数据,再也不会出现“database is locked”的报错。busy_timeout 是为了让多进程同时写时,后到的写请求等待而不是立刻报错,3000 毫秒对交互式应用足够。
需要注意 WAL 模式有个隐藏问题:数据库目录下会出现 -wal 和 -shm 这两个额外的文件,很多用户不理解,以为是垃圾文件就删了。删掉 -wal 的后果是未 checkpoint 的事务直接丢失。所以我在文档里明确提示:这三个文件是一体的,备份时要么先执行 CHECKPOINT,要么三个文件一起复制。
关于 synchronous=NORMAL,WAL 模式下说 NORMAL 在大多数情况下不会丢失数据,但在系统断电极端场景下可能丢失最近几次提交。对我这个场景可以接受,因为我还有定时备份兜底。如果做金融类强一致应用,建议还是用 FULL,安全第一。
3. 内容寻址附件的工程实现
3.1 哈希当身份证:内容寻址原理
附件管理的第一版,我用的是最简单的方式:每上传一个附件,生成一个 GUID 文件名,把文件存到 attachments 目录,数据库记录里存 GUID 文件名和原始文件名。这种方式实现起来毫无难度,但运行半年后问题就暴露了:同一张图片插入到 10 篇笔记里,目录里就有 10 份物理拷贝,占用了 10 倍的磁盘空间。更严重的是,用户改了一个附件内容,文件名不变,数据库里记录的还是旧的文件,数据不一致。
这就是位置寻址(location addressing)的典型困境:按名字找数据,名字和内容没有强绑定关系,内容变了名字不变,内容和引用之间就容易脱节。
内容寻址(content addressing)的思路刚好反过来:不根据文件名找内容,而是根据内容的指纹找内容。指纹通常是文件的 SHA-256 哈希值,哈希一旦确定,文件内容也就确定了,内容变了哈希自然变。这样设计后,相同内容的文件天然只需要存储一份,而且任何引用方都可以通过重新计算哈希来校验文件是否被篡改或损坏。
实现上并不复杂,写入附件时的流程是:读文件流,边读边计算 SHA-256,得到哈希后拼接扩展名作为 storage_key,检查对象目录里是否已存在这个 key,不存在才写入物理文件,然后把哈希、storage_key、大小、MIME 类型写入 attachments 表。整个过程最大的开销是读一次文件计算哈希,对几十 MB 的 PDF 也就是几十毫秒到几百毫秒,完全在可接受范围内。
3.2 对象目录的分片命名与存储布局
有了 content hash,怎么组织物理文件系统也是个讲究。第一次我把所有文件直接平铺在一个目录里:objects/abcd1234...png。刚开始文件不多还好,等文件数超过一万,问题就来了。Windows 和 macOS 的文件系统在单目录下文件过多时,目录项查找性能会明显下降,而且部分文件系统对单个目录的文件数量有上限或性能急剧恶化的阈值。
我的方案是两级分片加哈希前缀,类似 Git 的对象存储思路:
具体做法是:取 SHA-256 哈希的前两个字符作为一级目录,第三第四个字符作为二级目录(可选),后面的哈希加扩展名作为文件名。这样 256 个一级目录,每个目录平均只放总文件数的 1/256,目录项数量被摊薄。哈希是十六进制字符串,天然均匀分布,不用担心某个目录被挤爆,也不用手动做负载均衡。
命名格式我采用的是 {hash}{ext},比如 abcdef1234567890.png。有人可能想直接在哈希后面拼接原始文件名,方便人识别,但我不建议这么做。一是哈希已经保证了唯一性,原始文件名放在数据库字段里即可;二是拼接文件名会导致路径变长,Windows 的路径长度限制很容易踩坑;三是文件名里出现中文、特殊字符,会导致各种跨平台兼容问题。原始的显示名一定要进数据库,文件系统里只要保留“内容指纹 + 扩展名”就足够了。
MIME 类型这里有个小坑。扩展名并不能完全代表 MIME,比如 md 文件可能是纯文本,webp 在某些旧系统里识别不出来。所以我会在写入附件时记录一份 MIME 类型到数据库,它是从 HTTP 下载时拿到的 Content-Type 里面提取的,不依赖扩展名推断。这样导出附件时能正确还原类型。
3.3 附件去重与生命周期管理
内容寻址带来的最大红利是去重天然生效:写入时检查哈希是否已存在,存在就直接复用,不存在才新建。这让“一张图片被 50 篇笔记同时使用”变成一种几乎零成本的正常状态,磁盘占用最多只存一份。
但是,去重带来一个新的管理难题:引用计数和垃圾回收。删除一篇笔记时,数据库里 attachments 表对应的记录也会被删掉,但物理文件还在对象目录里。如果没有任何笔记再引用这个文件,它就成了孤儿,时间长了对象目录会变成垃圾场。
我的做法是引入引用计数机制。attachments 表维护每一条引用,删除时先查一下还有没有其他记录引用同一个 file_hash,没有的话再删物理文件。但这套逻辑在分布式同步场景下会有竞态问题,本地优先的简单场景还可以接受。为了更稳妥,我加了一个离线 GC 工具作为兜底:扫描对象目录里所有文件,算出哈希集合;再查数据库里所有 storage_key,生成引用集合;集合差就是孤儿文件,清理前再二次确认那些文件确实没有被任何软删除的笔记引用,然后才删。这套 GC 我设计成手动触发的,没有像一些系统那样自动后台跑,主要是不想在用户打字的时候突然发生大量磁盘 IO。
对象目录里还可能有大文件问题。我自己设了一个阈值:超过 200MB 的附件不进入内容寻址目录,改成单独的大文件区域,用普通命名方式存,数据库里加一个类型标记区分。原因很简单:大文件的哈希计算耗时较长,去重收益不高,而且频繁读写容易触发其他性能问题。对笔记应用来说,200MB 以上的视频、镜像文件属于少数派,没必要为它们牺牲通用路径的简洁性。
4. 实操过程与工具链
4.1 Windows 下 SQLite 环境搭建
很多人在 Windows 上装 SQLite 时被绕晕了,以为要安装什么服务。其实 SQLite 不属于客户端/服务器数据库,不需要“安装”任何后台进程,用户拿到的是一个命令行工具和一堆动态库。
官方的用法是:到 sqlite.org 的下载页面找 Windows 版工具集,通常是 sqlite-tools-win-x64-xxxxxxx.zip,解压之后里面有三个可执行文件:sqlite3.exe 是命令行客户端,sqldiff.exe 用来比较数据库差异,sqlite3_analyzer.exe 用来做空间分析。
我的习惯是把解压后的目录放到 C:\sqlite,然后把 C:\sqlite 追加到系统环境变量 PATH 里。这样在任意目录打开 cmd 或 PowerShell,直接敲 sqlite3 就能进命令行。验证环境是否正常,运行:
能看到版本号就说明 OK 了。这里有一个很常见的坑:很多用户解压完 sqlite3.exe 以后,直接把 exe 拉到 cmd 窗口里运行,这样虽然是能跑,但命令行参数没法正确传递,用户体验很糟糕。正确做法是先用 cd 切换到数据库所在目录,或者用绝对路径打开数据库文件,例如:
进入交互模式之后,.tables 可以列出所有表,.schema notes 查看建表语句,.quit 退出。这一套流程背下来,日常排查就够用了。
4.2 可视化工具选型:三个工具的实际体验
命令行的效率高,但看数据、改数据还是图形界面方便。市面上的 SQLite 可视化工具我陆续用过七八款,最终长期保留的是三款:DB Browser for SQLite、SQLiteStudio、DBeaver。三者的定位差异很明显,我做个对照方便你选型:
| 工具 | 开源 | 跨平台 | 适合场景 | 我体感上的不足 |
|---|---|---|---|---|
| DB Browser for SQLite | 是 | Win/Mac/Linux | 日常数据浏览、SQL 执行、导入导出、表结构修改 | 表数据量大时界面略微卡顿 |
| SQLiteStudio | 是 | Win/Mac/Linux | 多连接管理、数据导入导出、内置脚本、表数据编辑 | UI 风格偏老,初次上手需要适应 |
| DBeaver 社区版 | 是 | 全平台 | 多数据库统一管理、复杂 SQL、ER 图 | 对 SQLite 的专用细节支持不如前两者;需要额外装驱动 |
日常开发调试时,我的主选是 DB Browser for SQLite。它打开数据库文件特别快,双击一条记录就能编辑字段,直接支持 CSV/JSON 导入导出,写优化器临时脚本也很方便。SQLiteStudio 我用来做跨库对比和批量更新,因为它支持保存多个连接,而且内置 SQL 脚本编辑器,跑多表关联统计比 DB Browser 顺手。DBeaver 更多是当我需要同时连 MySQL/PostgreSQL 和 SQLite 的时候才用,单看 SQLite 场景,它略微大材小用。
无论用哪个工具,都建议开启“写事务手动提交”之类的选项,避免在图形界面里误改数据。SQLite 的事务特性决定了误操作很难靠 Ctrl+Z 撤销,所以我自己的习惯是:动数据之前先备份 db 文件,再开工具改。
4.3 数据库初始化、迁移与备份
本地优先应用还有一个容易被忽略的工程点:数据库的初始化和版本迁移。我采用 PRAGMA user_version 字段来做 schema 版本管理。
应用启动时,先打开数据库文件,如果文件不存在就按当前 schema 创建新库,并把 user_version 设置为最新版本号,例如:
如果文件已存在,读取 user_version,然后按版本号顺序执行迁移脚本:3 升 4 执行一段 SQL,4 升 5 执行另一段,直到升到当前版本。千万不要直接写死 CREATE TABLE IF NOT EXISTS 然后就开始读写,因为老版本库可能需要调整字段、加索引、合并数据,直接跑当前 schema 会导致数据错乱。
备份更是本地优先应用的生命线。我的方案是三条线并行:第一条,每次应用退出时执行一次 WAL checkpoint,然后复制 .db 文件到备份目录,保留最近 7 份;第二条,每周做一次完整快照,包括数据库和 attachments 目录,压缩后放到备份目录;第三条,关键操作(比如执行大迁移)前手动触发一次备份,由迁移脚本自动完成。
实际经验说明,备份恢复流程也要定期演练。我遇到过好几次备份文件时间戳看起来正常,实际内容是损坏的,原因不明。所以每隔一个月,我会从备份目录随机挑一份,尝试把它恢复到临时目录,跑一遍 PRAGMA integrity_check,确认万无一失。
5. 常见问题与排查实录
5.1 中文乱码问题:老代码接入时最容易踩的坑
关于 SQLite 的中文乱码,网上搜“delphi sqlite 乱码”能出来一大片,这其实不是 SQLite 的问题,而是字符集协商的锅。SQLite 存储文本通常使用 UTF-8 编码,如果写入端用 UTF-16LE 或 GBK 编码把字节写进库,读取端按 UTF-8 解释,自然就是乱码。
排查方法很简单,用命令行工具打开数据库,执行:
正常情况会返回 UTF-8。然后查一条笔记,如果命令行显示正常,数据库本身是干净的,问题肯定出在读取端:连接字符串里没有指定字符集,或者客户端把 API 返回的 UTF-8 字节流按本地代码页转码了。
Windows 命令行里另一个乱码来源是控制台代码页不对。sqlite3.exe 以 UTF-8 输出中文,而 cmd 默认的代码页可能是 GBK(代码页 936),于是显示成乱码。解决办法是在 cmd 里执行:
把控制台代码页切到 UTF-8,再进 sqlite3 查询,中文就正常了。这跟数据库数据本身的项目完全无关。如果换了代码页还是乱码,再用 DB Browser for SQLite 这类图形工具打开看,要是图形工具显示正常,就可以确认数据库内部数据没问题,代码端的字符集配置才是根源。
5.2 数据库锁定与并发冲突:SQLITE_BUSY 实战排查
本地优先应用最常见的运行时问题就是 “database is locked” 或 SQLITE_BUSY。这个问题的直接机制是:另一个连接(进程)已经持有了写锁,当前连接试图写库时发现锁被占用,等待超时后抛出 BUSY。
我实际遇到的场景有三类。第一类是打开多个应用窗口,每个窗口都创建了独立数据库连接,其中一个窗口执行长事务,另一个窗口写数据发生冲突。第二类是后台线程(比如自动保存线程、同步线程)定时执行写语句,和用户正在编辑触发的写操作撞车。第三类是连接没有及时关闭,事务跨请求存在,把锁一直攥着不放。
排查步骤我的固定顺序是:先看数据库目录下有没有 -wal 和 -shm 文件,如果 -wal 文件长期存在且很大,说明有连接没 checkpoint;再用 .dbinfo 命令查看连接状态,直接用 DB Browser 打开数据库,尝试修改一条记录看是否报锁。最实用的一招是把所有连接都设置为 busy_timeout,并且统一走一个写连接,避免多连接长时间持有写事务:
如果某个连接确实长时间做大量写入,建议把写入拆成多个短事务。比如批量插入 10000 条笔记,每 500 条提交一次,而不是一个事务全部包住。长事务最大的问题不是它本身会失败,而是它会阻塞所有其他连接,导致应用表现为“卡死”。短事务既能降低锁冲突概率,也让 checkpoint 有机会跑。
5.3 附件目录膨胀:GC 策略与统计排查
对象目录膨胀是一个慢性问题。典型症状是:数据库 attachments 表显示只有几百条记录,但 objects 目录里文件数量却有一万多个。
我用这个 SQL 来定位有多少附件被多个笔记引用:
再结合对象目录扫描,就能算出孤儿文件的占比。我的 GC 工具做法是:先从数据库导出所有 storage_key 到一个集合,再遍历对象目录,凡是集合里不存在的路径就是可能的孤儿。为了安全,删除前会做二次确认:检查这个文件是否在最近 24 小时内有任何笔记删除操作涉及过(比如软删除的笔记尚未清理、记录的 note_id 还在 deleted_at 里标记),避免误删。
另外一个有用的习惯是,把对象目录的所有文件大小统计写进一个 meta 表,定期刷新,这样 GC 时不用全量扫描文件系统,速度会快很多。不过 meta 表本身也要和实际文件系统同步校验,我把它当成缓存而不是权威数据。
5.4 数据损坏与恢复:最后一道防线
SQLite 数据库文件损坏的概率非常低,但一旦发生就很致命,表现通常是启动时报 database disk image is malformed。最常见的原因是写入过程中程序被强杀、系统断电、磁盘故障,或者用户把 -wal 文件当成垃圾手动删掉了。
预防层面,前面说的 WAL 模式和 checkpoint 策略能挡掉绝大多数非正常退出带来的损坏。应用启动时我会自动跑一遍 PRAGMA integrity_check,如果检测到错误,不再尝试直接读写,而是立即把库文件复制到 recovery 目录,然后提示用户联系技术支持。
恢复层面,SQLite 自带一些工具可以处理。第一步先用 .recover 命令把可读取的部分导出到家:
第二步用 .dump 导出建表语句和数据,生成新的干净库:
第三步再用 PRAGMA integrity_check 验证恢复结果。需要提醒的是:恢复过程能救回大部分数据,但最近几次未 checkpoint 的提交可能丢失,这是 WAL 模式的固有代价。所以备份仍然是最重要的防线,恢复工具只是补救手段。
还有一点容易被忽视,数据库文件的“坏”有时候是误报。硬盘坏道、云盘同步冲突产生的替代文件,都可能让 SQLite 打开失败。我遇到过用户把数据库放在网盘同步目录里,本地被网盘回滚成了旧版本,SQLite 一打开就报错。本地优先应用的用户数据目录要避开云同步目录,或者在文档里警告用户不要把数据库放在 OneDrive、iCloud 这种同步盘里,这个经验很值钱。
结尾
写到这里,整个数据层方案的框架已经完整了。关于 SQLite、空间隔离和内容寻址,我最后再分享一些个人体会。做本地优先应用这两年,我的核心认知是:存储架构不是技术细节,而是产品边界的一部分。写入可靠性、附件去重、空间隔离、恢复能力,这些平时不显山露水的设计,会实打实影响用户能把多少信任感放在你的产品上。
如果你也在做类似的东西,我的建议是别急着写业务代码,花两三天把存储层想清楚。多建几张表不丢人,多跑几遍迁移脚本不丢人。等产品真的跑起来,用户数据渐渐丰满的时候,你会感谢那个当初愿意把 SQLite 的 PRAGMA 都试过一遍的自己。