1. 什么是缓存
缓存有很多种,从 CPU 缓存、磁盘缓存到浏览器缓存等,本文所说的缓存,主要针对后端系统的缓存。也就是将程序或系统经常要使用的对象存在内存中,以便在使用时可以快速调用,也可以避免加载数据或者创建重复的实例,以达到减少系统开销,提高系统效率的目的。
2. 为什么要用缓存
我们一般都会把数据存放在关系型数据库中,不管数据库的性能有多么好,一个简单的查询也要消耗毫秒级的时间,这样我们常说的 QPS 就会被数据库的性能所限制,我们想要提高QPS,只能选择更快的存储设备。
在日常开发有这样的一种场景:某些数据的数据量不大、不经常变动,但访问却很频繁。受限于硬盘 IO 性能或者远程网络等原因,每次都直接获取会消耗大量的资源。可能会导致我们的响应变慢甚至造成系统压力过大,这在一些业务上是不能忍的,而缓存正是解决这类问题的神器。
但是有一点需要注意,就是缓存的占用空间以及缓存的失效策略,下文也会提到。
3. 使用缓存的场景
对于缓存来说,数据不常变更且查询比较频繁是最好的场景,如果查询量不够大或者数据变动太频繁,缓存也就是失去了意义。
日常工作使用的缓存可以分为内部缓存和外部缓存。
内部缓存一般是指存放在运行实例内部并使用实例内存的缓存,这种缓存可以使用代码直接访问。
外部缓存一般是指存放在运行实例外部的缓存,通常是通过网络获取,反序列化后进行访问。
一般来说对于不需要实例间同步的,都更加推荐内部缓存,因为内部缓存有访问方便,性能好的特点;需要实例间同步的数据可以使用外部缓存。
下面对这两种类型的缓存分别的进行介绍。
3.1 内部缓存
为什么要是用内部缓存
在系统中,有些数据量不大、不常变化,但是访问十分频繁,例如省、市、区数据。针对这种场景,可以将数据加载到应用的内存中,以提升系统的访问效率,减少无谓的数据库和网路的访问。
内部缓存的限制就是存放的数据总量不能超出内存容量,毕竟还是在 JVM 里的。
最简单的内部缓存 - Map
功能强大的内部缓存 - Guava Cache / Caffeine
Guava中本地缓存基本原理为:ConcurrentMap(利用分段锁降低锁粒度) + LRU算法。
本地缓存的优点:
-
直接使用内存,速度快,通常存取的性能可以达到每秒千万级
-
可以直接使用 Java 对象存取
本地缓存的缺点:
-
数据保存在当前实例中,无法共享
-
重启应用会丢失
Guava Cache 的替代者 Caffeine
Spring 5 使用 Caffeine 来代替 Guava Cache,应该是从性能的角度考虑的。从很多性能测试来看 Caffeine 各方面的性能都要比 Guava 要好。
Caffeine 的 API 的操作功能和 Guava 是基本保持一致的,并且 Caffeine 为了兼容之前 Guava 的用户,做了一个 Guava 的 Adapter, 也是十分的贴心。
如果想了解更多请参考:是什么让 Spring 5 放弃了使用 Guava Cache?
3.2 外部缓存
最著名的外部缓存 - Redis / Memcached
也许是 Redis 太有名,只要一提到缓存,基本上都会说起 Redis。但其实这类缓存的鼻祖应该是 LiveJournal 开发的 Memcached。
Redis / Memcached 都是使用内存作为存储,所以性能上要比数据库要好很多,再加上Redis 还支持很多种数据结构,使用起来也挺方便,所以作为很多人的首选。
Redis 确实不错,不过即便是使用内存,也还是需要通过网络来访问,所以网络的性能决定了 Reids 的性能;
我曾经做过一些性能测试,在万兆网卡的情况下,对于 Key 和 Value 都是长度为 20 Byte 的字符串的 get 和 set 是每秒10w左右的,如果 Key 或者 Value 的长度更大或者使用数据结构,这个会更慢一些;
作为一般的系统来使用已经绰绰有余了,从目前来看,Redis 确实很适合来做系统中的缓存。
如果考虑多实例或者分布式,可以考虑下面的方式:
-
Jedis 的 ShardedJedis( 调用端自己实现分片 )
-
twemproxy / codis( 第三方组件实现代理 )
-
Redis Cluster( 3.0 之后官方提供的集群方案 )
这些方案各有特点,这次先不展开讨论,有兴趣的可以先研究一下。
Redis有很多优点:
-
很容易做数据分片、分布式,可以做到很大的容量
-
使用基数比较大,库比较成熟
同时也有一些缺点:
-
Java 对象需要序列化才能保存
-
如果服务器重启,再不做持久化的情况下会丢失数据,即使有持久化也容易出现各种各样的问题
4. 缓存的更新策略
使用缓存时,更新策略是非常重要的。最常见的缓存更新策略是 Cache Aside Pattern:
-
失效:应用程序先从 cache 取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
-
命中:应用程序从 cache 中取数据,取到后返回。
-
更新:先把数据存到数据库中,成功后,再让缓存失效。
不管是内部缓存还是外部缓存,都可以使用这样的更新策略,如果缓存系统支持,也可以通过设置过期时间来更新缓存。
更多的更新策略可以参考左耳朵耗子的这篇缓存更新的套路。
5. 缓存使用常见误区
序列化方案的选择
序列化的选择,尽量避免使用 Java 原生的机制,因为原生的序列化依赖 serialVersionUID 来判断版本,如果改变就无法正常的反序列化。
一般推荐使用 Json 或者 Hessian、ProtoBuf 等二进制方式。
缓存大对象
在缓存中存放大对象,存取的代价都比较高。实际使用时,往往只是需要其中的一部分,这样会导致每一次读取都消耗更多的网络和内存资源,也会浪费缓存的容量。
当然如果每次都是用完整的对象,这样做是没有问题的。
使用缓存进行数据共享
使用缓存来当作线程甚至进程之间的数据共享方式,会让系统间产生隐形的依赖,并且也可能会产生一些竞争,常常会发生问题。所以不推荐使用这种方式来共享数据。
没有及时更新或者删除缓存中已经过期或失效的数据
这个理解起来就很简单了,如果没有及时更新或者删除,就有可能读取到错误的数据,从而导致业务的错误。
对于支持设置过期时间的缓存系统,可以对每一个数据设置合适的过期时间,来尽量避免这样的情况。
6. libshmcache介绍
libshmcache使用场景