← 返回最佳实践列表

*Redis 过期策略与内存淘汰机制深度解析:原理、配置与生产实践

本文深入剖析 Redis 的过期键删除策略与内存淘汰机制,从源码原理到生产配置,帮助你构建高性能、高可用的 Redis 缓存系统。涵盖惰性删除、定期删除、八种淘汰策略的对比分析,以及真实场景下的故障排查与优化方案。


*目录


*一、为什么需要过期与淘汰机制

Redis 作为内存数据库,所有数据都存储在内存中。内存是有限的资源,如果不加以管理,将会面临两个问题:

  1. 过期数据占用内存:缓存数据通常具有时效性(如 Session、验证码、热点数据),过期后应当自动释放内存。
  2. 内存耗尽导致服务不可用:当写入数据量超过可用内存时,必须有策略决定哪些数据可以删除,以腾出空间给新数据。

Redis 通过过期策略解决第一个问题,通过内存淘汰策略解决第二个问题。这两个机制相互配合,构成了 Redis 内存管理的核心。


*二、键过期机制详解

*2.1 设置过期时间的方式

Redis 提供多种方式设置键的过期时间:

# 方式1:EXPIRE 命令,指定秒数
EXPIRE session:user:1001 3600

# 方式2:PEXPIRE 命令,指定毫秒数
PEXPIRE lock:resource:1001 30000

# 方式3:EXPIREAT 命令,指定 Unix 时间戳(秒)
EXPIREAT cache:product:1001 1750000000

# 方式4:PEXPIREAT 命令,指定 Unix 时间戳(毫秒)
PEXPIREAT cache:product:1001 1750000000000

# 方式5:SET 命令时直接设置过期时间
SET temp:code:1001 "123456" EX 300
SET temp:code:1001 "123456" PX 300000

返回值说明

  • 1:设置成功
  • 0:键不存在或设置失败

*2.2 过期时间的存储结构

Redis 使用过期字典(expires dict)来存储键的过期时间:

// redis/db.c 中的核心结构
typedef struct redisDb {
    dict *dict;                 // 键值对主字典
    dict *expires;              // 过期字典:key -> 过期时间(毫秒时间戳)
    dict *blocking_keys;
    dict *ready_keys;
    dict *watched_keys;
    int id;
    long long avg_ttl;          // 平均 TTL,用于统计
    unsigned long expires_cursor; // 定期删除的游标
} redisDb;

关键设计

  • 过期字典的键指向主字典中的键对象(共享指针,不额外复制)
  • 值是 long long 类型的毫秒时间戳
  • 这种设计使得检查过期时间的时间复杂度为 O(1) ### 2.3 惰性删除(Lazy Expiration)

原理:当客户端访问一个键时,Redis 检查该键是否已过期。如果过期,则立即删除并返回空值。

实现位置expireIfNeeded() 函数(db.c

执行流程

客户端执行 GET key
        ↓
    查找键是否存在
        ↓
    检查过期字典
        ↓
    当前时间 > 过期时间?
    ├─ 是 → 删除键,返回 nil
    └─ 否 → 返回键值

优点

  • 不消耗额外 CPU 资源,仅在访问时检查
  • 对读写性能无影响

缺点

  • 过期键可能长期占用内存(如果一直不被访问)
  • 大量过期键未被访问时,内存泄漏风险

*2.4 定期删除(Active Expiration)

原理:Redis 后台每 100ms 执行一次过期扫描,随机抽取一定数量的键检查是否过期,删除过期键。

实现位置activeExpireCycle() 函数(expire.c

核心参数

# redis.conf 中的配置
hz 10                          # 后台任务执行频率(每秒执行次数),默认 10
active-expire-effort 1         # 过期键扫描的 CPU 时间占比(Redis 7.0+)

执行流程

每 100ms 触发(hz=10 时)
        ↓
    遍历所有数据库
        ↓
    每个数据库随机抽取 20 个键(ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP)
        ↓
    检查并删除过期键
        ↓
    如果删除比例 > 25%,继续扫描该数据库
        ↓
    如果运行时间超过 25ms,暂停等待下一轮

Redis 7.0 改进

  • 引入 active-expire-effort 配置(1-10),控制过期扫描的 CPU 时间占比
  • 默认值为 1,最大允许消耗 25% 的 CPU 时间
  • 值为 10 时,允许消耗 75% 的 CPU 时间,适用于过期键密集的场景

*2.5 两种策略的协同工作

维度 惰性删除 定期删除
触发时机 客户端访问键时 后台定时任务
CPU 开销 无额外开销 消耗少量 CPU
内存回收及时性 不及时(可能长期不回收) 较及时
适用场景 所有访问模式 过期键较多的场景

Redis 实际策略:惰性删除 + 定期删除的混合策略,既保证访问时的一致性,又通过后台扫描防止内存泄漏。


*三、内存淘汰机制详解

*3.1 何时触发内存淘汰

当满足以下两个条件时,触发内存淘汰:

  1. maxmemory 配置项设置了内存上限
  2. 当前内存使用量达到或超过 maxmemory
# redis.conf 配置示例
maxmemory 4gb                  # 设置最大内存为 4GB
maxmemory-policy allkeys-lru  # 设置淘汰策略为所有键的 LRU

内存统计命令

# 查看内存使用情况
INFO memory

# 关键指标
used_memory: 4294967296      # 已使用内存(字节)
used_memory_human: 4.00G     # 人类可读格式
used_memory_rss: 4563402752   # 操作系统视角的内存占用
maxmemory: 4294967296         # 内存上限
maxmemory_policy: allkeys-lru # 当前淘汰策略
mem_fragmentation_ratio: 1.06  # 内存碎片率

*3.2 八种淘汰策略对比

Redis 提供 8 种内存淘汰策略,分为三大类:

策略 说明 适用场景
noeviction 不淘汰,直接返回写入错误 数据不允许丢失的场景
volatile-lru 已设置过期时间的键中,淘汰最近最少使用(LRU)的键 缓存+持久化数据混合场景
allkeys-lru 所有键中,淘汰最近最少使用(LRU)的键 纯缓存场景,最常用
volatile-lfu 已设置过期时间的键中,淘汰使用频率最低(LFU)的键 需要区分热点等级的缓存
allkeys-lfu 所有键中,淘汰使用频率最低(LFU)的键 纯缓存,考虑访问频率
volatile-random 已设置过期时间的键中,随机淘汰 特殊场景
allkeys-random 所有键中,随机淘汰 不推荐
volatile-ttl 已设置过期时间的键中,淘汰即将过期的键(TTL 最小) 希望尽快释放内存

策略选择决策树

是否允许数据丢失?
├─ 否 → noeviction
└─ 是 → 是否所有键都设置了过期时间?
    ├─ 是 → 需要区分访问频率?
    │   ├─ 是 → allkeys-lfu
    │   └─ 否 → allkeys-lru(推荐)
    └─ 否 → 未设置过期的键不能删除?
        ├─ 是 → volatile-lru 或 volatile-lfu
        └─ 否 → allkeys-lru(推荐)

*3.3 LRU 与 LFU 算法原理

*LRU(Least Recently Used)

核心思想:最近被访问的键在未来更可能被再次访问,淘汰最久未被访问的键。

传统实现:维护一个双向链表,每次访问将键移动到链表头部,淘汰时从尾部删除。时间复杂度 O(1),但内存开销大(需要额外指针)。

*LFU(Least Frequently Used)

核心思想:访问频率高的键在未来更可能被再次访问,淘汰访问次数最少的键。

传统实现:维护一个按访问频率排序的数据结构,淘汰频率最低的键。

LRU vs LFU 对比

维度 LRU LFU
关注维度 时间(最近访问) 频率(访问次数)
突发热点 能快速适应 适应较慢(需要积累频率)
周期性访问 可能误淘汰 能保留高频键
新键保护 需要(避免新键被快速淘汰)
实现复杂度 简单 较复杂

*3.4 近似 LRU/LFU 的实现

Redis 没有使用传统的 LRU/LFU 实现,而是采用近似算法来平衡内存和精度:

*近似 LRU 实现

原理:每个键对象使用 24 位字段记录最后一次访问的时间戳(分钟级精度)。

// redis/server.h 中的对象结构
typedef struct redisObject {
    unsigned type:4;        // 数据类型
    unsigned encoding:4;    // 编码方式
    unsigned lru:LRU_BITS;  // 24 位 LRU 时间戳或 LFU 计数
    int refcount;           // 引用计数
    void *ptr;              // 数据指针
} robj;

淘汰过程

  1. 随机抽取 5 个键(maxmemory_samples 配置,默认 5)
  2. 比较它们的 LRU 时间戳
  3. 淘汰最旧的那个

精度分析

  • 传统 LRU:100% 精确,内存开销大
  • Redis 近似 LRU:5 个样本时约 90% 精确度,15 个样本时约 99% 精确度
  • 内存开销几乎为零(复用对象头中的 24 位字段)

配置优化

# 提高 LRU 精度(消耗更多 CPU)
maxmemory-samples 10         # 默认 5,建议 5-10

*近似 LFU 实现(Redis 4.0+)

原理:24 位字段拆分为两部分:

  • 高 16 位:最后一次访问时间(分钟级)
  • 低 8 位:访问计数器(对数计数器)
// 24 位字段的拆分
// [16 bits: 最后访问时间][8 bits: 访问计数器]

计数器增长规则

  • 每次访问时,计数器以概率增加(概率随当前计数递减)
  • 计数器最大值为 255
  • 新键初始计数器为 5(避免新键被立即淘汰)

计数器衰减

  • 每过一分钟,计数器按配置衰减
  • lfu-decay-time 控制衰减速度(默认 1,表示每分钟衰减一次)

LFU 配置

# redis.conf
lfu-log-factor 10             # 对数计数因子,越大计数增长越慢
lfu-decay-time 1              # 衰减周期(分钟),越大频率统计越持久

*四、架构与数据流转

┌─────────────────────────────────────────────────────────┐
│                      客户端请求                          │
│                          │                              │
│                          ▼                              │
│              ┌─────────────────────┐                   │
│              │    惰性删除检查       │                   │
│              │  expireIfNeeded()    │                   │
│              │   键已过期?→删除     │                   │
│              └─────────────────────┘                   │
│                          │                              │
│                          ▼                              │
│              ┌─────────────────────┐                   │
│              │    执行命令          │                   │
│              │   GET/SET/LPUSH...  │                   │
│              └─────────────────────┘                   │
│                          │                              │
│                          ▼                              │
│    ┌─────────────────────────────────────────┐          │
│    │         后台定期扫描(每 100ms)          │          │
│    │   activeExpireCycle()                   │          │
│    │   随机抽取键 → 检查过期 → 删除           │          │
│    └─────────────────────────────────────────┘          │
│                          │                              │
│                          ▼                              │
│    ┌─────────────────────────────────────────┐          │
│    │         内存淘汰触发(写入时)             │          │
│    │   内存是否达到 maxmemory?                │          │
│    │   ├─ 否 → 正常写入                       │          │
│    │   └─ 是 → 执行淘汰策略                   │          │
│    │       随机抽样 → 按策略选择淘汰键 → 删除   │          │
│    └─────────────────────────────────────────┘          │
└─────────────────────────────────────────────────────────┘

数据流转说明

  1. 读请求:先经过惰性删除检查,再执行命令
  2. 写请求:执行命令后,如果内存超限,触发淘汰策略
  3. 后台扫描:独立线程周期性清理过期键(单线程模型中实际是事件循环)

*五、核心命令与配置

*5.1 过期相关命令

# 设置过期时间(秒)
EXPIRE key seconds
# 示例:设置 1 小时过期
EXPIRE session:user:1001 3600

# 设置过期时间(毫秒)
PEXPIRE key milliseconds

# 设置过期时间戳(秒)
EXPIREAT key timestamp

# 查看剩余过期时间(秒)
TTL key
# 返回:
#  -1  键存在但无过期时间
#  -2  键不存在
#  >=0 剩余秒数

# 查看剩余过期时间(毫秒)
PTTL key

# 移除过期时间,使键永久有效
PERSIST key

# 查看键的过期时间(Redis 7.0+)
EXPIRETIME key               # 返回秒级时间戳
PEXPIRETIME key              # 返回毫秒级时间戳

*5.2 内存相关命令

# 查看内存统计
INFO memory

# 查看内存碎片(Redis 4.0+)
MEMORY DOCTOR

# 查看键的内存占用
MEMORY USAGE key [SAMPLES count]

# 查看数据库统计
INFO keyspace
# 输出示例:
# db0:keys=100000,expires=95000,avg_ttl=345600000

# 手动触发内存回收(仅用于测试)
DEBUG POPULATE 100000        # 生成 10 万个测试键

*5.3 核心配置项

# redis.conf 内存管理配置

# ===== 内存上限 =====
maxmemory 4gb
# 可选单位:bytes, kb, mb, gb
# 设置为 0 表示无限制(不推荐生产环境)

# ===== 淘汰策略 =====
maxmemory-policy allkeys-lru
# 可选:noeviction, volatile-lru, allkeys-lru, volatile-lfu,
#       allkeys-lfu, volatile-random, allkeys-random, volatile-ttl

# ===== 淘汰样本数 =====
maxmemory-samples 5
# 默认 5,越大 LRU/LFU 精度越高,消耗越多 CPU
# 建议:5-10

# ===== 后台任务频率 =====
hz 10
# 控制过期键扫描和 AOF 刷盘的频率
# 建议:10-100(Redis 7.0+ 动态调整,不建议超过 100)

# ===== 过期扫描力度(Redis 7.0+)=====
active-expire-effort 1
# 范围 1-10,越大扫描越积极,消耗更多 CPU

# ===== LFU 配置(Redis 4.0+)=====
lfu-log-factor 10
# 访问计数器增长因子,越大增长越慢
# 0-1: 非常频繁访问才增加计数
# 10: 一般访问频率
# 100: 非常罕见访问才增加计数

lfu-decay-time 1
# 计数器衰减周期(分钟)
# 1: 每分钟衰减一次(对高频键更敏感)
# 10: 每 10 分钟衰减一次(对低频键更宽容)

*六、多语言代码示例

*6.1 Redis CLI

# 场景:用户 Session 缓存,1 小时过期
SET session:user:1001 "{\"id\":1001,\"name\":\"张三\"}" EX 3600

# 场景:验证码,5 分钟过期
SET verify:code:13800138000 "123456" EX 300

# 场景:查看缓存命中率
redis-cli INFO stats | grep keyspace
# keyspace_hits:1234567
# keyspace_misses:12345
# 命中率 = hits / (hits + misses) = 99.0%

# 场景:批量设置过期时间
for key in $(redis-cli --scan --pattern "temp:*"); do
    redis-cli EXPIRE $key 3600
done

*6.2 Shell 脚本

#!/bin/bash
# 监控 Redis 内存和过期键的脚本

REDIS_HOST="localhost"
REDIS_PORT="6379"
ALERT_THRESHOLD=80           # 内存使用率告警阈值(%)

# 获取内存信息
memory_info=$(redis-cli -h $REDIS_HOST -p $REDIS_PORT INFO memory)
used_memory=$(echo "$memory_info" | grep "^used_memory:" | cut -d: -f2 | tr -d '\r')
maxmemory=$(echo "$memory_info" | grep "^maxmemory:" | cut -d: -f2 | tr -d '\r')

# 计算使用率
if [ "$maxmemory" -gt 0 ]; then
    usage_percent=$((used_memory * 100 / maxmemory))
    echo "内存使用率: ${usage_percent}%"

    if [ "$usage_percent" -ge "$ALERT_THRESHOLD" ]; then
        echo "[ALERT] 内存使用率超过 ${ALERT_THRESHOLD}%"
        # 发送告警(这里可以接入钉钉/企业微信/邮件)
    fi
else
    echo "maxmemory 未设置,请配置内存上限"
fi

# 查看过期键统计
keyspace_info=$(redis-cli -h $REDIS_HOST -p $REDIS_PORT INFO keyspace)
echo "数据库统计:"
echo "$keyspace_info"

# 查看内存碎片率
fragmentation_ratio=$(echo "$memory_info" | grep "^mem_fragmentation_ratio:" | cut -d: -f2 | tr -d '\r')
echo "内存碎片率: ${fragmentation_ratio}"
if (( $(echo "$fragmentation_ratio > 1.5" | bc -l) )); then
    echo "[WARN] 内存碎片率过高,建议重启或使用内存整理"
fi

*6.3 Python 示例

import redis
import random
import time

# 连接 Redis
r = redis.Redis(
    host='localhost',
    port=6379,
    db=0,
    decode_responses=True,
    # 生产环境必须使用连接池
    max_connections=50
)

# 场景1:缓存商品信息,设置随机过期时间防止雪崩
def cache_product(product_id, data):
    """
    缓存商品数据,过期时间 5-7 分钟随机,防止缓存雪崩
    """
    key = f"product:{product_id}"
    # 基础过期时间 300 秒 + 0-120 秒随机偏移
    expire_time = 300 + random.randint(0, 120)
    r.setex(key, expire_time, data)
    print(f"Cached {key}, expires in {expire_time}s")

# 场景2:使用 Pipeline 批量设置过期时间
def batch_set_expiration(pattern, expire_seconds):
    """
    为匹配 pattern 的所有键批量设置过期时间
    """
    keys = r.scan_iter(match=pattern, count=100)

    pipeline = r.pipeline()
    count = 0

    for key in keys:
        pipeline.expire(key, expire_seconds)
        count += 1

        # 每 1000 个命令批量提交,避免 Pipeline 过大
        if count % 1000 == 0:
            pipeline.execute()
            pipeline = r.pipeline()
            print(f"Processed {count} keys...")

    # 提交剩余的命令
    if count % 1000 != 0:
        pipeline.execute()

    print(f"Total: {count} keys set to expire in {expire_seconds}s")

# 场景3:监控内存和过期键
def monitor_memory():
    """
    监控 Redis 内存使用情况和过期键比例
    """
    info = r.info('memory')
    keyspace = r.info('keyspace')

    used_memory = info['used_memory']
    maxmemory = info['maxmemory']
    policy = info['maxmemory_policy']

    print(f"已使用内存: {used_memory / 1024 / 1024:.2f} MB")
    print(f"内存上限: {maxmemory / 1024 / 1024:.2f} MB")
    print(f"淘汰策略: {policy}")

    if maxmemory > 0:
        usage_percent = (used_memory / maxmemory) * 100
        print(f"内存使用率: {usage_percent:.2f}%")

        if usage_percent > 80:
            print("[WARNING] 内存使用率超过 80%,请检查数据量和淘汰策略")

    # 查看 db0 的键和过期键数量
    if 'db0' in keyspace:
        db0 = keyspace['db0']
        print(f"总键数: {db0['keys']}, 有过期时间的键: {db0['expires']}")

# 测试
if __name__ == "__main__":
    cache_product(1001, "{\"name\":\"iPhone 15\",\"price\":5999}")
    monitor_memory()

*6.4 Java 示例(Jedis)

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import redis.clients.jedis.Pipeline;
import redis.clients.jedis.responses.ScanResult;

import java.util.List;
import java.util.Random;

public class RedisExpirationDemo {

    private static JedisPool jedisPool;
    private static final Random random = new Random();

    static {
        JedisPoolConfig config = new JedisPoolConfig();
        config.setMaxTotal(100);           // 最大连接数
        config.setMaxIdle(50);              // 最大空闲连接
        config.setMinIdle(10);              // 最小空闲连接
        config.setTestOnBorrow(true);       // 借用时测试连接

        jedisPool = new JedisPool(config, "localhost", 6379, 3000, null, 0);
    }

    /**
     * 缓存数据并设置随机过期时间(防止缓存雪崩)
     */
    public static void cacheWithRandomExpire(String key, String value, int baseExpireSeconds) {
        try (Jedis jedis = jedisPool.getResource()) {
            // 基础过期时间 + 0-120 秒随机偏移
            int expireTime = baseExpireSeconds + random.nextInt(120);
            jedis.setex(key, expireTime, value);
            System.out.println("Cached " + key + ", expires in " + expireTime + "s");
        }
    }

    /**
     * 批量设置过期时间(Pipeline 优化)
     */
    public static void batchSetExpiration(String pattern, int expireSeconds) {
        try (Jedis jedis = jedisPool.getResource()) {
            String cursor = "0";
            int total = 0;

            do {
                ScanResult<String> scanResult = jedis.scan(cursor, 
                    new redis.clients.jedis.params.ScanParams().match(pattern).count(1000));
                List<String> keys = scanResult.getResult();
                cursor = scanResult.getCursor();

                if (!keys.isEmpty()) {
                    Pipeline pipeline = jedis.pipelined();
                    for (String key : keys) {
                        pipeline.expire(key, expireSeconds);
                    }
                    pipeline.sync();
                    total += keys.size();
                }
            } while (!cursor.equals("0"));

            System.out.println("Total " + total + " keys set to expire in " + expireSeconds + "s");
        }
    }

    /**
     * 监控内存使用情况
     */
    public static void monitorMemory() {
        try (Jedis jedis = jedisPool.getResource()) {
            String usedMemory = jedis.info("memory").split("used_memory:")[1].split("\r\n")[0];
            String maxMemory = jedis.info("memory").split("maxmemory:")[1].split("\r\n")[0];
            String policy = jedis.info("memory").split("maxmemory_policy:")[1].split("\r\n")[0];

            long used = Long.parseLong(usedMemory.trim());
            long max = Long.parseLong(maxMemory.trim());

            System.out.println("已使用内存: " + (used / 1024 / 1024) + " MB");
            System.out.println("内存上限: " + (max / 1024 / 1024) + " MB");
            System.out.println("淘汰策略: " + policy);

            if (max > 0) {
                double usagePercent = (double) used / max * 100;
                System.out.println("内存使用率: " + String.format("%.2f", usagePercent) + "%");
            }
        }
    }

    public static void main(String[] args) {
        cacheWithRandomExpire("product:1001", "{\"name\":\"iPhone 15\"}", 300);
        monitorMemory();

        // 关闭连接池
        jedisPool.close();
    }
}

*七、生产实战案例

*案例 1:缓存雪崩导致数据库崩溃

场景:电商大促期间,大量商品缓存设置了相同的 1 小时过期时间。到期后,大量请求同时穿透到数据库,导致数据库连接池耗尽。

问题分析

# 查看当时的 Redis 监控
redis-cli INFO stats | grep keyspace
# keyspace_hits: 123456
# keyspace_misses: 89012      # 突增的 miss 数

# 查看内存变化
redis-cli INFO memory | grep used_memory
# used_memory: 2147483648     # 内存突然下降(大量键同时过期)

解决方案

import random

def set_cache_with_jitter(key, value, base_expire):
    """
    设置缓存时添加随机偏移(Jitter),防止集中过期
    """
    # 基础过期时间 + 10% 的随机偏移
    jitter = int(base_expire * 0.1 * random.random())
    expire_time = base_expire + jitter

    redis.setex(key, expire_time, value)

优化后的配置

# 应用层:过期时间添加随机偏移
# 原配置:所有缓存 3600 秒
# 新配置:3600 + random(0, 600) 秒

# Redis 层:增加后台扫描频率
hz 100                       # 提高过期扫描频率
active-expire-effort 3       # 适度提高过期扫描力度

效果

  • 过期时间分散在 1-1.17 小时之间
  • 缓存雪崩概率降低 90% 以上
  • 数据库连接峰值从 1000 降至 120

*案例 2:内存碎片导致 OOM

场景:Redis 内存使用率显示 70%,但操作系统实际内存占用 95%,触发 OOM Killer。

诊断过程

# 查看内存碎片率
redis-cli INFO memory | grep mem_fragmentation_ratio
# mem_fragmentation_ratio: 1.35   # 正常范围 1.0-1.5

# 查看具体内存分布
redis-cli INFO memory
# used_memory: 3000000000        # 3GB 数据
# used_memory_rss: 4200000000   # 4.2GB 实际占用
# mem_fragmentation_ratio: 1.4   # 碎片率偏高

# 查看大键
redis-cli --bigkeys
# 发现大量 Hash 键频繁更新(字段增删)

根因分析

  • 业务使用 Hash 存储用户行为数据,频繁增删字段
  • Redis 使用 jemalloc 分配器,分配和释放产生大量内存碎片
  • 碎片率 1.4 意味着 40% 的内存被碎片占用

解决方案

# 方案1:使用内存整理(Redis 4.0+,需要配置)
# 注意:active-defrag 会消耗 CPU,建议在低峰期启用
# 方案1:使用内存整理(Redis 4.0+,需要配置)
# 注意:active-defrag 会消耗 CPU,建议在低峰期启用
activedefrag yes
active-defrag-threshold-lower 10    # 当碎片率达到 10% 时开始整理
active-defrag-threshold-upper 100   # 当碎片率达到 100% 时全力整理
active-defrag-cycle-min 5           # 最小整理时间占比 5%
active-defrag-cycle-max 20          # 最大整理时间占比 20%

# 方案2:业务层优化(根本解决)
# 将频繁更新的 Hash 改为 String 或禁用 big Hash
# 使用 Hash 时,控制字段数量不超过 1000

效果

  • 碎片率从 1.4 降至 1.1
  • 操作系统内存占用从 4.2GB 降至 3.3GB
  • 不再触发 OOM

*案例 3:noeviction 策略导致写入失败

场景:某金融系统 Redis 配置为 maxmemory-policy noeviction,数据量持续增长,最终所有写入操作返回 (error) OOM command not allowed when used memory > maxmemory

诊断过程

# 查看当前策略和内存状态
redis-cli INFO memory | grep -E "maxmemory|used_memory"
# maxmemory: 2147483648
# used_memory: 2147483648
# maxmemory_policy: noeviction

# 查看被拒绝的命令统计
redis-cli INFO stats | grep rejected
# rejected_connections: 0
# evicted_keys: 0          # 未淘汰任何键
# expired_keys: 12345      # 仅过期键被删除

根因分析

  • noeviction 策略在内存达到上限时拒绝写入,不淘汰任何数据
  • 业务误将此策略用于缓存场景,导致服务不可用
  • 未设置过期时间的键占用了 80% 的内存

解决方案

# 步骤1:紧急切换淘汰策略(无需重启)
redis-cli CONFIG SET maxmemory-policy allkeys-lru

# 步骤2:确认策略生效
redis-cli CONFIG GET maxmemory-policy
# 1) "maxmemory-policy"
# 2) "allkeys-lru"

# 步骤3:写入恢复正常后,修改配置文件确保持久化
echo "maxmemory-policy allkeys-lru" >> /etc/redis/redis.conf

优化后的配置

# 缓存场景推荐配置
maxmemory 2gb
maxmemory-policy allkeys-lru
maxmemory-samples 10
hz 50

效果

  • 切换策略后 5 分钟内写入恢复正常
  • 后续淘汰操作平滑进行,无业务感知
  • 缓存命中率维持在 85% 以上

*八、FAQ

*Q1:为什么 Redis 不使用定时器(Timer)实现过期删除?

:如果使用传统定时器(如最小堆),需要为每个有过期时间的键维护一个定时器,内存开销为 O(N)。对于百万级键的场景,这会带来巨大的内存消耗和定时器管理开销。Redis 的惰性删除 + 定期删除策略在内存和 CPU 之间取得了更好的平衡。

*Q2:TTL 返回 -1 是什么意思?键是永久有效的吗?

TTL key 返回 -1 表示该键存在但没有设置过期时间。这并不意味着键永久有效——如果配置了 maxmemory-policy 且内存达到上限,该键仍然可能被淘汰。如果需要确保键永久存在,需要同时满足:

  1. 不设置过期时间(或使用 PERSIST 移除过期时间)
  2. 配置 maxmemory-policy noeviction(不推荐生产环境)

*Q3:Redis 6.0 的多线程 I/O 会影响过期键扫描和内存淘汰吗?

:Redis 6.0 引入的多线程 I/O 仅用于网络读写和协议解析,命令执行(包括过期删除、内存淘汰)仍然由主线程完成。因此,过期键扫描和内存淘汰的执行逻辑与单线程版本完全一致,多线程 I/O 不会对其产生影响。

*Q4:allkeys-lru 和 volatile-lru 在生产环境中如何选择?

:选择取决于业务场景:

  • allkeys-lru:适用于纯缓存场景,所有数据都可以被淘汰。这是最常见的配置,推荐用于大多数缓存系统。
  • volatile-lru:适用于混合场景(既有缓存数据,又有持久化数据)。只淘汰设置了过期时间的键,保护未设置过期时间的键。

关键判断:如果未设置过期时间的键被误淘汰会导致业务故障,选择 volatile-lru;否则选择 allkeys-lru

*Q5:LFU 的计数器衰减是否会导致热点键被误淘汰?

:不会。LFU 的衰减机制设计合理:

  • 计数器按对数增长,高频访问的键计数器会快速达到较高值(如 100+)
  • 衰减周期(lfu-decay-time)内,计数器仅减少 1
  • 即使衰减后,高频键的计数器仍然远高于低频键
  • 新键初始计数器为 5,提供了基本保护

如果需要更强的保护,可以调大 lfu-decay-time(如 10 分钟),使频率统计更持久。

*Q6:过期键扫描(activeExpireCycle)会阻塞 Redis 吗?

:不会。Redis 设计了严格的时限控制:

  • 每次扫描的运行时间不超过 25ms(快速模式)或 1ms(慢速模式)
  • 如果超时,扫描会暂停并在下一轮继续
  • 扫描期间,Redis 仍然可以处理客户端请求
  • 但大量过期键同时被删除时,会短暂影响性能(CPU spike)

*九、总结

Redis 的过期策略与内存淘汰机制是构建高性能缓存系统的核心基础。本文从源码原理、架构设计、命令配置、多语言实践到生产案例,系统性地解析了这两个机制:

核心要点

  1. 过期策略:Redis 采用惰性删除 + 定期删除的混合策略,既保证访问一致性,又防止内存泄漏。定期删除的频率可通过 hzactive-expire-effort 调整。
  2. 淘汰策略:Redis 提供 8 种淘汰策略,allkeys-lru 是大多数缓存场景的首选。Redis 4.0+ 引入的 LFU 策略更适合需要区分访问频率的场景。
  3. 近似算法:Redis 通过 24 位字段实现近似 LRU/LFU,在内存开销几乎为零的情况下达到 90%-99% 的精度。
  4. 生产建议
    • 始终设置 maxmemorymaxmemory-policy
    • 缓存过期时间添加随机偏移(Jitter),防止缓存雪崩
    • 监控 mem_fragmentation_ratio 和内存使用率
    • 避免使用 noeviction 策略用于缓存场景

下一步建议