图解 LFU 缓存淘汰算法以及在 Redis 中的应用(附带个人完整代码实现)

news2024/9/21 16:16:30

文章目录

  • LFU 算法
    • 理论介绍
    • 算法实现
      • 数据结构
      • 查询操作
      • 插入/更新操作
  • Redis 缓存淘汰算法
    • 缓存污染难题
    • Redis LFU缓存淘汰策略

本篇博客的主要内容:

  • 以图解的方式,介绍 LFU 算法的一种实现;
  • 介绍 LFU 算法在 Redis 中的应用。

LFU 算法

理论介绍

最不常使用 (LFU Least Frequently Used) 算法是一种常用的缓存置换算法,旨在移除 访问频次最低 的缓存项。与常用的 LRU 算法不同,LFU 算法更加重视元素的访问频率,而非最近一次访问时间。

实现 LFU 淘汰算法的缓存需要满足如下规则:

  1. 缓存中键值对的数目不能超过容量 capacity
  2. get(key) 操作:如果键 key 存在于缓存,则返回该键;否则空值。
  3. put(key, val) 操作:如果 key 已经存在,变更其值;如果 key 不存在,插入键值对。当缓存达到其容量 capacity 时,则应该在插入新项之前,移除 最不经常使用 的键值对。如果存在两个或更多键具有相同的最小频率,应该去除 最近最久未使用(LRU) 的键值对。

解释下高亮部分:

  • 最不经常使用的键值对:使用频数最少的键,使用 get(key) 或 put(key, val) 访问键对都会增加键的频数。当一个键首次插入到缓存中时,它的使用计数器被设置为 1
  • 最近最久未使用的键值对:缓存中键的最小频数为 count_min,当需要淘汰缓存时,可能存在多个键的频数等于 count_min。对于这种情况,针对频数等于 count_min 的键值对,采用 LRU(最近最久未使用)算法淘汰。

算法实现

本节介绍如何设计 get、put 函数以 O(1) 时间复杂度运行 的 LFU 缓存。

数据结构

缓存中的键值对通过一个全局双向循环链表存储,链表节点 Node 的定义如下:

struct Node{
    int key;
    int val;
    Node* prev;
    Node* next;
    int count;
    Node(int k, int v): key(k), val(v), prev(nullptr), next(nullptr), count(1) {}
    Node(int k, int v, Node *p, Node *n): key(k), val(v), prev(p), next(n), count(1) {}
};
  • key、val:键值对的键与值
  • prev:前驱节点;
  • next:后继节点;
  • count:节点的访问次数,新节点插入时设置初始值 1。

缓存中使用字段 head 存储双向循环链表的头节点,头节点是 链表中频数最高的节点

Node *head = nullptr;

LFU 缓存是一个由多个 链表分段 拼装而成的双向循环链表,相同链表段中的节点频数相等。示意图如下:
双向循环链表



当一个频数 count 的节点被访问,频数将递增为 count + 1,我们需要在 O(1) 的时间将该节点频数 count 的链表分段移动到频数 count + 1 的链表分段头部

因此,需要一个哈希表 count2SegHead 维护频数count 到链 表分段头节点 的映射

unordered_map<int, Node*> count2SegHead;

示意图如下:
count2SegHead 哈希表



为了在 O(1) 时间复杂度通过 key 访问节点,需要使用哈希表 key2Node 维护 键key 到 节点 的映射

unordered_map<int, Node*> key2Node;

示意图如下:


查询操作

查询操作步骤如下:

  1. 通过哈希表 key2node 查询到指定键的节点 node;
  2. 将 node 节点从双向循环链表中移除;
  3. 节点频数为 count,找到 count + 1 的链表分段头节点 seg_head;
  4. 更新 node.count = count + 1,将 node 节点插入 seg_head 前,同时设置 count2SegHead[count + 1] = node,即设置 node 节点为频数 count + 1 的链表段头节点。

下面这副示意图展示了查询 key=8 节点的流程:

  1. 通过 key2Node 找到节点位置,key=8 节点的频数为 3。
  2. 从频数 3 的链表分段中移除 key=8 节点;
  3. 查询 count2SegHead 得到频数为 3+1=4 的链表分段头节点(key=3),将 key=8 节点的频数更新为 4,然后插入到 key=3 节点的前驱。
  4. 最后,更新 head 指针以及 count2SegHead[4] = node
    在这里插入图片描述

查询函数 getNode 代码实现如下:

#include "bits/stdc++.h"

using namespace std;

class LFUCache {
    struct Node{
        int key;
        int val;
        Node* prev;
        Node* next;
        int count;
        Node(int k, int v): key(k), val(v), prev(nullptr), next(nullptr), count(1) {}
        Node(int k, int v, Node *p, Node *n): key(k), val(v), prev(p), next(n), count(1) {}
    };

    unordered_map<int, Node*> key2Node;
    unordered_map<int, Node*> count2SegHead; // 频数到链表分段的映射

    int capacity;
    int size = 0;
    Node *head = nullptr; // 双向循环链表头节点

    // 将链表中的 node 节点移动到 newCount 链表段
    void moveNode(Node* node, int newCount) { // ------(1)
        auto nextNode = remove(node);

        node->count = newCount;
        if(count2SegHead.find(newCount) == count2SegHead.end())
            insert(node, nextNode);
        else
            insert(node, count2SegHead[newCount]);
    }
    // 将节点插入到链表段头节点 segHead 前, 同时维护 head 指针和 count2LinkedList
    void insert(Node* node, Node* segHead) {
        count2SegHead[node->count] = node;
        if(segHead == nullptr) {
            node->next = node->prev = node;
            head = node;
            return;
        }
        node->next = segHead, node->prev = segHead->prev;
        node->prev->next = node, node->next->prev = node;
        if(head->count <= node->count) head = node;
    }

	// remove 函数
	Node* remove(Node *node){
		// 循环链表中仅有node节点
	    if(node->next == node){
	        count2SegHead.erase(node->count);
	        head = nullptr;
	        return nullptr;
	    }
	
	    Node* next;
	    // node 节点为 node->count 链表段的头节点
	    if(count2SegHead[node->count] == node) {
	        if(node->next->count == node->count)
	            count2SegHead[node->count] = node->next;
	        else
	            count2SegHead.erase(node->count);
	        next = node->next;
	    } 
	    // node 不是链表段头节点
	    else next = count2SegHead[node->count];
	
	
	    node->prev->next = node->next, node->next->prev = node->prev;
	    node->prev = node->next = nullptr;
	
	    if(head == node) head = next;
	    return next;
	}

public:
    LFUCache(int cap) {
        this->capacity = cap;
    }

    Node* getNode(int key){
        if(key2Node.find(key) == key2Node.end()) return nullptr;
        auto node = key2Node[key];

        moveNode(node, node->count + 1); // ------(1)
        return node;
    }
};

moveHead(Node* node, int newCount):将循环链表中的节点 node 移动到频数为 newCount 的链表段头部。


moveHead 执行 remove(Node *node) 函数从双向循环链表中移除 node 节点:

  • 如果循环链表中只有 node 节点,将 head 设置为 nullptr,count2SegHead 哈希表移除频数为 node->count 的键值对;
  • 如果 node 为 node->count 链表段的头节点,考察 node->next 的频数:
    • 如果相等,将 node->next 设置为链表段新的头节点;
    • 如果不相等,说明 node 为该链表段的唯一节点,移除 node 后链表段也被移除,count2SegHead 移除 node->count 键值对。
  • 如果 node 不为链表段头节点,可以直接移除,无需维护 head 及 count2SegHead;
  • 返回值:node->count 链表段移除 node 后的头节点;如果链表段已移除,则返回该链表段的后继链表段头节点。
Node* remove(Node *node){
	// 循环链表中仅有node节点
    if(node->next == node){
        count2SegHead.erase(node->count);
        head = nullptr;
        return nullptr;
    }

    Node* next;
    // node 节点为 node->count 链表段的头节点
    if(count2SegHead[node->count] == node) {
        if(node->next->count == node->count)
            count2SegHead[node->count] = node->next;
        else
            count2SegHead.erase(node->count);
        next = node->next;
    } 
    // node 不是链表段头节点
    else next = count2SegHead[node->count];


    node->prev->next = node->next, node->next->prev = node->prev;
    node->prev = node->next = nullptr;

    if(head == node) head = next;
    return next;
}

移除 node 节点后,使用 insert(Node* node, Node* segHead) 将 node 插入到链表段 segHead 的头部:

  • 将 node 设置为 node->count 链表段新头节点:count2SegHead[node->count] = node;
  • 如果 segHead 为 nullptr,说明全局链表为空,执行双向循环链表的初始化操作;
  • 如果 segHead 非空,将 node 插入到 segHead 的前驱。
  • 如果 node.count 操作计数大于等于当前头节点 head 的计数,将 node 设置为新的头节点。
    // 将节点插入到链表段头节点 segHead 前, 同时维护 head 指针和 count2LinkedList
    void insert(Node* node, Node* segHead) {
        count2SegHead[node->count] = node; // 设置为新的链表段头节点
        if(segHead == nullptr) {
        	// segHead 为 null, 说明全局链表为空, 执行初始化操作
            node->next = node->prev = node;
            head = node;
            return;
        }
        // 双向链表插入操作
        node->next = segHead, node->prev = segHead->prev;
        node->prev->next = node, node->next->prev = node;
        // 如果 node 操作计数大于等于当前头节点的计数, 更新 head
        if(head->count <= node->count) head = node;
    }

插入/更新操作

插入/更新操作的步骤如下:

  1. 使用 getNode 查询键为 key 的节点;
  2. 如果节点存在,只需要更新 node.val 为 value 新值,频数更新、双向循环链表的维护由 getNode 函数完成。
  3. 如果节点不存在,则执行新节点插入操作,新节点的初始频数 count 等于 1;
    • 缓存大小等于容量,则淘汰缓存中频数最少的节点,如果频数最少的节点非唯一,淘汰最近一次访问时间最早(LRU)的节点。
    • 缓存大小小于容量,将节点插入到频数 1 的链表段头节点之前。
      还需要考虑到,当前不存在频数为 1 的节点,也即不存在频数为 1 的链表段。这种情况下,将新节点插入到 head 节点之前即可。
void put(int key, int value) {
    if(capacity <= 0) return;

    Node *node = getNode(key);
    if(node == nullptr) {
    	// 键等于 key 的节点不存在, 插入新的节点
        if(size == capacity){
            // 容量已经达到上限, 淘汰频数最低的节点, 即 head.prev
            auto evict = head->prev;
            remove(evict);
            key2Node.erase(evict->key);
            delete evict;
        } else size++;
		
		// 新的节点, 
        node = new Node(key, value, nullptr, nullptr);
        key2Node[key] = node;
        if(count2SegHead.find(1) == count2SegHead.end())
        	// 如果不存在频数为 1 的链表段, 将新节点插入到head的前驱;
            insert(node, head);
        else 
        	// 如果存在频数等于 1 的链表段, 新节点插入到该链表段的头节点前
            insert(node, count2SegHead[1]);
    } else node->val = value; // 更新键等于 key 的节点值
}

下面,我用两个场景,带大家理解 LFU 缓存的插入操作:

场景1:插入节点后缓存大小(size=7) 未超出 容量capacity(7),新节点(key=4) 插入到频数等于 1 的链表段前,成为该链表段的头节点。示意图如下:

在这里插入图片描述

场景2:插入节点后缓存大小(size=7) 超出 容量capacity(6),先 淘汰操作频数最小的节点 (key=7),此时频数为 1 的链表段将被移除,新节点(key=4) 插入到头节点(key=3) 之前。示意图如下:


Redis 缓存淘汰算法

Redis 中实现了基于 LFU 的缓存淘汰策略:volatile-lfu 和 allkeys-lfu。在介绍该策略之前,我们先了解下 缓存污染 的概念。

缓存污染难题

缓存污染 是指 访问很少的数据 在服务完访问请求后还继续 留存在缓存中,造成缓存空间的浪费。

缓存污染一旦变得严重后,有大量不再访问的数据滞留在缓存中,往缓存中写入新数据时需要先把数据逐步淘汰出缓存,引入额外的操作时间开销。


如何解决缓存污染问题?

  • volatile-ttl 策略:如果业务应用在设置过期时间时,明确知道数据的访问情况(即数据的时效性),Redis 按照数据的剩余最短存活时间淘汰数据,可以避免缓存污染。
  • volatile-lru、all-lru:淘汰候选数据集中,lru 字段最小(最近一次访问时间最久的数据。

LRU 方案的缺陷:只考虑了数据的访问时间,没有考虑数据的访问频数,在处理 扫描式单次查询操作 时,无法解决缓存问题!


幸运的是,Redis 从 4.0 版本开始增加了 LFU 淘汰策略:从 数据访问的时效性数据访问次数 两个角度筛选出需要淘汰的数据。


Redis LFU缓存淘汰策略

在实现 LRU 算法时,Redis 使用 RedisObject 结构来保存数据的;该结构的 lru 字段记录数据最近一次访问的时间戳。实现 LFU 算法时,将原来 24bit 的 lru 字段,进一步拆分成两个部分:

  • ldt 值:lru 字段的前 16bit,表示数据的访问时间戳;
  • counter 值:lru 字段的后 8bit,表示数据的访问次数。

Redis使用LFU策略淘汰数据时,选择 lru 字段最小的数据进行淘汰。等价于 优先淘汰访问次数少的数据,访问次数相等则淘汰时间戳最小 的数据。

注意:count 为计数器的值,初始默认为 5 而不是 1。如果初始值为 1,刚被写入缓存的数据可能会因为使用次数太少而立即被淘汰。


counter 值 8bits,最大值 255,如果采用线性增加的方式 counter 很快就会达到上限,Redis 就不能很好筛选访问 1000次 和 10000 次的数据。因此,Redis 采用了非线性的频数更新策略:

p = 1 c o u n t × l f u _ l o g _ f a c t o r + 1 p=\frac{1}{count \times {lfu\_log\_factor} + 1} p=count×lfu_log_factor+11

每次缓存的数据被访问, counter 加 1 的概率等于 p p p,源码如下:

double r = (double)rand()/RAND_MAX;
...
double p = 1.0/(baseval*server.lfu_log_factor + 1);
if (r < p) counter++;   

通过设置 lfu_log_factor 配置项,可以控制计数器的增加速度,避免 counter 值过快到达255。下图所示,当 lfu_log_factor 等于100时,小于10M 的数据能被区分出来。


一般可以将 lfu_log_factor 取值为 10,已经足够区分1k、1w、10w的数据访问量。



counter的衰减机制

LFU 策略使用衰减因子配置项 lfu_decay_time 来控制访问次数的衰减。

  1. LFU 策略会计算当前时间和数据最近一次访问时间的差值,并把这个差值换算成以分钟为单位。
  2. 然后,LFU 策略再把这个差值除以 lfu_decay_time 值,所得的结果就是数据 counter 要衰减的值。
    假设 lfu_decay_time 取值为 1,如果数据在 N 分钟内没有被访问,那么它的访问次数就要减 N。

lfu_decay_time 取值更大,那么相应的衰减值会变小,衰减效果也会减弱。如果业务应用中有短时高频访问的数据的话,建议把 lfu_decay_time 值设置为 1,在数据不被访问后,可以 迅速衰减访问次数,从缓存中淘汰出去,避免缓存污染。


总结:LFU 淘汰策略对于扫描式单次数据读取操作时,虽然仍然会频繁写缓存,但可以 避免访问次数高的热点数据因为最近一次访问时间较早而被淘汰,提升了缓存的命中率。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1535273.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Docker 搭建私人仓库

docker 搭建私人仓库有下面几种方式&#xff1a; 1、docker hub 官方私人镜像仓库2、本地私有仓库 官方私人镜像仓库搭建很简单(就是需要有魔法&#xff0c;否则就异步到第二种方法吧)&#xff0c;只需要 login、pull、tag、push 几种命令就完事了。而本地私人镜像仓库则比较麻…

探究BufferedOutputStream的奥秘

咦咦咦&#xff0c;各位小可爱&#xff0c;我是你们的好伙伴——bug菌&#xff0c;今天又来给大家普及Java IO相关知识点了&#xff0c;别躲起来啊&#xff0c;听我讲干货还不快点赞&#xff0c;赞多了我就有动力讲得更嗨啦&#xff01;所以呀&#xff0c;养成先点赞后阅读的好…

[音视频学习笔记]六、自制音视频播放器Part1 -新版本ffmpeg,Qt +VS2022,都什么年代了还在写传统播放器?

前言 参考了雷神的自制播放器项目&#xff0c;100行代码实现最简单的基于FFMPEGSDL的视频播放器&#xff08;SDL1.x&#xff09; 不过老版本的代码参考意义不大了&#xff0c;我现在准备使用Qt VS2022 FFmpeg59重写这部分代码&#xff0c;具体的代码仓库如下&#xff1a; …

本地化语音识别、视频翻译和配音工具:赋能音频和视频内容处理

随着人工智能技术的飞速发展&#xff0c;语音识别、视频翻译和配音等任务已经变得更加容易和高效。然而&#xff0c;许多现有的工具和服务仍然依赖于互联网连接&#xff0c;这可能会导致延迟、隐私问题和成本问题。为了克服这些限制&#xff0c;我们介绍了一种本地化、离线运行…

RCE漏洞

RCE漏洞概述 远程命令执行/代码注入漏洞&#xff0c;英文全称为Reote Code/CommandExecute&#xff0c;简称RCE漏洞。PHPJava等Web开发语言包含命令执行和代码执行函数,攻击者可以直接向后台服务器远程执行操作系统命今或者运行注入代码&#xff0c;进而获取系统信息、控制后台…

社交媒体的未来:探讨Facebook的发展趋势

引言 在数字化时代&#xff0c;社交媒体已经成为人们日常生活中不可或缺的一部分。作为全球最大的社交媒体平台之一&#xff0c;Facebook一直在不断地追求创新&#xff0c;以满足用户日益增长的需求和适应科技发展的变革。本文将探讨Facebook在未来发展中可能面临的挑战和应对…

10W字解析 SpringBoot技术内幕文档,实战+原理齐飞,spring事务实现原理面试

第3章&#xff0c;Spring Boot构造流程源码分析&#xff0c;Spring Boot的启动非常简单&#xff0c;只需执行一个简单的main方法即可&#xff0c;但在整个main方法中&#xff0c;Spring Boot都做了些什么呢&#xff1f;本章会为大家详细讲解Spring Boot启动过程中所涉及的源代码…

Linux下Docker部署中间件(Mysql、Redis、Nginx等)

我的自备文件 文件传输 内网下直接上传很慢 使用scp命令将另一台服务器上的文件传输过来&#xff1b;在已有文件的服务器往没有文件的服务器传输 scp -r 传输的文件夹/文件 root要传输的地址:放置的地址 scp -r tools root172.xx.x.xxx:/data/ 安装二进制文件、脚本及各中间件…

《深入解析 C#》—— C# 3 部分

文章目录 第三章 C#3&#xff1a;LINQ及相关特性3.1 自动实现属性&#xff08;*&#xff09;3.2 隐式类型 var&#xff08;*&#xff09;3.3 对象和集合初始化3.3.1 对象初始化器3.3.2 集合初始化器 3.4 匿名类型3.4.1 基本语法和行为3.4.2 编译器生成类型3.4.3 匿名类型的局限…

Hive和Hadoop版本对应关系

通过 Downloads (apache.org) 即可查看

MySQL的基本操作

目录 引言 一、SQL语句简介 &#xff08;一&#xff09;SQL通用语法 &#xff08;二&#xff09;SQL分类 &#xff08;三&#xff09;数据类型 1.数值类型 2.字符串类型 3.日期/时间类型 4.修饰符 二、登录mysql服务 三、SQL语句操作 &#xff08;一&#xff09;DD…

vue3 + ts +element-plus + vue-router + scss + axios搭建项目

本地环境&#xff1a; node版本&#xff1a;20.10.0 目录 一、搭建环境 二、创建项目 三、修改页面 四、封装路由vue-router 五、element-plus 六、安装scss 七、封装axios 一、搭建环境 1、安装vue脚手架 npm i -g vue/cli 2、查看脚手架版本 vue -V3、切换路径到需…

Studio One 6 Mac中文版破解版下载(附Mac版注册机)

Studio One 6 Mac版是一款强大的音乐创作与制作软件&#xff0c;其可通过更简单的方式来录制音频及进行MIDI制作&#xff0c;并提供丰富的专业功能。它具备音乐创作、录音混缩、MIDI编辑、音频处理、Loops拼接、视频配乐和母带与专辑制作等功能。软件提供了强大的音频性能&…

在iOS中安装

返回&#xff1a;OpenCV系列文章目录&#xff08;持续更新中......&#xff09; 上一篇&#xff1a;使用CUDA 为Tegra构建OpenCV-CSDN博客 下一篇&#xff1a; 警告&#xff01; 本教程可以包含过时的信息。 所需软件包 CMake 2.8.8 或更高版本Xcode 4.2 或更高版本 从 G…

笔试总结01

1、spring原理 1、spring原理 spring的最大作用ioc/di,将类与类的依赖关系写在配置文件中&#xff0c;程序在运行时根据配置文件动态加载依赖的类&#xff0c;降低的类与类之间的藕合度。它的原理是在applicationContext.xml加入bean标记,在bean标记中通过class属性说明具体类…

旅游小程序的市场与发展趋势

随着科技的发展&#xff0c;移动互联网已经成为我们生活中不可或缺的一部分。在这个时代&#xff0c;小程序已经成为了一种新的趋势&#xff0c;尤其是在旅游行业。那么&#xff0c;旅游小程序有哪些市场&#xff0c;发展趋势又怎么样呢&#xff1f; 一、旅游小程序的市场 1. 用…

AI原生安全 亚信安全首个“人工智能安全实用手册”开放阅览

不断涌现的AI技术新应用和大模型技术革新&#xff0c;让我们感叹从没有像今天这样&#xff0c;离人工智能的未来如此之近。 追逐AI原生&#xff1f;企业组织基于并利用大模型技术探索和开发AI应用的无限可能&#xff0c;迎接生产与业务模式的全面的革新。 我们更应关心AI安全原…

Linux的基本使用

1.Linux的背景 1.1什么Linux Linux是⼀个操作系统.和Windows是"并列"的关系. 1.2Linux系统的优势 1. 开源(意味着免费,便宜) 2. 稳定(Linux可以运⾏很多年,都不会发⽣重⼤问题) 3. 安全(Linux只有管理员或者特定⽤⼾才能访问Linux内核) 4. ⾃由(不会被强加商业产品和…

JVM内存划分

一、运行时数据区域 堆、方法区&#xff08;元空间&#xff09;、虚拟机栈、本地方法栈、程序计数器。 Heap(堆)&#xff1a; 对象的实例以及数组的内存都是要在堆上进行分配的&#xff0c;堆是线程共享的一块区域&#xff0c;用来存放对象实例&#xff0c;也是垃圾回收&…

用大语言模型控制交通信号灯,有效缓解拥堵!

城市交通拥堵是一个全球性的问题&#xff0c;在众多缓解交通拥堵的策略中&#xff0c;提高路口交通信号控制的效率至关重要。传统的基于规则的交通信号控制&#xff08;TSC&#xff09;方法&#xff0c;由于其静态的、基于规则的算法&#xff0c;无法完全适应城市交通不断变化的…