八、流处理
流处理和批处理最原始的区别在于,流处理处理无界数据,而批处理针对有界数据。在流处理中的上下文中,记录通常被叫做事件,一个事件由生产者(producer)/发布者(publisher)/发送者(sender)生成一次,然后可能由多个消费者(consumer)/订阅者(subscribers)/接收者(recipients)进行处理。流处理的目标是事件发生后,立刻得到处理。流处理中相关的事件通常被聚合为一个主题(topic)或流(stream)
本质上来说,文件或者是数据库可以连接生产者和消费者,但是这种方式下消费者需要不断降低轮询文件/数据库的间隔,才能降低事件处理的延迟,而轮询会增加数据库的额外开销,我们希望在有新的事件产生的时候,能够通知到消费者,数据库的触发器或许是一个可选项,但是触发器功能有限,为了解决这个问题,消息系统应运而生
2个进程之间进行消息传递(通信),可以通过接口调用,还可以使用消息服务
8.1-消息系统
消息系统一定要考虑2个问题:
🅰️生产者发送消息的速度比消费者能够处理的速度快该如何应对?有三种方式处理:
- 1️⃣丢掉消息
- 2️⃣将消息放入缓冲队列
- 3️⃣ 背压机制(backpressure)/流量控制(flow control):即为阻塞生产者,避免其发送更多的消息1
🅱️ 如果节点崩溃或短暂脱机,是否会有消息丢失?也即持久性要求
批处理的一个优良的特性是,它提供了强大的可靠性保证:失败的任务会自动重试,且失败任务的输出会自动丢弃。这意味着好像故障没有发生一样,我们尝试⚠️在流处理中达到类似的保证。有些消息系统是直连生产者和消费者,比如使用UDP连接的应用,这种方式的确latency很低,但此类应用有一个前提假设:消费者和生产者始终在线,流处理如果是用这种方式,消费者一旦脱机,可能会丢失期间的消息
消息代理/消息队列
本质上消息代理是针对处理消息流的数据库。消息代理解决了上文提及的2个问题:
🅰️ (消费者)消费能力不足时,暂存消息;不需要丢弃消息或者背压
🅱️ 持久性保证:落盘
消息代理 | 消息成功传递给消费者后,自动删除 | 基于主题的模式匹配 | 不支持任意查询,数据变化时,会通知消费者 |
---|---|---|---|
数据库 | 数据库保留数据直到显示删除 | 数据库支持二级索引 | 查询时,往往是基于某个时间点的快照 |
如上描述的,消息队列和常规的数据的差别,其中行1是关于消息代理的传统观点,被封装在JMS/AMQP2标准中,其实现有RabbitMQ,ActiveMQ等
AMQP: Advanced Message Queuing Protocol 高级消息队列协议:面向消息中间件提供的开放的应用层协定
消息代理中,如果有多个消费者读取同一个主题的消息时,使用2种主要的消息传递模式:
🅰️ 负载均衡(load balance) : 在消费者间共享消费主题
🅱️ 扇出(fan-out) : 将每条消息传递给多个消费者
两种模式可以组合使用:两个独立的消费者组可以每组各订阅一个主题,每一组都共同收到所有消息,但在每一组内部,每条消息仅由单个节点处理
消息代理使用确认(acknowledgment)3机制,来确保消息不会丢失,但一种可能的情况是:代理向消费者传递消息后消费者崩溃或处理了部分崩溃了,代理由于超出一段时间没有收到确认(也可能是确认在网络中丢失了),便将消息传递给另外一个消费者,当消费者的消费模式是负载均衡时,下面的情况可能会发生:处理m3时消费者2崩溃,因此稍后重传至消费者1
批处理的关键特性是:重试失败任务不会发生任何副作用,而AMQP/JMS风格的消息传递收到消息是具有破坏性的,因为确认可能导致消息从代理中被删除,因此再次运行同一个消费者可能会得到不同的结果。即便是注册新的消费者到消息系统,通常只能接收到消费者注册之后开始发送的消息,而文件系统/数据库系统新增的客户端能够读取到任意久远的数据
基于日志的消息代理(log-based message brokers) 尝试实现 🅰️既有数据库的持久存储方式;🅱️又有消息传递的低延迟通知
8.2-基于日志的消息代理
在基于日志(append only mode)的消息代理中,生产者通过将消息追加到日志末尾来发送消息,而消费者通过依次读取日志来接收消息,若消费者读到日志末尾,则会等待新消息追加的通知(如Unix的 tail -f
)。同时为了提升吞吐量,基于日志的消息代理可以对日志进行分区,每个分区内,代理为每个消息分配一个单调递增的序列号/偏移量(offset)。并且分区内消息完全有序(跨分区无顺序保证)
Apache Kafka、Amazon Kinesis Streams、Twitter的DistributedLog都是基于日志的消息代理。其中下面几点要注意:
1️⃣消费者组:支持多个消费者组成一个消费者主订阅一个主题,单个节点消费特定的分区,一般而言单线程处理单分区是更适合的选择,通过增加分区的方式提高并行度
2️⃣ 消费者偏移量:所有偏移量小于消费者的当前偏移量的消息已经被处理(类似单主复制中的日志序列号)4
3️⃣重播旧消息:消费者的消费唯一的副作用就是导致偏移量的前进,但是消费者可以操纵偏移量(类似于批处理)
8.3-流和数据库
此间的讨论,我们发现基于日志的消息代理从数据库中获得灵感并将其应用于消息传递,其实也可以反过来,从消息传递和流中获得灵感,并将他们应用于数据库。在单主复制中,主库的写入事件构成写入流,将写入流应用到从库,最终得到数据的精确副本。在异构数据系统中,由于相同或相关的数据出现在了不同的地方,因此相互间需要保持同步:如果某个项目在数据库中被更新,它也应当在缓存,搜索索引和数据仓库(使用ETL)中被更新,我们在批处理将描述了如何使用批处理去更新其他衍生数据系统。使用的批处理的弊端是时延,如何保证衍生数据系统低延迟获取记录系统的变更数据?
这就涉及到变更数据捕获(change data capture, CDC),这是一种观察写入数据库的所有数据变更,并将其提取并转换为可以复制到其他系统中的形式的过程。如下图:捕获数据库中的变更,并不断将相同的变更应用至搜索索引
从本质上说,变更数据捕获使得一个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序。 LinkedIn的Databus,Facebook的Wormhole和Yahoo!的Sherpa大规模地应用这个思路。 Bottled Water使用解码WAL的API实现了PostgreSQL的CDC,Maxwell和Debezium通过解析binlog对MySQL做了类似的事情
重放所有对数据库进行变更的日志,过于耗时,因此一般会保留数据库的快照,快照+快照时刻对应的偏移量可以加快重建数据库的完整状态。另外一个加速重建数据库完整状态的方式是日志压缩5,Apache Kafka支持这种日志压缩功能
8.4-事件溯源(Event Sourcing)
事件溯源起源于领取驱动设计(domain-driven design,DDD),和CDC类似,事件溯源将所有涉及对应用状态的变更存储为变更事件日志,其核心在于将用户的行为记录为不可变的事件,而不是在可变数据库中记录这些行为的影响。事件存储是仅追加的,原地删除和更新是不被鼓励的。事件日志和星型模式中的事实表有相似之处
使用事件溯源的应用需要拉取事件日志(表示写入系统的数据),并将其转换为适合向用户显示的应用状态,和CDC一样,重放事件日志可以重新构建系统的当前状态。 事件溯源的哲学是仔细区分事件(event)和命令(command),用户的请求刚到达时,它一开始是一个命令(在这个时间点上它仍然可能可能失败,比如违反了一些完整性条件)应用必须首先验证它是否可以执行该命令。如果验证成功并且命令被接受,则它变为一个持久化且不可变的事件。从数学角度来看,应用状态是事件流对时间求积分的结果 s t a t e ( n o w ) = ∫ t = 0 n o w s t r e a m ( t ) d t state(now) = \int_{t=0}^{now}{stream(t) \ dt} state(now)=∫t=0nowstream(t) dt,变更流是应用状态对时间求微分的结果 s t r e a m ( t ) = d s t a t e ( t ) d t stream(t) = \frac{d\ state(t)}{dt} stream(t)=dtd state(t)
日志压缩是连接事件日志与数据库状态之间的桥梁:它只保留每条记录的最新版本,并丢弃被覆盖的版本。我们可以基于事件溯源中的记录的事件日志派生出多个视图,通过将数据写入的形式与读取形式相分离6,并允许几个不同的读取视图,这样极大的提高了灵活性
事件溯源和变更数据捕获的最大缺点是事件日志的消费者通常是异步的,所以可能出现的情况是:用户写入日志,然后从日志衍生视图中读取,结果发现他的写入还没有反映在读取视图中,一种解决方案是将事件附加到日志时同步执行读取视图的更新,如果是事件日志和读取视图保存在同一个存储系统中,需要使用事务,如果是异构数据库则涉及分布式事务
🎈至此,我们谈及到了流的来源1️⃣用户活动事件,2️⃣传感器,3️⃣写入数据库;流如何传输1️⃣直接通过消息传送 2️⃣消息代理 3️⃣ 事件日志。那么我们用流干什么?
8.5-流处理
流一般有以下三种用途
1️⃣你可以将事件中的数据写入数据库,缓存,搜索索引或类似的存储系统,然后能被其他客户端查询
2️⃣ 以某种方式将事件推送给用户,如发送报警邮件或推送通知,或将事件流式传输到可实时显示的仪表板上。这种情况下,人是流的最终消费者
3️⃣ 你可以处理一个或多个输入流,并产生一个或多个输出流。流可能会经过由几个这样的处理阶段组成的流水线,最后再输出1️⃣或2️⃣
我们将重点讨论3️⃣处理流产生其他衍生流,处理这样的流的代码片段,称之为算子(operator)或者作业(job);流处理和批处理最大的区别是流处理处理无界数据,由于是无界导致排序没有意义,也就无法使用排序合并连接,容错机制也不能像批处理那样通过从头执行的方式
8.6-流处理应用
1️⃣复合事件处理(complex,event processing CEP),CEP通常使用高层次的声明式查询语句如SQL,在流中搜索某些事件(就像正则表达式一样),当发现匹配时,引擎发出一个复合事件(complex event)(因此得名)。一般而言,数据库会持久存储数据,并将查询视为临时的,当查询进入时,数据库搜索与查询匹配的数据,然后在查询完成时丢掉查询。 CEP引擎反转了角色:查询是长期存储的,来自输入流的事件不断流过它们。CEP的实现包括Esper、IBM InfoSphere Streams
2️⃣流分析,流分析关注大量事件上的聚合与统计指标,统计指标通常是在固定时间区(窗口[window])间内进行计算的。许多开源分布式流处理框架的设计都是针对分析设计的:例如Apache Storm,Spark Streaming,Flink
3️⃣ 维护物化视图。数据库的变更流(CDC或是事件日志)可以用于维护衍生数据系统,使其与源数据库保持最新,基于衍生查询(写入和查询相分离)。也是流的一个应用,但是要求任意时间段内的所有事件,和流分析场景有很大的不同。类似Spark Streaming 不支持
8.6.1-关于事件时间和处理时间
🅰️ 处理时间:事件到达处理节点的时钟。使用处理时间定义窗口,会因为处理速率的变动引入人为因素,如下图:
🅱️ 事件时间:事件发生的时间。延迟先发生的事件先到达处理节点,无法确定是否已经收到了特定窗口的所有事件,如何处理这种在窗口宣告完成之后到达的滞留(straggler)事件?
8.6.2-窗口
1️⃣ 滚动窗口(Tumbling Window):窗口有固定的长度,而且每个事件都只属于一个窗口
2️⃣跳动窗口(Hopping Window) :窗口有固定的长度,如1分钟跳跃步长的5分钟窗口
3️⃣滑动窗口(Sliding Window):滑动窗口包含了彼此间距在特定时长内的所有事件,通过维护一个按时间排序的事件缓冲区,并不断从窗口中移除过期的旧事件,可以实现滑动窗口
4️⃣会话窗口(Session window) 将同一用户出现时间相近的所有事件分组在一起,而当用户一段时间没有活动时(例如,如果30分钟内没有事件)窗口结束
8.7-流式连接
涉及流-流连接,流-表连接,与表-表连接
1️⃣ 流流连接,实际是窗口的连接,Window join 作用在两个流中有相同 key 且处于相同窗口的元素上。比如Flink将流流连接细分为滚动Window Join,滑动Window Join,会话Window Join。Flink的双流Join。比如下图是Spark Streaming中一个广告流和一个点击流的连接
2️⃣流表连接,实际是流扩展。如下图的点击流和用户档案的连接,首先将数据库副本加载到流处理器中,然后流处理器需要一次处理一个活动事件。 流表连接实际上非常类似于流流连接;最大的区别在于对于表的变更日志流,连接使用了一个可以回溯到“时间起点”的窗口
3️⃣ 表表连接。在推特时间线的例子中,用户查看自身主页时间线时,迭代用户所关注人群的推文并合并它们需要一个时间线缓存,在流处理器中实现这种缓存维护,需要推文事件流(发送与删除)和关注关系事件流(关注与取消关注),即该流处理的过程是维护了一个连接了两个表(推文与关注)的物化视图,如下时间线实际上是这个查询结果的缓存,每当基础表发生变化时都会更新
select follows.follower_id as timeline_id,
array_agg(tweets.* order by tweets.timestamp desc)
from tweets
join follows on tweets.sender_id = follows.followee_id
group by follows.follower_id
8.8-连接的时间依赖性
流流、流表、表表连接有很多共同点,他们都需流处理器维护连接一侧的一些状态(广告流和点击流,用户档案,关注列表),然后当连接另外一侧的消息到达时查询该状态。这里会有一个问题,如在流表连接的例子中,如果用户更新了档案,哪些活动事件与旧档案连接(在档案更新前)?,哪些又与新档案连接(在档案更新后)?即连接存在时序依赖,比如处理发票和税率问题时,当连接销售额与税率表时,你可能期望的是使用销售时的税率参与连接,如果你正在重新处理历史数据,销售时的税率可能和现在的税率有所不同
即如果跨流事件的顺序是未定的,则连接会变成不确定性的,那么在同样输入上重跑可能会得到不同的结果。这个问题在数仓中叫缓慢变化的维度(slowly changing dimension,SCD)7,通常通过对特定版本的记录使用唯一的标识符来解决:例如,每当税率改变时都会获得一个新的标识符,而发票在销售时会带有税率的标识符。这种变化使连接变为确定性的,但也会导致日志压缩无法进行:表中所有的记录版本都需要保留
8.9-流处理如何处理容错
在批处理中,容错的方式就是重跑,而且其输出的效果就像只处理了一次一样,这个原则叫做恰好/精确一次语义(exactly-once semantics),流处理为了实现恰好一次语义,有以下2种方式
🅰️ 微批,即将流分解成小块,并像微型批处理一样处理每个块,微批次(通常为1S)也隐式提供了一个与批次大小相等的滚动窗口(按处理时间而不是事件时间戳分窗),代表应用为Spark Streaming
🅱️存档点, Apache Flink会定期生成状态的滚动存档点并将其写入持久存储。如果流算子崩溃,它可以从最近的存档点重启,并丢弃从最近检查点到崩溃之间的所有输出
在流处理框架的范围内,微批次与存档点方法提供了与批处理一样的恰好一次语义。但是,只要输出离开流处理器(例如,写入数据库,向外部消息代理发送消息,或发送电子邮件),框架就无法抛弃失败批次的输出了。在这种情况下,重启失败任务会导致外部副作用发生两次,只有微批次或存档点不足以阻止这一问题,我们需要确保事件处理的所有输出和副作用当且仅当处理成功时才会生效。分布式事务是一种解决方案,另外一种方式是幂等性(idempotence)
幂等操作是多次重复执行与单次执行效果相同的操作,例如,将键值存储中的某个键设置为某个特定值是幂等的(再次写入该值,只是用同样的值替代),而递增一个计数器不是幂等的(再次执行递增意味着该值递增两次)。在使用来自Kafka的消息时,每条消息都有一个持久的,单调递增的偏移量。将值写入外部数据库时可以将这个偏移量带上,这样你就可以判断一条更新是不是已经执行过了,因而避免重复执行
🤣以上是《设计数据密集型应用》读书笔记的第6部分,也是最后一部分,欢迎吐槽,欢迎关注公众号:stackoverflow
比如能够恰好连接1个生产者和一个消费者Unix管道和TCP连接,他们在应对这个问题的时候使用背压机制,它们有一个固定大小的缓冲区,一旦填满发送者就会被阻塞,直到接受者从缓冲区取出数据 ↩︎
JMS: Java Message Service,是关于Java消息中间的一组接口标准,所有消息中间件(MOM)需要实现这组接口,可类比JDBC ↩︎
确认机制:消费者必须显示的告知代理处理完毕的时间,以便代理将消息从队列中移除 ↩︎
❓消费者节点失效,则失效消费者的分区将指派给其他节点,并从最后记录的偏移量开始消费消息。如果消费者已经处理了后续的消息,但还没有记录它们的偏移量,那么重启后这些消息将被处理两次,这个问题如何解决呢? ↩︎
日志压缩:类似在hash索引中讨论的日志压缩,存储引擎定期查找具有相同键的记录,丢弃到重复的内容并且只保留每个键的最新值。比如在CDC系统被配置为,每个变更都包含一个主键,且每个键的更新都替换了该键以前的值,那么只需要保留对键的最新写入就足够了。无论何时需要重建衍生数据系统(如搜索索引),你可以从压缩日志主题0偏移量处启动新的消费者,然后依次扫描日志中的所有消息 ↩︎
写入和读取形式相分离,也叫命令查询责任分离(command query responsibility segregation, CQRS) ↩︎
SCD,处理SCD问题有很多种方式,从SCD0到SCD6,其中最流行的是:SCD1和SCD2;wiki:https://en.wikipedia.org/wiki/Slowly_changing_dimension;YouTube tutorial:https://www.youtube.com/watch?v=XqdZF0DJpUs ↩︎