缓存设计汇总

问题

缓存考虑问题

  • 内存管理
  • 缓存值生命周期管理
  • 数据持久化
  • 分布式共享
  • 数据不一致问题:缓存+数据库双写
  • 多级缓存方案
  • 缓存淘汰策略
  1. FIFO(First In, First Out)
  2. LRU(Least RecentlyUsed):即最近最少使用,淘汰最近不使用的缓存数据。即按照最后被使用时间淘汰
  3. LFU(Least Frequently used):即最近使用次数最少的数据被淘汰,注意和LRU的区别在于LRU的淘汰规则是基于访问时间。LFU按照使用次数淘汰数据

缓存引入的问题

缓存穿透

是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。

解决方案:

  • 把所有存在的key都存到另外一个存储的Set集合里,查询时可以先查询key是否存在;
  • 接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;
  • 可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击
  • BloomFilter。这种方式在大数据场景应用比较多,比如 Hbase中使用它去判断数据是否在磁盘上。这种方案可以加在第1/2种方案中,在缓存之前加一层 BloomFilter ,在查询的时候先去 BloomFilter 去查询 key 是否存在,如果不存在就直接返回,存在再走查缓存 -> 查 DB。

缓存击穿

是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。

解决方案:

  • 设置热点数据永远不过期。
  • 那么我们可以在第一个查询数据的请求上使用一个互斥锁。其他的线程进入等待状态,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存。

问题:当然这是简化处理,理论上如果能根据key值加锁就更好了,就是线程A从数据库取key1的数据并不妨碍线程B取key2的数据,上面代码明显做不到这点。

缓存雪崩

缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至down机。和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。两种情况会导致此问题:1、多个缓存数据同时失效;2、缓存系统崩溃,缓存同时失效。

解决方案:

  • 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
  • 如果是第二个问题,缓存系统整体故障,则整个缓存系统不可用,大量回源请求,且由于缓存系统故障无法回写缓存,导致无法快速恢复。这是缓存系统的引入,在解决高性能、高并发的同时,引入新的故障点。考虑此问题,应从事前、事故中、事后不同阶段考虑:

        事前:增加缓存系统高可用方案设计,避免出现系统性故障;
        事故中:增加多级缓存,在单一缓存故障时,仍有其他缓存系统可用,如之前项目中使用的三级缓存方案:内存级缓存->Memcached->Redis这样的方案;启用熔断限流机制,只允许可承受流量,避免全部流量压垮系统;
        事后:缓存数据持久化,在故障后快速恢复缓存系统;

缓存模式

  • Cache-Aside:是最广泛使用的缓存模式之一,Cache-Aside可用来读或写操作。

        失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
        命中:应用程序从cache中取数据,取到后返回。
        更新:先把数据存到数据库中,成功后,再让缓存失效。

Cache Aside

  • Read-Through

Read Through

        Read-Through和Cache-Aside很相似,不同点在于程序不需要再去管理从哪去读数据(缓存还是数据库)。相反它会直接从缓存中读数据,该场景下是缓存去决定从哪查询数据。当我们比较两者的时候这是一个优势因为它会让程序代码变得更简洁。

  • Write-Through

当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作)。Write-Through下所有的写操作都经过缓存(如果没有命中缓存,直接更新数据库),每次我们向缓存中写数据的时候,缓存会把数据持久化到对应的数据库中去,且这两个操作都在一个事务中完成。因此,只有两次都写成功了才是最终写成功了。这的确带来了一些写延迟但是它保证了数据一致性。同时,因为程序只和缓存交互,编码会变得更加简单和整洁,当你需要在多处复用相同逻辑的时候这点变的格外明显。
当使用Write-Through的时候一般都配合使用Read-Through。
        Write-Through适用情况有:
        需要频繁读取相同数据
        不能忍受数据丢失(相对Write-Behind而言)和数据不一致  

  • Write-Behind

        Write-Behind和Write-Through在“程序只和缓存交互且只能通过缓存写数据”这一点上很相似。不同点在于Write-Through会把数据立即写入数据库中,而Write-Behind会在一段时间之后(或是被其他方式触发)把数据一起写入数据库,这个异步写操作是Write-Behind的最大特点。
数据库写操作可以用不同的方式完成,其中一个方式就是收集所有的写操作并在某一时间点(比如数据库负载低的时候)批量写入。另一种方式就是合并几个写操作成为一个小批次操作,接着缓存收集写操作(比如5个)一起批量写入。

        另外,Write Back实现逻辑比较复杂,因为他需要track有哪数据是被更新了的,需要刷到持久层上。操作系统的write back会在仅当这个cache需要失效的时候,才会被真正持久起来,比如,内存不够了,或是进程退出了等情况,这又叫lazy write。
        异步写操作极大的降低了请求延迟并减轻了数据库的负担。同时也放大了数据不一致的。比如有人此时直接从数据库中查询数据,但是更新的数据还未被写入数据库,此时查询到的数据就不是最新的数据。

Cache-Aside

读缓存

最经典的缓存+数据库读写的模式,cache aside pattern。读的时候,先读缓存,缓存没有的话,那么就读数据库。

preview

数据更新写缓存

先更新缓存还是先更新数据库?

更新缓存有两种做法:

  • delete cache删除缓存
  • set cache设置新值

先更新缓存再更新数据库

  • 缓存delete cache

先更新缓存再更新数据库1

        数据不一致问题。 

  • 缓存set cache

 先更新缓存再更新数据库2

         数据不一致问题。

 可看到先操作缓存不论是先删除缓存还是先更新缓存都会发生数据不一致的情况,所以不推荐这两种做法。

先更新数据库再更新缓存

  • 缓存delete cache

特殊情况:在高并发的场景下,可能会出现数据库与缓存数据不一致的的情况,考虑下面情形:

在高并发的场景下,可能会出现数据库与缓存数据不一致的的情况,考虑下面情形:
1、线程A发起一个写操作,还未操作数据库
2、缓存刚好失效
3、线程B查询数据库,得一个旧值
4、线程A将新值写入数据库
5、线程A删除缓存
6、线程B将查到的旧值写入缓存
但是这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必须在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率其实并不大。
  • 缓存set cache

        问题是,如果A、B两个线程同时做数据更新,A先更新了数据库,B后更新数据库,则此时数据库里存的是B的数据。而更新缓存的时候,是B先更新了缓存,而A后更新了缓存,则缓存里是A的数据。这样缓存和数据库的数据会发生不一致。其次,一是没有必要,可能下次不请求该key;二是复杂情况,比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据,并进行运算,才能计算出缓存最新的值的。这样更新缓存的代价是很高的。如果你频繁修改一个缓存涉及的多个表,那么这个缓存会被频繁的更新,频繁的更新缓存代价很高。而且这个缓存的值如果不是被频繁访问,就得不偿失了。

从理论上说,只要我们设置了缓存的过期时间,我们就能保证缓存和数据库的数据最终是一致的。因为只要缓存数据过期了,就会被删除。随后读的时候,因为缓存里没有,就得去查数据库的数据,然后将查出来的数据写入到缓存中。除了设置过期时间,我们还需要做更多的措施来尽量避免数据库与缓存处于不一致的情况发生。但是设置过期时间是基本操作,只要数据不是静态数据,就应该给缓存中的此类数据设置过期时间,它并不是解决“先更新缓存,再更新数据库”造成数据不一致问题的方法。

小结

  • 先操作数据库再删除缓存能有让人可接受的结果,所以最推荐这种做法。
  • 先操作缓存再更新数据库可能造成数据不一致的场景,不推荐这种做法。

异常情况

上面的讨论与对比都是在更新缓存和更新数据库这两步操作都成功的情况下叙述的。当然系统正常运行时的操作基本上都是成功的,那么如果两步操作有其中一步操作失败了呢?(以先操作数据库再操作缓存举例)
第一步失败:这种情况很简单,不会影响第二步操作,也不会影响数据一致性,直接抛异常出去就好了。
第二步失败:
1. 将需要删除的缓存key发送到消息队列中;
2. 另起终端消费队列消息,获得需要删除的缓存key;
3. 设置重试删除操作,超过最大重试次数(比如5次)后将消息转入死信队列并报警给运维人员。

缓存实现

本地缓存

  •  jvm内置缓存:比如用ConcurrentHashMap基于内存缓存实现,简单不实用;
  • guava-cache

分布式缓存

  • redis
  • memcache

注意事项

1. 提供client jar包给调用方时,client仅读不写缓存,server端负责写缓存。因为当client jar包升级导致写缓存DTO字段增删时,调用方使用不同版本读缓存会缺失字段。

参考

缓存模式(Cache Aside、Read Through、Write Through、Write Behind)

先更新缓存还是先更新数据库

缓存的设计与使用,值得我们去思考(值得一看)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值