亿级流量网站架构读后记录三、HTTP缓存篇

HTTP缓存

Last-Modified

public ResponseEntity<String> cache(
            //浏览器验证文档内容是否为修改时传入的Last-Modified
            @RequestHeader(value = "If-Modified-Since", required = false)Date ifModifiedSince
    ) throws Exception{
        DateFormat gmtDateFormat = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss 'GMT'", Locale.US);

        //文档最后修改时间(去掉毫秒, 利用/取整) 为方便测试,每10s生成一个新的
        long lastModifiedMillis = getLastModified() / 1000 * 1000;
        //当前系统时间 (去掉毫秒)
        long now = System.currentTimeMillis() / 1000 * 1000;
        //文档可以在浏览器/proxy上缓存多久(单位:秒)
        long maxAge = 20;

        //判断内容是否修改了,此处使用等值判断
        if(ifModifiedSince != null
                && ifModifiedSince.getTime() == lastModifiedMillis){
            MultiValueMap<String, String> headers = new HttpHeaders();
            //当前时间
            headers.add("Date", gmtDateFormat.format(new Date(now)));
            //过期时间 http 1.0 支持
            headers.add("Expires", gmtDateFormat.format(new Date(now + maxAge * 1000)));
            //文档生存时间 http 1.1 支持
            headers.add("Cache-Control", "max-age=" + maxAge);
            return new ResponseEntity<String>(headers, HttpStatus.NOT_MODIFIED);
        }

        String body = "<a href=''>点击访问当前链接</a>";
        MultiValueMap<String, String> headers = new HttpHeaders();
        //当前时间
        headers.add("Date", gmtDateFormat.format(new Date(now)));
        //文档修改时间
        headers.add("Last-Modified", gmtDateFormat.format(new Date(lastModifiedMillis)));
        //过期时间 http 1.0支持
        headers.add("Expires", gmtDateFormat.format(new Date(now + maxAge * 1000)));
        //文档生存时间 http 1.1 支持
        headers.add("Cache-Control", "max-age=" + maxAge);
        return new ResponseEntity<String>(body, headers, HttpStatus.OK);
    }

    Cache<String, Long> lastModifiedCache = CacheBuilder.newBuilder()
            .expireAfterWrite(10, TimeUnit.SECONDS).build();

    public long getLastModified() throws ExecutionException{
        return lastModifiedCache.get("lastModified", () -> {
            return System.currentTimeMillis();
        });
    }
  • 首次访问

    ​ 首次访问 http://localhost:9080/cache,将得到如下 响应头

    响应状态码200标识请求内容成功,另外,有如下几个缓存控制参数。

    • Last-Modified:表示文档的最后修改时间,当去服务器验证时会用到这个时间。

    • Expires:http/1.0规范定义,标识文档在浏览器中的过期时间,当缓存内容时间超过这个时间,则需要重新去服务器获取最新的内容。

    • Cache-Control:http/1.1规范定义,表示浏览器缓存控制,max-age=20表示文档可以在浏览器中缓存20秒。

    根据规范定义Cache-Control优先级高于Expires。实际使用时可以两个都用,或仅适用Cache-Control就可以了(比如京东的活动页sale.jd.com)。一般情况下Expires=当前系统时间 + 缓存时间毫秒值。

    F5刷新

    ​ 按F5刷新后,将看到浏览器发送如下 请求头

    ​ 此时发送时有一个If-Modified-Since请求头 ,其值是上次请求响应中的Last-Modified,即浏览器会用这个时间去服务器端验证内容是否发生了变更。接着收到如下响应信息:响应状态码为304,表示服务器通知浏览器缓存内容没有变化,直接使用缓存内容展示吧。

    Ctrl+F5强制刷新

    ​ 如果你想强制从服务器端获取最新的内容,则可以按 Ctrl + F5 组合键。浏览器在请求时不会带上 If-Modified-Since,但会带上 Cache-Control:no-cache 和 Pragma:no-cache,这是为了通知服务器端提供一份最新的内容。

    from cache

    ​ 当我们刷新,或者从地址栏刷新时,都会去服务端验证内容是否发生了变更。那什么情况不回去服务端验证呢:答案都是,从A页面跳转到A页面或从A页面跳转到B页面时;还有从历史记录前进或者后腿时,也会走 from cache。

    ​ 自行模拟从A页面跳转到A页面。此时,如果内容还在缓存时间之内,则直接从浏览器获取内容,而不会去服务器端验证。

    Age

    ​ 一般用于缓存代理层(如CDN)。大家在访问京东一些页面时,会发现一个Age响应头,强制刷新后会发现不断变化。这表示次内容在缓存代理层从创建到现在生存了多长时间。

    Vary

    ​ 一般用于缓存代理层(如CDN),如响应头列表,如”Vary:Accept-Encoding”、”Vary:User-Agent”,主要用于通知缓存服务器对于相同URL有着不同版本的相应。比如压缩版本和非压缩版本。缓存服务器应该根据Vary头来缓存不同版本的内容,如指定响应头为”Vary:Accept-Encoding”,则缓存代理层需根据”Accept-Encoding”请求头来判断不同版本缓存内容,”Accept-Encoding:gzip”请求头版本、无Accept-Encoding请求头版本。

    Via

    ​ 一般用于代理层(如CDN),标识访问到最终内容前经过了哪些代理层,用的什么协议,代理层是否命中缓存等。通过它可以进行一些故障诊断。

    ETag

    ​ 用于发送到服务端进行内容验证的。

    ​ 老的HTTP标准里有个Last-Modified+If-Modified-Since表明URL对象是否改变。Etag也具有这种功能,因为对象改变也造成Etag改变,并且它的控制更加准确。Etag有两种用法 If-Match/If-None-Match,就是如果服务器的对象和客户端的对象ID(不)匹配才执行。这里的If-Match/If-None- Match都能一次提交多个Etag。If-Match可以在Etag未改变时断线重传。If-None-Match可以刷新对象(在有新的Etag时返回);

    ​ Etag中有种Weak Tag,值为 W/”xxxxx”。他声明Tag是弱匹配的,只能做模糊匹配,在差异达到一定阈值时才起作用。比如内容的 gzip 版本 和 非 gzip 版本可以使用弱匹配验证;而强匹配字节必须完全一致(gzip和非gzip情况是不一样的),因此建议首先使用弱匹配。Nginx在生成ETag时使用的算法是 Last-Modified + Content-Length。

    ​ 另外,还可以使用html Meta标签控制浏览器缓存,但是,对代理层缓存无效,因此不建议使用。如果Last-Modified和ETag同时使用时,浏览器会同时发送If-Modified-Since和If-None-Match,必须两个都验证通过后才能返回304,Nginx就是这样做的。

    ​ 作用: Etag 主要为了解决 Last-Modified 无法解决的一些问题。

    ​ 1、一些文件也许会周期性的更改,但是他的内容并不改变(仅仅改变的修改时间),这个时候我们并不希望客户端认为这个文件被修改了,而重新GET;

    ​ 2、某些文件修改非常频繁,比如在秒以下的时间内进行修改,(比方说1s内修改了N次),If-Modified-Since能检查到的粒度是s级的,这种修改无法判断(或者说UNIX记录MTIME只能精确到秒)

    ​ 3、某些服务器不能精确的得到文件的最后修改时间;

    ​ 为此,HTTP/1.1引入了 Etag(Entity Tags).Etag仅仅是一个和文件相关的标记,可以是一个版本标记,比如说v1.0.0或者说”2e681a-6-5d044840”这么一串看起来很神秘的编码。但是HTTP/1.1标准并没有规定Etag的内容是什么或者说要怎么实现,唯一规定的是Etag需要放在”“内。

Apache中Etag实现/Etag

Apache首先判断是不是弱Etag,这个留在下面讲。如果不是,进入第二种情况:
强Etag根据配置文件中的配置来设置Etag值,默认的Apache的FileEtag设置为:FileEtag INode Mtime Size,
也就是根据这三个属性来生成Etag值,他们之间通过一些算法来实现,并输出成hex的格式,相邻属性之间用-分隔,比如:
Etag”2e681a-6-5d044840”.
这里面的三个段,分别代表了INode,MTime,Size根据算法算出的值的hex格式,(如果在这里看到了非Hex里面的字符(也就是0-f),那你可能看见神了:))
当然,可以改变Apache的FileEtag设置,比如设置成FileEtagSize,那么得到的Etag可能为:Etag”6”. 总之,设置了几个段,Etag值就有几个段。(不要误以为Etag就是固定的3段式)
说明:
这里说的都是Apache2.2里面的Etag实现,因为HTTP/1.1并没有规定Etag必须是什么样的实现或者格式,因此,也可以修改或者完全编写自己的算法得到Etag,比如”2e681a65d044840”,客户端会记住并缓存下这个Etag(Windows里面保存在哪里,下次访问的时候直接拿这个值去和服务器生成的Etag对比。
注意:
不管怎么样的算法,在服务器端都要进行计算,计算就有开销,会带来性能损失。因此为了榨干这一点点性能,不少网站完全把Etag禁用了(比如Yahoo!),这其实不符合HTTP/1.1的规定,因为HTTP/1.1总是鼓励服务器尽可能的开启Etag。

弱校验(弱Etag)/Etag

重新考虑前面提到的3个问题:
问题1、一些文件也许会周期性的更改,但是他的内容并不改变(仅仅改变的修改时间),这个时候我们并不希望客户端认为这个文件被修改了,而重新GET;
解决办法:如果使用强Etag,每次得会要求重新GET页面,如果使用Etag,比方说设置成 File Etag Size 等,就可以忽略 MTime 造成的 Last-Modified 时间修改从而影响了 If-Modified-Since(IMS) 这个校验了。这点和弱Etag无关。

问题2、某些文件修改非常频繁,比如在秒以下的时间内进行修改,(比方说1s内修改了N次),If-Modified-Since能检查到的粒度是s级的,这种修改无法判断(或者说UNIX记录MTIME只能精确到秒)
解决办法:如果是这种情况,Apache会自动判断请求时间和修改时间之间的差值,如果小于1s,Apache会认为这个文件在这1秒内可能会再次被修改,因此生成一个弱Etag(WeakEtag),这个Etag仅仅基于MTime来生成,因此MTime只能精确到s,所以1s内生成的Etag总是一样,这样就避免了使用强Etag造成的1s内频繁的刷新Cache的情况。(貌似不用Etag,仅仅使用Last-Modified就可以解决,但是这针对的仅仅是修改超级频繁的情况,很多文件可能同时也使用强Etag验证)。弱Etag以W/开始,比如:W/”2e681a”

问题3、某些服务器不能精确的得到文件的最后修改时间;

解决办法:生成Etag,因为Etag可以综合Inode,MTime和Size,可以避免这个问题

HttpClient客户端缓存

​ HttpClient4.3版本开始提供Http/1.1兼容的客户端缓存,没有1.0的。可以把该层看成浏览器缓存。HttpClient通过职责链模式来支持可插拔的组件结构。可以直接开箱使用。

<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpclient-cache</artifactId>
    <version>4.5.2</version>
</dependency>

​ CacheConfig主要进行如下几个方面的配置:

  • maxCacheEntries:缓存条目数量,当缓存的数量超了会进行清除。

  • maxObjectSize:每个缓存对象的最大大小,超过该大小的内容将不会被缓存,主要目的是防止出现缓存过大的内容。

  • asynchronousWorkersCore/asynchronousWorkersMax/revalidationQueueSize:异步更新缓存内容线程池配置。

    ​ 此外,HttpCacheStroage用于指定HTTP响应内容使用什么存储器来存储,BasicHttpCacheStorage表示放在内存中存储(使用LinkedHashMap实现了最简单的LRU算法)。默认还提供Ehcache和Memcached存储实现。其BasicHttpCacheStorage没有基于时间的过期策略,建议实际使用时根据需要选择如Ehcache或者自己扩展一个实现(比如,扩展后支持多级缓存:堆内存->本地磁盘->分布式)。

    ​ 缓存状态有HIT(响应命中,返回缓存内容)、MISS(缓存未命中,响应来自上游服务器)、VALIDATED(缓存不新鲜需要重新到上游服务器验证,且验证后返回缓存中的响应)、MODULE_RESPONSE(缓存直接生产的响应,比如,请求头“Cache-Contol:only-if-cached”标识只使用缓存内容,但是如缓存没有,则生成一个504响应,此时缓存状态为MODULE_RESPONSE)。

​ HttpClient请求流程如下:

  • 检查HTTP请求是否符合HTTP/1.1规范,如果不符合,则会进行修正(比如,请求头Cache-Control同时配置了max-age和no-cache)。
  • 清除该请求中的无效请求头。
  • 检查该请求是否可以使用缓存内容,如果不能则发送请求到上游服务器获取新的内容。
  • 如果该请求可以使用缓存内容作为响应,则尝试读取缓存中的缓存内容。如果读取失败,则同样发送请求到上游服务器获取最新的内容。
  • 如果缓存的响应内容可以使用,则会构建一个包含ByteArrayEntity的BasicHttpResponse对象。否则,会向上游服务器发出重新验证缓存内容的请求。
  • 如果缓存的响应内容向上游服务器验证失败,那么会重新向上游服务器发出一次不含缓存头的请求来获取最新的内容。

​ HttpClient 响应流程如下:

  • 检查收到的响应是否兼容HTTP/1.1,如果不兼容,则会让其符合规范。
  • 检查响应是否可以缓存,如果可以,则会从响应中读取内容体,并缓存起来。
  • 如果响应数据太大,超出了配置的大小,则直接返回响应不进行缓存。

Nginx缓存

​ nginx提供了expires、etag、if-modified-since指令来实现浏览器缓存控制。

  • expires

    ​ 如果我们使用Nginx作为静态资源服务器,那么可以使用expires进行缓存控制。

    location /img {
      alias /export/img/;
      expires 1d;
    }

    ​ 对于静态资源会自动添加ETag,可以通过配置etag off指令禁止生产ETag。如果是静态文件,那么Last-Modified值为文件的最后修改时间。Expires是根据当前服务器系统时间算出来的。

  • if-modified-since

    ​ 此指令用于指定Nginx如何对服务端的Last-Modified和浏览器端的if-modified-since时间进行比较,默认的”if_modified_since exact” 表示精确匹配,也可以使用”if_modified_since _before” 表示只要文件的最后修改时间早于或等于浏览器端的if-modified-since时间,就返回304。

  • nginx proxy_pass

    ​ 作为反向代理时,请求会先进入Nginx,然后Nginx将请求转发给后端应用。

    首先配置upstream

    upstream backend_tomcat{
      server 192.168.61.1:9080 max_fails=10 fail_timeout=10s weight=5;
    }

    接着配置location

    location = /cache{
      proxy_pass http://backend_tomcat/cache$is_args$args;
    }

    ​ 此时,只是做了相关的转发(负载均衡),并没有对请求和响应做什么处理。

    假设需要对后端返回的过期时间进行调整,可以添加Expires指令到location。

    location = /cache{
      proxy_pass http://backend_tomcat/cache$is_args$args;
      expires 5s;
    }

    ​ 即使我们更改了缓存过期头,但Nginx自己没有对这些内容做代理层缓存,每次请求还是要到后端验证。假设在过期时间内,这些验证在Nginx这一层进行就可以了,不需要到后端验证,这样可以减少后端很大的压力。流程如下:

    ​ 1.浏览器发起请求,首先到Nginx,根据URL在Nginx本地查找是否有代理层本地缓存。

    ​ 2.Nginx没有找到本地缓存,则访问后端获取最新的文档,并放入Nginx本地缓存,返回200状态码和最新的文档给浏览器。

    ​ 3.Nginx找到本地缓存,首先验证文档是否过期。如果过期,则访问后端获取最新的文档,并放入Nginx本地缓存,返回200状态码和最新的文档给浏览器;如果文档没有过期,即if-modified-since与缓存文档的last-modified匹配,则返回304状态码给浏览器。

    ​ 内容不需要访问后端,即不需要后端动态计算、渲染等,直接Nginx代理层就把内容返回,速度更快—内容越接近于用户速度越快。

    ​ CDN技术:用户首先访问全国各地的CDN节点(根节点),如果没有命中,则会回源到中央Nginx集群,该集群做二级缓存,如果没有命中,则返回回源到后端应用集群。

  • Nginx代理层缓存

    1.HTTP模块配置

    proxy_buffering           on;
    proxy_buffer_size     4k;
    proxy_buffers         512 4k;
    proxy_busy_buffers_size   64k;
    proxy_cache_path      /export/cache/proxy_cache levels=1:2           keys_zone=cache:512m inactive=5m max_size=8g use_temp_path=off;
    
    #proxy timeout
    
    proxy_connect_timeout 3s;
    proxy_read_timeout        5s;
    proxy_send_timeout        5s;

    ​ proxy_cache_path配置项说明:

    • levels=1:2 表示创建两级目录结构,缓存目录的第一级目录是1个字符,第二季目录是2个字符,比如/export/cache/proxy_cache/7/3c/,如果将所有文件放在一级目录下的话,文件量很大,会导致文件访问变慢。
    • keys_zone=cache:512m 设置存储所有缓存key和相关信息的共享内存区,1m大约能存储8000个key。
    • inactive=5m 指定被缓存的内容多久不被访问将从缓存中移除,已保证内容的新鲜,默认10分钟。
    • max_size=8g 最大缓存阈值,cache manager进程会监控最大缓存大小,当缓存达到该阈值时,该进程将从缓存中移除最近最少访问的内容。
    • use_temp_path 如果为on,则内容首先被写入临时文件proxy_temp_path,然后重命名到proxy_cache_path指定的目录;如果设置为off,则内容直接被写入到proxy_cache_path指定的目录,如果需要cache建议off。
  • proxy_cache配置

    location = /cache{
    //指定使用哪个共享内存区存储缓存信息
      proxy_cache cache;
      //设置缓存使用的key,默认为完整的访问URL,根据实际情况设置缓存key
      proxy_cache_key $scheme$proxy_host$request_uri;
      //为不同的响应状态码设置缓存时间。如果不设置状态码,则200、301、302都缓存,是最低等级生效的缓存设置时间。如果响应头包含Cache-Control:private/no-cache/no-store、Set-Cookie,或者只有一个Vary响应头且其值为*,则响应内容不会缓存。
      proxy_cache_valid 200 5s;
    
      proxy_pass http://backend_tomcat/cache$is_args$args;
      //在响应头中添加缓存命中的状态 HIT/MISS/EXPIRED/UPDATEING/STALE/REVALIDATED/BYPASS
      add_header cache-status $upstream_cache_status;
    }

    ​ 另外,proxy_cache_min_uses: 用于控制请求多少次后响应才被缓存。默认为1,根据数据是否比较集中,存储空间等调整;

    ​ proxy_no_cache 用于配置什么情况下响应不被缓存。比如配置”proxy_no_cache $args_nocache”,如果带的nocache参数值至少有一个不为空或者为0,则响应不被缓存。

    ​ proxy_cache_bypass: 控制什么情况不适用缓存,直接到后端获取最新内容。

    ​ proxy_cache_use_stale: 当对缓存的过期时间不敏感,或者后端服务处问题时,即使缓存的内容不新鲜也总比返回错误给用户强(类似于托底),此时可以配置该参数,如”proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504”,即如果出现超时、后端连接出错、500、502、503错误时,则即使缓存内容已过期也先返回给用户,此时$upstream_cache_status 为STALE。还有一个updating表示缓存已过期但正在被别的Nginx Worker进程更新,只是先返回了过期内容,此时缓存状态为UPDATING。

    ​ proxy_cache_revalidate: 当缓存过期后,如果开启了,则会发出一次if-modified-since或if-none-match条件请求,如果后端返回304,则状态为REVALIDATED,可以节省宽带和写磁盘的次数。

    ​ proxy_cache_lock: 当多个客户端同时请求同一份内容时,如果开启,则只有一个请求被发送到后端。其它请求将等待其返回。当第一个请求超过了proxy_cache_lock_timeout超时时间默认5s,则其它请求将同时请求到后端来获取,且响应不会被缓存。

    ​ proxy_cache_lock_age: 如果在指定的时间内默认为5s,最后一个发送到后端进行新缓存构建的请求还没有完成,则下一个请求将被发送到后端来构建缓存。(因为超时之后返回的内容是不缓存的,需要下一次请求来构建响应缓存。)

缓存头总结

​ 总结下Cache-Control:

  • public:响应头,可共享缓存(客户端和代理服务器都可以缓存),响应可以被缓存。
  • private:响应头,可私有缓存(客户端可以缓存,代理服务器不能缓存),比如用户私有内容,不能共享。
  • no-cache:请求头使用时表示需要回溯验证,响应头使用时表示允许缓存者缓存响应,但是,使用时必须回源验证,所以此处叫no-cache并不是很好。
  • no-store:请求和响应禁止缓存。
  • max-age:缓存的保鲜期和Expires类似,根据该值校验缓存是否新鲜。
  • max-stale:缓存的最大陈旧时间,如果缓存不新鲜但还在该最大陈旧时间内,则可以返回陈旧的内容。
  • min-fresh:缓存的最小保鲜期,请求时使用(保鲜期-当前Age)< min-fresh判断内容是否新鲜。要求缓存服务器返回至少还未过指定时间的缓存资源。
  • must-revalidate:当缓存过了新鲜期后,必须回源重新验证;如果没有过期则可以使用缓存。no-cache不能使用缓存,必须回源验证。
  • proxy-revalidate:与must-revalidate类似,但是只对缓存代理服务器有效,客户端遇到需要回源重新验证。
  • stale-while-revalidate:请求时,表示在指定的时间内可以先返回陈旧的内容,后台进行重新验证(如异步验证)。
  • stale-if-error:请求时,表示在指定的时间内,当重新验证请求响应状态码为500、502、503、504时,可以使用陈旧内容。
  • only-if-cached:请求时,使用该头表示只从缓存获取响应,如果没有,则504 Gateway Timeout。

一些经验

  • 只缓存200状态码的响应,像302要根据实际场景来决定。
  • 有些页面不需要强一致,可以进行几秒钟的缓存。比如商品详情页展示的缓存,可以缓存几秒钟。短时间的不一致对于用户来说是没有影响的。
  • JS/CSS/image等一些内容缓存可以设置为很久,比如1个月甚至1年,通过在页面修改版本来控制过期。
  • 假设商品详情页异步加载的一些数据,使用last-modified进行过期控制,而服务端做了逻辑修改,但内容是没有修改的,即内容的最后修改时间没变。如果想过期这些异步加载的数据,则可以考虑在商品详情页添加异步加载数据的版本号,通过添加版本号来加载最新的数据,或者将last-Modified时间加1来解决,但这种情况下使用ETag是最好的选择。
  • 商品详情页异步加载的一些数据,可以考虑更长时间的缓存,比如1个月而不是几分钟。可以通过MQ将修改时间推送到商品详情页,从而实现按需过期数据。
  • 服务端考虑使用tmpfs内存文件系统缓存,SSD缓存,使用服务端负载均衡算法一致性哈希来提升缓存命中率。
  • 缓存key要合理设计。比如,去掉某些参数或排序参数,以保证代理层的缓存命中率;要有清理缓存的工具,出问题时可以快速清理掉问题key。
  • AB测试、个性化需求时,要禁用浏览器缓存,但要考虑服务器端缓存。
  • 为了偏于查找问题,一般会在响应头添加源服务器信息,以便出现问题时,知道哪台服务器有问题。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值