Redis

Redis 核心知识笔记 - 第六部分:内存过期与淘汰策略

Redis 核心知识笔记 - 第六部分:内存过期与淘汰策略

一、过期策略

1.1 过期时间设置

Redis 允许为 Key 设置过期时间(TTL),到期后自动删除。

# 设置过期时间
EXPIRE key seconds              # 设置秒级过期时间
PEXPIRE key milliseconds        # 设置毫秒级过期时间
EXPIREAT key timestamp          # 设置过期时间点(Unix 时间戳,秒)
PEXPIREAT key timestamp         # 设置过期时间点(Unix 时间戳,毫秒)

# 设置值的同时设置过期时间
SET key value EX seconds        # 秒
SET key value PX milliseconds   # 毫秒
SETEX key seconds value         # 秒(旧语法)

# 查看剩余过期时间
TTL key                         # 返回秒数,-1 表示永不过期,-2 表示不存在
PTTL key                        # 返回毫秒数

# 移除过期时间
PERSIST key                     # 移除过期时间,变为永不过期

1.2 过期删除策略

Redis 使用 惰性删除 + 定期删除 两种策略配合:

惰性删除(Lazy Expiration)

优点

  • CPU 友好,只在访问时检查
  • 不会浪费 CPU 检查未访问的 Key

缺点

  • 内存不友好,过期但未访问的 Key 会一直占用内存

定期删除(Periodic Expiration)

配置参数

# redis.conf
# 定期删除频率,默认 10(每秒检查10次)
hz 10

1.3 过期策略对比

策略触发时机优点缺点
定时删除到期立即删除内存友好CPU 开销大(Redis 未采用)
惰性删除访问时检查CPU 友好可能内存泄漏
定期删除周期性检查折中方案仍可能有过期未删除的 Key

Note

Redis 同时使用惰性删除定期删除,但仍可能存在过期 Key 未及时删除的情况,这时就需要内存淘汰策略来保证内存不会被撑满。


二、内存淘汰策略

2.1 什么时候触发内存淘汰

当 Redis 使用内存达到 maxmemory 配置时,会触发内存淘汰。

# redis.conf
# 设置最大内存限制
maxmemory 4gb

# 设置淘汰策略
maxmemory-policy allkeys-lru

# 查看内存使用情况
INFO memory

2.2 八种淘汰策略

2.3 策略详解

策略范围算法说明
noeviction--不淘汰,写入时返回 OOM 错误
allkeys-lru所有 KeyLRU淘汰最近最少使用的 Key
allkeys-lfu所有 KeyLFU淘汰使用频率最低的 Key
allkeys-random所有 Key随机随机淘汰 Key
volatile-lru有过期时间的 KeyLRU淘汰最近最少使用的 Key
volatile-lfu有过期时间的 KeyLFU淘汰使用频率最低的 Key
volatile-random有过期时间的 Key随机随机淘汰 Key
volatile-ttl有过期时间的 KeyTTL优先淘汰即将过期的 Key

2.4 LRU vs LFU

LRU(Least Recently Used)

  • 原理:根据最后访问时间排序,淘汰最久未访问的
  • 问题:无法区分访问频率,冷数据偶尔被访问会导致热数据被淘汰
Key A: 被访问 1000 次,最后访问时间 10:00
Key B: 被访问 1 次,最后访问时间 10:01

LRU 会保留 Key B(更近访问),淘汰 Key A(实际更重要)

LFU(Least Frequently Used)

  • 原理:根据访问频率排序,淘汰访问次数最少的
  • 优势:更准确识别热点数据
  • 注意:Redis 4.0+ 支持
Key A: 被访问 1000 次
Key B: 被访问 1 次

LFU 会保留 Key A,淘汰 Key B ✓

2.5 Redis 近似 LRU 实现

Redis 没有使用传统的 LRU 链表(开销太大),而是使用近似 LRU 算法

# redis.conf
# LRU 采样数量,越大越精确,但 CPU 开销越大
maxmemory-samples 5

2.6 LFU 参数调优

# redis.conf

# LFU 计数器衰减因子(0-255)
# 值越大,衰减越慢,需要更多时间不访问才会降低频率
lfu-decay-time 1

# LFU 对数因子
# 值越大,达到最大频率所需的访问次数越多
lfu-log-factor 10

LFU 计数器增长曲线

lfu-log-factor达到 255 所需访问次数
0255
1524
1010000
100100万

三、策略选择建议

3.1 决策流程

3.2 场景推荐

场景推荐策略原因
通用缓存allkeys-lru简单有效,自动淘汰冷数据
热点数据明显allkeys-lfu更好保留热点数据
部分数据永不过期volatile-lru只淘汰有过期时间的 Key
Session 缓存volatile-ttl优先删除即将过期的会话
数据一致性要求高noeviction宁可报错也不丢数据

四、Java 代码示例

4.1 设置过期时间

@Service
public class RedisExpireService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    /**
     * 设置带过期时间的缓存
     */
    public void setWithExpire(String key, String value, long timeout, TimeUnit unit) {
        redisTemplate.opsForValue().set(key, value, timeout, unit);
    }
    
    /**
     * 设置过期时间
     */
    public Boolean expire(String key, long timeout, TimeUnit unit) {
        return redisTemplate.expire(key, timeout, unit);
    }
    
    /**
     * 设置过期时间点
     */
    public Boolean expireAt(String key, Date date) {
        return redisTemplate.expireAt(key, date);
    }
    
    /**
     * 获取剩余过期时间
     */
    public Long getExpire(String key, TimeUnit unit) {
        return redisTemplate.getExpire(key, unit);
    }
    
    /**
     * 移除过期时间
     */
    public Boolean persist(String key) {
        return redisTemplate.persist(key);
    }
    
    /**
     * 续期示例:用于分布式锁续期
     */
    public Boolean renewExpire(String key, String expectedValue, long timeout, TimeUnit unit) {
        String script = 
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "    return redis.call('pexpire', KEYS[1], ARGV[2]) " +
            "else " +
            "    return 0 " +
            "end";
        
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(script, Long.class),
            Collections.singletonList(key),
            expectedValue,
            String.valueOf(unit.toMillis(timeout))
        );
        
        return result != null && result == 1;
    }
}

4.2 内存监控

@Service
public class RedisMemoryService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    /**
     * 获取内存信息
     */
    public Map<String, Object> getMemoryInfo() {
        Properties info = redisTemplate.execute((RedisCallback<Properties>) 
            connection -> connection.info("memory")
        );
        
        Map<String, Object> result = new HashMap<>();
        if (info != null) {
            // 已使用内存
            result.put("used_memory", info.getProperty("used_memory"));
            result.put("used_memory_human", info.getProperty("used_memory_human"));
            // 峰值内存
            result.put("used_memory_peak", info.getProperty("used_memory_peak"));
            result.put("used_memory_peak_human", info.getProperty("used_memory_peak_human"));
            // 最大内存配置
            result.put("maxmemory", info.getProperty("maxmemory"));
            result.put("maxmemory_human", info.getProperty("maxmemory_human"));
            // 淘汰策略
            result.put("maxmemory_policy", info.getProperty("maxmemory_policy"));
            // 内存碎片率
            result.put("mem_fragmentation_ratio", info.getProperty("mem_fragmentation_ratio"));
        }
        return result;
    }
    
    /**
     * 获取 Key 占用内存大小(Redis 4.0+)
     */
    public Long getKeyMemoryUsage(String key) {
        return redisTemplate.execute((RedisCallback<Long>) connection -> {
            // MEMORY USAGE key
            Object result = connection.execute("MEMORY", 
                "USAGE".getBytes(), 
                key.getBytes()
            );
            return result != null ? (Long) result : null;
        });
    }
    
    /**
     * 获取淘汰统计信息
     */
    public Map<String, Object> getEvictionStats() {
        Properties info = redisTemplate.execute((RedisCallback<Properties>) 
            connection -> connection.info("stats")
        );
        
        Map<String, Object> result = new HashMap<>();
        if (info != null) {
            // 被淘汰的 Key 数量
            result.put("evicted_keys", info.getProperty("evicted_keys"));
            // 过期的 Key 数量
            result.put("expired_keys", info.getProperty("expired_keys"));
            // 缓存命中率
            String hits = info.getProperty("keyspace_hits");
            String misses = info.getProperty("keyspace_misses");
            if (hits != null && misses != null) {
                long h = Long.parseLong(hits);
                long m = Long.parseLong(misses);
                double hitRate = h * 100.0 / (h + m);
                result.put("hit_rate", String.format("%.2f%%", hitRate));
            }
        }
        return result;
    }
}

4.3 智能过期时间

@Service
public class SmartCacheService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    private static final Random random = new Random();
    
    /**
     * 设置缓存,带随机过期时间(防止缓存雪崩)
     */
    public void setWithRandomExpire(String key, String value, 
            long baseSeconds, long randomRange) {
        long expire = baseSeconds + random.nextInt((int) randomRange);
        redisTemplate.opsForValue().set(key, value, expire, TimeUnit.SECONDS);
    }
    
    /**
     * 缓存预热:批量设置不同过期时间
     */
    public void warmupCache(Map<String, String> data, long baseSeconds) {
        data.forEach((key, value) -> {
            // 过期时间在 baseSeconds 的 80%-120% 之间随机
            long expire = (long) (baseSeconds * (0.8 + random.nextDouble() * 0.4));
            redisTemplate.opsForValue().set(key, value, expire, TimeUnit.SECONDS);
        });
    }
    
    /**
     * 访问时续期(延长热点数据生命周期)
     */
    public String getAndRefresh(String key, long refreshThreshold, 
            long newExpire, TimeUnit unit) {
        String value = redisTemplate.opsForValue().get(key);
        
        if (value != null) {
            // 检查剩余过期时间
            Long ttl = redisTemplate.getExpire(key, unit);
            if (ttl != null && ttl < refreshThreshold) {
                // 剩余时间小于阈值,续期
                redisTemplate.expire(key, newExpire, unit);
            }
        }
        
        return value;
    }
}

五、运维监控

5.1 关键监控指标

# 查看内存使用
redis-cli INFO memory

# 关键指标
used_memory:1073741824           # 已使用内存(字节)
used_memory_human:1.00G          # 已使用内存(可读)
used_memory_rss:1181116416       # 操作系统分配的内存
used_memory_peak:1073741824      # 历史峰值
maxmemory:2147483648             # 最大内存配置
maxmemory_policy:allkeys-lru     # 淘汰策略
mem_fragmentation_ratio:1.10     # 内存碎片率

5.2 内存碎片处理

# 内存碎片率 > 1.5 时需要关注

# 方法1:重启 Redis(简单粗暴)

# 方法2:开启主动碎片整理(Redis 4.0+)
CONFIG SET activedefrag yes

# 碎片整理配置
active-defrag-ignore-bytes 100mb      # 碎片达到多少才开始整理
active-defrag-threshold-lower 10      # 碎片率低于此值不整理
active-defrag-threshold-upper 100     # 碎片率超过此值全力整理
active-defrag-cycle-min 1             # 整理周期最小 CPU 占用
active-defrag-cycle-max 25            # 整理周期最大 CPU 占用

5.3 过期 Key 清理脚本

#!/bin/bash
# 批量删除过期 Key(适用于 Key 太多导致定期删除不及时的情况)

REDIS_CLI="redis-cli -h 127.0.0.1 -p 6379 -a password"
PATTERN="cache:*"
COUNT=100

# 使用 SCAN 遍历,避免 KEYS 命令阻塞
cursor=0
deleted=0

while true; do
    result=$($REDIS_CLI SCAN $cursor MATCH "$PATTERN" COUNT $COUNT)
    cursor=$(echo "$result" | head -n 1)
    keys=$(echo "$result" | tail -n +2)
    
    for key in $keys; do
        # 检查 Key 是否已过期(TTL = -2 表示不存在/已过期)
        ttl=$($REDIS_CLI TTL "$key")
        if [ "$ttl" = "-2" ]; then
            $REDIS_CLI DEL "$key"
            ((deleted++))
        fi
    done
    
    if [ "$cursor" = "0" ]; then
        break
    fi
done

echo "Deleted $deleted expired keys"

六、最佳实践

6.1 过期时间设置原则

原则说明
必须设置 TTL避免内存泄漏,除非数据确实永不过期
避免集中过期随机化过期时间,防止缓存雪崩
热点数据续期频繁访问的数据可延长过期时间
合理设置时长太短增加 DB 压力,太长浪费内存

6.2 淘汰策略配置建议

# 生产环境推荐配置

# 设置最大内存(通常为物理内存的 70-80%)
maxmemory 12gb

# 推荐使用 allkeys-lru 或 allkeys-lfu
maxmemory-policy allkeys-lfu

# 增加采样数量提高精确度
maxmemory-samples 10

# 开启惰性释放(Redis 4.0+)
lazyfree-lazy-eviction yes
lazyfree-lazy-expire yes
lazyfree-lazy-server-del yes

6.3 内存使用优化

  1. 使用合适的数据结构:Hash 存储对象比 String 更节省内存
  2. 压缩 Value:对大 Value 进行 GZIP 压缩
  3. 精简 Key 名:使用简短但有意义的 Key 前缀
  4. 使用整数代替字符串:整数可以使用更紧凑的编码
  5. 定期清理无用 Key:使用 SCAN 定期扫描清理