作者 | 黄永灿
后端开发攻城狮,关注服务端技术与性能优化。
什么时候需要缓存
一般来说会从两个方面来判断是否需要缓存:
CPU 占用:如果某些应用需要消耗大量的 CPU 资源去计算,例如你使用正则表达式比较频繁或者业务查询链很复杂时,而它又占用了很多 CPU 的话,那就应该使用缓存将结果缓存下来。
数据库 IO 占用:如果你发现你的数据库连接池比较空闲,可以不用缓存。但是如果数据库连接池比较繁忙,甚至经常报出连接不够的报警,那么是时候应该考虑缓存了。
如何选择合适缓存
缓存主要分为本地缓存和分布式缓存。本地缓存的优点在于没有网络延迟查询速度快,缺点是在集群环境下容易出现数据不一致的情况,以及缓存过大时容易触发 GC,分布式缓存的优点在于数据一致性得到了保证,并且独立于应用程序,扩展维护方便。
本地缓存
首先来对比一下几个常用的本地缓存,具体原理可以参考你应该知道的缓存进化史:
ConcurrentHashMap 比较适合缓存比较固定不变的元素,且缓存的数量较小的。虽然从上面表格中比起来有点逊色,但是由于它是 JDK 自带的类,在各种框架中依然有大量的使用。比如我们可以用来缓存反射的 Method,Field 等等。
LRUMap 相对于 ConcurrentHashMap 引入了淘汰算法,如果不想引入第三方包,又想使用淘汰算法淘汰数据,可以使用这个。
对于 Ehcache 来说,由于其 jar 包很大,较重量级。对于需要持久化和集群的一些功能的,可以选择 Ehcache,但一般都会用分布式缓存来替代它。
对于 Guava Cache 来说,Guava 这个 jar 包在很多 Java 应用程序中都有大量的引入。 所以很多时候直接用就好了,并且它本身是轻量级的而且功能较为丰富,在不了解 Caffeine 的情况下可以选择 Guava Cache。
对于 Caffeine 来说,很多人把它形容为未来的缓存,它在命中率,读写性能上都比 Guava Cache 好很多。 并且它的 API 和 Guava Cache 基本一致,甚至会多一点。以下是 Caffeine 和其他缓存的读写性能对比。
总结一下:如果不需要淘汰算法则选择 ConcurrentHashMap;如果需要淘汰算法和一些丰富的 API,这里推荐选择 Caffeine 或者 Guava Cache。
分布式缓存
目前常用的分布式缓存主要有以下几种,不同的分布式缓存功能特性和实现原理方面有很大的差异,因此它们所适应的场景也有所不同:
MemCache:是一款纯内存的多线程缓存,其吞吐量较大,性能是这三者中最好的,但是支持的数据结构较少,并且不支持持久化。
Redis:支持丰富的数据结构,读写性能很高,但是数据全内存,必须要考虑资源成本,支持持久化,一般没有特别的要求,可以直接选择 Redis。
Tair:数据量特别大时推荐使用,支持丰富的数据结构,读写性能较高,部分类型比较慢,理论上容量可以无限扩充。
多级缓存
相信很多人一想到缓存马上脑子里面就会出现下面的图。
Redis 用来存储热点数据,Redis 中没有的数据则直接去数据库访问。很多人有疑问,我已经有 Redis 了,我干嘛还需要了解 Guava,Caffeine 这些本地(进程)缓存呢。主要有以下两个原因:
提高可用性,Redis 如果挂了或者使用老版本的 Redis,其会进行全量同步,此时 Redis 是不可用的,这个时候我们只能访问数据库,很容易造成雪崩。
提升性能,访问 Redis 会有一定的网络 I/O 以及序列化反序列化,虽然性能很高但是其终究没有本地方法快,可以将最热的数据存放在本地,以便进一步加快访问速度。这个思路类似于计算机系统中 CPU 使用的L1、L2、L3 多级缓存,用来减少对内存的直接访问,从而加快访问速度。
所以如果仅仅是使用 Redis,能满足我们大部分需求,但是当需要追求更高的性能以及更高的可用性的时候,那就不得不了解多级缓存。
俗话说得好,世界上没有什么是一个缓存解决不了的事,如果有,那就两个。一般来说我们选择一个进程缓存和一个分布式缓存来搭配做多级缓存,一般来说引入两个也足够了,如果使用三个,四个的话,技术维护成本会很高,反而有可能会得不偿失,如下图所示。
利用 Caffeine 做一级缓存,Redis 作为二级缓存。
首先去 Caffeine 中查询数据,如果有直接返回。如果没有则进行第2步。
再去 Redis 中查询,如果查询到了返回数据并在 Caffeine 中填充此数据。如果没有查到则进行第3步。
最后去 Mysql 中查询,如果查询到了返回数据并在 Redis,Caffeine 中依次填充此数据。
对于 Caffeine 的缓存,如果有数据更新,只能删除更新数据的那台机器上的缓存,其他机器只能通过超时来过期缓存,超时设定可以有两种策略:
设置成写入后多少时间后过期
设置成写入后多少时间刷新
对于 Redis 的缓存更新,其他机器立马可见,但是也必须要设置超时时间,其时间比 Caffeine 的过期长。为了解决进程内缓存的问题,设计进一步优化:
缓存更新策略
先删除缓存,再更新数据库?
先更新数据库,再删除缓存?
PS:为什么要删除缓存,而不是直接更新缓存呢?
当有多个并发的请求更新数据,你并不能保证更新数据库的顺序和更新缓存的顺序一致,那就会出现数据库中和缓存中数据不一致的情况。所以一般来说考虑删除缓存。
首先我们来看一下先删除缓存,再更新数据库会有什么问题。
如果有两个并发操作,一个是更新操作,另一个是查询操作,更新操作删除缓存后,查询操作没有命中缓存,先把老数据读出来后放到缓存中,然后更新操作更新了数据库。于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了。一般不推荐这种更新方式。
更新缓存的三种设计模式
Cache Aside Pattern
最常用的设计模式,也就是上文提到的先更新数据库,再删除缓存,其具体逻辑如下:
失效:应用程序先从 Cache 取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
命中:应用程序从 Cache 中取数据,取到后返回。
更新:先把数据存到数据库中,成功后,再让缓存失效。
这个方案解决了之前场景出现的问题,但是是否是天衣无缝的呢?且看下图。
Read/Write Through Pattern
我们可以看到,在上面的 Cache Aside 套路中,我们的应用代码需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository)。所以,应用程序比较啰嗦。而 Read/Write Through 套路是把更新数据库(Repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的Cache。
Read Through 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),Cache Aside 是由调用方负责把数据加载入缓存,而 Read Through 则用缓存服务自己来加载,从而对应用方是透明的。
Write Through 套路和 Read Through 相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由 Cache 自己更新数据库(这是一个同步操作)。
其具体执行流程如下:
Write Behind Caching Pattern
Write Behind 又叫 Write Back。一些了解 Linux 操作系统内核的同学对 Write Back 应该非常熟悉,这其实就是Linux文件系统的 Page Cache 的算法。Write Back 套路,一句说就是,在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。
优点:
直接操作内存,省掉了 DB 操作,性能非常非常好。
异步更新数据到数据库,可以把对同一个数据的多次操作进行合并。
缺点:
数据不是强一致性的,甚至可能丢失。
实现的逻辑较为复杂,需要跟踪哪些数据更新了。
其具体执行流程如下:
缓存的几个大坑
缓存穿透
缓存穿透是指查询一个一定不存在的数据时,首先,缓存肯定不命中,从而就要查数据库来获取这个值并写入缓存后返回,但是由于在数据库中也没有这个值,所以结果也不会写入缓存中。这将导致这个数据每次请求都要到数据库查询,失去了缓存的意义。在流量大的时候,可能数据库就挂了。另外要是有人利用不存在的 key 频繁请求数据库,这就是系统的漏洞。
解决方案:
设定指定值:如果一个查询返回的数据为空(可能由于数据不存在,也可能是系统故障),我们仍然把这个空结果进行缓存,缓存的值设定为一个指定值,同时设置它的过期时间很短,最长不超过五分钟。(因为缓存会占用内存,长时间缓存一个不存在的值比较耗资源。)在这五分钟内,这个值可能由于写入操作从而不再是一个不存在的值,这是就要更新缓存,用真实值替代指定值。
过滤器:最常见的就是布隆过滤器,将所有可能存在的数据哈希到一个足够大的 Bitmap 中,一个一定不存在的数据会被这个 Bitmap 拦截掉,从而避免了对底层存储系统的查询压力。
缓存并发
有时候如果网站并发访问高,一个缓存如果失效,可能出现多个进程同时查询 DB,同时设置缓存的情况,如果并发确实很大,这也可能造成 DB 压力过大,还有缓存频繁更新的问题。
解决方案:
加分布式锁:加载数据的时候可以利用分布式锁锁住这个数据的 Key,在 Redis 中直接使用 setNX 操作即可,对于获取到这个锁的线程,查询数据库更新缓存,其他线程采取重试策略,这样数据库不会同时受到很多线程访问同一条数据。
异步加载:由于缓存并发是热点数据才会出现的问题,可以对这部分热点数据采取到期自动刷新的策略,而不是到期自动淘汰。淘汰其实也是为了数据的时效性,所以采用自动刷新也可以。
缓存雪崩
缓存雪崩指的是在某一个时刻,大量缓存同时失效,请求全部转到了数据库,导致数据库压力过大。引起这个问题的主要原因还是高并发。平时我们设定一个缓存的过期时间时,可能有一些会设置1分钟后或5分钟后。当并发很高时,会出在某一个时间同时生成了很多的缓存,并且过期时间都一样,这个时候就可能引发一当过期时间到后,这些缓存同时失效,请求全部转发到 DB ,DB 可能会压力过重。 当发生大量的缓存穿透,例如对某个失效的缓存的高并发访问也会造成缓存雪崩。
解决方案:
合理设计的缓存过期时间,将数据失效时间均匀地分布在时间轴上,一定程度上能够避免缓存同时失效带来的雪崩效应。例如可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
采用多级缓存,不同级别缓存设置的超时时间不同,及时某个级别缓存都过期,也有其他级别缓存兜底。
最后
本文主要给大家介绍了为什么需要缓存(多级缓存),不同缓存分别适用的场景以及缓存应用上需要注意的一些地方。当然,要想用好缓存光靠这些还远远不够,例如缓存的调优和监控、序列化、本地缓存的GC调优等等这些也是本文没有谈到的地方,还需要大家结合实际情况来进行处理。
参考
全文完
以下文章您可能也会感兴趣:
我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com 。
杏仁技术站
长按左侧二维码关注我们,这里有一群热血青年期待着与您相会。