第11章 流处理系统
批处理系统有一个很大的假设:即输入是有界的,即已知和有限的⼤小,所以批处理知道它何时完成输⼊的读取。
实际上,很多数据是⽆界限的,因为它随着时间的推移而逐渐到达:你的用户在昨天和今天产⽣了数据,明天他们将继续产⽣更多的数据。除非你停业,否则这个过程永远都不会结束,所以数据集从来就不会以任何有意义的⽅式“完成”。因此,批处理程序必须将数据⼈为地分成固定时间段的数据块, 例如,在每天结束时处理理一天的数据,或者在每小时结束时处理一小时的数据。
日常批处理中的问题是,输⼊的变更只会在⼀天之后的输出中反映出来,这对于许多急躁的用户来说太慢了。为了减少延迟,我们可以更频繁地运行处理——比如说,在每秒钟的末尾——或者甚⾄更连续一些,完全抛开固定的时间切片,当事件发⽣时就立即进⾏处理,这就是流处理背后的想法。
发送事件流
当输⼊是⼀个⽂件(一个字节序列),第⼀个处理步骤通常是将其解析为⼀系列记录。在流处理的上下文中,记录通常被叫做事件(event),但它本质上是一样的:⼀个小的,独立的,不可变的对象, 包含某个时间点发生的某件事情的细节。一个事件通常包含⼀个来⾃墙上时钟的时间戳,以指明事件发⽣的时间。
事件可能被编码为文本字符串或JSON,或者某种二进制编码。这种编码允许你存储一个事件,例如将其附加到⼀个文件,将其插⼊关系表,或将其写⼊文档数据库。它还允许你通过网络将事件发送到另⼀个节点以进行处理。
在批处理中,⽂件被写⼊一次,然后可能被多个作业读取。类似地,在流处理术语中,一个事件由⽣产者生成⼀次,然后可能由多个消费者或接收者进⾏处理。在⽂件系统中,文件名标识⼀相关记录;在流媒体系统中,相关的事件通常被聚合为⼀个主题或流。
原则上将,⽂件或数据库就足以连接生产者和消费者:生产者将其生成的每个事件写入数据存储,且每个消费者定期轮询数据存储,检查自上次运行以来新出现的事件。这实际上正是批处理在每天结束时处理当天数据时所做的事情。
但当我们想要进⾏低延迟的连续处理时,如果数据存储不是为这种⽤途专门设计的,那么轮询开销就会很⼤。轮询的越频繁,能返回新事件的请求⽐例就越低,⽽额外开销也就越高。相比之下,最好能在新事件出现时直接通知消费者。
消息系统
向消费者通知新事件的常⽤方式是使用消息传递系统:生产者发送包含事件的消息,然后将消息推送给消费者。
像生产者和消费者之间的Unix管道或TCP连接这样的直接信道,是实现消息传递系统的简单⽅法。但是,大多数消息传递系统都在这一基本模型上进⾏扩展。特别的是,Unix管道和TCP仅连接一个发送者与一个接收者,⽽一个消息传递系统允许多个生产者节点将消息发送到同一个主题,并允许多个消费者节点接收主题中的消息。
在这个发布/订阅模式中,不同的系统采取各种各样的方法,并没有针对所有⽬的的通用答案。为了区分这些系统,问一下这两个问题会特别有帮助:
- 如果生产者发送消息的速度比消费者能够处理的速度快会发生什么?一般来说,有三种选择:系统可以丢掉消息,将消息放入缓冲队列,或使用背压(也称为流量量控;即阻塞生产者,以免其发送更多的消息)。例如Unix管道和TCP使用背压:它们有⼀个固定⼤小的缓冲区,如果填满,发送者会被阻塞,直到接收者从缓冲区中取出数据。
如果消息被缓存在队列中,那么理解队列增⻓会发生什么是很重要的。当队列装不不进内存时系统会崩溃吗?还是将消息写⼊磁盘?如果是这样,磁盘访问又会如何影响消息传递系统的性能? - 如果节点崩溃或暂时脱机,会发⽣什么情况?——是否会有消息丢失?与数据库一样,持久性可能需要写入磁盘和/或复制的某种组合,这是有代价的。如果你能接受有时消息会丢失,则可能在同一硬件上获得更高的吞吐量和更低的延迟。
生产者与消费者之间的直接消息传递
许多消息传递系统使⽤生产者和消费者之间的直接⽹络通信,⽽不通过中间节点。
尽管这些直接消息传递系统在设计它们的环境中运⾏良好,但是它们通常要求应⽤用代码意识到消息丢失的可能性。它们的容错程度极为有限:即使协议检测到并重传在网络中丢失的数据包,它们通常也只是假设生产者和消费者始终在线。
如果消费者处于脱机状态,则可能会丢失其不可达时发送的消息。一些协议允许⽣产者重试失败的消息传递,但当⽣产者崩溃时,它可能会丢失消息缓冲区及其本应发送的消息,这种⽅法可能就没⽤了。
消息代理
⼀种⼴泛使⽤的替代⽅法是通过消息代理(message broker)(也称为消息队列列)发送消息,消息代理实质上是一种针对处理理消息流⽽优化的数据库。它作为服务器运行,⽣产者和消费者作为客户端连接到服务器。生产者将消息写入代理,消费者通过从代理那⾥读取来接收消息。
通过将数据集中在代理上,这些系统可以更容易地适应不断变化的客户端(连接,断开连接和崩溃),而持久性问题则转移到代理的身上。⼀一消息代理只将消息保存在内存中,而另一些消息代理(取决于配置)将其写⼊磁盘,以便在代理崩溃的情况下不会丢失。针对缓慢的消费者,它们通常会允许⽆上限的排队(而不是丢弃消息或背压),尽管这种选择也可能取决于配置。
排队的结果是,消费者通常是异步的:当生产者发送消息时,通常只会等待代理确 认消息已经被缓存,⽽不等待消息被消费者处理。向消费者递送消息将发生在未来某个未定的时间点——通常在⼏分之⼀秒之内,但有时当消息堆积时会显著延迟。
消息代理与数据库对比
有些消息代理甚⾄可以使用XA或JTA参与两阶段提交协议务。这个功能与数据库在本质上非常相似,尽管消息代理和数据库之间仍存在实践上很重要的差异:
- 数据库通常保留数据直⾄显式删除,⽽大多数消息代理在消息成功递送给消费者时会⾃动删除消息。这样的消息代理不适合长期的数据存储。
- 由于它们很快就能删除消息,大多数消息代理都认为它们的工作集相当⼩——即队列很短。如果代理需要缓冲很多消息,⽐如因为消费者速度较慢(如果内存装不下消息,可能会溢出到磁盘), 每个消息需要更长的处理时间,整体吞吐量可能会恶化。
- 数据库通常⽀持⼆级索引和各种搜索数据的⽅式,而消息代理通常支持按照某种模式匹配主题,订阅其子集。 这可以些机制虽然不同,但本质上都是让客户端可以选择它们想要了解的部分数据。
- 查询数据库时,结果通常基于某个时间点的数据快照;如果另⼀个客户端随后向数据库写⼊一些改变了查询结果的内容,则第⼀个客户端不会发现其先前结果现已过期(除非它重复查询或轮询变更)。相⽐之下,消息代理不支持任意查询,但是当数据发生变化时(即新消息可用时),它们会通知客户端。
多个消息者
当多个消费者从同一主题中读取消息时,有使⽤两种主要的消息传递模式:
- 负载均衡
每条消息都被传递给消费者之一,所以处理该主题下消息的工作能被多个消费者共享。代理可以为消费者任意分配消息。当处理消息的代价高昂,希望能并行处理消息时,此模式⾮常有⽤。 - 扇出
每条消息都被传递给所有消费者。扇出允许几个独立的消费者各自“收听”相同的消息广播,⽽不会相互影响
两种模式可以组合使⽤:例如,两个独立的消费者组可以每组各订阅一个主题,每⼀组都共同收到所有消息,但在每一组内部,每条消息仅由单个节点处理。
确认和重新传递
消费随时可能会崩溃,所以有一种可能的情况是:代理向消费者递送消息,但消费者没有处理,或者在消费者崩溃之前只进⾏了部分处理。为了确保消息不会丢失,消息代理使用确认:客户端必须显式告知代理消息处理完毕的时间,以便代理能将消息从队列中移除。
如果与客户端的连接关闭,或者代理超出一段时间未收到确认,代理则认为消息没有被处理,因此它将消息再递送给另一个消费者。 (请注意可能发生这样的情况,消息实际上是处理完毕的,但确认在⽹络中丢失了。这需要⼀种原子提交协议才能来处理)。
当与负载均衡相结合时,这种重传⾏为对消息的顺序有种有趣的影响。在下图中,消费者通常按照⽣产者发送的顺序处理消息。然⽽消费者2在处理消息m3时崩溃,与此同时消费者1正在处理消息m4。未确认的消息m3随后被重新发送给消费者1,结果消费者1按照m4,m3,m5的顺序处理消息。因此m3 和m4的交付顺序与以⽣产者1的发送顺序不同。
即使消息代理试图保留消息的顺序,负载均衡与重传的组合也不可避免地导致消息被重新排序。为避免此问题,你可以让每个消费者使用单独的队列(即不使⽤负载均衡功能)。如果消息是完全独立的,则消息顺序重排并不是一个问题。如果消息之间存在因果依赖关系,这就是⼀个很重要的问题。
分区日志
基于日志的消息存储
⽇志只是磁盘上仅支持追加式修改记录的序列。我们可以这样使用日志来实现消息代理:生产者通过将消息追加到⽇志末尾来发送消息,而消费者通过依次读取⽇志来接收消息。如果消费者读到⽇志末尾,则会等待新消息追加的通知。 Unix工具 tail -f 能监视文件被追加写入的数据,基本上就是这样工作的。
为了扩展到⽐单个磁盘所能提供的更高吞吐量,可以对⽇志进⾏分区。不同的分 区可以托管在不同的机器上,且每个分区都拆分出一份能独立于其他分区进⾏读写的日志。⼀个主题可以定义为⼀组携带相同类型消息的分区。这种方法下图所示:
在每个分区内,代理为每个消息分配一个单调递增的序列号或偏移量。这种序列号是有意义的,因为分区是仅追加写入的,所以分区内的消息是完全有序的。不同的分区之间则没有顺序保证。
Apache Kafka等消息系统采用的是这种基于日志的工作方式,而AMQP/JMS风格的传统消息系统在消费者确认消息后,会从代理中删除该消息。
对比日志与传统消息系统
基于⽇志的方法天然⽀持扇出式消息传递,因为多个消费者可以独⽴读取日志,⽽不会相互影响——读取消息不会将其从日志中删除。为了在一组消费者之间实现负载平衡,代理可以将整个分区分配给消费者组中的节点,⽽不是将单条消息分配给消费者客户端。
每个客户端消费指派分区中的所有消息。然后使⽤分配的分区中的所有消息。通常情况下,当⼀个用户被指派了一个日志分区时,它会以简单的单线程⽅式顺序地读取分区中的消息。这种粗粒度的负载均衡方法有⼀一些缺点:
- 共享消费主题工作的节点数,最多为该主题中的⽇志分区数,因为同一个分区内的所有消息被递送到同一个节点。
- 如果某条消息处理缓慢,则它会阻塞该分区中后续消息的处理(一种⾏首阻塞的形式)。
因此在消息处理代价⾼昂,希望逐条并⾏处理,以及消息的顺序并没有那么重要的情况下,JMS/AMQP⻛格的消息代理是可取的。另⼀方面,在消息吞吐量很⾼,处理迅速,顺序很重要的情况下,基于日志的方法表现得非常好。
消费者偏移量
顺序消费⼀个分区使得判断消息是否已经被处理变得相当容易:所有偏移量小于消费者的当前偏移量的消息已经被处理理,⽽具有更大偏移量的消息还没有被看到。因此,代理不需要跟踪确认每条消息,只需要定期记录消费者的偏移即可。这种方法减少了额外记录开销,而且在批处理和流处理中采用这种方法有助于提⾼基于日志的系统的吞吐量。
实际上,这种偏移量与单领导者数据库复制中常⻅的⽇志序列号⾮常相似。在数据库复制中,⽇志序列号允许跟随者断开连接后,重新连接到领导者,并在不跳过任 何写⼊的情况下恢复复制。这⾥原理完全相同:消息代理的表现得像⼀个主库,⽽而消费者就像⼀个从库。
如果消费者节点失效,则失效消费者的分区将指派给其他节点,并从最后记录的偏移量开始消费消息。如果消费者已经处理了后续的消息,但还没有记录它们的偏移量,那么重启后这些消息将被处理两次。
磁盘空间使用
如果只追加写⼊日志,则磁盘空间终究会耗尽。为了回收磁盘空间,日志实际上被分割成段,并不时地将旧段删除或移动到归档存储。
这就意味着如果一个慢消费者跟不上消息产生的速率而落后的太多,它的消费偏移量指向了删除的段, 那么它就会错过⼀些消息。实际上,日志实现了一个有限⼤小的缓冲区,当缓冲区填满时会丢弃旧消息,它也被称为循环缓冲区或环形缓冲区。不过由于缓冲区在磁盘上,因此可能相当的⼤。
因为每个消息都被写入到磁盘,日志的吞吐量基本保持不变。这种⾏为与默认将消息保存在内存中,仅当队列太⻓时才写⼊磁盘的消息传递系统形成鲜明对比。当队列很短时,这些系统⾮常快;⽽当这些系统开始写入磁盘时,就要慢的多,所以吞吐量取决于保留的历史数量。
当消费者跟不上生产者时
如果消费者无法跟上⽣产者发送信息的速度时,我们有三种选择:丢弃信息,进⾏缓冲或施加背压。在这种分类法里,基于⽇志的方法是缓冲的⼀种形式,具有很⼤,但⼤小固定的缓冲区(受可用磁盘空间的限制)。
如果消费者远远落后,而所要求的信息⽐保留在磁盘上的信息还要旧,那么它将不不能读取这些信息,所以代理实际上丢弃了比缓冲区容量更大的旧信息。你可以监控消费者落后日志头部的距离,如果落后太多就发出报警。由于缓冲区很大,因⽽而有足够的时间让运维来修复慢消费者,并在消息开始丢失之前让其赶上。
即使消费者真的落后太多开始丢失消息,也只有那个消费者受到影响;它不会中断其他消费者的服务。
这是一个巨⼤的运维优势:你可以实验性地消费⽣产日志,以进⾏开发,测试或调试,⽽不必担心会中断⽣产服务。当消费者关闭或崩溃时,会停⽌消耗资源,唯⼀剩下的只有消费者偏移量。
重新处理信息
我们之前提到,使⽤AMQP和JMS⻛格的消息代理,处理和确认消息是一个破坏性的操作,因为它会导致消息在代理上被删除。另⼀⽅面,在基于⽇志的消息代理中,使⽤消息更像是从⽂件中读取数据:这是只读操作,不会更改日志。
除了消费者的任何输出之外,处理的唯⼀副作⽤是消费者偏移量的前进。但偏移量是在消费者的控制之下的,所以如果需要的话可以很容易地控制:例如你可以⽤昨天的偏移量跑⼀个消费者副本,并将输出写到不同的位置,以便重新处理最近⼀天的消息。你可以使⽤各种不同的处理代码重复任意次。
这⼀⽅面使得基于日志的消息传递更像上一章的批处理,其中衍⽣数据通过可重复的转换过程与输⼊数据显式分离。它允许进⾏更多的实验,更容易从错误和漏洞中恢复,使其成为在组织内集成数据流的良好工具。
数据库与流
保持系统同步
没有⼀个系统能够满⾜所有的数据存储,查询和处理需求。在实践中,⼤多数重要应用都需要组合使⽤几种不同的技术来满足所有的需求:例如,使⽤OLTP数据库来为⽤户请求提供服务,使⽤缓存来加速常⻅请求,使⽤全文索引处理搜索查询,使⽤数据仓库⽤于分析。每一个组件都有⾃己的数据副本,有⾃己的存储,并根据⾃己的⽬的进⾏优化。
由于相同或相关的数据出现在了不同的地方,因此相互间需要保持同步:如果某个项⽬在数据库中被更新,它也应当在缓存,搜索索引和数据仓库中被更新。对于数据仓库,这种同步通常由ETL进程执⾏,通常是先取得数据库的完整副本,然后执⾏转换,并批量加载到数据仓库中。
如果周期性的完整数据库转储过于缓慢,有时会使⽤的替代⽅法是双写,其中应⽤代码在数据变更时明确写⼊每个系统:例如,⾸先写⼊数据库,然后更新搜索索引,然后使缓存项失效(甚至同时执⾏这些写⼊)。
但是,双写有⼀些严重的问题,其中一个是竞争条件,如下图所示。在这个例⼦中,两个客户端同时想要更新一个项⽬X:客户端1想要将值设置为A,客户端2想要将其设置为B。两个客户端⾸先将新值写入数据库,然后将其写入到搜索索引。因为运⽓气不好,这些请求的时序是交错的:数据库⾸先看到来⾃客户端1的写⼊将值设置为A,然后来自客户端2的写入将值设置为B,因此数据库中的最终值为B。搜索索引首先看到来⾃客户端2的写入,然后是客户端1的写入,所以搜索索引中的最终值是A。即使没发⽣错误,这两个系统现在也永久地不一致了。
除⾮有一些额外的并发检测机制(如版本向量),否则你甚⾄不会意识到发⽣了并发写⼊——一个值将简单地以⽆提示⽅式覆盖另一个值。
双重写入的另⼀个问题是,其中一个写入可能会失败,⽽另一个成功。这是⼀个容错问题,⽽不是一个并发问题,但也会造成两个系统互相不一致的结果。确保它们要么都成功要么都失败,是原子提交问题的一个例子,解决这个问题的代价是昂贵的。
如果你只有一个单领导者复制的数据库,那么这个领导者决定了写入顺序,⽽状态机复制⽅法可以在数据库副本上工作。然⽽,在上图中,没有单个主库:数据库可能有⼀个领导者,搜索索引也可能有⼀个领导者,但是两者都不追随对方,所以可能会发生冲突。
变更数据捕获
最近,⼈们对变更数据捕获(change data capture, CDC)越来越感兴趣,这是⼀种观察写入数据库的所有数据变更,并将其提取并转换为可以复制到其他系统中的形式的过程。 CDC是非常有意思的,尤其是当变更能在被写⼊后⽴刻用于流时。
例如,你可以捕获数据库中的变更,并不断将相同的变更应⽤至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他衍⽣数据系统只是变更流的消费者,如下图所示。
实现变更数据捕获
我们可以将⽇志消费者叫做衍⽣数据系统,比如,存储在搜索索引和数据仓库中的数据,只是记录系统数据的另一种视图。变更数据捕获是一种机制,可确保对记录系统所做的所有更改都反映在派生数据系统中,以便派生系统具有数据的准确副本。
从本质上说,变更数据捕获使得⼀个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序(避免了重新排序问题)。
数据库触发器可用来实现变更数据捕获,通过注册观察所有变更的触发器,并将相应的变更项写⼊变更日志表中。但是它们往往是脆弱的,而且有显著的性能开销。解析复制日志可能是一种更稳健的⽅方法,但它也很有挑战,例如应对模式变更。
像消息代理一样,变更数据捕获通常是异步的:记录数据库系统不会等待消费者应用变更再进行提交。 这种设计具有的运维优势是,添加缓慢的消费者不会过度影响记录系统。不过,所有复制延迟可能有的问题在这里都可能出现。
初始快照
如果你拥有所有对数据库进行变更的日志,则可以通过重放该⽇志,来重建数据库的完整状态。但是在许多情况下,永远保留所有更改会耗费太多磁盘空间,且重放过于费时,因此⽇志需要被截断。
例如,构建新的全文索引需要整个数据库的完整副本——仅仅应用最近变更的⽇志是不够的,因为这样会丢失最近未曾更新的项⽬。因此,如果你没有完整的历史⽇志,则需要从一个一致的快照开始。
数据库的快照必须与变更日志中的已知位置或偏移量相对应,以便在处理完快照后知道从哪⾥开始应⽤变更。
日志压缩
如果你只能保留有限的历史⽇志,则每次要添加新的衍⽣数据系统时,都需要做⼀次快照。但⽇志压缩(log compaction)提供了一个很好的备选⽅案。
原理很简单:存储引擎定期在⽇志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。
在日志结构存储引擎中,具有特殊值NULL(墓碑(tombstone))的更新表示该键被删除,并会在⽇志压缩过程中被移除。但只要键不被覆盖或删除,它就会永远留在日志中。这种压缩⽇志所需的磁盘空间仅取决于数据库的当前内容,⽽不取决于数据库中曾经发⽣的写入次数。如果相同的键经常被覆盖写入,则先前的值将最终将被垃圾回收,只有最新的值会保留下来。
在基于⽇志的消息代理与变更数据捕获的上下⽂中也适⽤相同的想法。如果CDC系统被配置为,每个变更都包含一个主键,且每个键的更新都替换了该键以前的值,那么只需要保留对键的最新写入就⾜够了。
现在,⽆论何时需要重建衍⽣数据系统,你可以从压缩⽇志主题0偏移量处启动新的消费者,然后依次扫描日志中的所有消息。日志能保证包含数据库中每个键的最新值—— 换句话说,你可以使用它来获取数据库内容的完整副本,⽽无需从CDC源数据库取一个快照。
事件溯源
我们在这⾥讨论的想法和事件溯源(Event Sourcing)之间有一些相似之处,这是一个在领域驱动设计(domain-driven design, DDD)社区中折腾出来的技术。我们将简要讨论事件溯源,因为它包含了一些关于流处理系统的有用想法。
与变更数据捕获类似,事件溯源涉及到将所有对应⽤状态的变更存储为变更事件⽇志。最⼤的区别是事件溯源将这一想法应用到了几个不同的抽象层次上:
- 在变更数据捕获中,应⽤以可变⽅式使⽤数据库,任意更新和删除记录。变更⽇志是从数据库的底层提取的(例如,通过解析复制⽇志),从⽽确保从数据库中提取的写⼊顺序与实际写入的顺序相匹配,从⽽避免竞态条件。写入数据库的应⽤不需要知道CDC的存在。
- 在事件溯源中,应⽤逻辑显式构建在写入事件日志的不可变事件之上。在这种情况下,事件存储是仅追加写⼊的,更新与删除是不⿎励的或禁止的。事件被设计为旨在反映应用层面发生的事情,⽽不是底层的状态变更。
事件源是⼀种强大的数据建模技术:从应用的角度来看,将⽤户的⾏为记录为不可变的事件更有意义, ⽽不是在可变数据库中记录这些行为的影响。事件代理使得应用随时间演化更为容易,通过事实更容易理解事情发生的原因,使得调试更为容易,并有利于防⽌应用Bug。
诸如Event Store这样的专业数据库已经被开发出来,供使⽤事件溯源的应用使⽤用,但总的来说,这种方法独立于任何特定的工具。传统的数据库或基于日志的消息代理也可以用来构建这种⻛格的应用。
从事件日志导出当前状态
事件⽇志本身并不是很有用,因为用户通常期望看到的是系统的当前状态,⽽不是变更历史。例如,在购物⽹站上,用户期望能看到他们购物⻋里的当前内容,⽽不是他们购物车所有变更的一个仅追加列表。
因此,使⽤事件溯源的应用需要拉取事件⽇志(表示写入系统的数据),并将其转换为适合向用户显示的应用状态(从系统读取数据的方式)。这种转换可以使⽤任意逻辑,但它应当是确定性的,以便能再次运行,并从事件日志中衍⽣出相同的应用状态。
与变更数据捕获一样,重放事件⽇志允许让你重新构建系统的当前状态。不过,⽇志压缩需要采⽤不同的⽅式处理:
- ⽤于记录更新的CDC事件通常包含记录的完整新版本,因此主键的当前值完全由该主键的最近事件确定,⽽日志压缩可以丢弃相同主键的先前事件。
- 另一⽅面,事件溯源在更高层次进⾏建模:事件通常表示用户操作的意图,而不是因为操作⽽发⽣的状态更新机制。在这种情况下,后⾯的事件通常不会覆盖先前的事件,所以你需要完整的历史事件来重新构建最终状态。这⾥进行同样的日志压缩是不可能的。
使⽤事件溯源的应用通常有⼀些机制,用于存储从事件日志中导出的当前状态快照,因此它们不需要重复处理完整的日志。然⽽这只是⼀种性能优化,用来加速读取,提⾼从崩溃中恢复的速度;真正的⽬的是系统能够永久存储所有原始事件,并在需要时重新处理完整的事件日志。
命令和事件
事件溯源的哲学是仔细区分事件和命令。当来⾃用户的请求刚到达 时,它一开始是一个命令:在这个时间点上它仍然可能可能失败,⽐如,因为违反了一些完整性条件。 应用必须首先验证它是否可以执⾏该命令。如果验证成功并且命令被接受,则它变为⼀个持久化且不可变的事件。
在事件⽣成的时刻,它就成为了事实。即使客户稍后决定更改或取消预订,他们之前曾预定了某个特定座位的事实仍然成⽴,⽽更改或取消是之后添加的单独的事件。
事件流的消费者不允许拒绝事件:当消费者看到事件时,它已经成为⽇志中不可变的⼀部分,并且可能已经被其他消费者看到了。因此任何对命令的验证,都需要在它成为事件之前同步完成。
从同一事件日志中派生多个视图
从不变的事件日志中分离出可变的状态,你可以针对不同的读取⽅式,从相同的事件⽇志中衍生出几种不同的表现形式。效果就像⼀个流的多个消费者一样。
添加从事件日志到数据库的显式转换,能够使应⽤更容易地随时间演进:如果你想要引⼊一个新功能, 以新的⽅式表示现有数据,则可以使⽤事件日志来构建⼀个单独的,针对新功能的读取优化视图,⽆需修改现有系统⽽与之共存。并行运行新旧系统通常比在现有系统中执行复杂的模式迁移更容易。一旦不再需要旧的系统,你可以简单地关闭它并回收其资源。
如果你不需要担⼼如何查询与访问数据,那么存储数据通常是非常简单的。模式设计,索引和存储引擎的许多复杂性,都是希望⽀持某些特定查询和访问模式的结果。出于这个原因,通过将数据写入的形式与读取形式相分离,并允许⼏个不同的读取视图,能获得很大的灵活性。这个想法有时被称为命令查询责任分离(command query responsibility segregation, CQRS)。
数据库和模式设计的传统⽅法是基于这样⼀种谬论,数据必须以与查询相同的形式写入。如果可以将数据从针对写⼊优化的事件⽇志转换为针对读取优化的应用状态,那么有关规范化和⾮规范化的争论就变得无关紧要了:在针对读取优化的视图中对数据进⾏非规范化是完全合理的,因为翻译过程提供了使其与事件⽇志保持一致的机制。
不变性的约束
许多不使⽤事件溯源模型的系统也还是依赖不可变性:各种数据库在内部使⽤不可变的数据结构或多版本数据来⽀持时间点快照。
永远保持所有变更的不变历史,在多大程度上是可行的?答案取决于数据集的流失率。⼀些⼯作负载主要是添加数据,很少更新或删除;它们很容易保持不变。其他工作负载在相对较小的数据集上有较⾼的更新/删除率;在这些情况下,不可变的历史可能增至难以接受的巨大,碎片化可能成为一个问题,压缩与垃圾收集的表现对于运维的稳健性变得至关重要。
除了性能⽅面的原因外,也可能有出于管理方面的原因需要删除数据的情况,尽管这些数据都是不可变的。例如,隐私条例可能要求在用户关闭帐户后删除他们的个⼈信息,数据保护⽴法可能要求删除错误的信息,或者可能需要阻⽌敏感信息的意外泄露。
在这种情况下,仅仅在日志中添加另一个事件来指明先前的数据应该被视为删除是不够的——你实际上是想改写历史,并假装数据从⼀开始就没有写入。
真正删除数据是⾮常⾮常困难的,因为副本可能存在于很多地⽅:例如,存储引擎,⽂件系统和SSD通常会向⼀个新位置写入,⽽不是原地覆盖旧数据,⽽备份通常是特意做成不可变的,防⽌意外删除或损坏。删除更多的是“使取回数据更困难”,⽽不是“使取回数据不可能”。
流处理
流一般会有一下三种处理方式:
- 你可以将事件中的数据写⼊数据库,缓存,搜索索引或类似的存储系统,然后能被其他客户端查询。
- 你能以某种⽅式将事件推送给用户,例如发送报警邮件或推送通知,或将事件流式传输到可实时显示的仪表板上。
- 你可以处理一个或多个输入流,并产⽣一个或多个输出流。流可能会经过由几个这样的处理阶段组成的流⽔线,最后再输出。
与批量作业相⽐的⼀个关键区别是,流不会结束。这种差异会带来很多隐含的结果。比如,排序对⽆界数据集没有意义,因此⽆法使⽤排序合并联接。容错机制也必须改变:对于已经运⾏了几分钟的批处理作业,可以简单地从头开始重启失败任 务,但是对于已经运⾏数年的流作业,重启后从头开始跑可能并不是一个可行的选项。
流处理的适用场景
长期以来,流处理一直⽤于监控目的,如果某个事件发生,希望能得到警报。例如:
- 欺诈检测系统需要确定信⽤卡的使⽤模式是否有意外地变化,如果信⽤卡可能已被盗刷,则锁卡。
- 交易系统需要检查金融市场的价格变化,并根据指定的规则进行交易。
- 制造系统需要监控⼯厂中机器的状态,如果出现故障,可以快速定位问题。
- 军事和情报系统需要跟踪潜在侵略略者的活动,并在出现袭击征兆时发出警报。
这些类型的应⽤需要非常精密复杂的模式匹配与相关检测。然而随着时代的进步,流处理的其他⽤用途也开始出现。
复杂事件处理
复合事件处理(complex event processing, CEP)是20世纪90年代为分析事件流而开发出的一种⽅法,尤其适⽤于需要搜索某些事件模式的应⽤。与正则表达式允许你在字符串中搜索特定字符模式的⽅式类似,CEP允许你指定规则以在流中搜索某些事件模式。
CEP系统通常使⽤高层次的声明式查询语⾔,比如SQL,或者图形⽤户界面,来描述应该检测到的事件模式。这些查询被提交给处理引擎,该引擎消费输入流,并在内部维护⼀个执行所需匹配的状态机。当发现匹配时,引擎发出一个复合事件,并附有检测到的事件模式详情。
在这些系统中,查询和数据之间的关系与普通数据库相比是颠倒的。通常情况下,数据库会持久存储数据,并将查询视为临时的:当查询进⼊时,数据库搜索与查询匹配的数据,然后在查询完成时丢掉查询。 CEP引擎反转了角色:查询是⻓期存储的,来⾃输入流的事件不断流过它们,搜索匹配事件模式的查询。
流分析
使⽤流处理的另一个领域是对流进行分析。 CEP与流分析之间的边界是模糊的,但⼀般来说,分析往往对找出特定事件序列并不关心,⽽更关注⼤量事件上的聚合与统计指标——例如:
- 测量某种类型事件的速率
- 滚动计算一段时间窗⼝内某个值的平均值
- 将当前的统计值与先前的时间区间的值对比(例如,检测趋势,当指标与上周同比异常偏高或偏低时报警)
这些统计值通常是在固定时间区间内进⾏计算的,例如,你可能想知道在过去5分钟内服务每秒查询次数的均值,以及此时间段内响应时间的第99百分位点。在几分钟内取平均,能抹平秒和秒之间的无关波动,且仍然能向你展示流量模式的时间图景。聚合的时间间隔称为窗⼝。
流分析系统有时会使用概率算法,例如Bloom filter来管理成员资格,HyperLogLog用于基数估计以及各种百分比估计算法。概率算法产出近似的结果,但⽐起精确算法的优点是内存使⽤要少得多。使⽤近似算法有时让人们觉得流处理系统总是有损的和不精确的,但这是错误看法:流处理并没有任何内在的近似性,而概率算法只是一种优化。
维护物化视图
数据库的变更流可以⽤于维护衍生数据系统(如缓存,搜索索引和数据仓库),使其与源数据库保持最新。我们可以将这些示例视作维护物化视图的一种具体场景:在某个数据集上衍生出⼀个替代视图以便高效查询,并在底层数据变更时更新视图。
同样,在事件溯源中,应⽤程序的状态是通过应⽤(apply)事件日志来维护的;这⾥的应用状态也是一种物化视图。与流分析场景不同的是,仅考虑某个时间窗⼝内的事件通常是不够的:构建物化视图可能需要任意时间段内的所有事件,除了那些可能由日志压缩丢弃的过时事件。实际上,你需要一个可以一直延伸到时间开端的窗口。
在流上搜索
除了允许搜索由多个事件构成模式的CEP外,有时也存在基于复杂标准来搜索单 个事件的需求。
例如,媒体监测服务可以订阅新闻⽂章Feed与来自媒体的播客,搜索任何关于公司,产品或感兴趣的话题的新闻。这是通过预先构建一个搜索查询来完成的,然后不断地将新闻项的流与该查询进⾏匹配。在一些网站上也有类似的功能:例如,当市场上出现符合其搜索条件的新房产时,房地产网站的⽤户可以要求网站通知他们。 Elasticsearch的这种过滤器功能,是实现这种流搜索的一种选择。
传统的搜索引擎首先索引⽂件,然后在索引上跑查询。相比之下,搜索一个数据流则反了过来:查询被存储下来,文档从查询中流过,就像在CEP中一样。简单的情况就是,你可以为每个文档测试每个查询。但是如果你有⼤量查询,这可能会变慢。为了优化这个过程,可以像对文档一样,为查询建⽴索引。因⽽收窄可能匹配的查询集合。
流的时间问题
流处理通常需要与时间打交道,尤其是⽤于分析目的时候,会频繁使⽤时间窗⼝口。“最后五分钟”的含义看上去似乎是清晰⽽无歧义的,但不幸的是,这个概念⾮常棘手。
在批处理中过程中,⼤量的历史事件迅速收缩。如果需要按时间来分析,批处理器需要检查每个事件中嵌⼊的时间戳。读取运行批处理机器的系统时钟没有任何意义,因为处理运行的时间与事件实际发生的时间无关。
批处理可以在几分钟内读取一年的历史事件;在大多数情况下,感兴趣的时间线是历史中的一年,⽽不是处理中的几分钟。⽽且使用事件中的时间戳,使得处理是确定性的:在相同的输入上再次运⾏相同的处理过程会得到相同的结果。
另⼀方面,许多流处理框架使⽤处理机器上的本地系统时钟(处理时间(processing time)来确定窗口。这种方法的优点是简单,事件创建与事件处理之间的延迟可以忽略不计。然而,如果存在显著的处理延迟,处理就失效了。
事件时间与处理时间
很多原因都可能导致处理延迟:排队,网络故障,性能问题导致消息代理/消息处理器出现争用,流消费者重启,重新处理过去的事件,或者在修复代码BUG之后从故障中恢复。而且,消息延迟还可能导致⽆法预测消息顺序。
将事件时间和处理时间搞混会导致错误的数据。例如,假设你有一个流处理器⽤于测量请求速率。如果你重新部署流处理器,它可能会停⽌一分钟,并在恢复之后处理积压的事件。如果你按处理时间来衡量速率,那么在处理积压日志时,请求速率看上去就像有一个异常的突发尖峰,而实际上请求速率是稳定的。
了解什么时候准备就绪
⽤事件时间来定义窗口的一个棘手的问题是,你永远也无法确定是不是已经收到了特定窗口的所有事件,还是说还有一些事件正在来的路上。
在一段时间没有看到任何新的事件之后,你可以超时并宣布一个窗口已经就绪,但仍然可能发⽣这种情况:某些事件被缓冲在另⼀台机器上,由于⽹络中断⽽延迟。你需要能够处理这种在窗⼝宣告完成之后到达的滞留事件。⼤体上,你有两种选择:
- 忽略这些滞留事件,因为在正常情况下它们可能只是事件中的一小部分。你可以将丢弃事件的数量作为⼀个监控指标,并在出现⼤量丢消息的情况时报警。
- 发布一个更正:滞留事件的更新值,你可能还需要收回以前的输出。
在某些情况下,可以使用特殊的消息来指示“从现在开始,不会有比t更早时间戳的消息了”(watermark),消费者可以使用它来触发窗口。但是,如果不同机器上的多个生产者都在⽣成事件,每个⽣产者都有⾃己的最小时间戳阈值,则消费者需要分别跟踪每个生产者。在这种情况下,添加和删除生产者都是比较棘⼿的。
你用谁的时钟
当事件可能在系统内多个地⽅进行缓冲时,为事件分配时间戳更加困难了。例如,考虑一个移动应用向服务器上报关于⽤量的事件。该应⽤可能会在设备处于脱机状态时被使用,在这种情况下,它将在设备本地缓冲事件,并在下一次互联⽹连接可用时向服务器上报这些事件(可能是⼏小时甚至⼏天)。对于这个流的任意消费者⽽言,它们就如延迟极⼤的滞留事件一样。
在这种情况下,事件上的事件戳实际上应当是⽤户交互发生的时间,取决于移动设备的本地时钟。然⽽用户控制的设备上的时钟通常是不可信的,因为它可能会被无意或故意设置成错误的时间。服务器收到事件的时间可能是更准确的,因为服务器在你的控制之下,但在描述⽤户交互⽅面意义不大。
要校正不正确的设备时钟,一种方法是记录三个时间戳:
- 事件发⽣生的时间,取决于设备时钟
- 事件发送往服务器器的时间,取决于设备时钟
- 事件被服务器器接收的时间,取决于服务器器时钟
通过从第三个时间戳中减去第二个时间戳,可以估算设备时钟和服务器时钟之间的偏移(假设⽹络延迟与所需的时间戳精度相⽐可忽略不计)。然后可以将该偏移应⽤于事件时间戳,从⽽估计事件实际发⽣的真实时间(假设设备时钟偏移在事件发生时与送往服务器之间没有变化)。
这并不是流处理理独有的问题,批处理有着完全⼀样的时间推理问题。只是在流处理的上下文中,我们更容易意识到时间的流逝。
窗口类型
当你知道如何确定一个事件的时间戳后,下一步就是如何定义时间段的窗口。然后窗⼝就可以用于聚合。有⼏种窗口很常⽤:
- 滚动窗⼝(Tumbling Window)
滚动窗⼝有着固定的长度,每个事件都仅能属于一个窗口。例如,假设你有一个1分钟的滚动窗口,则所有时间戳在10:03:00和10:03:59之间的事件会被分组到一个窗口中,10:04:00和10:04:59之间的事件被分组到下一个窗口,依此类推。通过将每个事件时间戳四舍五入至最近的分钟来确定它所属的窗⼝,可以实现1分钟的滚动窗口。 - 跳动窗⼝(Hopping Window)
跳动窗口也有着固定的⻓度,但允许窗口重叠以提供⼀些平滑。例如,一个带有1分钟跳跃步长的5分钟窗口将包含10:03:00至10:07:59之间的事件,⽽下一个窗⼝将覆盖10:04:00至10:08之间的事件等等。通过首先计算1分钟的滚动窗⼝,然后在几个相邻窗⼝上进行聚合,可以实现这种跳动窗口。 - 滑动窗⼝(Sliding Window)
滑动窗口包含了彼此间距在特定时长内的所有事件。例如,一个5分钟的滑动窗⼝应当覆盖10:03:39和10:08:12的事件,因为它们相距不超过5分钟(注意滚动窗⼝与步长5分钟的跳动窗⼝可能不会把这两个事件分组到同一个窗口中,因为它们使用固定的边界)。通过维护一个按时间排序的事件缓冲区, 并不断从窗⼝中移除过期的旧事件,可以实现滑动窗口。 - 会话窗口(Session window)
与其他窗⼝类型不同,会话窗口没有固定的持续时间,⽽定义为:将同一用户出现时间相近的所有事件分组在一起,⽽当用户⼀段时间没有活动时(例如,如果30分钟内没有事件)窗口结束。
流式join
由于流处理将数据管道泛化为对无限数据集进行增量处理,因此对流进⾏连接的需求也是完全相同的。然而,新事件随时可能出现在一个流中,这使得流连接要⽐批处理连接更具挑战性。
根据join的对象不同可以分为以下三类:
- 流和流join
两个输入流都是由活动事件组成,采用join操作用来搜索在特定时间窗口内发生的相关事件。例如,它可以匹配相同用户在30min内采取的两个动作。如果想要在一个流中查找相关事件,则两个join输入可以是相同的流。 - 流和表join
一个输入流由活动事件组成,而另一个则是数据库变更日志。更新日志维护了数据库的本地最新副本。对于每个活动事件,join操作用来查询数据库并输出一个包含更多信息的事件。 - 表和表join
两个输入流都是数据库更新日志。在这种情况下,一方的每一个变化都与另一方的最新状态join。结果是对两个表之间join的物化视图进行持续的更新。
join的时间依赖
这⾥描述的三种连接(流流,流表,表表)有很多共通之处:它们都需要流处理器维护连接一侧的一些状态(搜索与点击事件,用户档案,关注列表),然后当连接另一侧的消息到达时查询该状态。
⽤于维护状态的事件顺序是很重要的。在分区⽇志中,单个分区内的事件顺序是保留下来的。但典型情况下是没有跨流或跨分区的顺序保证的。
这就产⽣了一个问题:如果不同流中的事件发⽣在近似的时间范围内,则应该按照什么样的顺序进⾏处理?在流表连接的例子中,如果⽤户更新了它们的档案,哪些活动事件与旧档案连接(在档案更新前处理),哪些⼜与新档案连接(在档案更新之后处理)?换句话说:你需要对一些状态做连接,如果状态会随着时间推移⽽变化,那应当使⽤什么时间点来连接?
如果跨越流的事件顺序是未定的,则连接会变为不确定性的,这意味着你在同样输入上重跑相同的作业未必会得到相同的结果;当你重跑任务时,输⼊流上的事件可能会以不同的⽅式交叉在一起。
在数据仓库中,这个问题被称为缓慢变化的维度(slowly changing dimension, SCD),通常通过特定版本的记录使用唯⼀的标识符来解决:例如,每当税率改变时都会获得一个新的标识符,而发票在销售时会带有税率的标识符。这种变化使连接变为确定性的,但也会导致日志压缩无法进行:表中所有的记录版本都需要保留留。
流处理的容错
批处理框架可以很容 易地容错:如果MapReduce作业中的任务失败,可以简单地在另⼀台机器上再次启动,并且丢弃失败任务的输出。这种透明的重试是可能的,因为输⼊文件是不可变的,每个任务都将其输出写⼊到HDFS上的独⽴文件中,⽽输出仅当任务成功完成后可见。
特别是,批处理容错⽅法可确保批处理作业的输出与没有出错的情况相同,即使实际上某些任务失败了。看起来好像每条输⼊记录都被处理了恰好一次——没有记录被跳过,⽽且没有记录被处理两次。 尽管重启任务意味着实际上可能会多次处理记录,但输出中的可见效果看上去就像只处理过一次。这个原则被称为恰好一次语义(exactly-once semantics),尽管有效⼀次(effectively-once)可能会是一个更写实的术语。
在流处理中也出现了同样的容错问题,但是处理起来没有那么直观:等待某个任务完成之后再使其输出可见并不是一个可⾏选项,因为你永远无法处理完一个无限的流。
微批处理和校验点
⼀个解决方案是将流分解成小块,并像微型批处理一样处理每个块。这种方法被称为微批处理(microbatching),它被⽤于Spark Streaming。批次的⼤小通常约为1秒,这是对性能妥协的结果:较⼩的批次会导致更大的调度与协调开销,而较⼤的批次意味着流处理器结果可见之前的延迟要更长。
微批处理也隐式提供了一个与批次⼤小相等的滚动窗⼝(按处理时间⽽不是事件时间戳分窗)。任何需要更⼤窗口的作业都需要显式地将状态从一个微批次转移到下⼀个微批次。
Apache Flink则使⽤不同的方法,它会定期⽣成状态的滚动校验点并将其写入持久存储。如果流算子崩溃,它可以从最近的存档点重启,并丢弃从最近检查点到崩溃之间的所有输出。存档点会由消息流中的壁障(barrier)触发,类似于微批处理之间的边界,但不会强制⼀个特定的窗⼝大小。
在流处理框架的范围内,微批处理与校验点⽅法提供了与批处理一样的恰好⼀次语义。但是,只要输出离开流处理器(例如,写入数据库,向外部消息代理发送消息,或发送电⼦邮件),框架就⽆法抛弃失败批次的输出了。在这种情况下,重启失败任务会导致外部副作用发⽣两次,只有微批处理或校验点不足以阻止这⼀问题。
原子提交
为了在出现故障时表现出恰好处理一次的样⼦,我们需要确保事件处理的所有输出和副作⽤当且仅当处理成功时才会生效。这些影响包括发送给下游算子或外部消息传递系统的任何消息,任何数据库写⼊,对算子状态的任何变更,以及对输⼊消息的任何确认。
这些事情要么都原⼦地发⽣生,要么都不发⽣生,但是它们不应当失去同步。
与XA不同,这些实现不会尝试跨异构技术提供事务,而是通过在流处理框架中同时管理状态变更与消息传递来内化事务。事务协议的开销可以通过在单个事务中处理多个输入消息来分摊。
幂等性
我们的⽬标是丢弃任何失败任务的部分输出,以便能安全地重试,⽽不会生效两次。分布式事务是实现这个目标的一种方式,⽽另一种方式是依赖幂等性。
幂等操作是多次重复执行与单次执行效果相同的操作。例如,将键值存储中的某个键设置为某个特定值是幂等的,而递增一个计数器不是幂等的。
即使⼀个操作不是天生幂等的,往往可以通过一些额外的元数据做成幂等的。例如,在使用来⾃Kafka 的消息时,每条消息都有⼀个持久的,单调递增的偏移量。将值写⼊外部数据库时可以将这个偏移量带上,这样你就可以判断一条更新是不是已经执⾏过了,因而避免重复执行。
依赖幂等性意味着隐含了一些假设:重启一个失败的任务必须以相同的顺序重放相同的消息,处理必须是确定性的, 没有其他节点能同时更新相同的值。
故障后重建状态
任何需要状态的流处理——例如,任何窗⼝聚合以及任何用于连接的表和索引,都必须确保在失败之后能恢复其状态。
一种选择是将状态保存在远程数据存储中,并进行复制,然⽽每个消息都要查询远程数据库可能会很慢。另一种方法是在流处理器本地保存状态,并定期复制。然后当流处理器从故障中恢复时,新任务可以读取状态副本,恢复处理而不丢失数据。例如,Flink定期捕获算⼦状态的快照,并将它们写入HDFS等持久存储中。
在某些情况下,甚至可能都不需要复制状态,因为它可以从输⼊流重建。例如,如果状态是从相当短的窗口中聚合而成,则简单地重放该窗⼝中的输入事件可能是⾜够快的。如果状态是通过变更数据捕获来维护的数据库的本地副本,那么也可以从日志压缩的变更流中重建数据库。
然⽽,所有这些权衡取决于底层基础架构的性能特征:在某些系统中,⽹络延迟可能低于磁盘访问延迟,网络带宽可能与磁盘带宽相当。没有针对所有情况的普世理想权衡,随着存储和网络技术的发展,本地状态与远程状态的优点也可能会互换。
第12章 数据系统的未来
数据集成
本书中反复出现的主题是,对于任何给定的问题都会有好几种解决⽅案,所有这些解决⽅案都有不同的优缺点与利弊权衡。
如果你有一个类似于“我想存储⼀一些数据并稍后再查询”的问题,那么并没有一种绝对正确的解决方案。但对于不同的具体环境,总会有不同的合适方法。软件实现通常必须选择一种特定的方法。使单条代码路径能做到稳定健壮且表现良好已经是一件非常困难的事情了——尝试在单个软件中完成所有事情,几乎可以保证,实现效果会很差。
因此软件⼯具的最佳选择也取决于情况。每一种软件,甚⾄所谓的“通用”数据库,都是针对特定的使⽤模式设计的。
面对让人眼花缭乱的诸多替代品,第一个挑战就是弄清软件与其适用环境的映射关系。但是,即使你已经完全理解各种工具与其适⽤环境间的关系,还有⼀个挑战:在复杂的应用中,数据的用法通常花样百出。不太可能存在适⽤于所有不同数据应⽤场景的软件,因此不可避免地需要拼凑⼏个不同的软件以提供应用所需的功能。
采用派生数据来组合工具
例如,为了处理任意关键词的搜索查询,将OLTP数据库与全⽂搜索引集成在⼀起是很常⻅的需求。尽管一些数据库(例如PostgreSQL)包含了全文索引功能,对于简单的应用完全够了,但更复杂的搜索能⼒就需要专业的信息检索工具了。相反的是,搜索引通常不适合作为持久的记录系统, 因此许多应用需要组合这两种不不同的⼯具以满足所有需求。
随着数据不同表示形式的增加,集成问题变得越来越困难。除了数据库和搜索索引之外,也许你需要在分析系统(数据仓库,或批处理和流处理系统)中维护数据副本;维护从原始数据中衍生的缓存,或反规范化的数据版本;将数据灌入机器学习, 分类,排名,或推荐系统中;或者基于数据变更发送通知。
某人认为鸡肋肋而毫无意义的功能可能是别⼈的核⼼需求。当你拉高视角,并考虑跨越整个组织范围的数据流时,数据集成的需求往往就会变得明显起来。
派生数据与分布式事务
保持不同数据系统彼此⼀致的经典⽅法涉及分布式事务。 与分布式事务相比,使⽤派生数据系统的⽅法如何?
在抽象层面,它们通过不同的方式达到类似的⽬标。分布式事务通过锁进⾏互斥来决定写入的顺序,而CDC和事件溯源使⽤日志进行排序。分布式事务使用原⼦提交来确保变更只生效一次,而基于⽇志的系统通常基于确定性重试和幂等性。
最大的不同之处在于事务系统通常提供线性一致性,这包含着有用的保证,例如读己之写。另⼀方面, 派生数据系统通常是异步更新的,因此它们默认不会提供相同的时序保证。
在愿意为分布式事务付出代价的有限场景中,它们已被成功应用。但是,我认为XA的容错能⼒和性能很差劲,这严重限制了它的实⽤性。我相信为分布式事务设计一种更好的协议是可⾏的。但使这样一种协议被现有⼯具广泛接受是很有挑战的,且不是立竿见影的事。
在没有⼴泛支持的良好分布式事务协议的情况下,我认为基于日志的衍⽣数据是集成不同数据系统的最有前途的方法。然而,诸如读⼰之写的保证是有用的。
全序的局限
对于⾜够⼩的系统,构建一个完全有序的事件日志是完全可行的。但是,随着系统向更大更复杂的工作负载扩展,限制开始出现:
- 在大多数情况下,构建完全有序的日志,需要所有事件汇集于决定顺序的单个领导节点。如果事件吞吐量大于单台计算机的处理能力,则需要将其分割到多台计算机上。然后两个不同分区中的事件顺序关系就不明确了。
- 如果服务器分布在多个地理位置分散的数据中⼼上,例如为了容忍整个数据中⼼掉线,通常在每个数据中⼼都有单独的主库,因为⽹络延迟会导致同步的跨数据中心协调效率低下。这意味着源⾃两个不同数据中⼼的事件顺序未定义。
- 将应⽤程序部署为微服务时,常见的设计选择是将每个服务及其持久状态作为独⽴单元进行部署,服务之间不共享持久状态。当两个事件来自不同的服务时,这些事件间的顺序未定义。
- 某些应⽤程序在客户端保存状态,该状态在用户输⼊时⽴即更新(无需等待服务器确认),甚⾄可以继续脱机⼯作。有了这样的应⽤程序,客户端和服务器很可能以不同的顺序看到事件。
在形式上,决定事件的全局顺序称为全序广播,相当于共识。⼤多数共识算法都是针对单个节点的吞吐量足以处理整个事件流的情况⽽设计的,并且这些算法不提供 多个节点共事件排序⼯作的机制。设计可以扩展到单个节点的吞吐量之上,且在地理散布环境中仍然工作良好的的共识算法仍然是一个开放的研究问题。
批处理和流处理集成
批处理和流处理的输出是衍⽣数据集。二者有许多共同的原则,主要的根本区别在于流处理器在⽆限数据集上运⾏,而批处理输入是已知的有限⼤小。处理引擎的实现方式也有很多细节上的差异,但是这些区别已经开始模糊。
Spark在批处理引擎上执⾏流处理,将流分解为微批,而Apache Flink则在流处理理引擎上执⾏批处理。原则上,⼀种类型的处理可以⽤另一种类型来模拟,但是性能特征会有所不同:例如,在跳跃或滑动窗⼝上,微批可能表现不佳。
保持派生状态
批处理有着很强的函数式风格:它⿎励确定性的纯函数,其输出仅依赖于输⼊,除了显式输出外没有副作用,将输入视作不可变的,且输出是仅追加的。流处理与之 类似,但它扩展了算子以允许受管理的,容错的状态。
具有良好定义的输⼊和输出的确定性函数的原理不仅有利于容错,也简化了有关组织中数据流的推理。⽆论派生数据是搜索索引,统计模型还是缓存,采用这种观点思考都是很有帮助的:将其视为从一个东⻄派生出另⼀个的数据管道,将⼀个系统的状态变更推送⾄函数式应用代码 中,并将其效果应⽤至派生系统中。
原则上,派生数据系统可以同步地维护,就像关系数据库在与被索引表写入操作相同的事务中同步更新辅助索引一样。然而,异步是基于事件⽇志的系统稳健的原因:它允许系统的⼀部分故障被抑制在本地,⽽如果任何⼀个参与者失败,分布式事务将中止,因此它们倾向于通过将故障传播到系统的其余部分来放大故障。
Lambda架构
如果批处理用于重新处理历史数据,并且流处理用于处理最近的更新,那么如何将这两者结合起来?
Lambda架构是这⽅面的⼀个建议,引起了很多关注。
Lambda架构的核⼼心思想是通过将不可变事件附加到不断增长的数据集来记录传⼊数据,这类似于事 溯源。为了从这些事件中派生出读取优化的视图, Lambda架构建议并⾏运行两个不同的系统:批处理系统和独立的流处理系统。
在Lambda方法中,流处理器消耗事件并快速⽣成对视图的近似更新:批处理器稍后将使用同一组事件并⽣成派生视图的更正版本。这个设计背后的原因是批处理更简单,因此不易出错,而流处理理器被认为是不太可靠和难以容错的。而且,流处理可以使⽤
Lambda一些实际问题:
- 在批处理和流处理框架中维护相同的逻辑是很显著的额外工作。
- 由于流管道和批处理管道产生独⽴的输出,因此需要合并它们以响应⽤户请求。
- 尽管有能力重新处理整个历史数据集是很好的,但在大型数据集上这样做经常会开销巨大。
统一批处理和流处理
最近的工作使得Lambda架构能够充分发挥其优点而规避其缺点,那就是允许批处理计算和流计算在同一个系统中实现。
在⼀个系统中统一批处理和流处理需要以下功能,这些功能越来越⼴泛:
- 通过处理最近事件流的相同处理引擎来重放历史事件的能力。例如,基于日志的消息代理可以重放消息,某些流处理器可以从HDFS等分布式⽂件系统读取输⼊。 - 对于流处理理器来说,恰好⼀次语义——即确保输出与未发生故障的输出相同,即使事实上发生故障。与批处理一样,这需要丢弃任何失败任务的部分输出。
- 按事件时间进⾏窗⼝化的工具,⽽不是按处理时间进⾏窗口化,因为处理历史事件时,处理时间毫无意义。
分拆数据库
略~
端到端的正确性
对于只读取数据的⽆状态服务,出问题也没什么⼤不了的:你可以修复该错误并重启服务,⽽一切都恢复正常。像数据库这样的有状态系统就没那么简单了:它们被设计为永远记住事物,所以如果出现问题,这种(错误的)效果也将潜在地永远持续下去,这意味着它们需要更仔细的思考。
我们希望构建可靠且正确的应⽤(即使⾯对各种故障,程序的语义也能被很好地定义与理解)。原子性,隔离性和持久性等事务特性一直是构建正确应用的首选工具。然⽽这些地基没有看上去那么牢固。
如果你的应用可以容忍偶尔的崩溃,以及以不可预料的方式损坏或丢失数据,那⽣活就要简单得多,⽽你可能只要双⼿合十念阿弥陀佛,期望佛祖能保佑最好的结果。另一⽅面,如果你需要更强的正确性保证,那么可序列化与原子提交就是久经考验的⽅法,但它们是有代价的:它们通常只在单个数据中⼼中工作,并限制了系统能够实现的规模与容错特性。
虽然传统的事务⽅法并没有走远,但我也相信在使应用正确⽽灵活地处理错误⽅面上,事务并不是最后的遗言。
做正确的事情
本章节审视了构建数据密集型应用系统在道德层面的一些问题。虽然数据可以用来帮助人们,但是也可能造成重大的伤害:
- 做出严重影响人们生活的貌似公平的决定,这种算法决定难以对其提起诉讼
- 导致歧视和剥削
- 使监视泛滥
- 暴露私密信息
- …
我们也面临着数据泄露的风险,而且即使是善意的数据使用也可能会产生某些一箱不到的后果。
由于软件和数据对世界的影响如此之大,我们的工程师必须谨记,我们有责任为我们赖以生存的世界而努力:一个以人性和尊重来对待人的世界。