Java笔记 ·

缓存小结(一)

何为缓存

缓存,是一种存储数据的组件,它的作用是让对数据的请求更快地返回,是一种常见的空间换时间的性能优化手段。

缓存不仅是指内存,凡是位于速度相差较大的两种硬件之间,用于协调两者数据传输速度差异的结构,均可称之为缓存。

缓存实例

TLB(Translation Lookaside Buffer)

计算机系统中常会借助一个叫做 TLB(Translation Lookaside Buffer)的组件来缓存最近转换过的虚拟地址和物理地址的映射,从而加快地址转换的速度。

TLB 就是一种缓存组件,缓存复杂运算的结果。

视频播放

视频平台上的短视频实际上是使用内置的网络播放器来完成的。网络播放器接收的是数据流,将数据下载下来之后经过分离音视频流,解码等流程后输出到外设设备上播放。

播放器中通常会设计一些缓存的组件,在未打开视频时缓存一部分视频数据,比如打开视频时,服务端可能一次会返回三个视频信息,我们在播放第一个视频的时候,播放器已经帮我们缓存了第二、三个视频的部分数据,这样在看第二个视频的时候就可以给用户“秒开”的感觉。

HTTP协议缓存

  • 当第一次请求静态的资源时,比如一张图片,服务端除了返回图片信息,在响应头里面还有一个“Etag”的字段。浏览器会缓存图片信息以及这个字段的值。
  • 当下一次再请求这个图片的时候,浏览器发起的请求头里面会有一个“If-None-Match”的字段,并且把缓存的“Etag”的值写进去发给服务端。
  • 服务端比对图片信息是否有变化,如果没有,则返回浏览器一个 304 的状态码,浏览器会继续使用缓存的图片信息。
  • 通过这种缓存协商的方式,可以减少网络传输的数据大小,从而提升页面展示的性能。

缓存与缓冲区

  • 缓存不仅是一种组件的名字,更是一种设计思想。可以提高低速设备的访问速度,或者减少复杂耗时的计算带来的性能问题
  • 缓冲区则是一块临时存储数据的区域,这些数据后面会被传输到其他设备上。用以弥补高速设备和低速设备通信时的速度差。

任何能够加速读请求的组件和设计方案都是缓存思想的体现。而这种加速通常是通过两种方式来实现:

  • 使用更快的介质
  • 缓存复杂运算的结果

缓存分类

日常开发中,常见的缓存主要就是 静态缓存分布式缓存热点本地缓存这三种。

静态缓存处在负载均衡层,分布式缓存处在应用层和数据库层之间,本地缓存处在应用层。我们需要将请求尽量挡在上层,因为越往下层,对于并发的承受能力越差。

缓存命中率是我们对于缓存最重要的一个监控项,越是热点的数据,缓存的命中率就越高。

静态缓存

静态缓存在 Web 1.0 时期是非常著名的,它一般通过生成 Velocity 模板或者静态 HTML 文件来实现静态缓存,在 Nginx 上部署静态缓存可以减少对于后台应用服务器的压力。

这种缓存仅能针对静态数据,面对动态数据无能为力。

分布式缓存

Memcached、Redis 是分布式缓存的典型例子。它们性能强劲,通过一些分布式的方案组成集群可以突破单机的限制。所以在整体架构中,分布式缓存承担着非常重要的角色。

热点本地缓存

热点本地缓存主要部署在应用服务器的代码中,用于阻挡热点查询对于分布式缓存节点或者数据库的压力。当遇到极端的热点数据查询的时候,可以在代码中使用一些本地缓存方案,如 HashMap,Guava Cache 或者是 Ehcache 等。

由于本地缓存是部署在应用服务器中,而我们应用服务器通常会部署多台,当数据更新时,我们不能确定哪台服务器本地中了缓存,更新或者删除所有服务器的缓存不是一个好的选择,所以我们通常会等待缓存过期。因此,这种缓存的有效期很短,通常为分钟或者秒级别,以避免返回前端脏数据。

缓存的不足

  • 缓存比较适合于读多写少的业务场景,并且数据最好带有一定的热点属性
  • 缓存会给整体系统带来复杂度,并且会有数据不一致的风险
  • 缓存通常使用内存作为存储介质,但是内存并不是无限的
  • 缓存会给运维带来一定的成本

读写策略

Cache Aside(旁路缓存)策略

更新数据时不更新缓存,而是删除缓存中的数据,在读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存中。

读策略步骤

  • 从缓存中读取数据;
  • 如果缓存命中,则直接返回数据;
  • 如果缓存不命中,则从数据库中查询数据;
  • 查询到数据后,将数据写入到缓存中,并且返回给用户。

写策略步骤

  • 更新数据库中的记录;
  • 删除缓存记录。

不足

当写入比较频繁时,缓存的数据会被频繁的清理,从而影响缓存命中率。解决方案有如下两种:

  • 在更新数据时也更新缓存,只是在更新缓存前先加一个分布式锁 ,因为这样在同一时间只允许一个线程更新缓存,就不会产生并发问题了。但对于写入的性能会有一些影响
  • 在更新数据时更新缓存,只是给缓存加一个较短的过期时间,这样即使出现缓存不一致的情况,缓存的数据也会很快地过期,对业务的影响也是可以接受。

Read/Write Through(读穿 / 写穿)策略

核心原则是用户只与缓存打交道,由缓存和数据库通信,写入或者读取数据

Write Through(写穿)策略

先查询要写入的数据在缓存中是否已经存在,如果已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,如果缓存中数据不存在,我们把这种情况叫做“Write Miss(写失效)”。

一般来说,可以选择两种“Write Miss”方式:

  • “Write Allocate(按写分配)”,做法是写入缓存相应位置,再由缓存组件同步更新到数据库中
  • “No-write allocate(不按写分配)”,做法是不写入缓存中,而是直接更新到数据库中

在此策略中一般选用后者,原因是无论采用哪种方式都需要同步到数据库,“No-write allocate”方式相比“Write Allocate”还减少了一次缓存的写入,能够提升写入的性能

Read Through策略

先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库中同步加载数据。

在使用本地缓存的时候可以考虑使用这种策略,比如本地缓存 Guava Cache 中的 Loading Cache 就有 Read Through 策略的影子。

不足

Write Through 策略中写数据库是同步的,这对于性能来说会有比较大的影响,因为相比于写缓存,同步写数据库的延迟就要高很多了。

Write Back(写回)策略

这个策略的核心思想是在写入数据时只写入缓存,并且把缓存块儿标记为“脏”的。而脏块儿只有被再次使用时才会将其中的数据写入到后端存储中。

写策略

在“Write Miss”的情况下,采用的是“Write Allocate”的方式,即在写入后端存储的同时要写入缓存,这样在之后的写请求中都只需要更新缓存即可,而无需更新后端存储了。

读策略

  • 在读取缓存时如果发现缓存命中则直接返回缓存数据。
  • 如果缓存不命中则寻找一个可用的缓存块儿:
    • 如果这个缓存块儿是“脏”的,就把缓存块儿中之前的数据写入到后端存储中,并且从后端存储加载数据到缓存块儿;
    • 如果不是脏的,则由缓存组件将后端存储中的数据加载到缓存中;
    • 最后将缓存设置为不是脏的,返回数据。

操作系统层面的 Page Cache、日志的异步刷盘、消息队列中消息的异步写入磁时大多采用了这种策略,避免了直接写磁盘造成的随机写问题。故在向低速设备写入数据的时候,可以在内存里先暂存一段时间的数据,甚至做一些统计汇总,然后定时地刷新到低速设备上。

缺点是一旦缓存机器掉电,就会造成原本缓存中的脏块儿数据丢失。

三种策略使用场景

  • Cache Aside 是在使用分布式缓存时最常用的策略,可以在实际工作中直接拿来使用。
  • Read/Write Through 和 Write Back 策略需要缓存组件的支持,所以比较适合在实现本地缓存组件的时候使用;
  • Write Back 策略是计算机体系结构中的策略,不过写入策略中的只写缓存,异步写入后端存储的策略有很多的应用场景。

参考资料

高并发系统设计40问

Tip: 本文是极客时间高并发系统设计40问学习笔记。

参与评论