news 2026/9/18 6:34:20

MiroFish:本地优先文件镜像与SQLite FTS5检索实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MiroFish:本地优先文件镜像与SQLite FTS5检索实践

MiroFish 是我为了解决“东西明明存在、但我就是找不到”这件事写的一个本地优先的文件镜像与检索工具。故事的起点很具体:上周三下午,为了翻一份两年前的会议记录,我在三块硬盘、两个网盘目录和一堆散落的 Markdown 之间找了四十分钟,最后发现它躺在一个.docx里,文件名叫做“新建文档(3)”。那天晚上我就动手写代码,目标很朴素——让“找东西”这件事从四十分钟压缩到四秒钟。

这篇文章不打算做成一份说明书。我更想把它当成一次完整的项目复盘:从为什么砍掉 Elasticsearch,到增量镜像的指纹设计,再到文件监听踩过的三个真实事故,以及最后把检索体验调到“能用”的那些细节。如果你手头也有几万到几十万个散乱文件、对云端索引又不太放心,这套思路基本可以照抄;如果你只是想看看一个本地工具该怎么从零长起来,里面的取舍逻辑同样能用得上。

1. 为什么我要做 MiroFish:一个被“找东西”逼出来的本地镜像工具

1.1 我的真实痛点:三个硬盘、七种格式、一次找不到的会议记录

先把我面对的数据摊开来看,这样后面所有的设计决策才有参照。三块硬盘:一块是笔记本内置的 NVMe,容量 1TB,装的是近两年的活跃文件;一块是 4TB 的移动机械盘,用来放 2019 年以来的历史资料;还有一块 2TB 的老硬盘,里面是从更早的机器上整盘拷过来的东西,目录结构已经没人看得懂。格式上有 Markdown 笔记、.docx会议记录、.pdf技术文档、截图 PNG、代码片段.txt,以及若干.xlsx台账。

真正让我崩溃的不是数据量,而是命名信息的丢失。一块硬盘上有一千多个文件叫“新建文档”“未命名”“副本”,另一块上有几百个带日期的文件但日期是错的(从旧机器拷贝时 mtime 被重置成拷贝时间)。我的第一反应是装个桌面搜索工具,试了两个之后放弃了:一个默认索引全盘,包括系统目录和软件缓存,索引库三天涨到 8GB;另一个干脆不支持中文子串检索,“会议记录”四个字输进去,结果里全是包含“会”字的无关文件。

所以 MiroFish 的第一条需求是明确的:**只索引我指定的目录,索引体积要可控,中文必须能按子串命中。**第二条需求来自工作流——我经常在笔记本上写东西、在台式机上查东西,两边文件并不完全一致,我需要一个“镜像层”来告诉我:这个文件在两台机器上是不是同一份、哪一份更新、有没有出现过内容相同但路径不同的重复品。

1.2 MiroFish 这个名字背后的定位:mirror 加 fish

名字不是随便起的。mirror指向“镜像”——它不搬文件、不改文件、不占用你的原目录,而是在旁边维护一份关于文件的元数据映射:路径、大小、修改时间、内容哈希、所属设备、首次入库时间、最近一次确认时间。fish指向“捞”——在信息的海里把那条鱼捞出来,检索层负责这件事。

这个命名直接决定了两层架构:

  • 镜像层(mirror):负责扫描、指纹计算、变更识别、去重判定,输出一张稳定的文件元数据表。
  • 检索层(fish):基于元数据表和正文抽取结果建全文索引,负责查询解析、打分排序、结果定位。

两层之间用一张 SQLite 表解耦,镜像层写入、检索层读取,互不阻塞。这个解耦是我做对的第一个决策。早期版本我把扫描和建索引揉在一个循环里,结果是:一旦某个 PDF 抽取卡住,整个扫描进度就停了,重启之后还得重头再来。拆成两层之后,扫描只管记录“正文抽取待办”标记,抽取失败最多影响那一个文件的可检索性,不会污染镜像状态。

1.3 一句话说清它做什么,以及它明确不做什么

MiroFish 做的事,一句话概括:把你指定的若干目录镜像成一份本地索引,让你用关键词、路径片段、扩展名和短语组合,在几百毫秒内定位到具体文件。

它明确不做的事,同样重要,甚至更重要:

不做的事原因
不做云端同步我只要“知道文件在哪”,不需要“把文件搬来搬去”,同步带来的冲突解决成本远超收益
不做权限与多用户单人本地工具,引入用户体系只会让表结构和查询逻辑复杂一倍
不默认索引全盘系统目录、缓存目录、依赖包目录的文本几乎没有检索价值,却会吃掉 80% 的索引体积
首版不做向量语义检索语义检索需要模型与向量库,冷启动成本高;先用 BM25 把“精确回忆型”查询打到极致
不做内容修改只读扫描,任何情况下不写回源文件,出问题的概率因此降低一个量级

最后一条“只读”原则救过我一次。早期我写过一个“自动整理重复文件”的功能,把内容相同的文件移动到一个_duplicates目录。测试时因为哈希计算读到了半写入的文件,误判了两组不同内容的文件为重复,差点把一份正在编辑的文档挪走。从那以后我给自己立了规矩:MiroFish 的输出只有查询结果和报告,任何破坏性动作都交给人工确认后手动执行。

2. 选型复盘:为什么是 SQLite FTS5 而不是 Elasticsearch

2.1 先把数据量算清楚:你的索引到底有多大

选型不能凭感觉,得先算数。我按自己的实际情况估了一遍:

  • 待索引文件约 18 万个,其中文本类(Markdown、txt、代码、docx 抽取文本)约 11 万个,平均正文 3KB,合计约 330MB 纯文本。
  • 图片、视频、压缩包只入元数据,正文为空,约 7 万个,每条元数据记录平均 300 字节,合计约 21MB。
  • FTS5 索引的膨胀系数按经验在 1.5 到 3 倍之间(取决于分词粒度和是否存原文)。用 trigram 做子串支持时偏高,按 2.5 倍算,约 830MB。
  • 元数据表本身加索引,约 150MB。

总量落在1GB 上下,单机单文件完全放得下。这个数字直接判了 Elasticsearch 的死刑——它的 JVM 堆最低也要 1GB 起,再加上它自己的段文件,索引还没建完,资源开销已经是数据的五倍。而 SQLite 是进程内库,没有额外服务,内存占用基本等于 page cache 加上连接本身的开销。

这里有个通用的估算方法值得记一下:索引体积 ≈ 正文体积 × 分词膨胀系数 × 冗余系数。trigram 分词器会为每个 3 字符窗口建一个 token,中文一句话长度是 n,产生的 token 数量接近 n,所以膨胀系数天然比按词切分高一截。如果你的数据量是 100GB 级别,这个账就要重新算,SQLite 也会开始吃不消。

2.2 FTS5、Whoosh、Meilisearch、Elasticsearch 的横向对比

把候选方案摊在一张表里对比,比任何技术博客都直观:

方案部署形态常驻内存中文子串增量更新二进制体积结论
SQLite FTS5进程内库几乎为零(走 page cache)trigram 支持INSERT/DELETE 即可随 Python 分发选它
Whoosh纯 Python 库中等需自写分析器支持但段合并慢几 MB十万级文档后明显变慢
Meilisearch独立服务(Rust)100MB 起自带中文分词支持约 50MB过度设计,多一个要守护的进程
Elasticsearch独立服务(JVM)1GB 起需装分词插件支持数百 MB数据量不匹配,运维成本高

真正让我下定决心的是故障面。Elasticsearch 和 Meilisearch 都是独立进程,意味着我要处理:端口占用、开机自启、版本升级、数据目录权限、进程被系统 OOM 杀掉之后的恢复。而 SQLite 是一个文件,备份就是复制文件(在 WAL 模式下需要先做 checkpoint 或直接用.backup命令),迁移就是拷贝,重装系统就是重新指个路径。

提示:如果你的场景是多人协作、需要分布式检索、或者单索引超过几十 GB,上面这个结论不成立,老老实实上独立服务。选型的关键从来不是哪个更强,而是哪个的失败模式你能接受。

2.3 中文分词这个绕不过去的坎:trigram 方案与 jieba 预分词的取舍

SQLite FTS5 默认的unicode61分词器对中文是很不友好的。它按“非字母数字字符”切分,而所有汉字都被视为字母,于是一整句中文会被当成一个巨大的 token。你搜“会议记录”时,只有当某个文件的整句文本恰好等于或前缀匹配这个 token 才可能命中,实际效果约等于没有。

我试过两条路。

第一条是预分词:用 jieba 把正文切成词,词之间用空格连接,再交给unicode61索引。

import jieba def tokenize_for_fts(text: str) -> str: words = jieba.cut_for_search(text) # 搜索引擎模式,长词会再切出短词 return " ".join(w for w in words if w.strip())

这条路的优点是索引体积小、查询速度快、能拿到词级 BM25 权重。缺点是依赖分词词典:如果我搜“镜像工具”,而原文里是“镜像文件工具”,切词结果里可能出现“镜像 / 文件 / 工具”,查询词“镜像工具”不一定能切出匹配的单元。更麻烦的是专业名词、项目代号、人名,词典里没有就会被切碎。

第二条是trigram 分词器,SQLite 3.34 之后内置:

CREATE VIRTUAL TABLE doc_fts USING fts5( path, body, tokenize = "trigram case_sensitive 0", detail = full );

trigram 会把文本切成所有长度为 3 的字符窗口,因此任意长度大于等于 3 的子串都能命中,“镜像工具”这种组合词直接搜就行,不需要任何词典。代价有三个,我实测过:索引用体积大约是原文的 2.5 到 3 倍,写入速度比unicode61慢约 40%,以及少于 3 个字符的查询无法用索引命中

第三个代价是最容易翻车的。用户搜“AI”或者“CP”这种两个字符的词,MATCH会直接返回空,而且不报错。我的处理办法是在查询解析层做判断:如果查询词长度小于 3,就走LIKE '%xx%'扫表,虽然慢,但至少能出结果;同时给这类查询加一个结果上限,避免全表扫描把响应时间拖到几秒。

def build_query(q: str) -> tuple[str, list]: tokens = [t for t in q.split() if t] if any(len(t) < 3 for t in tokens): # 短词退化为 LIKE 扫描,并限制返回条数 where = " AND ".join("body LIKE ?" for _ in tokens) params = [f"%{t}%" for t in tokens] return f"SELECT path FROM file_meta WHERE {where} LIMIT 200", params return "SELECT path FROM doc_fts WHERE body MATCH ? LIMIT 200", [" ".join(tokens)]

最后我的选择是混合方案:正文列用 trigram 建索引,保证任意子串可查;额外加一列body_seg存 jieba 分词结果,只对高频词做加速。两套索引让总体积涨到了 1.4GB 左右,换来的是“不管我怎么搜都能出东西”。这笔交易我觉得值。

3. 增量镜像的核心机制:文件指纹、去重与状态机

3.1 用 mtime 加 size 做第一层过滤,用内容哈希做第二层

全量哈希 18 万个文件要多久?我在两块盘上实测过:NVMe 上平均 40 秒,机械盘上接近 4 分钟,而且这期间磁盘几乎被占满,其他操作都会卡。所以每次扫描都全量哈希是不可接受的,必须做分层判断。

第一层看(mtime, size)这对组合。如果两者都没变,直接跳过,不读文件内容。这一层能过滤掉 95% 以上的文件,扫描时间从分钟级降到秒级。

第二层只在第一层判定“可能变了”时触发,计算内容哈希。我一开始用 SHA-256,后来换成 BLAKE2b,速度提升约三成。再后来发现对于“是否变化”这个判断,其实连加密哈希都不需要,用 xxHash64 就够了——碰撞概率在几十万文件规模下可以忽略,而且速度快十倍。

import xxhash, os from pathlib import Path def quick_fingerprint(p: Path) -> str: st = p.stat() h = xxhash.xxh64() # 头部 64KB + 尾部 64KB,兼顾大文件速度和准确度 with open(p, "rb") as f: h.update(f.read(65536)) if st.st_size > 131072: f.seek(-65536, os.SEEK_END) h.update(f.read(65536)) return f"{st.st_size}-{h.hexdigest()}"

这里有个真实的坑:只读头部会漏掉“文件头没变、尾部追加内容”的情况,比如日志文件;只读尾部则会漏掉“头部被改了”的情况。所以我用头尾各 64KB 的组合指纹,再加上size一起参与判断。只有当size没变、但指纹变了,才升级到全文件哈希。这套逻辑上线之后,实测误判率为零,扫描窗口从 4 分钟压到了 8 秒左右。

3.2 相似度去重:SimHash 与分块哈希该怎么选

去重这件事要分两个层次问清楚:你要找的是完全相同的文件,还是高度相似的文件?这两个问题的解法完全不同。

完全相同,用内容哈希做键即可,一张hash -> [paths]的映射表,GROUP BY hash HAVING COUNT(*) > 1就能把所有重复组列出来。这部分没有任何歧义。

高度相似,就要上 SimHash。它的原理是把文本特征映射成 64 位指纹,相似文本的指纹只有少数几位不同,因此可以用汉明距离快速比较。我用 3-gram 词频作为特征,64 位输出,汉明距离阈值设 3。经验数据是:阈值 3 能抓住“同一份文档改了几句话”的情况,误报率很低;阈值提到 6 之后,同主题的不同文档开始被误判成重复。

def simhash64(tokens: list[str]) -> int: v = [0] * 64 for tok in tokens: h = int(xxhash.xxh64(tok).hexdigest(), 16) for i in range(64): v[i] += 1 if (h >> i) & 1 else -1 out = 0 for i in range(64): if v[i] > 0: out |= 1 << i return out

至于分块哈希(把文件切成固定块或内容定义块,逐块做哈希),我最后没有用。它的价值在于跨文件复用存储块,适合备份和同步场景;而 MiroFish 不搬文件、不存内容,只需要回答“这两份是不是同一份”,SimHash 加完整哈希已经完全够用。多引入一层分块逻辑,只会让“为什么这两个文件被判为重复”这个问题变得难以解释。

提示:SimHash 这类近似算法一定要留一个人工复核入口。我的做法是把候选重复组输出成一份 Markdown 报告,每组列出路径、大小、SimHash 距离,让人看一眼再决定。算法负责把 1 万组候选缩到 200 组,人负责最后拍板,这个分工最省心。

3.3 状态机设计:新增、修改、删除、移动、重命名怎么区分

扫描的难点不在于发现变化,而在于给变化定性。同一个“旧路径消失、新路径出现”的现象,可能是删除加新增,也可能是重命名,还可能是移动到了别的目录。如果定性错了,索引里就会留下一堆永远指向不存在路径的僵尸记录。

我的做法是把文件身份和路径解耦。身份用一个稳定的键表示,路径只是身份的一个属性。

CREATE TABLE file_meta ( file_id INTEGER PRIMARY KEY, dev INTEGER NOT NULL, inode INTEGER NOT NULL, path TEXT NOT NULL, size INTEGER NOT NULL, mtime REAL NOT NULL, fingerprint TEXT, content_hash TEXT, last_seen INTEGER NOT NULL, missing INTEGER DEFAULT 0, UNIQUE(dev, inode) ); CREATE INDEX idx_path ON file_meta(path); CREATE INDEX idx_hash ON file_meta(content_hash);

状态迁移的判断逻辑,按优先级从高到低:

现象判定条件处理方式
新增(dev, inode)未出现,路径未出现插入新记录
修改(dev, inode)已存在,指纹变化更新 size、mtime、指纹,标记正文待重抽
重命名或移动旧路径消失且新路径出现,content_hash相同更新 path,保留 file_id,不动索引条目
删除旧路径消失,同哈希的其他路径也不存在标记missing = 1,不物理删除
复制新路径出现,content_hash与已有记录相同但 inode 不同新增记录,标记为副本候选

这里有三个刻意的设计。第一,删除只做标记,不做物理删除。因为误判的代价太高:如果某次扫描时外部硬盘没挂载,所有记录都会被判定为删除,物理删除之后恢复起来极其痛苦。标记为missing之后,索引里的内容还在,只是结果列表里排在后面并标注“文件当前不可见”。

第二,重命名判定优先于删除加新增。因为重命名场景下文件内容没变,没必要重新抽取正文和重建索引,直接改路径是最省事的。判断依据就是内容哈希相同、且旧路径在本轮扫描中消失。

第三,(dev, inode)而不是路径做主键。Linux 和 macOS 上,inode 在同一设备内唯一,重命名不会改变它,所以“同一个 inode 换了路径”天然就是重命名。Windows 上没有 inode 这个概念,我用file_idvolume_serial的组合替代,逻辑一致。这个细节如果不处理,跨平台版本会在文件重命名时产生大量重复记录。

4. 文件监听这块最容易翻车:inotify 的三类真实事故

4.1 编辑器“原子保存”导致文件被误判为删除

第一类事故最隐蔽,也最消耗时间。我写完监听模块之后,发现每次在编辑器里保存一个 Markdown 文件,MiroFish 都会报告“文件被删除,然后新增了一个同名文件”。索引条目被删了又建,正文被重复抽取,日志里刷满了无意义的变化记录。

排查过程分三步。第一步,先把原始事件流打出来看。

from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler class RawLogger(FileSystemEventHandler): def on_any_event(self, event): print(f"{event.event_type:12} src={event.src_path} " f"dst={getattr(event, 'dest_path', '')} dir={event.is_directory}")

日志一出来,问题立刻清楚了。一次保存产生的事件序列是:

created src=/notes/.tmp_8f3a2.md modified src=/notes/.tmp_8f3a2.md moved src=/notes/.tmp_8f3a2.md dst=/notes/meeting.md deleted src=/notes/meeting.md

大多数现代编辑器和工具链在保存时都是这个套路:先写临时文件,写完通过rename原子替换目标文件。对文件系统来说,meeting.md这个路径上的 inode 被换掉了——旧 inode 被删除,新 inode 顶上来。所以监听层看到的就是“删除 + 移动”。

我的处理分两层。监听层把moved事件直接翻译成“目标路径发生变化”;扫描层在收到“删除”通知时不立即标记missing,而是延迟 2 秒再做一次确认扫描——因为绝大多数原子保存的后续事件会在毫秒级内到达。两秒之后如果路径确实不存在了,才标记为缺失。

这个“延迟确认”的思路还顺手解决了另一个问题:下载工具写大文件时会持续产生modified事件,每次事件都触发重抽正文是巨大的浪费。延迟窗口内的重复事件会被合并成一次处理。

4.2 inotify watch 数量上限与递归监听的性能陷阱

第二类事故是在我把监听目录从 3 个扩到 20 个之后爆发的。程序启动几秒后直接抛异常,提示达到 inotify 监视数量上限。查下来原因是:inotify 是按目录建立监视的,不递归。要监听一棵有 20 万个子目录的树,就需要 20 万个 watch,而系统默认上限通常是 65536 或 81920。

# 查看当前上限 cat /proc/sys/fs/inotify/max_user_watches # 临时调高 sudo sysctl fs.inotify.max_user_watches=524288 # 持久化 echo 'fs.inotify.max_user_watches=524288' | sudo tee /etc/sysctl.d/99-inotify.conf

但把上限调高只是治标。每个 watch 都要占内核内存,20 万个 watch 会吃掉几百 MB 的内核内存,而且目录一多,事件分发本身的延迟也会上升。所以我引入了混合策略

  • 热目录走监听:最近 30 天有写入、或者我日常在用的少数几个目录,挂 inotify,做到秒级感知。
  • 冷目录走对账:其余目录不挂监听,靠定时全盘扫描发现变化。
  • 目录增删同步维护 watch:监听到createdis_directory为真时,动态给新目录挂 watch;监听到目录删除时,连带移除子树下的 watch。

对账频率我设成冷目录 6 小时一次、热目录 1 小时一次作为兜底。实测下来,日常使用中感知不到延迟,而 watch 数量从 20 万降到了 1.2 万左右。

4.3 事件风暴与去抖动队列的设计

第三类事故来得最猛:我在被监听的目录里解压了一个源码压缩包,三秒钟内产生了 4 万多个事件(而且是每层目录各一个 created 事件,逐层上报),程序直接把 CPU 打满,事务不断提交,WAL 文件涨到几百 MB。

根因是每个事件都独立走了一次数据库写事务。修复方案很直接:引入去抖动队列,把事件按路径聚合,在固定时间窗口内合并成一批。

import time, threading from collections import OrderedDict class DebouncedQueue: def __init__(self, flush_interval=0.8, max_batch=2000, sink=None): self.pending = OrderedDict() # path -> (event_type, ts) self.interval = flush_interval self.max_batch = max_batch self.sink = sink self.lock = threading.Lock() def push(self, path: str, ev: str): with self.lock: self.pending[path] = (ev, time.time()) if len(self.pending) >= self.max_batch: self._flush_locked() def _flush_locked(self): batch = list(self.pending.items()) self.pending.clear() self.sink(batch) # 用一个事务批量写入 def run(self): while True: time.sleep(self.interval) with self.lock: if self.pending: self._flush_locked()

配合批量写入时的PRAGMA调优,4 万个事件的入库时间从几十秒降到了 3 秒出头。这里的关键参数是flush_interval:设得太小,合并效果差;设得太大,用户刚保存完文件去搜会搜不到。0.8 秒是我反复调整后手感最好的值——保存后一次呼吸的时间就能搜到,同时足以把解压、git checkout这类突发写入合并掉。

提示:事件风暴期间一定要限制单批大小。我在push里加了max_batch,超过阈值立刻落盘,避免内存里的 pending 字典无限增长。这个保护在遇到递归符号链接时尤其重要。

5. 从能跑到好用:检索体验与工程化收尾

5.1 查询语法设计:字段限定、模糊匹配与结果排序

工具能跑起来之后,真正决定我愿不愿意天天用它的是检索手感。我参考了熟悉的搜索语法,做了一套轻量前缀解析器:

语法含义示例
普通词正文或路径命中会议记录
"..."短语精确匹配"季度预算评审"
path:xx仅匹配路径path:2023/项目A
ext:xx按扩展名过滤ext:md ext:txt
size:>1M按大小过滤size:>500K
-xx排除会议 -模板

排序是另一个需要打磨的地方。纯 BM25 排序在实际使用中有个明显问题:一篇很长的文档因为词频高而排在前面,但我要找的可能只是一个短小的笔记。我的做法是给 BM25 分数乘上几个修正因子:

SELECT f.path, bm25(doc_fts, 2.0, 1.0) * (1.0 / (1 + LENGTH(f.path) / 120.0)) AS score FROM doc_fts JOIN file_meta f ON f.rowid = doc_fts.rowid WHERE doc_fts MATCH ? AND f.missing = 0 ORDER BY score LIMIT 50;

bm25(doc_fts, 2.0, 1.0)里的两个权重分别对应path列和body列,路径权重给到 2.0 是因为我发现“我想找文件名里带关键词的文件”这个需求非常高频。后面那个LENGTH(f.path)的修正因子是我自己加的:路径越短,通常代表这个文件越靠近项目根目录、越可能是主文档而不是某个深层的临时产物。

实测效果是,搜“MiroFish 设计”时,/notes/mirofish-design.md稳定排第一,而不是某个引用过这个项目名的超长会议记录。这种“排第一的就是我要的”体验,比多十种查询语法都重要。

5.2 首次全量索引的性能实测与调优

首次建索引是最耗时的一步,也是最容易让人放弃的一步。18 万个文件,我的第一版跑了 27 分钟,优化后压到 4 分半。主要的提速点有四个。

第一,写事务批量化。每条记录一次事务时,SQLite 要为每次提交做 fsync,这是最大的瓶颈。改成 5000 条一批之后,写入吞吐提升了接近 20 倍。

第二,PRAGMA 参数调整。建索引期间用的是这一组:

PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA cache_size = -64000; -- 约 64MB page cache PRAGMA temp_store = MEMORY; PRAGMA mmap_size = 268435456; -- 256MB 内存映射

synchronous = NORMAL在 WAL 模式下是安全与速度的平衡点:崩溃时可能丢失最后一批事务,但数据库不会损坏。对可重建的索引来说,这个代价完全可以接受。

第三,正文抽取并行化。文件读取和文本抽取是 IO 与 CPU 混合型任务,我用线程池做抽取、单线程做数据库写入,避免多线程争抢写锁。

第四,跳过无价值目录。我把.gitnode_modules__pycache__、各种缓存目录、以及大于 20MB 的文本文件全部排除。仅这一条就砍掉了约 30% 的文件数量和一大半的处理时间。

建完之后的日常维护只需要PRAGMA optimize;定期跑一次,让 SQLite 自己决定要不要更新统计信息。

5.3 打包部署:单文件分发与定时对账任务

最后一公里是让它安静地跑在后台。我的部署形态非常简单:一个常驻进程负责监听和增量更新,一个 cron 任务负责冷目录对账。

常驻进程用 launchd(macOS)和 systemd(Linux)各写了一份单元文件,关键是设置自动重启和资源限制,避免它出问题之后吃掉整机内存。日志按天轮转,只保留 14 天,因为监听类程序的日志会疯长。

打包这块我试了两条路。PyInstaller 打成单文件最省事,缺点是每次启动要解压一遍,冷启动接近两秒,而且体积从源码的几百 KB 涨到 15MB 左右。最后我选了更朴素的方式:用虚拟环境加一个入口脚本,配合 shell 别名mf直接调用。启动时间 80 毫秒,更新就是git pull,对我这种单人自用场景来说更顺手。

跨设备这块我没有做实时同步,理由是 SQLite 文件在并发写入下容易出现锁冲突,而跨设备的因果顺序很难保证。我采用的方案是:每台机器各自维护一份本机索引,各自独立扫描;对需要跨设备比对的目录,额外输出一份“路径、大小、内容哈希”的清单文件,两台机器各导一份,用一个独立命令做比对。这样既拿到了跨设备的重复检测能力,又完全回避了分布式一致性问题。

定时对账任务的内容也很简单:

# 每天凌晨 3:20 对冷目录做一次全量对账 20 3 * * * /path/to/mirofish scan --profile cold --quiet >> ~/.mirofish/cron.log 2>&1 # 每小时清理一批已确认超过 90 天不可见的记录 5 * * * * /path/to/mirofish prune --older-than 90d --yes >> ~/.mirofish/cron.log 2>&1

prune命令是我后来补的。因为删除只做标记、不做物理删除,长期运行之后missing = 1的记录会累积。清理策略刻意设得很保守:只有连续 90 天所有扫描都确认不可见,才真正删除记录。外部硬盘一个月插一次也不会有问题。


最后分享两个我实际用下来最有用的细节。一个是给索引加一个“最近打开”字段:MiroFish 自己不知道你打开了哪个文件,但我用了一个很土的办法——把检索结果的第一条记录写进一个recent表,时间久了之后,“最近搜到并定位过的文件”本身就是一个极好用的时间线,比文件系统的 mtime 靠谱得多,因为它记录的是我什么时候真正关心过这个文件。另一个是把扫描报告存成 Markdown 而不是日志:每次全量扫描之后,新出现的重复组、疑似重命名的条目、体积异常增长的文件,全部列成一份带链接的报告。日志我从来不看,但这份报告我每周会翻一次,好几次都是靠它发现了误放到笔记目录里的临时文件。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 6:31:29

NHANES加权分析完整指南:从survey设计到R实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 6:30:41

机器学习入门核心术语详解:从特征到泛化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 6:30:39

元数据管理:从数据沼泽到数据资产的关键技术

1. 元数据管理&#xff1a;从数据沼泽到数据资产的关键跃迁在数字化转型浪潮中&#xff0c;企业数据量呈现指数级增长。根据IDC最新预测&#xff0c;到2025年全球数据总量将达到175ZB&#xff0c;而企业数据利用率却不足30%。这种"数据丰富但知识贫乏"的困境&#xf…

作者头像 李华
网站建设 2026/9/18 6:30:10

Modbus TCP调试避坑指南:从IP到寄存器的常见通信故障排查

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 6:29:29

SpringBoot+Vue构建高校图书馆管理系统中台架构实践

1. 项目概述这个图书馆管理系统是我去年为一个高校图书馆开发的实战项目&#xff0c;采用目前企业级开发中最主流的SpringBootVue前后端分离架构。系统上线后日均访问量稳定在3000&#xff0c;经受住了开学季借阅高峰的考验。相比传统PHP或JSP方案&#xff0c;这套技术栈在并发…

作者头像 李华