数据 · · 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。
- 需要历史快照(“上个月的进度是多少”) → 那时候确实得存,因为时间点的投影无法从当前数据重算。
在此之前,多一份存储就多一份对不上的可能。一个人维护的系统,宁可算得慢一点,也不要修数据修到凌晨。