文章目录
一 什么是LRU算法
LRU的全名为Least Recently Used,意指最近少用,这是一种非常经典的算法,应用范围非常的广,典型的Redis缓存就采用了各类变种的LRU算法。
LRU常常被用来处理大量缓存数据的排序问题。当下应用项目随着业务规模的上升,数据访问量也不断的攀升,对于静态、热点数据的访问已经对生产环境造成了巨大压力。
常规的解决思路为对静态数据或热点数据进行缓存处理,比如说我当下参与的项目中,数据缓存就被分为三层(数据库自身的缓存处理不谈):
- 应用进程级别全局缓存
- 数据库事务级别缓存
- 数据库连接级别缓存
这里还不包括分布式部署的Redis缓存处理(我们仅将静态数据交予Redis管理)。
虽然缓存数据使DB的访问压力问题得以解决,但是随之而来的是缓存造成的内存开支以及定位缓存所产生的效率问题。一旦缓存的数据量过大,那么对应用节点的内存消耗就非常严重;而且对于被缓存数据的检索也变得愈加困难,怎么办?对于内存消耗问题,解决思路是限制缓存大小;对于检索效率问题,解决思路为对数据进行热度排序。
不论是缓存的内存消耗还是热度排序问题,其解决方案均为——LRU,将频繁访问的数据安置在缓存队列的头部,以实现快速检索;对于热度较低的数据,将其移除缓存节省内存开支(淘汰策略)。
综上,LRU是一种缓存置换算法,一种数据淘汰策略,那么如何确定哪些数据属于“最近少用”就很关键了,大致从两个问题考虑,一是时间,二是频度。长时间没有被访问的数据可以淘汰,同时间段内访问频次低的也可以淘汰,下面就针对各种这种核心思想来看看如何实现LRU算法。
二 手工实现LRU
手工实现LRU算法的思路非常多,之所以把这个章节放在最前,就是为了让道友门能理解算法的核心思想,后续的章节会把Redis中的LRU算法应用再整理一遍。
2.1 简单链表实现
这是一种最简单的实现原型,算法处理过程如下:
- 新数据插入到链表头部;
- 每当缓存命中(即缓存数据被访问),则将数据移到链表头部;
- 当链表满的时候,将链表尾部的数据丢弃。
这就需要程序中维护一个链表,并且每次有数据访问的时候,从链表头部开始遍历。常规情况下我们在访问一个数据的时候,一定是通过数据的ID来进行检索,所以用如下结构来示意存储数据的格式:
/**
* @author 柠檬睡客
*/
class Data {
/**
* 数据ID,一般为数据表的主键,如果是存放于Redis的缓存数据则ID更为复杂,需通过既定的编码规则来生成,甚至需要包含版本信息
*/
private String id;
public String getId() {
return this.id;
}
}
接下来我们使用LinkedList来描述缓存队列,并且限制队列的长度以控制缓存大小:
/**
* @author 柠檬睡客
*
* 以链表来实现的LRU算法
*/
public class ListLRU {
/**
* 用以存放缓存数据的链表
*/
private static final LinkedList<Data> LRUCacheList = new LinkedList<Data>();
/**
* 限制缓存链表的长度
*/
private static final int CacheLimit = 100;
}
接下来需要实现数据的读取,注意:缓存工具不应该放开数据存放接口,对于应用来说数据访问应该是透明的,我们假定优先从缓存读取数据,若无缓存则从数据库读取,若数据库读取成功则将数据加入缓存,再返回给应用:
/**
* 供应用访问,优先从缓存获取数据,无缓存再访问DB
*/
public static Data getData(String id) {
Data data = null;
synchronized (LRUCacheList) {
for (int i = 0; i < LRUCacheList.size(); i++) {
// 当命中数据时,需要将数据移动至链表头部
if (id.equals(LRUCacheList.get(i).getId())) {
data = LRUCacheList.remove(i);
putData(data);
return data;
}
}
}
if (data == null) {
data = getDataFromDB(id);
}
if (data != null) {
putData(data);
}
return data;
}
/**
* 从DB访问数据,此方法对应用不放开
*/
private static Data getDataFromDB(String id) {
return null;
}
因为getData方法已经解决了数据重复的问题,所以存放数据的时候一律添加至头部,如果此时队列已满,则删除队列末尾的数据即可,以此实现淘汰策略:
/**
* 增加缓存数据的方法不应对应用放开
*/
private static void putData(Data data) {
synchronized (LRUCacheList) {
if (LRUCacheList.size() == CacheLimit) {
LRUCacheList.removeLast();
}
LRUCacheList.addFirst(data);
}
}
OK,一个基于链表的简单LRU算法已经实现了,仔细分析下这个实现过程,它是存在缺陷的:
- 命中率问题:每次访问数据都需要从链表头开始遍历,这对于热点数据的访问效果很好(热点数据都聚集在链表头部附近,很快就能遍历到结果),可一旦数据分散较为均匀时,访问命中率急剧下降;
- 性能损耗问题:每次命中数据后,都需要将数据重新移动至链表头部,存在相当大的性能损耗;
- 缓存污染问题:并非所有数据有必要缓存(可能整个应用运行过程中某些数据被访问的次数很少),但是算法实现过程没办法对热点数据(频繁访问的数据,这才是有价值进行缓存的)进行区分,使得缓存队列贬值。
针对上述问题,我们再从不同的维度看如何优化,见下文。
2.2 解决命中率和缓存污染问题
为了解决上述实现过程中的命中率问题,我们对缓存数据结构再封装,加入一个表示命中次数的成员,并且额外开辟一个临时队列,用以存储当前访问过的数据,每次命中数据则对计数器+1,达到临界值的时候才将数据从临时队列转移到缓存队列,实现逻辑如下:
- 初次访问数据,将数据加入临时队列,命中次数+1;
- 再次访问数据,命中次数继续+1;
- 命中次数大于临界值,转入缓存队列;
- 临时队列可采取FIFO或者LRU或者时间排序算法实现淘汰;
- 缓存队列可按命中次数排序、FIFO或者时间排序算法实现淘汰;
这里为化简演示过程,我对临时队列采用FIFO模式进行淘汰处理,缓存队列则按命中次数排序后淘汰访问最少,首先调整缓存数据结构:
class CacheData {
Data data;
/**
* 补充命中次数统计
*/
int hitCount;
public CacheData(Data data) {
this.data = data;
this.hitCount = 1;
}
public Data getData() {
return this.data;
}
public int getHitCount() {
return this.hitCount;
}
public int hitData() {
return ++hitCount;
}
}
然后补充临时队列,以及数据命中次数的阈值,并调整缓存数据类型:
/**
* @author 柠檬睡客
*
* 补充命中统计解决命中率问题
*
*/
public class HitListLRU {
/**
* 用以存放缓存数据的链表
*/
private static final LinkedList<CacheData> LRUCacheList = new LinkedList<CacheData>();
/**
* 限制缓存链表的长度
*/
private static final int CacheLimit = 100;
/**
* 用以临时存放数据的链表
*/
private static final LinkedList<CacheData> TemporaryList = new LinkedList<CacheData>();
/**
* 限制临时链表的长度
*/
private static final int TemporaryLimit = 100;
/**
* 命中次数阈值,达到阈值后从临时队列转移到缓存队列
*/
private static final int HitLimit = 5;
}
接下来需要为缓存队列设计排序,这里简单的用一个比较器来实现,实现一个Comparator与CacheData解耦:
class CacheDataComparator implements Comparator<CacheData> {
public int compare(CacheData data1, CacheData data2) {
if (data1.getHitCount() > data2.getHitCount()) {
return 1;
} else if (data1.getHitCount() < data2.getHitCount()) {
return -1;
} else {
return 0;
}
}
}
然后我们重写getDate方法:
/**
* 供应用访问,优先从缓存获取数据,无缓存再访问DB
*/
public static Data getData(String id) {
Data data = null;
// 优先从缓存队列中访问
synchronized (LRUCacheList) {
for (int i = 0; i < LRUCacheList.size(); i++) {
// 命中数据时,更新命中次数统计值
if (id.equals(LRUCacheList.get(i).getData().getId())) {
LRUCacheList.get(i).hitData();
return LRUCacheList.get(i).getData();
}
}
}
// 若缓存队列没有,再遍历临时队列
data = getDataFromTemporaryList(id);
// 如果临时队列也没有,则访问DB
if (data == null) {
data = getDataFromDB(id);
}
if (data != null) {
putTemporaryData(new CacheData(data));
}
return data;
}
从临时队列遍历数据的方法和getData差不多,只不过多了处理命中阈值的逻辑而已:
/**
* 从临时队列查询数据
*/
private static Data getDataFromTemporaryList(String id) {
CacheData cacheData = null;
synchronized (TemporaryList) {
for (int i = 0; i < TemporaryList.size(); i++) {
cacheData = LRUCacheList.get(i);
// 命中数据时,更新命中次数统计值,并且一旦达到命中阈值需要转移到缓存队列
if (id.equals(cacheData.getData().getId())) {
if (cacheData.hitData() >= HitLimit) {
TemporaryList.remove(i);
putCacheData(cacheData);
return cacheData.getData();
}
}
}
}
return null;
}
最后,还有两个方法需要实现,将数据加入临时队列和缓存队列:
/**
* 将数据从临时队列加入到缓存队列
*/
private static void putCacheData(CacheData cacheData) {
synchronized (LRUCacheList) {
if (LRUCacheList.size() >= CacheLimit) {
// 对缓存队列按命中次数排序,移除最末数据
Collections.sort(LRUCacheList, new CacheDataComparator());
LRUCacheList.removeLast();
}
LRUCacheList.addFirst(cacheData);
}
}
/**
* 将数据加入临时队列
*/
private static void putTemporaryData(CacheData cacheData) {
synchronized (TemporaryList) {
// 以FIFO模式淘汰数据
if (TemporaryList.size() >= TemporaryLimit) {
TemporaryList.removeLast();
}
TemporaryList.addFirst(cacheData);
}
}
结束,这一次我们没有轻易的将数据加入到缓存队列,而是像新入职的实习生一样,待观察一段时间,如果这段时间内数据的访问频次很高,再将其加入到缓存队列,这样就有效的解决了命中问题。并且置于缓存队列中的数据一定是经过命中频次保证的,如此也解决了缓存污染问题,使缓存队列中的数据都是热点数据。
再总结一下,如上只是一种解决思路,其实还是有很多问题的,算法复杂度高了,内存开销虽然可以通过调整队列长度来限制,但是临时队列的额外内存开销还是客观存在的。
而且对于数据的命中过程,依然是从头部开始遍历整个队列,访问效率问题还是没有得到解决。
这里我再给出一些其他思路,比如说以访问频度来划分出多个队列,然后数据在多个队列间传递,直到频度最高的队列中的数据将其转入缓存等等。但不论如何,因其数据结构的限制,访问效率问题永远存在,内存开销问题也无法解决,那么到底该怎么解决?改变数据结构!见下文。
2.3 解决执行效率问题
当我们无法通过算法进一步提升程序执行效率,并减少内存开支的时候,我们就需要转过头来重新考虑一下数据结构的问题了。执行效率目前的瓶颈就在于链表的遍历,有什么数据结构能快速的命中数据呢?HashMap!
在链表的基础上,我们在补充一个HashMap,Key值存放数据的ID,Value则存放缓存数据,如此快速定位缓存数据问题得到解决,只要Key存在,那么缓存数据一定存在。
那么如何解决链表元素的移动问题呢?因为缓存链表还是需要调整数据的位置的,当前的数据结构无法做到不对全表(最糟糕情况,数据在链表末尾)遍历,可行办法是双向链表,链表中的每一个元素(链表节点)都包含前、后节点的引用,此时不再需要遍历链表,仅需要调整元素的前后节点引用即可。这种设计思想跟CLHLock自旋锁很像,CLHLock在本地成员自旋(可以参考我之前写过的博客Java锁手册),双向链表的元素移动则在自己的成员引用上做手脚。
开工,先设计一个双向链表结构,元素(后文称节点吧)定义如下:
/**
* @author 柠檬睡客
*
* 链表节点
*/
class Node {
/**
* 数据ID
*/
private String id;
private Data data;
/**
* 指向前一个节点的引用
*/
private Node pre;
/**
* 指向后一个节点的引用
*/
private Node next;
public Node(String key, Data data) {
this.id = key;
this.data = data;
}
public String getKey() {
return id;
}
public void setKey(String key) {
this.id = key;
}
public Data getData() {
return data;
}
public void setData(Data data) {
this.data = data;
}
public Node getPre() {
return pre;
}
public void setPre(Node pre) {
this.pre = pre;
}
public Node getNext() {
return next;
}
public void setNext(Node next) {
this.next = next;
}
}
接下来重新设计缓存结构,注意咯:
- 因为Map的Value为链表节点,而节点中已经包含了缓存数据,以及前后节点的引用,那么就无需在额外的定义链表结构了,Map中的Value集合已经隐式的形成了一个链表结构(节省结构定义产生的内存开支);
- 但是!!!仍然需要在程序中保留头部和尾部节点的哨兵引用,目的是方便在头尾操作节点的引用;
- 为了避免每次都对Map长度进行计算(对Map尺寸进行计算还是有开销的),我们需要一个全局的长度计数器。
所以工具定义如下:
/**
* @author 柠檬睡客
*
* 以双向链表+HashMap结构实现LRU算法
*/
public class NodeListLRU {
/**
* 限制缓存链表的长度
*/
private static final int CacheLimit = 100;
/**
* 当前有多少节点的统计,避免计算Map尺寸产生的开销
*/
private static volatile int count = 0;
/**
* 缓存数据的Map结构
*/
private static HashMap<String, Node> CacheMap;
/**
* 头节点哨兵(不是头节点,它的后继节点才是头节点)
*/
private static Node head;
/**
* 尾节点哨兵(不是尾节点,它的前驱节点才是尾节点)
*/
private static Node tail;
}
接下来需要在静态代码块中对静态成员进行初始化动作:
static {
CacheMap = new HashMap<String, Node>();
// 初始化头节点和尾节点,利用哨兵模式减少判断头结点和尾节点为空
Node headNode = new Node(null, null);
Node tailNode = new Node(null, null);
headNode.next = tailNode;
tailNode.pre = headNode;
head = headNode;
tail = tailNode;
}
基础设施准备完毕后需要重写getData方法:
/**
* 优先从缓存获取数据,没有缓存则再访问DB
*/
public static Data getData(String id) {
Data data = null;
synchronized (CacheMap) {
// 如果存在缓存,则将数据移动至链表头部
Node node = CacheMap.get(id);
if (node != null) {
moveNodeToHead(node);
data = node.getData();
} else {
data = getDataFromDB(id);
}
if (data != null) {
putData(data);
}
}
return data;
}
如果是新增数据,那么需要将其加入到缓存链表,这时需要注意链表长度(由count计数器替代)是否已经达到阈值:
/**
* 将数据加入缓存链表,一定是头部
*/
private static void putData(Data data) {
synchronized (CacheMap) {
// 如果队列超限,则将尾部节点移除
if (count > CacheLimit) {
removeTail();
}
// 将节点插入到头部
Node node = new Node(data.getId(), data);
addHead(node);
}
}
最后我们需要把移除节点、新增节点和转移节点三个方法补充完整:
/**
* 新增节点到头部
*/
private static void addHead(Node node) {
synchronized (CacheMap) {
// 原头部节点的后继取出
Node next = head.getNext();
// 设置原头部节点的前驱为新增节点
next.setPre(node);
// 设置新增节点的后继为原头部节点
node.setNext(next);
// 设置新增节点的前驱为头部哨兵
node.setPre(head);
// 设置头部哨兵的后继为新增节点
head.setNext(node);
CacheMap.put(node.getKey(), node);
count++;
}
}
/**
* 移除尾节点
*/
private static void removeTail() {
synchronized (CacheMap) {
// 被移除的节点
Node node = tail.getPre();
// 被移除节点的前驱和后继
Node pre = node.getPre();
Node next = node.getNext();
// 拼接被移除节点的前驱和后继,使两者相连
pre.setNext(next);
next.setPre(pre);
// 从链表里面移除
node.setNext(null);
node.setPre(null);
CacheMap.remove(node.getKey());
count--;
}
}
/**
* 移动节点到头
*/
private static void moveNodeToHead(Node node) {
// 从链表里面移除,参考removeTail
Node pre = node.getPre();
Node next = node.getNext();
pre.setNext(next);
next.setPre(pre);
node.setNext(null);
node.setPre(pre);
// 添加节点到头部
addHead(node);
}
完活,一个基于双向链表和HashMap的LRU算法就实现了。如此我们不但能够通过散列KEY值快速的定位缓存数据,还能够节省一个链表的结构定义,这些缓存数据的物理内存不是连续的,全部通过Node节点自身的成员引用串联。
上述的实现方式依然采用尾部淘汰策略,这样做不是不可以,但是相对来说还是存在更多优化空间的,典型的LRU算法的应用——Redis,大名鼎鼎,它几乎将LRU算法实现的出神入化了。
在完全理解并手动实现了若干版本的LRU之后,我们再看看Redis是如何实现的,源码部分建议大家自行阅读,后文我仅把实现的理念整理一遍。
三 Redis如何利用LRU
3.1 缓存淘汰策略
在介绍Redis如何利用LRU之前,必须要先说明下Redis对与缓存的淘汰策略(当缓存达到上限后),上文介绍过一些简单的淘汰策略,包括FIFO、用时最久或者命中频度等等,而Redis给出了更为丰富和高效的策略模式:
- noeviction:默认策略,不接受任何写入请求,直接报错,简单粗暴;
- allkeys-lru:对KEY进行LRU计算,执行淘汰;
- volatile-lru:对设置了过期时间的KEY进行LRU计算,执行淘汰;
- allkeys-random:随机取KEY,执行淘汰;
- volatile-random:对设置了过期时间的KEY,随机选取执行淘汰;
- volatile-ttl:对设置了过期时间的KEY,按时间排序淘汰,越早过期越优先 。
需要注意的是,当使用volatile-lru、volatile-random、volatile-ttl这三种策略时,如果没有计算出可淘汰的key,结果和noeviction一样直接报错。
再介绍下和淘汰策略相关的指令:
- config get maxmemory-policy:获取当前的淘汰策略;
- config set maxmemory-policy allkeys-lru(模式自选):设置淘汰策略。
当然可以直接修改配置文件,不多说。我更想多说的是Redis中对LRU算法的实现。
3.2 LRU实现
Redis的不同版本中对LRU算法的实现略有不同,我按照自己检索到的资料分步整理。这部分内容大部分摘自想不到!面试官问我:Redis 内存满了怎么办?
3.2.1 近似算法
Redis使用的是近似LRU算法,它跟常规的LRU算法还不太一样。近似LRU算法通过随机采样法淘汰数据,每次随机出5(默认)个key,从里面淘汰掉最近最少使用的key。
可以通过maxmemory-samples参数修改采样数量:例:maxmemory-samples 10,参数值越大,淘汰的结果越接近于严格的LRU算法。
Redis为了实现近似LRU算法,给每个key增加了一个额外增加了一个24bit的字段,用来存储该key最后一次被访问的时间。
之所以采用近似算法而非精准算法,是因为LRU算法是需要内存开支的,精准的LRU对内存的消耗远大于近似算法,而且近似算法已经能够应对大多数应用场景了。
3.2.2 近似算法优化
Redis3.0版本中对近似LRU算法进行了一些优化。
新算法会维护一个候选池(大小为16),池中的数据根据访问时间进行排序,第一次随机选取的key都会放入池中,随后每次随机选取的key只有在访问时间小于池中最小的时间才会放入池中,直到候选池被放满。当放满后,如果有新的key需要放入,则将池中最后访问时间最大(最近被访问)的移除。
当需要淘汰的时候,则直接从池中选取最近访问时间最小(最久没被访问)的key淘汰掉就行。
3.2.3 LFU算法
LFU算法是Redis4.0里面新加的一种淘汰策略。它的全称是Least Frequently Used,它的核心思想是根据key的最近被访问的频率进行淘汰,很少被访问的优先被淘汰,被访问的多的则被留下来。
LFU算法能更好的表示一个key被访问的热度。假如你使用的是LRU算法,一个key很久没有被访问到,只刚刚是偶尔被访问了一次,那么它就被认为是热点数据,不会被淘汰,而有些key将来是很有可能被访问到的则被淘汰了。如果使用LFU算法则不会出现这种情况,因为使用一次并不会使一个key成为热点数据。
LFU一共有两种策略:
- volatile-lfu:在设置了过期时间的key中使用LFU算法淘汰key;
- allkeys-lfu:在所有的key中使用LFU算法淘汰数据。
设置使用这两种淘汰策略跟前面讲的一样,不过要注意的一点是这两周策略只能在Redis4.0及以上设置,如果在Redis4.0以下设置会报错。
四 结语
如果想关注更多硬技能的分享,可以参考积少成多系列传送门,未来每一篇关于硬技能的分享都会在传送门中更新链接。