状态、历史与一致性
数据库里那个「已毕业」的布尔值,可能来自三种完全不同的过程,而你分不出来。
- 只保存当前状态会丢掉什么;
- 当前状态里常常混着历史事实、规则与人的决定;
- 事件溯源:保存发生过什么,再用它算出状态;
- 投影、重放,以及重放时为什么不能重复对外界动手;
- 把一致性从「多份状态同步」转移到「这段计算是否正确」。
一、一个布尔值背后的三件事
假设一个教务系统里,学生的记录上有一个字段 can_graduate = true。看起来很清楚:这个学生满足毕业条件。
但这个 true 是怎么来的?至少有三种可能:
- 按规则算出来的:学分够了、必修课都过了、毕业设计通过了,程序自动判定。
- 人工特批的:学生转专业时少了一门课,学院批准用另一门课替代,教务员手动改了数据库。
- 救火改的:学生马上要离校,系统还没实现新的替代规则,工程师直接把字段改成
true。
三种情况在数据库里完全一样。几个月后如果这个字段被质疑,没有人能说清它当时为什么是 true。
二、只存当前状态会丢掉三类信息
把系统理解成「当前状态的快照」,是最直观的设计:数据库里有学生表、课程表、选课记录表,每个功能去读写这些表。 这种思路叫 CRUD,即创建、读取、更新、删除。它对简单的信息维护非常有效。
一旦业务跨越很长时间,它就会丢东西。丢的主要是三样:
| 丢掉的东西 | 具体后果 |
|---|---|
| 这件事为什么发生 | 一个状态改变之后,无法解释当时的依据。上级来查,只能靠某个人回忆。 |
| 哪些结论依赖它 | 成绩更正之后,不知道哪些学分认定、哪些毕业审核需要用新成绩重算。 |
| 什么时候知道的 | 业务本身在 6 月 1 日生效,系统到 6 月 3 日才录入。想知道「6 月 2 日该不该放行」,只存一个时间答不出来。 |
表格第三行值得单独说明。业务发生的时间与系统得知的时间是两个不同的时间。前者决定规则适用哪一版,后者决定当时系统手里有什么信息。 只存一个时间戳,等于把这两个问题混成一个。
三、事件溯源:保存发生过的事
另一种做法是把系统接受过的每一件事依次记录下来,当前状态由这些记录算出来。这种做法叫事件溯源。
以校园卡为例:先充值 100 元,再消费 12 元,再退款 12 元。这三种记录下来,当前余额是 100 元。 另一种情况是只充值 100 元,余额也是 100 元。
两种情况的余额相同,历史不同。如果只存余额,你无法区分这个学生是没消费过,还是消费后又退款了。 而这件区别在核对账目、处理争议、分析消费习惯时都要用到。
flowchart LR A["事件 1
充值 100 元"] --> B["事件 2
消费 12 元"] B --> C["事件 3
退款 12 元"] C --> D["投影
当前余额 100 元"] C --> E["另一投影
本月消费记录"] C --> F["另一投影
退款率统计"]
这里有两个新词。把一串事件转换成某种查询结果的过程叫投影(projection)。 同一个事件序列可以投出很多种视图:当前余额、本月消费明细、退款率。它们来自同一份事实,不必各自维护一份。
重新把已有事件跑一遍的过程叫重放(replay)。系统坏了、规则改了、需要重新核对,都可以重放。 日常为了查询速度,可以维护好当前结果,来了新事件就增量更新;需要重建时再从某个快照继续往后跑。
四、重放不能重复对外界动手
重放有个必须守住的边界:它只能重新计算内部状态,不能把已经完成的外部动作再做一次。
举几个会出事的例子:
- 重放消费记录算余额时又扣一次钱。真实扣款在支付机构那边已经发生,重放只是内部计算。
- 重建一次选课视图时给所有学生再发一遍短信。短信是外部动作,不能跟着重放走。
- 重算历史外币消费时用了今天的汇率。当时的汇率是当时的事实,需要作为输入一起保存下来。
这几条共同的道理是:重放要使用当时的输入,不能换成今天的输入。如果某个历史决定依赖了一次外部查询的结果,那个结果也要存下来, 不能拿今天查到的值冒充过去的依据。
五、区分「当时发生了什么」与「按今天规则重算」
保存事件之后很容易走到另一个极端:以为规则以后随便改,历史都能自动修正。这个想法要分开两步看。
| 问题 | 需要什么 | 能否改写已经发生的事 |
|---|---|---|
| 当时究竟发生了什么,为什么那样决定 | 当时记录的事实、适用规则版本、相关输入 | 不能,只能追加更正与说明 |
| 用今天的新规则重新审一遍会得到什么 | 新规则版本加历史事实 | 能算出新结果,但它不改写已经发生的事实 |
举一个具体的例子。一位学生已经获得学位。今天换一套规则重算,结果不符合要求。 这不能因此让「当年已经授予学位」这件事从历史里消失。如果需要复核或撤销,应当走一套正式流程,再记录一个新的决定。
六、把一致性从「同步」转移到「计算」
传统做法里,同一个事实常常在多个地方各存一份:总学分字段、各类别学分字段、毕业审核结论字段。 成绩一更正,这几处都要改,漏一处就出现互相矛盾的状态。
换一种组织方式:只保存事实与规则这两样东西,其余结果全部算出来。 毕业条件是否满足,变成「已通过的课程 + 去重与替代规则 + 适用培养方案」经过一段计算得到的结果,不再允许单独修改。
这样做的直接好处是,「保证多份状态一致」这个难题变成了「这段计算是否正确」这个问题。后者好处理得多: 给定输入可以重跑、可以写测试、可以审计每一步、可以分析依赖。
有一类东西不能算出来,就是人的决定。某个学生获得课程替代豁免,这是人的判断,必须作为一条事实保存下来, 包含批准对象、适用范围、依据和时间。否则那个没有来由的「审核通过」只是在隐藏复杂性,下一次重新审核时系统仍然不知道为什么要通过。
七、哪些信息值得保存
保存历史不是越多越好。每存一样东西,就要维护它的格式、版本、迁移与查询。 「哪些信息值得保存」本身是架构决定,判断依据是:这条信息将来会不会被用来解释某个结论、重算某个结果、或追溯责任。
- 值得保存:业务事件、规则版本、人的决定与依据、外部输入的当时取值。
- 不必保存:随时能算出来的派生值、可以重建的缓存、纯展示用的中间结果。
- 必须谨慎:日志能保存但不能替代事实记录。日志是为可解释性准备的材料,还需要设计这些材料之间的关系。
对于已经运行多年、只存了当前状态的旧系统,迁移时也要诚实:旧系统里那些来历不明的字段, 迁移后仍然是来历不明的。可以做的事是明确标注它是「迁移时点的基线快照」并记录来源与时间, 不必假装它是「我们亲眼记录过的事件」。
① 这个值依赖哪些更基本的事实;
② 如果某个事实被更正,谁会受影响;
③ 如果只保存这个值而不保存事实,将来会解释不清什么。
三行写完,你就完成了一次「从当前状态反推事实来源」的练习,这是这一章的核心动作。