Day17 X 初探快取 & HTTP Caching

今天即将进入 Caching & Networking 章节的第一天,快取,是一个非常重要的技术,不论是前端还是後端,甚至是再更底层的系统开发,都ㄧ定会实作快取的机制来提升效能。而在前端的世界里,又依照作用的区域分成了多种快取,今天就要来简单介绍一下快取的观念与前端开发里最常遇到的一种快取 - HTTP Caching

现今的网页架构相较於过往偏向静态的形式已经变得复杂许多,大部分资料都要靠动态抓取,而抓取资料的过程就会产生许多 Request 请求去取得 Response ,不管是 client 端对 API 的 Ajax 操作,或是 server 端对资料库的 query 都是类似的形式,而抓取资料的过程是需要时间的:client call API 後要等待 API response、backend 下 DB query 後也要等待资料库查询结果回传,而当这样的请求ㄧ多,例如 High Concurrency 的状况,是很有可能对服务的性能造成影响的,为了解决这个困境,就需要这个章节的主角隆重登场了,那就是快取 (Caching)。

什麽是快取?

首先让我们思考一下上面的问题,如果一直去发出网路请求或是 DB query 会造成性能影响的话,你想到最直觉的解决方式会是什麽?

那就不要发出网路请求或 DB query啊!?

咦,讲干话吗,怎麽听起来跟「吃饭会花钱怎麽办?那就不要吃饭啊!」一样无理。不过看似无济於事的一个方式,却是快取的核心概念。快取的概念其实就是提供一个额外的储存空间,将可能需要透过请求得到的资料放在里面,当之後要再请求资料时,先别急着发出请求,先问问快取它有没有你要的资料吧,有的话很好,那你资料直接跟快取拿就好,也就省略了真的发出 request 的步骤,取得资料的速度也理所当然会提升,如果快取没有你要的资料,再发出 request 去取得。而通常适合被快取的资料有两项特性:

  • 很常被使用到
  • 资料不常变动

由上图也可以得知使用快取的好处除了回应速度比较快之外,还有减轻网路频宽、减缓 server 流量压力...等等。

Cache Hit vs Cache Miss

一般在谈快取时,Cache Hit 与 Cache Miss 是蛮常出现的名词,所谓 Cache Hit 指的就是当发出请求时在快取就找到想要的资源,Cache Miss 指的就是快取中找不到想要的资源,必须再回去跟 origin server 拿资料。以效能优化的角度来说,会希望能够尽量提高 Cache Hit 的比率,也就是尽量降低 Cache Miss 的比率,不过同时也要注意资料本身是不是过期了,假设今天一个电商网站的某个产品已经改了图片上的价钱,但为了追求效能,尽量让所有请求都可以 Cache Hit,使用者因此拿到旧的图片,图片上的价钱还是旧的,这就会产生许多问题了呢!所以根据需求让现有快取失效并更新快取也是必须考量的问题。

快取的种类

其实快取一开始出现时是在指 OS 方面的机制,透过快取 ,CPU 可以不必一直到 main memory 去拿资料,从而减少性能的耗损,後来这个概念被运用到了 OS 层以外的地方。以 Web 开发领域来说,依照作用的层级,快取又可以被大致分 Client Cache, Server Cache(也被称作 Application Cache) 与 Networking Cache。

在这次铁人赛系列文中,我会专注於介绍 Client Cache 中的 HTTP Caching, Service Worker Caching 与被我归类为 Networking Cache 的 CDN caching,例外也会提到 DNS 的 cache 机制,这些是我认为跟前端开发与效能优化比较相关且ㄧ定要知道的快取类别。

Public 与 Private 的快取

凌驾在所有快取种类之上还可以再分成公有快取与私有快取两大类,公有快取的定义是指快取服务器上存的回覆能给好几个不同的请求者服务,例如请求回传时经过的 proxy server 上的快取,这个快取上的资料可以提供给多个使用者使用。而私有服务器相对只会服务一个请求者,例如今天主要会聚焦的浏览器的快取,只有在使用这个浏览器,也就是这台电脑的使用者可以使用快取的资源。

HTTP Caching

client cache 所指的是服务器与浏览器之间的快取机制,假设今天你是一个电商平台的开发者,而你们的商品大概每过几个月才会更换一次,而看过电商网站就知道,卖东西是需要图片来吸引消费者的,也就是说你的网站被浏览时,得透过 HTTP request 去下载上百张图片,问题在於每次浏览都得重新下载一次所有图片,但刚刚也说了,这些图片可能几个月才会更换,重复下载相同内容是浪费效能的一件事,於是我们可以把图片存在浏览器的快取中,这样除了第一次浏览网站要下载外,之後就可以直接去快取取得。

从浏览器到服务器的这个过程可以经过不只一层的快取,在 Caching & Networking 的章节会尽量完整的介绍每一层的快取机制,今天则会重点介绍跟 HTTP 机制相关的 HTTP Caching,也可以称作 Browser Caching,Let's Start !

不知道各位读者在浏览网页时有没有观察过一个行为:通常第一次浏览页面时画面会花比较久的时间载入,而之後重新整理或重新造访同样页面时载入速度会变快许多,这其实就是拜浏览器的快取所赐,你可以随便开启一个网站,重新整理後打开 Devtool 的 network tab,会看到许多资源会显示 from disk cache,这表示这些资源来自浏览器的快取,并没有再发出 request,因此载入速度会比第一次来的快。

HTTP Cache 是避免浏览器向服务器发送不必要请求的一道防线,要启用 HTTP Cache 需要服务器端与浏览器端事先经过协商,至於协商的方式顾名思义就是透过 HTTP Request 来达成,浏览器与服务器透过在 HTTP Request 与 Response 的 header 带入一些资讯来协商快取的机制,例如服务器告诉浏览器需不需要快取这个回传的资源,或是判断现在快取的资料是不是已经过期需要重新到 server 抓取...等等。

Expires

Expires 是一个旧版本的方式,服务器在 response header 中可以加入 Expires 字段,例如:

Expires: Wed, 21 Oct 2015 07:28:00 GM

当浏览器收到 response 後看到 Expires 字段就会把这个资源快取起来,後续有对相同资源的请求时浏览器会去检查使用者现在的时间是否有超过 Expires 中指定的过期时间,如果没有超过,就会直接回传快取的资料,也就是像上图中会显示资源是 from disk cache,如果时间超过的话就会发起 network request 跟服务器再拿一次。

不过这会产生一个问题,因为是根据使用者电脑的时间来决定,如果使用者的电脑时间被调到了非常未来的时间点,那所有的快取都会被认定为过期,造成 Cache Miss 的状况。

後来出现了新的 header Cache-Control 来解决这个问题,如果 response header 中同时出现 Expires 与 Cache-Control,将会采用 Cache-Control 的设定,较旧版的 Expires 将会被浏览器忽略。

Cache-Control

为了解决上述提到的 Expires 与一些存在於 HTTP 1.0 的快取问题,在 HTTP 1.1 时推出了 Cache-Control 这个 header。

相较於 Expires 给的是一个绝对时间,Cache-Control 通常会搭配 max-age 给一个相对时间,例如:

# 某个 http response header

Cache-Control: max-age=60

这代表使用者在收到这个 response 的 60 秒内,如果再对相同资源发出请求,就会得到快取的版本,如果超过 60 秒後才对这个资源发出请求,则会发出一个新的 network request。

而 Cache-Control 这个 header 并不是只有 max-age 可以使用,根据 MDN 文件,其实还有非常多属性可以搭配,今天就介绍一些较常见的属性就好 。

  • Cache-Control: no-store

    并不是所有的内容我们都希望要被快取,例如说关於使用者资讯的一些比较私密的资料或是经常变动的内容,我们会希望不要保留在 client side,而是每次都到 server 去抓取,这时候可以在 response header 中加入

    Cache-Control: no-store
    ```
    这个字段告诉浏览器「请不要帮我对这个资源做任何快取」。
    
  • Cache-Control: no-cahce

    这个字段等介绍完下面的其他特性後我再回过头来补充。目前各位要记住,虽然它叫做 no-cache,但它代表的并不是告诉浏览器不要做任何快取。

    不要任何快取的是 no-store !
    不要任何快取的是 no-store !
    不要任何快取的是 no-store !

  • Cache-Control: private

    代表此 response 只可以被浏览器储存起来。

  • Cache-Control: public

    代表此 response 可以被任何快取软件储存起来,例如 reverse proxy 的快取、浏览器的快取。

  • Cache-Control: s-maxage

    s-maxage 会覆写 max-age 或者 Expires 标头,不过只对共用快取软件生效(比如 nginx)。私有快取(Browser)会无视这个指令。

快取时效过期後可以做些什麽?

既然设定了快取的时效,那麽接下来就要讨论当快取过期了之後,能够做些什麽。也许你的第一个反应就是再回到服务器拿一次资料就好,但万一档案并没有任何改变呢?例如一个企业的官网总会有 logo,但 logo 也许过了一年也是没有任何更改的,这样去重新抓取似乎有点没有效率且影响效能,接下来就来看看快取过期後,我们能不能透过一些方式,尽量有效运用原有的快取。

Last-Modified & If-Modified-Since

首先先来介绍从 HTTP 1.0 就被提出的比较旧的解决方案:Last-Modified 搭配 If-Modified-Since。

为了怕第一次接触的读者搞混,这边先确认一下它们各自是出现在哪里:

  • Last-Modified: 出现在服务器回应给浏览器的 response header 里,告诉浏览器这个档案上次更改是什麽时候。
  • If-Modified-Since: 出现在浏览器发出请求的 resquest header 里,用来跟服务器确认档案在某个时间点後是不是有经过更改。

而它们的使用流程是这样的,举例来说,当你对公司形象网站的 Logo 第一次发出网路请求时,服务器的 response header 可能会带入以下资讯

Last-Modified: Sat, 11 Sep 2021 07:28:00 GMT
Cache-Control: max-age=31536000 # 一年

浏览器收到 response 後会把 Logo 快取起来,设定过期时限是一年,并记录档案最後修改日期是 Sat, 11 Sep 2021 07:28:00。

在这一年内,每当有对这个 Logo 的请求时,浏览器就会回传快取的版本,而当超过一年,原本快取的时效过期时,浏览器就会带上之前纪录的更改时间,去问服务器从这个时间点以後,这个档案是否有被更改过。

# HTTP request header

GET /ironman-logo.png
If-Modified-Since: Sat, 11 Sep 2021 07:28:00 GMT

假设服务器认定档案被更改过,那就会回传一份新的档案给浏览器,之後的快取流程就会按照前面提过的重新跑一次。但是如果服务器查询了一下发现这个档案从浏览器提供的时间之後就都没有修改过,就会告诉使用者「你可以继续使用快取的版本」,服务器会回传一个 304 的 Status code,304 代表 Not Modified,浏览器收到 304 後就会知道可以继续沿用之前的快取版本。

不过 Last-Modified & If-Modified-Since 的方法会产生一个问题,就是它是根据档案的编辑时间来做判断的,如果今天档案只是被打开便重新存档,内容完全没有更动,因为编辑时间改动,服务器会认为这个档案被改变。更好的做法应该是依据档案的内容有没有更动来决定是否要重新抓取档案,接下来要讲的 Etag & If-None-Match 就解决了这个问题。

Etag & If-None-Match

不知道大家是不是都有听过 JWT token 这个身份验证机制,它的概念就是利用杂凑函数去判断 token 有没有经过人为改变来判断是不是合法的 token。

Etag 也是利用类似的概念,相同内容的档案会产生独一无二的 etag 值,就算是加一个空白或标点符号也会导致 Etag 改变。

使用 Etag 的流程变成 server 在 response header 会带入 etag 让浏览器存起来

Cache-Control: max-age=86400
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"

等到快取时效过後,使用者又请求了相同资源,浏览器就会在 request header 中带入:

If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

服务器会去检验浏览器带过来的 Etag 与最新的档案是否相符,如果相同,代表档案内容没有变,浏览器可以继续使用相同的快取,如果不符合,代表浏览器快取中已是旧的版本,因此需要重新抓取一次。

避免 Mid-Air Collisions

有时候在页面的编辑模式时,例如修改表单资讯(这边指的不是可以同时共编的系统),会想要避免同时有多人编辑的状况,避免资料的不一致性。这时候 Etag 也可以帮上忙。

当从 view mode 进到 edit mode 时,可以先纪录当前页面的 Etag 值

ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"

等到修改完成时再去检查 Etag 是否一样,这边要透过 If-Match 这个 header

If-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

如果服务器发现 hash 值不一致,代表当你在编辑的时候,有人同时也编辑了内容,这时候服务器可以回传 412 的 status code,412 代表 Precondition Failed,告诉使用者需要重新再编辑一次。

最後自己画了一张简单的图表统整一下今天提到的 HTTP Caching 的运作流程

要注意如果快取如果没过期的话,直接从快取拿是不会发出网路请求的,如果是使用 Etag 或是 If-Modified-Since 的机制的话,实际上还是会发出网路请求到 server,只是网路封包大小通常会比真的跟服务器抓取资料还要小许多,不过因为还是发出了网路请求,效能一定还是比直接使用快取还要差,因此选择适合的 cache policy 是非常重要的喔!

Cache Busting

看完上面的内容,聪明的你可能会想到一些问题:「如果今天档案被更改了,例如网站的 background-color 原本是白色,後来修正成黑色,但因为浏览器的快取还没有过期,会继续载入旧的 CSS file,使用者有可能一直从快取拿到旧的版本,也就是说使用者看到的网站背景颜色一直会是白色的,直到快取到期才会更新。

这样不太对啊...也就是说应该要有一种机制可以在档案更新时让浏览器知道这件事,而不再使用旧的快取。Cache Busting 就是一种为档案建立一个独立识别档名,只要档名变了,浏览器就认为这是一个新的档案,需要重新跟服务器抓取,其实这就有点像把前面介绍的 Etag 机制实作在 HTML file 里面而已,例如这样子:

<!DOCTYPE html>
    <html>
    <head>
      <link rel='stylesheet' href='./css/public/style-dasd2134das.css'></link>
      <script src='./public/js/ajskdj1213.js'></script>
    </head>
    <body>
     <div id="container">
     <!-- 交给 SPA 去 render -->
     </div>
    </body>
</html>

不过如果每次都要自己改 JS 或是 CSS 的档名,同时也要修改 HTML 里面引入的档名也太麻烦了吧?

不要担心,像 webpack 这些 bundler 都可以做到在打包时加一串 hash 字串到档名里,并同时自动更新 HTML 里面引入的档名,以上面修改 CSS background color 的例子来说,修正後重新打包的 HTML 可能会变成这个样子

<!DOCTYPE html>
    <html>
    <head>
      <link rel='stylesheet' href='./css/public/style-eawe124as.css'></link>
      <script src='./public/js/ajskdj1213.js'></script>
    </head>
    <body>
     <div id="container">
     <!-- 交给 SPA 去 render -->
     </div>
    </body>
</html>

应该会发现只有 CSS 档名中的 hash 有改变。
再将 HTML 档案设成 Cache-Control: no-cache,每一次请求时都会去重新检视一次 HTML 是否改变,上面的例子因为引入的 CSS 档名变了,浏览器会认定需要重新抓取一次档案,就不会使用过期的快取。这样的机制其实简单来说就是让每一个版本的档案都有一个独一无二的档名,当档案内容改变,就更新档名,强制浏览器重新抓取一次。

Memory Cache vs Disk Cache

如果你是 Chrome 浏览器的爱好者,可能曾经发现除了 from disk cache 以外,偶尔还会出现一些资源是显示 from memory cache,memory cache 又是什麽呢?

memory cahce 并不是所有浏览器都会实作的一种快取,这边可以看作是 Chrome 特别实作的一种快取(其他浏览器我就不清楚了),顾名思义它把资源存在 memory (RAM) 里面,所以在效能上会比 disk cache 还要快,但缺点是存在里面的资料具有挥发性,当关闭浏览器时,原本存在 memory 的资料就会被清空。

至於浏览器怎麽决定哪些资源要放到 memory cache 里,目前没有明确的定义,只知道透过 resource hint 例如 preload, prefetch 载入的资源比较有机会被放到 memory cache 中。你可能会觉得,既然速度那麽快,就把全部资源都先存到 memory cache 就好啦?这是不可能的,记忆体的容量相比硬碟小非常多,如何有效运用 memory 的空间是很难的问题,所幸这块身为开发者的我们不必担心,浏览器都帮我们做好了。

需要特别注意的是,memory cache 在快取资源时不会管 HTTP header Cache-Control 的设定,同时在识别时也不像前面介绍的 HTTP Caching 是用 URL 或档名来判断,而会另外判断 Content-Type, CORS 等其他特徵。

最後提一下 memory cache 相比其他 cache 的优先顺序,在明天我们将介绍 service worker 的 cache,这边读者可以先记一下三种快取的优先顺序(指会先到哪种 cache 找有没有资料)

memory cache -> service worker cache -> disk cache (browser cache)

本日小结

Caching & Networking 章节,总共会介绍 4 种快取,今天介绍的 HTTP Caching (又被称作 browser caching),是四种快取中最基本且最被广泛使用的一种。网路上有非常多的文章在讨论使用快取前後对网站效能的影响,如果还不明白快取的强大,建议可以去参考一下。

快取对我来说迷人的地方就是它仅仅是一个概念,但是从应用层到系统底层都可以看到它的踪迹,并且在不同地方会有独特的实作方式,这次系列文也仅仅会提到 client side 的 cache,也就只是快取机制的冰山一角,听到这里,你是不是也对於後续几天会学到的快取机制感到兴奋了呢?

Reference & 图片来源

https://www.keycdn.com/support/web-cache

https://wp-rocket.me/blog/cache-miss-vs-cache-hit/#section-2

https://blog.techbridge.cc/2017/06/17/cache-introduction/

https://developer.mozilla.org/zh-TW/docs/Web/HTTP/Caching


<<:  虹语岚访仲夏夜-18(打杂的Allen篇)

>>:  企划实现(17)

後记

大家好,这是Kate的第二次参赛。 这次的心得也是:万岁,终於平安写完三十天了。 铁人赛这种东西是不...

Day 03:不用三分钟,建立第一个 Angular 专案范本

接下来,我们就要来建立第一个专案范本。 首先,我们可以在桌面上先建一个资料夹,命名叫 Angular...

有限状态过程 Finite State Process

有限状态过程 (FSP, Finite State Process) 是有限状态机的一种表达方式,本...

Day 22 | Livewire 实作 购物网站(一): 建立商品列表

今天来做第二个实作:购物网站。这也是很容易遇到的专案类型,照原本的做法做一个购物网站都要花费大量的时...

Day32 - Windows 提权(3)-Windows Exploit Suggester

Windows Exploit Suggester Windows Exploit Suggeste...