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 更新模式
这是最常用的缓存模式,具体流程是:

- 避坑指南一:先更新数据库,再更新缓存
这种方法最大的问题就是两个并发写操作导致脏数据。如下图(以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。
参考: