整合营销服务商

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

免费咨询热线:

1分钟,彻底弄懂浏览器缓存策略

1分钟,彻底弄懂浏览器缓存策略

者:kevinylzhao,腾讯音乐前端开发工程师

浏览器缓存策略对于前端开发同学来说不陌生,大家都有一定的了解,但如果没有系统的归纳总结,可能三言两语很难说明白,甚至说错,尤其在面试过程中感触颇深,很多候选人对这类基础知识竟然都是一知半解,说出几个概念就没了,所以重新归纳总结下,温故而知新。


Web 缓存介绍

  • Web 缓存是指一个 Web 资源(如 html 页面,图片,js,数据等)存在于 Web 服务器和客户端(浏览器)之间的副本。
  • 缓存会根据进来的请求保存输出内容的副本;当下一个请求来到的时候,如果是相同的 URL,缓存会根据缓存机制决定是直接使用副本响应访问请求,还是向源服务器再次发送请求。

Web 缓存的好处

  • 减少网络延迟,加快页面打开速度
  • 减少网络带宽消耗
  • 降低服务器压力
  • ...

HTTP 的缓存机制

简化的流程如下

根据什么规则缓存

  1. 新鲜度(过期机制):也就是缓存副本有效期。一个缓存副本必须满足以下条件,浏览器会认为它是有效的,足够新的:
  • 含有完整的过期时间控制头信息(HTTP 协议报头),并且仍在有效期内;
  • 浏览器已经使用过这个缓存副本,并且在一个会话中已经检查过新鲜度;
  1. 校验值(验证机制):服务器返回资源的时候有时在控制头信息带上这个资源的实体标签 Etag(Entity Tag),它可以用来作为浏览器再次请求过程的校验标识。如果发现校验标识不匹配,说明资源已经被修改或过期,浏览器需求重新获取资源内容。

HTTP 缓存的两个阶段

浏览器缓存一般分为两类:强缓存(也称本地缓存)和协商缓存(也称弱缓存)。

本地缓存阶段

浏览器发送请求前,会先去缓存里查看是否命中强缓存,如果命中,则直接从缓存中读取资源,不会发送请求到服务器。否则,进入下一步。

协商缓存阶段

当强缓存没有命中时,浏览器一定会向服务器发起请求。服务器会根据 Request Header 中的一些字段来判断是否命中协商缓存。如果命中,服务器会返回 304 响应,但是不会携带任何响应实体,只是告诉浏览器可以直接从浏览器缓存中获取这个资源。如果本地缓存和协商缓存都没有命中,则从直接从服务器加载资源。

启用&关闭缓存

按照本地缓存阶段和协商缓存阶段分类:

  1. 使用 HTML Meta 标签    Web 开发者可以在 HTML 页面的节点中加入标签,如下:

上述代码的作用是告诉浏览器当前页面不被缓存,事实上这种禁用缓存的形式用处很有限:

a. 仅有 IE 才能识别这段 meta 标签含义,其它主流浏览器仅识别“Cache-Control: no-store”的 meta 标签。

b. 在 IE 中识别到该 meta 标签含义,并不一定会在请求字段加上 Pragma,但的确会让当前页面每次都发新请求(仅限页面,页面上的资源则不受影响)。

  1. 使用缓存有关的 HTTP 消息报头 这里需要了解 HTTP 的基础知识。一个 URI 的完整 HTTP 协议交互过程是由 HTTP 请求和 HTTP 响应组成的。有关 HTTP 详细内容可参考《Hypertext Transfer Protocol — HTTP/1.1》、《HTTP 权威指南》等。

在 HTTP 请求和响应的消息报头中,常见的与缓存有关的消息报头有:

上图中只是常用的消息报头,下面来看下不同字段之间的关系和区别:

  1. Cache-Control 与 Expires
  2. Cache-Control:HTTP1.1 提出的特性,为了弥补 Expires 缺陷加入的,提供了更精确细致的缓存功能。详细了解详细看几个常见的指令:_ max-age:功能和 Expires 类似,但是后面跟一个以“秒”为单位的相对时间,来供浏览器计算过期时间。_ no-cache:提供了过期验证机制。(在 Chrome 的 devtools 中勾选 Disable cache 选项,发送的请求会去掉 If-Modified-Since 这个 Header。同时设置 Cache-Control:no-cache Pragma:no-cache,每次请求均为 200)
    • no-store:表示当前请求资源禁用缓存;
    • public:表示缓存的版本可以被代理服务器或者其他中间服务器识别;
    • private:表示只有用户自己的浏览器能够进行缓存,公共的代理服务器不允许缓存。
  • Expires:HTTP1.0 的特性,标识该资源过期的时间点,它是一个绝对值,格林威治时间(Greenwich Mean Time, GMT),即在这个时间点之后,缓存的资源过期;优先级:Cache-Control 优先级高于 Expires,为了兼容,通常两个头部同时设置;浏览器默认行为:其实就算 Response Header 中沒有设置 Cache-Control 和 Expires,浏览器仍然会缓存某些资源,这是浏览器的默认行为,是为了提升性能进行的优化,每个浏览器的行为可能不一致,有些浏览器甚至没有这样的优化。
  1. Last-Modified 与 ETag
  2. Last-Modified(Response Header)与 If-Modified-Since(Request Header)是一对报文头,属于 http 1.0。If-Modified-Since 是一个请求首部字段,并且只能用在 GET 或者 HEAD 请求中。Last-Modified 是一个响应首部字段,包含服务器认定的资源作出修改的日期及时间。当带着 If-Modified-Since 头访问服务器请求资源时,服务器会检查 Last-Modified,如果 Last-Modified 的时间早于或等于 If-Modified-Since 则会返回一个不带主体的 304 响应,否则将重新返回资源。(注意:在 Chrome 的 devtools 中勾选 Disable cache 选项后,发送的请求会去掉 If-Modified-Since 这个 Header。)
  • ETag 与 If-None-Match 是一对报文头,属于 http 1.1。ETag 是一个响应首部字段,它是根据实体内容生成的一段 hash 字符串,标识资源的状态,由服务端产生。If-None-Match 是一个条件式的请求首部。如果请求资源时在请求首部加上这个字段,值为之前服务器端返回的资源上的 ETag,则当且仅当服务器上没有任何资源的 ETag 属性值与这个首部中列出的时候,服务器才会返回带有所请求资源实体的 200 响应,否则服务器会返回不带实体的 304 响应。
  • ETag 能解决什么问题?

a. Last-Modified 标注的最后修改只能精确到秒级,如果某些文件在 1 秒钟以内,被修改多次的话,它将不能准确标注文件的新鲜度;

b. 某些文件也许会周期性的更改,但是它的内容并不改变(仅仅改变的修改时间),但 Last-Modified 却改变了,导致文件没法使用缓存;

c. 有可能存在服务器没有准确获取文件修改时间,或者与代理服务器时间不一致等情形。

  • 优先级:ETag 优先级比 Last-Modified 高,同时存在时会以 ETag 为准。
缓存位置

浏览器可以在内存、硬盘中开辟一个空间用于保存请求资源副本。我们经常调试时在 DevTools Network 里看到 Memory Cache(內存缓存)和 Disk Cache(硬盘缓存),指的就是缓存所在的位置。请求一个资源时,会按照优先级(Service Worker -> Memory Cache -> Disk Cache -> Push Cache)依次查找缓存,如果命中则使用缓存,否则发起请求。这里先介绍 Memory Cache 和 Disk Cache。

200 from memory cache

表示不访问服务器,直接从内存中读取缓存。因为缓存的资源保存在内存中,所以读取速度较快,但是关闭进程后,缓存资源也会随之销毁,一般来说,系统不会给内存分配较大的容量,因此内存缓存一般用于存储较小文件。同时内存缓存在有时效性要求的场景下也很有用(比如浏览器的隐私模式)。

200 from disk cache

表示不访问服务器,直接从硬盘中读取缓存。与内存相比,硬盘的读取速度相对较慢,但硬盘缓存持续的时间更长,关闭进程之后,缓存的资源仍然存在。由于硬盘的容量较大,因此一般用于存储大文件。

下图可清晰看出差别:

200 from prefetch cache

在 preload 或 prefetch 的资源加载时,两者也是均存储在 http cache,当资源加载完成后,如果资源是可以被缓存的,那么其被存储在 http cache 中等待后续使用;如果资源不可被缓存,那么其在被使用前均存储在 memory cache。

CDN Cache

以腾讯 CDN 为例:X-Cache-Lookup:Hit From MemCache 表示命中 CDN 节点的内存;X-Cache-Lookup:Hit From Disktank 表示命中 CDN 节点的磁盘;X-Cache-Lookup:Hit From Upstream 表示没有命中 CDN。

整体流程

从上图能感受到整个流程,比如常见两种刷新场景:

  • 当 F5 刷新网页时,跳过强缓存,但是会检查协商缓存;
  • 当 Ctrl + F5 强制刷新页面时,直接从服务器加载,跳过强缓存和协商缓存

其他 Web 缓存策略

IndexDB

IndexedDB 就是浏览器提供的本地数据库,能够在客户端存储可观数量的结构化数据,并且在这些数据上使用索引进行高性能检索的 API。

异步 API 方法调用完后会立即返回,而不会阻塞调用线程。要异步访问数据库,要调用 window 对象 indexedDB 属性的 open() 方法。该方法返回一个 IDBRequest 对象 (IDBOpenDBRequest);异步操作通过在 IDBRequest 对象上触发事件来和调用程序进行通信。

常用异步 API 如下:

在 16 年曾基于 IndexDB 做过一整套缓存策略,有不错的优化效果:

Service Worker

SW 从 2014 年提出的草案到现在已经发展很成熟了,基于 SW 做离线缓存,让用户能够进行离线体验,消息推送体验,离线缓存能力涉及到 Cache 和 CacheStorage 的概念,篇幅有限,不展开了。

LocalStorage

localStorage 属性允许你访问一个 Document 源(origin)的对象 Storage 用于存储当前源的数据,除非用户人为清除(调用 localStorage api 或者清除浏览器数据), 否则存储在 localStorage 的数据将被长期保留。

SessionStorage

sessionStorage 属性允许你访问一个 session Storage 对象,用于存储当前会话的数据,存储在 sessionStorage 里面的数据在页面会话结束时会被清除。页面会话在浏览器打开期间一直保持,并且重新加载或恢复页面仍会保持原来的页面会话。

定义最优缓存策略

  • 使用一致的网址:如果您在不同的网址上提供相同的内容,将会多次获取和存储该内容。注意:URL 区分大小写!
  • 确定中继缓存可以缓存哪些资源:对所有用户的响应完全相同的资源很适合由 CDN 或其他中继缓存进行缓存;
  • 确定每个资源的最优缓存周期:不同的资源可能有不同的更新要求。审查并确定每个资源适合的 max-age;
  • 确定网站的最佳缓存层级:对 HTML 文档组合使用包含内容特征码的资源网址以及短时间或 no-cache 的生命周期,可以控制客户端获取更新的速度;
  • 更新最小化:有些资源的更新比其他资源频繁。如果资源的特定部分(例如 JS 函数或一组 CSS 样式)会经常更新,应考虑将其代码作为单独的文件提供。这样,每次获取更新时,剩余内容(例如不会频繁更新的库代码)可以从缓存中获取,确保下载的内容量最少;
  • 确保服务器配置或移除 ETag:因为 Etag 跟服务器配置有关,每台服务器的 Etag 都是不同的;
  • 善用 HTML5 的缓存机制:合理设计启用 LocalStorage、SessionStorage、IndexDB、SW 等存储,会给页面性能带来明显提升;
  • 结合 Native 的强大存储能力:善于利用客户端能力,定制合适的缓存机制,打造极致体验。

结语

通过了解浏览器各种缓存机制和存储能力特点,结合业务制定合适的缓存策略,善用缓存是基本功,可以用于时常审查负责的业务,可能就会发现个别业务并没有运用到位,共勉。

荐学习

  • “68道 Redis+168道 MySQL”精品面试题(带解析),你背废了吗?
  • 接招吧!最强“高并发”系统设计 46 连问,分分钟秒杀一众面试者

01 前言

我们将先从Redis、Nginx+Lua等技术点出发,了解缓存应用的场景。通过使用缓存相关技术,解决高并发的业务场景案例,来深入理解一套成熟的企业级缓存架构是如何设计的。

02 Redis基础

2.1 简介

Redis是一个开源的使用ANSI C语言编写、遵守BSD协议、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API。

它通常被称为数据结构服务器,因为值(value)可以是 字符串(String), 哈希(Hash), 列表(list), 集合(sets) 和 有序集合(sorted sets)等类型。

Redis 与其他 key - value 缓存产品有以下三个特点:

  • Redis支持数据的持久化,可以将内存中的数据保存在磁盘中,重启的时候可以再次加载进行使用。
  • Redis不仅仅支持简单的key-value类型的数据,同时还提供list,set,zset,hash等数据结构的存储。
  • Redis支持数据的备份,即master-slave模式的数据备份。

优势

  • 性能极高 – Redis能读的速度是110000次/s,写的速度是81000次/s 。
  • 丰富的数据类型 – Redis支持二进制案例的 Strings, Lists, Hashes, Sets 及 Ordered Sets 数据类型操作。
  • 原子 – Redis的所有操作都是原子性的,意思就是要么成功执行要么失败完全不执行。单个操作是原子性的。多个操作也支持事务,即原子性,通过MULTI和EXEC指令包起来。
  • 丰富的特性 – Redis还支持 publish/subscribe, 通知, key 过期等等特性。

2.2 数据类型

2.2.1 String(字符串)

string 是 redis 最基本的类型,你可以理解成与 Memcached 一模一样的类型,一个 key 对应一个 value。

string 类型是二进制安全的。意思是 redis 的 string 可以包含任何数据。比如jpg图片或者序列化的对象。

string 类型是 Redis 最基本的数据类型,string 类型的值最大能存储 512MB。

redis 127.0.0.1:6379> SET runoob "laowang"
OK
redis 127.0.0.1:6379> GET runoob
"laowang"

2.2.2 Hash(哈希)

Redis hash 是一个键值(key=>value)对集合。

Redis hash 是一个 string 类型的 field 和 value 的映射表,hash 特别适合用于存储对象。

每个 hash 可以存储 2^32 -1 键值对(40多亿)。

redis 127.0.0.1:6379> HMSET runoob field1 "Hello" field2 "World"
"OK"
redis 127.0.0.1:6379> HGET runoob field1
"Hello"
redis 127.0.0.1:6379> HGET runoob field2
"World"

2.2.3 List(列表)

Redis 列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边)。

列表最多可存储 2^32 - 1 元素 (4294967295, 每个列表可存储40多亿)。

redis 127.0.0.1:6379> lpush runoob redis
(integer) 1
redis 127.0.0.1:6379> lpush runoob mongodb
(integer) 2
redis 127.0.0.1:6379> lpush runoob rabitmq
(integer) 3
redis 127.0.0.1:6379> lrange runoob 0 10
1) "rabitmq"
2) "mongodb"
3) "redis"

2.2.4 Set(集合)

Redis 的 Set 是 string 类型的无序集合。

集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1)。

sadd 命令 :添加一个 string 元素到 key 对应的 set 集合中,成功返回 1,如果元素已经在集合中返回 0。

集合中最大的成员数为 2^32 - 1(4294967295, 每个集合可存储40多亿个成员)。

redis 127.0.0.1:6379> DEL runoob
redis 127.0.0.1:6379> sadd runoob redis
(integer) 1
redis 127.0.0.1:6379> sadd runoob mongodb
(integer) 1
redis 127.0.0.1:6379> sadd runoob rabitmq
(integer) 1
redis 127.0.0.1:6379> sadd runoob rabitmq
(integer) 0
redis 127.0.0.1:6379> smembers runoob

1) "redis"
2) "rabitmq"
3) "mongodb"

2.2.5 zset(sorted set:有序集合)

Redis zset 和 set 一样也是string类型元素的集合,且不允许重复的成员。

不同的是每个元素都会关联一个double类型的分数。redis正是通过分数来为集合中的成员进行从小到大的排序。

zset的成员是唯一的,但分数(score)却可以重复。

zadd 命令 :添加元素到集合,元素在集合中存在则更新对应score

redis 127.0.0.1:6379> zadd runoob 0 redis
(integer) 1
redis 127.0.0.1:6379> zadd runoob 0 mongodb
(integer) 1
redis 127.0.0.1:6379> zadd runoob 0 rabitmq
(integer) 1
redis 127.0.0.1:6379> zadd runoob 0 rabitmq
(integer) 0
redis 127.0.0.1:6379> > ZRANGEBYSCORE runoob 0 1000
1) "mongodb"
2) "rabitmq"
3) "redis"

03 Redis深入:带着问题出发?

3.1 如果让你设计一个KV数据库,该如何设计

对这个问题的思考,将有助于我们从整体架构上去学习Redis。

假设现在我们已经设计好了一个KV数据库,首先如果我们要使用,是不是得有入口,我们是通过动态链接库还是通过网络socket对外提供访问入口,这就涉及到了访问模块。Redis就是通过

通过访问模块访问KV数据库之后,我们的数据存储在哪里?为了保证访问的高性能,我们选择存储在内存中,这又需要有存储模块。存在内存中的数据,虽然访问速度快,但存在的的问题就是断电后,无法恢复数据,所以我们还需要支持持久化操作

有了存储模块,我们还需要考虑,数据是以什么样的形式存储?怎样设计才能让数据操作更优,这就设计到了,数据类型的支持,索引模块。 索引的作用是让键值数据库根据 key 找到相应 value 的存储位置,进而执行操作。

有了以上模块的只是,我们是不是要对数据进行操作了?比如往KV数据库中插入或更新一条数据,删除和查询,这就是需要有操作模块了。

至此我们已经构造出了一个KV数据库的基本框架了,带着这些架构,我们再深入到每个点中去探究,这样就会轻松很多,不会迷失在末枝细节中了。

3.2 Redis为什么那么快?

我们都知道Redis访问快,这是因为redis的操作都是在内存上的,内存的访问本身就很快,另外Redis底层的数据结构也对“快”起到了至关重要的作用。

我们平常所以所说Redis的5种数据结构:String、Hash、Set、ZSet和List指的只是键值对中值的数据结构,而我这里所说的数据结构,指的是它们底层实现。

Redis的底层数据结构有:简单动态字符串、整数数组、压缩列表、跳表、hash表、双向列表6种。

简单动态数组:就是String的底层实现

其中整数数组、hash表、双向列表都是我们常见的数据结构

压缩列表和跳表属于特殊的数据结构

压缩列表是Redis实现的特殊的数组:它本质就是一个数组,只不过,我们常见的数组的每个元素分配的空间大小是一致的,这样就会导致有多余的内存空间被浪费了。压缩列表就是为了解决这样的问题,它的每个元素大小是按实际大小分配的,避免了内存的浪费,同时在压缩列表的表头还存了关于该列表的相关属性:用于记录列表个数zllen,表尾偏移量zltail和列表长度zlbytes。表尾还有一个zlend标记列表的结束。

跳表:有序链表查询元素只能逐一查询,跳表本质上就是链表的基础上加了多级索引,通过多级索引的几个跳转,快递定位到元素所在位置。

不同数据结构的查询时间复杂度

上面从存储方面解释了,redis为什么快.

3.2.1 为什么用单线程?

逆向思维可以说为什么不用多线程,这个我们得先看下多线程存在哪些问题?在正常应用操作中,使用多线程可以大大提高处理的时间。那是不是可以无限地加大线程数量,以获取更快的处理速度?实际试验后,发现在机器资源有限的情况下,不断增加线程处理时间,并没有像我们想象的那样成线性增长,而是到达一定阶段就趋于平衡,甚至有下降的趋势,这是为什么呢?

其实主要有两个方面,我们知道线程是CPU调度的最小单元,当线程多的时候,CPU需要不停的切换线程,线程切换是需要消耗时间的,当大量线程需要来回切换,那么CPU在这切换的损耗了很多时间。

另外当多个线程,需要对共享资源进行操作的时候,为了保证并发安全性,需要有额外的机制保证,比如加锁。这样就使得当多个线程在操作共享数据时,变成了串行。

所以为了避免这些问题,Redis采用了单线程操作数据。

3.2.2 单线程为什么还真这么快?

我们知道Redis单线程操作的,但是只是指的Redis对外提供键值对存储服务是单线程的。Redis的其他功能并不是,比如持久化,异步删除,集群同步等,都是由额外的线程去执行的。

除了上面说的,Redis的大部分操作都是在内存上完成的,加上高效的数据结构,是他实现高性能的一方面。另外一方面Redis采用的多路复用机制,使其在网络IO操作中能并发处理大量的客户端请求。

在网络 IO 操作中,有潜在的阻塞点,分别是 accept() 和 recv()。当 Redis 监听到一个客户端有连接请求,但一直未能成功建立起连接时,会阻塞在 accept() 函数这里,导致其他客户端无法和 Redis 建立连接。类似的,当 Redis 通过 recv() 从一个客户端读取数据时,如果数据一直没有到达,Redis 也会一直阻塞在 recv()。 这就导致 Redis 整个线程阻塞,无法处理其他客户端请求,效率很低。不过,幸运的是,socket 网络模型本身支持非阻塞模式。

Socket 网络模型的非阻塞模式设置,主要体现在三个关键的函数调用上,如果想要使用 socket 非阻塞模式,就必须要了解这三个函数的调用返回类型和设置模式。接下来,我们就重点学习下它们。在 socket 模型中,不同操作调用后会返回不同的套接字类型。socket() 方法会返回主动套接字,然后调用 listen() 方法,将主动套接字转化为监听套接字,此时,可以监听来自客户端的连接请求。最后,调用 accept() 方法接收到达的客户端连接,并返回已连接套接字。

针对监听套接字,我们可以设置非阻塞模式:当 Redis 调用 accept() 但一直未有连接请求到达时,Redis 线程可以返回处理其他操作,而不用一直等待。但是,你要注意的是,调用 accept() 时,已经存在监听套接字了。

类似的,我们也可以针对已连接套接字设置非阻塞模式:Redis 调用 recv() 后,如果已连接套接字上一直没有数据到达,Redis 线程同样可以返回处理其他操作。我们也需要有机制继续监听该已连接套接字,并在有数据达到时通知 Redis。这样才能保证 Redis 线程,既不会像基本 IO 模型中一直在阻塞点等待,也不会导致 Redis 无法处理实际到达的连接请求或数据。

Linux 中的 IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。为了在请求到达时能通知到 Redis 线程,select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的处理函数。

3.3 Redis是如何保证数据不丢失的?

因为Redis是操作是基于内存的,所有一点系统宕机存在内存中的数据就会丢失,为了实现数据的持久化,Redis中存在两个持久化机制AOF和RBD。

3.3.1 AOF(Append Only File)介绍

AOF的原理就是,通过记录下Redis的所有命令操作,在需要数据恢复的时候,再按照顺序把所有命令执行一次,从而恢复数据。

但跟数据库的写前日志不同的,AOF采用的写后日志,也就是在Redis执行过操作之后,再写入AOF日志。之所以为什么采用写后日志,可以避免因为写日志的占用redis调用的时间,另外为了保证Redis的高性能,在写aof日志的时候,不会做校验,若采用写前日志,如果命令是错误非法的,在恢复数据的时候就会出现异常。采用写后日志,只有命令执行成功的才会被保存。

3.3.2 AOF策略

AOF的执行策略有三种

all:每次写入/删除命令都会被写入日志文件中,保证了数据可靠性,但是写入日志,涉及到了磁盘的IO,必然会影响性能

everysec:每秒钟执行一次日志写入,在一秒之内的命令操作会记录在aof内存缓冲区,每一秒会写回到日志文件中,相对于每次写入性能得以提升,但是在aof缓冲区没有来得及回写到日志文件中时,系统发生宕机就会丢失这部分数据。

no:内存缓冲区的命令记录不会不主动写回到日志文件中,而交给操作系统决定。这种策略性能最高,但是丢失数据的风险也最大。

3.3.3 AOF重写机制

但是AOF文件过大,会带来性能问题,所有AOF重写机制就登场了。

AOF重写的原理是,将多个命令对同一个key的操作合并成一个,因为数据恢复时,我们只要关心数据最后的状态就可以了。

需要注意的是,与AOF日志由主线程写回不同,重写过程是由后台子线程bgwriteaof来完成的,这个避免阻塞主线程,导致数据库性能下降。

每次 AOF 重写时,Redis 会先执行一个内存拷贝,用于重写;然后,使用两个日志保证在重写过程中,新写入的数据不会丢失。而且,因为 Redis 采用额外的线程进行数据重写,所以,这个过程并不会阻塞主线程。

3.4 内存快照RDB

3.4.1 RDB Redis DataBase

所谓内存快照,就是指内存中的数据在某一个时刻的状态记录。对 Redis 来说,就是把某一时刻的状态以文件的形式写到磁盘上。

Redis执行RDB的策略是什么?

Redis进行快照的时候,是进行全量的快照,并且为了不阻塞主线程,会默认使用bgsave命令创建一个子线程,专门用于写入RDB文件。

快照期间数据还能修改吗?

如果不能修改,那么在快照期间,这块数据就会只能读取不能修改,那么必然影响使用。如果可以修改,那么Redis是如何实现的?其实Redis是借助操作系统的写时复制,在执行快照期间,让修改的数据,会在内存中拷贝出一份副本,副本的数据可以被写入rdb文件中,而主线程仍然可以修改原数据。

多久执行一次呢?

跟aof同样的问题,如果快照频率低,那么在两次快照期间出现宕机,就会出现数据不完整的情况,如果快照频率过快,那么又会出现两个问题,一个是不停的对磁盘写出,增大磁盘压力,可能上一次写入还没完成,新的快照又来了,造成恶性循环.另外虽然执行快照是主线程fork出来的,但是不停的fork的过程是阻塞主线程的。

那么如何配置才合适呢?

其实我们只需要第一次全量快照,后续只快照有数据变动的地方就可以大大降低快照的资源损耗了,那么如何记录这变动的数据呢,这里我们可以想到aof具有这样的功能。Redis4.0就提使用RDB+AOF混合模式来完成Redis的持久化。简单来说,内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。

3.5 主从库是如何实现数据一致的?

前面我们通过Redis的持久化机制,来保证服务器宕机之后,通过回放日志和重新读取RDB文件恢复数据,减少数据丢失的风险。
但是在单台及其的情况下,机器发生宕机,就无法对外提供服务了。我们所说的Redis具有高可靠性,指的一是,数据尽量少丢失,之前持久化机制就解决了这一问题,另一个是服务尽量少中断,Redis的做法是增加副本冗余量。Redis提供的主从模式,主从库之间采用了读写分离的方式。

从库只读取,主库执行读与写,写的数据主库会同步给从库。之所以只让主库写,是因为,如果从库也写,那么当客户端对一个数据修改了3次,为了保证数据的正确性,就要设法让主从库对于写操作协同,这会带来巨额的开销。

主从库间如何进行第一次同步的?

当我们启动多个 Redis 实例的时候,它们相互之间就可以通过 replicaof(Redis 5.0 之前使用 slaveof)命令形成主库和从库的关系,之后会按照三个阶段完成数据的第一次同步。

主库收到 psync 命令后,会用 FULLRESYNC 响应命令带上两个参数:主库 runID 和主库目前的复制进度 offset,返回给从库。从库收到响应后,会记录下这两个参数。

这里有个地方需要注意,FULLRESYNC 响应表示第一次复制采用的全量复制,也就是说,主库会把当前所有的数据都复制给从库。

在第二阶段,主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载。这个过程依赖于内存快照生成的 RDB 文件。

具体来说,主库执行 bgsave 命令,生成 RDB 文件,接着将文件发给从库。从库接收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。这是因为从库在通过 replicaof 命令开始和主库同步前,可能保存了其他数据。为了避免之前数据的影响,从库需要先把当前数据库清空。

在主库将数据同步给从库的过程中,主库不会被阻塞,仍然可以正常接收请求。否则,Redis 的服务就被中断了。但是,这些请求中的写操作并没有记录到刚刚生成的 RDB 文件中。为了保证主从库的数据一致性,主库会在内存中用专门的 replication buffer,记录 RDB 文件生成后收到的所有写操作。

最后,也就是第三个阶段,主库会把第二阶段执行过程中新收到的写命令,再发送给从库。具体的操作是,当主库完成 RDB 文件发送后,就会把此时 replication buffer 中的修改操作发给从库,从库再重新执行这些操作。这样一来,主从库就实现同步了。

3.6 Redis如何保证高可用的

3.6.1 主库挂了之后,还能接收写操作吗?

Redis在有了主从集群后,如果从库挂了,Redis对外提供服务不受影响,主库和其他从库,依然可以提供读写服务,但是当主库挂了之后,因为是读写分离的,如果此时有写的请求,那么就无法处理了。Redis是如果解决这样的问题的呢,这就要引入哨兵机制了。

当主库挂了,我们需要从从库中选出一个当做主库,这样就可以正常对外提供服务了。哨兵的本质就是一个Redis示例,只不过它是运行在特殊模式下的Redis进程。它主要有三个作用:监控、选举、通知

哨兵在监控到主库下线的时候,会从从库中通过一定的规则,选举出适合的从库当主库,并通知其他从库变更主库的信息,让他们执行replicaof命令,和新主库建立连接,并进行数据复制。那么具体每一步都是怎么做的呢?

监控:哨兵会周期性向主从库发送PING命令,检测主库是否正常运行,如果主从库没有在规定的时间内回应哨兵的PING命令,则会被判定为“下线状态”,如果是主库下线,则开始自动切换主库的流程。但是一般如果只有一个哨兵,那么它的判断可能不具有可靠性,所以一般哨兵都是采用集群模式部署,称为哨兵集群。单多个哨兵均判断该主库下线了,那么可能他就真的下线了,这是一个少数服从多数的规则。

选举: 哨兵选择新主库的过程称为“筛选 + 打分”。简单来说,我们在多个从库中,先按照一定的筛选条件,把不符合条件的从库去掉。然后,我们再按照一定的规则,给剩下的从库逐个打分,将得分最高的从库选为新主库,如下图所示:

1、排除那些已经下线的从库,以及连接不稳定的从库。连接不稳定是通过配置项down-after-milliseconds,当主从连接超时达到一定阈值,就会被记录下来,比如设置的10次,那么就会标记该从库网络不好,不适合做为主库。

2、筛选出从库后,第二部就要开始打分了,主要从三方面打分,

1.从库优先级,这是可以通过slave-property设置的,设置的高,打分的就高,就会被选为主库,比如你可以给从库中内存带宽资源充足设置高优先级,当主库挂了之后被优先选举为主库。

2.从库与旧主库之间的复制进度,之前我们知道主从之间增量复制,有个参数slave-repl-offset记录当前的复制进度。这个数值越大,说明与主库复制进度越靠近,打分也会越高。

? 3.每个从库创建实例的时候,会随机生成一个id,id越小的得分越高。

通知:哨兵提升一个从库为新主库后,哨兵会把新主库的地址写入自己实例的pubsub(switch-master)中。客户端需要订阅这个pubsub,当这个pubsub有数据时,客户端就能感知到主库发生变更,同时可以拿到最新的主库地址,然后把写请求写到这个新主库即可,这种机制属于哨兵主动通知客户端。

如果客户端因为某些原因错过了哨兵的通知,或者哨兵通知后客户端处理失败了,安全起见,客户端也需要支持主动去获取最新主从的地址进行访问。

所以,客户端需要访问主从库时,不能直接写死主从库的地址了,而是需要从哨兵集群中获取最新的地址(sentinel get-master-addr-by-name命令),这样当实例异常时,哨兵切换后或者客户端断开重连,都可以从哨兵集群中拿到最新的实例地址。

3.6.2 哨兵集群

部署哨兵集群的时候,我们知道只需要配置:sentinel monitor 跟主库通信就可以了,并不知道其他哨兵的信息,那么是如何知道的呢?

Redis有提供了pub/sub机制,哨兵跟主库建立了连接之后,将自己的信息发布到 “sentinel:hello”频道上,其他哨兵发布并订阅了该频道,就可以获取其他哨兵的信息,那么哨兵之间就可以相互通信了。

那么哨兵如何知道从库的连接信息呢,那是因为INFO命令,哨兵向主库发送该命令后,获得了所有从库的连接信息,就能分从库建立连接,并进行监控了。

从本质上说,哨兵就是一个运行在特定模式下的 Redis 实例,只不过它并不服务请求操作,只是完成监控、选主和通知的任务。所以,每个哨兵实例也提供 pub/sub 机制,客户端可以从哨兵订阅消息。哨兵提供的消息订阅频道有很多,不同频道包含了主从库切换过程中的不同关键事件。

3.6.3 切片集群

与mysql一样,当一张表的数据很大时,查询耗时可能就会越来越大,我们采取的措施是分表分库。同样的Redis也样,当数据量很大时,比如高达25G,在单分片下,我们需要机器有32G的内存。但是我们会发现,有时候redis响应会变得很慢,通过INFO查询Redis的latest_fork_usec指标,最近fork耗时,发现耗时很大,快到秒级别了,fork这个动作会阻塞主线程,于是就导致了Redis变慢了。

于是就有redis分片集群, 启动多个 Redis 实例组成一个集群,然后按照一定的规则,把收到的数据划分成多份,每一份用一个实例来保存。回到我们刚刚的场景中,如果把 25GB 的数据平均分成 5 份(当然,也可以不做均分),使用 5 个实例来保存,每个实例只需要保存 5GB 数据。

那么,在切片集群中,实例在为 5GB 数据生成 RDB 时,数据量就小了很多,fork 子进程一般不会给主线程带来较长时间的阻塞。采用多个实例保存数据切片后,我们既能保存 25GB 数据,又避免了 fork 子进程阻塞主线程而导致的响应突然变慢。

那么数据是如何决定存在在哪个分片上的呢?

Redis Cluster 方案采用哈希槽(Hash Slot,接下来我会直接称之为 Slot),来处理数据和实例之间的映射关系。在 Redis Cluster 方案中,一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中。具体的映射过程分为两大步:首先根据键值对的 key,按照CRC16 算法计算一个 16 bit 的值;然后,再用这个 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。

我们在部署 Redis Cluster 方案时,可以使用 cluster create 命令创建集群,此时,Redis 会自动把这些槽平均分布在集群实例上。例如,如果集群中有 N 个实例,那么,每个实例上的槽个数为 16384/N 个。 也可以使用 cluster meet 命令手动建立实例间的连接,形成集群,再使用 cluster addslots 命令,指定每个实例上的哈希槽个数。

前面介绍了Redis相关知识,了解了Redis的高可用,高性能的原因。很多人认为提到缓存,就局限于Redis,其实缓存的应用不仅仅在于Redis的使用,比如还有Nginx缓存,缓存队列等等。下面我们会将讲解Nginx+Lua实现多级缓存方法,来解决高并发访问的场景。

04 缓存的应用

我们来看一张微服务架构缓存的使用

我们可以看到微服务架构中,会大量使用到缓存

1.客户端缓存(手机、PC)
2.Nginx缓存
3.微服务网关限流令牌缓存
4.Nacos缓存服务列表、配置文件
5.各大微服务自身也具有缓存
6.数据库查询Query Cache
7.Redis集群缓存
8.Kafka也属于缓存


应对高并发的最有效手段之一就是分布式缓存,分布式缓存不仅仅是缓存要显示的数据这么简单,还可以在限流、队列削峰、高速读写、分布式锁等场景发挥重大作用。分布式缓存可以说是解决高并发场景的有效利器。以以下场景为例:

1、凌晨突然涌入的巨大流量。【队列术】【限流术】
2、高并发场景秒杀、抢红包、抢优惠券,快速存取。【缓存取代MySQL操作】
3、高并发场景超卖、超额抢红包。【Redis单线程取代数据库操作】
4、高并发场景重复抢单。【Redis抢单计数器】

一谈到缓存架构,很多人想到的是Redis,但其实整套体系的缓存架构并非只有Redis,而应该是多个层面多个软
件结合形成一套非常良性的缓存体系。比如咱们的缓存架构设计就涉及到了多个层面的缓存软件。

1、HTML页面做缓存,浏览器端可以缓存HTML页面和其他静态资源,防止用户频繁刷新对后端造成巨大压力
2、Lvs实现记录不同协议以及不同用户请求链路缓存
3、Nginx这里会做HTML页面缓存配置以及Nginx自身缓存配置
4、数据查找这里用Lua取代了其他语言查找,提高了处理的性能效率,并发处理能力将大大提升
5、数据缓存采用了Redis集群+主从架构,并实现缓存读写分离操作
6、集成Canal实现数据库数据增量实时同步Redis

05 Nginx缓存

5.1 浏览器缓存

客户端侧缓存一般指的是浏览器缓存、app缓存等等,目的就是加速各种静态资源的访问,降低服务器压力。我们通过配置Nginx设置网页缓存信息,从而降低用户对服务器频繁访问造成的巨大压力。

HTTP 中最基本的缓存机制,涉及到的 HTTP 头字段,包括 Cache‐Control, Last‐Modified, If‐Modified‐Since, 
Etag,If‐None‐Match 等。
 
Last‐Modified/If‐Modified‐Since
 
Etag是服务端的一个资源的标识,在 HTTP 响应头中将其传送到客户端。所谓的服务端资源可以是一个Web页面,也可
以是JSON或XML等。服务器单独负责判断记号是什么及其含义,并在HTTP响应头中将其传送到客户端。比如,浏览器第
一次请求一个资源的时候,服务端给予返回,并且返回了ETag: "50b1c1d4f775c61:df3" 这样的字样给浏览器,当浏
览器再次请求这个资源的时候,浏览器会将If‐None‐Match: W/"50b1c1d4f775c61:df3" 传输给服务端,服务端拿到
该ETAG,对比资源是否发生变化,如果资源未发生改变,则返回304HTTP状态码,不返回具体的资源。
 
Last‐Modified :标示这个响应资源的最后修改时间。web服务器在响应请求时,告诉浏览器资源的最后修改时间。
 
If‐Modified‐Since :当资源过期时(使用Cache‐Control标识的max‐age),发现资源具有 Last‐Modified 声
明,则再次向web服务器请求时带上头。
 
If‐Modified‐Since ,表示请求时间。web服务器收到请求后发现有头 If‐Modified‐Since 则与被请求资源的最后修
改时间进行比对。若最后修改时间较新,说明资源有被改动过,则响应整片资源内容(写在响应消息包体内),HTTP 
200;若最后修改时间较旧,说明资源无新修改,则响应 HTTP 304 (无需包体,节省浏览),告知浏览器继续使用所保
存的 cache 。
 
Pragma行是为了兼容 HTTP1.0 ,作用与 Cache‐Control: no‐cache 是一样的
 
Etag/If‐None‐Match
Etag :web服务器响应请求时,告诉浏览器当前资源在服务器的唯一标识(生成规则由服务器决定),如果给定URL中的
资源修改,则一定要生成新的Etag值。
 
If‐None‐Match :当资源过期时(使用Cache‐Control标识的max‐age),发现资源具有Etage声明,则再次向web服
务器请求时带上头 If‐None‐Match (Etag的值)。web服务器收到请求后发现有头 If‐None‐Match 则与被请求资源
的相应校验串进行比对,决定返回200或304。
 
Etag:
Last‐Modified 标注的最后修改只能精确到秒级,如果某些文件在1秒钟以内,被修改多次的话,它将不能准确标注文
件的修改时间,如果某些文件会被定期生成,当有时内容并没有任何变化,但 Last‐Modified 却改变了,导致文件没
法使用缓存有可能存在服务器没有准确获取文件修改时间,或者与代理服务器时间不一致等情形 Etag是服务器自动生成
或者由开发者生成的对应资源在服务器端的唯一标识符,能够更加准确的控制缓存。 Last‐Modified 与 ETag 是可以
一起使用的,服务器会优先验证 ETag ,一致的情况下,才会继续比对 Last‐Modified ,最后才决定是否返回304。

5.2 代理缓存

用户如果请求获取的数据不是需要后端服务器处理返回,如果我们需要对数据做缓存来提高服务器的处理能力,我们可以按照如下步骤实现:

1、请求Nginx,Nginx将请求路由给后端服务
2、后端服务查询Redis或者MySQL,再将返回结果给Nginx
3、Nginx将结果存入到Nginx缓存,并将结果返回给用户
4、用户下次执行同样请求,直接在Nginx中获取缓存数据

06 多级缓存架构

具体流程

1、用户请求经过Nginx
2、Nginx检查是否有缓存,如果Nginx有缓存,直接响应用户数据
3、Nginx如果没有缓存,则将请求路由给后端Java服务
4、Java服务查询Redis缓存,如果有数据,则将数据直接响应给Nginx,并将数据存入缓存,Nginx将数据响应给用户
5、如果Redis没有缓存,则使用Java程序查询MySQL,并将数据存入到Reids,再将数据存入到Nginx中

优缺点

优点:
1、采用了Nginx缓存,减少了数据加载的路径,从而提升站点数据加载效率
2、多级缓存有效防止了缓存击穿、缓存穿透问题
缺点
Tomcat并发量偏低,导致缓存同步并发量失衡,缓存首次加载效率偏低,Tomcat 大规模集群占用资源高

优点
1、采用了Nginx缓存,减少了数据加载的路径,从而提升站点数据加载效率
2、多级缓存有效防止了缓存击穿、缓存穿透问题
3、使用了Nginx+Lua集成,无论是哪次缓存加载,效率都高
4、Nginx并发量高,Nginx+Lua集成,大幅提升了并发能力

6.1 抢红包案例架构设计分享

上面我们已经分析过红包雨的特点,要想实现一套高效的红包雨系统,缓存架构是关键。我们根据红包雨的特点设计了如上图所示的红包雨缓存架构体系。

1、红包雨分批次导入到Redis缓存而不要每次操作数据库
2、很多用户抢红包的时候,为了避免1个红包被多人抢到,我们要采用Redis的队列存储红包
3、追加红包的时候,可以追加延时发放红包,也可以直接追加立即发放红包
4、用户抢购红包的时候,会先经过Nginx,通过Lua脚本查看缓存中是否存在红包,如果不存在红包,则直接终止抢红包
5、如果还存在红包,为了避免后台同时处理很多请求,这里采用队列术缓存用户请求,后端通过消费队列执行抢红包

6.2 缓存队列使用场景

1、队列控制并发溢出:并发量非常大的系统,例如秒杀、抢红包、抢票等操作,都是存在溢出现象,比如秒杀超卖、抢红包超额、一票多单等溢出现象,如果采用数据库锁来控制溢出问题,效率非常低,在高并发场景下,很有可能直接导致数据库崩溃,因此针对高并发场景下数据溢出解决方案我们可以采用Redis缓存提升效率。

2、队列限流:解决大量并发用户蜂拥而上的方法可以采用队列术将用户的请求用队列缓存起来,后端服务从队列缓存中有序消费,可以防止后端服务同时面临处理大量请求。缓存用户请求可以用RabbitMQ、Kafka、RocketMQ、ActiveMQ等。用户抢红包的时候,我们用Lua脚本实现将用户抢红包的信息以生产者角色将消息发给RabbitMQ,后端应用服务以消费者身份从RabbitMQ获取消息并抢红包,再将抢红包信息以WebSocket方式通知给用户。

6.3 Nginx限流

nginx提供两种限流的方式:一是控制速率,二是控制并发连接数。

1、速率限流

控制速率的方式之一就是采用漏桶算法。具体配置如下:

2、控制并发量

ngx_http_limit_conn_module 提供了限制连接数的能力。主要是利用limit_conn_zone和limit_conn两个指令。利用连接数限制 某一个用户的ip连接的数量来控制流量。

(1)配置限制固定连接数
如下,配置如下:
配置限流缓存空间:

根据IP地址来限制,存储内存大小10M
limit_conn_zone $binary_remote_addr zone=addr:1m;

location配置:

limit_conn addr 2;

参数说明:

limit_conn_zone $binary_remote_addr zone=addr:10m;  表示限制根据用户的IP地址来显示,设置存储地址为的
内存大小10M
 
limit_conn addr 2;   表示 同一个地址只允许连接2次。

(2)限制每个客户端IP与服务器的连接数,同时限制与虚拟服务器的连接总数。
限流缓存空间配置:

limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_conn_zone $server_name zone=perserver:10m;

location配置

limit_conn perip 10;#单个客户端ip与服务器的连接数
limit_conn perserver 100; #限制与服务器的总连接数

每个IP限流 3个
总量5个

07 缓存灾难问题如何解决

7.1 缓存穿透

产生原因

当我们查询一个缓存不存在的数据,就去查数据库,但此时如果数据库也没有这个数据,后面继续访问依然会再次查询数据库,当有用户大量请求不存在的数据,必然会导致数据库的压力升高,甚至崩溃。

如何解决

1、当查询到不存在的数据,也将对应的key放入缓存,值为nul,这样再次查询会直接返回null,如果后面新增了该key的数据,就覆盖即可。

2、使用布隆过滤器。布隆过滤器主要是解决大规模数据下不需要精确过滤的业务场景,如检查垃圾邮件地址,爬虫URL地址去重,解决缓存穿透问题等。

7.2 缓存击穿

产生原因

当缓存在某一刻过期了,一般如果再查询这个缓存,会从数据库去查询一次再放到缓存,如果正好这一刻,大量的请求该缓存,那么请求都会打到数据库中,可能导致数据库打垮。

如何解决

1、尽量避免缓存过期时间都在同一时间。

2、定时任务主动刷新更新缓存,或者设置缓存不过去,适合那种key相对固定,粒度较大的业务。

? 分享下我在公司的负责的系统是如何防止缓存击穿的,由于业务场景,缓存的数据都是当天有效的,当天查询的只查当日有效的数据,所以当时数据都是设置当天凌晨过期,并且缓存是懒加载,这样导致0点高峰期数据库压力明显增大。后来改造了下,做了个定时任务,每天凌晨3点,跑第二天生效的数据,并且设置失效时间延长一天。有效解决了该问题,相当于缓存预热。

3、多级缓存

采用多级缓存也可以有效防止击穿现象,首先通过程序将缓存存入到Redis缓存,且永不过期,用户查询的时候,先查询Nginx缓存,如果Nginx缓存没有,则查询Redis缓存,并将Redis缓存存入到Nginx一级缓存中,并设置更新时间。这种方案不仅可以提升查询速度,同时又能防止击穿问题,并且提升了程序的抗压能力。

4、分布式锁与队列。解决思路主要是防止多请求同时打过去。分布式锁,推荐使用Redisson。队列方案可以使用nginx缓存队列,配置如下。

7.3 缓存雪崩

产生原因

缓存雪崩是指,由于缓存层承载着大量请求,有效的保护了存储层,但是如果缓存层由于某些原因整体不能提供服
务,于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层也会挂掉的情况。

如何解决

1、做缓存集群。即使个别节点、个别机器、甚至是机房宕掉,依然可以提供服务,比如 Redis Sentinel 和 Redis Cluster 都实现了高可用。

2、做好限流。微服务网关或者Nginx做好限流操作,防止大量请求直接进入后端,使后端载荷过重最后宕机。

3、缓存预热。预先去更新缓存,再即将发生大并发访问前手动触发加载缓存不同的key,设置不同的过期时间,让缓存失效的时间点尽量均匀,不要同时失效。

4、加锁。数据操作,如果是带有缓存查询的,均使用分布式锁,防止大量请求直接操作数据库。

5、多级缓存。采用多级缓存,Nginx+Redis+MyBatis二级缓存,当Nginx缓存失效时,查找Redis缓存,Redis缓存失效查找MyBatis二级缓存。

7.4 缓存一致性

问题描述

数据的在增量数据,未同步到缓存。导致缓存与数据库数据不一致。

解决方案Canal

用户每次操作数据库的时候,使用Canal监听数据库指定表的增量变化,在Java程序中消费Canal监听到的增量变化,并在Java程序中实现对Redis缓存或者Nginx缓存的更新。
用户查询的时候,先通过Lua查询Nginx的缓存,如果Nginx缓存没有数据,则查询Redis缓存,Redis缓存如果也没有数据,可以去数据库查询。

原文链接:https://www.cnblogs.com/whgk/p/13995972.html

、浏览器缓存基本认识


分为强缓存和协商缓存

1、浏览器在加载资源时,先根据这个资源的一些http header判断它是否命中强缓存,强缓存如果命中,浏览器直接从自己的缓存中读取资源,不会发请求到服务器。比如某个css文件,如果浏览器在加载它所在的网页时,这个css文件的缓存配置命中了强缓存,浏览器就直接从缓存中加载这个css,连请求都不会发送到网页所在服务器。

2、当强缓存没有命中的时候,浏览器一定会发送一个请求到服务器,通过服务器端依据资源的另外一些http header验证这个资源是否命中协商缓存,如果协商缓存命中,服务器会将这个请求返回,但是不会返回这个资源的数据,而是告诉客户端可以直接从缓存中加载这个资源,于是浏览器就又会从自己的缓存中去加载这个资源。

强缓存与协商缓存的共同点是:如果命中,都是从客户端缓存中加载资源,而不是从服务器加载资源数据;区别是:强缓存不发请求到服务器,协商缓存会发请求到服务器。

当协商缓存也没有命中的时候,浏览器直接从服务器加载资源数据。

二、强缓存的原理


2.1 介绍

当浏览器对某个资源的请求命中了强缓存时,返回的http状态为200,在chrome的开发者工具的network里面size会显示为from cache,比如京东的首页里就有很多静态资源配置了强缓存,用chrome打开几次,再用f12查看network,可以看到有不少请求就是从缓存中加载的



强缓存是利用Expires或者Cache-Control这两个http response header实现的,它们都用来表示资源在客户端缓存的有效期。


Expires是http1.0提出的一个表示资源过期时间的header,它描述的是一个绝对时间,由服务器返回,用GMT格式的字符串表示,如:Expires:Thu, 31 Dec 2037 23:55:55 GMT


2.2 Expires缓存原理

1、浏览器第一次跟服务器请求一个资源,服务器在返回这个资源的同时,在respone的header加上Expires,如




2、浏览器在接收到这个资源后,会把这个资源连同所有response header一起缓存下来(所以缓存命中的请求返回的header并不是来自服务器,而是来自之前缓存的header)

3、浏览器再请求这个资源时,先从缓存中寻找,找到这个资源后,拿出它的Expires跟当前的请求时间比较,如果请求时间在Expires指定的时间之前,就能命中缓存,否则就不行

4、如果缓存没有命中,浏览器直接从服务器加载资源时,Expires Header在重新加载的时候会被更新

Expires是较老的强缓存管理header,由于它是服务器返回的一个绝对时间,在服务器时间与客户端时间相差较大时,缓存管理容易出现问题,比如随意修改下客户端时间,就能影响缓存命中的结果。所以在http1.1的时候,提出了一个新的header,就是Cache-Control,这是一个相对时间,在配置缓存的时候,以秒为单位,用数值表示,如:Cache-Control:max-age=315360000


2.3 Cache-Control缓存原理

1、浏览器第一次跟服务器请求一个资源,服务器在返回这个资源的同时,在respone的header加上Cache-Control,如:


2、浏览器在接收到这个资源后,会把这个资源连同所有response header一起缓存下来

3、浏览器再请求这个资源时,先从缓存中寻找,找到这个资源后,根据它第一次的请求时间和Cache-Control设定的有效期,计算出一个资源过期时间,再拿这个过期时间跟当前的请求时间比较,如果请求时间在过期时间之前,就能命中缓存,否则就不行

4、如果缓存没有命中,浏览器直接从服务器加载资源时,Cache-Control Header在重新加载的时候会被更新

Cache-Control描述的是一个相对时间,在进行缓存命中的时候,都是利用客户端时间进行判断,所以相比较Expires,Cache-Control的缓存管理更有效,安全一些。

这两个header可以只启用一个,也可以同时启用,当response header中,Expires和Cache-Control同时存在时,Cache-Control优先级高于Expires:




三、强缓存的管理


前面介绍的是强缓存的原理,在实际应用中我们会碰到需要强缓存的场景和不需要强缓存的场景,通常有2种方式来设置是否启用强缓存


1、通过代码的方式,在web服务器返回的响应中添加Expires和Cache-Control Header

2、通过配置web服务器的方式,让web服务器在响应资源的时候统一添加Expires和Cache-Control Header

比如在javaweb里面,我们可以使用代码设置强缓存
还可以通过java代码设置不启用强缓存

nginx和apache作为专业的web服务器,都有专门的配置文件,可以配置expires和cache-control,这方面的知识,如果你对运维感兴趣的话,可以在百度上搜索nginx 设置 expires cache-control或 apache 设置 expires cache-control 都能找到不少相关的文章。

由于在开发的时候不会专门去配置强缓存,而浏览器又默认会缓存图片,css和js等静态资源,所以开发环境下经常会因为强缓存导致资源没有及时更新而看不到最新的效果,解决这个问题的方法有很多,常用的有以下几种

处理缓存带来的问题

1、直接ctrl+f5,这个办法能解决页面直接引用的资源更新的问题

2、使用浏览器的隐私模式开发

3、如果用的是chrome,可以f12在network那里把缓存给禁掉(这是个非常有效的方法)


4、在开发阶段,给资源加上一个动态的参数,如css/index.css?v=0.0001,由于每次资源的修改都要更新引用的位置,同时修改参数的值,所以操作起来不是很方便,除非你是在动态页面比如jsp里开发就可以用服务器变量来解决(v=${sysRnd}),或者你能用一些前端的构建工具来处理这个参数修改的问题

5、如果资源引用的页面,被嵌入到了一个iframe里面,可以在iframe的区域右键单击重新加载该页面,以chrome为例



6、如果缓存问题出现在ajax请求中,最有效的解决办法就是ajax的请求地址追加随机数

7、还有一种情况就是动态设置iframe的src时,有可能也会因为缓存问题,导致看不到最新的效果,这时候在要设置的src后面添加随机数也能解决问题

8、如果你用的是grunt和gulp、webpack这种前端工具开发,通过它们的插件比如grunt-contrib-connect来启动一个静态服务器,则完全不用担心开发阶段的资源更新问题,因为在这个静态服务器下的所有资源返回的respone header中,cache-control始终被设置为不缓存


四、强缓存的应用


强缓存是前端性能优化最有力的工具,没有之一,对于有大量静态资源的网页,一定要利用强缓存,提高响应速度。通常的做法是,为这些静态资源全部配置一个超时时间超长的Expires或Cache-Control,这样用户在访问网页时,只会在第一次加载时从服务器请求静态资源,其它时候只要缓存没有失效并且用户没有强制刷新的条件下都会从自己的缓存中加载,比如前面提到过的京东首页缓存的资源,它的缓存过期时间都设置到了2026年





然而这种缓存配置方式会带来一个新的问题,就是发布时资源更新的问题,比如某一张图片,在用户访问第一个版本的时候已经缓存到了用户的电脑上,当网站发布新版本,替换了这个图片时,已经访问过第一个版本的用户由于缓存的设置,导致在默认的情况下不会请求服务器最新的图片资源,除非他清掉或禁用缓存或者强制刷新,否则就看不到最新的图片效果


这个问题已经有成熟的解决方案,具体内容可阅读知乎这篇文章详细了解:http://www.zhihu.com/question/20790576

文章提到的东西都属于理论上的解决方案,不过现在已经有很多前端工具能够实际地解决这个问题,由于每个工具涉及到的内容细节都有很多,本文没有办法一一深入介绍。有兴趣的可以去了解下grunt gulp webpack fis 还有edp这几个工具,基于这几个工具都能解决这个问题,尤其是fis和edp是百度推出的前端开发平台,有现成的文档可以参考:

http://fis.baidu.com/fis3/api/index.html

http://ecomfe.github.io/edp/doc/initialization/install/

强缓存还有一点需要注意的是,通常都是针对静态资源使用,动态资源需要慎用,除了服务端页面可以看作动态资源外,那些引用静态资源的html也可以看作是动态资源,如果这种html也被缓存,当这些html更新之后,可能就没有机制能够通知浏览器这些html有更新,尤其是前后端分离的应用里,页面都是纯html页面,每个访问地址可能都是直接访问html页面,这些页面通常不加强缓存,以保证浏览器访问这些页面时始终请求服务器最新的资源


五、协商缓存的原理


5.1 介绍

当浏览器对某个资源的请求没有命中强缓存,就会发一个请求到服务器,验证协商缓存是否命中,如果协商缓存命中,请求响应返回的http状态为304并且会显示一个Not Modified的字符串,比如你打开京东的首页,按f12打开开发者工具,再按f5刷新页面,查看network,可以看到有不少请求就是命中了协商缓存的



查看单个请求的Response Header,也能看到304的状态码和Not Modified的字符串,只要看到这个就可说明这个资源是命中了协商缓存,然后从客户端缓存中加载的,而不是服务器最新的资源



5.2 Last-Modified,If-Modified-Since控制协商缓存

1、浏览器第一次跟服务器请求一个资源,服务器在返回这个资源的同时,在respone的header加上Last-Modified的header,这个header表示这个资源在服务器上的最后修改时间




2、浏览器再次跟服务器请求这个资源时,在request的header上加上If-Modified-Since的header,这个header的值就是上一次请求时返回的Last-Modified的值


3、服务器再次收到资源请求时,根据浏览器传过来If-Modified-Since和资源在服务器上的最后修改时间判断资源是否有变化,如果没有变化则返回304 Not Modified,但是不会返回资源内容;如果有变化,就正常返回资源内容。当服务器返回304 Not Modified的响应时,response header中不会再添加Last-Modified的header,因为既然资源没有变化,那么Last-Modified也就不会改变,这是服务器返回304时的response header



4、浏览器收到304的响应后,就会从缓存中加载资源

5、如果协商缓存没有命中,浏览器直接从服务器加载资源时,Last-Modified Header在重新加载的时候会被更新,下次请求时,If-Modified-Since会启用上次返回的Last-Modified值

【Last-Modified,If-Modified-Since】都是根据服务器时间返回的header,一般来说,在没有调整服务器时间和篡改客户端缓存的情况下,这两个header配合起来管理协商缓存是非常可靠的,但是有时候也会服务器上资源其实有变化,但是最后修改时间却没有变化的情况,而这种问题又很不容易被定位出来,而当这种情况出现的时候,就会影响协商缓存的可靠性。所以就有了另外一对header来管理协商缓存,这对header就是【ETag、If-None-Match】。它们的缓存管理的方式是

5.3 ETag、If-None-Match控制协商缓存

1、浏览器第一次跟服务器请求一个资源,服务器在返回这个资源的同时,在respone的header加上ETag的header,这个header是服务器根据当前请求的资源生成的一个唯一标识,这个唯一标识是一个字符串,只要资源有变化这个串就不同,跟最后修改时间没有关系,所以能很好的补充Last-Modified的问题



2、浏览器再次跟服务器请求这个资源时,在request的header上加上If-None-Match的header,这个header的值就是上一次请求时返回的ETag的值




3、服务器再次收到资源请求时,根据浏览器传过来If-None-Match和然后再根据资源生成一个新的ETag,如果这两个值相同就说明资源没有变化,否则就是有变化;如果没有变化则返回304 Not Modified,但是不会返回资源内容;如果有变化,就正常返回资源内容。与Last-Modified不一样的是,当服务器返回304 Not Modified的响应时,由于ETag重新生成过,response header中还会把这个ETag返回,即使这个ETag跟之前的没有变化




4、浏览器收到304的响应后,就会从缓存中加载资源。

六、协商缓存的管理


协商缓存跟强缓存不一样,强缓存不发请求到服务器,所以有时候资源更新了浏览器还不知道,但是协商缓存会发请求到服务器,所以资源是否更新,服务器肯定知道。大部分web服务器都默认开启协商缓存,而且是同时启用【Last-Modified,If-Modified-Since】和【ETag、If-None-Match】,比如apache:





如果没有协商缓存,每个到服务器的请求,就都得返回资源内容,这样服务器的性能会极差。


【Last-Modified,If-Modified-Since】和【ETag、If-None-Match】一般都是同时启用,这是为了处理Last-Modified不可靠的情况。

有一种场景需要注意

分布式系统里多台机器间文件的Last-Modified必须保持一致,以免负载均衡到不同机器导致比对失败;

分布式系统尽量关闭掉ETag(每台机器生成的ETag都会不一样);

京东页面的资源请求,返回的repsones header就只有Last-Modified,没有ETag:



协商缓存需要配合强缓存使用,你看前面这个截图中,除了Last-Modified这个header,还有强缓存的相关header,因为如果不启用强缓存的话,协商缓存根本没有意义


七、相关浏览器行为对缓存的影响


如果资源已经被浏览器缓存下来,在缓存失效之前,再次请求时,默认会先检查是否命中强缓存,如果强缓存命中则直接读取缓存,如果强缓存没有命中则发请求到服务器检查是否命中协商缓存,如果协商缓存命中,则告诉浏览器还是可以从缓存读取,否则才从服务器返回最新的资源。这是默认的处理方式,这个方式可能被浏览器的行为改变:

1、当ctrl+f5强制刷新网页时,直接从服务器加载,跳过强缓存和协商缓存;

2、当f5刷新网页时,跳过强缓存,但是会检查协商缓存