1、LRU
1.1、什么是LRU?
LRU(Least Recently Used,最近最少使用)算法是一种常用于缓存淘汰策略的算法。其基本思想是根据数据的访问历史,当缓存空间达到上限时,淘汰最近最少被使用的数据,以腾出空间来存储新的数据。
1.2、手写LRU
核心思想:
- 查询时:如果命中缓存,数据移到最前面
- 添加时:
- 如果缓存中有,修改缓存中的数据,并将数据移到最前面;
- 如果缓存中没有,创建节点添加到缓存中,当缓存数量达到最大值时,将最近最少访问的数据剔除
代码实现如下:
- 利用哈希表快速定位
- 利用双向链表存储数据
class LRUCache {
int capacity ; //缓存容量
int size ; //缓存当前存储元素个数
Map<Integer , ListNode > cache ; // 缓存
ListNode head ,tail ;
public LRUCache(int capacity) {
cache = new HashMap<>(capacity);
this.capacity =capacity ;
this.size = 0 ;
head =new ListNode();
tail = new ListNode();
head.next =tail ;
tail.pre = head ;
}
public int get(int key) {
//先看缓存中有没有,如果没有返回-1
if(!cache.containsKey(key)){
return -1 ;
}
//有则将当前节点移动到头部,即head后第一个
ListNode node = cache.get(key);
moveToFirst(node);
return node.value;
}
public void put(int key, int value) {
//先看缓存中有没有,如果没有
if(!cache.containsKey(key)){
//创建,同时添加到cache和双向链表中
ListNode node= new ListNode(key, value);
cache.put(key , node);
addToFirst(node);
size++;
//检查容量,如果超过则淘汰tail前一个
if(size > capacity) {
//注意:删除时同时删除cache和双向链表中节点
cache.remove(tail.pre.key);
removeLast();
size -- ;
}
}else{
//拿出来更新节点,并移动到第一个
ListNode node = cache.get(key);
node.value = value ;
moveToFirst(node);
}
}
public void moveToFirst(ListNode node){
//断开连接
node.pre.next = node.next ;
node.next.pre = node.pre;
//在将节点添加到头部
addToFirst(node);
}
public void addToFirst(ListNode node){
node.next =head.next ;
node.pre = head ;
node.next.pre = node;
head.next = node ;
}
public void removeLast(){
ListNode node = tail.pre;
node.pre.next = node.next ;
node.next.pre = node.pre;
}
}
class ListNode{
int key ;
int value ;
ListNode pre , next ;
public ListNode(){
}
public ListNode(int key , int value){
this.key = key;
this.value = value;
}
}
2、工程实践
2.1、LRU在MySQL中的应用
MySQL的数据存储在磁盘上,操作时需要加载到内存中。在内存中存储数据页的结构是Buffer Pool。
假设我们根据主键去修改某条数据的字段,根据索引查到对应记录行,在这期间所有访问的数据页都会加载到内存中,我们去修改的实际上是内存中的值,我们被修改的缓存页称为脏页,这些脏页是需要刷新到磁盘上的,称为刷脏。
Buffer Pool主要由以下部分组成:
- 缓存页
- 控制块
- 空闲链表
- 刷新链表
- LRU链表
Buffer Pool相当于一个缓存,缓存总有满的时候,其中LRU链表负责在BufferPool满时,将不常用的页给淘汰掉
- Innodb将LRU链表分成了5/8的热数据区域和3/8的冷数据区域,并且前1/4的页被访问不移动
- 新加入的页会放到冷数据头部,如果一秒后被访问,就会放到热数据头部,这样设计是为了在全表扫描时,频繁修改LRU
- 每次从LRU链表的尾部选择一个数据页。如果此数据页是脏页,将其刷新并将其放入空闲链表中。
MySQL的BufferPool基于冷热数据隔离改进了其LRU算法,并且进入冷数据区一秒后被访问,才会添加到热数据区头部,避免全表扫描时大换血。
2.2、LRU在Redis中的应用
1、思路总结
Redis中则采用随机采样这种近似LRU算法,其思路为:
- 维护一个大小为16(默认)的待淘汰数据集合,其中数据按照最近被访问的时间进行排序
- 每次随机选择5(默认)个key 加入待淘汰数据集合中
- 当待淘汰集合满之后,访问的时间间隔最大的key就会被取出来淘汰掉
2、什么是待淘汰数据集合
为了淘汰数据,Redis 定义了一个数组 EvictionPoolLRU,用来保存待淘汰的候选键值对。这个数组的元素类型是 evictionPoolEntry 结构体,该结构体保存了待淘汰键值对的空闲时间 idle、对应的 key 等信息。
以下代码展示了 EvictionPoolLRU 数组和 evictionPoolEntry 结构体:
//该数组的大小默认是 16 个元素,也就是可以保存 16 个待淘汰的候选键值对
static struct evictionPoolEntry *EvictionPoolLRU;
struct evictionPoolEntry {
unsigned long long idle; //待淘汰的键值对最后一次被访问时间
sds key; //待淘汰的键值对的key
sds cached; //缓存的SDS对象
int dbid; //待淘汰键值对的key所在的数据库ID
};
3、如何知道数据的最近一次访问时间
Redis是基于内存的k-v结构, 采用RedisObject结构来存储value,在RedisObject有一个lru属性,采用24个bit位记录该对象最后一次被访问的时间,如下:
typedef struct redisObject {
unsigned type:4; //对象类型,String、List、Set、ZSet、Hash
unsigned encoding:4; // 底层编码
unsigned lru:LRU_BITS; //采用24个bit位记录该对象最后一次被访问的时间
int refcount; // 对象引用计数器,为0可被回收
void *ptr; // 指向实际数据地址
} robj;
4、从哪里选择key,为什么是5个
根据在Redis中设置的淘汰算法,如:
- volatile-lru 从设置过期时间的key中随机选择
- allkeys-lru 从所有key中随机选择
函数采样的 key 的数量,是由 redis.conf 中的配置项 maxmemory-samples 决定的,该配置项的默认值是 5。