赫赫有名的双刃剑:缓存(下)
缓存使用的问题
缓存穿透 (没起作用)
缓存穿透,指的是在某些情况下,大量对于同一个数据的访问,经过了缓存屏障,但是缓存却未能起到应有的保护作用。
举例来说,对某一个 key 的查询,如果数据库里没有这个数据,那么缓存中也没有数据的存放,每次请求到来都会去查询数据库,缓存根本起不到应有的作用。
解决:
- 可以在缓存中对这个 key 存放一个空结果,毕竟“没有结果”也是结果,也是需要缓存起来的。
- 使用布隆过滤器等数据结构,在数据库查询之前,预先过滤掉某些不存在的结果。
特殊情况:
一般的缓存策略下,往往需要先发生一次缓存命中失败,接着从实际存储(比如数据库)中得到结果,再回填到内存缓存中。但是,如果这个数据库查询过程比较慢,大量同一数据的请求像雨点一样几乎同时到来,就会全部穿透缓存,一并落到了数据库上,而那个时候最早的那个请求引发的缓存回填甚至都还没有发生,在这种情况下数据库直接就挂掉了,虽然缓存的机制本身看起来并没有任何问题。
解决:
- 流量控制的方式,限制对于同一数据的访问,必须等到前一个完成以后,下一个才能进行,即如果缓存失效而引发的数据库查询正在进行,其它请求就得老老实实地等着。这种方法通用性好,但这个等待机制可能较为复杂,且有可能影响用户体验。
- 缓存预热,在大批量请求到来以前,先主动将该缓存填充好。这种方法操作简单高效,但局限性是需要提前知道哪些数据可能引发缓存穿透的问题。
缓存雪崩 (崩了)
原本起屏障作用的缓存,如果在一定的时间段内,对于大量的请求访问失效,即失去了屏障作用,造成它后方的系统压力过大,引起系统过载、宕机等问题,就叫做缓存雪崩。
解决:
- 限流. 保证了请求大量落到数据库的时候,系统只接纳能够承载的数量
- 预热. 在请求访问前,先主动地往内存中加载一定的热点数据,这样请求到来的时候,缓存不是空的,已经具有一定的保护能力了
其它场景:
缓存数据通常都有过期时间的,如果缓存加载的时间比较集中,那么很可能到了某一时间点,大量的缓存就会同时过期,于是对应这些数据的请求全部落到了后面的数据库上,从而造成系统崩溃。
解决:
- 避免缓存集中写入的时间,如果无法避免,就使用一个范围随机数来均匀地分散过期时间,从而打散缓存过期对系统造成的压力。
缓存容量失控
可能的原因:
- 使用时间条件触发的任务来完成. 通过时间因素来限制空间大小,远不如通过队列长度来限制空间大小来得可靠。换句话说,如果这 10 分钟内事件暴增,链表就很容易变得非常大。这个变化范围取决于请求的上限,而不是在缓存系统自己的掌控中。
- 清理内存 (缓存) 遇到异常而无法彻底清空. 链表清空数据并写入数据库是一个耗时的异步行为,这是另一个受控性较差的点。
LRU 的致命缺陷
LRU 指的是 Least Recently Used,最少最近使用算法。这是缓存队列维护的最常见算法,原理是:维护一个限定最大容量的队列,队列头部总是放置最近访问的元素(包括新加入的元素),而在超过容量限制时总是从队尾淘汰元素。
image.png如果用户有意无意地访问一些错误信息,就会破坏掉这个 LRU 队列中最近访问数据的真实性。
解决:
- LRU-K. 就是主缓存队列排的是“第 K 次访问的元素”,也就是说,如果访问次数小于 K,则在另外的一个“低级”队列中维护,这样就保证了只有到达一定的访问下限才会被送到主 LRU 队列中。(某个 key 命中一定次数后才放到主 LRU 队列中, 先在 "低级" 队列中积攒人气)
- 这种方法保证了偶然的页面访问不会影响网站在 LRU 队列中应有的数据分布。再进一步优化,可以将两级队列变成更多级,或者是将低级队列的策略变成 FIFO(2Q 算法)等等,但原理是不变的。
缓存框架
鉴于缓存的普遍性,缓存框架也可以说是百花齐放。
集成方式
方式 1:编程方式
Cache<String, City> cityCache = cacheManager.createCache("cityCache", CacheConfigurationBuilder.newCacheConfigurationBuilder(String.class, City.class, resourcePools));
cityCache.put("Beijing", beijingInfo);
City beijing = cityCache.get("Beijing");
方式 2:方法注解
@Cacheable(value="getCity", key="#name")
public City getCity(String name) { ... }
方式 3:配置文件的注入
<mapper namespace="..." >
<cache type="org.mybatis.caches.ehcache.EhcacheCache"/>
...
</mapper>
方式 4:Web 容器的 Filter
在 Ehcache 2 中,可以配置 net.sf.ehcache.constructs.web.filter.SimplePageCachingFilter 这样一个 filter 到 Tomcat 的 web.xml 中,再配合 filter 的映射匹配参数和初始化参数,就可以实现整个请求的过滤功能。
方式 5:页面模板中的 Cache 标签
这种方式相对比较少见,有一些页面模板支持 Cache 标签或表达式语法(例如 Django 中,它被称为 Template Fragment Caching),在标签属性或语法参数中可以指定缓存的时间和条件,标签内部的 HTML 将被缓存起来,以避免在每次模板渲染时都去执行其中的逻辑。
核心要素
要素 1:缓存数据的生命周期管理
- 创建
- 更新
- 移动
- 淘汰
<mapper namespace="..." >
<cache type="org.mybatis.caches.ehcache.EhcacheCache"/>
...
</mapper>
- Flush,右侧黄色的箭头,数据从高层向低层移动;
- Fault,左侧绿色箭头,数据从低层拷贝到高层,但不删除;
- Eviction,下方红色箭头,数据永久淘汰出缓存数据容器;
- Expiration,上方烟灰色图案,数据过期了,意味着可以被 flushed 或者 evicted,但是考虑到性能,不一定立即执行这个操作;
- Pinning,右上角蓝色图案,数据被强制钉在某一层,不受流动规则控制。
要素 2:数据变动规则
- 何时触发上述行为进行.
要素 3:核心 API
这里本质上反映的是缓存框架实现的时候,核心代码结构的设计。当我们把这类的代码结构设计进一步上升到规范层面,它们就可以被定义成接口,即允许不同的缓存框架可以实现同样的设计.
要素 4:用户侧 API
这是指暴露给用户访问缓存的接口,比如常见的向缓存内放置一条数据的接口,或者从缓存内取出一条数据的接口。值得一提的是,我们通常见到的用户 API 都是 Map-like 的结构,即众所周知的 key-value 形式,但其实缓存框架完全可以支持其它的形式,这取决于数据访问的方式,因此这并不是一个绝对的限制。
公众号:码农架构