山月集 SHANYUE

数据

文章

数据 2 分钟阅读

不存状态:一个钓鱼图鉴的取舍

解锁、进度、纪录链,三个看起来必须落库的东西,其实全是钓获记录的投影。

给钓鱼图鉴建表的时候,最自然的写法是把”状态”也存下来:

-- 看起来很正常,其实是第二份真相
CREATE TABLE species_state (
  species_id  TEXT PRIMARY KEY,
  unlocked    INTEGER NOT NULL DEFAULT 0,
  unlocked_at TEXT
);

问题不在性能,在一致性。一旦有了 unlocked,它就可能在某个时刻和 catches 表说的不是同一件事:删掉一条记录忘了回滚标志、补录一条忘了置位、两处写入竞争。任何”状态”字段,都是未来某个 bug 的住址。

三个派生量

这个模块里所有看起来像状态的东西,其实都是钓获集合的投影:

看起来要存实际来源
是否解锁该鱼种是否存在钓获记录
收集进度已解锁的可钓种数 ÷ 可钓种总数
重量纪录链按时间扫描,只保留刷新过最好成绩的那些

所以数据模型只剩两样:鱼种(Git)+ 钓获(一张表)。派生逻辑写在一个纯函数文件里,图鉴页、详情页、流水页、写入接口全都调它。

纪录链是最能看出差别的一处

“个人纪录”听起来是个字段,其实是一条链。

按 (caughtAt, createdAt, id) 升序扫描某个鱼种的全部钓获,只有严格大于此前所有重量的那一条才进链。第一条必然入选。并列重量不进链——否则”破纪录”这个词就失去意义了。

这条定义带来几个反直觉但正确的行为:

  • 改一条历史记录的重量,整条链会重算,可能某一条就从链上”消失”了。
  • 删掉链上某条,后面那条会补位。
  • 补录一条更早的旧鱼,链头可能直接变。

这些不是 bug,是不存状态必然的代价:你没法给一条派生出来的链做增量更新。但收益是——你永远不会遇到”链上说 2.3 斤,流水里找不到这条鱼”。

什么情况下该改主意

说清楚触发条件,这套设计才算完整:

  • 钓获超过几万条,页面计算超过 200ms → 引入物化视图,但仍然只物化、不手写。
  • 需要多用户 → 派生逻辑要按用户分区,纯函数的入参得加上 owner。
  • 需要历史快照(“上个月的进度是多少”) → 那时候确实得存,因为时间点的投影无法从当前数据重算。

在此之前,多一份存储就多一份对不上的可能。一个人维护的系统,宁可算得慢一点,也不要修数据修到凌晨。