万字长文聊缓存(上)

原创
01/05 07:00
阅读数 0

深入解析SpringMVC核心原理:从手写简易版MVC框架开始(SmartMvc) : https://github.com/silently9527/SmartMvc

IDEA多线程文件下载插件: https://github.com/silently9527/FastDownloadIdeaPlugin

公众号:贝塔学JAVA

摘要

缓存的目的是为了提高系统的访问速度,让数据更加接近于使用者,通常也是提升性能的常用手段。缓存在生活中其实也是无处不在,比如物流系统,他们基本上在各地都有分仓库,如果本地仓库有数据,那么送货的速度就会很快;CPU读取数据也采用了缓存,寄存器->高速缓存->内存->硬盘/网络;我们经常使用的maven仓库也同样有本地仓库和远程仓库。现阶段缓存的使用场景也越来越多,比如:浏览器缓存、反向代理层缓存、应用层缓存、数据库查询缓存、分布式集中缓存。

本文我们就先从浏览器缓存和Nginx缓存开始聊起。

浏览器缓存

浏览器缓存是指当我们去访问一个网站或者Http服务的时候,服务器可以设置Http的响应头信息,其中如果设置缓存相关的头信息,那么浏览器就会缓存这些数据,下次再访问这些数据的时候就直接从浏览器缓存中获取或者是只需要去服务器中校验下缓存时候有效,可以减少浏览器与服务器之间的网络时间的开销以及节省带宽。

Htpp相关的知识,欢迎去参观 《面试篇》Http协议

Cache-Control

该命令是通用首部字段(请求首部和响应首部都可以使用),用于控制缓存的工作机制,该命令参数稍多,常用的参数:

  • no-cache: 表示不需要缓存该资源
  • max-age(秒): 缓存的最大有效时间,当max-age=0时,表示不需要缓存

Expires

控制资源失效的日期,当浏览器接受到Expires之后,浏览器都会使用本地的缓存,在过期日期之后才会向务器发送请求;如果服务器同时在响应头中也指定了Cache-Controlmax-age指令时,浏览器会优先处理max-age。如果服务器不想要让浏览器对资源缓存,可以把Expires和首部字段Date设置相同的值

Last-Modified / If-Modified-Since

Last-Modified

Last-Modified 用于指明资源最终被修改的时间。配合If-Modified-Since一起使用可以通过时间对缓存是否有效进行校验;后面实战会使用到这种方式。

If-Modified-Since

如果请求头中If-Modified-Since的日期早于请求资源的更新日期,那么服务会进行处理,返回最新的资源;如果If-Modified-Since指定的日期之后请求的资源都未更新过,那么服务不会处理请求并返回304 Mot Modified的响应,表示缓存的文件有效可以继续使用。

实战事例

使用SpringMVC做缓存的测试代码:

@ResponseBody
@RequestMapping("/http/cache")
public ResponseEntity<String> cache(@RequestHeader(value = "If-Modified-Since", required = false)
                                            String ifModifiedSinceStr) throws ParseException {

    DateFormat dateFormat = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss 'GMT'", Locale.US);
    Date ifModifiedSince = dateFormat.parse(ifModifiedSinceStr);

    long lastModifiedDate = getLastModifiedDate(ifModifiedSince);//获取文档最后更新时间
    long now = System.currentTimeMillis();
    int maxAge = 30; //数据在浏览器端缓存30秒

    //判断文档是否被修改过
    if (Objects.nonNull(ifModifiedSince) && ifModifiedSince.getTime() == lastModifiedDate) {
        HttpHeaders headers = new HttpHeaders();
        headers.add("Date", dateFormat.format(new Date(now))); //设置当前时间
        headers.add("Expires", dateFormat.format(new Date(now + maxAge * 1000))); //设置过期时间
        headers.add("Cache-Control""max-age=" + maxAge);
        return new ResponseEntity<>(headers, HttpStatus.NOT_MODIFIED);
    }

    //文档已经被修改过
    HttpHeaders headers = new HttpHeaders();
    headers.add("Date", dateFormat.format(new Date(now))); //设置当前时间
    headers.add("Last-Modified", dateFormat.format(new Date(lastModifiedDate))); //设置最近被修改的日期
    headers.add("Expires", dateFormat.format(new Date(now + maxAge * 1000))); //设置过期时间
    headers.add("Cache-Control""max-age=" + maxAge);

    String responseBody = JSON.toJSONString(ImmutableMap.of("website""https://silently9527.cn"));
    return new ResponseEntity<>(responseBody, headers, HttpStatus.OK);

}

//获取文档的最后更新时间,方便测试,每15秒换一次;去掉毫秒值
private long getLastModifiedDate(Date ifModifiedSince) {
    long now = System.currentTimeMillis();

    if (Objects.isNull(ifModifiedSince)) {
        return now;
    }

    long seconds = (now - ifModifiedSince.getTime()) / 1000;
    if (seconds > 15) {
        return now;
    }
    return ifModifiedSince.getTime();
}
  1. 当第一次访问 http://localhost:8080/http/cache的时候,我们可以看到如下的响应头信息:

前面我们已提到了Cache-Control的优先级高于Expires,实际的项目中我们可以同时使用,或者只使用Cache-ControlExpires的值通常情况下都是系统当前时间+缓存过期时间

  1. 当我们在15秒之内再次访问 http://localhost:8080/http/cache会看到如下的请求头:

此时发送到服务器端的头信息If-Modified-Since就是上次请求服务器返回的Last-Modified,浏览器会拿这个时间去和服务器校验内容是否发送了变化,由于我们后台程序在15秒之内都表示没有修改过内容,所以得到了如下的响应头信息

响应的状态码304,表示服务器告诉浏览器,你的缓存是有效的可以继续使用。

If-None-Match / ETag

If-None-Match

请求首部字段If-None-Match传输给服务器的值是服务器返回的ETag值,只有当服务器上请求资源的ETag值与If-None-Match不一致时,服务器才去处理该请求。

ETag

响应首部字段ETag能够告知客服端响应实体的标识,它是一种可将资源以字符串的形式做唯一标识的方式。服务器可以为每份资源指定一个ETag值。当资源被更新时,ETag的值也会被更新。通常生成ETag值的算法使用的是md5。

  • 强ETag值:不论实体发生了多么细微的变化都会改变其值
  • 弱ETag值:只用于提示资源是否相同,只有当资源发送了根本上的变化,ETag才会被改变。使用弱ETag值需要在前面添加 W/
ETag: W/"etag-xxxx"

通常建议选择弱ETag值,因为大多数时候我们都会在代理层开启gzip压缩,弱ETag可以验证压缩和不压缩的实体,而强ETag值要求响应实体字节必须完全一致。

实战事例

@ResponseBody
@RequestMapping("/http/etag")
public ResponseEntity<String> etag(@RequestHeader(value = "If-None-Match", required = false)
                                           String ifNoneMatch) throws ParseException {
    long now = System.currentTimeMillis();
    int maxAge = 30; //数据在浏览器端缓存30秒

    String responseBody = JSON.toJSONString(ImmutableMap.of("website""https://silently9527.cn"));
    String etag = "W/\"" + MD5Encoder.encode(responseBody.getBytes()) + "\""; //弱ETag值

    if (etag.equals(ifNoneMatch)) {
        return new ResponseEntity<>(HttpStatus.NOT_MODIFIED);
    }

    DateFormat dateFormat = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss 'GMT'", Locale.US);
    HttpHeaders headers = new HttpHeaders();
    headers.add("ETag", etag);
    headers.add("Date", dateFormat.format(new Date(now))); //设置当前时间
    headers.add("Cache-Control""max-age=" + maxAge);

    return new ResponseEntity<>(responseBody, headers, HttpStatus.OK);
}

ETag是用于发送到服务器端进行内容变更验证的,第一次请求http://localhost:8080/http/etag,获取到的响应头信息:

在30秒之内,我们再次刷新页面,可以看到如下的请求头信息:

这里的If-None-Match就是上一次请求服务返回的ETag值,服务器校验If-None-Match值与ETag值相等,所以返回了304告诉浏览器缓存可以用。

ETag与Last-Modified两者应该如何选择?

通过上面的两个事例我们可以看出ETag需要服务器先查询出需要响应的内容,然后计算出ETag值,再与浏览器请求头中If-None-Match来比较觉得是否需要返回数据,对于服务器来说仅仅是节省了带宽,原本应该服务器调用后端服务查询的信息依然没有被省掉;而Last-Modified通过时间的比较,如果内容没有更新,服务器不需要调用后端服务查询出响应数据,不仅节省了服务器的带宽也降低了后端服务的压力;

对比之后得出结论:「通常来说为了降低后端服务的压力ETag适用于图片/js/css等静态资源,而类似用户详情信息需要调用后端服务的数据适合使用Last-Modified来处理」

Nginx

通常情况下我们都会使用到Nginx来做反向代理服务器,我们可以通过缓冲、缓存来对Nginx进行调优,本篇我们就从这两个方面来聊聊Nginx调优

缓冲

默认情况下,Nginx在返回响应给客户端之前会尽可能快的从上游服务器获取数据,Nginx会尽可能的将上有服务器返回的数据缓冲到本地,然后一次性的全部返回给客户端,如果每次从上游服务器返回的数据都需要写入到磁盘中,那么Nginx的性能肯定会降低;所以我们需要根据实际情况对Nginx的缓存做优化。

  • proxy_buffer_size: 设置Nginx缓冲区的大小,用来存储upstream端响应的header。
  • proxy_buffering: 启用代理内容缓冲,当该功能禁用时,代理一接收到上游服务器的返回就立即同步的发送给客户端, proxy_max_temp_file_size被设置为0;通过设置 proxy_buffering为on, proxy_max_temp_file_size为0 可以确保代理的过程中不适用磁盘,只是用缓冲区; 开启后 proxy_buffersproxy_busy_buffers_size参数才会起作用
  • proxy_buffers: 设置响应上游服务器的缓存数量和大小,当一个缓冲区占满后会申请开启下一个缓冲区,直到缓冲区数量到达设置的最大值
  • proxy_busy_buffers_size: 在从上游服务器读取响应数据时分配给发送到客户端响应的缓冲区大小,所有连接共用 proxy_busy_buffers_size设置的缓冲区大小,一旦 proxy_buffers设置的buffer被写入,直到buffer里面的数据被完整的传输完(传输到客户端),这个buffer将会一直处 在busy状态,我们不能对这个buffer进行任何别的操作。所有处在busy状态的buffer size加起来不能超过 proxy_busy_buffers_size, 所以 proxy_busy_buffers_size是用来控制同时传输到客户端的buffer数量的;典型是设置成 proxy_buffers的两倍。

「Nginx代理缓冲的设置都是作用到每一个请求的」,想要设置缓冲区的大小到最佳状态,需要测量出经过反向代理服务器器的平均请求数和响应的大小;proxy_buffers指令的默认值 8个 4KB 或者 8个 8KB(具体依赖于操作系统),假如我们的服务器是1G,这台服务器只运行了Nginx服务,那么排除到操作系统的内存使用,保守估计Nginx能够使用的内存是768M

  1. 每个活动的连接使用缓冲内存:8个4KB = 8 * 4 * 1024 = 32768字节
  2. 系统可使用的内存大小768M: 768 * 1024 * 1024 = 805306368字节
  3. 所以Nginx能够同时处理的连接数:805306368 / 32768 = 24576

经过我们的粗略估计,1G的服务器只运行Nginx大概可以同时处理24576个连接。

假如我们测量和发现经过反向代理服务器响应的平均数据大小是 900KB , 而默认的 8个4KB的缓冲区是无法满足的,所以我们可以调整大小

http {
    proxy_buffers 30 32k;
}

这样设置之后每次请求可以达到最快的响应,但是同时处理的连接数减少了,(768 * 1024 * 1024) / (30 * 32 * 1024)=819个活动连接;

如果我们系统的并发数不是太高,我们可以将proxy_buffers缓冲区的个数下调,设置稍大的proxy_busy_buffers_size加大往客户端发送的缓冲区,以确保Nginx在传输的过程中能够把从上游服务器读取到的数据全部写入到缓冲区中。

http {
    proxy_buffers 10 32k;
    proxy_busy_buffers_size 64k;
}

缓存

Nignx除了可以缓冲上游服务器的响应达到快速返回给客户端,它还可以是实现响应的缓存,通过上图我们可以看到

  • 1A: 一个请求到达Nginx,先从缓存中尝试获取
  • 1B: 缓存不存在直接去上游服务器获取数据
  • 1C: 上游服务器返回响应,Nginx把响应放入到缓存
  • 1D: 把响应返回到客户端
  • 2A: 另一个请求达到Nginx, 到缓存中查找
  • 2B: 缓存中有对应的数据,直接返回,不去上游服务器获取数据

Nginx的缓存常用配置:

  • proxy_cache_path: 放置缓存响应和共享的目录。 levels 设置缓存文件目录层次, levels=1:2 表示两级目录,最多三层,其中 1 表示一级目录使用一位16进制作为目录名,2 表示二级目录使用两位16进制作为目录名,如果文件都存放在一个目录中,文件量大了会导致文件访问变慢。 keys_zone设置缓存名字和共享内存大小, inactive 当被放入到缓存后如果不被访问的最大存活时间, max_size设置缓存的最大空间
  • proxy_cache: 定义响应应该存放到哪个缓存区中( keys_zone设置的名字)
  • proxy_cache_key: 设置缓存使用的Key, 默认是完整的访问URL,可以自己根据实际情况设置
  • proxy_cache_lock: 当多个客户端同时访问一下URL时,如果开启了这个配置,那么只会有一个客户端会去上游服务器获取响应,获取完成后放入到缓存中,其他的客户端会等待从缓存中获取。
  • proxy_cache_lock_timeout: 启用了 proxy_cache_lock之后,如果第一个请求超过了 proxy_cache_lock_timeout设置的时间默认是5s,那么所有等待的请求会同时到上游服务器去获取数据,可能会导致后端压力增大。
  • proxy_cache_min_uses: 设置资源被请求多少次后才会被缓存
  • proxy_cache_use_stale: 在访问上游服务器发生错误时,返回已经过期的数据给客户端;当缓存内容对于过期时间不敏感,可以选择采用这种方式
  • proxy_cache_valid: 为不同响应状态码设置缓存时间。如果设置 proxy_cache_valid 5s,那么所有的状态码都会被缓存。

设置所有的响应被缓存后最大不被访问的存活时间6小时,缓存的大小设置为1g,缓存的有效期是1天,配置如下:

http {
    proxy_cache_path /export/cache/proxy_cache keys_zone=CACHE:10m levels=1:2 inactive=6h max_size=1g;
    server {
        location / {
            proxy_cache CACHE; //指定存放响应到CACHE这个缓存中
            proxy_cache_valid 1d; //所有的响应状态码都被缓存1d
            proxy_pass: http://upstream;
        }
    }
}

如果当前响应中设置了Set-Cookie头信息,那么当前的响应不会被缓存,可以通过使用proxy_ignore_headers来忽略头信息以达到缓存

proxy_ignore_headers Set-Cookie

如果这样做了,我们需要把cookie中的值作为proxy_cache_key的一部分,防止同一个URL响应的数据不同导致缓存数据被覆盖,返回到客户端错误的数据

proxy_cache_key "$host$request_uri$cookie_user"

注意,这种情况还是有问题,因为在缓存的key中添加cookie信息,那么可能导致公共资源被缓存多份导致浪费空间;要解决这个问题我们可以把不同的资源分开配置,比如:

server {
    proxy_ignore_headers Set-Cookie;
    
    location /img {
        proxy_cache_key "$host$request_uri";
        proxy_pass http://upstream;
    }
    
    
    location / {
        proxy_cache_key "$host$request_uri$cookie_user";
        proxy_pass http://upstream;
    }
}

清理缓存

虽然我们设置了缓存加快了响应,但是有时候会遇到缓存错误的请求,通常我们需要为自己开一个后面,方便发现问题之后通过手动的方式及时的清理掉缓存。Nginx可以考虑使用ngx_cache_purge模块进行缓存清理。

location ~ /purge/.* {
    allow 127.0.0.1;
    deny all;
    proxy_cache_purge cache_one $host$1$is_args$args
}

「该方法要限制访问权限」proxy_cache_purge缓存清理的模块,cache_one指定的key_zone,$host$1$is_args$args 指定的生成缓存key的参数

存储

如果有大的静态文件,这些静态文件基本不会别修改,那么我们就可以不用给它设置缓存的有效期,让Nginx直接存储这些文件直接。如果上游服务器修改了这些文件,那么可以单独提供一个程序把对应的静态文件删除。

http {
    proxy_temp_path /var/www/tmp;
    
    server {
        root /var/www/data;
        
        location /img {
            error_page 404 = @store
        }
        
        location @store {
            internal;
            proxy_store on;
            proxy_store_access group:r all:r;
            proxy_pass http://upstream;
        }
    }
}

请求首先会去/img中查找文件,如果不存在再去上游服务器查找;internal 指令用于指定只允许来自本地 Nginx 的内部调用,来自外部的访问会直接返回 404 not found 状态。proxy_store表示需要把从上游服务器返回的文件存储到 /var/www/dataproxy_store_access设置访问权限

总结

  • Cache-ControlExpires 设置资源缓存的有效期
  • 使用 Last-Modified / If-Modified-Since判断缓存是否有效
  • 使用 If-None-Match / ETag判断缓存是否有效
  • 通过配置Nginx缓冲区大小对Nginx调优
  • 使用Nginx缓存加快请求响应速度

如何加快请求响应的速度,本篇我们主要围绕着Http缓存和Nignx反向代理两个方面来聊了缓存,你以为这样就完了吗,不!「下一篇我们将从应用程序的维度来聊聊缓存」

写到最后 

文中或许会存在或多或少的不足、错误之处,有建议或者意见也非常欢迎大家提意见。

最后,「白嫖不好,创作不易」,希望朋友们可以「点赞」,因为这就是我分享的全部动力来源🙏

本文分享自微信公众号 - 贝塔学JAVA(betajava)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

展开阅读全文
打赏
0
0 收藏
分享
加载中
更多评论
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部