当我们使用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实现为每个键维护了两个值:

  1. 一个衰减的访问计数器(0-255),随着时间推移会减少。
  2. 一个最近一次计数器衰减的时间戳。 每次键被访问时,计数器会根据一个复杂公式增加(增加的概率与当前计数值成反比,防止热门键计数无限增长),并且会随着时间推移缓慢衰减。淘汰时,就淘汰计数器值最小的键。这能更好地应对“突然火爆又长期冷门”的数据,避免它们长期占据内存。

四、实战配置与示例演示

下面,我们将通过一个完整的示例,演示如何在主流的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-policyvolatile-lruallkeys-random,并给部分键设置过期时间(使用 jedis.setex),来观察不同策略下的行为差异。

五、应用场景与选型建议

  • allkeys-lru最常见的场景。如果你的数据访问模式符合“二八法则”(20%的热点数据承载80%的访问),且没有明确要求某些数据绝对不可删除,就用它。它像一个智能管家,自动帮你保留热门数据。
  • volatile-lru / volatile-ttl:适用于缓存场景。你明确知道所有缓存数据都设置了合理的过期时间。volatile-ttl 会优先清理快过期的数据,可能更符合缓存语义。如果你的缓存分为“可丢失的”和“不可丢失的”,只为可丢失的缓存键设置TTL,然后使用此策略,就能保护不可丢失的数据。
  • allkeys-lfu:适用于访问频率分布非常不均匀,且需要更精准区分“高频”和“低频”数据的场景。比如,某些数据在某个活动期间爆发性访问后便无人问津,LFU能比LRU更快地将其淘汰。
  • allkeys-random:当所有键被访问的概率几乎相等时使用,比较少见。它的优点是实现简单,淘汰开销小。
  • noeviction:适用于纯存储场景,数据不允许丢失,且你有其他机制(如监控报警、快速扩容)来确保内存不会用尽。或者,你希望用写入失败来作为系统需要紧急扩容的强信号。

六、技术优缺点与注意事项

优点:

  • 自动化管理:无需手动清理,系统根据策略自动维护内存水位。
  • 策略多样:提供了多种策略适应不同业务模式。
  • 可配置性强:可以精细控制采样大小、LFU计数衰减速度等参数。

缺点与注意事项:

  1. 数据丢失:除了 noeviction,其他策略都会导致数据被主动删除,应用必须能容忍缓存未命中或数据丢失。
  2. 性能开销:淘汰过程本身需要CPU计算(如LRU采样比较、LFU计数更新),在内存频繁触顶、持续淘汰时,会有一定性能损耗。
  3. 近似算法:LRU/LFU都是近似的,在极端情况下可能做出非最优淘汰决定。
  4. volatile-* 策略的陷阱:如果大量键没有设置TTL,这些键永远不会进入淘汰池,可能导致内存很快被无TTL的数据占满,而策略却无法清理它们,最终可能触发 noeviction 类似的行为(如果无TTL键已占满内存)或导致不可预测的结果。
  5. 监控与告警:不能因为有了淘汰策略就高枕无忧。必须监控 used_memoryevicted_keys(被淘汰的键总数)等指标。如果 evicted_keys 持续快速增长,说明内存严重不足,淘汰非常频繁,这会影响性能,是急需扩容的明确信号。

七、文章总结

Redis的内存淘汰策略是一套精巧的机制,是在内存有限性与数据无限性之间寻求平衡的关键工具。理解并正确配置它,是每个使用Redis的开发者必备的技能。

选择策略的核心在于明确你的数据属性:它们是缓存(可丢)还是存储(不可丢)?它们的访问模式是时间敏感型(LRU)还是频率敏感型(LFU)?你的数据是否都有明确的生存周期(TTL)?

记住,没有放之四海而皆准的最佳策略。allkeys-lru 是一个稳健的默认选择,但结合具体业务逻辑仔细斟酌,才能让Redis在你的系统里发挥出最大效能。最后,请务必搭配监控系统,让淘汰策略成为你系统的“自动减震器”,而不是一个掩盖内存不足问题的“黑盒”。