1. 项目定位与整体设计思路
1.1 为什么我会从主流笔记软件里跑出来
大概两年前,我下定决心把所有笔记从一款商业云笔记软件里迁出来,自己动手写一套。这个项目最后命名为 mysol,全称是 My Simple Outline Library,最早只是一个命令行工具的原型,如今已经变成我每天都在用的知识库系统。
先说清楚我遇到了什么问题。当时手头有几千条笔记,分散在三四个平台里,有手机备忘录、电脑上的Markdown文件,还有某个商业软件里的云文档。时间一长就出现三个痛点:第一是数据被锁死在各自格式里,导出出来的文件七零八落;第二是搜索体验参差不齐,换一台设备经常找不到自己要的内容;第三是多个平台之间同步经常产生冲突和重复,我分不清哪个才是最新版本。更让我不舒服的一点是,有些笔记涉及个人比较私密的信息,放在别人的服务器上始终心里不踏实。
基于这些原因,我希望的笔记系统必须满足几个条件:纯本地优先、数据格式开放、毫秒级搜索、跨设备可靠同步,以及尽量低的维护成本。市面上的工具各有特长,但我想要的这个组合一直没有遇到,所以干脆自己动手。mysol 这个名字一开始是“MySQL”的谐音梗,因为我最开始想用MySQL当存储,后来方案变了,但名字留了下来,后面干脆解释成 My Simple Outline Library。
1.2 整体方案定型:文件即笔记、索引即加速
mysol 的核心理念很简单:所有笔记的真实内容都存放在纯 Markdown 文件里,SQLite 数据库只承担索引和搜索的角色。也就是说,就算有一天数据库文件损坏,我拿一个文本编辑器仍然能读完所有笔记,这对长期维护来说非常关键。
目录结构大致是这样:
~/mysol/ notes/ 2024/ 2024-06-01-markdown语法小结.md 2024-06-02-项目复盘.md inbox/ 2024-06-03-临时想法.md index.db config.tomlnotes 目录下按年份和主题分子目录,每个笔记文件名里带上日期和 slug。index.db 存放标题、标签、路径、创建时间、修改时间以及全文搜索索引。所有核心操作都围绕这套结构展开,目录本身就是事实来源,数据库随时可以重建。
为什么不用数据库作为唯一存储?这里想多说一句。像 Notion 这类工具,你能看到的一切几乎都存在数据库里,一旦导出或者服务器出问题,整个信息结构就碎了。而以文本文件为中心,天然支持 Git 版本管理、diff 对比、批量替换脚本,甚至以后换软件也只需要写一个遍历目录的脚本就能搞定。我的原则是:工具可以换,数据必须死磕在自己手里。
1.3 技术选型对比与理由
方案层面我对比过几套组合,这里直接把结论列出来:
| 方案 | 优点 | 缺点 | 我的选择 |
|---|---|---|---|
| 纯文件系统 + Git | 最简单,可读性强 | 搜索要靠外部工具,标签体系弱 | 作为底层基础 |
| MySQL/PostgreSQL | 功能强、生态成熟 | 部署重、需要服务常驻、备份复杂 | 不选 |
| SQLite | 单文件、零配置、支持全文搜索 | 并发写一般,不适合多进程强写 | 作为索引引擎 |
| Go | 编译单二进制、交叉编译简单、标准库够用 | 生态相对于Python稍小 | 作为主语言 |
最终技术栈就是 Go + SQLite + Markdown 文件 + Git。Go 选它的直接原因是编译产物是一个无依赖的静态二进制,放到树莓派、旧笔记本、VPS 上都能跑,不需要装运行时。SQLite 则是因为笔记量级撑死几十万条,单机本地完全够用,而且它支持 FTS5 全文索引,中文场景配合 ngram 分词器也能有不错的效果。
这里也补充一句经验:不要一上来就做 Web 界面和自动同步,先把命令行工作流跑通,把底层数据模型定好。界面可以随时重写,数据模型错了返工成本极高。
2. 核心实现:数据模型与命令行日常
2.1 笔记的存储结构与数据库设计
mysol 的数据库表设计得很克制,整个 schema 只有三张核心表。notes 表保存每篇笔记的基本信息,包括唯一 ID、标题、slug、创建时间、修改时间、标签列表、文件相对路径等。另外用一张 virtual table 做全文索引。第三张表是 key-value 配置项,用来存索引版本号、最近同步时间等状态。
建表语句大概是这样的:
CREATE TABLE notes ( id TEXT PRIMARY KEY, title TEXT NOT NULL, slug TEXT UNIQUE NOT NULL, ctime INTEGER NOT NULL, mtime INTEGER NOT NULL, tags TEXT DEFAULT '', content_path TEXT NOT NULL, status TEXT DEFAULT 'active' ); CREATE VIRTUAL TABLE notes_fts USING fts5( body, tokenize = "ngram token_size 2" );ctime 和 mtime 都使用 Unix 时间戳保存,显示的时候再转成本地时区,这样不管在哪台设备上看,底层数据都不会因为时区问题出现混乱。tags 字段用逗号分隔,简单直接,一个标签体系刚开始不需要设计得多复杂,等后续确实需要层级标签再来演进。
写入流程是:先保存 Markdown 文件到磁盘,再往 SQLite 里插入索引记录,最后把正文内容同步到 FTS 表。整个流程用事务包住,任一环节失败都回滚,避免数据库记录和文件不一致。这里尤其要提醒一个坑:千万别把文件写在磁盘前先更新数据库,一旦文件写入中途断电或崩掉,数据库里就会出现一条指向空路径的记录,搜索能搜到但打不开,非常恶心。
2.2 CLI 命令设计与使用流
mysol 日常使用完全基于命令行,最核心的几个命令是 new、list、edit、search、sync。我尽量让每个命令都符合直觉:
mysol new "2024年6月阅读清单" --tag 阅读 --tag 2024 mysol list --tag 阅读 --sort mtime mysol edit 06f3a2b1 mysol search "SQLite FTS5" --limit 10 mysol sync push mysol sync pullnew 命令会根据当前日期自动生成合适的文件名,并把笔记丢进对应年份目录。如果时间信息不明确,就放进 inbox 目录,等有空再清理。edit 命令会调用$VISUAL环境变量里指定的编辑器,默认是 vim。list 命令输出格式可以用--format json来获得结构化数据,方便接到 fzf 等工具里做模糊查找。
这里想分享一个细节:每篇笔记的 ID 我用的是时间戳加随机数生成的一串短 ID,而不是数据库自增整数。好处是两个设备离线各自创建笔记时,ID 冲突概率几乎为零,这在 Git 同步场景里特别重要。如果当初用自增 ID,两台设备可能生成同样的主键,同步后会互相覆盖。
2.3 中文全文搜索的实现思路
SQLite 自带的 FTS5 默认分词器对中文几乎不可用,它会按照字节/英文单词来切分,搜一个双字词经常匹配不到。我在早期版本里踩了这个坑,后来切换成 ngram 分词器才解决。
ngram 的核心思路是把连续两个字作为一个 token,形成类似“笔记”“记系”“系统”这样的重叠二元组。这样搜索“笔记”时就能命中包含“笔记系统”的文档。建表语法在上面代码中已经给出,实测下来:
- 中文双字词搜索基本可用,响应时间在几十毫秒级别;
- 索引体积大约是原始文本量的 1.5 到 2 倍,对笔记这种量级可以忽略;
- 缺点是对“系统性”“系统化”这类词会产生一些无关匹配,但整体精度已经足够日常使用。
顺带说一句,用 ngram 做词元切分后,搜索时记得对用户输入也做同样的切分处理,否则可能因为 query 过长导致命中率下降。mysol 在内部把所有关键词拆成二元组后去 FTS 查,再用 SQLite 的 bm25 排序算法决定结果顺序,实际效果基本能达到“看到关键词就能搜到想要笔记”的水平。
3. 同步、备份与敏感信息处理
3.1 基于 Git 的多端同步链路
同步是我最开始觉得最麻烦、后来反而最省心的一部分。mysol 的同步机制不引入自有服务器,而是把整个笔记目录交给 Git 仓库管理,远程仓库可以是私有 GitLab、Gitea 或者任何一台你能 SSH 访问的机器。
工作流是这样的:写完一篇笔记后,mysol 会在终端进程空闲 15 秒后自动执行git add -A && git commit -m "update",把变更固化成本地版本。在另一台设备上,执行mysol sync pull就能拉取远端变更,再扫描一遍 notes 目录增量更新 SQLite 索引。
这套方案的好处非常明显:第一,每条笔记都有完整的版本历史,改错了能随时回滚;第二,不依赖任何特定云服务商,Git 仓库自己说了算;第三,同步记录是明文可读的,我能清楚知道哪次提交改了什么。坏处也不是没有,移动端处理 Git 相对麻烦,这个我在第 4 节会展开说。
实际操作中需要特别注意:永远不要把 index.db 提交到 Git 仓库。索引文件是二进制且每次重建都会产生大量变更,放进去只会造成无意义的冲突和仓库膨胀。我在 .gitignore 里固定排除 index.db 和临时文件,两边的设备只需要在代码层面保证:扫描目录后能够重建索引,数据就永远不会丢。
3.2 分条加密的敏感笔记方案
虽然笔记库是私有的,但保不齐哪天远端仓库的访问凭据泄露,或者旧机器硬盘被翻到,所以敏感内容必须单独处理。我的方案不是整库加密,而是对单条笔记用 age 工具加密,密文文件以.age后缀保存在同一目录结构里。
age 是一个简洁的加密工具,公钥加密、私钥解密,比传统 PGP 上手成本低得多。日常操作大致是这样:
# 生成密钥对并保存公钥 age-keygen -o ~/.config/mysol/key.txt # 加密一条敏感笔记 age -r age1xxxxxxxx... -o secret.md.age secret.md # 解密后通过分页器查看 mysol show secretmysol 的 show 命令会识别.age后缀,调用 age 解密后把内容通过 less 展示,退出后不会在终端留下明文。密钥文件默认权限设为0600并放到仅当前用户可读的目录。对需要更保险的场景还可以配置把密钥放到单独的硬件令牌后面的阶段,但那已经超出日常笔记维护范畴了。
为什么不整库加密?因为整库加密以后,Git 的 diff 和按文件粒度同步全部失效,每次修改都得解密整个库,体验太差。分条加密的核心优势是,日常笔记保持明文方便检索,只有真敏感的条目才单独上锁,两者互不干扰。
3.3 备份策略与恢复演练
同步解决了版本问题,但没解决灾难恢复问题。我的备份策略分三层:
- 第一层是 Git 远程仓库,多台机器之间的代码历史天然互为备份;
- 第二层是服务器上的每日定时快照,把整个 mysol 目录压缩后存到另一个物理磁盘;
- 第三层是每隔一段时间手动做一次冷备份,把笔记目录和密钥单独拷到移动硬盘里。
SQLite 的在线备份可以用官方命令:
sqlite3 index.db ".backup /backup/mysol/index-$(date +%F).db"这个命令能在数据库正常工作时生成一致性快照,不建议直接cp,因为文件可能在拷贝过程中被写入导致损坏。另外一定要定期做恢复演练,比如从备份目录恢复到一台临时机器上,确认所有笔记可读、搜索能跑起来。备份只有在你真正恢复成功过才算有效,光备份不查验,等于没备份。
4. 部署到常驻环境的实操记录
4.1 用 systemd 让 mysol 自动同步
mysol 的本职是命令行工具,但我希望在家里那台跑着 NAS 的迷你主机上有一个常驻进程,让它随时拉取远端状态、更新索引,这样手机端远程访问时拿到的总是最新数据。这里我用 systemd 写了一个用户级服务:
[Unit] Description=mysol sync watcher After=network.target [Service] User=mysol ExecStart=/usr/local/bin/mysol sync watch Restart=always RestartSec=30 EnvironmentFile=/home/mysol/.config/mysol/env [Install] WantedBy=multi-user.targetsync watch 子命令做的事情很单纯:每 30 秒检查一次本地是否有未提交的变更,有就执行提交;每 5 分钟执行一次git pull --ff-only;拉取后触发索引重建。由于是单进程,可以避免两个同步循环互相打架。注意在 EnvironmentFile 里通过环境变量传入访问令牌,不要在 unit 文件里写明文。
这套方案跑下来非常省心。家里那台机器通常只有几瓦功耗,噪音接近于零,7×24 小时挂着,基本不需要人工干预。如果未来增加了 Web UI 或移动端 API,这个常驻进程还能顺势扩展成轻量的本地服务。
4.2 编辑链路:命令行、Markdown 与移动端补全
我日常写笔记的工作流很简单:在电脑上打开终端,mysol new建一条,进入 Vim 开始写。因为所有笔记都是纯 Markdown,也完全可以同时在 Typora 这类编辑器里打开目录浏览,两种工具互相不冲突。尤其推荐在终端里配合 fzf 做一个快捷键,直接模糊查找标题后选中文案,回车就打开对应文件。
移动端这块我目前没有做完整的 App,用的是 Termux 加一个 shell 脚本。手机上的 Termux 配置好 SSH 密钥后,可以通过命名管道或者直接调用 SSH 在远程服务器上执行mysol new,把记录下来的内容写入远端仓库。语音转文字输入后随便整理一下标题就保存,十几秒就能完成一条速记。早期我也试过用 WebDAV 方案,但同步方向和数据格式控制都不如直接走 Git 来得干净。
实际体验三个月以后,我统计了一下笔记数量,大概新增了四百多条,搜索平均响应时间没有明显上升,依旧是毫秒级。一个明显变化是链路变短之后,记录东西的频率肉眼可见地提高了,碎片化的想法不再因为打开应用太慢而被放弃。这套系统不追求 UI 的华丽,图的就是“想到就能记、记完就能搜”。
4.3 运行数月后的真实体验
运行几个月后,mysol 给我最大的感受是“有掌控感”。从数据格式、存储位置、同步策略到备份方式,每一层逻辑我都清楚,出了问题也知道从哪下手。这和以前用商业笔记软件时“等官方修复”的感觉完全不同。
当然它也有缺点。最明显的是移动端体验仍然比较糙,没有原生 App 那种顺手感;其次是协作功能为零,如果有人想一起编辑同一份笔记,目前只能直接操作 Git 分支,对非技术用户不友好。还有一个容易被忽略的点:因为功能是自己写的,遇到 bug 必须自己想,修 bug 的时间也算维护成本。但这个成本相比它带来自由,我自己是完全可以接受的。
这套系统的可扩展方向也很明确:可以基于 FTS5 做标签自动聚合,可以把加密笔记的密钥接入操作系统钥匙串,也可以做一个简单的本地 Web 只读接口供浏览器搜索。不需要一次做完,有需求时一段段加就行。
5. 常见问题与排查技巧实录
5.1 中文乱码、换行符与时区
刚开始用的时候我在 Windows 笔记本上编辑过一篇笔记,提交后回到 Linux 机器上发现文件末尾全是^M,整个 git diff 看着非常痛苦。Git 默认可能会把换行符自动转换,但没有规则时很容易混乱。解决方案是为 mysol 仓库设置:
git config core.autocrlf input git config core.eol lf所有文本文件统一用 LF 换行,Windows 下提交时自动转成 LF,拉取回来就不会再有乱码问题。文件编码方面也统一成 UTF-8,写文件时强制指定编码,读文件时如果遇到非法字节就直接报错并提示该文件可能存在编码问题。
时区问题处理得比较早,底层所有时间都存 Unix 时间戳,展示时用time.Unix(ts, 0).Local()转换。有一点要特别说明:不要把本地时区字符串写进文件名,比如2024-06-01-08-30-笔记.md,因为换一台不同时区的设备后你很难判断这个时间是哪个时区的。文件里如果需要记录时间,直接写 ISO 8601 带时区的格式更安全。
5.2 SQLite 锁与并发写问题
mysol 后期加了一个很小的 Web 展示服务,结果第一次遇到 SQLite 的并发写问题:某个页面触发搜索时,另一条写入事务同时提交,日志里开始频繁出现database is locked。这个问题的根因在于 SQLite 同一时刻只允许一个写事务,Web 服务的多连接很容易造成竞争。
解决办法有三板斧,缺一不可:
db.SetMaxOpenConns(1) // 限制到单连接 db.Exec("PRAGMA journal_mode=WAL") db.Exec("PRAGMA busy_timeout=5000")WAL 模式让读和写可以并行,busy_timeout 让写入等待而不是立刻报错,SetMaxOpenConns(1)确保同一个进程内不会同时出现多个 SQLite 会话。加上这三条之后,再也没看到锁错误。经验就是:凡是用 SQLite 做 Web 或常驻服务的底层存储,这三个参数应当成为默认值。
另一个教训关乎“重建索引”。早期版本我用多 goroutine 并发扫描目录并写入 SQLite,结果总是触发约束冲突。后来把所有写操作收敛到单一 goroutine,通过通道排队执行,问题彻底解决。本质上还是要承认 SQLite 是一个嵌入式库,照顾它的性格比硬拧要省事太多。
5.3 Git 同步冲突的正确处理姿势
只要有多端同时修改,冲突就不可避免。最常见的冲突场景是:手机上用 SSH 快速改了一条笔记,电脑上也同时改了同一条,两边各自 commit 后 pull 时出现CONFLICT (content)。
很多工具的做法是建议你选“mine”或“theirs”,但笔记场景下这种二选一非常危险,很可能丢掉其中一边的新内容。mysol 的做法比较保守:发生冲突时,把两个版本分别保留,机器上自动生成file.conflict副本,然后在终端里提示用户手动合并。
我自己的处理流程是:
# 先查看冲突标记 git diff --name-only --diff-filter=U # 打开文件并逐一审视冲突块 vim notes/2024/2024-06-01-markdown语法小结.md # 合并完成后标记为已解决 git add -A && git commit -m "resolve conflict"这么做虽然偶尔会花几分钟手工处理,但相比之下换回的是零数据损失。千万切忌直接执行git checkout --theirs或--ours来盲目覆盖,尤其是离线时间长、两边改动都多的时候,该花的时间必须花。
6. 关于 mysol 笔记,最后最想说的几点
如果看完这些,你也有点想折腾一套自己的笔记系统,我的建议是先别追求功能完整,从最小闭环开始:一个能创建 Markdown 文件、一个能搜索、一个能同步,就够了。mysol 是我在这些需求上一点点长出来的系统,很多设计并不超前,只是处处贴合我自己的使用习惯。
造这个项目让我重新理解了数据所有权这件事。软件可以迭代、UI 可以替换、平台可以迁移,但只要数据还在自己手里,主动权就不会丢。过程中踩过的每一个坑——时区、编码、SQLite 锁、Git 冲突——后来看都变成了宝贵的经验,这些东西写在任何一本教科书里都不会比亲手踩一遍更让人印象深刻。
如果你也想走类似路线,我额外送一个小技巧:给新笔记起名时多用有结构感的 title,比如以“项目-日期-关键词”的方式组织,搜索之前先靠目录浏览也能快速定位。等哪天你积累到几千条笔记回头看,会发现当初的这个决定,比选哪一个工具更值得花心思。