You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

14 KiB

25 | 过期缓存:如何防止缓存被流量打穿?

你好,我是陶辉。

这一讲我们将对一直零散介绍的缓存做个全面的总结,同时讨论如何解决缓存被流量打穿的场景。

在分布式系统中缓存无处不在。比如浏览器会缓存用户CookieCDN会缓存图片负载均衡会缓存TLS的握手信息Redis会缓存用户的sessionMySQL会缓存select查询出的行数据HTTP/2会用动态表缓存传输过的HTTP头部TCP Socket Buffer会缓存TCP报文Page Cache会缓存磁盘IOCPU会缓存主存上的数据等等。

只要系统间的访问速度有较大差异缓存就能提升性能。如果你不清楚缓存的存在两个组件间重合的缓存就会带来不必要的复杂性同时还增大了数据不一致引发错误的概率。比如MySQL为避免自身缓存与Page Cache的重合就使用直接IO绕过了磁盘高速缓存。

缓存提升性能的幅度不只取决于存储介质的速度还取决于缓存命中率。为了提高命中率缓存会基于时间、空间两个维度更新数据。在时间上可以采用LRU、FIFO等算法淘汰数据而在空间上则可以预读、合并连续的数据。如果只是简单地选择最流行的缓存管理算法就很容易忽略业务特性从而导致缓存性能的下降。

在分布式系统中,缓存服务会为上游应用挡住许多流量。如果只是简单的基于定时器淘汰缓存,一旦热点数据在缓存中失效,超载的流量会立刻打垮上游应用,导致系统不可用。

这一讲我会系统地介绍缓存及其数据变更策略同时会以Nginx为例介绍过期缓存的用法。

缓存是最有效的性能提升工具

在计算机体系中,各类硬件的访问速度天差地别。比如:

  • CPU访问缓存的耗时在10纳秒左右访问内存的时延则翻了10倍
  • 如果访问SSD固态磁盘时间还要再翻个1000倍达到100微秒
  • 如果访问机械硬盘对随机小IO的访问要再翻个100倍时延接近10毫秒
  • 如果跨越网络访问时延更要受制于主机之间的物理距离。比如杭州到伦敦相距9200公里ping时延接近200毫秒。当然网络传输的可靠性低很多一旦报文丢失TCP还需要至少1秒钟才能完成报文重传。

可见最快的CPU缓存与最慢的网络传输有1亿倍的速度差距一旦高速、低速硬件直接互相访问前者就会被拖慢运行速度。因此我们会使用高速的存储介质创建缓冲区,通过预处理、批处理以及缓冲数据的反复命中,提升系统的整体性能。

不只是硬件层面软件设计对访问速度的影响更大。比如对关系数据库的非索引列做条件查询时间复杂度是O(N)而对Memcached做Key/Value查询时间复杂度则是O(1)所以在海量数据下两者的性能差距远高于硬件。因此RabbitMQ、Kafka这样的消息服务也会充当高速、低速应用间的缓存。

如果两个实体之间的访问时延差距过大,还可以通过多级缓存,逐级降低访问速度差,提升整体性能。比如[第1讲] 我们介绍过CPU三级缓存每级缓存越靠近CPU速度越快容量也越小以此缓解CPU频率与主存的速度差提升CPU的运行效率。

再比如下图的Web场景中浏览器的本地缓存、操作系统内核中的TCP缓冲区参见[第11讲]、负载均衡中的TLS握手缓存、应用服务中的HTTP响应缓存、MySQL中的查询缓存等每一级缓存都缓解了上下游间不均衡的访问速度通过缩短访问路径降低了请求时延通过异步访问、批量处理提升了系统效率。当然缓存使用了简单的Key/Value结构因此可以用哈希表、查找树等容器做索引这也提升了访问速度。

从系统底层到业务高层缓存都大有用武之地。比如在Django这个Python Web Server中既可以使用视图缓存将动态的HTTP响应缓存下来

from django.views.decorators.cache import cache_page

@cache_page(60 * 15)
def my_view(request):
    ...

也可以使用django-cachealot 这样的中间件将所有SQL查询结果缓存起来:

INSTALLED_APPS = [
  ...
  'cachalot',
  ...
]

还可以在更细的粒度上使用Cache API中的get、set等函数将较为耗时的运算结果存放在缓存中

cache.set('online_user_count', counts, 3600)
user_count = cache.get('online_user_count')

这些缓存的应用场景大相径庭,但数据的更新方式却很相似,下面我们来看看缓存是基于哪些原理来更新数据的。

缓存数据的更新方式

缓存的存储容量往往小于原始数据集,这有许多原因,比如:

  • 缓存使用了速度更快的存储介质,而这类硬件的单位容量更昂贵,因此从经济原因上只能选择更小的存储容量;
  • 负载均衡可以将上游服务的动态响应转换为静态缓存,从时间维度上看,上游响应是无限的,这样负载均衡的缓存容量就一定会不足;
  • 即使桌面主机的磁盘容量达到了TB级但浏览器要对用户访问的所有站点做缓存就不可能缓存一个站点上的全部资源在一对多的空间维度下缓存一样是稀缺资源。

因此,我们必须保证在有限的缓存空间内,只存放会被多次访问的热点数据,通过提高命中率来提升系统性能。要完成这个目标,必须精心设计向缓存中添加哪些数据,缓存溢出时淘汰出哪些冷数据。我们先来看前者。

通常,缓存数据的添加或者更新,都是由用户请求触发的,这往往可以带来更高的命中率。比如,当读请求完成后,将读出的内容放入缓存,基于时间局部性原理,它有很高的概率被后续的读请求命中。[第15讲] 介绍过的HTTP缓存就采用了这种机制。

对于磁盘操作,还可以基于空间局部性原理,采用预读算法添加缓存数据(参考[第4讲] 介绍的PageCache。比如当我们统计出连续两次读IO的操作范围也是连续的就可以判断这是一个顺序读IO如果这个读IO获取32KB的数据就可以在这次磁盘中多读出128KB的数据放在缓存这会带来2个收益

  • 首先通过减少定位时间提高了磁盘工作效率。机械磁盘容量大价格低它的顺序读写速度由磁盘旋转速度与存储密度决定通常可以达到100MB/s左右。然而由于机械转速难以提高服务器磁盘的转速也只有10000转/s磁头定位与旋转延迟大约消耗了8毫秒因此对于绝大部分时间花在磁头定位上的随机小IO比如4KB读写吞吐量只有几MB。
  • 其次,当后续的读请求命中提前读入缓存的数据时,请求时延会大幅度降低,这提升了用户体验。

而且并不是只有单机进程才能使用预读算法。比如公有云中的云磁盘之所以可以实时地挂载到任意虚拟机上就是因为它实际存放在类似HDFS这样的分布式文件系统中。因此云服务会在宿主物理机的内存中缓存虚拟机发出的读写IO由于网络传输的成本更高所以预读效果也更好。

写请求也可以更新缓存,你可以参考[第20讲] 我们介绍过write through和write back方式。其中write back采用异步调用回写数据能通过批量处理提升性能。比如Linux在合并IO的同时也会像电梯运行一样每次使磁头仅向一个方向旋转写入数据提升机械磁盘的工作效率因此得名为电梯调度算法。

说完数据的添加我们再来看2种最常见的缓存淘汰算法。

首先来看FIFO(First In, First Out)先入先出淘汰算法。[第16讲] 介绍的HTTP/2动态表会将HTTP/2连接上首次出现的HTTP头部缓存在客户端、服务器的内存中。由于它们基于相同的规则生成所以拥有相同的动态表序号。这样传输1-2个字节的表序号要比传输几十个字节的头部划算得多。当内存容量超过SETTINGS_HEADER_TABLE_SIZE阈值时会基于FIFO算法将最早缓存的HTTP头部淘汰出动态表。

再比如[第14讲] 介绍的TLS握手很耗时所以我们可以将密钥缓存在客户端、服务器中等再次建立连接时通过session ID迅速恢复TLS会话。由于内存有限服务器必须及时淘汰过期的密钥其中Nginx也是采用FIFO队列淘汰TLS缓存的。

其次LRU(Less Recently Used)也是最常用的淘汰算法比如Redis服务就通过它来淘汰数据OpenResty在进程间共享数据的shared_dict在达到共享内存最大值后也会通过LRU算法淘汰数据。LRU通常使用双向队列实现时间复杂度为O(1)),队首是最近访问的元素,队尾就是最少访问、即将淘汰的元素。当访问了队列中某个元素时,可以将其移动到队首。当缓存溢出需要淘汰元素时,直接删除队尾元素,如下所示:

以上我只谈了缓存容量到达上限后的淘汰策略为了避免缓存与源数据不一致在传输成本高昂的分布式系统中通常会基于过期时间来淘汰缓存。比如HTTP响应中的Cache-Control、Expires或者Last-Modified头部都会用来设置定时器响应过期后会被淘汰出缓存。然而一旦热点数据被淘汰出缓存那么来自用户的流量就会穿透缓存到达应用服务。由于缓存服务性能远大于应用服务过大的流量很可能会将应用压垮。因此过期缓存并不能简单地淘汰下面我们以Nginx为例看看如何利用过期缓存提升系统的可用性。

Nginx是如何防止流量打穿缓存的

当热点缓存淘汰后大量的并发请求会同时回源上游应用其实这是不必要的。比如下图中Nginx的合并回源功能开启后Nginx会将多个并发请求合并为1条回源请求并锁住所有的客户端请求直到回源请求返回后才会更新缓存同时向所有客户端返回响应。由于Nginx可以支持C10M级别的并发连接因此可以很轻松地锁住这些并发请求降低应用服务的负载。

启用合并回源功能很简单只需要在nginx.conf中添加下面这条指令即可

proxy_cache_lock on;

当1个请求回源更新时其余请求将会默认等待如果5秒可由proxy_cache_lock_timeout修改后缓存依旧未完成更新这些请求也会回源但它们的响应不会用于更新缓存。同时第1个回源请求也有时间限制如果到达5秒可由proxy_cache_lock_age修改后未获得响应就会放行其他并发请求回源更新缓存。

如果Nginx上的缓存已经过期未超过proxy_cache_path中inactive时间窗口的过期缓存并不会被删除且上游服务此时已不可用那有没有办法可以通过Nginx提供降级服务呢所谓“服务降级”是指部分服务出现故障后通过有策略地放弃一些可用性来保障核心服务的运行这也是[第20讲] BASE理论中Basically Available的实践。如果Nginx上持有着过期的缓存那就可以通过牺牲一致性向用户返回过期缓存以保障基本的可用性。比如下图中Nginx会直接将过期缓存返回给客户端同时也会一直试图更新缓存。

开启过期缓存功能也很简单添加下面2行指令即可

proxy_cache_use_stale updating;
proxy_cache_background_update on;

当然上面两条Nginx指令只是开启了最基本的功能如果你想进一步了解它们的用法可以观看《Nginx核心知识100讲》第102课

小结

这一讲我们系统地总结了缓存的工作原理以及Nginx解决缓存穿透问题的方案。

当组件间的访问速度差距很大时,直接访问会降低整体性能,在二者之间添加更快的缓存是常用的解决方案。根据时间局部性原理,将请求结果放入缓存,会有很大概率被再次命中,而根据空间局部性原理,可以将相邻的内容预取至缓存中,这样既能通过批处理提升效率,也能降低后续请求的时延。

由于缓存容量小于原始数据集因此需要将命中概率较低的数据及时淘汰出去。其中最常用的淘汰算法是FIFO与LRU它们执行的时间复杂度都是O(1),效率很高。

由于缓存服务的性能远大于上游应用一旦大流量穿透失效的缓存到达上游后就可能压垮应用。Nginx作为HTTP缓存使用时可以打开合并回源功能减轻上游压力。在上游应用宕机后还可以使用过期缓存为用户提供降级服务。

思考题

最后,留给你一道讨论题。缓存并不总是在提高性能,回想一下,在你的实践中,有哪些情况是增加了缓存服务,但并没有提高系统性能的?原因又是什么?欢迎你在留言区与大家一起分享。

感谢阅读,如果你觉得这节课让你加深了对缓存的理解,也欢迎把它分享给你的朋友。