关于缓存这件事

缓存在计算机领域中应用的非常广泛,在很多系统中,缓存是实现高性能的关键。当然,缓存不是万能药,如果没有恰当使用好缓存,可能最终达不到期待的效果,反而带来很多问题。这里,我来简单聊一下关于缓存的一些认识。

、缓存 的定义

缓存最初是指CPU和内存之间的高速存储,用于让数据访问的速度适应CPU的处理速度。而目前“缓存”的含义已经被扩充,引用维基百科的定义:凡是位于速度相差较大的两种硬件之间,用于协调两者数据传输速度差异的结构,均可称之为缓存(Cache)。

缓存有很多实际的场景,在硬件上有CPU与内存之间的高速缓存、硬盘缓存等,软件上有文件系统的页缓存(Page Cache)、浏览器页面缓存,后台服务将热数据存放于KV系统或者本地内存也是缓存的一种形式。

一般缓存有这几种表现形式:

1. 使用更快的存储介质,提升数据访问速度

例如CPU的高速缓存、文件系统的Page Cache,或者使用KV系统存储数据库的热点数据,都是利用更快速的存储介质,来提升数据的访问速度,降低CPU处理数据时的等待时延。

2. 让数据离处理器更近,使得访问数据耗时更少

例如浏览器的页面缓存,或者程序中使用到的本地缓存,又或者我们网站中使用到的CDN技术,都是通过将数据存放于更近的地方,降低网络延迟带来的时延,使得数据访问耗时更少。

当然,很多的缓存应用场景是结合了两者,例如使用本地内存来缓存来自于数据库的数据,就是使用了更高速的存储介质(内存 vs 磁盘),并且缩短了数据与处理器的距离(省去了与数据库交互的网络时延)。

二、缓存存储的设计模式   

1、 Cache Aside 模式

Cache Aside模式的基本步骤如下:

  • 读操作:优先从cache读取,如果cache没有命中就从数据库读取数据,成功后协会到cache中

  • 写操作:先把数据写入到数据库中,写入成功之后,将缓存 失效 (而不是写入cache)

这里为什么在写数据库之后不直接将数据写入cache,而是让cache数据失效?这是为了避免写并发问题,当两个写操作几乎同时进行的时候,有可能后进行的写操作先返回,这样最后cache中的数据就变成是先进行的写操作的值,这个值是旧数据,会导致跟数据库不一致了。

当然,在Cache Aside模式下,读写并发的时候也可能存在问题。例如先来一个读请求A,然后有一个写请求B。读请求A没有命中cache,转而从数据库读取到数据,然后B请求写入一个新的数据到数据库中。此时如果B请求比A请求先返回的话,那么在B请求让cache失效之后,A请求将数据库中的旧数据写入到cache中,cache也会存在脏数据。不过这种情况发生概率较小,因为一般读请求处理较快,而写请求需要加锁等操作,速度会相对慢一些。为了避免这种情况导致cache一直存放着脏数据,需要 对cache的数据加上过期时间 ,那么即使存在脏数据,那么在过期时间之后也会恢复。

2、Read/Write Through 模式

在Cache Aside模式中,需要应用程序维护cache和数据库两个数据库,在读和写两种场景中都需要判断是否命中cache,并且需要自行将数据同步到数据库,因此应用程序的代码逻辑比相对复杂一些。

Read/Write Through模式中,则由cache作为一个类似代理的角色,由cache自动实现与数据库的数据交互:

  • Read Through模式

    应用程序向缓存代理发起读请求。如果命中缓存,则立刻返回;如果没有命中,则由缓存代理向数据库读取数据返回,并将数据写入Cache中。 对于应用程序来说,没有缓存和数据库之分,由缓存代理统一了存储入口

  • Write Through模式

    Write Through和Read Through类似。应用程序的写操作,都是跟缓存代理进行交互。缓存代理收到写请求后,如果没有命中缓存,直接写数据库,然后返回;如果命中缓存,那么会同步写入数据库和缓存。

3、Write Behind Caching 模式

Write Behind Caching模式在读操作上和Read Through一样,但是在写操作上策略会更加激进。Write Behind Caching模式下,写操作是先写cache,然后就返回。至于什么时候将cache中的数据刷新到数据库(或者cache对应的可靠的数据源),则根据缓存代理中的策略来定,一般是定期刷新,或者当写入数据量占cache的比例达到一定数值,就会进行数据刷新。

这种模式在写入的时候,因为都是写cache,因此性能是非常高的。但是,在数据写入到cache之后,还没有刷新到数据库之前,如果机器宕机,或者进程被杀,那么cache中写入的数据就可能会丢失。

其实文件系统中的Page Cache,就是这种模式,平时我们调用系统调用write,数据不会立即写入到磁盘上,而是首先写到Page Cache中,然后文件系统会定期将Page Cache中的脏页刷新到磁盘上。如果我们对数据一致性要求非常高,那么就需要在write之后调用一些`fsync`,主动将数据进行刷新到磁盘上,但这样会导致性能不高。这也是数据一致性与性能之间的权衡。

4 、离线缓存

对于数据源是SQL数据库的情况下,还有离线缓存这种模式。离线缓存就是缓存更新并非实时的,是通过离线脚本或者消息队列的形式,将数据库的变更离线更新到cache中。

一般来说,使用离线更新缓存的方式的情况下,对应的缓存是全量缓存,即缓存中包含了所需要的全量的数据,应用程序直接从缓存中读取数据,而不会回源数据库。 对于读操作来说,缓存就是数据库

离线缓存大体上有两种实现方式:

(1) 定时脚本扫描

如果使用定时脚本进行扫描,一般来说,数据库表里面,会有一个类似于update_time的字段,这个字段会在一行记录被更新的时候自动更新为当前时间。通过这个字段,定时脚本就可以知道最近哪些数据被更新,就可以将这些数据写入到cache中。

这种方式优点是:

  • 基本实现比较简单,门槛较低;

  • 不需要依赖第三方组件即可实现。

但也存在这些缺点:

  • 通用性较低。 对于数据表的结构要求较高,必须要有一个自动更新的update_time字段,通用性较低。 另外,数据表的行记录不能_硬删除_, 只能通过类似del_flag的字段进行作为删除标记,不然定时扫描脚本无法感知删除的操作

  • 容错性较低。 对于机器的时钟要求比较高,如果定时程序的机器时间与数据库的机器时间不一致,就会导致定时程序加载不到最新的变更

  • 需要扫描数据库,会增大数据库的压力。

(2)使用binlog

数据库的每个更新操作,都会记录一行binlog日志,从数据库master同步到slave中,用于数据的主从同步。 我们可以起一个程序“伪装”为master的一个slave,接收来自master的binlog,这样就可以得到数据库的最新变更了。 这个接收binlog的程序通过解析binlog,将变更数据写入到cache中。

下图就是使用binlog进行同步数据的架构图(图片来自fynn大神~)。

使用binlog方式进行同步的优点有这些:

  • 通用性较高。 对表的结构没有特殊要求。

  • 容错性较高。 不需要依赖机器时间的一致,也可以避免定时脚本扫描方式中存在的漏数据问题。

目前已经有一些现有的工具可以作为binlog接收程序,来实现数据库数据同步,例如canal、mypipe、maxwell 和腾讯云的DTS等。 在线教育后台这里,fynn大神搭建了一个基于腾讯云DTS + kafka的数据同步系统,使用腾讯云DTS接收MySQL binlog,然后使用kafka进行消息广播,业务服务需要关注数据库数据变更的话,从kafka消费消息即可。

离线缓存的方式中,业务程序是直接读取缓存数据,性能上相比起读数据库是高很多的,也不需要考虑如何业务考虑如何更新缓存。 不过,离线缓存的话, 数据是不能及时更新到cache ,这种也是一种为了性能牺牲一定的数据实时性的策略。 对于对数据一致性和实时性要求高的场景,离线缓存就不适用了。

当然,如果的确需要实时性,可以使用数据库代理,或者业务代码手动写入缓存等方法,对写入操作实时写入cache。 但是由于写入口难以统一收拢,同时保证写数据库和写缓存是事务性的,在实现上存在难度。 当使用离线缓存更新,而不是实时缓存更新,一般就是考虑到 降低实现复杂度和逻辑的耦合度

三、缓存的使用陷阱

缓存的使用会存在一些隐藏的陷阱,这些陷阱会导致一些严重的后果。我们有必要了解缓存中存在的陷阱,并且加上一些措施来规避。缓存中存在的陷阱主要有这几个:

  • 缓存穿透

  • 缓存雪崩

  • 缓存击穿

这些陷阱都是在一些特定的情况下,请求会都直接透传到后端存储系统中,会导致系统不可用。下面逐一介绍这些陷阱出现的场景以及对应的解决方案。

1. 缓存穿透

缓存穿透的意思是请求中访问的key,在后端数据库中本来就不存在,因此缓存中也肯定不存在的。如果系统在发现缓存中不存在这些数据之后回源到数据库获取数据,当这类不存在数据的请求量很大,就会有大量请求到达数据库,对数据库造成很大的压力,并且可能造成系统不可用。

系统的逻辑错误或者 外部的恶意请求 都可能会引发大量访问不存在的数据,从而导致缓存穿透。

解决方案:

(1) 对于不存在的key也进行缓存

当我们从后端数据库发现一个key是不存在,也把这个key记录在缓存中,并且标记为key不存在,那么下次使用相同的key访问数据的时候,就可以知道这个key是不存在的,可以直接在缓存层返回。 不过这个方案也存在缺陷, 如果是外部恶意构造的请求,可以构造很多不一样并且不存在的key ,那么这个方案就会失效,因为如果恶意构造的key只出现一次,那么请求还是会穿透到数据库,同时,缓存里面会被塞满很多这种无效的key,甚至有效的key都会被淘汰掉。

(2)使用布隆过滤器

布隆过滤器的基本思想是使用一个大位图,以及几个哈希函数。

  • 写入一个key:分别使用这几个哈希函数对key算出哈希值,在位图上哈希值对应的位改为1。

  • 查询一个key是否存在:分别使用这几个哈希函数对key算出哈希值,并查看位图上哈希值对应的位是否 被设置为1。只要有一个位是没有被设置为1,说明这个key是不存在的。

优点:

  • 相对于把所有不存在的key记录下来,会比较节省空间

  • 大部分情况下,可以判断出一个不存在的key,不会轻易被一个从来没有请求过的key穿透

缺点:

  • 不保证100%准确。在布隆过滤器位图大小不变的情况下,当数据量越大,准确率就越低。

  • 对于防止缓存穿透的场景下,在服务启动的时候需要用全量数据来建立布隆过滤器。

2. 缓存雪崩

缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。

当然,我们平时较少会将缓存的失效时间设置为相同。 不过在批量读取接口可能会出现一批数据的过期时间是一样的,那么当后面访问这批数据的时候,可能都需要全部从后端存储获取,造成短时间内对数据库压力激增。

解决方案 在设置过期时间的时候,可以使用一定范围内的随机值作为过期时间,这样就 可以 让key的过期时间比较分散,不至于很多的key同时缓存失效。

3. 缓存击穿

如果系统中有一部分key被很高频访问,当这些热门key失效的时候,对这些key的读取访问就会都落到后端数据库,可能会瞬间压垮数据库。 例如微博上,热门话题的明星的资料就是热门的key,如果这些明星资料缓存失效了,大量的请求就会穿透到数据库上。 这种热门key失效导致的问题,就是缓存击穿。

解决方案:

(1)使用互斥锁

  • 发现一个key在缓存中没有的时候,先获取针对这个key的互斥锁;

  • 获取到锁之后进行double check;

  • 如果double check之后key在缓存中还是没有,才访问数据获取这个数据

优点:

  • 这种方案,等于将大量透出到后端数据库的请求进行聚合,最后只有少数的请求真正需要访问到数据库。

缺点:

  • 需要用到互斥锁,因为热门key都会抢占同一个互斥锁,会降低CPU的利用率,锁可能会成为新的性能瓶颈。

(2) 对于热门key,使用异步线程刷新缓存

如果我们有能力识别哪些key是热门key,可以对于这些热门key起独立的线程,异步刷新对应的缓存,这样所有请求都不需要访问到数据库。 当然,对于整体数据量不大的情况,可以对所有的key都异步刷新缓存(类似上面介绍到的离线缓存模式)。

优点:

  • 所有请求都无需请求数据库,直接访问缓存,可以完全避免热门key对于数据库的冲击。

缺点:

  • 牺牲了数据实时性

  • 引入了复杂性,需要增加识别热门key的方案,一旦热门key的判断出现问题,那么就可能导致热门key的访问又穿透到数据库

四、总结

本文大致阐述了缓存的定义、相关设计模式以及使用缓存可能遇到的陷阱。

缓存在日常的工程开发中应用广泛,恰当地使用缓存可以对我们系统的整体性能有很大的提升。我们在使用缓存的时候,最好结合系统实际情况进行选择和取舍,应用适合的设计模式。同时,我们也需要考虑在缓存失效的情况下,系统会受到多大的冲击,并添加相应措施避免缓存失效带来的一系列问题。

参考文献:

缓存更新的套路-coolshell

缓存-维基百科

布隆过滤器-维基百科

我来评几句
登录后评论

已发表评论数()

相关站点

热门文章