缓存

234 阅读8分钟

1. 确认是否需要缓存

一般从两个方面来看是否需要使用缓存:

  • CPU占用:如果你有某些应用需要消耗大量的cpu去计算,比如正则表达式,如果你使用正则表达式比较频繁,那你就应该使用缓存将正则表达式的结果缓存下来。
  • 数据库IO占用:如果你发现你的数据库连接池比较空闲,那么不应该使用缓存。但是如果数据库连接池比较繁忙,甚至经常报出连接不够的报警,那么是时候应该考虑使用缓存了。

如果并没有上述的两个问题,那么你不必为了增加缓存而缓存。

2. 缓存的适用场景

  • 对于数据实时性要求不高:
    对于一些经常访问但是很少改变的数据,读明显多于写,很有必要使用缓存。
  • 对于性能要求高

3. 缓存的三种模式

一般来说,缓存有以下三种模式:

  • Cache Aside 更新模式
  • Read/Write Through 更新模式
  • Write Behind Caching 更新模式

通俗一点就是,同时更新缓存和数据库(Cache Asiede 更新模式);先更新缓存,缓存负责同步更新数据库(Read/Write Through更新模式);先更新缓存,缓存定时异步更新数据库(Write Behind Caching 更新模式)。

3.1 Cache Aside 更新模式

这是最常用的缓存模式,具体流程是:

Cache Aside 更新模式流程图
注意上面所提到的,缓存更新时先更新数据库,然后再让缓存失效。考虑一下为什么不是直接更新缓存呢?这里有一些缓存更新的坑:

  • 避坑指南一:先更新数据库,再更新缓存
    这种方法最大的问题就是两个并发写操作导致脏数据。如下图(以Redis和Mysql为例),两个并发更新操作,先数据库更新的反而后更新缓存,后更新数据库的反而先更新缓存。这样就会造成数据库和缓存中的数据不一致,应用程序中读取的都是脏数据。

  • 避坑指南二:先删除缓存,再更新数据库
    这个逻辑是错误的,因为两个并发的读和写操作导致脏数据。如下图,假设更新操作先删除了缓存,此时正好有一个并发的读操作,没有命中缓存后从数据库中取出老的数据并且更新回缓存,然后更新操作也完成了数据库更新,此时,数据库和缓存中的数据就不一致,应用程序之后的读操作读到的数据都是脏数据。

  • 避坑指南三:先更新数据库,后删除缓存
    其实,这种做法并不能算坑,在实际的系统中也推荐使用这种方式。但是这种方式理论上还是可能存在问题。如下图,查询操作没有命中缓存,然后到数据库中查找出旧数据。此时出现一个并发的更新操作,更新操作在读操作读取了数据库中的旧数据之后,更新了数据库中的数据并且删除了缓存中的数据(此时缓存中没有更新的数据)。然后此时读操作将从数据库中读取的旧数据更新回了缓存中。这样就导致数据库和缓存中的数据不一致,后续应用程序读取的都是旧数据(脏数据)。

但是,仔细想一想,这种并发的概率极低。因为这个条件需要发生在读操作是缓存失效,而且有一个并发的写操作。实际上数据库的写操作会比读操作慢得多,而且还要加锁,而读操作必须在写操作钱进入数据库操作,又要晚于写操作更新缓存,所有这些条件都具备的概率并不大。但是为了避免这种极端的情况造成脏数据所产生的的影响,我们还是要为缓存设置过期时间。

3.2 Read/Write Through 更新模式

在上面的Cache Aside 更新模式中,应用代码需要维护两个数据存储,一个是缓存(Cache),一个是数据库。而在Read/Write Through 更新模式中,应用程序只需要维护缓存,数据库的维护工作有缓存代理了。

  • Read Through
    Read Through 模式就是在查询操作中更新缓存,也就是说,当缓存失效的时候,Cache Aside模式是由调用方负责把数据加载到缓存,而Read Through则用缓存服务自己来加载。
  • Write Through
    Write Through 模式和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后缓存自己更新数据库(这是一个同步操作)

3.3 Write Behind Caching 更新模式

Write Behind Caching 更新模式就是在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是直接操作内存速度快。因为异步,Write Behind Caching 更新模式还可以合并对同一个数据的多次操作到数据库,所以性能的提高是相当可观的。

但其带来的问题是,数据不是强一致性的,而且可能会丢失。另外,Write Behind Caching更新模式实现逻辑比较复杂,因为它需要确认有哪些数据是被更新了的,哪些数据需要刷到持久层上。只有在缓存需要失效的时候,才会把它真正持久起来。

3.4 三种模式总结

三种缓存模式的优缺点:

  • Cache Aside 更新模式实现起来比较简单,但是需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository)。
  • Read/Write Through 更新模式只需要维护一个数据存储(缓存),但是实现起来要复杂一些。
  • Write Behind Caching 更新模式和Read/Write Through 更新模式类似,区别是Write Behind Caching 更新模式的数据持久化操作是异步的,但是Read/Write Through 更新模式的数据持久化操作是同步的。优点是直接操作内存速度快,多次操作可以合并持久化到数据库。缺点是数据可能会丢失,例如系统断电等。

缓存是通过牺牲强一致性来提高性能的。所以使用缓存提升性能,就是会有数据更新的延迟。这需要我们在设计时结合业务仔细思考是否适合用缓存。然后缓存一定要设置过期时间,这个时间太短太长都不好,太短的话请求可能会比较多的落到数据库上,这也意味着失去了缓存的优势。太长的话缓存中的脏数据会使系统长时间处于一个延迟的状态,而且系统中长时间没有人访问的数据一直存在内存中不过期,浪费内存。

4. 选择合适的缓存

缓存分为进程内缓存和分布式缓存两种。

4.1 选择合适的进程缓存

  • ConcurrentHashMap:读写性能好、分段锁、jdk自带类很小、不支持持久化、不支持集群。适合缓存比较固定不变的元素,且缓存的数量较小的。
  • LRUMap:读写性能一般,全局锁、淘汰算法是LRU、基于LinkedHashMap、较小、不支持持久化、不支持集群。如果不想引入第三方包,又想使用淘汰算法淘汰数据,可以使用这个
  • Ehcache:读写性能好、支持多种淘汰算法(LRU、LFT、FIFO)、功能很丰富、工具比较大、支持持久化、支持集群。
  • Guava Cache:读写性能好,需要做淘汰操作、淘汰算法是LRU、功能很丰富、工具比较小、不支持持久化、不支持集群。
  • Caffeine:读写性能很好,功能很丰富,工具比较小,不支持持久化、不支持集群。推荐使用Caffeine,其在命中率,读写性能上都比Guava Cache好很多,并且其API和Guava Cache基本一致。

如果不需要淘汰算法则选择ConcurrentHashMap,如果需要淘汰算法和一些丰富的API,推荐使用Caffeine。

4.2 选择合适的分布式缓存

  • MemCache: 只支持简单的key-value数据结构、不支持持久化、数据纯内存,数据存储不宜过多、读写性能很高
  • Redis: 支持String、Hash、List、Set、Sorted Set;支持持久化;数据纯内存;读写性能很高
  • Tair: String、HashMap、List、Set;支持持久化;可以配置全内存或内存+磁盘引擎,数据容量可无限扩充;读写性能对于String类型比较高,复杂类型比较慢

如果服务对延迟比较敏感、Map/Set数据比较多的话,适合Redis。如果服务需要放入的缓存量的数据很大,对延迟又不是特别敏感的话,可以选择Tair。

参考: