redis LRU策略是如何实现的
Redis 为了在性能和内存使用之间取得平衡,并没有实现传统意义上精确的 LRU(Least Recently Used,最近最少使用)算法,而是采用了一种近似 LRU 算法。
传统 LRU 算法原理
首先,理解标准的 LRU 算法是理解 Redis 实现的基础。标准的 LRU 算法通常通过哈希表 + 双向链表实现:
- 哈希表 (Hash Table): 用于存储键和对应链表节点的映射,确保可以 O(1) 复杂度快速查找到数据。
- 双向链表 (Doubly Linked List): 用于维护数据的访问顺序。所有数据都在这个链表中,链表头部是最近访问的数据,尾部是最久未访问的数据。
工作流程如下: 1. 数据访问: 当一个数据被访问时,通过哈希表找到其在链表中的位置,然后将其移动到链表头部。 2. 数据新增: 新数据插入时,会创建一个新节点并放置在链表头部。 3. 数据淘汰: 当缓存空间已满需要淘汰数据时,直接将链表尾部的节点移除。
这种实现的优点是算法精度高,总能淘汰掉最久未使用的数据。但缺点是需要额外的内存空间来维护双向链表,并且每次数据访问都需要移动链表节点,在高并发场景下会带来性能开销。
Redis 的近似 LRU 算法实现
考虑到内存占用和性能问题,Redis 的作者认为严格的 LRU 实现过于复杂和耗费资源。 因此,Redis 采用了牺牲一部分准确性来换取更高效率和更少内存占用的近似 LRU 算法。
其核心实现依赖于以下两点:
a. 在 redisObject 中记录访问时间戳
Redis 的每个键值对都是一个 redisObject 对象。在这个对象结构中,有一个名为 lru 的 24 位(bit)字段。
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:24; // 在LRU模式下,记录最后一次访问的时间戳
int refcount;
void *ptr;
} robj;
- 当启用了 LRU 相关的淘汰策略时,这个
lru字段会存储一个相对于全局时钟server.lruclock的时间戳。 - 每次一个键被访问(读取或修改)时,Redis 就会更新这个键对应
redisObject的lru字段为当前的server.lruclock值。 - 由于
lru字段只有 24 位,按秒为单位的时间戳大约能存储 194 天,超过后会发生回绕(wrap around),但这对于缓存场景来说通常足够了。
b. 基于随机采样的淘汰机制
当 Redis 的内存使用达到 maxmemory 上限并需要触发淘汰时,它并不会遍历所有的键来找出那个 lru 值最小(即最久未访问)的键。 这样做会非常耗时。
取而代之的是,Redis 执行以下步骤:
- 随机采样: 从所有的键中,随机选取 N 个键(这个 N 值可以通过
maxmemory-samples参数配置,默认为 5)。 - 比较淘汰: 比较这 N 个被采样键的
lru时间戳,将其中时间戳最小(也就是最久没有被访问)的那个键淘汰出去。 - 循环执行: 这个淘汰过程会一直执行,直到内存使用降到
maxmemory限制以下。
Redis 3.0 及以后的优化:
为了提高近似 LRU 算法的精度,Redis 3.0 引入了一个候选池 (eviction pool)。
- 这个候选池大小为 16,用于存放待淘汰的候选键。
- 随机采样出的 N 个键会和候选池中的键进行比较。
- 新采样的键如果其空闲时间(
server.lruclock-key.lru)大于候选池中空闲时间最小的键,则会进入候选池,并挤掉原来池中空闲时间最小的键。 - 最终需要淘汰时,会从候选池中选择空闲时间最长的键进行淘汰。
通过这种方式,采样过程可以逐步筛选出更接近全局最久未被访问的键,使得算法效果更逼近真实的 LRU。
Redis LRU 策略的优缺点
优点:
- 节省内存: 无需像传统 LRU 那样维护一个庞大的双向链表,极大地节省了内存空间。
- 性能高: 避免了每次数据访问时对链表的复杂操作,只在需要淘汰时进行采样和比较,性能损耗小。
- 精度可调: 通过调整
maxmemory-samples参数,可以在算法精度和性能之间进行权衡。采样值越大,算法越接近真实 LRU,但性能消耗也相应增加。
缺点:
- 非严格精确: 由于是近似实现,无法保证每次都淘汰掉全局最久未被使用的键,可能会误淘汰一些相对较热的数据。
- 缓存污染问题: LRU 算法的一个固有缺陷是,如果一个很久未被访问的数据被偶然访问了一次,它的排名会立刻提前,导致真正频繁访问的热点数据可能因为最近没有被访问而被淘汰。 为了解决这个问题,Redis 4.0 引入了 LFU(最不常用)策略。