二月二十日,一项设备日租服务在目录中显示一百元。三月十五日收到更正:二月十五日起应为八十元。四月五日又收到第二次更正,同一生效日的价格实际应为九十元。查询“二月二十日多少钱”时,一百、八十和九十都能在记录里找到,区别在于查询采用哪个时点掌握的信息。

分类、规格与类型对象 让旧合同固定到签约规格。本篇继续检查目录历史:既要按当前认识查看过去价格,也要重现某次报价时能看到的目录。所有日期、价格和更正均为合成教学材料;目录追溯更正不自动改变已签合同,更没有自动退款的含义。

一次查询需要两个时间参数

有效时间回答某个事实在哪段业务时间适用,记录时间回答系统从什么时候开始按这份记录理解事实。二月十五日是更正价格的有效起点,三月十五日是本系统收到并录入第一次更正的时点。把后者直接写成二月十五日,会丢掉信息迟到一个月这一事实。

Martin Fowler 在2021年4月7日发表的《Bitemporal History》中使用 actual history 与 record history 区分这两个维度,并讨论追溯更新使历史认识发生变化的情况。本篇把它们对应为有效时间与记录时间;实际价格样例、输入边界和存储算法都是教学设计。原文:The Two Dimensions

概念来源或教学选择 本篇表示 所回答的问题
actual history 的时间维度 valid_on,日历日期 哪一天适用的价格
record history 的时间维度 known_at,UTC记录时刻 截至哪个记录时点的认识
教学存储选择 每次追加完整有效期快照 怎样保留旧认识并检验查询

查询接口因此采用 price_at(valid_on, known_at)。固定 valid_on 为二月二十日,移动 known_at,得到价格认识的变化;固定记录时点,再移动有效日期,得到该时点看到的价格时间表。两个参数都叫 date 会遮住区别,调用代码中应保留各自含义。

具体到一份租赁目录

实验只管理 CAM-DAY 一项服务,以人民币分记录每日价格。有效日期的声明范围是 [2026-01-01,2027-01-01);每个版本必须完整覆盖这个范围。记录时刻统一使用带 UTC 信息的时间戳。日历日期不会被偷偷当成某地零点,这里也没有按经过小时计算租金。

最早一份快照在一月一日录入,认为范围内每天都是一万分。三月十五日追加一份快照,二月十五日前仍是一万分,此后是八千分。四月五日再追加一份,前段保持不变,后段改为九千分。

记录起点,UTC 有效日期区间,右端不含 人民币分/天
01-01 00:00 01-01 至次年01-01 10000
03-15 00:00 01-01 至02-15 10000
03-15 00:00 02-15 至次年01-01 8000
04-05 00:00 01-01 至02-15 10000
04-05 00:00 02-15 至次年01-01 9000

表中同一记录起点的行构成一份完整快照,而不是五条随意竞争优先级的价格记录。选择快照后,再在快照内部按有效日期找区间。第一次快照可以包含未来有效日期,因此实现没有要求“有效时间必须早于记录时间”;提前公布未来价格完全可以由这套表示承载。

追加快照怎样保留旧认识

模型使用冻结的 Segment(start,end,cents) 和 Revision(recorded_at,segments)。History 保留按记录时刻递增的修订元组,每次追加之前先验证新区间是否合法,再形成包含新修订的元组。已有修订的业务内容没有被重新赋值。

记录区间不必给每份旧快照回填一个结束字段。相邻记录起点自然决定区间:第一份适用于一月一日起至三月十五日前,第二份适用于三月十五日起至四月五日前,最后一份在新的修订出现前继续适用。查询选择不晚于 known_at 的最后一份快照,表达的是记录轴上的半开语义。

1
2
3
4
candidates = [r for r in revisions if r.recorded_at <= known_at]
revision = candidates[-1]
price = next(s.cents for s in revision.segments
if s.start <= valid_on < s.end)

这是核心算法片段。完整程序还处理最早记录以前的查询,以及声明有效范围以外的日期:两种情况都返回 None,表示该模型无法回答,不能返回零元。当前实现顺序扫描快照与分段,优点是容易核对,代价是历史增长时查询工作量随之增加;实验没有做性能容量结论。

完整快照还会重复保存未变化的前半段。这是教学实现为了减少更新分裂逻辑而作出的取舍。按二维区间存储局部变化可以减少重复,但更正一小段时需要处理原区间拆分、记录边界与其他分段关系,不能只增加两个时间字段就认为更新已经正确。

同一有效日期的三份回答

真实实验查询二月二十日,记录截止选二月二十日时返回一万分;选三月十五日返回八千分;选四月五日返回九千分。最后再用最早记录视角查询,结果仍是一万分。后来的更正没有删除早先用于报价的认识。

1
2
3
4
valid_on=2026-02-20
known_at=2026-01-01T00:00:00+00:00 -> 10000
known_at=2026-03-15T00:00:00+00:00 -> 8000
known_at=2026-04-05T00:00:00+00:00 -> 9000

这里的一月视角已经对未来的二月价格作出声明。它不表示系统在一月就知道实际租赁是否发生,只表示目录中提前存在这项价格计划。价格计划、签约事实和结算记录仍是不同对象。

反例采用一个只保留最新价格的字典,给二月二十日留下九千分。它可以回答当前修正后的目录,却无法重现当时的一万分。实验直接比较字典结果与旧记录视角,得到不相等的断言结果。仅保存一条“最后修改于四月五日”,也不能反推出此前八千分和一万分的内容。

两根时间轴都要检查端点

有效轴采用半开区间。四月视角下,二月十四日仍为一万分,二月十五日恰好切换到九千分。有效范围的开始日期被包含,次年一月一日被排除。零长度、反向区间都被拒绝,避免一条无法命中的价格记录留在目录里。

记录轴同样检查边界。三月十五日零点前一微秒查询,仍按旧快照返回一万分;恰好零点返回八千分。若把筛选条件误写成严格小于,更正时刻的查询就会意外延用旧价格。测试使用固定时间,不依赖进程执行到哪一秒。

本例还要求每份快照形成有序、无重叠、无缺口的完整有效区间划分。负例分别把第二段起点改为二月十四日和二月十六日,程序拒绝重叠或缺口。这个完整性要求是当前价格目录的教学约束;允许暂未定价的业务可以合法存在空白,但必须在查询结果中表达空白的含义。

同一个记录时刻重复追加,以及向历史记录序列倒插更早时刻,都会被拒绝。业务事实晚到时,应该用新的记录时刻描述过去的有效区间,而不是伪造系统早已知道。真实系统如何取得可信记录时刻、如何处理两个写入者同时提交,还需要独立设计;本例只校验传入值。

什么时候只保留一种历史就够了

如果只要求查询当前认可的历史价格,一条有效时间线可能已经满足需求。如果只要求解释已经签订的合同,第11篇固定规格与约定金额的方案也可能足够:合同直接保存行动所依据的数据,不必让所有目录查询都携带第二个时间参数。Fowler 也明确讨论了可以避免双时态的情况及其复杂度。原文:Using Bitemporality

这里新增第二个维度,是因为问题明确包括“某日当时看到的整份目录是什么”。一份合同快照只能说明该合同采用的条件,无法代表当时目录中所有未签约价格。如果没有这类查询要求,保存完整记录时间线会增加存储、查询接口与解释成本。

目录追溯更正后的九十元也不意味着系统应自动把旧合同改成九十元。是否退差额、补收或保留原约定取决于独立政策;还需要知道哪些合同依赖了哪份价格以及哪些款项已经结清。双时态查询提供比较依据,没有实现这些后续动作。

运行和未覆盖的保证

在仓库根目录运行:

1
bash examples/software-modeling/labs/12/run.sh

实验使用 Python 标准库,读取 models/12/history.json,不访问数据库或外部服务。第12篇实验包 保留仓库相对路径,解压后可执行同一命令。本次 Python 3.14.4 运行二十二个场景,全部通过,退出码为零;原始输出及环境位于 examples/software-modeling/evidence/12/。

冻结记录和追加接口只防止本实验覆盖旧快照的普通操作。它们没有证明存储不可篡改、管理员不能改文件、并发提交可串行化或重启后历史仍在。多部门分别获知同一事实的时间也未建模。把这些限制写清楚,才能避免把一个正确的二维查询函数误当成完整审计系统。