分布式事务的替换者:在线事件处理OLEP(事件溯源) - ACM权威


近半个世纪以来,ACID事务(满足原子性,一致性,隔离性和持久性的特性)一直是确保数据存储系统一致性的首选。众所周知的原子性属性:在发生故障时,可确保事务写入的全部或全部都不会; 隔离防止同时运行的事务干扰; 和持久性确保在发生故障时不会丢失已提交事务所做的写入。
虽然事务在单个数据库产品的范围内运行良好,但是跨越来自不同供应商的多个不同数据存储产品的事务存在问题:许多存储系统不支持它们,而那些通常表现不佳的存储系统。如今,大规模应用程序通常通过组合针对不同访问模式优化的多种不同数据存储技术来实现。分布式事务在大多数此类设置中未能获得采用,而大多数大型应用程序依赖于临时,不可靠的方法来维护其数据系统的一致性。
然而,近年来,事件日志作为大规模应用中的数据管理机制的使用已经增加。这一趋势包括数据建模的事件溯源/事件采购方法、变更数据捕获系统的使用,以及Apache Kafka等基于日志的发布/订阅系统的日益普及。虽然许多数据库在内部使用日志(例如,预写日志或复制日志),但新一代基于日志的系统是不同的:它们不是将日志用作实现细节,而是将它们提升到应用程序编程模型的级别。
由于此方法使用应用程序定义的事件来解决传统上属于事务处理域的问题,因此我们将其命名为OLEP(在线事件处理),以与OLTP(在线事务处理)和OLAP(在线分析处理)进行对比。本文解释了OLEP出现的原因,并展示了它如何允许应用程序保证跨异构数据系统的强一致性,而无需借助原子提交协议或分布式锁定。OLEP系统的体系结构允许它们实现始终如一的高性能,容错和可伸缩性。

今日应用程序架构:多语言持久性
针对不同的访问模式设计了不同的数据存储系统,并且没有一种单一适用的存储技术能够有效地满足所有可能的数据使用。因此,今天许多应用程序使用几种不同存储技术的组合,这种方法有时称为多语言持久性。例如:
•全文搜索。当用户需要对数据集(例如,产品目录)执行关键字搜索时,需要全文搜索索引。虽然一些关系数据库(如PostgreSQL)包含基本的全文索引功能,但更高级的用法通常需要专用的搜索服务器,如Elasticsearch。为了改进索引或搜索结果排名算法,可能需要不时地重建搜索引擎的索引。
•数据仓库。大多数企业从其OLTP数据库中导出运营数据,并将其加载到数据仓库中以进行业务分析。对于此类分析工作负载(例如,面向列的编码)执行良好的存储布局与OLTP存储引擎的存储布局非常不同,因此需要使用不同的系统。
•流处理。消息代理允许应用程序在事件发生时订阅事件流(例如,表示用户在网站上的动作),并且流处理器提供用于解释和响应那些流的基础结构(例如,检测欺诈或滥用的模式) 。
•应用程序级缓存。为了提高只读请求的性能,应用程序通常维护经常访问的对象的缓存(例如,在memcached中)。当底层数据发生更改时,应用程序会使用自定义逻辑来相应地更新受影响的缓存条目。
请注意,这些存储系统并不完全相互独立。相反,一个系统通常在另一个系统中保存数据的复制或物化视图。因此,当一个系统中的数据更新时,通常需要在另一个系统中更新。

OLTP事务是预定义的和短的
在传统视图中,也就是如今由大多数关系数据库产品实现的,事务是交互式会话,其中客户端的查询和数据修改命令与客户端上的任意处理和业务逻辑交织。此外,交易持续时间没有时间限制,因为会话传统上可能包括人工交互。
然而,今天的现实看起来有所不同。大多数OLTP数据库事务由用户通过HTTP发送到Web应用程序或Web服务的请求触发。在绝大多数应用程序中,事务的跨度不会超过单个HTTP请求的处理。这意味着,当服务将响应发送给用户时,底层数据库上的任何事务都已提交或中止。在跨越多个HTTP请求的用户工作流程中(例如,将项目添加到购物车,进行结帐,确认送货地址,输入付款详细信息以及进行最终确认),没有任何一个事务跨越整个用户工作流程; 只有简短的非交互式事务来处理工作流的单个步骤。
此外,OLTP系统通常执行一组相当小的已知事务模式。在此基础上,一些数据库系统将事务的业务逻辑封装为应用程序提前注册的存储过程。为了执行事务,使用某些输入参数调用存储过程,然后该过程在单个执行线程上运行完成,而不与数据库外的任何节点通信。

异构分布式事务是有问题的
区分两种类型的分布式事务很重要:
•同类分布式事务是指参与节点都运行相同数据库软件的事务。例如,Google的Cloud Spanner和VoltDB是最近支持同类分布式事务的数据库系统。
•异构分布式事务跨越不同供应商的几种不同存储技术。例如,X / Open XA(扩展体系结构)标准定义了跨异构系统执行2PC(两阶段提交)的事务模型,JTA(Java Transaction API)使XA可用于Java应用程序。
虽然一些同类事务实现已经证明是成功的,但异构事务仍然存在问题。就其性质而言,它们只能依赖参与系统的最低公分母。例如,如果应用程序进程在准备阶段失败,则XA事务会阻止执行; 此外,XA不提供死锁检测,也不支持乐观并发控制方案。
此处列出的许多系统(如搜索索引)不支持XA或任何其他异构事务模型。因此,确保跨不同存储技术的写入的原子性仍然是应用程序的挑战性问题。

建立在事件日志之上
多语言持久性特点:需要在两个独立的存储系统(如OLTP数据库(如RDBMS)和全文搜索服务器)中维护记录的应用程序。如果异构分布式事务可用,则系统可以确保跨两个系统的写入的原子性。但是,大多数搜索服务器不支持分布式事务,使系统容易受到这些潜在的不一致性的影响:

• 非原子写入。如果记录已经写入其中一个系统,此时发生故障,就无法写入另一个系统,使得这两个系统记录出现彼此不一致。
• 不同的写入顺序。如果同一记录有两个并发更新请求A和B,一个系统可以按顺序A,B处理它们,而另一个系统按顺序B,A处理它们,系统可能不知道这两种顺序哪种是最新的,也就造成不一致。

下图给出了这些问题的简单解决方案(事件溯源):当应用程序想要更新记录而不是直接将记录写入两个存储系统,只是将更新事件追加到日志中。数据库和搜索索引各自订阅此日志,并按照它们在日志中出现的顺序将更新写入其存储。通过日志对更新进行排序,数据库和搜索索引以相同的顺序应用相同的写入集,使它们彼此保持一致。实际上,数据库和搜索索引是对日志中事件序列的物化视图。该方法解决了上述两个问题:

•将单个事件附加到日志是原子的; 因此,要么两个订阅者都看到一个事件,要么两者都没有。如果订户失败并恢复,它将恢复处理之前未处理的任何事件。因此,如果将更新写入日志,则最终将由所有订户处理。
•日志的所有订阅者都以相同的顺序查看其事件。因此,每个存储系统将以相同的顺序顺序写入记录。
在此示例中,日志仅顺序化写入,但应用程序可以随时从存储系统读取。由于日志订阅者是异步的,因此读取索引可能会返回数据库中尚不存在的记录,反之亦然; 对于许多应用来说,这种瞬态不一致性不是问题。对于那些需要它的应用程序,也可以通过日志序列化读取; 稍后将介绍此示例。

日志抽象
有几个日志实现可以担任这个角色,包括Apache Kafka,CORFU(来自Microsoft Research),Apache Pulsar和Facebook的LogDevice。所需的日志抽象具有以下属性:
• 持久。日志将写入磁盘并复制到多个节点,从而确保在发生故障时不会丢失任何事件。
• 仅附加。只能通过在末尾附加新事件将新事件添加到日志中。除了附加之外,日志可以允许丢弃旧事件(例如,通过截断早于某个保留期的日志段或通过执行基于密钥的日志压缩)。
• 顺序读取。日志的所有订阅者以相同的顺序查看相同的事件。为每个事件分配一个单调递增的LSN(日志序列号)。订阅者从指定的LSN开始读取日志,然后按日志顺序接收所有后续事件。
• 容错。在出现故障时,日志仍然可用于读写。
• 分区。单个日志可以具有它可以支持的最大吞吐量(例如,单个网络接口或单个磁盘的吞吐量)。然而,可以假设系统线性扩展,具有许多分区 - 也就是说,许多独立日志可以分布在许多机器上 - 并且在不同的日志分区之间没有排序保证。可以将多个逻辑日志多路复用到单个物理日志分区中。

关于日志的订阅者做出以下假设:
•订户可以维护根据日志中的事件读取和更新的状态(例如,数据库),并且可以在崩溃中幸存。此外,订户可以将更多事件附加到任何日志(包括其自己的输入)。
•订户定期检查已处理的最新LSN到稳定存储。当用户崩溃时,恢复后它将从最新的检查点LSN恢复处理。因此,订户可以处理一些事件两次(在最后一个检查点和崩溃之间处理的事件),但它从不跳过任何事件。每个订户至少处理一次日志中的事件。
•使用确定性逻辑在单个线程上按顺序处理单个日志分区中的事件。因此,如果订户崩溃并重新启动,它可能会将重复事件附加到其他日志。

现有的基于日志的流处理框架(如Apache Kafka Streams和Apache Samza)满足了这些假设。基于有序日志确定性地更新状态对应于经典状态机复制原则。由于在从故障中恢复时可以多次处理事件,因此状态更新也必须是幂等的。

旁白:恰好一次语义
一些基于日志的流处理器(如Apache Flink)支持所谓的一次性语义,这意味着即使事件可能被处理多次,处理的效果也会像处理完一次一样。此行为是通过管理处理框架内的副作用并将这些副作用与将标记处理的日志部分的检查点一起原子提交来实现的。
但是,当日志消费者写入外部存储系统时,,无法确完全一次的语义,因为这样做需要跨流处理器和存储系统的异构原子提交协议,这在许多情况下是不可用的。例如全文搜索索引存储系统。因此,具有完全一次语义的框架在与外部存储交互时仍然表现出至少一次处理并依赖于幂等性来消除重复处理的影响。

原子性和执行约束
需要原子性的典型示例是在银行/支付系统中,即使两个帐户存储在不同的节点上,从一个帐户到另一个帐户的资金转移必须以原子方式进行。此外,这样的系统通常需要保持一致性属性或不变量(例如,帐户不能透支超过某个设置限制)。图3显示了如何使用OLEP方法而不是分布式事务来实现此类支付应用程序。具有实心头的箭头表示将事件附加到日志,而具有空心头的箭头表示订阅日志中的事件。它的工作原理如下:
1.当用户希望将资金从源帐户转移到目标帐户时,他或她首先将支付请求事件附加到源帐户的日志。此事件仅表明转移资金的意图 ; 这并不意味着转移成功。该事件带有唯一ID以标识请求。
2.单线程支付执行程序进程订阅源帐户日志。它维护一个包含源帐户和当前余额的事务的数据库。该过程基于当前余额和可能的其他因素确定性地检查是否应该允许支付请求。此日志使用者与存储过程的执行非常相似。
3.如果执行程序决定授予付款请求,它会将该事实写入其本地数据库,并将事件附加到几个不同的日志:至少是源帐户日志的付款事件和目的地帐户日志的收款事件。如果此付款需要支付费用(例如,由于透支帐户或货币转换),则可以将附加的付款事件附加到源帐户日志,并且相应的收费付款事件可以附加到收费帐户的日志。原始事件ID包含在所有这些生成的事件中,以便可以跟踪它们的原点。
4.由于执行程序订阅了源帐户日志,因此付款事件将被传递回执行程序。它使用唯一的事件ID来确定它已经处理了此付款并将其记录在其数据库中。
5.其他帐户上的付款事件(例如目标帐户上的收款)同样由单线程执行程序处理,每个帐户有一个单独的执行程序。通过基于原始事件ID抑制重复,使事件处理成为幂等的。
6.处理用户请求的服务器也可以订阅源帐户日志,从而在处理完付款请求时得到通知。该状态信息可以返回给用户。
如果付款执行程序崩溃并重新启动,它可能会重新处理在崩溃之前部分处理的一些付款请求。由于执行程序是确定性的,因此在恢复时它将做出相同的决定来批准或拒绝请求,从而可能将重复的支付事件附加到源,目标和费用日志。但是,根据事件中的ID,下游进程很容易检测并忽略此类重复项。

多分区处理​​​​​​​
在该支付示例中,每个帐户具有单独的日志,因此可以存储在不同的节点上。此外,每个支付执行者只需要订阅来自单个账户的事件,不同的账户由不同的执行者处理。这些因素允许系统线性扩展到任意数量的帐户。
在此示例中,是否允许付款请求的决定仅取决于源帐户的余额; 您可以假设到目标帐户的付款总是成功,因为它的余额只会增加。因此,支付执行者只需要针对源帐户中的其他事件顺序化支付请求。如果其他日志分区需要对决策做出贡献,则支付请求的批准可以作为多阶段过程来执行,其中每个阶段将请求相对于特定日志是顺序化的。
将“事务”拆分为流处理器的多级流水线允许每个阶段仅基于本地数据进行处理; 它确保永远不会阻止一个分区等待与另一个分区的通信或协调。与多分区事务不同,这种流水线设计允许OLEP系统线性扩展。

事件处理的优点
除了这种可伸缩性优势之外,以OLEP样式开发应用程序还有几个优点:
•由于每个日志都可以支持许多独立订阅者,因此可以根据事件日志轻松创建新的派生视图或服务。例如,在图3的支付方案中,如果达到客户信用卡上的特定支出限制,则新帐户日志订户可以向客户的智能手机发送推送通知。可以通过从头到尾使用事件日志来构建现有数据集上的新搜索索引或视图。
•如果应用程序错误导致将错误事件附加到日志中,则很容易恢复:订阅者可以编程为忽略不正确的事件,并且可以重新计算从事件派生的任何视图。相反,在支持任意插入,更新和删除的数据库中,从不正确的写入中恢复更加困难,可能需要从备份中还原数据库。
•类似地,使用仅附加日志而不是可变数据库调试要容易得多,因为可以重放事件以诊断特定情况下发生的事件。
•出于数据建模的目的,与自由形式的数据库突变相比,仅追加事件日志越来越受欢迎; 这种方法在领域驱动设计社区中称为事件溯源。基本原理是事件比表上的插入/更新/删除操作更准确地捕获状态转换和业务流程,并且这些状态更新可以描述为处理事件所产生的副作用。例如,“学生取消课程注册”事件清楚地表达了意图,而副作用“从注册表中删除了一行”和“一个取消原因被添加到学生反馈表”则不太清楚。

(banq注:后两句是数据库世界语言,其中有“表”和“行”等词语,这些陈述语句所指目标是在数据库这个范畴的世界,而不是指向真实世界,条条大路通罗马,如果这个罗马是纸上的罗马,就永远到不了。

•从数据分析的角度来看,事件日志比数据库中的状态更有价值。例如,在电子商务环境中,业务分析师不仅可以在结账时查看购物车的最终状态,而且还可以查看添加到购物车和从购物车中移除的完整商品序列,因为已移除的商品包含信息(例如,一种产品可替代另一种产品,或者顾客可能会在以后的某种情况下返回购买某种商品)。
•对于分布式事务,如果任何一个参与节点不可用,则整个事务必须中止,因此故障会被放大。相反,如果日志具有多个订户,则它们彼此独立地进行:如果一个订户发生故障,则不会妨碍发布者或其他订户的操作,因此包含故障。


OLEP方法的缺点
在前面的示例中,日志使用者会更新数据存储中的状态,虽然OLEP方法确保日志中的每个事件最终都会被每个消费者处理,即使面对崩溃,在处理事件之前没有时间上限。
这意味着如果客户端从两个不同的消费者或日志分区更新的两个不同的数据存储中读取,则客户端读取的值可能彼此不一致。例如,源帐户和目标帐户一个处理完支付,另外一个还在读取日志没有处理完支付。因此,即使帐户最终会收敛到一致状态,但在某个特定时间点读取时,它们可能会不一致。
请注意,在ACID上下文中,防止这种异常属于隔离的标题,而不是原子性 ; 仅具有原子性的系统不保证将以一致的状态读取两个帐户。以“读取已提交”隔离级别运行的数据库事务(包括PostgreSQL,Oracle DB和SQL Server在内的许多系统中的默认隔离级别),这种方式在从两个帐户读取时可能会遇到相同的异常。防止此异常需要更强的隔离级别:“可重复读取”,快照隔离或可序列化。

(banq注:但是更强的隔离级别意味性能更低,意思是数据库的默认ACID设置也不能保证真正强一致,即使是升级到更高级隔离级别,鉴于实现复杂,包括Oracle数据库都有漏洞,可查询相关文章,总之,不一致性在数据库ACID真正产品中也是存在的,只是在ACID理论上不存在,我们都被理论驯化了,忘记第一性原则。)。

目前,OLEP方法不为直接发送到数据存储的读取请求提供隔离(而不是通过日志顺序化)。希望未来的研究能够实现更强的隔离级别,例如跨日志更新的数据存储中的快照隔离。(banq注:读取请求隔离通过订阅主题topic的设计可实现)

案例研究:纽约时报
纽约时报维持在Apache kafka保存了1851年以来出版的所有文本内容,图像文件存储在单独的系统中,但图像的URL和标题也存储为日志事件。
每当发布或更新一段内容(称为资产)时,都会在该日志中附加一个事件。有几个系统订阅了这个日志:例如,每篇文章的全文都写入索引服务进行全文搜索; 需要更新各种缓存页面(例如,具有特定标签的文章列表,或特定作者的所有文章); 和个性化系统通知可能对新文章感兴趣的读者。
每个资产都被赋予唯一标识符,并且事件可以创建或更新具有给定ID的资产。此外,事件可以引用其他资产的标识符 - 非常类似于关系数据库中的规范化模式,其中一个记录可以引用另一个记录的主键。例如,图像(具有标题和其他元数据)是可以由一个或多个文章引用的资产。
日志中的事件顺序满足两个规则:
•只要一个资产引用另一个资产,发布引用资产的事件就会出现在引用资产之前的日志中。
•更新资产时,最新版本是日志中最新事件发布的版本。
例如,编辑器可能会发布图像,然后更新文章以引用图像。然后,日志的每个使用者按顺序通过三个状态:
1.存在旧版本的文章(不引用图像)。
2.图像也存在,但尚未被任何文章引用。
3.文章和图像都存在,文章引用图像。
不同的日志消费者将在不同的时间以相同的顺序通过这三种状态。日志顺序确保没有消费者处于文章引用尚不存在的图像的状态,从而确保引用的完整性。
此外,每当更新图像或标题时,需要在缓存和搜索索引中更新引用该图像的所有文章。这种一致性模型很容易适用于日志,它提供了分布式事务的大部分好处,而且没有性能成本。

结论
跨异构存储技术的分布式事务支持要么不存在,要么受到差的操作和性能特征的影响。相比之下,OLEP越来越多地用于在这种设置中提供良好的性能和强一致性保证。
在数据系统中,将日志(例如,预写日志)用作内部实现细节是很常见的。OLEP方法是不同的:它使用事件日志而不是事务作为数据管理的主要应用程序编程模型。仍然使用传统数据库,但它们的写入来自日志而不是直接来自应用程序。工业界的一些有影响力的人物已经探索过这种方法,例如Jay Kreps,4 Martin Fowler和Greg Young,其名称包括事件采购/溯源和CQRS(Command / Query Responsibility Segregation)。
OLEP的使用不仅仅是开发人员的实用主义,而是提供了许多优点。这些包括线性可扩展性; 一种有效管理多语言持久性的方法; 支持增量开发,迭代添加或删除新的应用程序功能或存储技术; 通过直接访问事件日志,对调试提供出色的支持; 并提高可用性(因为当其他节点发生故障时,运行节点可以继续进行)。
因此,预计OLEP将越来越多地用于在使用异构存储技术的大规模系统中提供强一致性。