生成式软件工程
搜索

状态、历史与一致性

数据库里那个「已毕业」的布尔值,可能来自三种完全不同的过程,而你分不出来。

这一章讲什么
  • 只保存当前状态会丢掉什么;
  • 当前状态里常常混着历史事实、规则与人的决定;
  • 事件溯源:保存发生过什么,再用它算出状态;
  • 投影、重放,以及重放时为什么不能重复对外界动手;
  • 把一致性从「多份状态同步」转移到「这段计算是否正确」。

一、一个布尔值背后的三件事

假设一个教务系统里,学生的记录上有一个字段 can_graduate = true。看起来很清楚:这个学生满足毕业条件。

但这个 true 是怎么来的?至少有三种可能:

  1. 按规则算出来的:学分够了、必修课都过了、毕业设计通过了,程序自动判定。
  2. 人工特批的:学生转专业时少了一门课,学院批准用另一门课替代,教务员手动改了数据库。
  3. 救火改的:学生马上要离校,系统还没实现新的替代规则,工程师直接把字段改成 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)。系统坏了、规则改了、需要重新核对,都可以重放。 日常为了查询速度,可以维护好当前结果,来了新事件就增量更新;需要重建时再从某个快照继续往后跑。

四、重放不能重复对外界动手

重放有个必须守住的边界:它只能重新计算内部状态,不能把已经完成的外部动作再做一次。

举几个会出事的例子:

这几条共同的道理是:重放要使用当时的输入,不能换成今天的输入。如果某个历史决定依赖了一次外部查询的结果,那个结果也要存下来, 不能拿今天查到的值冒充过去的依据。

常见坑 事件格式和业务规则都会变,要分别管理版本。事实录错时应当追加一条更正事件,不直接改那条记录, 保留原始记录与更正之间的关系。悄悄改掉历史,等于把可追溯这件事废掉。

五、区分「当时发生了什么」与「按今天规则重算」

保存事件之后很容易走到另一个极端:以为规则以后随便改,历史都能自动修正。这个想法要分开两步看。

问题需要什么能否改写已经发生的事
当时究竟发生了什么,为什么那样决定当时记录的事实、适用规则版本、相关输入不能,只能追加更正与说明
用今天的新规则重新审一遍会得到什么新规则版本加历史事实能算出新结果,但它不改写已经发生的事实

举一个具体的例子。一位学生已经获得学位。今天换一套规则重算,结果不符合要求。 这不能因此让「当年已经授予学位」这件事从历史里消失。如果需要复核或撤销,应当走一套正式流程,再记录一个新的决定。

六、把一致性从「同步」转移到「计算」

传统做法里,同一个事实常常在多个地方各存一份:总学分字段、各类别学分字段、毕业审核结论字段。 成绩一更正,这几处都要改,漏一处就出现互相矛盾的状态。

换一种组织方式:只保存事实与规则这两样东西,其余结果全部算出来。 毕业条件是否满足,变成「已通过的课程 + 去重与替代规则 + 适用培养方案」经过一段计算得到的结果,不再允许单独修改。

这样做的直接好处是,「保证多份状态一致」这个难题变成了「这段计算是否正确」这个问题。后者好处理得多: 给定输入可以重跑、可以写测试、可以审计每一步、可以分析依赖。

有一类东西不能算出来,就是人的决定。某个学生获得课程替代豁免,这是人的判断,必须作为一条事实保存下来, 包含批准对象、适用范围、依据和时间。否则那个没有来由的「审核通过」只是在隐藏复杂性,下一次重新审核时系统仍然不知道为什么要通过。

生活类比 这像记账。你不必手动维护「本月支出合计」这个数字,只要有每一笔支出,合计随时能算。 手动维护合计的麻烦在于,改了一笔明细就得记得改合计;漏改了,账本就是错的,而且错得很难发现。

七、哪些信息值得保存

保存历史不是越多越好。每存一样东西,就要维护它的格式、版本、迁移与查询。 「哪些信息值得保存」本身是架构决定,判断依据是:这条信息将来会不会被用来解释某个结论、重算某个结果、或追溯责任。

对于已经运行多年、只存了当前状态的旧系统,迁移时也要诚实:旧系统里那些来历不明的字段, 迁移后仍然是来历不明的。可以做的事是明确标注它是「迁移时点的基线快照」并记录来源与时间, 不必假装它是「我们亲眼记录过的事件」。

现在就做 挑一个你熟悉的系统(教务、食堂、图书馆借阅任选),找出一个「合计数」或「状态标记」字段。写三行:
① 这个值依赖哪些更基本的事实;
② 如果某个事实被更正,谁会受影响;
③ 如果只保存这个值而不保存事实,将来会解释不清什么。
三行写完,你就完成了一次「从当前状态反推事实来源」的练习,这是这一章的核心动作。

自测

1. 为什么同一个余额可能对应两段完全不同的历史,而这件区别重要?
例如充值 100 元与充值 100 元后消费 12 元又退款 12 元,余额都是 100。区别重要在于:对账、处理争议、分析消费行为、审计都需要知道钱是怎么流动的。只存余额就丢失了这些依据。
2. 重放时最容易出事的做法是什么?
把外部动作跟着重放一起执行,例如重放消费记录时又扣一次钱、重建视图时又给所有人发一遍短信。重放只能重算内部状态,外部动作必须与内部重建分开,并且要使用当时的输入,不能换成今天的输入。
3. 换了新规则重算,发现某位学生不符合毕业条件,能不能因此撤销已经授予的学位?
不能因为重算结果就直接改写历史。重算只能得到「按新规则看会怎样」这个结论,不改变已经发生的事实。如需复核或撤销,应走正式流程并记录一个新的决定,保留原始记录与后续处理之间的关系。
4. 「保存事实与规则,结果算出来」这条做法把什么难题换成了什么难题?
把「保证多份状态永远一致」换成了「这段计算是否正确」。后者可以用重跑、测试、审计与依赖分析来处理,比前者更容易管理。代价是每次查询可能要计算,需要为性能保存派生结果并明确它们的失效与重建方式。
5. 哪些信息不适合算出来、必须作为事实保存?
人的决定。例如课程替代豁免、特批入学、人工放行。这类决定没有可推导的规则,必须记录批准对象、适用范围、依据与时间,否则系统无法解释为什么通过,下次审核也无从判断。