美团金融的业务在过去的一段时间里发展很是快速。在业务增加的同时,咱们也注意到,不少用户的支付环境,实际上是在弱网环境中的。javascript
你们知道,前端可以服务用户的前提是 JavaScript 和 CSS 等静态资源可以正确加载。若是网络环境恶劣,那么咱们的静态资源尺寸越大,用户下载失败的几率就越高。前端
根据咱们的数据统计,咱们的业务中有2%的用户流失与资源加载有关。所以每次更新的代价越小、加载成功率越高,用户流失率也就会越低,从而就可以变相提升订单的转化率。java
做为一个发版频繁的业务,要下降发版的影响,能够作两方面优化:node
针对第一点,咱们有本身的模块加载器来作,这里先按下不表,咱们来重点聊聊增量更新的问题。webpack
看图说话。git
咱们的增量更新经过在浏览器端部署一个 SDK 来发起,这个 SDK 咱们称之为 Thunder.js 。github
Thunder.js 在页面加载时,会从页面中读取最新静态资源的版本号。同时, Thunder.js 也会从浏览器的缓存(一般是 localStorage)中读取咱们已经缓存的版本号。这两个版本号进行匹配,若是发现一致,那么咱们能够直接使用缓存当中的版本;反之,咱们会向增量更新服务发起一个增量补丁的请求。web
增量服务收到请求后,会调取新旧两个版本的文件进行对比,将差别做为补丁返回。Thunder.js 拿到请求后,便可将补丁打在老文件上,这样就获得了新文件。算法
总之一句话:老文件 + 补丁 = 新文件。docker
增量补丁的生成,主要依赖于 Myers 的 diff 算法。生成增量补丁的过程,就是寻找两个字符串最短编辑路径的过程。算法自己比较复杂,你们能够在网上找一些比较详细的算法描述,好比这篇 《The Myers diff algorithm》,这里就不详细介绍了。
补丁自己是一个微型的 DSL(Domain Specific Language)。这个 DSL 一共有三种微指令,分别对应保留、插入、删除三种字符串操做,每种指令都有本身的操做数。
例如,咱们要生成从字符串“abcdefg”到“acdz”的增量补丁,那么一个补丁的全文就相似以下:
=1\t-1\t=2\t-3\t+z
复制代码
这个补丁当中,制表符\t
是指令的分隔符,=
表示保留,-
表示删除,+
表示插入。整个补丁解析出来就是:
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 官方的插件方案,这个方案容许开发者使用 C/C++ 编写代码,然后再由 Node.js 来加载调用。因为原生代码的性能自己就比较不错,这是一种很是直接的优化方案。
后两种方案是浏览器侧的方案。
其中 ASM.js 由 Mozilla 提出,使用的是 JavaScript 的一个易于优化的子集。这个方案目前已经被废弃了。
取而代之的 WebAssembly ,由 W3C 来领导,采用的是更加紧凑、接近汇编的字节码来提速。目前在市面上刚刚崭露头角,相关的工具链还在完善中。 Mozilla 本身已经有一些尝试案例了,例如将 Rust 代码编译到 WebAssembly 来提速 sourcemap 的解析。
然而在考虑了这三种方案以后,咱们并无获得一个很好的结论。这三个方案的均可以提高 JavaScript 的运行性能,可是不管采起哪种,都没法将单个补丁的计算耗时从数十秒降到毫秒级。何况,这三种方案若是不加以复杂的改造,依然会运行在 JavaScript 的主线程之中,这对 Node.js 来讲,依然会发生严重的阻塞。
因而咱们开始考虑 Node.js 以外的方案。换语言这一想法应运而生。
更换编程语言,是一个很慎重的事情,要考虑的点不少。在增量计算这件事上,咱们主要考虑新语言如下方面:
固然,除了这些点以外,咱们还考虑了调优、部署的难易程度,以及语言自己是否可以快速驾驭等因素。
最终,咱们决定使用 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 层没能将流量拦截下来,进一步传递到了计算层呢?
为了防止过量的计算请求进入到计算环节,咱们还针对性地进行了流量控制。经过压测,咱们找到了单机计算量的瓶颈,而后将这个限制配置到了系统中。一旦计算量逼近这个数字,系统就会对超量的计算请求进行降级,再也不进行增量计算,直接返回全量文件。
另外一方面,咱们也有相应的线下预热机制。咱们为业务方提供了一个预热工具,业务方在上线前调用咱们的预热工具,就能够在上线前预先获得增量补丁并将其缓存起来。咱们的预热集群和线上计算集群是分离的,只共享分布式存储,所以双方在实际应用中互不影响。
有关容灾,咱们总结了以往见到的一些常见故障,分了四个门类来处理。
最后,在这套服务以外,咱们浏览器端的 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年加入美团,现负责金服平台基础组件的开发工做。