1.4 计数器应用
到目前为止,我们已经介绍了Knative的不少优点:更轻松的部署、更轻松的事件机制、渐进式开发、火星独角兽(每个人都向开发人员承诺的常见特性)等,但没有提供任何具体的细节。为了证明上述特性,下面我们从一个简单的例子(计数器应用)开始,介绍Knative如何使工作变得更快、更智能、更轻松。计数器[3]如图1.4所示。
图1.4 计数器
第一次看到计数器变化的时候,笔者就觉得它很神奇,其实也不神奇,它只是一个CGI程序(Web应用),可能是使用Perl语言编写的[4]。CGI程序是Knative的主要应用场景之一。下面是为博客首页[5]添加点击率计数器的代码。
清单1.1 博客首页HTML文件
首先介绍请求和响应的基本流程,如图1.5所示。主页的访问者从Web服务器获取HTML文档。该文档包含一些网页样式,最重要的是,包含计数器。
图1.5 请求和响应的基本流程
图1.5中展示的具体流程如下:
①浏览器向主页服务器发出GET请求。
②主页服务器返回主页的HTML文件。
③浏览器找到hits.png图片标签,然后通过GET请求获取hits.png。
④文件存储返回hits.png。
早期的程序中,Web服务端的所有流程都是串行的,即当访问者发送请求时,CGI服务端的/CGI-BIN/hitctr.pl进程会渲染图像,然后返回,这可能会花费1~2s的时间。如果网络差,那么花费的时间可能会更长。
不过现在来说,花费这么长的时间对访问者来说是不可接受的:没有人愿意等待几秒等图片渲染完。于是有了异步请求,即接受请求后,Web服务器立刻返回HTML响应,计数器图片则由其他服务响应。
Web服务器如何实现这种功能呢?实际上,Web服务器并不会实现这种功能,它只表示发生了点击动作。记住,Web服务器只是响应Web界面,而不是渲染图片。Web服务器通过CloudEvent格式发送异步消息(new_hit)。
那么消息发送到哪里了呢?消息会被发送到事件代理(Broker)。事件代理是Knative事件模块的消息汇聚点,它仅用于过滤和转发事件,具体是什么事件它不会关注。触发器(Trigger)来定义谁会接收什么事件这些具体细节。每个触发器都定义了对哪些事件感兴趣,以及事件的接收方是谁。当事件到达事件代理时,它会使用触发器的过滤器(Filter)。如果匹配完成,则将事件发送到订阅者(Subscriber),如图1.6所示。
图1.6 代理和触发器的工作流程
触发器使得处理多个事件流成为可能。Web服务器不知道也不关注new_hit事件会被谁消费。有了new_hit事件,就可以用来计数了。不同于之前的同步阻塞调用,现在可以用Perl脚本异步处理计数功能了。
既然已经使用了事件功能,不妨用得更彻底一些。毕竟渲染图片并不是计数应用的关键能力(比如执行SQL语句UPDATE时,并不能取回图片)。这里我们通过统计服务来消费new_hit事件,然后发出一个新的统计事件,这个事件会被其他订阅者来消费,消息流转过程如图1.7所示。
图1.7 消息流转过程
①主页服务发送new_hit事件。
②触发器匹配到new_hit事件,事件代理会将事件转发到计数器应用。
③计数器应用更新内部计数器,然后发送hits事件(包含总的点击数)。
④另一个触发器匹配到hits事件,事件代理会将该事件转发到图片渲染器服务。
⑤图片渲染器渲染出一个新的图片,然后更新文件服务器中的hits.png。
现在,如果访问者重新加载其浏览器,就会看到命中计数器已经更新了。
困难点
把所有的流程都汇总在一起,如图1.8所示。
注意,图1.8中用了两组标号。一组用于Web请求响应(图的左侧),另一组用于事件流(图的右侧)。这是很重要的一点:Web服务是同步的,事件流是异步的,这个区别很重要,如图1.9所示。
图1.8 数据流汇总图
图1.9 同步调用效率低,异步调用数据一致性无法保证
由于事件流是异步的,因此无法保证hits.png在下一位访问者请求之前更新。比如,访问者可能会看到0001336,重新加载后看到的还是0001336[6]。除此之外,一个访问者可能看不到任何变化,另一位访问者可能注意到计数器跳跃增长,因为后面提交的图片会覆盖前面提交的图片。不仅如此,访客还可能看到计数减少,因为0001338的渲染可能在0001337的渲染之前就已经完成了,或者是事件未按顺序到达,或者是某些事件甚至从未收到。
还记得前面说过计数器应用(Hit Counter)会记录点击总数吗?如果计数器应用在内存中保存点击总数,则是有问题的。比如当没有请求时,Knative的自动缩放器会把计数器应用的实例缩容到零,内存中保存的点击总数自然就消失了。计数器应用冷启动之后点击总数为零。但是另一方面,如果有多个计数器应用实例,则这些实例都是单独计数的。准确的图片点击数取决于流量路由到哪个计数器应用实例,并不是我们期望的总数。
我们正在讨论的是无状态系统,解决上述问题的思路就是把数据保存在共享位置,而不是代码逻辑中。比如每个计数器应用都使用Redis递增一个公共值。否则逻辑可能会变得比较复杂:每个实例都监听事件[7],只有当收到的数据大于自己内存中的数据时,才将数据更新为输入的计数,同时要保证系统中没有循环事件。