前端遇上Go: 静态资源增量更新的新实践
https://mp.weixin.qq.com/s/hCqQW1F8FngPPGZAisAWUg

前端遇上Go: 静态资源增量更新的新实践

原创: 洋河 美团技术团队 前天

前端遇上Go: 静态资源增量更新的新实践-LMLPHP

总第259篇

2018年 第51篇

为什么要做增量更新

美团金融的业务在过去的一段时间里发展非常快速。在业务增长的同时,我们也注意到,很多用户的支付环境,其实是在弱网环境中的。

大家知道,前端能够服务用户的前提是 JavaScript 和 CSS 等静态资源能够正确加载。如果网络环境恶劣,那么我们的静态资源尺寸越大,用户下载失败的概率就越高。

根据我们的数据统计,我们的业务中有2%的用户流失与资源加载有关。因此每次更新的代价越小、加载成功率越高,用户流失率也就会越低,从而就能够变相提高订单的转化率。

作为一个发版频繁的业务,要降低发版的影响,可以做两方面优化:

  1. 更高效地使用缓存,减少静态资源的重复下载。

  2. 使用增量更新,降低单次发版时下发的内容尺寸。

针对第一点,我们有自己的模块加载器来做,这里先按下不表,我们来重点聊聊增量更新的问题。

增量更新是怎么一个过程

看图说话。

前端遇上Go: 静态资源增量更新的新实践-LMLPHP

图1 增量更新的客户端流程图

我们的增量更新通过在浏览器端部署一个 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. 删除1个字符

  3. 保留2个字符

  4. 删除3个字符

  5. 插入1个字符:z

具体的 JavaScript 代码就不在这里粘贴了,流程比较简单,相信大家都可以自己写出来,只需要注意转义和字符串下标的维护即可。

增量更新其实不是前端的新鲜技术,在客户端领域,增量更新早已经应用多年。看过我们《美团金融扫码付静态资源加载优化实践》的朋友,应该知道我们其实之前已有实践,在当时仅仅靠增量更新,日均节省流量达30多GB。而现在这个数字已经随着业务量变得更高了。

那么我们是不是就已经做到万事无忧了呢?

我们之前的增量更新实践遇到了什么问题

我们最主要的问题是增量计算的速度不够快。

之前的优化实践中,我们绝大部分的优化其实都是为了优化增量计算的速度。文本增量计算的速度确实慢,慢到什么程度呢?以前端比较常见的JS资源尺寸——200KB——来进行增量计算,进行一次增量计算的时间依据文本不同的数量,从数十毫秒到十几秒甚至几十秒都有可能。

对于小流量业务来说,计算一次增量补丁然后缓存起来,即使第一次计算耗时一些也不会有太大影响。但用户侧的业务流量都较大,每月的增量计算次数超过 10 万次,并发计算峰值超过 100 QPS 。

那么不够快的影响是什么呢?

前端遇上Go: 静态资源增量更新的新实践-LMLPHP图2 Node.js 的事件循环

我们之前的设计大致思想是用一个服务来承接流量,再用另一个服务来进行增量计算。这两个服务均由 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: 静态资源增量更新的新实践-LMLPHP图3 相同算法、相同文件的计算时间对比

结果显示,尽管针对不同的文件会出现不同的情况,Go 的高性能依然在计算性能上碾压了 Node.js 。这里需要注意,文件长度并不是影响计算耗时的唯一因素,另一个很重要的因素是文件差异的大小。

不一样的并发模型

Go 语言是 Google 推出的一门系统编程语言。它语法简单,易于调试,性能优异,有良好的社区生态环境。和 Node.js 进行并发的方式不同, Go 语言使用的是轻量级线程,或者叫协程,来进行并发的。

专注于浏览器端的前端同学,可能对这种并发模型不太了解。这里我根据我自己的理解来简要介绍一下它和 Node.js 事件驱动并发的区别。

如上文所说, Node.js 的主线程如果陷入在某个大计算量的函数中,那么整个事件循环就会阻塞。协程则与此不同,每个协程中都有计算任务,这些计算任务随着协程的调度而调度。一般来说,调度系统不会把所有的 CPU 资源都给同一个协程,而是会协调各个协程的资源占用,尽可能平分 CPU 资源。

前端遇上Go: 静态资源增量更新的新实践-LMLPHP图4 Go 的 goroutine

相比 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 的工具了。目前官方也在孕育一个新的方案来进行统一,我们拭目以待吧。

前端遇上Go: 静态资源增量更新的新实践-LMLPHP图5 Go 社区的各种第三方包管理工具

对于镜像,目前也没有太好的方案,我们参考了 moby (就是 docker )的做法,将第三方包直接存入我们自己项目的 Git 。这样虽然项目的源代码尺寸变得更大了,但无论是新人参与项目,还是上线发版,都不需要去外网拉取依赖了。

匮乏的内部基础设施支持

Go 语言在美团内部的应用较少,直接结果就是,美团内部相当一部分基础设施,是缺少 Go 语言 SDK 支持的。例如公司自建的 Redis Cluster ,由于根据公司业务需求进行了一些改动,导致开源的 Redis Cluster SDK ,是无法直接使用的。再例如公司使用了淘宝开源出 KV 数据库—— Tair ,大概由于开源较早,也是没有 Go 的 SDK 的。

由于我们的架构设计中,需要依赖 KV 数据库进行存储,最终我们还是选择用 Go 语言实现了 Tair 的 SDK。所谓“工欲善其事,必先利其器”,在 SDK 的编写过程中,我们逐渐熟悉了 Go 的一些编程范式,这对之后我们系统的实现,起到了非常有益的作用。所以有时候手头可用的设施少,并不一定是坏事,但也不能盲目去制造轮子,而是要思考自己造轮子的意义是什么,以结果来评判。

语言之外

要经受生产环境的考验,只靠更换语言是不够的。对于我们来说,语言其实只是一个工具,它帮我们解决的是一个局部问题,而增量更新服务有很多语言之外的考量。

如何面对海量突发流量

因为有前车之鉴,我们很清楚自己面对的流量是什么级别的。因此这一次从系统的架构设计上,就优先考虑了如何面对突发的海量流量。

首先我们来聊聊为什么我们会有突发流量。

对于前端来说,网页每次更新发版,其实就是发布了新的静态资源,和与之对应的 HTML 文件。而对于增量更新服务来说,新的静态资源也就意味着需要进行新的计算。

有经验的前端同学可能会说,虽然新版上线会创造新的计算,但只要前面放一层 CDN ,缓存住计算结果,就可以轻松缓解压力了不是吗?

这是有一定道理的,但并不是这么简单。面向普通消费者的 C 端产品,有一个特点,那就是用户的访问频度千差万别。具体到增量更新上来说,就是会出现大量不同的增量请求。因此我们做了更多的设计,来缓解这种情况。

前端遇上Go: 静态资源增量更新的新实践-LMLPHP图6 增量服务架构设计

这是我们对增量更新系统的设计。

放在首位的自然是 CDN 。面对海量请求,除了帮助我们削峰之外,也可以帮助不同地域的用户更快地获取资源。

前端遇上Go: 静态资源增量更新的新实践-LMLPHP图7 增量服务 API 层

在 CDN 之后,我们将增量更新系统划分成了两个独立的层,称作 API 层和计算层。为什么要划分开呢?在过往的实践当中,我们发现即使我们再小心再谨慎,仍然还是会有犯错误的时候,这就需要我们在部署和上线上足够灵活;另一方面,对于海量的计算任务,如果实在扛不住,我们需要保有最基本的响应能力。基于这样的考虑,我们把 CDN 的回源服务独立成一个服务。这层服务有三个作用:

  1. 通过对存储系统的访问,如果有已经计算好的增量补丁,那么可以直接返回,只把最需要计算的任务传递给计算层。

  2. 如果计算层出现问题,API 层保有响应能力,能够进行服务降级,返回全量文件内容。

  3. 将对外的接口管理起来,避免接口变更对核心服务的影响。在这个基础上可以进行一些简单的聚合服务,提供诸如请求合并之类的服务。

那如果 API 层没能将流量拦截下来,进一步传递到了计算层呢?

前端遇上Go: 静态资源增量更新的新实践-LMLPHP图8 增量服务计算层

为了防止过量的计算请求进入到计算环节,我们还针对性地进行了流量控制。通过压测,我们找到了单机计算量的瓶颈,然后将这个限制配置到了系统中。一旦计算量逼近这个数字,系统就会对超量的计算请求进行降级,不再进行增量计算,直接返回全量文件。

前端遇上Go: 静态资源增量更新的新实践-LMLPHP图9 预热的设计

另一方面,我们也有相应的线下预热机制。我们为业务方提供了一个预热工具,业务方在上线前调用我们的预热工具,就可以在上线前预先得到增量补丁并将其缓存起来。我们的预热集群和线上计算集群是分离的,只共享分布式存储,因此双方在实际应用中互不影响。

如何容灾

有关容灾,我们总结了以往见到的一些常见故障,分了四个门类来处理。

  • 线路故障。我们在每一层服务中都内置了单机缓存,这个缓存的作用一方面是可以泄洪,另一方面,如果线路出现故障,单机缓存也能在一定程度上降低对线路的依赖。

  • 存储故障。对于存储,我们直接采用了两种公司内非常成熟的分布式存储系统,它们互为备份。

  • CDN 故障。做前端的同学或多或少都遇到过 CDN 出故障的时候,我们也不例外。因此我们准备了两个不同的 CDN ,有效隔离了来自 CDN 故障的风险。

最后,在这套服务之外,我们浏览器端的 SDK 也有自己的容灾机制。我们在增量更新系统之外,单独部署了一套 CDN ,这套 CDN 只存储全量文件。一旦增量更新系统无法工作, SDK 就会去这套 CDN 上拉取全量文件,保障前端的可用性。

回顾与总结

服务上线运转一段时间后,我们总结了新实践所带来的效果:

99.97%64.91%164.07 KB1184KB

考虑到每个业务实际的静态文件总量不同,在这份数据里我们刻意包含了总量和人均节省流量两个不同的值。在实际业务当中,业务方自己也会将静态文件根据页面进行拆分(例如通过 webpack 中的 chunk 来分),每次更新实际不会需要全部更新。

由于一些边界情况,增量计算的成功率受到了影响,但随着问题的一一修正,未来增量计算的成功率会越来越高。

现在来回顾一下,在我们的新实践中,都有哪些大家可以真正借鉴的点:

  1. 不同的语言和工具有不同的用武之地,不要试图用锤子去锯木头。该换语言就换,不要想着一个语言或工具解决一切。

  2. 更换语言是一个重要的决定,在决定之前首先需要思考是否应当这么做。

  3. 语言解决更多的是局部问题,架构解决更多的是系统问题。换了语言也不代表就万事大吉了。

  4. 构建一个系统时,首先思考它是如何垮的。想清楚你的系统潜在瓶颈会出现在哪,如何加强它,如何考虑它的备用方案。

对于 Go 语言,我们也是摸着石头过河,希望我们这点经验能够对大家有所帮助。

作者简介

洋河,2013年加入携程UED实习,参与研发了人生中第一个星数超过100的 GitHub 开源项目。2014年加入小米云平台,同时负责网页前端开发、客户端开发及路由器固件开发,积累了丰富的端开发经验。2017年加入美团,现负责金服平台基础组件的开发工作。

如果你想近距离与我们的作者沟通、交流,请来GitChat,点击“免费预订”即可参与读者交流,报名链接

----------  END  ----------

招聘信息

如果大家对我们所做的事情也有兴趣,想要和我们一起共建大前端团队的话,欢迎发送简历至 [email protected]

也许你还想看

 

Picasso:开启大前端的未来

美团外卖iOS多端复用的推动、支撑与思考

美团外卖Android Crash治理之路

前端遇上Go: 静态资源增量更新的新实践-LMLPHP

阅读 3940
 
 
05-07 10:35