当我们使用Redis时,常常会把它想象成一个超级快的“大柜子”,里面可以放很多数据。但是,这个柜子的空间(也就是服务器的内存)是有限的,不可能无限制地往里面塞东西。当柜子快被塞满时,我们该怎么办?是拒绝新物品放入,还是扔掉一些旧的物品为新来的腾地方?这就是Redis的“内存淘汰策略”要解决的问题。
理解这些策略,对于设计稳定、高效的缓存系统至关重要。它直接决定了在内存压力下,你的应用会如何表现:是突然报错,还是平滑地牺牲一部分非核心数据。
一、为什么需要淘汰策略?内存满了会怎样?
首先,我们必须明确一点:Redis是基于内存的数据库。虽然它支持持久化(把数据存到硬盘),但所有读写操作都是在内存中完成的。内存的速度快,但成本高、容量有限。
如果我们不设置最大内存限制,Redis会一直使用内存,直到把服务器物理内存吃光,然后开始使用交换分区(Swap),这会导致性能急剧下降,就像让一个短跑运动员在泥地里跑步一样。更糟糕的是,如果Swap也用完了,操作系统可能会开始终止进程,Redis服务本身就可能被“杀掉”。
因此,明智的做法是通过配置 maxmemory 参数,为Redis设定一个内存使用上限。例如,在一台8G内存的服务器上,我们可能为Redis分配6G。一旦内存使用达到6G,Redis就会根据我们预设的“淘汰策略”来采取行动。
二、Redis的八大淘汰策略详解
Redis提供了8种策略,可以分为“不淘汰”和“淘汰”两大类。
第一类:不淘汰,直接报错
- noeviction(默认策略):当内存不足以容纳新写入数据时,新写入操作会报错。这个策略可以确保现有数据不被意外删除,但要求上层应用必须能妥善处理写入错误。它适合你确信数据绝对不能丢失,且内存绝对充足的场景(或者你希望用报错来紧急提醒扩容)。
第二类:从所有键中淘汰 这类策略会从整个Redis数据集中挑选键进行删除。
- allkeys-lru:使用LRU(最近最少使用)算法,淘汰全体键中最近最少被访问的那个键。
- allkeys-lfu:使用LFU(最不经常使用)算法,淘汰全体键中在一段时间内使用频率最低的那个键。
- allkeys-random:从全体键中随机选择一个键进行淘汰。
第三类:从设置了过期时间的键中淘汰 这类策略只会在那些设置了过期时间(TTL) 的键中挑选牺牲品。
- volatile-lru:使用LRU算法,从设置了过期时间的键中,淘汰最近最少被访问的。
- volatile-lfu:使用LFU算法,从设置了过期时间的键中,淘汰使用频率最低的。
- volatile-random:从设置了过期时间的键中,随机选择一个淘汰。
- volatile-ttl:淘汰设置了过期时间的键中,剩余存活时间(TTL)最短的那个。这个策略的目标是清理那些即将过期的“僵尸”数据。
关键理解点:allkeys-* 策略的淘汰池是“所有数据”,而 volatile-* 策略的淘汰池是“仅设置了过期时间的数据”。如果你的数据都没有设置过期时间,那么 volatile-* 策略就会退化成 noeviction,因为找不到可淘汰的候选键。
三、核心算法:LRU与LFU背后的故事
LRU和LFU是两种经典的思想,Redis对它们进行了近似实现以平衡精度和性能。
LRU (最近最少使用) 它的理念很简单:如果一个数据最近被访问过,那么它将来被访问的可能性也更高。所以,当需要淘汰时,就淘汰最久没被碰过的那个。 Redis并没有为每个键维护一个精确的访问时间戳链表(那太耗内存了),而是采用了一种近似方法:每个键会记录一个“空闲时间”(idle time),表示自上次访问后过去了多久(以秒或毫秒为单位)。当需要淘汰时,Redis会随机采样一批键(数量可配置),然后从这批样本中淘汰掉空闲时间最长的那个。这是一种用概率换性能的聪明做法。
LFU (最不经常使用) LFU更关注访问的“频率”,而不是“新鲜度”。它认为,过去被访问次数多的数据,未来也更可能被访问。 Redis的LFU实现为每个键维护了两个值:
- 一个衰减的访问计数器(0-255),随着时间推移会减少。
- 一个最近一次计数器衰减的时间戳。 每次键被访问时,计数器会根据一个复杂公式增加(增加的概率与当前计数值成反比,防止热门键计数无限增长),并且会随着时间推移缓慢衰减。淘汰时,就淘汰计数器值最小的键。这能更好地应对“突然火爆又长期冷门”的数据,避免它们长期占据内存。
四、实战配置与示例演示
下面,我们将通过一个完整的示例,演示如何在主流的Java技术栈(使用Jedis客户端)中观察和理解淘汰策略。我们假设你已经在本地运行了一个Redis服务器。
技术栈:Java + Jedis
首先,我们需要在 redis.conf 配置文件中设置最大内存和淘汰策略,或者通过命令行动态配置。
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
public class RedisEvictionDemo {
private static final String REDIS_HOST = "localhost";
private static final int REDIS_PORT = 6379;
public static void main(String[] args) throws InterruptedException {
// 1. 创建Jedis连接池
JedisPoolConfig poolConfig = new JedisPoolConfig();
JedisPool jedisPool = new JedisPool(poolConfig, REDIS_HOST, REDIS_PORT);
try (Jedis jedis = jedisPool.getResource()) {
// 2. 清理旧数据,确保实验环境干净
jedis.flushAll();
System.out.println("已清空Redis数据库。");
// 3. 动态配置:设置最大内存为100MB,淘汰策略为 allkeys-lru
// 注意:动态配置在重启后会失效,生产环境应在redis.conf中配置
jedis.configSet("maxmemory", "100mb");
jedis.configSet("maxmemory-policy", "allkeys-lru");
System.out.println("已设置 maxmemory=100mb, policy=allkeys-lru");
// 4. 模拟填充数据,直到触发淘汰
System.out.println("开始填充数据...");
int keyIndex = 0;
try {
while (true) {
String key = "key:" + keyIndex++;
String value = "这是一个非常长的测试字符串,用于快速占用内存。".repeat(100); // 重复100次以快速占内存
jedis.set(key, value);
if (keyIndex % 1000 == 0) {
System.out.println("已插入 " + keyIndex + " 个键");
}
}
} catch (Exception e) {
// 当内存满且策略为noeviction时,会抛出异常。对于LRU策略,会一直淘汰,通常不会异常。
System.out.println("写入过程中可能出现异常(取决于策略): " + e.getMessage());
}
// 5. 检查当前键的数量和内存使用
Long dbSize = jedis.dbSize();
String memoryInfo = jedis.info("memory");
System.out.println("当前数据库键数量: " + dbSize);
// 从info信息中提取used_memory
System.out.println("内存信息摘要: " + memoryInfo.split("used_memory:")[1].split("\r\n")[0]);
// 6. 验证LRU行为:访问一些旧的键,然后继续写入,观察被淘汰的是否是未被访问的
System.out.println("\n--- 验证LRU行为 ---");
// 假设我们访问前100个键中的某几个
for (int i = 0; i < 100; i += 10) {
jedis.get("key:" + i); // 访问这些键,刷新它们的“最近使用时间”
}
System.out.println("已访问 key:0, key:10, ..., key:90");
// 继续写入新数据,触发淘汰
System.out.println("继续写入新数据,触发淘汰...");
for (int i = 0; i < 5; i++) {
String newKey = "new_key_after_access:" + i;
jedis.set(newKey, "新数据");
}
// 检查之前被访问的键是否还在
System.out.println("检查被访问过的key:0是否存在: " + jedis.exists("key:0"));
// 检查一个很可能未被访问的早期键是否存在(例如key:1)
System.out.println("检查未被访问的key:1是否存在: " + jedis.exists("key:1"));
// 注意:由于LRU是近似算法,且采样随机,结果可能有波动,但key:0存在的概率应远大于key:1。
} finally {
jedisPool.close();
}
}
}
代码注释说明:
- 本例使用Jedis客户端连接Redis。
- 通过
configSet动态修改Redis配置,便于实验。生产环境务必在redis.conf中固定配置。 - 通过循环插入大字符串快速消耗100MB内存。
- 通过
jedis.get()模拟对特定键的访问,以影响LRU算法的决策。 - 最后通过检查键是否存在,直观感受LRU策略的效果。
你可以尝试修改 maxmemory-policy 为 volatile-lru 或 allkeys-random,并给部分键设置过期时间(使用 jedis.setex),来观察不同策略下的行为差异。
五、应用场景与选型建议
allkeys-lru:最常见的场景。如果你的数据访问模式符合“二八法则”(20%的热点数据承载80%的访问),且没有明确要求某些数据绝对不可删除,就用它。它像一个智能管家,自动帮你保留热门数据。volatile-lru/volatile-ttl:适用于缓存场景。你明确知道所有缓存数据都设置了合理的过期时间。volatile-ttl会优先清理快过期的数据,可能更符合缓存语义。如果你的缓存分为“可丢失的”和“不可丢失的”,只为可丢失的缓存键设置TTL,然后使用此策略,就能保护不可丢失的数据。allkeys-lfu:适用于访问频率分布非常不均匀,且需要更精准区分“高频”和“低频”数据的场景。比如,某些数据在某个活动期间爆发性访问后便无人问津,LFU能比LRU更快地将其淘汰。allkeys-random:当所有键被访问的概率几乎相等时使用,比较少见。它的优点是实现简单,淘汰开销小。noeviction:适用于纯存储场景,数据不允许丢失,且你有其他机制(如监控报警、快速扩容)来确保内存不会用尽。或者,你希望用写入失败来作为系统需要紧急扩容的强信号。
六、技术优缺点与注意事项
优点:
- 自动化管理:无需手动清理,系统根据策略自动维护内存水位。
- 策略多样:提供了多种策略适应不同业务模式。
- 可配置性强:可以精细控制采样大小、LFU计数衰减速度等参数。
缺点与注意事项:
- 数据丢失:除了
noeviction,其他策略都会导致数据被主动删除,应用必须能容忍缓存未命中或数据丢失。 - 性能开销:淘汰过程本身需要CPU计算(如LRU采样比较、LFU计数更新),在内存频繁触顶、持续淘汰时,会有一定性能损耗。
- 近似算法:LRU/LFU都是近似的,在极端情况下可能做出非最优淘汰决定。
volatile-*策略的陷阱:如果大量键没有设置TTL,这些键永远不会进入淘汰池,可能导致内存很快被无TTL的数据占满,而策略却无法清理它们,最终可能触发noeviction类似的行为(如果无TTL键已占满内存)或导致不可预测的结果。- 监控与告警:不能因为有了淘汰策略就高枕无忧。必须监控
used_memory、evicted_keys(被淘汰的键总数)等指标。如果evicted_keys持续快速增长,说明内存严重不足,淘汰非常频繁,这会影响性能,是急需扩容的明确信号。
七、文章总结
Redis的内存淘汰策略是一套精巧的机制,是在内存有限性与数据无限性之间寻求平衡的关键工具。理解并正确配置它,是每个使用Redis的开发者必备的技能。
选择策略的核心在于明确你的数据属性:它们是缓存(可丢)还是存储(不可丢)?它们的访问模式是时间敏感型(LRU)还是频率敏感型(LFU)?你的数据是否都有明确的生存周期(TTL)?
记住,没有放之四海而皆准的最佳策略。allkeys-lru 是一个稳健的默认选择,但结合具体业务逻辑仔细斟酌,才能让Redis在你的系统里发挥出最大效能。最后,请务必搭配监控系统,让淘汰策略成为你系统的“自动减震器”,而不是一个掩盖内存不足问题的“黑盒”。
评论