事件流如何提高应用程序的扩展性、可靠性和可维护性
关于事件流处理,在不同的场景中有不同的概念。有人称之为流处理,有人称之为事件溯源或CQRS,还有人称之为“复杂事件处理(Complex Event Processing)”。不管名称是什么,它们的基本原则都是一样的。Martin Kleppmann是Apache Samza的贡献者。在本文中,我们将跟随他的思路深入理解这些概念,以便帮助我们设计更好的系统。
“流处理(stream processing)”源于LinkedIn构建大规模数据系统的经验,并在开源项目Apache Kafka和Apache Samza中实现。Martin以Google Analytics为例具体介绍了这一概念。Google Analytics是一小段JavaScript代码,可以追踪哪个访问者访问了哪个网页。然后,系统管理员可以研究这些数据,并按照时间段、URL等划分这些数据。为了实现这个目的,每次用户访问一个页面时,就需要记录一个事件来反映这个事实。页面访问事件可能是(图1)这样的结构:
每个事件都是包含上述信息的一个简单不变的事实。它只简单地记录已发生的事情。然后,我们就可以从这些页面访问事件中生成图形仪表板。通常来说,这些事件可以使用(图2)所示的其中一种方式存储:
选项(a):在每个事件进来的时候将其存储,并把它们全部转存到一个大型的数据库、数据仓库或Hadoop集群中。在需要时,就可以在数据集上执行查询。这个过程会扫描所有事件,或者至少是某个大型的数据子集,并动态地完成聚合。
选项(b):如果每个事件都存储数据量太大的话,可以选择存储事件的聚合结果。比如,如果要记录某个事件的发生次数,那么就可以在这个事件进来时将计数器加1。我们还可以将多个计数器保存在OLAP立方中。有了OLAP立方,当需要查找一个URL在某一天的访问量时,直接读取相应URL和日期组合的计数器就可以了。这样就只需要读取一个值,而不需要扫描一个很长的事件列表。
选项(a)的好处是,存储原始事件数据可以最大化分析的灵活性。比如,可以跟踪某个人以什么顺序访问了哪些页面,采用选项(b)就无法实现。这种分析对于一些离线处理任务非常重要,比如训练一个推荐系统。在这种应用场景下,最好是保存原始事件。
不过,选项(b)也有它的用途,尤其是需要实时决策或响应的时候。比如,为了防止别人破坏网站,可能需要引入一个访问频率限制,在一个小时内一个特定的IP只允许请求100次;如果客户端超出这个限制,就阻塞它。这时,通过原始数据存储实现效率将非常低下,因为系统需要不断地重新扫描事件历史才能确定某个人是否超出了限制。而针对每个IP每个时间窗口维护一个计数器将会更高效。总之,存储原始事件和存储聚合结果都是有用的,只不过应用场景不同。
对于选项(b),在最简单的情况下,可以让Web服务器直接更新聚合结果。这时,可以将计数器保存在像memcached或Redis这样具有原子增量操作的缓存中。每次Web服务器处理一个请求,就直接向缓存发送一条增量命令。更复杂一点,可以引入事件流(如图3),或者消息队列,或者事件日志。流上的事件与(图1)中PageViewEvent记录相同。
这种架构的好处是,同样的事件数据可以供多个消费者使用,不同的消费者完成不同的任务,非常灵活和易于扩展。
“事件溯源(Event sourcing)”是一个同流处理类似的概念,只不过它出自领域驱动设计社区。它关注数据在数据库中的存储结构。这里将以电商网站的购物车为例:
如果用户123将产品999的数量改成了3,那么系统将通过UPDATE操作实现数据修改:
不过,按照事件溯源的思想,这不是一个好的数据库设计方式,因为它没有记录购物车每次变化的信息,即丢失了历史操作信息。因此,在用户123初次添加产品999的时候,系统应该记录AddToCart事件;当用户改变主意想买3个999时,系统接着记录UpdateCartQuantity事件。总之,用户对购物车的每次操作都记为一个单独的事件。这就是事件溯源的本质:将每次写操作记为一个不可变事件,而不是对数据库执行破坏性写入。
可以发现,它同流处理的例子(关于Google Analytics)一样:(a)存储原始事件;(b)存储聚合结果。
通过进一步思考可以观察到,(a)是理想的数据写入形式,只需要将事件追加到日志尾部,而不需要更新多个不同的表。这对数据库而言是一种最简单、最快速的写入方式。另一方面,(b)是理想的数据读取形式。比如,在用户想知道购物车中有什么的时候,他并不会关心购物车中产品的变化历史,所以直接读取聚合结果会获得最好的性能。
为了帮助我们更深入的理解上述概念,Martin又分别举了Twitter、Facebook和Wikipedia的例子。本文就不一一赘述了,感兴趣的读者可以查看原文。
现在,让我们回到有关事件流的讨论。不管是流处理,还是事件溯源,只要有了事件流,就可以完成以下工作:
- 获取所有的原始事件(也许还要做一点转换),然后将它们加载到一个大型的数据仓库中供分析人员使用;
- 更新全文搜索索引,使用户可以搜索最新数据;
- 更新缓存,使系统可以从快速缓存中读取数据,并保证缓存中的数据是最新的;
- 通过对事件流进行处理创建一个新的事件流,然后将后者作为另一个系统的输入。
与传统的数据库使用方法相比,采用类似事件溯源的方法是一个重大的变革。这项变革带来了如下好处:
- 松耦合——数据读写使用不同的数据库模式,读取的数据经由写入的数据转换而来,应用程序不同部分之间的耦合度降低了;
- 读写性能——规范化(写入快)和非规范化(读取快)的争论源于数据读写使用同一模式的假设,如果数据读写使用不同的数据库模式,读写速度都会得到提升;
- 扩展性——因为事件流是一种简单的抽象,而且允许开发人员将应用程序分解成流的生产者和消费者,所以很容易跨机器并行和扩展;
- 灵活性——原始事件简单、明确,“模式迁移”不会造成多大影响;而向用户展示数据要复杂得多,但如果有一个转换过程可以实现从原始事件到缓存内容的转换,那么当需要新的用户界面时,只需要使用新的逻辑构建新的缓存;
- 错误场景——原始事件是不变的事实,如果系统出现问题,那么开发人员总是可以用相同的顺序将事件重放。
这里需要注意,实际上,数据库写操作通常都有一个类似事件的不变性,大部分数据库都有的“写前日志(write-ahead log)”本质上就是一个写操作的事件流,虽然在不同的数据库中实现形式可能不同,如PostgreSQL、InnoDB和Oracle中的MVCC机制,CouchDB、Datomic和LMDB中的追加式B树。
接下来,Martin介绍了如何在应用程序层面上使用事件流。
他用的比较多的是Apache Kafka和Apache Samza。前者是一个消息代理,就像一个发布-订阅消息队列,一秒钟可以处理包含数百万条消息的事件流,并将它们永久存储到磁盘上及跨机器复制。后者是与Kafka搭配使用的处理过程,开发人员可以用它编写代码,消费输入流,生产输出流。
除了Samza之外,开发人员还可以选择Storm或Spark Streaming这两种最流行的流处理框架。关于它们之间的区别,感兴趣的读者可以查看Samza文档。这些分布式流处理框架均源于互联网公司。它们都关注底层的一些事情:如何将流处理扩展到多台机器;如何将Job部署到集群;如何处理故障;如何在多租户环境下实现可靠的性能。它们像MapReduce更多一些,而像数据库更少一些。
相比之下,还有一些面向流处理的高级语言,如复杂事件处理(CEP)。使用CEP,可以编写查询或规则来匹配满足特定模式的事件。这些查询或规则与SQL查询类似,只不过CEP引擎会不断的查找事件流来匹配查询,并在匹配成功时发送通知。这对于欺诈检测或业务流程监控非常有用。
还有一个相关概念是在流上进行全文搜索。它是说,在流上事先注册一个查询,当有事件匹配查询时发送通知。这里有一些与此相关的试验性工作。以下是其它一些与流处理相关的概念:
- Actor框架——像Akka、Orleans和Erlang OTP等框架也是基于不可变事件的流。不过,它们更多的是一种并发机制,而不是数据管理机制;
- “响应式(Reactive)”——它似乎是一个定义松散的概念集合,像函数响应式编程,主要是将事件流提供给用户界面使用;
- 变更数据捕获(CDC)——按照我们熟悉的方式使用数据库,但要将任何插入、更新和删除操作抽取到一个数据变更事件流中。