jinhailang / blog

技术博客:知其然,知其所以然
https://github.com/jinhailang/blog/issues
60 stars 6 forks source link

Nginx 缓存实践与实现讨论 #44

Open jinhailang opened 5 years ago

jinhailang commented 5 years ago

Nginx 缓存实践与实现讨论

根据近来使用 Nginx 缓存的实践,做一个总结,以及阐述了一般的缓存系统实现的关键和问题。

Nginx 缓存是比较传统的单机本地缓存(需要说明的是,根据 KEY 计算缓存路径的方式是一样的,也就是说多个 Nginx 进程是可以共享相同缓存资源的),比较简单,容易理解。但是,通过 Nginx 缓存的使用与理解,能够窥探到业界通用的缓存系统的设计与实现方法。Nginx 会对上游返回的 Response 进行缓存,存放在特定目录下(磁盘),Nginx 会启动一个专门的 Worker 进程对缓存进行管理,周期性的删除过期缓存等。当一个请求进来,首先会判断是否需要使用缓存(proxy_cache_bypass),如果需要使用缓存,则直接将请求代理到上游;否则根据 proxy_cache_key 计算出 KEY(32 位 Hash 值),根据这个值从对应的目录(可设置多级目录)下获取相同名称的资源。需要注意的是,磁盘上缓存的是整个响应,包括响应头和响应体,而且,此时,Nginx 不会再对请求头和响应头进行判断,只是直接读进内存,返回响应到客户端,这点也很重要。实例:

Path: /tmp/c/5a/3a1796923cc2b162a5e7f89dc4cf95ac

▒q-\▒▒▒▒▒▒▒▒yq-\▒▒go▒
KEY: httphttpbin.org/cache/60
HTTP/1.1 200 OK
Connection: close
Server: gunicorn/19.9.0
Date: Thu, 03 Jan 2019 02:20:41 GMT
Content-Type: application/json
Content-Length: 219
Cache-Control: public, max-age=60
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Via: 1.1 vegur

{
  "args": {},
  "headers": {
    "Accept": "*/*",
    "Connection": "close",
    "Host": "httpbin.org",
    "User-Agent": "curl/7.52.1"
  },
  "origin": "103.126.92.86",
  "url": "http://httpbin.org/cache/60"
}

proxy_cache_key

缓存 KEY 的计算是缓存应用中非常关键的部分,一般的自然想到的就是根据请求 URL 来计算,即: $scheme$proxy_host$uri$is_args$args

但是,这会导致几个问题:

根据标准的 HTTP 协议,服务端(源站)应该在响应头设置 Vary 字段,来显示指定这些影响缓存的头部字段,缓存系统需要支持这种协商机制,对不同的客户端请求返回不同的资源。但是,Vary 这个字段使用的比较少,甚至很多程序员都不清楚这个字段,而且,很多缓存系统也是不支持这个字段。

因此,KEY 的计算需要非常谨慎,最好的办法是根据具体的 Host 选择合适的字段来计算,即 proxy_cache_key 可配置化。一般为了简单快速实现,通用的方式主要有:

总之,通用实现的场景下,最重要的是,宁可多缓存几份或;者直接回源,也不能出现请求响应的资源不匹配的情况

proxy_pass

有些请求响应(大多数动态资源)是不能缓存的,那么 Nginx 怎么会知道哪些响应应该被缓存呢?根据 HTTP 协议, 响应头 Cache-ControlExpires 控制缓存失效时间,也即,Nginx 只会缓存这种显式指定了缓存失效时间的响应。

当然,在代理层是可以做很多事情的,比如,可以使用 add_header 来添加 Cache-Control 头,使得源站返回的响应能够被缓存,或者设置 proxy_no_cache 来强制不缓存某些响应。

proxy_cache_purge

缓存系统必不可少的一个功能就是需要支持外部直接刷新的接口,因为很多场景下,需要将被缓存的资源提前失效,这个时候一般是通过 API 接口直接刷新的,也即请求对应的 PURGE 方法。缓存刷新模块看似简单,但是,在大流量的场景下,可能还要支持批量刷新功能,比如大型网站的更新迭代,往往有大量的资源需要更新,CDN 厂商经常会遇到这类需求。要保证缓存能够被快速,准确和稳定的刷新,还是挺有挑战的。一般缓存失效,磁盘上对应的文件并不会立即直接被删除掉,因为读写磁盘代价较大,而且缓存的资源实在太多了,一般先会在内存标记,然后待缓存管理进程(线程)周期性的轮询删除,配置命令 proxy_cache_path 属性 inactive 就是指定删除周期的。

Nginx 中可以使用 proxy_cache_purge 命令来支持 PURGE 请求刷新对应缓存资源。

map $request_method $purge_method {
    PURGE   1;
    default 0;
}

server {
    ...
    location / {
        proxy_pass http://backend;
        proxy_cache cache_zone;
        proxy_cache_key $uri;
        proxy_cache_purge $purge_method;
    }
}

但是,这个命令只有商业版才会有,大部分开发者用的应该都是开源版。

This functionality is available as part of our commercial subscription.

小结

在整个架构中,缓存系统往往是比较容易引起问题的模块,除了自身的问题外,因为缓存的使用还有一个与上下游协商的过程,也可能出现外部服务不规范导致的问题,一般比较多的问题已就是缓存未及时刷新,导致客户端使用了旧资源,以及 404 等错误状态未启用缓存(或过滤)导致缓存穿透,缓存刷新,过期不合理,导致缓存雪崩等。一般的使用场景直接使用 Nginx 自带的缓存就够了,但是,对于比较复杂的场景,要自建缓存系统才行,在 CDN 中缓存系统尤为重要。

以下,是我在项目中的使用实例:

    proxy_cache_path /tmp levels=1:2 keys_zone=mcache:5m max_size=5g inactive=60m use_temp_path=off;

    ...

    location / {
        proxy_cache_key $scheme$proxy_host$uri$is_args$args$http_accept_encoding; # compatible with clients that do not support gzip.
        proxy_no_cache $cookie_nocache $arg_nocache$arg_comment;
        proxy_no_cache $http_pragma $http_authorization;
        proxy_cache_bypass $cookie_nocache $arg_nocache$arg_comment;
        proxy_cache_bypass $http_pragma $http_authorization;
        proxy_cache_bypass $http_cache_control;

        proxy_cache mcahe;
        proxy_pass http://test.com;
        proxy_set_header Host "myhost.com";
        add_header X-Cache $upstream_cache_status;
    }