首页 文章详情

DDIA:流积分就是快照,快照微分就得到了流

木鸟杂记 | 93 2024-04-29 11:44 0 0 0
UniSMS (合一短信)

DDIA 读书分享会,会逐章进行分享,结合我在工业界分布式存储和数据库的一些经验,补充一些细节。每两周左右分享一次,欢迎加入,Schedule 在这里[1]。我们有个对应的分布式&数据库讨论群,每次分享前会在群里通知。如想加入,可以加我的微信号:qtmuniao,简单自我介绍下,并注明:分布式系统群。

我们在这里讨论的事件溯源(event souring)和领域驱动设计(domain-driven design,DDD)社区中的相关概念有些相似之处。由于这个概念会牵扯出流式系统中的一些重要的思想,因此我们这里简单讨论一下。

和 CDC 类似,事件溯源也是将应用状态的所有变更存储为事件变更日志。最大的区别是事件溯源使用了不同层次的抽象:

  • 在 CDC 中,应用层以可修改的方式(mutable way)使用数据库,可以按需更新或者删除记录。变更日志是从数据库底层导出的(如解析复制日志),该变更日志可以保证日志导出的顺序和其真正的写入顺序一致,从而避免了图 11-4 中交错的情况出现。从另一个角度说,往数据库写数据的应用无需感知和关心 CDC 的存在。
  • 在事件溯源中,应用层的逻辑是显式地基于写入事件日志的不可变之上的。在这种情况下,事件存储是仅追加的,任何更新和删除都是禁止的。也就是说,这里的事件是在应用层而非更底层来追踪状态变更的。

事件溯源是一种非常强大的数据建模技术:从应用层的视角来说,与其记录用户行为对数据库造成的影响,不如直接记录下用户的行为本身更有物理意义。事件溯源使得追踪应用随着时间的演化过程非常容易,通过全景式展现每个步骤发生了什么、追踪应用层 bug 等等,让调试变得非常容易。

例如,我们如果保存事件“学生取消了选课”,就很清晰直观;但如果保存事件“从选课表中删除一行,往学生反馈表中增加一个取消原因”,其实就隐含了对系统底层的很多假设。如果之后应用层产生一个新的事件,例如“该课空缺将会被分配给等待列表中的下一个人”,则使用事件溯源的方式可以轻松将所有事件串联起来。但如果使用记录副作用的方式,一旦底层系统发生变更,就没办法将跨系统事件关联上了。

数据溯源模型很像编年表数据模型(chronicle data model);与此同时,事件日志(event log)又很像星型模型(参见AP 建模:星状型和雪花型)中的事实表(fact table)。

人们也开发了一些专用的数据库用来进行事件溯源,如 Event Store。但一般来说,事件溯源并不和任何特定的底层存储绑定。也可以基于传统的数据库和消息代理来构建事件溯源的应用。

从事件日志中算出当前状态

当然,单纯事件日志本身用处并不是很大,因为通常来说用户需要的是当前最新的状态,而非变更记录。举个例子,在购物网站中,用户会想看到当前购物车中有什么商品,而非购物车中商品被添加的历史。

因此,使用事件溯源的应用需要将事件(写入系统的数据更改)日志转化成方便进行展示某时刻的状态(因此数据才能被读取)。可以使用任何逻辑完成这个转换,但要确保这个过程是确定性的,这样能够保证即使多次重放(replaying),仍然会得到相同的状态。

和 CDC 一样,重放事件日志能够让你还原系统当前的状态。但在 CDC 和事件溯源对日志压缩的处理稍有不同:

  • CDC 中对于日志的更新事件通常包含该记录的全量状态而非增量状态。因此,某个主键对应的最新值通常可以由该主键的最新事件来确定,那么此时的日志压缩就很简单,直接丢弃相同键的所有较旧事件即可。
  • 但在事件溯源中,通常是站在应用层,即用户视角来记录的用户行为。在这种情况下,后续事件不会覆盖、而是依赖之前的事件,因此你需要全量的事件历史才能还原最终状态。因此,我们是不能对事件溯源的日志进行压缩的。

使用事件溯源的应用通常会有一些快照机制,可以将从事件日志中物化而来的状态保存下来。则在重启时,就不需要每次都重复的处理全量日志了。不过这只是加快宕机恢复性能的一种手段,事件溯源系统本身的最终目的仍是要保存下所有原始的事件、在有需要时可以从任意时刻起来重新处理日志。之后我们还会在“不可变的局限性”中对此进行详细讨论。

命令和事件

在事件溯源的哲学里,会仔细甄别事件(event)和命令(commands)。当用户的请求刚到达系统时,表现形式是一个命令:因为还有可能失败,比如不符合系统的一致性检查。应用层必须先要校验该命令可以执行,如果校验成功,命令被接受,就会转变为系统内部的一个持久化的、不可变的事件

举个例子,如果用户想用某个用户名进行注册、在剧院或者航班上预定一个座位时,相关系统首先检查用户名有没有被使用、相关座位还在不在。(我们在容错的共识算法 小节中讨论过这个例子)如果检查通过,系统就会产生一个事件,表明该用户名被该用户 ID 注册了,或者特定的座位给该用户预留了。

在事件产生的那一刻,就变成了一个事实。即使客户之后打算更改或者取消预定,也只是会新产生一个新的事件,而不会修改或者删除之前的事件。

所有事件流的消费者不能够拒绝执行任何一个事件:因为在消费者看到该事件的那一刻,该事件就已经是日志中不可变的一部分了,并且可能已经被其他消费者看到和消费了。因此,所有命令的校验都需要在其变成事件前同步的完成,如可以使用可串行事务自动地对命令校验并将其转化为事件。

另一种方法是,用户也可以将预定座位的请求拆成两个事件:

  1. 意向预定。系统会进行完整性校验。
  2. 确认预定。收到系统校验通过回复后,再发一个请求进行确认。

我们在使用全序广播实现线性一致性存储一节中讨论过。这种拆分使得校验环节可以异步的发生。

状态、流和不可变性

在第十章中,对于批处理来说,我们看到过输入文件不可变的优点:你可以做各种批处理的实验而不用担心影响到原输入文件。这种不可变的原则也可以给事件溯源和 CDC 带来诸多益处。

通常我们可以认为,数据库保存了应用的当前状态——这本质上是对读优化的,可以很方便、高效的处理读请求。但状态的本质在于变化,因此数据库允许对数据进行插入、更新和删除。那这种情况下,怎么保持不变性呢?

无论状态如何变化,都是事件序列按时间顺序依次施加的结果。例如,当前可用的座位列表是所有座位减去所有接收到的预定的结果、当前的账户余额是是该账户所有收支事件累加的结果、web 服务器的响应分布图是所有 web 请求的单个相应事件累加的结果。

不管系统的状态如何变化,总是和一个固定的事件序列对应。无论事件内容是什么,是发生还是取消,但不变的是——他们都作为事件发生了。其背后的关键点在于,可变化的状态和不可变的事件序列并不冲突:他们是一体两面的。所有变化的日志,变更日志(change log),正是状态随时间的演进过程。

如果从数学角度来思考这个问题,可以认为:

  1. 应用当前的状态是所有历史事件流对时间的积分
  2. 变化流是应用状态函数对时间的微分

当然,这个类比会有一些局限,比如应用状态的二阶导并没有什么物理意义。但总的来说,这种类比能给我们一种看待状态和事件间关系的角度。

44aad0840e002e7409351596c45608d0.webp

快照是历史事件流的积分

如果我们将变更日志持久化,本质上来说,就获得了状态回溯、重现的能力。如果我们将事件日志认为是系统的“本源记录”,而其他可变的状态都是从其衍生而来,则系统中的数据流理解起来会容易得多。正如 Pat Helland 所说:

事务日志记录下了所有施加于数据库之上的更改。高速的追加操作是记录这种变化的唯一途径。从这个角度来看,数据库的内容其实日志所有最新状态的一个缓存,而日志才是真相本源。数据库只是日志序列子集的一个缓存,且该缓存中每个值恰好是日志中每个记录的最新值。

日志压缩的过程,是沟通事件日志和数据库状态的桥梁:日志压缩会保存日志中出现过的所有记录的最新状态,而抛弃被覆盖的值。

不可变事件的优点

在数据库中,不可变性是一个相当古老的思想。例如,几个世纪以来,会计就一直在用“不可变性”来记账。当一个交易发生时,就会记到分类账簿里。这类账簿,本质上就是一串针对钱、商品和服务事件的日志。而各种账户,如利润、损失和资产负债表都是由上述分类账簿累加算出来的。

如果某条记录出错了,会计通常不会直接更改账簿中的出错记录,而是通过追加一条修正该出错的交易。例如一条对客户多收了的钱的退款交易。由于审计需要等原因,这条错误交易会在账簿中一直存在下去。如果从错误账簿的计算出的报表已经对外发布,则需要在下一个记账周期中进行修正。这种不可变性在会计行业中很常见。

这种保存所有不可变记录的可审计性不仅在财务系统中很重要,在其他没有那么严监管的系统中也有很多好处。我们在 批处理输出的哲学中讨论过,如果我们允许对原始数据进行破坏性的原地修改,则在部署的有 bug 的代码后造成了数据破坏时,恢复原始数据会非常困难。如果我们使用 append-only 的不可变事件日志形式来进行修改,定位故障位置进行错误恢复都会变得简单很多。

另一方面,不可变的事件记录下了比当前状态更多的信息。例如,在购物网站的场景中,一个用户将某个商品加到了购物车中,后来又删掉了。尽管从最终下单的状态来说,第二个事件抵消了第一个事件的影响。但从分析用户先增后删意图的角度来讲,这两个事件并不能抵消。可能是因为他们想之后再买,也可能是因为他们找到了替代品。这个先增后删的信息回被事件日志中记录下来,但是数据库中的订单表中却没有相关条目。

日志衍生多视图

除此之外,将可变的状态从不可变的事件日志中解耦开来,你还可以针对同一份事件日志来中构造面向不同读取场景的多种状态表示。这种方法本质上是给一个事件流增加多个不同类型的消费者:举个例子,从 Kafka 中注入数据到分析型数据库 Druid 中、使用 Kafka 保存提交日志的分布式 KV 存储 Pistachio 和使用 Kafka Connect 将数据导出到各种数据库和索引中。其他一些存储和索引系统中,也使用类似的方式来从分布式日志中读取输入。

将从事件日志到数据库的翻译过程显式化,会使得我们的应用更容易随着时间进行演进。某个时刻,你想引入一个新的功能,但其要求使用某种新的组织方式对数据进行组织。此时,如果有一个日志来记录所有事件,你就可以使用事件日志来构建一个额外的、面向新功能优化的读取视图,而不用修改原来的数据系统。让新老系统并存,会比在老系统上进行复杂的数据模式迁移简单。并且,一旦将来确定老数据系统没用了,就可以简单地将其关停然后回收其所占资源。

如果你无需担心数据将来会被怎么读取和使用,那么存储数据是一件非常简单的事情;很多复杂的模式设计、索引构建和存储布局都是为了支持特定的查询和访问模式。基于此,如果你将数据的写入形式和读取形式分开(很多云原生数据库就是这么干的,如谷歌 AlloyDB,微软 Socrates),由此,我们可以获得极大的设计弹性。这种思想有时也被称为:命令查询职责分离command query responsibility segregation,CQRS)。

传统数据库的和模式设计有一种误解:数据必须以面向查询的方式进行写入(注:其实也不算误解,因为这样可以避免翻译计算耗费和额外存储耗费)。如果你能自由的将数据从对写优化的格式转化为对读优化的数据状态,那么针对数据规范化(去除冗余)和去规范化(保持冗余)的考量讨论就变的无关紧要了:因为可以使用翻译过程来保证冗余数据的一致性性,且同时按所需方式按利于读的方式来去规范化(也就是冗余)地组织数据。

在衡量负载一节中我们讨论过 Twitter 的首页时间线(也称“瀑布流”),本质上是对所有关注人最近发的推文的缓存(类似于一个信箱)。这也是一个针对状态进行读优化的例子:首页时间线是非常去规范化的,因为你的推文会被所有关注你的用户首页冗余了一份。然而,扇出服务(fan-out service,类似我们上面提到的“翻译服务”)会保持这些首页“瀑布流”信息和新发推文、新的关注关系保持同步和一致,从而让这些冗余的一致性可控。

并发控制

事件溯源(event souring)和 CDC 的最大缺点在于,事件日志的产生和消费过程通常是异步的,因此可能会出现:用户已经写入了某个事件到日志中,但从某个日志衍生视图中去读取,却发现该写入还并没有反映到该读取视图中。我们在读你所写一节中讨论过该问题和一些可用的解决方案。

一种方案是将追加事件到日志更新读取视图两个过程进行同步。但这要求使用事务将两者包进一个原子单元中,具体来说,你需要:

  1. 将日志更新和视图读取放到一个存储系统中,或者
  2. 使用一个分布式事务来协调这两个系统,又或
  3. 使用全序广播实现线性一致性存储

但另一方面,从事件日志中计算当前系统状态也会简化并发控制。很多针对多对象的事务(参见单对象和多对象操作 )需求本质上是因为单个用户需要在多个物理位置同时更改数据。但使用事件溯源,我们可以设计一个对用户的多个行为自包含的事件,从而将所有单用户的写入点收束到一处——对事件日志进行追加——从而很容易实现原子性。

如果事件日志和应用状态使用相同的分区方式(例如,消费分区 3 的事件日志,产生的更新也只需要作用于分区 3 的应用状态),则只需要用一个单线程消费事件日志即可,而无需任何对写的并发控制 —— 我们可以通过安排,让其每次只消费一个事件即可(参见物理上串行)。通过确定该分区中所有事件的状态,日志本身就已经消除了并发带来的不确定性。但如果单个事件会涉及多个分区状态的更新,就需要做一些额外的工作了,我们下一章中会继续讨论。

不可变性的一些局限

很多不使用事件溯源的系统很多时候也间接地依赖了不可变性:很多数据库在内部实现时,使用了不可变的数据结构和多版本的数据管理来支持基于时间点的快照(参见索引和快照隔离)。像 Git、Mercurial 和 Fossil 之类的版本控制系统,也是依赖不可变数据来保存每个文件的历史版本。

我们若想将所有不可变的事件变更历史保存下来,在多大程度上是可行的?其回答取决于我们数据集的“流失量”(the amount of churn)。在某些负载中,读取居多而增删极少,因此很容易做成不可变的。但在另外一些负载中,但涉及更改的数据子集在整体数据集中占比很大;在这种情况下,不可变的更改历史将会非常大,数据的碎片化会变成一个大问题,数据压缩垃圾回收的性能便成为维持系统稳定性和鲁棒性中至关重要的因素了。

除了性能问题之外,在有些场景下,由于运维管理原因,需要对数据进行永久删除,然而由于不可变的事件日志的存在,真正的物理删除变的很麻烦。比如,由于隐私条例,如果用户删除账户后,其所有相关信息应该都被删除;数据保护相关条例要求删除所有错误信息;或者防止敏感信息的意外泄露。

在这些情况下,通过追加一个新的删除事件来标记这些数据被删除了是不够的。你需要真正的重写之前的事件日志历史,来删除所有相关数据,以达到这些数据看起来像从来就没有出现在系统中的效果。例如,Datomic 称该功能为切除(excision),Fossil 版本控制系统也有类似的概念称为避免(shunning,这个翻译不太好。)。

反直觉的是,真正地、彻底地删除数据其实非常困难,因为一份数据的各种副本可能以多种形式存在很多地方:例如,存储引擎、文件系统和 SSD 在底层实现时,通常会不会覆盖数据,而会在新的地方写入;备份数据通常做成整体不可变的,以防止误删或者误改。因此,对于数据系统来说,删除一条数据,本质上可以理解为:“让查询这份数据更难”,而非“无从查到这份数据”。然而,有些时候你不得不尽力保证后者,我们之后会在立法和自律小节中讨论更多。

825a3b55291f95132b7a73816cb7b1ca.webp

题图故事

0c440d036e97a9fbb4ae3622f3a4ab59.webp

 庐山,西游记片头取景地

参考资料

[1]

DDIA 读书分享会: https://ddia.qtmuniao.com/


good-icon 0
favorite-icon 0
收藏
回复数量: 0
    暂无评论~~
    Ctrl+Enter