本地优先笔记应用数据管理:SQLite与内容寻址实践

本地优先SQLite内容寻址 本内容遵循CC 4.0 BY-SA版权协议
几木木 几木木 墨衍会员 · AI 创作全网分发 墨衍智能分发 通过墨衍一键同步至各平台 可同步平台 CSDN微信公众号微博知乎掘金百家号博客园抖音小红书 你的文章也可以这样分发 墨衍会员 ¥399起/年,写一次发全网 我也要

本地优先(local-first)的笔记应用这两年被聊得很多,我身边不少朋友从各种云端笔记迁到了本地方案。可真正动手做一个本地优先的应用时,你会发现一个尴尬的事实:把数据存在本地只是第一步,难的是“怎么管”。文本文件好办,附件一多就开始乱;笔记库一多,检索和同步的边界说不清楚;更别提数据库损坏、中文乱码、备份失败这些幺蛾子。我自己从最早“全部塞进文件夹”的朴素方案,一路改成今天的架构——SQLite 管元数据和索引,工作区(空间)做隔离,附件全部走内容寻址,也就是拿 SHA-256 哈希当文件名、靠引用计数做垃圾回收。这篇文章把我工程里的完整拆解拿出来讲透,适合正在做或者准备做本地优先应用的开发者,也适合对笔记软件数据存储原理感兴趣的普通用户。

1. 本地优先的数据管理,难的不是“存本地”,而是“管得住”

1.1 先把数据拆成三类,再谈怎么存

做本地优先应用,第一件事不是选数据库,是想清楚自己手里到底有哪几类数据。以笔记应用为例,本质上只有三类:

  • 正文:Markdown 或者富文本内容。这是用户天天要编辑的东西,必须可读、可迁移、可文本检索。
  • 元数据:标题、标签、创建时间、更新时间、笔记之间的关系、工作区(空间)的归属。没有元数据,正文就是一堆没索引的 txt,检索靠翻文件夹,标签靠文件名前缀,协作和同步更是无从谈起。
  • 附件:图片、PDF、音频、视频这些二进制文件。它们不应该塞进数据库里,也不适合和正文混在同一个目录里直接铺开。

这三类数据的读写频率、一致性要求、备份策略完全不同。正文要高频率读写、低冲突;元数据要求事务一致性和快速检索;附件体积大、基本只写一次却要长期可靠。把它们混在一起处理,是很多本地优先应用后期维护困难、数据越用越乱的根源。

1.2 早期“全用文件夹”方案的四个真实痛点

我最早做原型的时候,就是经典 Obsidian 式思路:笔记内容用 Markdown 文件存,附件丢进一个 assets/ 目录,搜索自己写遍历,标签靠文件名里的 #tag 解析。做 Demo 没问题,数据量一上来,问题全暴露了。

第一是检索效率。笔记到几千篇以后,每次全文搜索都遍历一遍所有 Markdown 文件,速度和“百万博客站不用搜索引擎直接读文件”差不多,肉眼可见地卡。标签解析也容易出歧义,正文里出现 #tag 到底是标签还是标题里的井号,很难完全分清。

第二是元数据没法原子更新。给一篇笔记增加标签,你不可能去改笔记正文,只能另开一个索引文件。一旦索引文件和笔记不同步,比如用户改了文件名、移了目录,标签就飞了,关联关系断得悄无声息。

第三是附件管理基本失控。同一张图片被三篇笔记引用,在文件夹里就得存三份;两个不同来源的同名文件 report.pdf 同时导入,第二个大概率覆盖第一个;用户手动删了附件,笔记里还留着死链,没有任何机制发现。

第四是一致性没有保障。写正文写到一半断电、同步工具冲突产生 xxx (冲突副本).md,这些问题在纯文件方案里很难靠应用自身兜住,只能靠用户自己维护。

经历这一轮之后我做了一个判断:正文保留为文件,保证用户随时拿编辑器能打开、能迁移;但元数据、检索索引、附件引用关系,必须交给一个带事务能力的数据库统一管理。

1.3 数据库选型时,SQLite 是怎么胜出的

在本地优先的场景里,这个数据库几乎没有悬念,就是 SQLite。很多人的第一反应是“这么简单的方案还叫架构”?可你看完后面几节就会明白,真正绕不开 SQLite 的不是“存得下”,而是它的几个特性刚好卡在本地优先的命门上:

  • 单文件:一个空间就是一个 .db 文件,备份、移动、跨机器拷贝,本质就是复制一个文件。用户能理解,代码也好写。
  • 零运维:不需要装服务、不需要开进程、不需要配账号。应用启动时打开连接,退出时关闭,桌面端、移动端、服务器端都能跑。
  • ACID 事务:元数据写入具备原子性,不会出现标签写了一半、正文已经改了这种中间状态。
  • 文件格式极其稳定:SQLite 的库文件格式有非常强的向后兼容承诺,十年前生成的库文件,今天照样能打开。对“用户数据是资产”的本地优先理念来说,这是底线。
  • 生态成熟:从命令行工具到图形界面,从 Python 到 Go 到 Rust 都有 SQLite 驱动。别以为它只在小型工具里出现,老牌的工业组态软件里也不少见,KingsCada 这类系统的历史数据、报警记录就经常用单文件库来存。

当然 SQLite 也有边界:写入并发能力有限、不适合做多客户端实时写同一文件、对超大二进制内容不友好。所以它的正确位置不是“存所有东西”,而是“存需要事务和索引的那部分”。附件和正文留在文件系统,SQLite 做的是元数据中枢和索引层。

2. SQLite 底座怎么搭:从 WAL 到核心表结构

2.1 连接初始化阶段,我固定做这四件事

SQLite 要跑得稳,不是打开连接就能写,连接初始化阶段我会固定执行这几条 PRAGMA。代码以 Python 为例,其他语言思路一样:

PYTHON
db = sqlite3.connect("space.db")
db.execute("PRAGMA foreign_keys = ON")
db.execute("PRAGMA journal_mode = WAL")
db.execute("PRAGMA synchronous = NORMAL")
db.execute("PRAGMA busy_timeout = 5000")
  • foreign_keys:默认是关的,不打开的话外键约束形同虚设,删除笔记时会留下孤儿附件引用。
  • journal_mode = WAL:默认的 delete 日志模式每写一次事务都要动主库文件,多读少写的桌面场景容易锁库。WAL 模式把写操作追加到 -wal 文件,读操作可以并行,应用的界面渲染查库和后台写库才不会互相卡。
  • synchronous = NORMAL:配合 WAL,在桌面应用场景下基本不会丢数据,性能比 FULL 好一截。
  • busy_timeout:多个连接同时写时的等待时间,写死 5 秒比你直接收到 database is locked 报错要友好得多。

还有一条容易被忽略的:用 PRAGMA user_version 管 schema 版本,不要靠写死表结构猜。你是 v1 建的库,应用升级到 v2 要加新表,全靠这张“版本号表”驱动迁移脚本。SQLite 官方推荐的做法就是维护 PRAGMA user_version 的数值,迁移时先读版本,再逐级执行增量升级脚本。

2.2 一个空间一张库,核心表就是这个规模

我最终的结构是“一个空间一套独立数据”,空间目录结构如下:

TEXT
data_root/
spaces.json # 全局空间注册表,记录有哪些空间,各自在哪
spaces/
home/
space.db # 空间自己的 SQLite 库
notes/ # Markdown 正文,按时间/目录组织
objects/ # 内容寻址附件存储,见第 4 节
work/
space.db
notes/
objects/

space.db 里的表,看起来大概是这样:

SQL
-- 笔记主表:正文本体在文件系统,这里只存元数据
CREATE TABLE notes (
id INTEGER PRIMARY KEY,
uuid TEXT NOT NULL UNIQUE,
path TEXT NOT NULL UNIQUE, -- 相对 notes/ 的路径,如 "2026/01/15.md"
title TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER -- 软删除标记,回收站需要
);
 
CREATE INDEX idx_notes_updated ON notes(updated_at DESC);
 
-- 标签
CREATE TABLE tags (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
 
CREATE TABLE note_tags (
note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (note_id, tag_id)
);
 
-- 内容寻址附件资源表
CREATE TABLE resources (
hash TEXT PRIMARY KEY, -- 附件内容 SHA-256
ext TEXT NOT NULL, -- 原始扩展名,如 ".png"
mime TEXT NOT NULL,
size INTEGER NOT NULL,
ref_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
 
CREATE TABLE note_resources (
note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
resource_hash TEXT NOT NULL REFERENCES resources(hash),
usage_type TEXT NOT NULL DEFAULT 'image',
context TEXT, -- 在笔记中出现的上下文,如文件名/位置
PRIMARY KEY (note_id, resource_hash, context)
);
 
-- 全文检索表,用 FTS5
CREATE VIRTUAL TABLE notes_fts USING fts5(
title,
body,
content='',
tokenize='unicode61'
);

你可能会问:正文都在文件系统了,title 也在元数据里,那 notes_fts 表有什么用?这正是关键。本地优先应用最容易被吐槽的就是“自带搜索不如云端笔记”,因为文件系统遍历太慢。FTS5 的职责是承担正文全文索引:每次保存笔记时,把纯文本正文喂给 FTS5,搜索走虚拟表返回 rowid,再回到 notes 表拿路径。content='' 表示这是一个 contentless 表,索引和正文分开存,不会把所有正文复制一份,库不会膨胀得太夸张。

2.3 正文存文件还是存库,这是我的取舍

我前面说了正文放文件系统,这本身是个取舍,不是唯一答案。

正文放文件系统,好处是用户随时能用任何编辑器打开、能 git 版本管理、能和其他工具链互通;坏处是写入不具备事务性,应用写一半断电,可能留下半个文件。所以我在保存流程里加了“临时文件 + 原子改名”的机制:先写 note.md.tmp,fsync 之后再 os.rename 覆盖正式文件,尽量把断电风险压到最小。

正文放 SQLite,好处是可以在一个事务里同时更新正文和元数据,完全原子;坏处是用户失去了“直接用编辑器打开笔记”的自由,库文件也失去“单文件即全部”容易人工审计的优点。

我最终选择了“文件存正文、SQLite 管全部索引和附件关系”,因为对笔记产品来说,可迁移性和用户掌控感是产品信任的基本盘。元数据的丢失对用户的影响远大于正文抖动,所以我的事务重心全放在 SQLite 这边。

3. 空间隔离:多个笔记库之间,边界怎么切才算干净

3.1 “空间”到底解决什么问题

“空间”这个词在不同的应用里叫法不一样,有的叫知识库、有的叫工作区、有的叫保管库。不管叫什么,它解决的问题是同一个:在你的笔记应用里,生活和工作是两堆数据,不应该混在一起。

空间隔离要做到的不是表面上的“分类文件夹”,而是三个层面的隔离:

  • 检索隔离:我在“工作”空间搜“合同”,不应该翻出“生活”空间里写给维修师傅的合同提醒。
  • 命名冲突隔离:两个空间各有一篇路径完全相同的 notes/2026/01/15.md,结构上互不干扰。
  • 附件生命周期隔离:删除一个空间,应该连带回收属于这个空间的所有附件,不能牵动别的空间。

3.2 一空间一库 vs 单库多空间的对比

实现空间隔离有两条路线,我实际都跑过。

路线 A:一空间一库。每个空间独立的 space.db,空间之间就是不同文件,隔离天然成立。备份一个空间就是备份一个目录,同步一个空间也只会动一个文件。缺点是跨空间统一搜索要做多库合并,跨空间引用笔记会比较别扭。

路线 B:单库多空间。所有笔记都在一张 notes 表里,用 space_id 区分,查询时强制带上 space_id 条件。这条路的好处是跨空间搜索一次 SQL 搞定,坏处是隔离要靠代码自觉,漏写一条 WHERE space_id = ? 就全乱了。

两者的取舍我用一张表说明:

维度 一空间一库 单库多空间
隔离强度 文件级隔离,天然封闭 行级隔离,依赖代码纪律
跨空间检索 需多库合并,较重 简单
备份/迁移 按空间整目录操作 只能全量或按行导出
同步 可按空间粒度同步 难做空间粒度同步
附件回收 空间删除即目录删除 要额外处理 space_id 关系

我做桌面端笔记应用,最终选的是一空间一库。原因很直接:隔离这件事,能用文件系统边界解决的就不要用代码纪律解决。代码纪律总有漏写的那天,文件系统边界没有。同时我也想保留按空间单独打包归档给用户的能力,一个空间就是一个 .zip,这对用户理解“我的数据在哪”特别重要。

3.3 全局注册表与空间切换的落地细节

选了一空间一库,还需要一个轻量的全局注册表,让应用启动时知道有哪些空间、默认打开哪个。我没有为这个再做一张大库,就用了 data_root/spaces.json:

JSON
{
"spaces": [
{ "id": "home", "name": "生活记录", "root": "spaces/home", "created_at": 1700000000 },
{ "id": "work", "name": "工作知识库", "root": "spaces/work", "created_at": 1700000001 }
],
"active": "work"
}

切换空间的过程要注意一个顺序问题:先把当前空间在内存里的连接状态保存回去(包括未写完的 meta、回收站待办),再关闭当前库连接,最后打开目标库。不能在连接还挂着 -wal 文件没检查点的情况下直接切,否则下次打开可能触发长时间恢复。

另一个容易被忽略的隔离细节是附件路径必须落在空间目录内。很多应用出现“附件跨空间串了”的 bug,根源就是附件路径用了绝对路径,或者导入时没做路径规范化,结果两个空间的 objects 目录共享了。我的规则是:所有附件引用在数据库里只存哈希和相对路径,应用层负责解析到空间根目录。任何人拿到一个空间目录都能独立打开,不依赖外部任何东西。

4. 附件内容寻址:用哈希换去重、完整性和路径稳定

4.1 附件管理的三个痛点,其实是同一个根因

把附件处理清楚,是本地优先应用从“能用”到“可靠”的分水岭。附件相关的坑,归纳起来是三个:

  • 同名覆盖:两个不同的文件都叫 photo.jpg,放进同一个目录,后者覆盖前者。
  • 重复冗余:同一张图贴进三篇笔记,磁盘上就得存三份,空间浪费不说,改动其中一份还不会同步到另外两处。
  • 引用失效:用户手动改了文件名、搬了目录,笔记里的链接全部变成死链,应用毫无感知。

这三个问题的根因都在于:你用“文件名”这种业务属性当存储主键,而文件名是可变、可重复、不可靠的。换个思路,不按“这个文件叫什么”去组织,而按“这个文件内容是什么”去组织,用内容本身生成一个唯一标识,问题就全解开了。

4.2 SHA-256 + 两级目录的存储布局

附件写入时,我先计算整个文件内容的 SHA-256 哈希。这个哈希就是附件的身份证。存储布局用的是 Git 对象库同款思路:取哈希前两位当目录名,剩下的拼上原始扩展名当文件名。

TEXT
objects/
3a/
c9f6f8...e2.png
7b/
12a0d3...ab.pdf

对应数据库里的 resources 表,hash 是主键。落盘代码如下:

PYTHON
import hashlib, shutil
from pathlib import Path
 
def store_attachment(src: Path, objects_root: Path):
sha = hashlib.sha256()
with open(src, "rb") as f:
# 边读边算哈希,避免把整个文件载入内存
for chunk in iter(lambda: f.read(1024 * 1024), b""):
sha.update(chunk)
digest = sha.hexdigest()
 
target_dir = objects_root / digest[:2]
target_dir.mkdir(parents=True, exist_ok=True)
target_path = target_dir / (digest[2:] + src.suffix)
 
# 已存在说明内容重复,直接返回哈希,不重复存储
if target_path.exists():
return digest, src.suffix, target_path.stat().st_size
 
shutil.copyfile(src, target_path)
return digest, src.suffix, target_path.stat().st_size

这样设计的直接收益:

  • 去重:同一图片再导入一次,算出的哈希一样,直接跳过复制,数据库里只是把另一篇笔记挂到同一个 resource_hash 上。
  • 文件名不再冲突:任何两个不同内容的文件,拿到同一个哈希后 256 位的概率可以忽略不计;内容相同的文件即使名字不同,也共享同一份存储。
  • 路径稳定:只要内容不变,路径永远不变,Markdown 里引用它也不会因为用户改名而失效。

4.3 引用计数与垃圾回收,不能等硬盘满了才做

内容寻址解决了“往哪放”,但回收才是更考验工程的地方。用户删掉一篇笔记,如果附件文件还在 objects 里躺着,日积月累就是一堆垃圾。但如果用户只是不小心删了笔记、马上又从回收站恢复,你急着删附着对象就是事故。

我的处理是两层:数据库引用计数 + 软删除兜底。

note_resources 表记录每一篇笔记引用了哪些资源。笔记写入时,对引用的资源执行 ref_count + 1;笔记删除时,先走软删除(notes.deleted_at 置位),此时引用计数不立刻减,给回收站恢复留余地。只有当笔记被彻底删除(或者在回收站超过 30 天)时,才真正对引用计数 -1。计数降到 0 的资源,进入一个待回收队列,下次启动时做一次文件系统清理。

清理动作必须放在应用体检阶段,不能放在正常写入路径上:

PYTHON
def gc_resources(db, objects_root):
stale = db.execute(
"SELECT r.hash FROM resources r WHERE r.ref_count = 0"
).fetchall()
for (hash_value,) in stale:
path = objects_root / hash_value[:2] / (hash_value[2:] + ext_for(hash_value))
if path.exists():
path.unlink()
db.execute("DELETE FROM resources WHERE hash = ?", (hash_value,))

注意一个小坑:resources 表里并没有存完整文件路径,只存了哈希和扩展名,所以删除时要拼出对象路径。为了避免扩展名被误放,我在写入时就规定了 ext 必须保留在库里,文件系统只认布局规则。

4.4 内容寻址的边界,你必须知道的三个前提

内容寻址不是银弹,它有明确的前提。

第一个前提是内容不可变。你如果允许用户在应用内直接编辑图片、改 PDF,那就不能原地改,必须新导出一个文件、计算新哈希、生成新对象,然后让笔记指向新哈希。依赖“内容变了哈希就变”这个特性,反而能把版本区分开。

第二个前提是哈希只解决完整性,不解决更名后的可读性。哈希路径很难一眼看出这文件是什么,所以数据库里必须存原始文件名、MIME、扩展名这些元数据,导出附件时用这些信息还原一个有意义的文件名,而不是给用户一个 a1b2c3...e4.png。

第三个前提是校验不能只做一次。内容寻址的全部信任建立在“哈希一致代表内容一致”上。只要你在应用层打开附件时顺手做一次分块校验,不匹配就直接报损坏,用户才有机会从备份恢复。这个成本很低,但能让你在磁盘悄悄坏掉一个扇区时第一时间知道,而不是等笔记打开发现图片花了才措手不及。

5. 数据安全兜底:备份、完整性检查与损坏恢复

5.1 备份前必须理解 WAL:直接复制 .db 是个大坑

很多人备份 SQLite 的方式是“把 .db 文件复制走”,这在 WAL 模式下是不安全的。WAL 模式下,最新数据可能还躺在 space.db-wal 文件里没有合并回主库,你直接复制主库,得到的是一个缺了最近几次写入的旧快照。

正确做法有两种,我推荐优先用 SQLite 自带的备份命令:

BASH
sqlite3 space.db ".backup '/backup/space-backup.db'"

或者应用内执行 VACUUM INTO '/backup/space-backup.db'。VACUUM INTO 会生成一个紧凑的、无 WAL 依赖的完整快照,还可以顺手压缩碎片。两条路都能保证备份文件的完整性。

如果用户想要更傻瓜的方案,那就做一个“先检查点,再复制文件”的脚本:

BASH
sqlite3 space.db "PRAGMA wal_checkpoint(TRUNCATE);"
cp space.db /backup/space.db
cp space.db-wal /backup/ 2>/dev/null || true
cp space.db-shm /backup/ 2>/dev/null || true

这算是一个兜底招,但要注意拷贝过程中应用不能有新写入,否则三个文件又会不一致。所以生产环境我还是建议用 .backup 或者 VACUUM INTO,它们由 SQLite 自己保证一致性。

5.2 完整性检查:启动时自动跑一次 quick_check

SQLite 有一个成本很低的自检命令,是每个本地优先应用都应该在启动时跑的:

BASH
sqlite3 space.db "PRAGMA quick_check;"

quick_check 比 integrity_check 快,日常用足够;如果 quick_check 报错,再跑全量的 integrity_check 做详细定位。我把这项检查放在应用启动后的后台线程里执行,不阻塞界面。一旦发现损坏,立刻做三件事:弹出提示让用户知道、自动打开最近一次备份、把损坏的库文件改名为 space.db-corrupt-<时间戳> 保留现场,而不是直接覆盖。

这里有一个工程上的原则:损坏恢复最重要的是“别慌”,最怕的是用户手动重装、覆盖、修复之后把现场破坏了。保留损坏原始文件,是对后续人工排查的唯一武器。

5.3 迁移与归档:单文件带来的极致便利

数据安全不只是防损坏,还要让用户随时能“带着数据走”。我设计的一个空间就是一个目录,里面是 space.db + notes/ + objects/。归档就是打 zip,导入就是解压后注册进 spaces.json,不需要任何服务端协调。

跨版本升级时,迁移脚本同样靠 PRAGMA user_version 驱动。比如 v1 到 v2 要引入标签表:

PYTHON
def migrate(db):
version = db.execute("PRAGMA user_version").fetchone()[0]
if version < 2:
db.executescript("""
CREATE TABLE tags (...);
CREATE TABLE note_tags (...);
""")
db.execute("PRAGMA user_version = 2")

迁移要放在一个事务里,失败回滚,绝不能“表建了一半,版本号已经改了”。我的习惯是先备份一份 space.db.pre-migrate,再跑迁移,最后删掉备份。迁移这件事,多留一步后手永远不会错。

6. 工程落地踩坑手册:乱码、锁库与工具链实测

6.1 中文乱码的根因,多半不在 SQLite,而在驱动层

热词里有一条“delphi sqlite 亂碼”,我太熟了,早年用 Delphi 配合 Zeos、UniDAC 这类组件连 SQLite 的开发者基本都被这问题坑过。乱码的根因十有八九是:SQLite 文件里存的是 UTF-8 字节,客户端驱动或者界面控件却按本地 ANSI/GBK 去解释,结果中文全部变成乱码。这不是数据库坏了,是“文件里的字节”和“客户端读法”对不上。

排查时可以先用一个可靠的图形工具打开同一个库,看看显示是否正常。如果工具里正常、你自己的程序里乱码,那问题一定在客户端配置:检查连接串或驱动是否明确指定了字符集,比如 Delphi 里把连接的 Charset 属性设为 UTF-8,别让它继承系统默认的 GBK 区域设置。如果用 Python 这类默认用 Unicode 的语言,这类问题基本不会出现。

真正麻烦的是另一种情况:数据写入时就已经错了。早年的组件在高版本 SQLite 和旧驱动混用下,可能把 GBK 字节直接塞进了库,库里存的就是一堆“看起来像 UTF-8 的开头但其实是 GBK 尾巴”的脏字节。这种情况没法靠改连接解决,只能做数据修复迁移:把每条记录按字节读出,识别实际编码,再转成 UTF-8 后回写。这个动作风险高,建议先把库完全备份,再在副本上操作。

6.2 查看与调试工具的实际选型

本地优先应用的开发期和排障期,离不开几款 SQLite 图形工具。我把用过的工具做了个对比:

工具 适合场景 优点 注意点
SQLiteStudio 日常浏览、改数据 单文件绿色运行,插件机制,轻量 界面偏朴素,大库滚动略卡
DB Browser for SQLite 快速查表和做小修改 打开快、Windows 下免安装版好用,表结构可视化清晰 对复杂 SQL 的编辑器体验一般
DBeaver 同时要连 MySQL/Postgres 等多种库 功能最全,SQL 编辑器强 偏重,且它更适合“数据库专业用户”
sqlite3 命令行 备份、恢复、跑脚本、一键检查 万能,.backup、.recover、PRAGMA 全支持 没有图形界面

Windows 下装命令行工具很简单:去 SQLite 官网下载 sqlite-tools-win-x64 的 zip,把 sqlite3.exe 解压出来加到 PATH 里就行。图形工具基本各有绿色版,不需要安装,适合直接拷到用户的 U 盘上用。我曾经帮一个用户排查“笔记打不开”,远程指导他用 DB Browser for SQLite 直接打开 space.db 跑 PRAGMA quick_check,两分钟定位到索引损坏,比让他描述症状快得多。

6.3 锁库问题:桌面应用也要注意并发写

本地优先应用虽然是单机,但锁库场景一点不少。最典型的是:界面线程在做一个耗时的全文索引写入,后台线程同时收到用户保存请求,两个连接同时写同一个 SQLite 文件,其中一个就会收到 database is locked。

除了前面说的 busy_timeout,我还在应用层做了一个简单的写操作串行化:所有写操作都通过同一个连接执行,或者用一个线程锁包住写事务。SQLite 本质上是单写者,与其让多个连接互相等,不如从源头控制并发。读操作可以放开走 WAL 的并行读,写操作必须串行。

顺手提一个规则:长期不用的连接一定要关闭。有些人打开连接后一直不关,文件句柄占着,Windows 上容易出现共享冲突,表现得比 database is locked 更诡异。每完成一次“打开空间、用、关空间”的生命周期,就把连接关干净,这是最省心的做法。

6.4 性能调优的三个低成本手段

最后说性能。本地优先应用的数据量一般不至于到“必须上重型数据库”的程度,但检索和写入速度的体感差异很明显。我实测下来,三个低成本手段收益最大:

  • FTS5 全文检索替代 LIKE '%关键词%'。几千条笔记时 LIKE 已经能感觉到延迟,几万条时基本不可用。FTS5 是 SQLite 自带的全文检索引擎,速度快好几个数量级,配合前面说的 contentless 表不会占用太多库体积。
  • 给常用查询加索引。notes(updated_at)、note_tags(tag_id) 这种高频查询条件必须有索引。加索引要在建表阶段想好,等数据多了再回头补,迁移成本就上来了。
  • PRAGMA cache_size 设置合理值。默认的缓存对几千条笔记没问题,笔记多、附件引用多的库可以适当调大,比如 PRAGMA cache_size = -16000(约 16MB),减少磁盘随机读。

这些手段加完之后,我从笔记正文检索到结果返回基本能做到毫秒级,用户体感上跟云端笔记的即时搜索没有区别。对一个本地优先应用来说,快速、可靠、可迁移这三件事能同时兑现,数据管理这一层就算真正稳住了。

相关推荐

基于Python+Open CV的手势识别算法设计

伴随着人工智能时代的到来,人机交互的领域也逐渐成为研究的一大方向;其中,手势识别是人机交互领域的一项关键技术,自此手势识别也迎来了一波高潮,近几年无论是在消费领域、电子领域、数码领域、家电领域甚至汽车领域上,都能或多或少的见到手势识别的身影。 手势识别在设计智能高效的人机界面方面具有至关重要的作用, 目前手势识别已应用到手语识别、智能监控、到虚拟现实等各个领域,手势识别的原理都是利用各种传感器(例如红外、摄像头等)对手部的形态进行捕捉并进行建模,形成一个模型信息的序列帧,而后将这些信息序列转换为机器能够识别的相对应指令(例如打开、切换菜单、移动等)来完成控制。 采用Python的集成开发环境Pycharm进行本次课程设计,在Pycharm中进行需要库(模块)的下载,调取电脑摄像头,按帧读取摄像头采集到的头像,形态学处理,图像旋转(由于摄像头采集到的图像是镜像,需要用cv2.flip将摄像头采集到的图像进行处理),选取图片中固定位置作为手势输入,用红线画出手势识别框,基于hsv的肤色检测,进行高斯滤波,找出轮廓,求出图像中手势的凹凸点,手指间角度求取。

努力学习前端的小陈

ROS-CV-ImageConverter:将ROS图像转换为OpenCV(cv :: Mat)类型的图像

ROS-CV图像转换器 依存关系 cv_bridge(ROS软件包)

Willis Wang

计算机视觉图像处理-CV2与图像插值算法

用最近邻插值与双线性插值原理完成图像缩放 最近邻插值,是指将目标图像中的点,对应到源图像中后,找到最相邻的整数点,作为插值后的输。缺点:使用最近邻插值算法处理图像放大时,在图象中可能出现明显的块状效应 双线性插值,在两个方向上做三次线性插值。使用双线性插值算法处理图像放大时,在图象中可能出现马赛克 代码 import cv2 img = cv2.imread(’./xiabang.jpg’, cv2.IMREAD_UNCHANGED) #缩放比例 scale_percent = 0.3 width = int(img.shape[1] * scale_percent) height = int

weixin_38697274

基于水平集CV模型的图像分割(Matlab代码)

基于水平集CV模型的图像分割(Matlab代码),全套代码

小宇QAQ

导向滤波(guide_filter)的python实现

导向滤波(guide_filter)的python实现,可以结合暗通道;理论实现比较好的图像去噪效果。

zachzhan_g
返回顶部
几木木
博客等级 码龄8年 44粉丝 5,262原创