整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

前端工程师必备之缓存问题

存是个老生长谈的问题,对于前端工程师来讲更是我们的必修课。或许很多人会说我的项目并没有问题,根本不需要聊什么缓存。如果真的是这样,只能证明你前端道路才刚刚开始。

背景

小郭今天分享缓存的原因在于:公司的一个核心APP中嵌入了SPA,而且应用核心都分布在SPA中,功能复杂且重。问题出现了:应用核心页面打开一直处于加载状态,排除掉弱网环境的原因,重点就在于没有缓存,每次进入页面都需要重载DOM和数据,拖慢页面打开速度。

那应该处理缓存问题呢?接下来小郭从三个方向来讲解。

浏览器缓存策略

在了解浏览器缓存前,我们需要先了解一下相关的概念:cache-control,expires,last-Modified,ETag。

浏览器通过请求头实现缓存,关键的请求头有cache-control,expires,last-Modified,ETag等。我们从时间和空间两个角度来看浏览器缓存。

时间

浏览器发送第一次请求:不缓存,服务端根据设定的缓存策略返回相应的header,如:cache-control,expires,last-Modified,ETag。

浏览器发送第二次请求:

  • 强缓存策略:不需要和服务端通信就决定是否使用缓存,cache-control优先级大于expires① 有cache-control且不过期,返回本地磁盘缓存,状态值200;② 有expires且不过期,返回本地磁盘缓存,状态值200。
  • 协商缓存策略:需要和服务端通信决定是否用缓存,Etag优先级大于last-Modified。① 有Etag,请求头添加If-None-Match,值就是上次返回的Etag值,然后发送给服务端。服务端对比If-None-Match与现有的Etag值是否一样;一样的话只返回header,状态码304,浏览器从本地磁盘获取缓存信息;不一样走正常流程,返回header+body,状态码200;② 有last-Modified,添加请求头If-Modified-Since,值是上次返回的last-Modified,然后发送给服务端。服务端对比If-Modified-Since与现有的是否一样;一样的话返回只返回header,状态码304,浏览器从本地磁盘获取缓存信息;不一样走正常流程,返回header+body,状态码200
  • 无缓存

空间

  • 浏览器和服务端:服务端需要决定使用哪种缓存策略并在响应头返回;前端不需要设置,是浏览器本身机制。
  • html和静态资源:通常html不设置缓存,因为其它资源的入口都是html文件;静态资源(js,css,图片等)会设置缓存

部署时缓存的问题

如果缓存就按理论上设置,那就太简单了。在实际应用有个严重的问题,我们不仅要缓存代码,还需要更新代码。如果静态资源名字不变,怎么让浏览器即能缓存又能在有新代码时更新。最简单的解决方式就是静态资源路径添加一个版本值,版本不变就走缓存策略,版本变了就加载新资源。如下:

<script src="xx/xx.js?v=24334452"></script>

然而这种处理方式在部署时有问题。

解决方法:静态资源和页面是分开部署

  • 先部署页面再部署静态资源,会出现用户访问到旧的资源
  • 先部署静态资源再部署页面,会出现没有缓存用户加载到新资源而报错

这些问题的本质是以上的部署方式是“覆盖式发布”,解决方式是“非覆盖式发布”。即用静态资源的文件摘要信息给文件命名,这样每次更新资源不会覆盖原来的资源,先将资源发布上去。这时候存在两种资源,用户用旧页面访问旧资源,然后再更新页面,用户变成新页面访问新资源,就能做到无缝切换。简单来说就是给静态文件名加hash值

那如何实现呢?

现在前端代码都用webpack之类的构建工具打包,那么结合webpack该怎么做,怎么才能做到持久化缓存?

webpack持久化缓存

一、webpack给文件名添加hash值是很简单的,但hash/chunkhash/contenthash要用哪个呢?

官方定义

hash: unique hash generated for every build

chunkhash: hashes based on each chunks' content

contenthash: hashes generated for extracted content

根据分析,contenthash才是我们需要的,内容有更新,hash值才会更新。

二、webpack会打包业务代码、第三方库及运行时代码,为保证缓存互不干扰,应该将它们提取出来。

第三方库提取方式是设置optimization的splitChunks的cacheGroups。splitChunks能提取模块,cacheGroups能缓存模块,并且cacheGroups的配置会覆盖splitChunks相同配置,既能提取又能缓存,故只需设置cacheGroups。

运行时代码的提取方式为配置runtimeChunk,默认为false,表示运行时代码嵌入到不同的chunk文件中;现在将运行时代码提取出来,并命名为manifest。

module.exports = {
  entry: {
    index: "./src/index.js",
    bar: "./src/bar.js"
  },
  output: {
    filename: "[name].[contenthash].js"
  },
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendor: {
          test:/[\\/]node_modules[\\/]/,
          name: "vendors",
          chunks: "all"
        }
      }
    },
    runtimeChunk: {
      name: "manifest"
    }
  }
};

三、 moduleName 和 chunkName 对文件的影响

module:就是js模块

chunk:webpack编译过程中由多个module组成的文件

bundle:bundle是chunk文件的最终状态,是webpack编译后的结果

一个文件被分离为3个文件,文件间怎么相互依赖的,会影响彼此打包,解决方法是将moduleId和chunkId改成按照文件路径生成。

optimization: {
  moduleIds: 'hashed',
  namedModules: true,
  namedChunks: true
}

这样子moduleId在编译后的文件是文件目录的hash值,更加安全。这也是namedChunks在production默认为false的原因,不想依赖的文件路径在编译后的文件直接展示,但是为了持久性缓存,这里也只能打开。

四、CSS文件缓存

当css代码提取成单独文件,当我们改变css时,怎么保证不影响引用它的js文件呢?配置如下:

plugins: [
  new MiniCssExtractPlugin({
    filename: "[contenthash].css"
  })
]

webpack持久化缓存目标是当且仅当该文件内容变动才改变该文件名字的hash值

const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = { 
  output: { 
    filename: [name].[contenthash].js, // 让hash值只在内容变动时更新 
    chunkFilename: [name].[contenthash].js // 动态引入的模块命名,同上 
  }, 
  module: { 
    rules: [ { 
      test: /\.css$/, 
      use: [ 
        "loader: MiniCssExtractPlugin.loader", // 提取出来css "css-loader" 
      ] 
    } ] 
  }, 
  optimization: { 
    moduleIds: "hashed", // 混淆文件路径名 
    runtimeChunk: { name: 'manifest' }, // 提取runtime代码命名为manifest 
    namedModules: true, // 让模块id根据路径设置,避免每增加新模块,所有id都改变,造成缓存失效的情况 
    namedChunks: true, // 避免增加entrypoint,其他文件都缓存失效 
    cacheGroups: { 
      vendor: { // 提取第三方库文件 
        test: /[\\/]node_modules[\\/]/, 
        name: 'vendors', chunks: 'all', 
      }, 
    },
  } 
  plugins: [ 
    new webpack.HashedModuleIdsPlugin(), // 与namedModules: true作用一样 
    new MiniCssExtractPlugin({ 
      filename: "[contenthash].css", // css文件也是按contenthash命名 
      chunkFilename: "[contenthash].css", // 动态引入的css命名,同上 
    }) 
  ], 
}

总结

浏览器有其缓存机制,想要既能缓存又能在部署时没有问题,需要给静态文件名添加hash值。在webpack中,有些配置能让我们实现持久化缓存。感兴趣的同学可以自行去测试哦!

有任何问题可以在下方留言,想了解更多前端知识欢迎关注公众号“一郭鲜”,文章也将同步于公众号,前端学习不迷路

存概述

在很久很久以前人类和洪水作斗争的过程中,水库发挥了至关重要的作用 : 在发洪水时可以蓄水,缓解洪水对下游的冲击;在干旱时可以把库存的水释放出来以供人们使用。这里的水库就起着缓存的作用。在如今互联网的世界里随着互联网的普及,内容信息越来越复杂,用户数和访问量越来越大,我们的应用需要支撑更多的并发量,同时我们的应用服务器和数据库服务器所做的计算也越来越多。

但是往往我们的应用服务器资源是有限的,且服务器技术变革是缓慢的,数据库每秒能接受的请求次数也是有限的,那么如何能够有效利用有限的资源来提供尽可能大的吞吐量呢?一个有效的办法就是引入缓存,打破标准流程,每个环节中请求可以从缓存中直接获取目标数据并返回,从而减少计算量,有效提升响应速度,让有限的资源服务更多的用户。

缓存的定义

缓存就是数据交换的缓冲区(称作Cache),这个概念最初是来自于内存和 CPU。当某一硬件要读取数据时,会首先从缓存中查找需要的数据,如果找到了则直接使用执行,缓存找不到的话则从内存中找。由于缓存的运行速度比内存快得多,故缓存的作用就是帮助硬件更快地运行。

缓存的分类

当用户从键入一个地址到页面的展示过程中通常包含了很多种缓存。有前端缓存、本地缓存(协商缓存,强缓存等)到我们的网关缓存(CDN 缓存)、最后到我们服务端缓存。服务端缓存又区分为进程缓存(本地缓存),还有比较火的分布式缓存,最后到了数据库层面的缓存。如下图所示:

缓存是一把双刃剑

在我们通常的软件设计中,有一些热点数据需要展示到页面,我们通常当这些数据缓存到内存或者其他读写速度优异的框架中。减少与数据库进行 I/O 操作。提升数据的响应速度。这一切看起来就是这么完美。

实际上,在缓存系统的设计架构中,还有很多坑。如果设计不当会导致很多严重的后果。设计不当,轻则请求变慢、性能降低,重则会数据不一致、系统可用性降低,甚至会导致缓存雪崩,整个系统无法对外提供服务。

接下来我们着重讲述一下在缓存设计过程中几大经典的问题。

缓存失效

先解释一下什么叫做缓存失效

我们在存放缓存的时候,可以指定缓存 Key 的失效时间,当失效时间到了,此缓存就会失效,由于在缓存中找不到该数据,所以这个时候如果用户有请求该数据就绕过缓存直接到数据库中请求数据。

看到这里小伙伴们肯定有很多问号?

这不是很正常的现象嘛?为什么要把这个问题拿出来说呢?莫急看下图图示

这里我们通过两个场景来说明一下

  • 场景一:这种情况下一般不会对数据库造成比较严重的影响,因为失效的 key 的数量比较少,即使同时请求到数据库层面也是可以接受的。
  • 场景二:在这种场景中,当缓存里面的大量 Key 同时失效,这个时候如果有请求过来,会穿过失效的 Key全部落到数据库层面。导致数据库的负荷瞬间添加。可能会出现数据库宕机等特大事故。

解决方案

看到这里很多聪明的小伙伴其实已经想到了。场景 2 的事故主要因为很多 key 一起失效的原因,跟我们日常写缓存的过期时间息息相关。如果我们在日常的开发过程中需要将一批 Key 设置到缓存中并制定失效时间。这个时候就要注意场景 2 发生的情况。我们可以在失效时间 + 随机时间。避免大量 Key 失效冲击我们的数据库。

缓存击穿

通常情况下,我们去查询数据都是存在的。那么如果请求去查询一条压根儿数据库中根本就不存在的数据,也就是缓存和数据库都查询不到的这条数据会怎么样呢?这样会导致每次访问都会直接打到数据库上面去。这种查询不存在数据的现象我们称为缓存穿透。

下面是缓存失效的场景

很多伙伴看到这里肯定又会觉得这是一件很正常的事情。试想一下,如果有黑客会对你的系统进行攻击,拿一个不存在的 key 不停的去查询数据,会产生大量的请求到数据库去查询。可能会导致你的数据库由于压力过大而宕掉。

解决方案一

  • 首先我们能想到的就是在网关参数进行过滤。校验请求的 key 是否是我们系统 key 的格式等

当然这网关层所能做到的只是一些简单过滤。每个后端的设计人员应该对服务的可用性和健壮性负责。接下来我们看看服务端应该如何处理

  • 服务端可以将不存在的 key 暂时保存到我们的缓存中,再次接收到同样的请求后如果直接命中缓存并且值为空那么就会直接返回,不会穿透到数据库层面,这样就避免了缓存击穿。

但是黑客/恶意攻击者是不会这么轻易被打发的。每次请求都会传不同的 key 来攻击我们的服务。这个时候这个方案起不到作用了。

解决方案二

构建一个 BloomFilter(布隆过滤器) 缓存过滤器,记录全量数据。这样访问数据时,可以直接通过 BloomFilter 判断这个 key 是否存在,如果不存在直接返回即可,根本无需查缓存和 DB。这样在缓存之前加了一层校验。如果key 值不存在,就不会请求到我们的缓存更加不会到我们的数据库中。

布隆过滤器可以理解为一个不怎么精确的 set结构,当你使用它的 contains 方法判断某个对象是否存在时,它可能会误判。但是布隆过滤器也不是特别不精确,只要参数设置的合理,它的精确度可以控制的相对足够精确,只会有小小的误判概率。当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在。即使误判不存在走到缓存和后端服务也是可以接受的。

缓存雪崩

缓存雪崩是指缓存的部分节点不可用导致整个缓存体系甚至整个服务系统不可用

那么你可能会有疑问,缓存雪崩和缓存击穿有什么关系呢?

从概念上来看,缓存击穿是因为查询不存在的 key 穿透缓存直接访问我们的数据库。而缓存雪崩是因为我们的缓存节点不可用,请求未经过缓存就直到了我们的数据库层面。然而两者都会影响我们的服务稳定性。

缓存节点的不可用会导致缓存雪崩,那么我们缓存组件集群部署是不是就解决了这个问题呢?

集群部署有两种情况:

  • 一种就是简单的主从例如 redis 的哨兵之殇
  • 采取一致性 hash 算法集群部署例如 redis 的分片集群

第一种情况:发送雪崩的时候一般是多个节点同时不可用,例如我们的节点服务器内容不足,虽然分主从节点都是存储的数据都是一样的。如果缓存中的数据过大导致节点不可用。那大部分节点也会存在这个问题。请求会大面积的落到数据库层面导致后端系统崩溃。

第二种情况: 首先看一下下图虽然数据根据会根据取模算法分配到不同的节点中,假设节点 A 不可用,数据 A 会按照逆时针找到节点 B,会因为本来应该存放到节点 A 的数据存放到节点 B,以此类推会导致整个缓存节点不可用。请求也会大面积落到我们后端的数据库层面导致系统崩溃。

解决方案

  • 对缓存体系进行实时监控,当请求访问的慢速比超过阀值时,及时报警,通过机器替换、服务替换进行及时恢复。
  • 对缓存增加多个副本,缓存异常或请求 miss 后,再读取其他缓存副本。
  • ehcache 本地缓存 + Hystrix 限流&降级,避免 MySQL被打死
  • 业务 DB 的访问增加读写开关,当发现 DB 请求变慢、阻塞,慢请求超过阀值时,就会关闭读开关,部分或所有读 DB 的请求进行 failfast 立即返回,待 DB 恢复后再打开读开关。

数据不一致

数据不一致的概念很简单:就是缓存中的数据和数据库中的数据不一致

那为什么会不一致呢?我们的数据被缓存之后,一旦数据被修改(修改时也是删除缓存中的数据)或删除,我们就需要同时操作缓存和数据库。这时就会存在一个数据不一致的问题。

如上图所示当我们先删除数据库再去操作缓存,缓存中未删除数据库其实已经不存在该数据了。这个时候就会出现缓存不一致的情况。

聪明的小伙伴肯定想到了我们还是需要先做缓存删除操作,再去完成数据库操作。则会去数据库中查询,如果缓存中没有该数据,则会去数据库中查询,之后再放入到缓存中。这样就完美了嘛?答案肯定不会这么简单。请看下图:

解决方案

这里其实没有什么很完美的解决方法。可以将变更的 key 添加到安全队列中。当另一个查询请求 B 进来时,如果发现缓存中没有该值,则会先去队列中查看该数据是否正在被更新或删除,如果队列中有该数据,则阻塞等待,直到 A 操作数据库成功之后,唤醒该阻塞线程,再去数据库中查询该数据。这里其实也是有很多缺陷的。线程需要阻塞等待。

最好的解决方案就是如果数据更新比较频繁且对数据有一定的一致性要求,我通常不建议使用缓存。看到这里是不是发出了一句切!!!!

总结


缓存虽然能大幅度的提高服务器的性能以及用户的体验感。但是随着而来的就是各种由于缓存导致的一系列问题。所以当我们使用缓存的过程中需要注意以上的经典问题。

迎关注我的头条号:Wooola,专注于Java、Golang、微服务架构,致力于每天分享原创文章、快乐编码和开源技术。

前言

最近发版前端项目,用户经常反馈新添加功能在线上环境不好用,常出现新老页面并存的状况。经前端同事排查法发现,实际上只需要重新刷新一下页面就没事了。但是每次去通知用户肯定不现实,所以需要对前端的js和css等文件采取一定的缓存失效的措施,强制浏览器重新去服务器获取新的js代码以及css文件。

楼主经过实际的项目情况反馈,总结以下两点切实可行的办法,分享给大家,希望对大家有帮助。

  1. 路径后面加时间戳或者随机数的方式
  2. 采用hash(md5)重命名文件

路径后面加时间戳或者随机数的方式

时间版本号

如果每次发布,针对修改过的js或者css文件路径加上时间的版本号,一般以年月日拼写。

<script type="text/javascript" src="lib/common.js?v=20190719"></script>
<link rel="stylesheet" type="text/css" href="assets/css/ie/ie8.css?v=20190719" />

如果发生紧急情况,需要在一天当中对某些css或者js文件多次发版,可以把时间精确到时分秒。

目前楼主主推采用加版本号的方式,因为文件太多,只能做增量修改。好处是没有做任何修改js或者css文件可以不用加版本号。

采用随机数

document.write('<script src=\".lib/common.js?r=' + Math.random() + "\"" + '><\/script>');

一般不建议用随机数的方式,因为每次刷新页面,随机数都会变化,那么浏览器认为一个新的url需要重新请求服务端获取js或css文件,不会在使用浏览器本地缓存。同时占用网络带宽,影响服务器响应速度。

采用hash(md5)重命名文件

可以利用 gulp-rev或者webpack

entry: {
 main: './src/common.js',
 slove: './src/ie8.js'
},
output: {
 filename: '[name].[hash].js',
 path: path.resolve(__dirname, 'dist')
}

例如百度搜索首页,就是利用hash给js和css文件重命名。