ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

内部工具“艾非”复盘:SQLite FTS5检索与字段级同步实战

内部工具“艾非”复盘:SQLite FTS5检索与字段级同步实战

“艾非”这个代号,是某个内部工具项目立项时随手写下的两个字,本身没有任何业务含义,纯粹是候选名字全被占完了之后的临时替代。我把这个模拟项目当作案例来复盘,是因为它踩过的坑很典型:一句话需求、没有明确边界、技术选型凭直觉、上线前才发现同步逻辑是错的。这篇文章不聊概念,只把“艾非”从一句话需求走到能每天用的全过程摊开——需求怎么收敛成三条硬边界、检索方案为什么最后落在 SQLite FTS5、数据模型怎么长出来、多端冲突怎么兜底、性能瓶颈到底卡在哪一层。如果你手上也有一个还停在文档里的内部工具,或者正打算自己做个小而全的个人信息聚合工具,这里的取舍逻辑和可以直接跑的代码片段都能拿走。

1. 先把“艾非”这个词拆开看:代号背后的项目定位

1.1 为什么给项目起一个没有含义的代号

很多团队在立项阶段花在起名上的时间,比花在定义问题上的时间还多。名字里带“智能”“云”“助手”,反而会让所有人对它的预期发散——产品觉得是入口,研发觉得是引擎,运营觉得是流量池,最后谁也没说清楚它到底存什么数据、解决谁的什么动作。“艾非”这个无意义代号的最大价值,就是它在会议里不携带任何联想,逼着每个人只能用功能描述它,而不能用名词糊弄过去。我们当时的做法是:所有讨论必须落到动词上,比如“记录一件事”“找回一条内容”“改掉一个错字”,凡是说不出动词的需求,一律进待定池。这个规则听起来很粗暴,但它把第一版的功能清单从四十多项砍到了七项。这一步没有任何技术含量,却决定了后面所有架构决策的方向——因为你不可能为一个模糊的东西设计出清晰的表结构。

1.2 从一句话需求到三条硬边界

原始需求只有一句话:让个人零散的记录能被快速找回来。这句话里藏着一个陷阱——“快速”和“找回来”都是主观词。我们花了两个下午把它翻译成可验证的表述,最终收敛成三条硬边界。第一条是数据必须本地优先,断网状态下所有读写动作不能降级,这条直接排除了任何依赖远端服务的方案。第二条是首次检索的响应时间控制在 100 毫秒以内,注意是首次,冷启动也算,这条决定了不能把索引放在内存里慢慢加载。第三条是单设备数据量按十万条设计,超过十万条允许线性劣化但不允许功能失效。这三条边界写下来之后,后面几乎所有的技术选型都是被推着走的,而不是拍脑袋拍的。我后来养成一个习惯:任何项目在写第一行代码之前,先用一句话加上三个数字把它框住,哪怕这三个数字是猜的,也比完全没有坐标强。

1.3 谁在用、用在哪:把场景优先级排出来

场景排序这件事,最怕的是“所有人都能用”。我们把潜在使用场景列了十几条,然后按两个维度打分:发生频率和数据丢失后的痛感。高频且痛感高的只有两类:随手记一句话(多为待办、灵感、临时结论)和事后按模糊印象回溯(记得大概提过某个词,但记不住原话)。低频但痛感极高的是一类:某条重要记录被误删或误改,需要找回历史版本。剩下的场景,比如图表统计、多人共享、格式转换,全部划到第二期。这个排序带来的直接结果是数据模型里必须保留修订信息,而不是简单地覆盖更新——这一点在后来的同步设计里救了命。我见过太多工具在第二期才开始加版本历史,结果要重构整个写入路径,代价远超一开始多存一列的开销。场景优先级不是给老板看的 PPT 页,它是你决定哪张表要留冗余、哪条链路要加日志的真实依据。

2. 技术选型:为什么最后落在 SQLite FTS5 上

2.1 三种候选方案的实测对比

选检索方案的时候我们正经对比了三条路:纯内存索引、外部检索引擎、SQLite 自带全文索引。评价维度不只看查询速度,还看冷启动、写放大、部署复杂度和数据一致性。测试数据是八万条模拟记录,平均每条正文 200 字左右,跑在同一台普通笔记本上。

方案冷启动耗时单次查询 P95写入放大部署复杂度一致性风险
纯内存倒排索引约 2.4 秒(需全量加载)3 到 8 毫秒低低进程崩溃即丢索引
外部检索引擎约 0.6 秒(后台服务常驻)5 到 12 毫秒中高(独立进程+端口)需处理双写不同步
SQLite FTS5约 40 毫秒(按需打开)8 到 25 毫秒低极低(单文件)与主库同事务,天然一致

纯内存方案的查询确实最快,但它直接撞上了第二条硬边界——冷启动两秒多,用户点了搜索框还得等,体验上是断的。外部检索引擎的问题不在性能,在运维:一个本地优先的工具再挂一个常驻服务,安装包体积、端口冲突、版本升级全是负担。FTS5 的查询慢了几毫秒,但它和主数据在同一个文件、同一个事务里,这意味着“写入成功但索引没更新”这种经典脏数据问题从根上不存在。对一个只有一两个人维护的项目来说,消除一整类故障模式的价值,远大于几毫秒的响应差异。这个判断标准我后来反复用:在人力有限的项目里,优先选那些“少一种失败方式”的方案,而不是“快一点点”的方案。

2.2 中文检索:分词这道坎怎么绕过去

FTS5 默认的 unicode61 分词器对中文是按字切的,效果很差——搜“会议纪要”会命中一堆只含“会”字的无关内容。有两条路:接一个中文分词库,或者用 trigram。我们两条都试了。接分词库的做法是在写入前把正文切好词,用空格拼成一段“索引文本”存进辅助列,查询时同样切词。好处是召回准,坏处是分词库本身有十几兆,词典还得跟着版本走,而且新词、人名、缩写经常切错,切错就意味着搜不到,用户完全无从理解为什么搜不到。

最后选的是 SQLite 3.34 以后内置的 trigram 分词器,把索引列声明成这样:

CREATE VIRTUAL TABLE entry_fts USING fts5( title, body, content='entry', content_rowid='id', tokenize='trigram' );

trigram 把文本按三字符窗口切开建索引,中文和英文一视同仁,不需要词典,也没有新词问题。代价是索引体积大约是原文的 2 到 2.5 倍,八万条数据大概多占 40 兆,这个量级对本地工具完全可以接受。另一个代价是查询词长度必须不小于 3 个字符,两个字的词搜不出来。我们的处理办法是给两个字的短查询走一条兜底路径:先用 trigram 拿不到结果时,退回LIKE '%关键词%'扫一遍,八万条数据下大约 60 到 90 毫秒,虽然慢但能出结果,而且这种短查询占比不到一成。这个“主路径快、兜底路径慢但不缺”的组合,比强行追求一个方案覆盖所有情况要现实得多。

注意:启用 trigram 之后,MATCH里不要再用*通配前缀,trigram 不支持前缀查询,写了也不报错,只是静默返回空结果,排查起来非常费时间。

2.3 存储与索引同库带来的连锁收益

把索引和主数据放在一个文件里,好处远不止“少一个组件”。最直接的是备份——用户复制一个文件就等于备份了全部数据和索引,不需要导出再重建。其次是事务语义:一次写入要么数据和索引一起生效,要么一起回滚,中间不存在不一致窗口。第三是迁移,换机器的时候把文件拷过去,打开就能搜,不用等索引重建。我们做过一次压力测试:强行在写入过程中杀掉进程两百次,重启后校验索引与主表的一致性,零偏差。如果换成双写架构,这个测试几乎不可能通过,除非引入补偿任务,而补偿任务本身又需要一套状态机去追踪,复杂度是滚雪球式的。

当然也有代价。单文件意味着写入是串行的,高并发写会遇到锁等待。这个约束在个人工具场景下不是问题,但如果哪天要多进程同时写,就得靠 WAL 模式和忙等超时来缓解。我们在连接初始化时固定做了这几件事:

conn = sqlite3.connect(db_path, timeout=5.0) conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA synchronous=NORMAL") conn.execute("PRAGMA busy_timeout=3000") conn.execute("PRAGMA cache_size=-64000") # 约 64MB 页缓存

journal_mode=WAL让读写不再互相阻塞,synchronous=NORMAL在 WAL 下已经能保证崩溃一致性,同时省掉每次提交的 fsync 开销,批量导入时差距能到三倍以上。cache_size用负数表示按 KB 计,-64000 就是 64 兆,这个值我试过从 8 兆调到 128 兆,64 兆之后查询耗时的边际改善就很小了,再往上加只是白占内存。这些 PRAGMA 不是抄来的模板,每一条都对应一个具体的观测现象,改之前建议先记下基线数据,不然调完根本说不清是哪个参数起了作用。

3. 数据模型与索引结构:把“能搜到”变成“搜得准”

3.1 四张核心表撑起的最小闭环

“艾非”第一版只有四张表,到现在也没加过第五张。条目表存正文和元信息,标签表存标签名,关联表处理多对多,外加一张全文索引虚拟表。条目表里有两个字段值得单独说:uid和rev。uid是客户端生成的全局唯一标识,不用自增主键做跨端识别,因为多端离线创建时自增 ID 必然冲突。rev是修订号,每次内容变更加一,用来做冲突检测和历史追溯。

CREATE TABLE entry ( id INTEGER PRIMARY KEY, uid TEXT NOT NULL UNIQUE, title TEXT NOT NULL DEFAULT '', body TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, rev INTEGER NOT NULL DEFAULT 1, deleted INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX idx_entry_updated ON entry(updated_at DESC); CREATE INDEX idx_entry_deleted ON entry(deleted, updated_at DESC); CREATE TABLE tag ( id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE ); CREATE TABLE entry_tag ( entry_id INTEGER NOT NULL REFERENCES entry(id) ON DELETE CASCADE, tag_id INTEGER NOT NULL REFERENCES tag(id) ON DELETE CASCADE, PRIMARY KEY (entry_id, tag_id) ); CREATE TABLE entry_revision ( entry_id INTEGER NOT NULL, rev INTEGER NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL, saved_at INTEGER NOT NULL, PRIMARY KEY (entry_id, rev) );

deleted用的是软删除标记而不是真删。原因很实际:误删找回是高频痛感场景,硬删除之后就只剩数据库备份这一条路,而普通用户不会备份。软删除配合一个定期清理任务(比如只保留最近 90 天的删除记录),既满足了找回需求,也不会让表无限膨胀。索引idx_entry_deleted把deleted放在第一列,是因为列表查询永远带deleted = 0这个条件,把选择性低的列放前面看起来反直觉,但实测下来比放在后面少一次回表,十万条数据下列表首屏能快 15 毫秒左右。

3.2 增量索引与事务边界的写法

用content='entry'声明外部内容表之后,FTS5 不会自动跟着主表变化,需要自己维护。这里有个容易忽略的点:外部内容表模式下,删除和更新索引必须用专门的命令,直接DELETE FROM entry_fts会报错或者行为异常。正确做法是这样:

def upsert_entry(conn, uid, title, body, now): with conn: # 进入事务,异常自动回滚 row = conn.execute( "SELECT id, rev FROM entry WHERE uid = ?", (uid,) ).fetchone() if row is None: cur = conn.execute( "INSERT INTO entry(uid, title, body, created_at, updated_at) " "VALUES (?, ?, ?, ?, ?)", (uid, title, body, now, now), ) eid, rev = cur.lastrowid, 1 else: eid, rev = row # 更新前先落一份历史 conn.execute( "INSERT OR REPLACE INTO entry_revision(entry_id, rev, title, body, saved_at) " "SELECT id, rev, title, body, updated_at FROM entry WHERE id = ?", (eid,), ) conn.execute( "UPDATE entry SET title = ?, body = ?, updated_at = ?, rev = ? WHERE id = ?", (title, body, now, rev + 1, eid), ) conn.execute( "INSERT INTO entry_fts(entry_fts, rowid, title, body) " "VALUES ('delete', ?, ?, ?)", (eid, title, body) if row else (eid, '', ''), ) conn.execute( "INSERT INTO entry_fts(rowid, title, body) VALUES (?, ?, ?)", (eid, title, body), ) return eid, rev

上面这段里有个细节值得单独拎出来:'delete'那一条必须带上旧值,而不是新值。它依赖外部内容表的内容来做反索引,传新值会导致旧词残留,表现为“已经改掉的词还能搜到”。这个问题我调了大半天,因为查询结果看起来“只是多了一条”,很容易误判成查询逻辑的锅。更稳的写法是先按rowid查出当前索引里的旧内容再删,虽然多一次查询,但不会出错。另外整个函数包在with conn里,异常时数据和索引一起回滚,这也是单文件架构最实在的好处——换成双写,这段代码得加事务补偿。

3.3 排序打分与召回策略的调参记录

检索结果排序用的是 FTS5 内置的 bm25 函数。它的返回值越小越相关,这一点和很多人的直觉相反,第一次写排序的时候我把ASC写成了DESC,结果最不相关的内容排在最前面。权重方面,标题命中的价值远大于正文命中,实测把标题权重设成正文的 6 到 10 倍效果比较明显,最终定在 8 倍:

SELECT e.id, e.title, e.updated_at, bm25(entry_fts, 8.0, 1.0) AS score FROM entry_fts JOIN entry e ON e.id = entry_fts.rowid WHERE entry_fts MATCH ? AND e.deleted = 0 ORDER BY score ASC, e.updated_at DESC LIMIT 30;

ORDER BY里加了updated_at DESC作为第二排序键,作用是给同分结果一个稳定的兜底顺序,同时也隐含了“越近的记录越可能是我要找的”这个假设。这个假设在个人记录场景下命中率相当高。召回方面,我们把结果上限压到 30 条,不是因为性能,是因为超过 30 条用户基本不会往下翻,还不如把空间留给筛选条件。用户真正需要的是“第一屏里有没有”,而不是“能不能翻到第 200 条”。

加权系数的调法我踩过一个坑:一开始全凭手感调,调完觉得“好像好了一点”,但换个关键词就发现退化了。后来改成固定一套 40 条带标注的查询样本,每次调参跑一遍,记录前 5 条里正确结果的平均数量。这套样本很土,就是一个文本文件,一行查询、一行期望 ID,但它让调参从玄学变成了有反馈的实验,这个习惯建议每个做检索的人都养成。

4. 同步与冲突:多端写入下的一致性处理

4.1 为什么最终放弃“最后写入者胜”

多端同步的第一版用的是最朴素的时间戳方案:谁的时间戳大谁赢,整条记录覆盖。上线测试第三天就出了事故——用户在手机端改了一段正文,在电脑端只加了一个标签,两边几乎同时提交,结果电脑端的提交因为时间戳晚了 200 毫秒,把手机端刚改的正文整段覆盖回去了。这个场景的可怕之处在于用户完全无法理解:他只是加了个标签,为什么正文没了。

问题根源是整条记录粒度的覆盖,把不相关的修改也一起抹掉了。第二个问题是跨设备时钟不可靠,设备时间被手动修改过、时区跳变、休眠唤醒后时间漂移,都会让“谁更晚”这个判断失效。所以方案必须同时改两件事:把冲突粒度从整条记录降到字段级,以及不依赖墙钟时间做因果判断。

4.2 基于逻辑时钟的字段级合并

改动后的做法是:每个字段维护一对(值, 逻辑版本),逻辑版本由设备本地单调递增的计数器加上设备标识组成,比较时先比计数再比设备标识,保证在任意两端之间全序且不依赖真实时间。合并函数很直白:

def merge_fields(local: dict, remote: dict) -> dict: """local/remote 结构: {field: (value, (counter, device_id))}""" out = dict(local) for field, (val, ver) in remote.items(): cur = out.get(field) if cur is None or ver > cur[1]: out[field] = (val, ver) return out

这样手机端改了正文、电脑端改了标签,合并后两个改动都保留,因为它们的字段集合不相交。只有在同一个字段被两端同时修改时才真正冲突,此时按逻辑版本决出胜负,同时把失败的一方写进冲突日志。这个方案不是严格意义上的无冲突数据结构,它仍然是收敛到某个结果的策略,但对个人工具而言,“同字段并发修改”的概率极低,值得用这个简化换取实现成本的大幅下降。真要上完整的多值合并,光是文本类型的合并规则就够写一个月,收益在这个场景下并不成立。

注意:字段级合并要求存储层能表达“这个字段没改过”和“这个字段被改成空了”的区别。空字符串和NULL必须分开处理,否则清空正文的操作会在合并时被当成“未修改”而丢失。我们在写入前把空值统一转成一个显式的哨兵值,读取时再还原,这个坑不提前堵住,上线后一定出问题。

4.3 冲突日志与人工兜底

自动合并永远有覆盖不了的角落:两端改了同一段话的不同位置。这种情况下任何自动策略都会丢东西,所以必须留一条人能介入的通道。我们的做法是把冲突记录落进一张独立的表,字段包括条目 ID、冲突字段名、两个版本的值、发生时间。界面上不做弹窗打扰,只在条目详情里挂一个小标记,点开能对比两版并选择保留哪个。

这里的经验是:冲突处理界面的第一原则是“默认已解决”,不能让用户面对一堆待办。如果每次同步都跳出一个“有 3 个冲突需要处理”,用户很快就会关掉同步功能,那前面所有合并逻辑都白做了。所以我们的策略是能自动合的全自动,只有同字段冲突才进日志,并且进日志的同时就已经用了较新的版本,用户看到的是“这条记录发生过冲突,可查看旧版本”,而不是“你必须先选一个才能继续用”。这个默认值的取向看起来是小细节,实际上决定了功能会不会被用户主动关掉。

5. 性能与稳定性:从能用变到好用

5.1 写入吞吐的三次优化

第一次批量导入一万条模拟数据,耗时 47 秒,慢到没法接受。拆开看,时间几乎全花在两处:每条一次事务提交,以及每条一次索引插入。三次优化分别针对不同层次。第一次是把整批包进一个事务,耗时降到 21 秒。第二次关掉了导入期间的自动索引维护,改成批量导入后再统一重建,降到 6 秒左右。

def bulk_import(conn, rows): conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA synchronous=NORMAL") with conn: conn.executemany( "INSERT INTO entry(uid, title, body, created_at, updated_at) " "VALUES (?, ?, ?, ?, ?)", rows, ) # 外部内容表模式下,重建索引是独立一步 conn.execute("INSERT INTO entry_fts(entry_fts) VALUES('rebuild')") conn.execute("INSERT INTO entry_fts(entry_fts) VALUES('optimize')")

rebuild会按主表全量重建索引,optimize会合并索引内的分段结构,后者在反复增量写入之后对查询速度有可见改善,实测十万条数据下能省 10% 到 20% 的查询时间。第三次优化是把synchronous在导入阶段设成OFF,导完再切回NORMAL,降到 3 秒多。这一步有风险,因为期间断电会损坏文件,所以只在“新建数据库并导入历史数据”这种可以重来的场景下用,日常写入通道绝不能这么设。

5.2 冷启动与首屏检索的真实耗时

第三条硬边界是首次检索 100 毫秒内,这一条卡得最紧。冷启动的耗时构成大概是:打开数据库文件 5 到 15 毫秒(取决于文件大小和磁盘状态)、加载页缓存、执行查询、渲染结果。其中最容易失控的是页缓存——如果每次启动都全表扫一遍预热,文件大了之后耗时线性增长。

我们的做法是彻底不预热,查询时依赖索引直接命中,让操作系统自己去管文件缓存。实测在八万条数据、文件约 120 兆的情况下,冷启动后第一次查询的 P95 是 62 毫秒,热态稳定在 10 到 20 毫秒。有个反直觉的发现:把cache_size调得更大并不会改善冷启动,因为冷启动时缓存是空的,调大只是让它在后续查询中少读几次磁盘。真正对冷启动有效的是减少文件体积和减少需要扫描的页数,为此我们做了一件事——把超过 4KB 的长正文拆到独立的附属表,主表只留前 512 个字符用于列表展示和索引。这一改把主表体积压掉了六成,冷启动首查降到 40 毫秒上下。这个拆分后来还顺带解决了列表页加载慢的问题,属于一次改动两个收益。

5.3 本地工具也需要最小可观测性

本地运行的工具有个陷阱:出问题时你手里什么数据都没有。用户只会说“搜不出来”,你连他搜的是什么、耗时多久、有没有报错都不知道。所以哪怕只有一个人维护,也要埋最基本的观测点。我们只做了三件事:所有数据库操作记录耗时,超过 200 毫秒的写进慢日志;每次查询记录关键词哈希、返回条数、耗时;异常统一捕获后写本地日志文件,带滚动清理,最多保留 7 天。

这三件事加起来不到 150 行代码,但解决过一次很难复现的问题:某用户反馈“搜某些词会卡三秒”。日志里能看到他的关键词长度是 2,走的正是LIKE兜底路径,而他的数据量有二十多万条,远超设计时的十万条上限。定位到之后,处理办法是给兜底路径也加一个 trigram 索引的近似查询,而不是继续扫全表。如果当初没埋这个日志,这个反馈大概率会被归为“偶发卡顿”然后不了了之。观测不是为了好看的仪表盘,是为了让你在收到一句模糊反馈时,能在五分钟内定位到具体哪一行代码。

6. 上线前的自查清单与踩坑实录

6.1 常见问题速查表

下面这张表是我在上线前一轮全量回归时整理的,每一条都对应一次真实踩坑,不是凭空想的检查项。

现象最可能的原因排查动作处理方式
改掉的词还能搜到索引删除时传了新值而非旧值查该 rowid 的索引内容删除时带旧值,或先查后删
两个字的词搜不到trigram 最小长度限制看查询词长度走 LIKE 兜底路径
冷启动首查超过 1 秒启动时做了全量预热看启动阶段耗时分布取消预热,依赖索引
批量导入极慢每条一次事务提交数事务次数整批一个事务
同步后正文被覆盖整条记录粒度覆盖对比两端修改字段改字段级合并
清空正文的修改丢失空值与未修改未区分检查合并前的字段值用显式哨兵值表示空
索引体积暴涨反复增量写未合并段看索引文件大小定期执行 optimize
多进程写报 database is locked未设忙等超时看异常堆栈设 busy_timeout + WAL

6.2 几条我反复验证过的实操心得

第一条,先写验收脚本,再写功能代码。这个项目里我给自己定了个死规矩:每个功能点必须能用一个脚本验证,脚本放在版本库里,跑一次十秒内出结果。好处不在于测试覆盖率,而在于它逼你把“完成”定义清楚。比如“检索快”不是完成标准,“八万条数据下 P95 小于 100 毫秒”才是。没有这个约束,你会在自测时用三条数据点几下觉得挺好,然后被真实数据打脸。

第二条,索引相关的问题优先怀疑写入路径,而不是查询路径。我前后遇到的索引异常,八成都出在写入侧:删索引传错值、更新没触发重建、批量导入后忘了 rebuild。查询语句本身出错反而少见,因为查询错了通常表现得很明显(直接零结果),而写入侧出错是静默的——数据看起来正常,只是搜不到或者搜出旧内容,这种问题最消耗时间。所以排查顺序应该是:这条记录在索引里吗?索引内容和主表一致吗?一致了再去看查询。

第三条,别在第一个版本就上完整的同步。如果重来一次,“艾非”第一版我会只做单机,同步放到第二版并且先只做单向备份。多端同步引入的复杂度是数量级的——冲突模型、时钟、日志、界面兜底,每一样都能单独吃掉两周。这些复杂度只有在“确实需要多端”的前提下才值得付,而很多工具在第一版阶段根本没验证过单机场景下用户会不会真的天天用。

第四条,给数据留一份可读的导出格式。哪怕主存储是数据库,也要提供一个纯文本或结构化文本的导出。理由很朴素:用户对“我的数据是不是被锁死在你的格式里”这件事的担忧,会直接影响他愿不愿意把重要内容放进来。这个功能开发量很小,但对信任感的贡献远超投入。

第五条,参数调优前先记基线。这一条听起来像废话,但我吃过亏:调了五个 PRAGMA,性能好了 20%,结果过两周发现是数据量变了导致的,参数其实没起作用。后来每次调参我都把它记在一个表里:改了什么、改前什么数、改后什么数、样本量多少。这份记录现在是我最常翻的文档,因为真实数据比任何一篇经验帖都可靠。

最后再分享一个不太像技术点的经验。这个项目从立项到能日常使用,前后拉了大半年,中间有两次差点放弃,原因都不是技术难题,而是“想加的功能太多”。每次想加东西的时候,我就翻出最开始那三条硬边界看一遍:本地优先、首查 100 毫秒、十万条不失效。凡是会动摇这三条的新需求,一律推到下一版。这个动作救了这个项目——它让我在很长一段时间里只做一件事,而不是同时做十件都没做完的事。

返回列表