双时间历史
通常需要访问某些属性的历史值。但有时,此历史本身需要根据追溯性更新进行修改。双时间历史将时间视为两个维度:实际历史记录在完美信息传输的情况下历史应该是什么,而记录历史记录了我们对历史的了解如何变化。
2021 年 4 月 7 日
当我们考虑某些属性(例如您的地址或薪资)如何随时间变化时,我们通常将其视为线性变化序列。但令人惊讶的是,它经常会比这更复杂,以至于经常会混淆计算机记录。
我可以用一个简单的例子说明这一切
- 我们正在处理公司薪资。我们于 2 月 25 日为公司运行薪资,我们的员工 Sally 根据她每月 6000 美元的薪资获得报酬。
- 3 月 15 日,我们收到人力资源部门的一封道歉信,告诉我们,2 月 15 日,Sally 的薪资涨到了 6500 美元。
那么,当我们被问到 2 月 25 日 Sally 的薪资是多少时,我们应该如何回答?从某种意义上说,我们应该回答 6500 美元,因为我们现在知道那是当时的薪资。但通常我们不能忽视 2 月 25 日我们认为薪资是 6000 美元,毕竟那是我们运行薪资的时候。我们打印了一张支票,寄给了她,她兑现了。所有这些都是基于她当时的薪资金额发生的。如果税务部门向我们询问 2 月 25 日的薪资,这将变得很重要。
两个维度
我发现,通过将时间视为两个维度,我可以理解这种混乱的大部分内容——因此得名“双时间”。一个维度是 Sally 薪资的实际历史,我将通过在每个月的 25 日进行采样来说明,因为那是运行薪资的时候。
日期 | 薪资 |
---|---|
1 月 25 日 | 6000 |
2 月 25 日 | 6500 |
3 月 25 日 | 6500 |
第二个维度出现在我们询问 2 月 25 日我们认为 Sally 的薪资历史是什么时。在 2 月 25 日,我们还没有收到人力资源部门的信,所以我们认为她的薪资一直是 6000 美元。实际历史和我们对历史的记录之间存在差异。我们可以通过在表格中添加新日期来显示这一点
记录日期 | 实际日期 | 薪资 |
---|---|---|
1 月 25 日 | 1 月 25 日 | 6000 |
2 月 25 日 | 1 月 25 日 | 6000 |
3 月 25 日 | 1 月 25 日 | 6000 |
2 月 25 日 | 2 月 25 日 | 6000 |
3 月 25 日 | 2 月 25 日 | 6500 |
3 月 25 日 | 3 月 25 日 | 6500 |
我使用术语实际和记录历史来表示这两个维度。您可能还会听到人们使用术语有效或生效(对于实际)和事务(对于记录)。[1]
我通过说类似“3 月 25 日,我们认为 2 月 25 日 Sally 的薪资是 6500 美元”来阅读此表格的行。使用这种思维方式,我可以查看 Sally 实际历史的早期表格,并更准确地说,它是 3 月 25 日已知(记录)的 Sally 的实际历史。
在编程方面,如果我想知道 Sally 的薪资,并且我没有历史记录,那么我可以使用类似 sally.salary
的方法获取它。要添加对(实际)历史的支持,我需要使用 sally.salaryAt('2021-02-25')
。在双时间世界中,我需要另一个参数 sally.salaryAt('2021-02-25', '2021-03-25')
另一种可视化方法是绘制一个图表,其中 x 轴是实际时间,y 轴是记录时间。我根据薪资水平对区域进行阴影处理。(图表的形状是三角形的,因为我们没有尝试记录未来的值。[2])
使用此图表,我可以制作一个表格,说明实际历史如何随着每次在 25 日运行薪资而发生变化。我们看到,2 月 25 日的薪资是在 Sally 没有加薪的时候运行的,但当 3 月 25 日的薪资运行时,加薪是已知的。
更改追溯性更改
现在考虑人力资源部门的另一封信
- 4 月 5 日:抱歉,我们之前的电子邮件中有一个错别字。Sally 2 月 15 日的加薪是 6400 美元。抱歉造成不便。
这是让天使哭泣的改变。但当我们从双时间历史的角度考虑它时,它并不难理解。以下是包含此新信息的图表。
水平线用于薪资,表示在特定记录时间点的实际历史。在 4 月 25 日,我们知道 Sally 的薪资从 2 月 15 日的 6000 美元增加到 6400 美元。从这个角度来看,我们从未见过 Sally 的 6500 美元薪资,因为它从未真实存在过。
查看图表,垂直线代表什么?
这代表了我们对特定日期值的了解。该表格指示了 2 月 25 日的记录薪资,因为我们的知识随着时间的推移而改变。
使用双时间性
当我们必须处理追溯性更改时,双时间历史是一种有用的历史框架。但是我们并没有经常看到它被使用,部分原因是许多人不知道这种技术,但也因为我们经常可以不使用它就解决问题。
避免它的一个方法是不支持追溯性更改。如果您的保险公司说任何更改在收到您的信件时生效——那么这是一种迫使实际时间与记录时间匹配的方法。
当行动基于一个被追溯性更改的过去状态时,追溯性更改是一个问题,例如,根据现在已更新的薪资水平发送出去的薪资支票。如果我们只是记录历史,那么我们不必担心它被追溯性更改——我们基本上忽略记录历史,只记录实际历史。即使我们确实有不变的行动,我们也可能会这样做,如果行动以记录任何必要的输入数据的方式记录下来。因此,Sally 的薪资可以记录她在开具支票时的薪资,这对于审计目的就足够了。在这种情况下,我们只需要她薪资的实际历史。记录历史则被埋藏在她的薪资单中。
如果任何追溯性更改都在行动发生之前进行,我们也可以只使用实际历史。如果我们在 2 月 24 日得知 Sally 的薪资变化,我们可以在薪资行动依赖于错误数字之前调整她的记录。
如果我们可以避免使用双时间历史,那么通常是最好的,因为它确实会使系统变得相当复杂。但是,当我们必须处理实际历史和记录历史之间的差异时,通常是由于追溯性更新,那么我们需要咬紧牙关。这其中最难的部分之一就是教育用户如何使用双时间历史。大多数人不会将历史记录视为会发生变化的东西,更不用说记录历史和实际历史这两个维度了。
追加式历史
在一个简单世界中,历史是追加式的。如果通信是完美且即时的,那么所有新信息都会立即被所有感兴趣的参与者了解。然后,我们可以将历史视为随着世界中发生的新事件而添加的东西。
双时间历史是一种接受通信既不完美也不即时的方式。实际历史不再是追加式的,我们会回过头去进行追溯性更改。但是记录历史本身是追加式的。我们不会改变我们对 2 月 25 日 Sally 薪资的了解。我们只是追加我们后来获得的知识。通过在实际历史之上叠加一个追加式的记录历史,我们允许实际历史被修改,同时创建其修改的可靠历史。
追溯性更改的后果
双时间历史是一种机制,允许我们跟踪值的更改方式,并且能够询问 sally.salaryAt(actualDate, recordDate)
会非常有用。但追溯性更改不仅仅是调整历史记录。正如专家所说:“人们认为时间是因果关系的严格进展,但实际上从非线性、非主观角度来看——它更像是时间扭曲的球体。” [3] 如果我们支付了 Sally 6000 美元,而我们应该支付她 6400 美元,那么我们需要纠正它。至少这意味着在以后的薪资单中获得更多报酬,但它也可能导致其他后果。也许更高的报酬意味着她应该在一个月前就跨越了某个重要的门槛,也许存在税收影响。
双时间历史本身不足以弄清楚这些依赖效应是什么,这需要一组额外的机制,这些机制超出了本模式的范围。一项措施是创建一个并行模型,该模型捕获世界应该具有的正确薪资状态,并使用它来确定补偿性更改。 [4] 双时间历史可以成为这些措施的有用元素,但它只解开了这个大球的一部分。
记录时间的视角
我上面关于记录时间的示例使用日期来捕获我们对实际历史的不断变化的理解。但我们捕获记录历史的方式可能比这更复杂。
为了使上面所有内容更容易理解,我在薪资日期对历史进行了采样。但更好的历史表示方法是使用日期范围。以下是一个涵盖 2021 年的表格
记录日期 | 实际日期 | 薪资 |
---|---|---|
1 月 1 日 - 3 月 14 日 | 1 月 1 日 - 12 月 31 日 | 6000 |
3 月 15 日 - 4 月 4 日 | 1 月 1 日 - 2 月 14 日 | 6000 |
3 月 15 日 - 4 月 4 日 | 2 月 15 日 - 12 月 31 日 | 6500 |
4 月 5 日 - 12 月 31 日 | 1 月 1 日 - 2 月 14 日 | 6000 |
4 月 5 日 - 12 月 31 日 | 2 月 15 日 - 12 月 31 日 | 6400 |
我们可以认为 Sally 的薪资是用两个键的组合记录的,即实际键(日期范围)和记录键(也是日期范围)。但我们对记录键的概念可能比这更复杂。
一个明显的例子是,不同的代理可以有不同的记录历史。对于 Sally 来说,情况显然如此,从人力资源部门到薪资部门的消息传递需要时间,因此,对实际历史进行这些修改的记录时间在两者之间会有所不同。
部门 | 记录日期 | 实际日期 | 薪资 |
---|---|---|---|
人力资源 | 1 月 1 日 - 2 月 14 日 | 1 月 1 日 - 12 月 31 日 | 6000 |
人力资源 | 2 月 15 日 - 12 月 31 日 | 1 月 1 日 - 2 月 14 日 | 6000 |
人力资源 | 2 月 15 日 - 12 月 31 日 | 2 月 15 日 - 12 月 31 日 | 6400 |
薪资 | 1 月 1 日 - 3 月 14 日 | 1 月 1 日 - 12 月 31 日 | 6000 |
薪资 | 3 月 15 日 - 4 月 4 日 | 1 月 1 日 - 2 月 14 日 | 6000 |
薪资 | 3 月 15 日 - 4 月 4 日 | 2 月 15 日 - 12 月 31 日 | 6500 |
薪资 | 4 月 5 日 - 12 月 31 日 | 1 月 1 日 - 2 月 14 日 | 6000 |
薪资 | 4 月 5 日 - 12 月 31 日 | 2 月 15 日 - 12 月 31 日 | 6400 |
任何可以记录历史的东西都会有自己的记录时间戳,用于记录它何时了解信息。根据这些数据,我们可能会说企业会选择某个代理来作为记录某些类型数据的定义代理。但代理会跨越权力界限——无论公司规模多大,它都不会改变它所处理的税务部门的记录日期。大量的努力都花在了解决由不同代理在不同时间了解相同事实而导致的问题上。
我们可以通过将部门和记录日期范围的概念合并到一个称为视角的单一概念中来概括这里发生的事情。因此,我们会说类似“根据人力资源部门在 2 月 25 日的视角,Sally 的薪资是 6400 美元”。在表格形式中,我们可以这样可视化它。
视角 | 实际日期 | 薪资 |
---|---|---|
人力资源,1 月 1 日 - 2 月 14 日 | 1 月 1 日 - 12 月 31 日 | 6000 |
人力资源,2 月 15 日 - 12 月 31 日 | 1 月 1 日 - 2 月 14 日 | 6000 |
人力资源,2 月 15 日 - 12 月 31 日 | 2 月 15 日 - 12 月 31 日 | 6400 |
薪资,1 月 1 日 - 3 月 14 日 | 1 月 1 日 - 12 月 31 日 | 6000 |
薪资,3 月 15 日 - 4 月 4 日 | 1 月 1 日 - 2 月 14 日 | 6000 |
薪资,3 月 15 日 - 4 月 4 日 | 2 月 15 日 - 12 月 31 日 | 6500 |
薪资,4 月 5 日 - 12 月 31 日 | 1 月 1 日 - 2 月 14 日 | 6000 |
薪资,4 月 5 日 - 12 月 31 日 | 2 月 15 日 - 12 月 31 日 | 6400 |
将此折叠成一个单一视角概念会给我们带来什么?它允许我们考虑可能存在的其他视角。一个例子是考虑替代视角。我们可以创建一个视角,其中我们删除个别加薪(例如 Sally 在 2 月 15 日的加薪),并在 3 月 1 日为所有员工加薪 10%。这将导致 Sally 薪资的新记录时间维度。
视角 | 实际日期 | 薪资 |
---|---|---|
现实世界 | 1 月 1 日 - 2 月 14 日 | 6000 |
现实世界 | 2 月 15 日 - 12 月 31 日 | 6400 |
有全球加薪 | 1 月 1 日 - 2 月 28 日 | 6000 |
有全球加薪 | 3 月 1 日 - 12 月 31 日 | 6600 |
记录时间概念的这种概括表明,我们可以使用本质上相同的机制来推理追溯性更改和替代历史,在实际历史之上叠加多个视角。
在历史记录上叠加多个视角维度,即使与双时态历史相比,也不是一种广泛有用的做法。但我发现这是一种思考这类情况的有用方法:推理关于历史或未来替代场景。
存储和处理双时间历史
向数据添加历史记录会增加复杂性。在双时态世界中,我需要两个日期参数来访问 Sally 的薪资 - sally.salaryAt('2021-02-25', '2021-03-25')
。如果我们将记录时间的默认值视为今天,我们可以通过默认值简化访问,那么只需要处理当前记录时间的处理就可以忽略双时态的复杂性。
然而,简化访问并不一定简化存储。如果任何客户端需要双时态数据,我们必须以某种方式存储它。虽然有一些数据库内置支持某种程度的时态性,但它们相对来说比较小众。明智的做法是,人们在处理长期数据时,往往会对小众技术格外谨慎。
鉴于此,通常最好的方法是自己想出一个方案。有两种主要方法。
第一种是使用双时态数据结构:将必要的日期信息编码到用于存储数据的结构中。这可以通过使用嵌套的日期范围对象,或在关系表中使用一对开始/结束日期来实现。
记录开始 | 记录结束 | 实际开始 | 实际结束 | 薪资 |
---|---|---|---|---|
1 月 1 日 | 3 月 14 日 | 1 月 1 日 | 12 月 31 日 | 6000 |
3 月 15 日 | 4 月 4 日 | 1 月 1 日 | 2 月 14 日 | 6000 |
3 月 15 日 | 4 月 4 日 | 2 月 15 日 | 12 月 31 日 | 6500 |
4 月 5 日 | 12 月 31 日 | 1 月 1 日 | 2 月 14 日 | 6000 |
4 月 5 日 | 12 月 31 日 | 2 月 15 日 | 12 月 31 日 | 6400 |
这允许访问所有双时态历史记录,但更新和查询起来很麻烦 - 虽然可以通过制作一个库来处理对双时态信息的访问来简化操作。
另一种方法是使用 事件溯源。在这里,我们不将 Sally 薪资的状态作为我们的主要存储,而是将所有更改存储为事件。这些事件可能看起来像这样
记录日期 | 实际日期 | 操作 | 值 |
---|---|---|---|
1 月 1 日 | 1 月 1 日 | sally.salary | 6000 |
3 月 15 日 | 2 月 15 日 | sally.salary | 6500 |
4 月 5 日 | 2 月 15 日 | sally.salary | 6400 |
请注意,如果事件需要支持双时态历史记录,它们本身也需要是双时态的。这意味着每个事件都需要一个实际日期(或时间)来表示事件在世界中发生的时间,以及一个记录日期(或时间)来表示我们了解事件的时间。
存储事件在概念上更直接,但需要更多处理才能回答查询。但是,可以通过构建应用程序状态的快照来缓存大部分处理。因此,如果大多数使用此数据的用户只需要当前实际历史记录,那么我们可以构建一个只支持实际历史记录的数据结构,从事件中填充它,并随着新事件的不断涌入而保持更新。那些想要双时态数据的用户可以创建一个更复杂的结构,并从相同的事件中填充它,但他们的复杂性不会让那些想要更简单模型的用户感到困难。(如果有些人想在不同的记录日期查看实际历史记录,他们可以使用几乎所有相同的代码来处理当前实际历史记录。)
进一步阅读
我在 1980 年代和 1990 年代使用各种软件系统时遇到了双时态历史记录的问题。我开始 记录 我观察到的模式,但在其他写作项目接手之前,我从未完成早期草稿。那里有一段关于双时态历史记录的讨论,我写这篇文章是为了突出这个概念,并希望更清楚地解释它。
大约在那个时候,Richard Snodgrass 写了一本书:Developing Time-Oriented Database Applications in SQL。它详细介绍了如何在 SQL 系统中处理这类问题,其方法影响了 SQL:2011 标准。
我从 Time Travel: A Pattern Language for Values That Change 中借鉴了视角的概念。
脚注
1: 实际/记录与有效/事务
有效时间和事务时间的术语来自 Snodgrass,也用于 SQL:2011 标准。当我第一次开始在 2000 年代初举办关于时态建模的研讨会时,我使用了这些术语,但人们发现它们令人困惑。因此,我们开始使用实际/记录代替。由于有效/事务还没有得到广泛使用,我将遵循这一教训,在这里使用实际/记录。
2: 双时态未来
对于历史,实际时间始终在记录时间之前或等于记录时间。但双时态的概念可以应用于未来。如果我在 5 月 5 日被告知 Sally 将在 5 月 12 日获得另一次加薪,那么我可以将这次加薪记录为记录时间为 5 月 5 日,实际时间为 5 月 12 日。
3: 如果你不认识这句话,你应该把 Blink 加入你的观看列表。这是有史以来拍摄的最棒的时间旅行故事之一。
4: 我在 2000 年代中期开始在我的早期关于 并行模型 的写作中探索这个主题。我当时没有继续走这条路,我不确定我将来是否会重新回到这条路上。
致谢
Alexandre Klaser、Dave Elliman、Joshua Taylor、Martha Rohte、Mauro Vilasi、Pavlo Kerestey、Pramod Sadalge、Rebecca Parsons、Saager Mhatre 和 Wolf Schlegel 在我们内部邮件列表中对这篇文章进行了有益的讨论。
Heikki Heinonen 提醒我注意视角表中的一些错误。
重大修订
2021 年 4 月 7 日: 发布
2021 年 3 月 17 日: 发送内部审核
2021 年 3 月 2 日: 开始起草