浅淡缓存
缓存作为常用的优化手段,是架构师必备技能之一,在面试时我也喜欢让候选人系统的介绍一下缓存知识,能把缓存体系说清楚的并不多。
单机硬件角度缓存
下图是经典的计算机组成原理的缓存结构图
速度从高到低依次是:L1 > L2 > L3 > 内存 > 磁盘(硬盘缓存+硬盘)
单位容量制造成本从高到低依次是:L1 > L2 > L3 > 内存 > 磁盘(硬盘缓存+硬盘)
对硬件来说,缓存是基于有限的成本下,介于相对高速和相对低速设备之间的缓冲,目的是尽可能的提升处理效率。
用户角度看缓存
DNS缓存
对终端用户而言,操作系统和浏览器都存在着DNS缓存
CDN
CDN不是缓存,其核心思想是将用户的请求导向离用户最近的服务节点上,提高用户访问网站的响应速度。只是这里说的最近的服务节点一般是容量有限的缓存服务器。
浏览器缓存
最典型流程是浏览器第一次访问时,服务器返回当前时间Last-Modified值,记为t,当下一次访问时,If-Modified-Since取值t,配合Cache-Control,如果服务端资源未被修改,返回状态码304,浏览器直接从本地磁盘取缓存的网页文件。
对用户来说,缓存是为了从最近的地方取得所需内容,玩命减少等待时间,提升响应速度。
架构角度看缓存
接入层缓存:Nginx缓存
进程内缓存:JVM堆内缓存、堆外缓存
主机内缓存:Local Redis、本地磁盘、Berkeley DB
跨主机缓存: Redis、Memcached
某个请求过来后,响应速度从快到慢依次是: 接入层 > 进程内 > 主机内 > 跨网络
从架构的角度看,缓存是分层的结构中各层减少跨层调用,提升响应速度的有效手段。
开发角度看缓存
大多数时候,这部分是我们关注的重点,通常所说的缓存手段一般也最先从这里开始。
最简单的缓存实现莫过于HashMap了,为了线程访问安全也有HashTable或ConcurrentHashMap的,也有为了实现一些简单的控制或淘汰机制自己引用封装HashMap的。用HashMap最大的缺点就是一个不好就容易出现OOM,这时候就有人利用Java的软引用加HashMap实现了SoftReferenceHashMap,这样在JVM内存不足的时候对象会被回收,释放内存,这就是缓存的雏形。可终究内存有限怎么办?也有人利用BekeleyDB+HashMap实现基于硬盘的大HashMap。
上面自实现的SoftReferenceHashMap没有灵活的淘汰算法,只能算小半成品,对于成熟的企业应用来说是远远不够的,相比之下EhCache 具有快速、精干、灵活等特点。
即使用上了EhCache,当堆内缓存过大时,FULL GC又显得不那么顺畅了,还好它也支持堆外缓存,付出的代价是多了额外的序列化工作,得到的好处是减少堆大小,可以使用更大的内存。
只考虑单机的情况,EhCache似乎已经足够了。但是,随着分布式架构的流行,Memcached和Redis又逐渐进入视野,相比之下,Redis支持更丰富的数据结构,更多的特性(如持久化),但有一点需要特别注意的,与Memcached相比,Redis采用了单线程,要尽量避免使用单个大Cache。可终究访问分布式Cache服务器是跨网络调用,即使是千兆带宽也比不上本地Cache的响应速度,通常这时候会进行缓存分层,即分布式层面有Redis,单机层面有EhCache,最热的数据先从EhCache中取,不存在时再从Redis取,分层缓存一定程度使得Cache的淘汰变得更复杂了。
单台Redis服务器容量始终有限,是时候开始考虑Sharding了,于是又有 自定义开发+Sentinel、TwemProxy、RedisCluster等实现,分别从Client端、代理层、Server端层面解决了问题,这些技术甚至可以组合实现更符合特定场景下的个性需求。
尽管我们的开发人员在努力减少查数据库,但其实数据库本身并不总是低效的IO操作,其内部也是有缓存实现的,为了提高查询效率,数据库会在内存划分一个专门的区域,用来存放用户最近执行的查询,这块区域就是缓存。只是和上面的缓存技术相比,数据库缓存显得并不那么好控。
对开发而主,宏观角度上的缓存介绍已经告一段落了,下面从更微观的角度来看缓存
MVC模式可以说是我们最熟悉的开发模式之一,通常,我们数据流是这样的View -> Controller -> Service -> DAO,由Service或DAO层决定是查数据库还是查Redis缓存,这部分缓存可以说是数据缓存,需要经过序列化才能转化为Java对象,序列化后的对象可以缓存到EhCache的堆内缓存或HashMap中,这部分缓存可以说是对象缓存。View层于B/S项目来说,通常是HTML页面,这些页面中动态的数据来自若干Java对象,而对这些生成后的HTML页面的缓存可以说是页面缓存。离用户由近到远,分别是:页面缓存 -> 对象缓存 -> 数据缓存。这条缓存链中,数据缓存也是我们采用得最多的。
对多数的后端开发者而言,缓存大多数时候是减少与DB的交互...
缓存的更新
缓存一定程度上缓解了读的压力,但对写来说,则情况变得有点复杂了,以前只需要写DB的,现在还需要额外写缓存。
模式1:
写DB->写缓存,这种是最简单直接的方式。缺点是写缓存操作代码“泄露”到了业务代码里,也有使用AOP来实现这部分操作的。
模式2:
写到抽象层,由抽象层 写缓存 -> 写DB (两步在同一事务里,保证强一致),比起模式1,因为多了一层抽象层解耦,所以缓存代码不会“泄露”到业务代码中。
模式3:
写缓存 -> 返回-> 异步写DB,由于写完缓存立即返回,所以性能极高,代价是可能会出现数据不一致,只适用于弱一致性场景。
老外给3个模式分别命名为:Cache-aside、Write-through、Write-behind
缓存淘汰策略:
经典的三种缓存淘汰算法
* LRU(Least Recently Used):最近最少使用算法,淘汰“最后访问时间”较早的。
* LFU(Least Frequently Used):最不经常使用算法,淘汰“访问次数”较少的。
* FIFO(First In First Out):先进先出算法,淘汰“创建时间”较早的。
Redis的淘汰策略
一般来说,我们在使用Redis创建缓存时会指定一个过期时间,然后也通常会有显示调用del删除缓存项的操作,对于没有显式del但又到了过期时间项的缓存,有两种方式淘汰:
1、下次get(xxx)的时候发现该key对应的缓存项已过期,于是主动淘汰之;
2、如果一直没有get(xxx)的操作,怎么办呢?后台任务会定期清理过期缓存。
总结:我们从经典的计算机组成原理的缓存结构开始作为引子,继而从用户的角度看存在浏览器缓存、DNS缓存、CDN,从架构的角度看又存在接入层缓存、应用层缓存,应用层缓存还分进程内缓存、主机内缓存,跨主机缓存,对后端开发来说HashMap、SoftReferenceHashMap、EhCache的演化过程可以说是对缓存的使用场景的深入及对技术理解的发展,然后到分布式架构被广泛采用下的分布式缓存Redis。缓存的分级是必然的结果,但又使缓存的更新变得复杂。经典MVC架构下,缓存还可以分:页面缓存、对象缓存、数据缓存,越接近用户就命中缓存,则意味着更低的成本取得数据和更快的响应时间。接着我们分析了三种缓存更新模式的特点并介绍了三种经典的缓存淘汰算法。最后我们分析了Redis的缓存淘汰策略是定期过期和懒惰策略的结合,这也是对CPU和内存的折衷考量。