什么是缓存
所谓缓存,就是将程序或系统经常要调用的对象存在内存中,一遍其使用时可以快速调用,不必再去创建新的重复的实例。这样做可以减少系统开销,提高系统效率。
根据存储的位置不同分为两类:
- 文件缓存:通过文件缓存, 顾名思义文件缓存是指把数据存储在磁盘上,不管你是以 XML 格式,序列化文件 DAT 格式还是其它文件格式 。
- 内存缓存:创建一个静态内存区域,将数据存储进去,例如我们 B/S 架构的将数据存储在 Application 中或者存储在一个静态 Map 中 。
根据网络划分:
- 本地缓存
- 网络缓存
为什么需要缓存
在系统中,有些数据,数据量小,但是访问十分频繁(例如国家标准行政区域数据或者一些数据字典等),针对这种场景,需要将数据搞到应用的本地缓存中,以提升系统的访问效率,减少无谓的数据库访问(数据库访问占用数据库连接,同时网络消耗比较大),但是有一点需要注意,就是缓存的占用空间以及缓存的失效策略。
根据场景决定缓存方案
缓存使用有如下几阶段:
本地缓存
通过在Web应用端内嵌一个本地缓存。
优势
- 访问比较快
- 易于创建使用
劣势
- 数据更新的一致性比较难保证
单机版的远程缓存
通过部署一套远程的缓存服务,然后应用端通过网络请求与缓存交互。
分布式缓存
缓存服务本身是一个大集群,能够提供给各种业务应用使用,并提供一些基本的分布式特性:水平扩展、容灾、数据一致性等。
自实现缓存的一些问题
- 没有缓存大小的设置,无法限定缓存提的大小以及存储数据的限制
- 没有缓存的失效策略
- 没有弱键引用,在内存占用吃紧的情况下,JVM 是无法回收的
- 没有监控统计
- 没有持久性存储
缓存更新的套路
缓存的更新操作是保证缓存数据一致性的关键。
先记录一个更新缓存不好的方案。
好些人在写更新缓存数据代码时,先删除缓存,然后再更新数据库,而后续的操作会把数据再装载的缓存中。然而,这个是逻辑是错误的。试想,两个并发操作,一个是更新操作,另一个是查询操作,更新操作删除缓存后,查询操作没有命中缓存,先把老数据读出来后放到缓存中,然后更新操作更新了数据库。于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了 。
其实更新缓存也是有套路的,如下
Cache Aside Pattern
- 失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
- 命中:应用程序从cache中取数据,取到后返回。
- 更新:先把数据存到数据库中,成功后,再让缓存失效。
一个是查询操作,一个是更新操作的并发,首先,没有了删除cache数据的操作了,而是先更新了数据库中的数据,此时,缓存依然有效,所以,并发的查询操作拿的是没有更新的数据,但是,更新操作马上让缓存的失效了,后续的查询操作再把数据从数据库中拉出来。而不会像文章开头的那个逻辑产生的问题,后续的查询操作一直都在取老的数据。
至于为什么不是写完数据库后更新缓存,是为了防止两个并发的写操作导致脏数据。
那么,是不是Cache Aside这个就不会有并发问题了?不是的,比如,一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后,之前的那个读操作再把老的数据放进去,所以,会造成脏数据。
但,这个case理论上会出现,不过,实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大。
Read/Write Through Pattern
在上面的Cache Aside套路中,我们的应用代码需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository)。所以,应用程序比较啰嗦。而Read/Write Through套路是把更新数据库(Repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的Cache。
Read Through
Read Through套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),Cache Aside是由调用方负责把数据加载入缓存,而Read Through则用缓存服务自己来加载,从而对应用方是透明的。
Write Through
Write Through套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作) 。
Read/Write Through PatternWrite Behind Caching Pattern
Write Behind又叫Write Back。Write Back套路,一句说就是,在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的I/O操作飞快无比(因为直接操作内存嘛 ),因为异步,write backg还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。
但是,其带来的问题是,数据不是强一致性的,而且可能会丢失(我们知道Unix/Linux非正常关机会导致数据丢失,就是因为这个事)。在软件设计上,我们基本上不可能做出一个没有缺陷的设计,就像算法设计中的时间换空间,空间换时间一个道理,有时候,强一致性和高性能,高可用和高性性是有冲突的。软件设计从来都是取舍Trade-Off。
另外,Write Back实现逻辑比较复杂,因为他需要track有哪数据是被更新了的,需要刷到持久层上。操作系统的write back会在仅当这个cache需要失效的时候,才会被真正持久起来,比如,内存不够了,或是进程退出了等情况,这又叫lazy write。
Write Behind Caching Pattern缓存失效策略
-
FIFO
First In First Out
先进先出,先进入的缓存先被淘汰,即存放时间最久的数据最先淘汰。
缺点:命中率比较低,第一个数据很大可能就是访问频率最高的数据。
-
LRU
Least Recently Used
最近最少使用,每次访问数据都会将其放在我们的队尾,如果需要淘汰数据,就只需要淘汰队首即可,即判断数据最近有没有被使用,使用时间比较早的数据淘汰。
缺点:如果有个数据在1个小时的前59分钟访问了1万次(可见这是个热点数据),再后一分钟没有访问这个数据,但是有其他的数据访问,就导致了我们这个热点数据被淘汰。
-
LFU
Least Frequently Used
最不经常使用,即判断一段时间内数据使用的次数,使用次数少的数据淘汰。
可以看做是对LRU的优化,利用额外空间记录每个数据的使用频率,最后选出频率低的淘汰。
缓存高并发下的问题
缓存穿透
描述
通常缓存都是根据key去查找value,如果缓存中不存在,则去DB中查找,如果查找到了则将此键值对写入缓存。但是,对于某些一直不存在的数据,每次都无法在缓存中查找到,所以每次都要去DB中查找,DB中也找不到所以没法写入缓存,如此往复,便失去了缓存的意义。 高并发下对DB造成极大的压力。
解决方案
- 对到DB中查询为空的情况也进行缓存(如约定一个特殊字符串来代替),但是这个数据的缓存过期时间需要设置得短一点。
- 将所有可能存在的数据根据key哈希到一个bitmap中,不可能存在的数据直接被bitmap过滤掉,不去查询DB
缓存并发
描述
高并发下,多个线程查询到某个缓存失效(或者该key没有缓存),同时去DB中查找并设置缓存,引起的压力问题。
解决方案
- 当查询到key为空时,加锁,然后查DB,更新缓存,解锁
缓存失效
描述
高并发下,我们若某一时间同时给大量缓存设置了相同的失效时间,导致某一时刻缓存同时失效;或者机器重启导致的缓存全部同时失效。这会让大量请求同时去查找DB,给DB造成巨大的压力。
解决方案
- 控制读取DB的线程数量
- 随机设置缓存的失效时间,避免大量缓存同时失效
推荐缓存框架
- Ehcache(也可以做分布式)
- Redis
- memcached
- Guava Cache
网友评论