发明BSD、TCP/IP、csh、vi和NFS的SUN首席科学家Bill Joy说过,在计算机体系结构领域里,缓存是唯一称得上伟大的思想的。其他的一切发明和技术不过是在不同场景下应用这一思想而已。在计算机软件领域里,情形也大体相似。如果罗列这个领域的伟大发明,绝对不超过二十项。这些包括分组交换网络、WEB、lisp、哈希算法、UNIX、编译技术、关系模型、面向对象、XML这些大名鼎鼎的家伙 ——只关注第一句就好。
一、缓存能提高性能的原因
开发过系统的人都知道,使用缓存之后系统的性能将得到很大程度上的提升,为什么使用缓存之后就能提高系统的响应速度呢?我以为有以下三点:
1)高速的存储介质
持久化的存储介质比如数据库、文件,虽然数据存储安全,但有时速度较慢,于是我们选择更快的存储介质比如RAM(内存),这里有个来自Google大神Jeff Dean的基准测试图,大家感受下:ps:真实的带小方块的那个图更直观
存储介质 | 速度 |
---|---|
L1 cache reference 读取CPU的一级缓存 | 0.5 ns |
Branch mispredict(转移、分支预测) | 5 ns |
L2 cache reference 读取CPU的二级缓存 | 7 ns |
Mutex lock/unlock 互斥锁\解锁 | 100 ns |
Main memory reference 读取内存数据 | 100 ns |
Compress 1K bytes with Zippy 1k字节压缩 | 10,000 ns |
Send 2K bytes over 1 Gbps network 在1Gbps的网络上发送2k字节 | 20,000 ns |
Read 1 MB sequentially from memory 从内存顺序读取1MB | 250,000 ns |
Round trip within same datacenter 从一个数据中心往返一次,ping一下 | 500,000 ns |
Disk seek 磁盘搜索 | 10,000,000 ns |
Read 1 MB sequentially from network从网络上顺序读取1兆的数据 | 10,000,000 ns |
Read 1 MB sequentially from disk 从磁盘里面读出1MB | 30,000,000 ns |
Send packet CA->Netherlands->CA 一个包的一次远程访问 | 150,000,000 ns |
从上图可以看出,内存的访问速度大约是磁盘的100~10w倍。
2) 近距离而不是远距离
对于读数据来说,就是说要把数据放到距离使用者最近的地方,减少中间的传输时间。例如视频app的视频缓存(可能采用某种压缩算法压缩了)、CDN加速等。
这种方式很直观,比如生活中我们做某件事的时候,时常要用到事务A,与其用到的时候跑一个来回拿来用,不如直接放到身边,伸手就能够着。所以说啊,艺术源于生活。
3)存储计算的结果
比如某些数据是经过复杂计算后得到的,我们直接把计算结果存起来,下次直接访问上次的计算结果就好了,例如Vue实例的computed属性(计算属性)。这样既能挺高获取速度,又能减少CPU的计算,用空间换时间,多好。
值得注意的是,这三种方式可以随意组合使用,具体要看你的使用场景。
缓存不能万能的
缓存确实是提供系统性能的最简单效果也最好的方式,但缓存也不是万能的,引入缓存会提升系统的复杂度,玩不好的话也会带来很多问题,比如debug难度增加了,数据出现脏读了。
所以啊当你的系统出现慢响应时,首先应看一下是不是因为访问量过大,达到了你的数据库的性能瓶颈:如果不是的话,很可能是因为程序中操作不当导致的系统变慢,这时你应该找到变慢原因并优化程序(具体操作具体分析),而不是直接引入缓存,这是治标不治本的方式;如果真的达到了存储组件的性能瓶颈,不应该再对数据库进行优化了,此时直接上缓存是个明智的选择。
二、常见的缓存使用模式
1、暂存(cache-aside pattern)
即将数据库中的数据暂时存储在缓存中,访问数据时先读取缓存,若缓存中有直接返回;缓存中没有再读取数据库,然后同步数据到缓存。
写操作时先更新数据库,再删除缓存。缓存的位置是处在数据库之前,起到了加速读取和保护数据库的作用。
2、只有高速缓存(cache-as-sor)
去掉数据库,让缓存来充当数据库的角色,所有的操作全在缓存中进行。
ps: sor 是 System of record(记录系统)的缩写
3、直写(write-through)
当发生写操作时,所有的写操作全都在一个线程中串行执行。保证了数据的实时一致性,但每个写操作的响应时间会加长,总的降低了系统的吞吐量。
4、后写(write-behind)
当发生写操作时,将一些写操作异步执行,比如对于复杂的写操作另起一个线程去异步执行,或者像剩余操作放到消息队列里面,后续异步执行。后写不能保证数据的实时一致性,但可以保证数据的最终一致性,同时每个写操作的响应时间会缩短,总的提高了系统吞吐量。
关于缓存无非就是读和写,以上四种基本涵盖了缓存读写所有的情况,关于选用直写还是后写,可以从数据的实时一致性和系统的访问量上来综合考虑,针对具体的情况灵活运用即可。
三、常见的缓存组件及其使用分析
从缓存所在的位置、是否是个独立的进程等方面来划分的话,缓存可分为本地缓存和远程缓存(又叫分布式缓存 )。
本地缓存有很多解决方案,java中常见的会有Ehcache、Caffeine、GuavaCache、OSCache、CurrentHashMap自定义等。
分布式缓存也有很多,如常见的Redis、Memcache等。
3.1 Ehcache
ehcache名字起的很好。
ehcache是用java写的一个简单易用的本地缓存库,他默认将数据存储在JVM的heap内存中,当超过一定量后,可以根据设置将数据存到硬盘中,理论上容量不用担心。像一些著名的持久层框架如Hibernate、mybatis都内置的Ehcache来缓存数据。
除此之外,Ehcache还具有一下特点:
1)数据可以设置过期时间
2)采用多种算法策略支持数据超量后的淘汰
3)支持数据后写模式
4)支持条件查询API等
以上对于一个本地缓存来说已经够用了。
缓存似乎很抽象、不直观,但其实他也是符合某种数据模型,像Ehcache的数据模型大致长这样:
注意事项:
由于Ehcache默认将数据存到JVM内存中,所以在配置Ehcache时注意不要超出了JVM的内存容量,要给你的程序预留一些内存,不然JVM可能就把OOM甩你脸上。
关于怎么样在程序中使用Ehcache构建缓存,可以参考这篇文章:
1、ehcache简介与HelloWorld_>no problem<的博客-CSDN博客
3.2 Redis
redis是个性能强劲的key-value数据库(只用它来做缓存有点浪费了),提供了很多数据结构以及基于这些数据结构的算法命令,为我们的程序提供了很大的扩展性。例如:string、hash、列表、集合、有序集合、bitmap、Hyperloglog、以及简单的geo、发布订阅、以及组合命令的Lua脚本等,能做啥就看你想象力了,咱们这里只谈论redis做缓存的方式。
用redis做缓存,我们是利用他的内存存储介质,并且是属于独立的服务与应用程序解耦。
3.2.1 编程式的方式直接操作redis
一般来说,这种分布式服务都有他对应语言的客户端,redis在java中的客户端有老牌的jedis、新秀lettuce(生菜,springBoot2内置的)、分布式的客户端redission等,我们可以基于这些客户端做一些简单的封装,使用。
3.2.2 基于注解的方式使用redis
spring对常用的缓存组件做了抽象,并用这些抽象来支持其提供的缓存注解。默认情况下,缓存实现为ConcurrentHashMap,基于springBoot热拔插的思想,我们只需要引入springBoot提供的
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
redis-starter就能很方便的将缓存注解的支持变成redis。
缓存注解如下:
@Cacheable(cacheNames = "缓存对象名称",key = "#id") 读取指定的缓存
@CachePut(cacheNames = "缓存对象名称",key = "#id") 写指定的缓存
@CacheEvict(cacheNames = "缓存对象名称",key = "#id") 过期指定的缓存
@Caching({}) 组合多个缓存注解
关于如何在springBoot中使用redis、如何使用spring提供的缓存注解、以及如何将缓存注解的支持变成redis等相关问题,可以参看这篇文章:
springBoot2.x整合redis_>no problem<的博客-CSDN博客
这里简单说一下springBoot切换缓存注解实现的原理:
springboot内部有个在org.springframework.boot.autoconfigure.cache包下有个CacheConfigurations类,定义了多种缓存组件的加载顺序,IOC容器启动时通过@ConditionalOnMissingBean 和@ConditionalOnBean 依次检测容器中是否存在指定的工厂类,若存在就会创建对应的缓存实现组件,最后都不存在则使用默认的。因为RedisCache的加载顺序是在默认的之前的,所以注入上面的starter即可替换默认的缓存实现。其实很简单,有点符合if-else if-else形式,只要有符合要求的,直接返回的意思。
3.3 GuavaCache
GuavaCache是google.common.cache包下的一个本地缓存实现,是在ConcurrentHashMap的基础上提供了缓存获取和淘汰等策略,数据也是存在JVM堆内存中的,简单点的缓存需求可以使用这个。
3.4 Caffeine
Caffeine也是个本地缓存,号称“本地缓存之王”,最大的特点就是性能高。据说他借鉴了GuavaCache和ConcurrentHashMap的设计,优化了数据结构和算法,从而变得高性能,一些常见的框架如play、spring、microNut都使用了他。
3.5 自定义简单缓存
我们可以使用java提供的数据集合如ArrayList、ConcurrentHashMap,自己做一个简单的缓存容器也可以。
3.6 Memcache
Memcache是一个分布式缓存组件,相比于redis来说,memcache是多线程的处理方式,所以不怕阻塞问题,其数据结构只有key-value形式,不过人家只是为了做缓存,key-value也够用了。
四、缓存使用过程中的一些经典问题
缓存击穿、缓存穿透、缓存雪崩,危害程度是逐步提升的。但前提是当你的数据库达到了性能瓶颈,引入缓存来缓解数据库的压力的时候,出现这三个问题对系统的稳定性危害很大,如果都没达到数据库的性能瓶颈,即使这三个问题出现,也只能说是缓存使用不当,并不会对系统稳定性带来多大影响。
4.1 缓存击穿
4.2 缓存穿透
4.3 缓存雪崩
4.4 缓存预热
4.5 缓存降级
4.6 如果保证数据库和缓存中数据的双写一致?
以上6个问题的介绍和解决方案,可以参考我的这篇文章,这里就不再重复了:
缓存设计和使用中的经典问题_>no problem<的博客-CSDN博客
4.7 如何保证缓存中的数据都是最热的数据?
解决:
方式一:
最简单直接的方式是,每次访问某个缓存key时,如果存在则重新设置过期时间。这样在过期时间内访问就永不过期,过期时间内不访问就被清除了。这和session的过期机制是一样的。但这样会造成双倍的操作,太直接了不太好。
方式二:
我们可以异步来做,搞一个工作进程、一个守护进程。守护进程负责对工作进程产生的数据进行操作,比如给每个缓存数据设置一个lastAccessTime的时间戳,每次访问该数据的时候,由缓存服务器端异步更新该时间戳(客户端是无感知的),守护进程启动一个定时任务的线程,按照某种策略如:对所有的数据按时间戳升序排序,当达到指定的容量后,删除总数量的15%的数据,这样能保证不过期的数据中只保留最热的。
对于过期了的数据,就交给缓存服务端来删除,至于是怎么检测过期的,客户端不用管。
所以,一个优秀的缓存服务应该提供以上两点功能,先有自己的默认值,然后提供一些配置让使用者可选择。在以上三中介绍的缓存组件中,redis、ehcache、memcache、GuavaCache、caffeine都提供了这些功能,并且他们都采用了几乎相同的数据淘汰算法,例如LRU、LFU、FIFO。
LRU(Last Recently Use,最近最少使用),简单来说就是根据最后访问时间戳来删除。
LFU(Last Frequently Use,最近最频繁使用),简单来说就是根据访问次数属性来删除。
FIFO(First In First Out,先进先出),数据入队,先存储的先删除。
如果面试官问你,如何保证redis中的数据都是最热的?
多半他想知道的就是redis服务器端对于未过期数据的逐出策略,这些策略就是说的上面的这几种算法。
好了,关于详细的实施步骤文中给了链接,方便大家拿来参考。本文只是给出了关于缓存使用中的一些理论,具体在系统中实施缓存时还会遇到很多问题,不过有了这些理论,咱也有了研究的方向了不是。
希望本文能给大家带来帮助,祝大家的系统性能都是蹭蹭蹭。