什么是LRU?
全名(Least recently used 最近最少使用),非常经典的一种算法,像MySQL的Buffer Pool中以及redis的删除策略使用的都是这个。
前一篇文章说过MySQL的Buffer Pool和redis都是基于内存的,且内存大小都是有限的。所以当内存空间不足时,需要淘汰一些很少使用的数据,释放内存空间给热点数据使用。
LRU是一种缓存的置换算法,一种数据淘汰策略,所以不论是缓存的内存消耗还是热度排序问题,最优的解决方案其实都是-LRU算法。那么如何确定哪些数据属于“最少使用”就很关键,大致从两个问题考虑,时间和频率。长时间没有被访问的数据可以淘汰,同时间段访问频率最少的也可以淘汰。
LRU是一种概念,关注的是算法实现,所以先使用一种方式来实现:
简单的链表结构:
//存放缓存的链表
public static final LinkedList<Demo> LruList = new LinkedList<>();
//链表的限制长度
public static final int CacheLimit = 3;
/**
* 主逻辑,缓存命中,则把当前元素先从链表上删除,并且放至头部
* @param id
* @return
*/
public static Demo getData(String id) {
Demo demo = null;
synchronized (LruList) {
for (int i = 0; i < LruList.size(); i++) {
if (id.equals(LruList.get(i).getId())) {
demo = LruList.remove(i);
putNode(demo);
}
}
}
//模拟,缓存中不存在,则从DB拿数据放到缓存中
if (null == demo){
LruList.add(new Demo(id));
}
//如果链表中的热点数据超过了限制的长度大小,则删除链表尾部的数据,也就是淘汰策略的实现
if (null != demo) {
putNode(demo);
}
return null;
}
/**
* 队列已满,删除链表尾部的数据,否则将该数据放至头部
* @param demo
*/
public static void putNode(Demo demo){
synchronized (LruList) {
if (LruList.size() == CacheLimit) {
LruList.removeLast();
}
LruList.addFirst(demo);
}
}
public static void main(String[] args) {
String[] ids = new String[]{"1", "2", "2", "4", "5", "5", "3", "5"};
for (int i = 0; i < ids.length; i++) {
getData(ids[i]);
}
}
class Demo {
private String id;
public String getId() {
return id;
}
public Demo(String id) {
this.id = id;
}
}
一种基于链表的LRU算法已经实现了,但仔细分析一下,是存在弊端的
- 性能消耗:每次命中数据后,都需要将数据重新移动至链表头部,性能消耗是很严重的。
- 缓存命中率:如果每次访问的数据都是在链表头部附近的,遍历会很快就定位到数据,但若数据一旦分散较为均匀,而链表又是每次都从头部遍历,访问命中率就会下降
- 缓存污染:并非所有的数据都有必要缓存,可能整个应用运行过程中某些数据被访问的次数很少,但是算法实现过程没法对热点数据进行区分,导致缓存中存在一些不会再被访问的数据。所以解决缓存污染的关键点就是 能识别出只访问一次或者访问次数很少的数据。
针对以上几种问题,我们来一一提出方案优化
解决方案:(HashMap + 双向链表)
1、解决性能问题
当问题无法通过算法来提升程序执行效率,并减少内存开支的时候,那就要考虑一下是不是数据结构的问题,性能的瓶颈就是在于链表的遍历,此时HashMap穿着超人内裤出现了,没错就是我!
在链表的基础上,再补充一个HashMap,key值存放数据的id,Value则存放缓存数据,key存在,那么缓存一定存在。
上面说了使用链表,每次缓存命中都会把该元素从原始位置删除,并且放至头部,当元素分散或在链表尾端,则需要全链表遍历,那么相应的替代方案就是双向链表, 链表中每一个元素都包含前后节点的引用,此时就不再需要遍历链表,调整元素的前后节点引用即可。
HashMap的底层实现原理在之前的《源码解析》- 十分钟了解HashMap的实现原理文章中有写过,不熟悉的童鞋可以移步先去了解一下。
伪代码先忽略。。。。之后补上
2、解决缓存命中率和缓存污染(LRU-K)
这个其实可以规避成一个问题,解决了缓存命中率实际上就是解决缓存污染的问题,
LRU-K简单理解,K表示每个缓存最近使用的次数,也就需要多维护一个队列,记录所有缓存的历史,只有当缓存被访问的次数到达一定阈值时,才将数据放入缓存。当需要淘汰时,LRU-K会淘汰第K次访问距离当前时间最大的数据。
再理的明白一点:
- 初问访问的数据,放至临时队列,命中次数+1
- 再次访问数据,命中次数继续+1
- 命中次数大于阈值,转入缓存队列
- 临时队列采用FIFO或者LRU或者时间算法实现淘汰
- 缓存队列可按命中的次数排序,FIFO或者时间排序实现淘汰
案例代码,之后会补上,此篇以理论知识为主。如果你理解了,可自行写个案例分享到评论区哈~