在Redis缓存体系中,内存淘汰策略直接决定了缓存命中率与服务性能。当Redis内存达到配置上限(maxmemory)时,需通过淘汰算法移除部分数据以腾出空间。LRU(Least Recently Used,最近最少使用)与LFU(Least Frequently Used,最不经常使用)是两种最常用的淘汰策略,二者基于不同的核心逻辑判断数据"价值",适用于不同业务场景。本文将从底层实现、核心差异、优缺点及选型建议等方面,全方位拆解两种算法。
一、核心定义与设计理念
1. LRU算法
LRU以"访问时间"为核心判断依据:认为最近被访问过的数据,未来被再次访问的概率更高,因此当需要淘汰数据时,优先移除最久未被访问的对象。其设计理念贴合"短期热点"场景,忽略历史访问频率,仅关注数据的近期活跃度。
类比场景:浏览器缓存网页时,优先保留最近打开的页面,关闭最久未访问的页面,这就是典型的LRU思想。
2. LFU算法
LFU以"访问频率"为核心判断依据:认为历史访问次数越多的数据,未来被再次访问的概率更高,因此淘汰时优先移除访问频率最低的对象。其设计理念贴合"长期热点"场景,能更好地识别持续活跃的数据,避免短期突发访问对缓存的干扰。
类比场景:视频平台缓存热门视频时,优先保留播放量最高的内容,而非仅最近播放过的内容,符合LFU逻辑。
二、Redis中的底层实现细节
传统LRU/LFU算法需维护复杂数据结构(如LRU链表、LFU频率字典),会带来较高的时间与空间开销。Redis为兼顾性能,采用了"近似实现"方案,且复用了redisObject结构体中的24位lru字段存储核心信息,大幅优化了资源占用。
1. Redis LRU算法实现
Redis并未实现严格的LRU(严格LRU需维护全局有序链表,每次访问都要移动节点,时间复杂度O(n)),而是采用"随机采样+时间戳"的近似方案,核心逻辑如下:
-
数据记录 :每个键对应的
redisObject结构体中,24位lru字段完整存储该键最后一次被访问的毫秒级时间戳(取模2²⁴,精度约194天,足够覆盖大部分业务场景)。 -
淘汰逻辑 :当触发淘汰时,Redis从所有键中随机采样N个(通过
maxmemory-samples配置,默认5个),从这N个键中选择lru值最小(最久未访问)的键淘汰。采样数越多,结果越接近严格LRU,但性能开销略高。
核心源码片段:
// 模拟RedisObject结构体,对应Redis中的核心数据对象
class RedisObject {
// 数据类型(如字符串、哈希、列表等)
private int type;
// 编码方式(如RAW、INT、HT等)
private int encoding;
// LRU模式下:存储最后访问的毫秒级时间戳
// LFU模式下:高16位存分钟级时间戳,低8位存频率计数器
private long lru;
// 引用计数,用于内存回收
private int refCount;
// 指向实际存储的数据
private Object ptr;
// 访问键时更新LRU时间戳(对应Redis的touchKey方法)
public void touchKey() {
// 记录当前毫秒级时间戳,模拟Redis的LRU_CLOCK()
this.lru = System.currentTimeMillis();
}
// getter/setter方法,模拟底层字段访问
public long getLru() { return lru; }
public void setLru(long lru) { this.lru = lru; }
public int getRefCount() { return refCount; }
public void setRefCount(int refCount) { this.refCount = refCount; }
}
// 模拟Redis LRU核心逻辑工具类
class RedisLruUtil {
// 从采样的键中选择最久未访问(lru值最小)的键淘汰
public static RedisObject selectEvictKey(RedisObject[] sampledKeys) {
if (sampledKeys == null || sampledKeys.length == 0) {
return null;
}
RedisObject evictKey = sampledKeys[0];
for (RedisObject key : sampledKeys) {
if (key.getLru() < evictKey.getLru()) {
evictKey = key;
}
}
return evictKey;
}
}
2. Redis LFU算法实现
LFU是Redis 4.0版本引入的策略,用于解决LRU的"缓存污染"问题(如一次性批量访问冷数据,导致旧热点数据被淘汰)。其核心挑战是在24位lru字段中同时存储频率与时间信息,Redis的解决方案是"字段拆分+概率递增+时间衰减",具体实现如下:
-
字段拆分 :将24位
lru字段拆分为两部分:-
高16位:存储分钟级时间戳(取模2¹⁶,精度约45.5天),用于计算计数器衰减时间;
-
低8位:存储访问频率计数器(logc),取值范围0-255,采用对数增长而非线性增长,避免计数器过快溢出。
-
-
频率递增规则 :计数器并非每次访问都+1,而是按概率递增,规则为:
概率 = 1 / (当前计数器值 × lfu-log-factor + 1)(lfu-log-factor为可配置因子,默认10)。计数器值越大,递增难度越高,既区分高频与低频数据,又避免8位空间被快速占满。 -
频率衰减机制 :为解决"历史包袱"(过去热门但现在冷的数据长期占用缓存),LFU会定期衰减计数器。通过
lfu-decay-time配置衰减周期(默认1分钟),若键闲置时间超过N个周期,计数器值按闲置时长递减(最低至0)。 -
淘汰逻辑:触发淘汰时,同样随机采样N个键,选择计数器值最小的键淘汰;若计数器值相同,则淘汰时间戳更旧的键。
核心源码片段
// LFU相关常量,对应Redis源码定义
class LfuConstants {
// 频率计数器初始值
public static final int LFU_INIT_VAL = 5;
// 时间戳掩码,取低16位(分钟级)
public static final int LFU_TIME_MASK = 0xFFFF;
// 频率计数器掩码,取低8位
public static final int LFU_COUNT_MASK = 0xFF;
}
// 扩展RedisObject,增加LFU相关操作
class LfuRedisObject extends RedisObject {
// 计算分钟级时间戳(对应Redis的LFUGetTimeInMinutes)
public static int getTimeInMinutes() {
// 毫秒转分钟,与掩码取与得到16位时间戳
return (int) ((System.currentTimeMillis() / 60000) & LfuConstants.LFU_TIME_MASK);
}
// 初始化LFU相关字段(对应Redis的createObject方法)
public void initLfu() {
int time = getTimeInMinutes();
// 高16位存时间戳,低8位存初始计数器值
long lfuVal = ((long) time << 8) | LfuConstants.LFU_INIT_VAL;
super.setLru(lfuVal);
}
// 概率性递增频率计数器(简化实现,贴合Redis核心逻辑)
public void incrementLfu(int logFactor) {
long lfuVal = super.getLru();
// 提取低8位频率计数器
int count = (int) (lfuVal & LfuConstants.LFU_COUNT_MASK);
// 计算递增概率,简化为阈值判断(实际Redis为随机数对比)
double probability = 1.0 / (count * logFactor + 1);
if (Math.random() < probability && count < LfuConstants.LFU_COUNT_MASK) {
// 计数器递增,保留高16位时间戳
lfuVal = (lfuVal & ~LfuConstants.LFU_COUNT_MASK) | (count + 1);
super.setLru(lfuVal);
}
}
// 频率计数器衰减(对应Redis的lfu-decay-time逻辑)
public void decayLfu(int decayTime) {
long lfuVal = super.getLru();
int time = getTimeInMinutes();
// 提取高16位存储的时间戳
int storedTime = (int) ((lfuVal >> 8) & LfuConstants.LFU_TIME_MASK);
// 计算闲置分钟数
int idleMinutes = time - storedTime;
if (idleMinutes <= 0) {
return;
}
// 按衰减周期递减计数器,最低至0
int count = (int) (lfuVal & LfuConstants.LFU_COUNT_MASK);
int newCount = Math.max(0, count - (idleMinutes / decayTime));
// 更新计数器和时间戳
lfuVal = ((long) time << 8) | newCount;
super.setLru(lfuVal);
}
}
三、LRU与LFU核心差异对比
为清晰呈现二者差异,从多个维度整理如下表格,结合Redis实现细节强化对比:
| 对比维度 | LRU算法 | LFU算法 |
|---|---|---|
| 核心依据 | 最近访问时间(短期活跃度) | 历史访问频率(长期活跃度) |
| lru字段用途 | 完整存储毫秒级时间戳 | 高16位:分钟级时间戳;低8位:频率计数器 |
| 更新开销 | 每次访问直接更新时间戳,开销低 | 概率性更新计数器,结合衰减计算,开销略高 |
| 抗缓存污染能力 | 弱:一次性批量访问冷数据会淘汰旧热点 | 强:依赖频率累积,不受短期突发访问干扰 |
| 对新热点的适应性 | 强:新键访问后立即被保护,不易被淘汰 | 弱:新键需累积足够频率才能避免被淘汰(冷启动问题) |
| 历史数据处理 | 不关注历史访问频率,仅看近期 | 通过衰减机制平衡历史与当前热度 |
| 可配置参数 | 仅maxmemory-samples(采样数) | lfu-log-factor(增长系数)、lfu-decay-time(衰减周期) |
四、优缺点与适用场景
1. LRU算法
优点
-
实现简单,更新与淘汰开销低,性能稳定;
-
对短期热点数据敏感,能快速适配突发访问模式(如秒杀活动、热点新闻)。
缺点
-
易受缓存污染:一次性扫描大量冷数据(如爬虫、全量查询)会挤掉原有热点数据;
-
无法识别长期热点:若某个高频数据短期未被访问,可能被近期低频数据淘汰。
适用场景
访问模式动态多变、短期热点明显的场景,例如:
-
新闻资讯平台:用户更关注最近发布/浏览的新闻;
-
用户会话缓存:存储用户近期操作记录、临时会话信息;
-
秒杀活动:短期突发流量,需快速保护新访问的热点商品。
2. LFU算法
优点
-
精准识别长期热点数据,缓存命中率更稳定;
-
抗缓存污染能力强,避免短期访问对缓存的干扰。
缺点
-
实现复杂,更新与衰减计算带来额外开销;
-
新热点冷启动问题:新键需多次访问才能积累足够频率,易被淘汰;
-
存在历史包袱:若衰减配置不合理,过时热点可能长期占用缓存。
适用场景
访问模式稳定、存在长期热点的场景,例如:
-
电商平台:热门商品、高频访问的类目数据(长期被用户查询);
-
社交平台:明星账号、热门话题的动态数据;
-
API接口缓存:高频调用的核心接口(如用户信息查询、商品库存接口)。
五、Redis配置实践与优化建议
1. 基础配置(redis.conf)
# 开启LRU策略(二选一)
maxmemory-policy volatile-lru # 仅对设置过期时间的键生效
maxmemory-policy allkeys-lru # 对所有键生效
# 开启LFU策略(二选一)
maxmemory-policy volatile-lfu # 仅对设置过期时间的键生效
maxmemory-policy allkeys-lfu # 对所有键生效
# LRU优化:采样数(默认5,取值3-10,值越大越接近严格LRU)
maxmemory-samples 10
# LFU优化:计数器增长系数(默认10,值越大增长越慢)
lfu-log-factor 10
# LFU优化:计数器衰减周期(默认1分钟,单位:分钟)
lfu-decay-time 1
2. 优化建议
-
LFU冷启动优化:对新上线的热点数据,可通过程序主动预热(批量访问),快速积累访问频率;
-
LFU衰减配置:根据业务场景调整
lfu-decay-time,例如对时效性强的数据(如促销信息),可将衰减周期设为5-10分钟,加速过时热点淘汰; -
采样数调整:对于内存较大(如10GB以上)的Redis实例,可将
maxmemory-samples设为8-10,平衡精度与性能; -
混合场景:若业务同时存在短期与长期热点,可结合过期时间与淘汰策略,例如对短期数据设置过期时间+LRU,对长期热点数据采用LFU。
六、总结
LRU与LFU的核心差异在于"时间"与"频率"的权衡:LRU聚焦短期活跃度,适合动态多变的访问场景,优势是简单高效;LFU聚焦长期活跃度,适合稳定热点场景,优势是缓存命中率更稳定、抗污染能力强。
在实际业务中,无需追求"最优算法",而应结合访问模式选型:若无法明确热点特性,可先采用LRU(配置较高采样数);若发现存在明显缓存污染或长期热点,再切换为LFU并优化相关参数。同时,需通过Redis的INFO stats命令监控evicted_keys(被淘汰键数)、keyspace_hits/misses(缓存命中/未命中数),持续优化策略,确保缓存效率最大化。