LRU是什么?
LRU是Least Recently Used的缩写,即最近最少使用,是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰。该算法赋予每个页面一个访问字段,用来记录一个页面自上次被访问以来所经历的时间 t,当须淘汰一个页面时,选择现有页面中其 t 值最大的,即最近最少使用的页面予以淘汰。
建议
如果你不是计算机专业科班出身,这个概念估计你够呛能理解(我也不是,最开始也很迷惑)。
可以去搜索:【操作系统 内存管理分页和分段 】【操作系统 虚拟内存 页面置换算法】进行理解和学习。
LRU的白话解释
内存快但是贵。恨不得计算机跑的所有东西全在内存里,这样老快了。
但是内存就那么多,早晚要满,满了就要清理出地方给新进来的用,问题是:
内存里的各种各样的数据,你清理谁?通过什么条件判断谁应该被清理?
所以就想出了各种算法,他们就是通过不同的策略来决定内存满了应该清理谁。lru就是其中的一种策略。
lru:最近最少使用。这就是他的判断依据。
lru算法是精确的,但是lru解决的问题是基于概率的
按照lru的算法处理,一定是能精确的剔除最近最少使用的数据。
但是你不能说这些被保留下来的数据将来就一定会被访问,可能再也不会被访问了。
你也不能说这些被剔除的数据将来不会被频繁访问。
所以这东西就是一个概率,所剔除的数据大概率是不会被频繁访问的。
关于LRU的误区
当你搜索lru相关的信息,很多都是和redis联系在一起的。
而且当你基础知识储备不够的时候,很容易就陷入了一个误区,以为lru是redis才有的东西。
其实不是。
且,更可笑的是:
有些博文标题明明说的是 redis的lru算法,结果内容却是lru的算法实现(是不是感觉很矛盾,请往下看)
mysql也有应用lru算法
这一点有多少人知道?
https://dev.mysql.com/doc/refman/8.0/en/innodb-buffer-pool.html
先说结论
1、lru算法是一个算法。如果说最常用的场景,应该是操作系统中,而不是什么redis。
2、redis是的,也应用了lru算法,包括配置文件里也有lru相关的配置,但是,redis用的是[近似lru算法]。
3、那些标题写着【redis的lru算法】内容讲的却是什么双向链表+map实现的,都是垃圾,误人子弟。
4、lru算法要求能纸上手写——面试
5、redis中的lru实现要求能说明白——面试
6、redis的源码是c语言写的,有几个“API调用工程师”能看懂的?你能研究个鸡儿的【redis的lru实现】。
7、lru算法的实现:双向链表+map 是经典解决方案,啥语言都能写出来。
lru算法的Go语言实现
package main
import "fmt"
func main() {
cache := Constructor(2 /* 缓存容量 */)
cache.Put(1, 1)
cache.Put(2, 2)
fmt.Println(cache.Get(1)) // 返回 1
cache.Put(3, 3) // 该操作会使得密钥 2 作废
fmt.Println(cache.Get(2)) // 返回 -1 (未找到)
cache.Put(4, 4) // 该操作会使得密钥 1 作废
fmt.Println(cache.Get(1)) // 返回 -1 (未找到)
fmt.Println(cache.Get(3)) // 返回 3
fmt.Println(cache.Get(4)) // 返回 4
}
type LinkNode struct {
key, val int
pre, next *LinkNode
}
type LRUCache struct {
m map[int]*LinkNode
cap int
head, tail *LinkNode
}
func Constructor(capacity int) LRUCache {
head := &LinkNode{}
tail := &LinkNode{}
head.next = tail
tail.pre = head
return LRUCache{
m: make(map[int]*LinkNode),
cap: capacity,
head: head,
tail: tail,
}
}
func (this *LRUCache) Get(key int) int {
v, ok := this.m[key]
// map中不存在 直接返回-1
if !ok {
return -1
}
v.next.pre = v.pre
v.pre.next = v.next
v.next = this.head.next
this.head.next.pre = v
this.head.next = v
v.pre = this.head
return v.val
}
func (this *LRUCache) Put(key int, value int) {
v, ok := this.m[key]
if ok {
// 如果存在
// 1、更新 key 的 value
v.val = value
// 2、移动到头部
v.next.pre = v.pre
v.pre.next = v.next
v.next = this.head.next
this.head.next.pre = v
this.head.next = v
v.pre = this.head
} else {
// 如果不存在
// 0、判断容量,是否触发:删除尾部节点【一定是先清理出位置,再插入新值】
if len(this.m) >= this.cap {
t := this.tail.pre
this.tail.pre = t.pre
t.pre.next = this.tail
delete(this.m, t.key)
}
// 1、创建一个新节点
node := &LinkNode{
key: key,
val: value,
}
// 2、加入到map中
this.m[key] = node
// 3、移动到头部
node.next = this.head.next
this.head.next.pre = node
this.head.next = node
node.pre = this.head
}
}
看不懂的或者还没学明白的,建议去 https://leetcode-cn.com/ 上去学习,上述代码leetcode已提交通过。
小技巧
【在双向链表的实现中,使用一个伪头部(dummy head)和伪尾部(dummy tail)标记界限,
这样在添加节点和删除节点的时候就不需要检查相邻的节点是否存在。】
这个小技巧想不到没关系,记住了就行,说是双向链表,严格的说应该是【有头有尾的双向链表】
面试要点
lru算法真的非常简单,背都能背下来,一般实现是借用2种数据结构:map + 双向链表
如果你使用的语言是Java或python,千万记住:【别想着投机取巧,使用OrderedDict或LinkedHashMap】
首先你要明白,使用这种功能非常强大的数据结构来实现lru的话,那就没剩多少东西好写了。
这绝不是面试官想看到的,首先你要想:他总不能让你手写实现一个map吧(那也太jb变态了)?那么就剩下双向链表了。
所以面试手写的时候,map可以用语言自带的,但是双向链表的功能你要自己实现。
redis为什么不直接用lru算法
双向链表,那么每个key都挂了2个指针,一个指针8字节,内存占用太大。
redis一直在效率和内存力求寻找平衡点,通过redis的底层数据结构实现就可见一二。
既然lru本身就是基于假设、基于概率的一个算法,那为什么要严格按照它的定义来呢?
1个指针占多少字节?
指针即为地址,指针几个字节跟语言无关,而是跟系统的寻址能力有关。
32位系统,是4个字节,64位系统,则为8字节。
redis如何实现lru算法
redis对LRU算法进行近似处理
方法是对少量key进行采样,然后从采样的key中驱出最好的(访问时间最长)key。
前置知识
1、redis会初始化一个evictPool,默认EVPOOLS_SIZE为16,可以存放16个ecitonPoolEntry。
struct evictionPoolEntry {
// Object idle time (inverse frequency for LFU)
unsigned long long idle; // 空闲时间
// Key name
sds key;
// Cached SDS object for key name
sds cached;
// Key DB number
int dbid;
};
随机挑选出5个(maxmemory-samples设置的值)元素,然后计算它的idle时间,放入evictionPool中去,按照idle时间从小到大排序。剔除的元素选择是:从pool中找到idle时间最长的那个元素。
2、Redis 使用对象来表示数据库中的键和值, 每次当我们在 Redis 的数据库中新创建一个键值对时, 我们至少会创建两个对象, 一个对象用作键值对的键(键对象), 另一个对象用作键值对的值(值对象)。
举个例子
redis> SET msg "hello world"
OK
SET 命令在数据库中创建了一个新的键值对,
其中键值对的键是一个包含了字符串值 "msg"
的对象,即 key对象。
而键值对的值则是一个包含了字符串值 "hello world"
的对象,即 value对象。
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 指向底层实现数据结构的指针
void *ptr;
// LRU_BITS 为 24bit
unsigned lru:LRU_BITS;
// ...
} robj;
redisObject的lru字段是秒级时间戳。在对象新建或者更新时lru会随着修改,24 bits数据要溢出的话需要194天,而缓存的数据更新非常频繁,已经足够了。
按照此设定,如何找出并淘汰时间戳最小的? 遍历么?显然不是。
1.0版本
并未引入LRU算法,只是简单的使用引用计数法,去掉内存中不再引用的对象以及运行一个定时任务serverCron去掉内存中已经过期的对象占用的内存空间
3.0版本之前
很粗暴,随机取出5个key淘汰1个lru字段最小的(maxmemory-samples 默认为5)
这显然不能很好的达到lru的效果。
3.0版本之后
1、前置条件
用一个全局时钟作为参照。对每个redisObject初始化和操作的时候都更新它各自的lru时钟。
2、判断是否需要剔除
如果配置设置了maxmemory,每一个读写命令过来,都要判断是否需要剔除。
判断如果容量还足够就直接退出。如果需要剔除数据,就会根据不同的替换策略(这里指的是lru)来剔除数据。
3、怎么剔除
Redis提供一个待淘汰候选key的evictPool,里面默认能装16个ecitonPoolEntry,按照idle排好序。
Redis随机选择maxmemory_samples数量的key,然后计算这些key的空闲时间idle time,
当满足条件时(比pool中的某些键的空闲时间还大)就可以进pool。
当某一时刻候选池的数据满了,那么时间最大的key就会被挤出候选池。
当执行淘汰时,直接从候选池中选取空闲时间最大的key进行淘汰。
idle的计算公式是什么?
对象的lru字段和全局的LRU_CLOCK()的差值乘以精度LRU_CLOCK_RESOLUTION,将秒转化为了毫秒
unsigned long long estimateObjectIdleTime(robj *o) {
unsigned long long lruclock = LRU_CLOCK();
if (lruclock >= o->lru) {
return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION;
} else {
return (lruclock + (LRU_CLOCK_MAX - o->lru)) * LRU_CLOCK_RESOLUTION;
}
}
pool满了怎么办?
pool满了,就把“最不应该剔除的元素" 也就是 idle最小的元素,移出pool。
说白了就是:监狱满了,你可以走了,因为要来一个比你罪行还重的小兄弟,给人家腾出地方。
maxmemory_samples
配置
选择一定数量的样本,这个值默认为5
值越高越接近真实的LRU/LFU算法,值越低,性能越高,所以需要平衡。
5个是比较合适的,10个接近真LRU但是非常消耗CPU,3个很快但不是非常精确
补充
lfu从Redis 4.0开始支持
官网地址 https://redis.io/topics/lru-cache
源码分析 https://zhuanlan.zhihu.com/p/40354122