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] Descriptionmysol sync watcher Afternetwork.target [Service] Usermysol ExecStart/usr/local/bin/mysol sync watch Restartalways RestartSec30 EnvironmentFile/home/mysol/.config/mysol/env [Install] WantedBymulti-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_modeWAL) db.Exec(PRAGMA busy_timeout5000)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-filterU # 打开文件并逐一审视冲突块 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比如以“项目-日期-关键词”的方式组织搜索之前先靠目录浏览也能快速定位。等哪天你积累到几千条笔记回头看会发现当初的这个决定比选哪一个工具更值得花心思。
阅读完成 · 觉得有帮助?