缓存

栏目: 数据库 · 发布时间: 4年前

内容简介:对于数据库的CRUD操作而言,当并发量较大时会出现读或者写的瓶颈。对于大多数场景而言,都是读多写少,因此读更容易成为数据库的瓶颈。而缓存就是为了解决读的问题而出现的。缓存的数据存储在内存中,因此性能很高。缓存的更新方式从大的方向分可以分为同步更新缓存和异步更新缓存同步更新缓存就是写数据或者读数据的时候同步更新缓存。

对于数据库的CRUD操作而言,当并发量较大时会出现读或者写的瓶颈。对于大多数场景而言,都是读多写少,因此读更容易成为数据库的瓶颈。而缓存就是为了解决读的问题而出现的。缓存的数据存储在内存中,因此性能很高。

缓存更新方案

缓存的更新方式从大的方向分可以分为同步更新缓存和异步更新缓存

同步更新缓存

同步更新缓存就是写数据或者读数据的时候同步更新缓存。

读的时候更新缓存

缓存

读的时候更新缓存策略很简单,如上图所示,主要有以下几个步骤:

  1. 读请求时,如果缓存数据存在则直接返回该数据;
  2. 读请求时,如果缓存数据不存在则从数据库中读入数据并写入缓存,然后返回数据;
  3. 写请求时,写入数据库成功后,删除缓存

写的时候更新缓存

缓存

写的时候更新缓存与读的时候更新缓存原理类似,只是在写数据时候会先写数据库,然后写缓存,而不是删除缓存。

接下来我们对比一下这两种方式的优缺点。

读的时候更新缓存在数据写入数据库后只需要删除缓存即可,操作比较简单,因此逻辑上会简单一些,这种方式是最常见的缓存更新方式。但是读请求的时候要先读数据库然后写入缓存,如果是一个影响很大的更新,那么缓存失效后的第一次读请求可能会比较慢。比如常见的好友列表,如果缓存失效,需要从数据库先从关系链表查好友的关系链,然后去用户表查每个好友的头像和昵称,最后将数据还要写入缓存,这个过程可能会比较耗时。

而写的时候更新缓存,只需要将同样的更新数据先写入数据库,然后写一遍缓存,不用从数据库中取出来然后写入缓存。不过使用这种方式的时候,读请求的时查询缓存没有命中,然后查数据库的逻辑不能省,因为缓存还会因为过期而失效。

这两种方式都有一个问题,写请求时写入数据库成功,然后同步写入缓存或者删除缓存这两个动作都可能失败,如果失败就会导致数据库中的数据与缓存中的数据不一致。首先,可以采取重试的策略来尽可能减小出现的概率,而且尽量要给缓存设置一个过期时间,这样可以使缓存中的数据与数据库中的数据达到最终一致性。

异步更新缓存

同步更新缓存需要在业务逻辑里单独处理这一段逻辑,而其本身与业务逻辑是不相关的,我们只能为了提升性能而引入了缓存系统。因此可以考虑通过异步的方式更新缓存,将缓存更新的服务与业务服务进行解耦。而且异步更新的方式,将缓存更新的操作单独用一个服务来实现,因此读写请求减少了缓存更新的逻辑,性能会得到提升。

先写DB,异步MQ更新缓存

缓存

一个简单的异步缓存更新方案入上图所示,写请求写完数据库后会抛一个MQ消息,然后有一个独立的缓存更新服务区接受这个消息,然后从数据库读数据并写入缓存。采用异步的方案以后,数据无需同步写入,减轻了业务服务的逻辑任务,在业务场景下可能很多个地方都需要更新缓存,采用异步更新发消息很方便。不过这里需要依赖中间件消息队列,需要消息队列能保证不丢消息。缓存更新服务中也会存在缓存更新失败的情况,不过我们可以采用不断重试的方案来避免这样的问题。

但是上面这个设计会有一些问题,主要是在并发情况下。

问题1:如果先有一个写请求更新了数据库的数据,然后抛出一条MQ消息。但是在这个MQ消息被处理前,这时候一条读请求被发起了,那么这个时候读请求会读到缓存中的旧数据。

问题2:如果先有一个写请求更新了数据库的数据,并抛消息MQ1。然后接着有另一个写请求紧跟着也更新了数据,并抛消息MQ2。如果MQ1和MQ2串行执行,那么就没有问题。但是分布式环境下,服务是多机多进程部署,因此MQ2可能比MQ1先被处理。考虑这种极端条件下,如果第二次写请求前,MQ1的消息已经到达缓存更新服务并从数据库中取出消息。就在这时,MQ2消息到达被另一个进程处理,从数据库中取出数据并先于MQ1消息更新了缓存,然后这时MQ1消息取出的数据写入缓存就覆盖了MQ2消息的更新的数据。这时候缓存中的数据也与数据库中的数据不一致了。

如果对缓存中的数据与数据库中的数据的一致性要求非常高,可以引入脏标和版本号的机制来实现。如果完全不能接受缓存中数据与数据库数据不一致,就不要使用缓存。

缓存
  1. 在更新数据到数据库之前先写一个脏标来标识缓存中的数据是脏的。脏标是用来解决问题1的。如果写脏标失败,则本次请求失败。如果写脏标成功,但是写数据库失败,本次请求也失败,这会导致缓存失效,下次读请求时发现缓存中的数据是脏数据,然后去读数据库。脏标写成功,数据也写成功,但是发消息失败,则由下一次读请求来更新缓存。为了避免脏标删除失败而导致缓存雪崩,最好给脏标设置一个过期时间。
  2. 给每一条数据都维护一个版本号,每次更新数据库都将版本号加1。版本号是用来解决问题2的。更新缓存之前先判断缓存中数据的版本号与数据库中数据的版本号,如果将要写入缓存中的数据的版本号大于缓存中数据的版本号则说明要更新的数据更新,此时更新缓存。如果数据库中数据的版本号小于缓存中数据的版本号则说明要更新的数据比缓存中的数据更旧或者数据相同,此时不更新缓存。
  3. 基于版本号的更新可以用 redislua 脚本来实现原子性
local cache_info = redis.call('GET', KEYS[1])
local cache_version = redis.call('GET', KEYS[2])
if(type(cache_version) ~= 'string' or 
   type(cache_info) ~= 'string' or 
   tonumber(cache_version) < tonumber(ARGV[1])) 
then 
   redis.call('SET', KEYS[2], ARGV[1], 'EX', ARGV[3])
   return redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3])
else 
   return 0 
end
复制代码

ps:KEYS[1]是缓存数据的key,KEYS[2]是版本号的key,ARGV[1]是更新后的缓存数据,ARGV[2]是更新后的版本号,ARGV[3]是key的过期时间。

先写缓存,异步将脏数据刷到数据库

先写缓存然后异步将数据刷到数据库的方法与操作系统的文件系统的读写核心流程是相同的。对于操作系统的文件系统,由于内存操作与磁盘操作存在百万数量级的差别,因此操作系统的文件系统维护了一个高速缓存区来减小这种巨大差距带来的影响。文件系统读操作时,先查询高速缓存区是否存在数据,如果没有则从磁盘读入高速缓存区。写数据时,将数据写入高速缓存区,系统调用write就返回成功了。然后通过一个名为update的后台进程,不断的调用sync将高速缓存区的内容写入磁盘。

先写缓存,然后异步将数据刷到数据库的方案流程图如下:

缓存

该方案的好处是,读写都是走缓存,因此数据极快,可以应对极高的并发请求。不过这种方案会导致缓存中数据与数据库中数据存在不一致的时间段,更为严重的是如果机器宕机,还没写入数据库的脏数据会丢失。如果要避免数据丢失,还可以使用双缓存的方案,不过这有会是系统更加复杂,维护一致性更加困难。

缓存中存在常见问题

缓存穿透

一般情况下查询数据,数据都是存在的。大部分业务系统都需要给用户创建一个账户,如果一个新用户去查询用户信息,数据库中不存在这个用户的信息,系统会返回前端说明是一个新用户。正常情况下,这样没有问题。如果有人利用这个漏洞,用很多个这种新用户的账号,不断请求用户系统的接口,所有的请求都会打到DB上,会DB带来很大的压力,甚至宕机。

像这种查询系统中压根不存在的数据,使请求落到DB上的情况,被称为缓存穿透。

对于缓存穿透常用解决方案有两个:缓存和空值和布隆过滤器

缓存空值

缓存空值的方法,正如其名,当查询到数据不存在时,向缓存的key中写入null。当查询到该key存在,且值为null时,按数据存在处理。

布隆过滤器

缓存

第二种方案是在前一种方案之前再加一层布隆过滤器,如果布隆过滤器能命中,则查缓存,如果布隆过滤器没有命中,则直接返回。布隆过滤器的特点是如果数据存在则布隆过滤器一定会命中,如果数据不存在则布隆过滤器绝大多数情况下不会被命中。因此,即使有部分不存在的数据通过了布隆过滤器的过滤,还是会被空值缓存拦截住。

第二种方案是在第一种方案的基础上形成了,因此第二种方案复杂一些,但是如果有大量不存在的数据被缓存会浪费缓存的空间,而布隆过滤器能过滤掉绝大多数这样的情况。因此,如果为null的key的数量不是很多,直接用第一种方法即可,反之,如果为null的key的数量很多,则建议加一层布隆过滤器。

缓存洞穿

在高并发下,当缓存数据失效的一瞬间,这时所有的请求都会打到DB上,造成DB瞬时压力陡增,这就是缓存洞穿。

防止缓存洞穿的方法是当发现缓存失效时,在查询DB之前先加锁,这样第一个取到锁的线程更新缓存,其他线程因为取不到锁会等待。等到一个线程更新缓存成功后,其他线程就可以从缓存中查询信息了。

缓存雪崩

缓存雪崩是指同一时间缓存大规模失效,导致请求都直接打到DB上,瞬间的流量将DB打挂,导致整个系统崩溃,这种情况就是缓存雪崩。比如缓存机器宕机或者重启时都可能导致缓存雪崩。

对于缓存雪崩首先采用缓存集群的方案来增加容错性,如果使用redis做缓存,可以使用主从+哨兵的部署来方案来提高可用性,避免缓存大量失效的问题发生。

对于微服务架构,雪崩已经发生的情况,可以使用开源的Hystrix实现降级和限流,避免DB宕机。但是Hystrix不具备很好的通用性,对于spring cloud可以比较方便的使用,对于其他语言下该怎么做呢?微服务治理的新趋势是使用server mesh,通过server mesh来避免服务雪崩。server mesh具有更好的通用性,而且对语言完全兼容。

热点数据失效

大量热点缓存数据同时失效,导致大量请求直接打到DB上。对于热点数据同时失效的问题,可以在过期时间上,加上一个随机值,避免缓存同时失效。

总结

本篇文章,总结了自己对缓存知识的认识,介绍了四种常见的缓存方案,每种方案各有优劣,需要根据业务需求来选择合理的方案。然后介绍了使用缓存时可能遇到的几个问题,并总结了常见的解决方案。


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

PHP Hacks

PHP Hacks

Jack Herrington D. / O'Reilly Media / 2005-12-19 / USD 29.95

Programmers love its flexibility and speed; designers love its accessibility and convenience. When it comes to creating web sites, the PHP scripting language is truly a red-hot property. In fact, PH......一起来看看 《PHP Hacks》 这本书的介绍吧!

HTML 编码/解码
HTML 编码/解码

HTML 编码/解码

Markdown 在线编辑器
Markdown 在线编辑器

Markdown 在线编辑器

正则表达式在线测试
正则表达式在线测试

正则表达式在线测试