Day29 X 面对高流量,前端可以做些什麽?

在现今的 Web 应用中,要建构一个稳定的大型系统,能够处理 High Concurrency 的流量是一个不可或缺的条件,尤其是在服务的热门时段,例如优惠活动、抢票...等等,都在考验系统的效能与稳定性。

提到应付高流量,会马上想到的通常都是一些 Server Side 的技术,例如 Load Balancing、Server Side Caching、Horizontal Scaling...等等。这时身为前端开发者的你也许会感到ㄧ丝沮丧,感觉自己在面对大流量下一点忙也帮不上,有种突然被推上工程师鄙视链顶端的感觉!?(误?)

今天的文章相比於前面的章节也许会稍微短了一些,不过我认为是个蛮有趣的主题,来看看面对高流量的使用者请求,身为前端开发者,我们可以做些什麽吧!

作为後端的节流装置

不能否认大部分的高流量应对策略都必须依靠後端的实作,例如开多台机器来作分流,资料库采用读写分离架构来分散压力。身为前端开发者,我们许多的优化技巧主要聚焦於如何在从服务器端拿到网页相关资源後尽量降低页面的渲染时间、尽量达到较好的效能指标(例如 TTI, FCP 这些指标)并提供良好的使用者体验,又或者像是页面流畅度这种跟个别使用者比较有关的体验。许多跟前端相关的优化策略,像前几天介绍的 HTTP2 也得靠後端的支援才能实作。不过我们可以先换个角度思考,造成後端服务器必须处理高流量的原因是什麽?我想其中一个原因可以归咎於:client side 在短时间内同时发了大量请求到 server side。

正常情况下,使用者与前後端沟通的状况应该为以下这样:

流量都还在 server 可以承受的范围内。但假设今天是双11,一个电商平台的流量状况可能会变成这样:

面对高流量,可能会对 server 造成不小的压力,既然使用者很多,图片中 Users 到 Web App 这段的流量数基本上没办法改变(甚至是一个好的象徵,代表你的产品有人要用啊),那我们可以更聚焦在 Web App 到 Server 这段,有没有可能减少 Web App 对 Server 的请求数量呢?像是以下这个状况:

这个概念有点像是把 Web App 这层看成水龙头,在水龙头外装一个省水的节流装置,限制前端 Web App 向後端 server 发出的请求数量,使 server 即使在面对大量的使用者的状况下,实际上不用处理那麽多的请求,以防止无法负荷的状况。於是身为前端开发者,我们就成为了後端的节流器,负责尽量减少真实传送到後端的网路请求。

How To Do That ?

这似乎是一个不错的策略,身为前端开发者,面对大流量我们果然还是能帮到後端的忙嘛!不过看起来好像是一门深奥的技术,很难做到的感觉。你想太多罗,其实需要的技巧都在系列文介绍过罗!在面对 high concurrency 的状况下,我认为前端可以透过以下几种技巧来帮助後端节流:

  • 静态资源的合并与压缩
  • 尽量减少 HTTP 请求数量
  • 各种 Cache 机制
  • CDN
  • 避免高频率的发出网路请求

前端快取机制(包含 HTTP cache, service worker cache, cookie, localstorage, sessionstorage)与 CDN 都在前面的篇章有详细提过了,因此就不再赘述。

以 Medium 为例,经过快取机制後,再次造访页面时许多资源都可以直接到浏览器的快取或是 CDN 拿取,而不用再回到 Origin Server 抓取,可以为服务器减少一些流量负担。

如果是采用 Server Side Rendering 的架构的话,通常会有一个 Node.js 的 rendering server 负责在 server side 产生 render 後的结果,并送到前端进行 hydration。

因为每一次 SSR 请求都需要经过这个 rendering server,当流量一大也会对这个 server 造成不小的负担,所以可以在这个 rendering server 中建立一个 memory 的 cache(要用 redis 也可以,不过设置上比较麻烦一点)来快取 SSR 的执行结果,就不用每一次有 SSR 请求都在 server side 重新渲染。而在这个需求下 LRU Cache 就是一个蛮适合的快取机制,因为 rendering server 通常是 Node.js 的环境,因此可以使用 node-lru-cache 这个 npm 套件,至於 LRU 这个演算法的实作细节与概念我就不在这赘述,有兴趣的读者再自行研究罗。

以上就是基本的 SSR result Caching,也建议可以到 Next.js repo 上的 example 看看。

至於「静态资源的合并与压缩」还有「尽量减少 HTTP 请求数量」这两点看似抽象陌生,其实系列文中的某些优化技巧运用的便是这些原则,重点在於降低请求的「大小」与「数量」。例如 Day07 介绍的 CSS Sprites 技术就是运用减少请求数量的概念,Day16 介绍的档案压缩则是降低请求的大小的概念。

Debounce & Throttle

而关於「避免高频率的发出网路请求」,应该许多开发者都有听过 throttle 与 debounce 的概念,因为前面的篇章没有提过,所以今天主要会聚焦在这两个概念上。

Debounce

Debounce 确保耗时的任务不会被频繁触发,从而导致网页性能停滞。
Debounce 是让使用者在触发相同事件(以前端来说像是点击、输入)的情境下,停止触发绑定事件的效果,直到使用者停止触发相同事件。换句话说,它限制了函数被执行的速率。

听起来有点抽象,举一个最常见的例子。假设有一个输入匡,会根据使用者输入的内容来发出网路请求透过 API 来抓取相关的资料,实作可能会是这样

监听 input element 的 change 事件并发出 AJAX request。
不过这样有一个问题,就是每一次 iuput 每打一个字都会发一次请求,然而通常在输入完整的句子或是有意义的单字出现前,这些不完整的字串发出请求後拿到的结果通常对我们来说都没什麽意义,例如今天我想要查我最爱的 NBA 球队「洛杉矶湖人」

洛 -> 发出请求
洛杉 -> 发出请求
洛杉矶 -> 发出请求
洛杉矶湖 -> 发出请求
洛杉矶湖人 -> 发出请求

就发出了五次网路请求,但可能至少到 「洛杉矶湖」的时候才会有我想要的资讯(毕竟洛杉矶很大,你光打洛杉矶可能会跳出一堆跟 NBA 无关的东西),这时候就可以运用 debounce 的机制,设定一个时间区段,当使用者在输入後过了该段时间仍然没有下一次输入事件时,才会呼叫对应的 callback function

例如设定 debounce time 是 500ms

洛
洛杉 (不到 500 ms 就出现下一次 input 了,所以不会触发事件)
洛杉矶(不到 500 ms 就出现下一次 input 了,所以不会触发事件)
洛杉矶湖 (不到 500 ms 就出现下一次 input 了,所以不会触发事件)
洛杉矶湖人(过了 500 ms 仍然没有下个字出现,就发出请求吧!)


(注:并不是所有输入匡都要实作 debounce,还是要根据自己的需求决定,有兴趣的人可以到 google 看看,每一次 input 都是会发出 network request 的)

所以说 Debounce 得概念如果用动画来看的话会是这个样子

是不是比较好理解了呢?

Throttle

Throttle 也就是节流的意思,它是另一种减缓事件触发的方法,概念为使用者触发相同事件时提供间隔,控制特定时间内事件触发的次数。Throttle 意味着确保某个函数在指定时间段内最多被调用一次(例如,每 10 秒一次),并确保功能以固定速率定期运行。

同样看起来十分抽象,所以也试着举一个生活化的例子来解释吧!
大家应该都玩过格斗类的电玩游戏吧,例如说我这个年纪的人童年的回忆--小朋友齐打交 LF2

通常是按一个按键就可以让操纵的人物进行攻击,而每一个人物的攻击速度会依照能力值而有所不同,例如冰人可能每隔三秒才能再吹出下一个龙卷风

但我们都知道在玩游戏的时候大家的心情都是激动的,在激战的时候往往会狂按键盘,希望角色的攻击速度可以快一点 ?,这时候为了让角色依照原本的设定,每隔几秒才能用一次绝招,必须对使用者的 input 事件做 throttle,避免使用者可以一直不停的丢绝招。

Throttle 在前端的应用可能像是你想在使用者卷动画面时做些对应的操作,但是有时候使用者可能会一直连续的滚动画面,例如在 Infinite Scroll 的场景,这会导致 callback 被呼叫太频繁,有可能导致效能的瓶颈,这时就可以使用 Throttle 技巧限制一段时间内该操作只能被呼叫几次。

如何实作 Debounce & Throttle

其实像 Lodash 这种函式库就收录了 debounce 与 throttle 的方法可以直接拿来用,或是专门用来处理分同步串流的 RxJS 也有提供 debounce 与 throttle 的 function。

而如果要用 JavaScript 自己实作这两个 function 也是可以的

// Debounce

function customDebounce(func, delay) {
  let timer = null;
 
  return () => {
    const context = this;
    const args = arguments;
 
    clearTimeout(timer);
    timer = setTimeout(() => {
      func.apply(context, args);
    }, delay);
  }
}

function handleScroll() {
  console.log('Do Something...');
}

window.addEventListener('scroll', customDebounce(handleScroll, 300));
// Throttle

const customThrottle = (func, limit) => {
  let lastFunc;
  let lastRan;
  return function() {
    const context = this;
    const args = arguments;
    if (!lastRan) {
      func.apply(context, args)
      lastRan = Date.now();
    } else {
      clearTimeout(lastFunc);
      lastFunc = setTimeout(function() {
          if ((Date.now() - lastRan) >= limit) {
            func.apply(context, args);
            lastRan = Date.now();
          }
       }, limit - (Date.now() - lastRan));
    }
  }
}
function handleScroll() {
  console.log('So Something')
}

window.addEventListener('scroll', customThrottle(handleScroll, 500));  // 每 500ms 才能再执行

Debounce 的实作还蛮单纯的,在函数域加入一个计时器,如果事件一直触发,便刷新计时器,直至计时器时限内没有触发该事件,便执行该事件。

Throttle 就稍微复杂了一些,实现方法是在函数域加入一个计时器并记录最新一次执行的时间点,并在後续又有事件发生时把现在的时间点与记录的时间点作比较,如果两者差距超过设定时限,便允许再次执行该事件的 handler,并设立新的执行时间点。

懂的善用 Debounce 与 Throttle 之後就可以减少一些不必要的操作或是请求罗,不仅能提升前端应用的效能,也发挥了节流器的功能,为接收请求的 server 减缓了一些负担。

本日小结

如果认真要说前端在面对高流量时能够帮上什麽忙,我认为其实能做的有限,像是刚刚提到的 Cache 机制,也只能针对 GET 请求,如果是写入资料或是更新资料的操作就没办法被快取了,更不用说读取资料相对於写入或修改资料已经更加简单迅速了。所以说我认为一个系统要能够处理大流量,本质是还是得从服务器端或系统面来着手。但这不代表前端帮不上任何忙,我们还是可以尽量为服务器减轻一些负担,也就是当服务器的节流器,尽量减少对服务器发出请求与降低请求的大小,累积起来也能够为服务器减轻不少负担。

让我觉得更有趣的一点是今天讲的节流方式除了 Debounce 与 Throttle 之外其实都在系列文前面的篇章都介绍过了,所以今天有点算是个小整理,看看之前学的优化技巧可以达到什麽效果,不知道读者这 29 天是不是都有吸收进去呢?

扣掉第一天的大纲与明天最後一天的完赛心得,基本上这个系列文跟技术相关的篇章就到这里结束了,真心希望可以让读者有所收获!感性的话留着明天说好了,虽然明天不会讲到什麽技术层面的内容,但还是想有始有终的总结一下这 30 天的旅程,希望一直支持到现在的朋友明天也可以造访一下,我们明天见罗~终於要完赛啦!

Reference & 图片来源

https://mropengate.blogspot.com/2017/12/dom-debounce-throttle.html

https://towardsdev.com/debouncing-and-throttling-in-javascript-8862efe2b563

https://codesandbox.io/s/1r029moq9l

https://www.pcmarket.com.hk/little-fighter-2-10000-players-nft-project/


<<:  我该问甚麽篇之找工作小Tips

>>:  [ 卡卡 DAY 29 ] - React Native iOS 打包步骤及离线 jsbundle产生

DAY10 - [CSS+RWD] 合体吧!网页与小结

今日文章目录 将这十天的零件,组成一页式网页 10天CSS小结 我这次写铁人赛的目标,是想将目前学...

[Day30] 今天是最後一天啦~

今天是我最後一次以git的主题跟各位沟通啦~ 说实话,觉得要坚持30天,天天发文真的有点难馁XD ...

Day21 AJAX 请求方法?

大家好我是乌木白,今天要衔接昨天的内容,我们今天继续讨论AJAX! 什麽是 HTTP 请求方法? ...

Swift 新手-使用者介面(UX/UI/Core)

什麽是使用者介面? 使用者介面是介於使用者与硬体而设计彼此之间互动沟通相关软件,目的在使得使用者能够...

【第十八天 - 命令执行】

Q1. 什麽是命令执行 指令是与电脑互动的一种方式,一般来说作业系统会包含至少一个 Shell 程序...