近半个世纪以来,ACID 事务(满足原子性、一致性、隔离性和持久性属性)一直是确保数据存储系统中一致性的首选抽象。著名的原子性属性确保在发生故障时,事务的所有写入操作要么全部生效,要么全部不生效;隔离性防止并发运行的事务之间的干扰;持久性确保已提交事务的写入操作在发生故障时不会丢失。
虽然事务在单个数据库产品的范围内运行良好,但跨越来自不同供应商的多个不同数据存储产品的事务一直存在问题:许多存储系统不支持它们,而支持它们的系统通常性能不佳。如今,大型应用程序通常通过组合多种不同的数据存储技术来实现,这些技术针对不同的访问模式进行了优化。分布式事务在大多数此类设置中未能获得采用,大多数大型应用程序反而依赖于临时的、不可靠的方法来维护其数据系统的一致性。
然而,近年来,事件日志作为大型应用程序中的数据管理机制的使用有所增加。这一趋势包括数据建模的事件溯源方法、变更数据捕获系统的使用,以及基于日志的发布/订阅系统(如 Apache Kafka)的日益普及。尽管许多数据库在内部使用日志(例如,预写日志或复制日志),但这种新一代的基于日志的系统有所不同:它们不是将日志用作实现细节,而是将其提升到应用程序编程模型的层面。
由于这种方法使用应用程序定义的事件来解决传统上属于事务处理领域的问题,我们将其命名为 OLEP(在线事件处理),以区别于 OLTP(在线事务处理)和 OLAP(在线分析处理)。本文解释了 OLEP 出现的原因,并展示了它如何使应用程序能够在异构数据系统之间保证强大的 一致性属性,而无需诉诸原子提交协议或分布式锁定。OLEP 系统的架构使其能够实现始终如一的高性能、容错能力和可扩展性。
不同的数据存储系统专为不同的访问模式而设计,没有一种通用的存储技术能够有效地服务于所有可能的数据用途。因此,如今许多应用程序都使用多种不同存储技术的组合,这种方法有时被称为多语言持久性。例如
• 全文搜索。 当用户需要在数据集(例如,产品目录)上执行关键字搜索时,需要全文搜索索引。尽管某些关系数据库(如 PostgreSQL)包含基本全文索引功能,但更高级的用途通常需要专用的搜索服务器,如 Elasticsearch。为了改进索引或搜索结果排名算法,可能需要不时地重建搜索引擎的索引。
• 数据仓库。 大多数企业从其 OLTP 数据库导出运营数据,并将其加载到数据仓库中以进行业务分析。对于此类分析工作负载表现良好的存储布局(如面向列的编码)与 OLTP 存储引擎的存储布局截然不同,因此需要使用不同的系统。
• 流处理。 消息代理允许应用程序订阅事件流(例如,表示用户在网站上的操作),流处理器提供了解释和响应这些流的基础设施(例如,检测欺诈或滥用模式)。
• 应用程序级缓存。 为了提高只读请求的性能,应用程序通常维护频繁访问对象的缓存(例如,在 memcached 中)。当底层数据发生更改时,应用程序会采用自定义逻辑来相应地更新受影响的缓存条目。
请注意,这些存储系统并非完全彼此独立。相反,一个系统持有另一个系统中的数据副本或物化视图是很常见的。因此,当一个系统中的数据更新时,通常需要在另一个系统中更新,如图 1 所示。
在传统的观点中,正如当今大多数关系数据库产品所实现的那样,事务是一个交互式会话,其中客户端的查询和数据修改命令与客户端上的任意处理和业务逻辑交织在一起。此外,事务的持续时间没有时间限制,因为会话传统上可能包含人工交互。
然而,今天的现实看起来有所不同。大多数 OLTP 数据库事务是由用户通过 HTTP 向 Web 应用程序或 Web 服务发出的请求触发的。在绝大多数应用程序中,事务的跨度不会超过处理单个 HTTP 请求的时间。这意味着,当服务向用户发送响应时,底层数据库上的任何事务都已提交或中止。在跨越多个 HTTP 请求的用户工作流程中(例如,将商品添加到购物车、进入结账、确认送货地址、输入付款详细信息以及给出最终确认),没有一个事务跨越整个用户工作流程;只有短暂的、非交互式的事务来处理工作流程的单个步骤。
此外,OLTP 系统通常执行一组相当小的已知事务模式。在此基础上,一些数据库系统将事务的业务逻辑封装为存储过程,这些存储过程由应用程序提前注册。要执行事务,需要使用某些输入参数调用存储过程,然后该过程在单个执行线程上运行完成,而无需与数据库外部的任何节点通信。
区分两种类型的分布式事务非常重要
• 同构分布式事务是指参与节点都运行相同的数据库软件的事务。例如,Google 的 Cloud Spanner 和 VoltDB 是最近支持同构分布式事务的数据库系统。
• 异构分布式事务跨越来自不同供应商的多种不同存储技术。例如,X/Open XA(扩展架构)标准定义了一个用于跨异构系统执行 2PC(两阶段提交)的事务模型,而 JTA(Java 事务 API)使 XA 可用于 Java 应用程序。
虽然一些同构事务实现已被证明是成功的,但异构事务仍然存在问题。就其本质而言,它们只能依赖于参与系统的最低公分母。例如,如果应用程序进程在准备阶段失败,XA 事务会阻止执行;此外,XA 不提供死锁检测,也不支持乐观并发控制方案。3
此处列出的许多系统(如搜索索引)不支持 XA 或任何其他异构事务模型。因此,确保跨不同存储技术的写入操作的原子性仍然是应用程序面临的挑战性问题。
图 1 显示了多语言持久性的一个示例:应用程序需要在两个独立的存储系统(如 OLTP 数据库(例如,RDBMS)和全文搜索服务器)中维护记录。如果异构分布式事务可用,则系统可以确保跨两个系统的写入操作的原子性。但是,大多数搜索服务器不支持分布式事务,这使得系统容易受到以下潜在不一致性的影响
• 非原子写入。如果发生故障,记录可能被写入其中一个系统,但未写入另一个系统,导致它们彼此不一致。
• 写入顺序不同。如果有两个针对同一记录的并发更新请求 A 和 B,一个系统可能按顺序 A、B 处理它们,而另一个系统可能按顺序 B、A 处理它们。因此,系统可能对哪个写入操作是最新操作存在分歧,导致它们不一致。
图 2 提出了解决这些问题的简单方案:当应用程序想要更新记录时,它不是直接写入两个存储系统,而是将更新事件附加到日志中。数据库和搜索索引都订阅此日志,并按事件在日志中出现的顺序将更新写入其存储。4 通过日志对更新进行排序,数据库和搜索索引应用相同的写入操作集,并以相同的顺序进行,从而保持它们彼此一致。实际上,数据库和搜索索引是日志中事件序列的物化视图。此方法按如下方式解决了上述两个问题
• 将单个事件附加到日志是原子的;因此,要么两个订阅者都看到事件,要么都不看到。如果订阅者发生故障并恢复,它会继续处理之前未处理的任何事件。因此,如果更新被写入日志,它最终将被所有订阅者处理。
• 日志的所有订阅者都以相同的顺序看到其事件。因此,每个存储系统都将以相同的串行顺序写入记录。
在此示例中,日志仅对写入操作进行序列化,但应用程序可以随时从存储系统读取数据。由于日志订阅者是异步的,因此读取索引可能会返回数据库中尚不存在的记录,反之亦然;对于许多应用程序来说,这种瞬时不一致性不是问题。对于那些需要它的应用程序,读取也可以通过日志进行序列化;稍后将介绍一个示例。
有几种日志实现可以充当此角色,包括 Apache Kafka、CORFU(来自 Microsoft Research)、Apache Pulsar 和 Facebook 的 LogDevice。所需的日志抽象具有以下属性
• 持久性。日志被写入磁盘并复制到多个节点,确保在发生故障时不会丢失任何事件。
• 仅追加。新事件只能通过将它们附加到末尾来添加到日志中。除了追加之外,日志还允许丢弃旧事件(例如,通过截断早于某个保留期的日志段或执行基于键的日志压缩)。
• 顺序读取。日志的所有订阅者都以相同的顺序看到相同的事件。每个事件都分配一个单调递增的 LSN(日志序列号)。订阅者通过从指定的 LSN 开始读取日志,然后按日志顺序接收所有后续事件。
• 容错。即使在发生故障的情况下,日志仍然高度可用,可用于读取和写入。
• 分区。单个日志可能具有它可以支持的最大吞吐量(例如,单个网络接口或单个磁盘的吞吐量)。但是,可以假设系统可以通过拥有许多分区(即,可以分布在多台机器上的许多独立日志)来线性扩展,并且在不同的日志分区之间没有排序保证。多个逻辑日志可以多路复用到单个物理日志分区中。
对日志的订阅者做出以下假设
• 订阅者可以维护状态(例如,数据库),该状态基于日志中的事件进行读取和更新,并且在崩溃后仍然存在。此外,订阅者可以将更多事件附加到任何日志(包括其自身的输入)。
• 订阅者定期将其已处理的最新 LSN 检查点保存到稳定存储中。当订阅者崩溃时,在恢复后,它会从最新的检查点 LSN 恢复处理。因此,订阅者可能会处理某些事件两次(上次检查点和崩溃之间处理的事件),但它永远不会跳过任何事件。日志中的事件至少由每个订阅者处理一次。
• 单个日志分区中的事件在单个线程上使用确定性逻辑顺序处理。因此,如果订阅者崩溃并重新启动,它可能会将重复事件附加到其他日志。
这些假设由现有的基于日志的流处理框架(如 Apache Kafka Streams 和 Apache Samza)满足。基于有序日志确定性地更新状态对应于经典的状态机复制原则。5 由于从故障中恢复时,事件可能会被多次处理,因此状态更新也必须是幂等的。
一些基于日志的流处理器(如 Apache Flink)支持所谓的恰好一次语义,这意味着即使事件可能被多次处理,处理的效果也与它只被处理一次的效果相同。此行为通过管理处理框架内的副作用并将这些副作用与将日志的某个部分标记为已处理的检查点原子地提交来实现。
但是,当日志消费者写入外部存储系统时(如图 2 所示),无法确保恰好一次语义,因为这样做需要在流处理器和存储系统之间使用异构原子提交协议,而这在许多存储系统(如全文搜索索引)上是不可用的。因此,具有恰好一次语义的框架在与外部存储交互时仍然表现出至少一次处理,并依赖于幂等性来消除重复处理的影响。
需要原子性的经典示例是银行/支付系统,其中资金从一个帐户到另一个帐户的转移必须原子地发生,即使这两个帐户存储在不同的节点上也是如此。此外,这样的系统通常需要维护一致性属性或不变量(例如,帐户的透支额不能超过某个设定限额)。图 3 显示了如何使用 OLEP 方法而不是分布式事务来实现这样的支付应用程序。带有实心箭头的箭头表示将事件附加到日志,而带有空心箭头的箭头表示订阅日志中的事件。它的工作原理如下
1. 当用户希望将资金从源帐户转移到目标帐户时,他或她首先将付款请求事件附加到源帐户的日志中。此事件仅表示转移资金的意图;它并不意味着转移已成功。该事件携带一个唯一 ID 来标识请求。
2. 单线程付款执行器进程订阅源帐户日志。它维护一个数据库,其中包含源帐户的交易和当前余额。此过程确定性地检查是否应允许付款请求,这基于当前余额以及可能的其他因素。此日志消费者与存储过程的执行非常相似。
3. 如果执行器决定批准付款请求,它会将该事实写入其本地数据库,并将事件附加到多个不同的日志:至少,将一个出账付款事件附加到源帐户日志,并将一个入账付款事件附加到目标帐户的日志。如果此付款需要支付费用(例如,由于帐户透支或货币兑换),则可能会将额外的出账付款事件附加到源帐户日志,并且可能会将相应的入账付款事件附加到费用帐户的日志。原始事件 ID 包含在所有这些生成的事件中,以便可以追溯其来源。
4. 由于执行器订阅源帐户日志,因此出账付款事件将传递回执行器。它使用唯一的事件 ID 来确定它已经处理了此付款并将其记录在其数据库中。
5. 其他帐户上的付款事件(例如,目标帐户上的入账付款)也由单线程执行器处理,每个帐户有一个单独的执行器。事件处理通过基于原始事件 ID 抑制重复项来实现幂等性。
6. 处理用户请求的服务器也可以订阅源帐户日志,从而在付款请求被处理时收到通知。此状态信息可以返回给用户。
如果付款执行器崩溃并重新启动,它可能会重新处理在崩溃之前部分处理的一些付款请求。由于执行器是确定性的,因此在恢复后,它将做出相同的决定来批准或拒绝请求,从而可能将重复的付款事件附加到源、目标和费用日志。但是,基于事件中的 ID,下游进程可以轻松检测并忽略此类重复项。
在此付款示例中,每个帐户都有一个单独的日志,因此可以存储在不同的节点上。此外,每个付款执行器只需要订阅来自单个帐户的事件,不同的帐户由不同的执行器处理。这些因素使系统能够线性扩展到任意数量的帐户。
在此示例中,是否允许付款请求的决定仅取决于源帐户的余额;您可以假设支付到目标帐户总是成功的,因为其余额只会增加。因此,付款执行器只需要相对于源帐户中的其他事件对付款请求进行序列化。如果其他日志分区需要参与决策,则付款请求的批准可以作为多阶段过程执行,其中每个阶段相对于特定日志对请求进行序列化。
将“事务”拆分为流处理器的多阶段管道允许每个阶段仅基于本地数据取得进展;它确保一个分区永远不会被阻塞,等待与另一个分区通信或协调。与通常在分布式事务实现中施加可扩展性瓶颈的多分区事务不同,这种管道式设计允许 OLEP 系统线性扩展。
除了这种可扩展性优势之外,以 OLEP 风格开发应用程序还具有以下几个优势
• 由于每个日志都可以支持许多独立的订阅者,因此基于事件日志轻松创建新的派生视图或服务。例如,在图 3 的付款场景中,如果客户的信用卡达到某个消费限额,新的帐户日志订阅者可以向客户的智能手机发送推送通知。可以通过从头到尾消费事件日志来简单地构建现有数据集上的新搜索索引或视图。3
• 如果应用程序错误导致不良事件附加到日志,则恢复起来相当容易:可以对订阅者进行编程以忽略不正确的事件,并且可以重新计算从事件派生的任何视图。相比之下,在支持任意插入、更新和删除的数据库中,从不正确的写入中恢复要困难得多,可能需要从备份还原数据库。
• 同样,与可变数据库相比,使用仅追加日志进行调试要容易得多,因为可以按顺序重放事件以诊断特定情况下发生的事情。
• 出于数据建模的目的,仅追加事件日志越来越受到青睐,而不是自由形式的数据库突变;这种方法在领域驱动设计社区中被称为事件溯源。2 其理由是,事件比表上的插入/更新/删除操作更准确地捕获状态转换和业务流程,而这些状态更新最好被描述为处理事件产生的副作用。例如,事件“学生取消课程注册”清楚地表达了意图,而副作用“从注册表中删除了一行”和“向学生反馈表添加了一个取消原因”则不太清晰。
• 从数据分析的角度来看,事件日志比数据库中的状态更有价值。例如,在电子商务环境中,业务分析师不仅可以看到结账时购物车的最终状态,还可以看到添加到购物车和从购物车中删除的商品的完整序列,因为删除的商品也携带信息(例如,一种产品是另一种产品的替代品,或者客户可能会在稍后的时间再次购买某个商品)。
• 使用分布式事务,如果任何一个参与节点不可用,则整个事务必须中止,因此故障会被放大。相比之下,如果日志有多个订阅者,他们可以彼此独立地取得进展:如果一个订阅者发生故障,这不会妨碍发布者或其他订阅者的操作,因此故障是受控的。
在之前的示例中,日志消费者更新数据存储中的状态(图 2 中的数据库和搜索索引;图 3 中的帐户余额和帐户报表)。虽然 OLEP 方法确保日志中的每个事件最终都将被每个消费者处理,即使在发生崩溃的情况下也是如此,但事件被处理的时间没有上限。
这意味着,如果客户端从两个不同的数据存储读取数据,而这两个数据存储由两个不同的消费者或日志分区更新,那么客户端读取的值可能彼此不一致。例如,读取付款的源帐户和目标帐户可能会返回付款已处理后的源帐户,但目标帐户在付款处理之前。因此,即使帐户最终将收敛到一致的状态,但在特定时间点读取时,它们可能是不一致的。
请注意,在 ACID 上下文中,防止这种异常属于隔离性的范畴,而不是原子性;仅具有原子性的系统并不能保证两个帐户将在一致的状态下被读取。以“读取已提交”隔离级别(许多系统(包括 PostgreSQL、Oracle DB 和 SQL Server)中的默认隔离级别)运行的数据库事务在从两个帐户读取数据时可能会遇到相同的异常。3 防止这种异常需要更强的隔离级别:“可重复读”、快照隔离或可串行化。
目前,OLEP 方法不为直接发送到数据存储的读取请求提供隔离(而不是通过日志序列化)。希望未来的研究能够实现更强的隔离级别,例如跨从日志更新的数据存储的快照隔离。
《纽约时报》将自 1851 年报纸创刊以来发布的所有文本内容都维护在 Apache Kafka 的单个日志分区中。6 图像文件存储在单独的系统中,但图像的 URL 和标题也存储为日志事件。
每当发布或更新一段内容(称为资产)时,就会将事件附加到此日志。多个系统订阅此日志:例如,每篇文章的全文都被写入索引服务以进行全文搜索;各种缓存页面(例如,具有特定标签的文章列表,或特定作者的所有作品)需要更新;个性化系统会通知可能对新文章感兴趣的读者。
每个资产都被赋予一个唯一标识符,一个事件可以创建或更新具有给定 ID 的资产。此外,事件可以引用其他资产的标识符——很像关系数据库中的规范化模式,其中一个记录可以引用另一个记录的主键。例如,图像(带有标题和其他元数据)是一种资产,可以被一篇或多篇文章引用。
日志中事件的顺序满足两个规则
• 每当一个资产引用另一个资产时,发布被引用资产的事件都会在日志中出现在引用资产之前。
• 当资产被更新时,最新版本是由日志中最新的事件发布的版本。
例如,编辑可能会发布一张图片,然后更新一篇文章以引用该图片。然后,日志的每个消费者都会按顺序经历三种状态
1. 文章的旧版本(未引用图片)存在。
2. 图片也存在,但尚未被任何文章引用。
3. 文章和图片都存在,文章引用图片。
不同的日志消费者将在不同的时间但以相同的顺序经历这三种状态。日志顺序确保没有消费者会处于文章引用了尚不存在的图片的状态,从而确保引用完整性。
此外,每当图像或标题更新时,所有引用该图像的文章都需要在缓存和搜索索引中更新。这可以通过日志消费者轻松实现,该消费者使用数据库来跟踪文章和图像之间的引用关系。这种一致性模型非常容易应用于日志,并且它提供了分布式事务的大部分优势,而没有性能成本。
有关《纽约时报》方法的更多详细信息,请参见一篇博客文章。6
对跨异构存储技术的分布式事务的支持要么不存在,要么存在较差的运营和性能特征。相比之下,OLEP 越来越多地用于在此类设置中提供良好的性能和强大的 一致性保证。
在数据系统中,日志(例如,预写日志)作为内部实现细节使用非常常见。OLEP 方法有所不同:它使用事件日志而不是事务作为数据管理的主要应用程序编程模型。传统数据库仍然被使用,但它们的写入来自日志,而不是直接来自应用程序。这种方法已被行业中的几位有影响力的人物探索过,例如 Jay Kreps4、Martin Fowler2 和 Greg Young,名称包括事件溯源和 CQRS(命令/查询职责分离)。1,7
OLEP 的使用不仅仅是开发人员的实用主义,而且它还提供了许多优势。这些优势包括线性可扩展性;有效管理多语言持久性的手段;支持增量开发,可以在其中迭代地添加或删除新的应用程序功能或存储技术;通过直接访问事件日志改进了调试支持;以及提高了可用性(因为运行节点可以在其他节点发生故障时继续取得进展)。
因此,预计 OLEP 将越来越多地用于在使用异构存储技术的大型系统中提供强大的 一致性。
这项工作得到了波音公司的资助。感谢 Pat Helland 对本文草稿的反馈。
1. Betts, D., Dom?nguez, J., Melnik, G., Simonazzi, F., Subramanian, M. 2012. 探索 CQRS 和事件溯源。Microsoft Patterns & Practices; http://aka.ms/cqrs。
2. Fowler, M. 2005. 事件溯源; https://martinfowler.com.cn/eaaDev/EventSourcing.html。
3. Kleppmann, M. 2017. 设计数据密集型应用。O'Reilly Media。
4. Kreps, J. 2013. 日志:每个软件工程师都应该了解的关于实时数据统一抽象的知识。LinkedIn Engineering; http://engineering.linkedin.com/distributed-systems/log-what-every-software-engineer-should-know-about-real-time-datas-unifying。
5. Schneider, F. B. 1990. 使用状态机方法实现容错服务:教程。《 计算调查》22(4), 299-319; https://dl.acm.org/citation.cfm?doid=98163.98167。
6. Svingen, B. 2017. 在《纽约时报》使用 Apache Kafka 发布内容,(9 月 5 日); https://open.nytimes.com/publishing-with-apache-kafka-at-the-new-york-times-7f0e3b7d2077。
7. Vernon, V. 2013. 实现领域驱动设计。Addison-Wesley。
最终一致性 对于许多数据项,工作永远不会在一个值上稳定下来。Pat Helland https://queue.org.cn/detail.cfm?id=3226077
演进与实践:金融领域的低延迟分布式应用 金融行业对低延迟分布式系统有着独特的需求。Andrew Brook https://queue.org.cn/detail.cfm?id=2770868
此“实时”已非彼“实时” 对高尚术语的误用和滥用 Phillip Laplante https://queue.org.cn/detail.cfm?id=1117409
Martin Kleppmann 是剑桥大学的分布式系统研究员,也是广受好评的 O'Reilly 书籍《Designing Data-Intensive Applications》(http://dataintensive.net/)的作者。此前,他曾是一名软件工程师和企业家,共同创立并出售了两家初创公司,并在 LinkedIn 从事大规模数据基础设施方面的工作。
Alastair R. Beresford 是剑桥大学计算机安全领域的讲师。他的研究工作考察了大规模分布式计算机系统的安全性和隐私,尤其关注智能手机、平板电脑和笔记本电脑等联网移动设备。
Boerge Svingen 是 Fast Search & Transfer (alltheweb.com, FAST ESP) 的创始人之一。后来,他成为 Open AdExchange 的创始人兼首席技术官,为在线新闻提供情境广告服务。他现在是纽约时报的工程总监。
版权 © 2019 归所有者/作者所有。出版权已授权给 。
最初发表于 Queue vol. 17, no. 1—
在 数字图书馆 中评论本文
Andrew Leung, Andrew Spyker, Tim Bozarth - Titus:将容器引入 Netflix 云
我们相信,我们的方法使 Netflix 能够快速采用容器并从中受益。虽然细节可能特定于 Netflix,但通过与现有基础设施集成并与合适的早期采用者合作来提供低摩擦容器采用的方法,对于任何希望采用容器的组织来说,都可能是一种成功的策略。
Marius Eriksen - 大规模函数式编程
现代服务器软件在开发和运营方面要求很高:它必须随时随地可用;它必须在毫秒内回复用户请求;它必须快速响应容量需求;它必须处理大量数据和更多流量;它必须快速适应不断变化的产品需求;并且在许多情况下,它必须容纳一个庞大的工程组织,其众多工程师就像一个又大又乱的厨房里的厨师。
Caitie McCaffrey - 分布式系统的验证
Leslie Lamport 以其在分布式系统方面的开创性工作而闻名,他曾说过:“分布式系统是指,即使你不知道存在的计算机发生故障,也可能导致你自己的计算机无法使用。” 鉴于这种黯淡的前景和大量可能的故障,你究竟该如何开始验证和确认你构建的分布式系统正在做正确的事情?
Philip Maddox - 测试分布式系统
由于多种原因,分布式系统可能特别难以编程。它们可能难以设计、难以管理,而且最重要的是,难以测试。即使在最佳情况下,测试普通系统也可能很棘手,而且无论测试人员多么勤奋,错误仍然可能通过。现在,将所有标准问题乘以在可能都在不同操作系统上的多个框上运行的多种语言编写的多个进程,就可能发生真正的灾难。