前端遇上Go: 静态资源增量更新的新实践
为什么要做增量更新
美团金融的业务在过去的一段时间里发展非常快速。在业务增长的同时,我们也注意到,很多用户的支付环境,其实是在弱网环境中的。
大家知道,前端能够服务用户的前提是 JavaScript 和 CSS 等静态资源能够正确加载。如果网络环境恶劣,那么我们的静态资源尺寸越大,用户下载失败的概率就越高。
根据我们的数据统计,我们的业务中有2%的用户流失与资源加载有关。因此每次更新的代价越小、加载成功率越高,用户流失率也就会越低,从而就能够变相提高订单的转化率。
作为一个发版频繁的业务,要降低发版的影响,可以做两方面优化:
- 更高效地使用缓存,减少静态资源的重复下载。
- 使用增量更新,降低单次发版时下发的内容尺寸。
针对第一点,我们有自己的模块加载器来做,这里先按下不表,我们来重点聊聊增量更新的问题。
增量更新是怎么一个过程
看图说话。
我们的增量更新通过在浏览器端部署一个 SDK 来发起,这个 SDK 我们称之为 Thunder.js 。
Thunder.js 在页面加载时,会从页面中读取最新静态资源的版本号。同时, Thunder.js 也会从浏览器的缓存(通常是 localStorage)中读取我们已经缓存的版本号。这两个版本号进行匹配,如果发现一致,那么我们可以直接使用缓存当中的版本;反之,我们会向增量更新服务发起一个增量补丁的请求。
增量服务收到请求后,会调取新旧两个版本的文件进行对比,将差异作为补丁返回。Thunder.js 拿到请求后,即可将补丁打在老文件上,这样就得到了新文件。
总之一句话:老文件 + 补丁 = 新文件。
增量补丁的生成,主要依赖于 Myers 的 diff 算法。生成增量补丁的过程,就是寻找两个字符串最短编辑路径的过程。算法本身比较复杂,大家可以在网上找一些比较详细的算法描述,比如这篇 《The Myers diff algorithm》,这里就不详细介绍了。
补丁本身是一个微型的 DSL(Domain Specific Language)。这个 DSL 一共有三种微指令,分别对应保留、插入、删除三种字符串操作,每种指令都有自己的操作数。
例如,我们要生成从字符串“abcdefg”到“acdz”的增量补丁,那么一个补丁的全文就类似如下:
=1\t-1\t=2\t-3\t+z
这个补丁当中,制表符\t是指令的分隔符,=表示保留,-表示删除,+表示插入。整个补丁解析出来就是:
- 保留1个字符
- 删除1个字符
- 保留2个字符
- 删除3个字符
- 插入1个字符:z
具体的 JavaScript 代码就不在这里粘贴了,流程比较简单,相信大家都可以自己写出来,只需要注意转义和字符串下标的维护即可。
增量更新其实不是前端的新鲜技术,在客户端领域,增量更新早已经应用多年。看过我们《美团金融扫码付静态资源加载优化实践》的朋友,应该知道我们其实之前已有实践,在当时仅仅靠增量更新,日均节省流量达30多GB。而现在这个数字已经随着业务量变得更高了。
那么我们是不是就已经做到万事无忧了呢?
我们之前的增量更新实践遇到了什么问题
我们最主要的问题是增量计算的速度不够快。
之前的优化实践中,我们绝大部分的优化其实都是为了优化增量计算的速度。文本增量计算的速度确实慢,慢到什么程度呢?以前端比较常见的JS资源尺寸——200KB——来进行增量计算,进行一次增量计算的时间依据文本不同的数量,从数十毫秒到十几秒甚至几十秒都有可能。
对于小流量业务来说,计算一次增量补丁然后缓存起来,即使第一次计算耗时一些也不会有太大影响。但用户侧的业务流量都较大,每月的增量计算次数超过 10 万次,并发计算峰值超过 100 QPS 。
那么不够快的影响是什么呢?
我们之前的设计大致思想是用一个服务来承接流量,再用另一个服务来进行增量计算。这两个服务均由 Node.js 来实现。对于前者, Node.js 的事件循环模型本就适合进行 I/O 密集型业务;然而对于后者,则实际为 Node.js 的软肋。 Node.js 的事件循环模型,要求 Node.js 的使用必须时刻保证 Node.js 的循环能够运转,如果出现非常耗时的函数,那么事件循环就会陷入进去,无法及时处理其他的任务。常见的手法是在机器上多开几个 Node.js 进程。然而一台普通的服务器也就8个逻辑CPU而已,对于增量计算来说,当我们遇到大计算量的任务时,8个并发可能就会让 Node.js 服务很难继续响应了。如果进一步增加进程数量,则会带来额外的进程切换成本,这并不是我们的最优选择。
更高性能的可能方案
“让 JavaScript 跑的更快”这个问题,很多前辈已经有所研究。在我们思考这个问题时,考虑过三种方案。
Node.js Addon
Node.js Addon 是 Node.js 官方的插件方案,这个方案允许开发者使用 C/C++ 编写代码,而后再由 Node.js 来加载调用。由于原生代码的性能本身就比较不错,这是一种非常直接的优化方案。
ASM.js / WebAssembly
后两种方案是浏览器侧的方案。
其中 ASM.js 由 Mozilla 提出,使用的是 JavaScript 的一个易于优化的子集。这个方案目前已经被废弃了。
取而代之的 WebAssembly ,由 W3C 来领导,采用的是更加紧凑、接近汇编的字节码来提速。目前在市面上刚刚崭露头角,相关的工具链还在完善中。 Mozilla 自己已经有一些尝试案例了,例如将 Rust 代码编译到 WebAssembly 来提速 sourcemap 的解析。
然而在考虑了这三种方案之后,我们并没有得到一个很好的结论。这三个方案的都可以提升 JavaScript 的运行性能,但是无论采取哪一种,都无法将单个补丁的计算耗时从数十秒降到毫秒级。况且,这三种方案如果不加以复杂的改造,依然会运行在 JavaScript 的主线程之中,这对 Node.js 来说,依然会发生严重的阻塞。
于是我们开始考虑 Node.js 之外的方案。换语言这一想法应运而生。
换语言
更换编程语言,是一个很慎重的事情,要考虑的点很多。在增量计算这件事上,我们主要考虑新语言以下方面:
- 运行速度
- 并发处理
- 类型系统
- 依赖管理
- 社区
当然,除了这些点之外,我们还考虑了调优、部署的难易程度,以及语言本身是否能够快速驾驭等因素。
最终,我们决定使用 Go 语言进行增量计算服务的新实践。
选择Go带来了什么
高性能
增量补丁的生成算法,在 Node.js 的实现中,对应 diff 包;而在 Go 的实现中,对应 go-diff 包。
在动手之前,我们首先用实际的两组文件,对 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 调用看出:
const util = require('./util'); const http = require('http'); module.exports = {};
这是一个非常简单的模块,它依赖两个其他模块,其中 util 来自我们本地的目录,而 http 则来自于 Node.js 内置。在这种情形下,只要你有良好的模块依赖关系,一个自己写好的模块想要给别人复用,只需要把整个目录独立上传到 npm 上即可。
简单来说, Node.js 的模块体系是一棵树,最终本地模块就是这样:
|- src |- module-a |- submodule-aa |- submodule-ab |- module-b |- module-c |- submodule-ca |- subsubmodule-caa |- bin |- docs
但 Go 语言就不同了。在 Go 语言中,每个模块不仅有一个短的模块名,同时还有一个项目中的“唯一路径”。如果你需要引用一个模块,那么你需要使用这个“唯一路径”来进行引用。比如:
package main import ( "fmt" "github.com/valyala/fasthttp" "path/to/another/local/module" )
第一个依赖的 fmt 是 Go 自带的模块,简单明了。第二个模块是一个位于 Github 的开源第三方模块,看路径形式就能够大致推断出来它是第三方的。而第三个,则是我们项目中一个可复用模块,这就有点不太合适了。其实如果 Go 支持嵌套的模块关系的话,相当于每个依赖从根目录算起就可以了,能够避免出现 ../../../../root/something 这种尴尬的向上查找。但是, Go 是不支持本地依赖之间的文件夹嵌套的。这样一来,所有的本地模块,都会平铺在同一个目录里,最终会变成这样:
|- src |- module-a |- submodule-aa |- submodule-ab |- module-b |- module-c |- submodule-ca |- subsubmodule-caa |- bin |- docs
现在你不太可能直接把某个模块按目录拆出去了,因为它们之间的关系完全无法靠目录来断定了。
较新版本的 Go 推荐将第三方模块放在 vendor 目录下,和 src 是平级关系。而之前,这些第三方依赖也是放在 src 下面,非常令人困惑。
目前我们项目的代码规模还不算很大,可以通过命名来进行区分,但当项目继续增长下去,就需要更好的方案了。
过于简单的去中心化第三方包管理
和有 npm 的 Node.js 另一个不一样是: Go 语言没有自己的包管理平台。对于 Go 的工具链来说,它并不关心你的第三方包到底是谁来托管的。 社区里 Go 的第三方包遍布各个 Git 托管平台,这不仅让我们在搜索包时花费更多时间,更麻烦的是,我们无法通过在企业内部搭建一个类似 npm 镜像的平台,来降低大家每次下载第三方包的耗时,同时也难以在不依赖外网的情况下,进行包的自由安装。
Go 有一个命令行工具,专门负责下载第三方包,叫做“ go-get ”。和大家想的不一样,这个工具没有版本描述文件。在 Go 的世界里并没有 package.json 这种文件。这给我们带来的直接影响就是我们的依赖不仅在外网放着,同时还无法有效地约束版本。同一个go-get命令,这个月下载的版本,可能到下个月就已经悄悄地变了。
目前 Go 社区有很多种不同的第三方工具来做,我们最终选择了 glide 。这是我们能找到的最接近 npm 的工具了。目前官方也在孕育一个新的方案来进行统一,我们拭目以待吧。
对于镜像,目前也没有太好的方案,我们参考了 moby (就是 docker )的做法,将第三方包直接存入我们自己项目的 Git 。这样虽然项目的源代码尺寸变得更大了,但无论是新人参与项目,还是上线发版,都不需要去外网拉取依赖了。
匮乏的内部基础设施支持
Go 语言在美团内部的应用较少,直接结果就是,美团内部相当一部分基础设施,是缺少 Go 语言 SDK 支持的。例如公司自建的 Redis Cluster ,由于根据公司业务需求进行了一些改动,导致开源的 Redis Cluster SDK ,是无法直接使用的。再例如公司使用了淘宝开源出 KV 数据库—— Tair ,大概由于开源较早,也是没有 Go 的 SDK 的。
由于我们的架构设计中,需要依赖 KV 数据库进行存储,最终我们还是选择用 Go 语言实现了 Tair 的 SDK。所谓“工欲善其事,必先利其器”,在 SDK 的编写过程中,我们逐渐熟悉了 Go 的一些编程范式,这对之后我们系统的实现,起到了非常有益的作用。所以有时候手头可用的设施少,并不一定是坏事,但也不能盲目去制造轮子,而是要思考自己造轮子的意义是什么,以结果来评判。
语言之外
要经受生产环境的考验,只靠更换语言是不够的。对于我们来说,语言其实只是一个工具,它帮我们解决的是一个局部问题,而增量更新服务有很多语言之外的考量。
如何面对海量突发流量
因为有前车之鉴,我们很清楚自己面对的流量是什么级别的。因此这一次从系统的架构设计上,就优先考虑了如何面对突发的海量流量。
首先我们来聊聊为什么我们会有突发流量。
对于前端来说,网页每次更新发版,其实就是发布了新的静态资源,和与之对应的 HTML 文件。而对于增量更新服务来说,新的静态资源也就意味着需要进行新的计算。
有经验的前端同学可能会说,虽然新版上线会创造新的计算,但只要前面放一层 CDN ,缓存住计算结果,就可以轻松缓解压力了不是吗?
这是有一定道理的,但并不是这么简单。面向普通消费者的 C 端产品,有一个特点,那就是用户的访问频度千差万别。具体到增量更新上来说,就是会出现大量不同的增量请求。因此我们做了更多的设计,来缓解这种情况。
这是我们对增量更新系统的设计。
放在首位的自然是 CDN 。面对海量请求,除了帮助我们削峰之外,也可以帮助不同地域的用户更快地获取资源。
在 CDN 之后,我们将增量更新系统划分成了两个独立的层,称作 API 层和计算层。为什么要划分开呢?在过往的实践当中,我们发现即使我们再小心再谨慎,仍然还是会有犯错误的时候,这就需要我们在部署和上线上足够灵活;另一方面,对于海量的计算任务,如果实在扛不住,我们需要保有最基本的响应能力。基于这样的考虑,我们把 CDN 的回源服务独立成一个服务。这层服务有三个作用:
- 通过对存储系统的访问,如果有已经计算好的增量补丁,那么可以直接返回,只把最需要计算的任务传递给计算层。
- 如果计算层出现问题,API 层保有响应能力,能够进行服务降级,返回全量文件内容。
- 将对外的接口管理起来,避免接口变更对核心服务的影响。在这个基础上可以进行一些简单的聚合服务,提供诸如请求合并之类的服务。
那如果 API 层没能将流量拦截下来,进一步传递到了计算层呢?
为了防止过量的计算请求进入到计算环节,我们还针对性地进行了流量控制。通过压测,我们找到了单机计算量的瓶颈,然后将这个限制配置到了系统中。一旦计算量逼近这个数字,系统就会对超量的计算请求进行降级,不再进行增量计算,直接返回全量文件。
另一方面,我们也有相应的线下预热机制。我们为业务方提供了一个预热工具,业务方在上线前调用我们的预热工具,就可以在上线前预先得到增量补丁并将其缓存起来。我们的预热集群和线上计算集群是分离的,只共享分布式存储,因此双方在实际应用中互不影响。
如何容灾
有关容灾,我们总结了以往见到的一些常见故障,分了四个门类来处理。
- 线路故障。我们在每一层服务中都内置了单机缓存,这个缓存的作用一方面是可以泄洪,另一方面,如果线路出现故障,单机缓存也能在一定程度上降低对线路的依赖。
- 存储故障。对于存储,我们直接采用了两种公司内非常成熟的分布式存储系统,它们互为备份。
- CDN 故障。做前端的同学或多或少都遇到过 CDN 出故障的时候,我们也不例外。因此我们准备了两个不同的 CDN ,有效隔离了来自 CDN 故障的风险。
最后,在这套服务之外,我们浏览器端的 SDK 也有自己的容灾机制。我们在增量更新系统之外,单独部署了一套 CDN ,这套 CDN 只存储全量文件。一旦增量更新系统无法工作, SDK 就会去这套 CDN 上拉取全量文件,保障前端的可用性。
回顾与总结
服务上线运转一段时间后,我们总结了新实践所带来的效果:
日均增量计算成功率 | 日均增量更新占比 | 单日人均节省流量峰值 | 项目静态文件总量 |
---|---|---|---|
99.97% | 64.91% | 164.07 KB | 1184 KB |
考虑到每个业务实际的静态文件总量不同,在这份数据里我们刻意包含了总量和人均节省流量两个不同的值。在实际业务当中,业务方自己也会将静态文件根据页面进行拆分(例如通过 webpack 中的 chunk 来分),每次更新实际不会需要全部更新。
由于一些边界情况,增量计算的成功率受到了影响,但随着问题的一一修正,未来增量计算的成功率会越来越高。
现在来回顾一下,在我们的新实践中,都有哪些大家可以真正借鉴的点:
- 不同的语言和工具有不同的用武之地,不要试图用锤子去锯木头。该换语言就换,不要想着一个语言或工具解决一切。
- 更换语言是一个重要的决定,在决定之前首先需要思考是否应当这么做。
- 语言解决更多的是局部问题,架构解决更多的是系统问题。换了语言也不代表就万事大吉了。
- 构建一个系统时,首先思考它是如何垮的。想清楚你的系统潜在瓶颈会出现在哪,如何加强它,如何考虑它的备用方案。
对于 Go 语言,我们也是摸着石头过河,希望我们这点经验能够对大家有所帮助。
最后,如果大家对我们所做的事情也有兴趣,想要和我们一起共建大前端团队的话,欢迎发送简历至 liuyanghe02@meituan.com 。
作者简介
洋河,2013年加入携程UED实习,参与研发了人生中第一个星数超过100的 Github 开源项目。2014年加入小米云平台,同时负责网页前端开发、客户端开发及路由器固件开发,积累了丰富的端开发经验。2017年加入美团,现负责金服平台基础组件的开发工作。
下一章:Kotlin代码检查在美团的探索与实践
背景 Kotlin有着诸多的特性,比如空指针安全、方法扩展、支持函数式编程、丰富的语法糖等。这些特性使得Kotlin的代码比Java简洁优雅许多,提高了代码的可读性和可维护性,节省了开发时间,提高了开 ...