使用|使用 NGINX 和 NGINX Plus 实现智能高效的字节范围缓存

作者: F5的欧文加勒特 产品管理高级总监2016 年 1 月 21 日
正确部署后,缓存是加速 Web 内容的最快捷方式之一。缓存不仅使内容更靠近最终用户(从而减少延迟),还减少了对上游源服务器的请求数量,从而提高了容量并降低了带宽成本。
AWS 等全球分布式云平台和 Route 53 等基于 DNS 的全球负载平衡系统的可用性使您可以创建自己的全球内容交付网络 (CDN)。
在本文中,我们将了解NGINX 开源和NGINX Plus如何缓存和交付使用字节范围请求访问的流量。一个常见的用例是 HTML5 MP4 视频,其中请求使用字节范围来实现特技播放(跳过和搜索)视频播放。我们的目标是实现支持字节范围的视频传输缓存解决方案,并最大限度地减少用户延迟和上游网络流量。
编者:在 NGINX Plus R8 中引入了逐个切片填充缓存切片中讨论的缓存切片方法。有关该版本中所有新功能的概述,请参阅我们博客: NGINX Plus R8 发版。
我们的测试框架 我们需要一个简单的、可重现的测试框架来研究使用 NGINX 进行缓存的替代策略。
一个简单、可重复的测试平台,用于研究 NGINX 中的缓存策略
【使用|使用 NGINX 和 NGINX Plus 实现智能高效的字节范围缓存】使用|使用 NGINX 和 NGINX Plus 实现智能高效的字节范围缓存
文章图片

我们从一个 10-MB 的测试文件开始,其中包含每 10 个字节的字节偏移量,以便我们可以验证字节范围请求是否正常工作:

origin$ perl -e 'foreach $i ( 0 ... 1024*1024-1 ) { printf "%09d\n", $i*10 }' > 10Mb.txt

文件中的第一行如下:
origin$ head 10Mb.txt 000000000 000000010 000000020 000000030 000000040 000000050 000000060 000000070 000000080 000000090

对文件中的中间字节范围(500,000 到 500,009)的 curl 请求会返回预期的字节范围:
client$ curl -r 500000-500009 http://origin/10Mb.txt 000500000

现在让我们为源服务器和 NGINX 代理缓存之间的单个连接添加 1MB/s 的带宽限制:
origin# tc qdisc add dev eth1 handle 1: root htb default 11 origin# tc class add dev eth1 parent 1: classid 1:1 htb rate 1000Mbps origin# tc class add dev eth1 parent 1:1 classid 1:11 htb rate 1Mbps 为了检查延迟是否按预期工作,我们直接从源服务器检索整个文件:cache$ time curl -o /tmp/foo http://origin/10Mb.txt % Total% Received% XferdAverageSpeedTime... DloadUploadTotal... 100 10.0M100 10.0M00933k00:00:10...... TimeTimeCurrent ... SpentLeftSpeed ... 0:00:10--:--:--933k real0m10.993s user0m0.460s sys0m0.127s

交付文件需要将近 11 秒,这是对边缘缓存性能的合理模拟,边缘缓存通过带宽有限的 WAN 网络从源服务器拉取大文件。
NGINX 的默认字节范围缓存行为
一旦 NGINX 缓存了整个资源,它会直接从磁盘上的缓存副本中为字节范围的请求提供服务。
当内容没有被缓存时会发生什么?当 NGINX 收到对未缓存内容的字节范围请求时,它会从源服务器请求整个文件(不是字节范围),并开始将响应流式传输到临时存储。
一旦 NGINX 收到满足客户端原始字节范围请求所需的数据,NGINX 就会将数据发送给客户端。在后台,NGINX 继续将完整响应流式传输到临时存储中的文件。传输完成后,NGINX 将文件移动到缓存中。
我们可以通过以下简单的 NGINX 配置很容易地演示默认行为:
proxy_cache_path /tmp/mycache keys_zone=mycache:10m; server { listen 80; proxy_cache mycache; location / { proxy_pass http://origin:80; } }

我们首先清空缓存:
cache # rm –rf /tmp/mycache/*

然后我们请求10Mb.txt的中间十个字节:
client$ time curl -r 5000000-5000009 http://cache/10Mb.txt 005000000 real0m5.352s user0m0.007s sys0m0.002s

NGINX 向源服务器发送整个10Mb.txt文件的请求,并开始将其加载到缓存中。一旦请求的字节范围被缓存,NGINX 就会将其交付给客户端。正如time命令所报告的,这发生在 5 秒多一点的时间内。
在我们之前的测试中,传送整个文件只需要 10 多秒,这意味着在将中间字节范围传送到客户端之后,检索和缓存10Mb.txt的全部内容还需要大约 5 秒。虚拟服务器的访问日志记录了完整文件中 10,486,039 字节 (10 MB) 的传输,状态码为 200:
192.168.56.10 - - [08/Dec/2015:12:04:02 -0800] "GET /10Mb.txt HTTP/1.0" 200 10486039 "-" "-" "curl/7.35.0"

如果我们curl在整个文件被缓存后重复请求,响应是立即的,因为 NGINX 从缓存中提供请求的字节范围。
但是,这种基本配置(以及由此产生的默认行为)存在问题。如果我们第二次请求相同的字节范围,在它被缓存之后但在整个文件被添加到缓存之前,NGINX 向源服务器发送一个对整个文件的新请求,并开始一个新的缓存填充操作。我们可以使用以下命令演示此行为:
client$ while true ; do time curl -r 5000000-5000009 http://dev/10Mb.txt ; done

对源服务器的每个新请求都会触发一个新的缓存填充操作,并且缓存不会“稳定下来”,直到缓存填充操作完成而没有其他操作正在进行。
想象一下用户在视频文件发布后立即开始观看的场景。如果缓存填充操作需要 30 秒(例如),但额外请求之间的延迟小于此值,则缓存可能永远不会填充,NGINX 将继续向源服务器发送越来越多的整个文件请求。
NGINX 提供了两种缓存配置,可以有效解决这个问题:
缓存锁 ——使用此配置,在由第一个字节范围请求触发的缓存填充操作期间,NGINX 将任何后续字节范围请求直接转发到源服务器。缓存填充操作完成后,NGINX 为缓存中的字节范围和整个文件的所有请求提供服务。
缓存切片 ——通过在 NGINX Plus R8 和 NGINX 开源 1.9.8 中引入的这种策略,NGINX 将文件分割成可以快速检索的更小的子范围,并根据需要从源服务器请求每个子范围。
对单个缓存填充操作使用缓存锁
以下配置在收到第一个字节范围请求时立即触发缓存填充,并在缓存填充操作正在进行时将所有其他请求转发到源服务器:
proxy_cache_path /tmp/mycache keys_zone=mycache:10m; server { listen 80; proxy_cache mycache; proxy_cache_valid 200 600s; proxy_cache_lock on; # Immediately forward requests to the origin if we are filling the cache proxy_cache_lock_timeout 0s; # Set the 'age' to a value larger than the expected fill time proxy_cache_lock_age 200s; proxy_cache_use_stale updating; location / { proxy_pass http://origin:80; } }

proxy_cache_lock on – 设置缓存锁。当 NGINX 收到文件的第一个字节范围请求时,它会从源服务器请求整个文件并启动缓存填充操作。NGINX 不会将后续的字节范围请求转换为对整个文件的请求或启动新的缓存填充操作。相反,它将请求排队,直到第一个缓存填充操作完成或锁定超时。
proxy_cache_lock_timeout – 控制缓存锁定多长时间(默认为 5 秒)。当超时到期时,NGINX 将每个排队的请求未经修改地转发到源服务器(作为保留标头的字节范围请求Range,而不是作为对整个文件的请求),并且不缓存源服务器返回的响应。
在我们使用10Mb.txt进行测试的情况下,缓存填充操作可能会花费大量时间,因此我们将锁定超时设置为 0(零)秒,因为没有必要将请求排队。NGINX 会立即将文件的任何字节范围请求转发到源服务器,直到缓存填充操作完成。
proxy_cache_lock_age – 设置缓存填充操作的最后期限。如果操作没有在指定时间内完成,NGINX 会再向源服务器转发一个请求。它总是需要比预期的缓存填充时间更长,因此我们将其从默认的 5 秒增加到 200 秒。
proxy_cache_use_stale updating – 如果 NGINX 正在更新资源,则告诉 NGINX 立即使用资源的当前缓存版本。这对第一个请求(触发缓存更新)没有影响,但会加速对客户端后续请求的响应。
我们重复我们的测试,请求10Mb.txt的中间字节范围。该文件没有被缓存,并且与之前的测试一样,time 表明 NGINX 需要 5 秒多一点的时间才能交付请求的字节范围(回想一下,网络的吞吐量限制为 1 Mb/s):
client # time curl -r 5000000-5000009 http://cache/10Mb.txt 005000000 real0m5.422s user0m0.007s sys0m0.003s

由于缓存锁定,在缓存被填充时,后续对字节范围的请求几乎立即得到满足。NGINX 将这些请求转发到源服务器,而不尝试从缓存中满足它们:
client # time curl -r 5000000-5000009 http://cache/10Mb.txt 005000000 real0m0.042s user0m0.004s sys0m0.004s

在源服务器访问日志的以下摘录中,带有状态代码的条目206确认源服务器在缓存填充操作完成期间正在处理字节范围请求。(我们使用该log_format指令将Range请求标头包含在日志条目中,以识别哪些请求已修改,哪些未修改。)
最后一行,带有状态码200,对应于第一个字节范围请求的完成。NGINX 将此修改为对整个文件的请求并触发缓存填充操作。
192.168.56.10 - - [08/Dec/2015:12:18:51 -0800] "GET /10Mb.txt HTTP/1.0" 206 343 "-" "bytes=5000000-5000009" "curl/7.35.0" 192.168.56.10 - - [08/Dec/2015:12:18:52 -0800] "GET /10Mb.txt HTTP/1.0" 206 343 "-" "bytes=5000000-5000009" "curl/7.35.0" 192.168.56.10 - - [08/Dec/2015:12:18:53 -0800] "GET /10Mb.txt HTTP/1.0" 206 343 "-" "bytes=5000000-5000009" "curl/7.35.0" 192.168.56.10 - - [08/Dec/2015:12:18:54 -0800] "GET /10Mb.txt HTTP/1.0" 206 343 "-" "bytes=5000000-5000009" "curl/7.35.0" 192.168.56.10 - - [08/Dec/2015:12:18:55 -0800] "GET /10Mb.txt HTTP/1.0" 206 343 "-" "bytes=5000000-5000009" "curl/7.35.0" 192.168.56.10 - - [08/Dec/2015:12:18:46 -0800] "GET /10Mb.txt HTTP/1.0" 200 10486039 "-" "-" "curl/7.35.0"

当我们在整个文件被缓存后重复测试时,NGINX 会从缓存中提供任何进一步的字节范围请求:
client # time curl -r 5000000-5000009 http://cache/10Mb.txt 005000000 real0m0.012s user0m0.000s sys0m0.002s

使用缓存锁可以优化缓存填充操作,但代价是在缓存填充期间将所有后续用户流量发送到源服务器。
逐片填充缓存
NGINX Plus R8 和 NGINX 开源 1.9.8 中引入的Cache Slice模块提供了另一种填充缓存的方法,当带宽受到严重限制并且缓存填充操作需要很长时间时,这种方法更有效。
编辑器 – 有关 NGINX Plus R8 中所有新功能的概述,请参阅我们博客上的 NGINX Plus R8 。
使用缓存切片方法,NGINX 将文件分成更小的段,并在需要时请求每个段。这些段在缓存中累积,并且通过将一个或多个段的适当部分传递给客户端来满足对资源的请求。对大字节范围(或者实际上是整个文件)的请求会触发每个所需段的子请求,这些段在从源服务器到达时被缓存。一旦所有的段都被缓存了,NGINX 就会组装来自它们的响应并将其发送给客户端。
NGINX 缓存切片详解
使用|使用 NGINX 和 NGINX Plus 实现智能高效的字节范围缓存
文章图片

启用 NGINX 缓存切片的请求处理
在下面的配置片段中,该slice指令(在 NGINX Plus R8 和 NGINX 开源 1.9.8 中引入)告诉 NGINX 将每个文件分段为 1-MB 片段。
在使用slice指令时,我们还必须将$slice_range变量添加到 proxy_cache_key 指令中以区分文件的片段,并且我们必须替换Range请求中的标头,以便 NGINX 从源服务器请求适当的字节范围。我们将请求升级为HTTP/1.1因为 HTTP/1.0 不支持字节范围请求。
proxy_cache_path /tmp/mycache keys_zone=mycache:10m; server { listen 80; proxy_cache mycache; slice1m; proxy_cache_key$host$uri$is_args$args$slice_range; proxy_set_headerRange $slice_range; proxy_http_version 1.1; proxy_cache_valid200 206 1h; location / { proxy_pass http://origin:80; } }

和以前一样,我们请求10Mb.txt中的中间字节范围:
client$ time curl -r 5000000-5000009 http://cache/10Mb.txt 005000000 real0m0.977s user0m0.000s sys0m0.007s

NGINX 通过请求单个 1-MB 文件段(字节范围 4194304–5242879)来满足请求,其中包含请求的字节范围 5000000–5000009。
KEY: www.example.com/10Mb.txtbytes=4194304-5242879 HTTP/1.1 206 Partial Content Date: Tue, 08 Dec 2015 19:30:33 GMT Server: Apache/2.4.7 (Ubuntu) Last-Modified: Tue, 14 Jul 2015 08:29:12 GMT ETag: "a00000-51ad1a207accc" Accept-Ranges: bytes Content-Length: 1048576 Vary: Accept-Encoding Content-Range: bytes 4194304-5242879/10485760

如果一个字节范围请求跨越多个段,NGINX 会请求所有需要的段(尚未缓存),然后从缓存的段中组装字节范围响应。
Cache Slice 模块是为交付 HTML5 视频而开发的,它使用字节范围请求将内容伪流到浏览器。它非常适用于初始缓存填充操作可能需要几分钟的视频资源,因为带宽受到限制,并且文件在发布后不会更改。
选择最佳切片大小
将切片大小设置为足够小的值,以便可以快速传输每个段(例如,在一两秒内)。这将减少多个请求触发上述持续更新行为的可能性。
另一方面,切片大小可能太小。如果对整个文件的请求同时触发数千个小请求,则开销可能会很高,从而导致内存和文件描述符使用过多以及磁盘活动更多。
此外,由于缓存切片模块将资源拆分为独立的段,因此一旦资源被缓存,就无法更改资源。ETag每次从源端接收到一个段时,该模块都会验证资源的标头,如果ETag发生更改,NGINX 会中止事务,因为底层缓存版本现在已损坏。我们建议您仅对发布后不会更改的大文件(例如视频文件)使用缓存切片。
概括 如果您使用字节范围交付大量资源,缓存锁定和缓存切片技术都可以最大限度地减少网络流量并为您的用户提供出色的内容交付性能。
如果缓存填充操作可以快速执行,并且您可以在填充过程中接受到源服务器的流量峰值,请使用缓存锁定技术。
如果缓存填充操作非常慢且内容稳定(不更改),请使用新的缓存切片技术。

    推荐阅读