大家知道,前端能够服务用户的前提是JavaScript和CSS等静态资源能够正确加载。如果网络环境恶劣,那么我们的静态资源尺寸越大,用户下载失败的概率就越高。
根据我们的数据统计,我们的业务中有2%的用户流失与资源加载有关。因此每次更新的代价越小、加载成功率越高,用户流失率也就会越低,从而就能够变相提高订单的转化率。
作为一个发版频繁的业务,要降低发版的影响,可以做两方面优化:
针对第一点,我们有自己的模块加载器来做,这里先按下不表,我们来重点聊聊增量更新的问题。
看图说话:
我们的增量更新通过在浏览器端部署一个SDK来发起,这个SDK我们称之为Thunder.js。
Thunder.js在页面加载时,会从页面中读取最新静态资源的版本号。同时,Thunder.js也会从浏览器的缓存(通常是localStorage)中读取我们已经缓存的版本号。这两个版本号进行匹配,如果发现一致,那么我们可以直接使用缓存当中的版本;反之,我们会向增量更新服务发起一个增量补丁的请求。
增量服务收到请求后,会调取新旧两个版本的文件进行对比,将差异作为补丁返回。Thunder.js拿到请求后,即可将补丁打在老文件上,这样就得到了新文件。
总之一句话:老文件+补丁=新文件。
补丁本身是一个微型的DSL(DomainSpecificLanguage)。这个DSL一共有三种微指令,分别对应保留、插入、删除三种字符串操作,每种指令都有自己的操作数。
例如,我们要生成从字符串“abcdefg”到“acdz”的增量补丁,那么一个补丁的全文就类似如下:
=1\t-1\t=2\t-3\t+z这个补丁当中,制表符\t是指令的分隔符,=表示保留,-表示删除,+表示插入。整个补丁解析出来就是:
具体的JavaScript代码就不在这里粘贴了,流程比较简单,相信大家都可以自己写出来,只需要注意转义和字符串下标的维护即可。
那么我们是不是就已经做到万事无忧了呢?
我们最主要的问题是增量计算的速度不够快。
对于小流量业务来说,计算一次增量补丁然后缓存起来,即使第一次计算耗时一些也不会有太大影响。但用户侧的业务流量都较大,每月的增量计算次数超过10万次,并发计算峰值超过100QPS。
那么不够快的影响是什么呢?
我们之前的设计大致思想是用一个服务来承接流量,再用另一个服务来进行增量计算。这两个服务均由Node.js来实现。对于前者,Node.js的事件循环模型本就适合进行I/O密集型业务;然而对于后者,则实际为Node.js的软肋。Node.js的事件循环模型,要求Node.js的使用必须时刻保证Node.js的循环能够运转,如果出现非常耗时的函数,那么事件循环就会陷入进去,无法及时处理其他的任务。常见的手法是在机器上多开几个Node.js进程。然而一台普通的服务器也就8个逻辑CPU而已,对于增量计算来说,当我们遇到大计算量的任务时,8个并发可能就会让Node.js服务很难继续响应了。如果进一步增加进程数量,则会带来额外的进程切换成本,这并不是我们的最优选择。
“让JavaScript跑的更快”这个问题,很多前辈已经有所研究。在我们思考这个问题时,考虑过三种方案。
Node.jsAddon是Node.js官方的插件方案,这个方案允许开发者使用C/C++编写代码,而后再由Node.js来加载调用。由于原生代码的性能本身就比较不错,这是一种非常直接的优化方案。
后两种方案是浏览器侧的方案。
其中ASM.js由Mozilla提出,使用的是JavaScript的一个易于优化的子集。这个方案目前已经被废弃了。
然而在考虑了这三种方案之后,我们并没有得到一个很好的结论。这三个方案的都可以提升JavaScript的运行性能,但是无论采取哪一种,都无法将单个补丁的计算耗时从数十秒降到毫秒级。况且,这三种方案如果不加以复杂的改造,依然会运行在JavaScript的主线程之中,这对Node.js来说,依然会发生严重的阻塞。
于是我们开始考虑Node.js之外的方案。换语言这一想法应运而生。
更换编程语言,是一个很慎重的事情,要考虑的点很多。在增量计算这件事上,我们主要考虑新语言以下方面:
当然,除了这些点之外,我们还考虑了调优、部署的难易程度,以及语言本身是否能够快速驾驭等因素。
最终,我们决定使用Go语言进行增量计算服务的新实践。
在动手之前,我们首先用实际的两组文件,对Go和Node.js的增量模块进行了性能评测,以确定我们的方向是对的。
结果显示,尽管针对不同的文件会出现不同的情况,Go的高性能依然在计算性能上碾压了Node.js。这里需要注意,文件长度并不是影响计算耗时的唯一因素,另一个很重要的因素是文件差异的大小。
Go语言是Google推出的一门系统编程语言。它语法简单,易于调试,性能优异,有良好的社区生态环境。和Node.js进行并发的方式不同,Go语言使用的是轻量级线程,或者叫协程,来进行并发的。
专注于浏览器端的前端同学,可能对这种并发模型不太了解。这里我根据我自己的理解来简要介绍一下它和Node.js事件驱动并发的区别。
如上文所说,Node.js的主线程如果陷入在某个大计算量的函数中,那么整个事件循环就会阻塞。协程则与此不同,每个协程中都有计算任务,这些计算任务随着协程的调度而调度。一般来说,调度系统不会把所有的CPU资源都给同一个协程,而是会协调各个协程的资源占用,尽可能平分CPU资源。
相比Node.js,这种方式更加适合计算密集与I/O密集兼有的服务。
当然这种方式也有它的缺点,那就是由于每个协程随时会被暂停,因此协程之间会和传统的线程一样,有发生竞态的风险。所幸我们的业务并没有多少需要共享数据的场景,竞态的情况非常少。
实际上Web服务类型的应用,通常以请求->返回为模型运行,每个请求很少会和其他请求发生联系,因此使用锁的场景很少。一些“计数器”类的需求,靠原子变量也可以很容易地完成。
Go语言的模块依赖管理并不像Node.js那么成熟。尽管吐槽node_modules的人很多,但却不得不承认,Node.js的CMD机制对于我们来说不仅易于学习,同时每个模块的职责和边界也是非常清晰的。
具体来说,一个Node.js模块,它只需关心它自己依赖的模块是什么、在哪里,而不关心自己是如何被别人依赖的。这一点,可以从require调用看出:
简单来说,Node.js的模块体系是一棵树,最终本地模块就是这样:
|-src|-module-a|-submodule-aa|-submodule-ab|-module-b|-module-c|-submodule-ca|-subsubmodule-caa|-bin|-docs但Go语言就不同了。在Go语言中,每个模块不仅有一个短的模块名,同时还有一个项目中的“唯一路径”。如果你需要引用一个模块,那么你需要使用这个“唯一路径”来进行引用。比如:
|-src|-module-a|-submodule-aa|-submodule-ab|-module-b|-module-c|-submodule-ca|-subsubmodule-caa|-bin|-docs现在你不太可能直接把某个模块按目录拆出去了,因为它们之间的关系完全无法靠目录来断定了。
较新版本的Go推荐将第三方模块放在vendor目录下,和src是平级关系。而之前,这些第三方依赖也是放在src下面,非常令人困惑。
目前我们项目的代码规模还不算很大,可以通过命名来进行区分,但当项目继续增长下去,就需要更好的方案了。
Go有一个命令行工具,专门负责下载第三方包,叫做“go-get”。和大家想的不一样,这个工具没有版本描述文件。在Go的世界里并没有package.json这种文件。这给我们带来的直接影响就是我们的依赖不仅在外网放着,同时还无法有效地约束版本。同一个go-get命令,这个月下载的版本,可能到下个月就已经悄悄地变了。
目前Go社区有很多种不同的第三方工具来做,我们最终选择了glide。这是我们能找到的最接近npm的工具了。目前官方也在孕育一个新的方案来进行统一,我们拭目以待吧。
对于镜像,目前也没有太好的方案,我们参考了moby(就是docker)的做法,将第三方包直接存入我们自己项目的Git。这样虽然项目的源代码尺寸变得更大了,但无论是新人参与项目,还是上线发版,都不需要去外网拉取依赖了。
Go语言在美团内部的应用较少,直接结果就是,美团内部相当一部分基础设施,是缺少Go语言SDK支持的。例如公司自建的RedisCluster,由于根据公司业务需求进行了一些改动,导致开源的RedisClusterSDK,是无法直接使用的。再例如公司使用了淘宝开源出KV数据库——Tair,大概由于开源较早,也是没有Go的SDK的。
由于我们的架构设计中,需要依赖KV数据库进行存储,最终我们还是选择用Go语言实现了Tair的SDK。所谓“工欲善其事,必先利其器”,在SDK的编写过程中,我们逐渐熟悉了Go的一些编程范式,这对之后我们系统的实现,起到了非常有益的作用。所以有时候手头可用的设施少,并不一定是坏事,但也不能盲目去制造轮子,而是要思考自己造轮子的意义是什么,以结果来评判。
要经受生产环境的考验,只靠更换语言是不够的。对于我们来说,语言其实只是一个工具,它帮我们解决的是一个局部问题,而增量更新服务有很多语言之外的考量。
因为有前车之鉴,我们很清楚自己面对的流量是什么级别的。因此这一次从系统的架构设计上,就优先考虑了如何面对突发的海量流量。
首先我们来聊聊为什么我们会有突发流量。
对于前端来说,网页每次更新发版,其实就是发布了新的静态资源,和与之对应的HTML文件。而对于增量更新服务来说,新的静态资源也就意味着需要进行新的计算。
有经验的前端同学可能会说,虽然新版上线会创造新的计算,但只要前面放一层CDN,缓存住计算结果,就可以轻松缓解压力了不是吗?
这是有一定道理的,但并不是这么简单。面向普通消费者的C端产品,有一个特点,那就是用户的访问频度千差万别。具体到增量更新上来说,就是会出现大量不同的增量请求。因此我们做了更多的设计,来缓解这种情况。
这是我们对增量更新系统的设计。
放在首位的自然是CDN。面对海量请求,除了帮助我们削峰之外,也可以帮助不同地域的用户更快地获取资源。
在CDN之后,我们将增量更新系统划分成了两个独立的层,称作API层和计算层。为什么要划分开呢?在过往的实践当中,我们发现即使我们再小心再谨慎,仍然还是会有犯错误的时候,这就需要我们在部署和上线上足够灵活;另一方面,对于海量的计算任务,如果实在扛不住,我们需要保有最基本的响应能力。基于这样的考虑,我们把CDN的回源服务独立成一个服务。这层服务有三个作用:
那如果API层没能将流量拦截下来,进一步传递到了计算层呢?
为了防止过量的计算请求进入到计算环节,我们还针对性地进行了流量控制。通过压测,我们找到了单机计算量的瓶颈,然后将这个限制配置到了系统中。一旦计算量逼近这个数字,系统就会对超量的计算请求进行降级,不再进行增量计算,直接返回全量文件。
另一方面,我们也有相应的线下预热机制。我们为业务方提供了一个预热工具,业务方在上线前调用我们的预热工具,就可以在上线前预先得到增量补丁并将其缓存起来。我们的预热集群和线上计算集群是分离的,只共享分布式存储,因此双方在实际应用中互不影响。
有关容灾,我们总结了以往见到的一些常见故障,分了四个门类来处理。
最后,在这套服务之外,我们浏览器端的SDK也有自己的容灾机制。我们在增量更新系统之外,单独部署了一套CDN,这套CDN只存储全量文件。一旦增量更新系统无法工作,SDK就会去这套CDN上拉取全量文件,保障前端的可用性。
考虑到每个业务实际的静态文件总量不同,在这份数据里我们刻意包含了总量和人均节省流量两个不同的值。在实际业务当中,业务方自己也会将静态文件根据页面进行拆分(例如通过webpack中的chunk来分),每次更新实际不会需要全部更新。
由于一些边界情况,增量计算的成功率受到了影响,但随着问题的一一修正,未来增量计算的成功率会越来越高。
现在来回顾一下,在我们的新实践中,都有哪些大家可以真正借鉴的点:
对于Go语言,我们也是摸着石头过河,希望我们这点经验能够对大家有所帮助。
最后,如果大家对我们所做的事情也有兴趣,想要和我们一起共建大前端团队的话,欢迎发送简历至liuyanghe02@meituan.com。