*Redis 过期策略与内存淘汰机制深度解析:原理、配置与生产实践
本文深入剖析 Redis 的过期键删除策略与内存淘汰机制,从源码原理到生产配置,帮助你构建高性能、高可用的 Redis 缓存系统。涵盖惰性删除、定期删除、八种淘汰策略的对比分析,以及真实场景下的故障排查与优化方案。
*目录
*一、为什么需要过期与淘汰机制
Redis 作为内存数据库,所有数据都存储在内存中。内存是有限的资源,如果不加以管理,将会面临两个问题:
- 过期数据占用内存:缓存数据通常具有时效性(如 Session、验证码、热点数据),过期后应当自动释放内存。
- 内存耗尽导致服务不可用:当写入数据量超过可用内存时,必须有策略决定哪些数据可以删除,以腾出空间给新数据。
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 何时触发内存淘汰
当满足以下两个条件时,触发内存淘汰:
maxmemory配置项设置了内存上限- 当前内存使用量达到或超过
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;
淘汰过程:
- 随机抽取 5 个键(
maxmemory_samples配置,默认 5) - 比较它们的 LRU 时间戳
- 淘汰最旧的那个
精度分析:
- 传统 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? │ │
│ │ ├─ 否 → 正常写入 │ │
│ │ └─ 是 → 执行淘汰策略 │ │
│ │ 随机抽样 → 按策略选择淘汰键 → 删除 │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
数据流转说明:
- 读请求:先经过惰性删除检查,再执行命令
- 写请求:执行命令后,如果内存超限,触发淘汰策略
- 后台扫描:独立线程周期性清理过期键(单线程模型中实际是事件循环)
*五、核心命令与配置
*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 且内存达到上限,该键仍然可能被淘汰。如果需要确保键永久存在,需要同时满足:
- 不设置过期时间(或使用 PERSIST 移除过期时间)
- 配置
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 的过期策略与内存淘汰机制是构建高性能缓存系统的核心基础。本文从源码原理、架构设计、命令配置、多语言实践到生产案例,系统性地解析了这两个机制:
核心要点:
- 过期策略:Redis 采用惰性删除 + 定期删除的混合策略,既保证访问一致性,又防止内存泄漏。定期删除的频率可通过
hz和active-expire-effort调整。 - 淘汰策略:Redis 提供 8 种淘汰策略,allkeys-lru 是大多数缓存场景的首选。Redis 4.0+ 引入的 LFU 策略更适合需要区分访问频率的场景。
- 近似算法:Redis 通过 24 位字段实现近似 LRU/LFU,在内存开销几乎为零的情况下达到 90%-99% 的精度。
- 生产建议:
- 始终设置
maxmemory和maxmemory-policy - 缓存过期时间添加随机偏移(Jitter),防止缓存雪崩
- 监控
mem_fragmentation_ratio和内存使用率 - 避免使用
noeviction策略用于缓存场景
- 始终设置
下一步建议:
- 阅读 Redis 持久化 RDB/AOF 生产指南 了解数据持久化与内存管理的协同
- 阅读 Redis 大 Key/热 Key 治理 掌握内存异常排查方法
- 使用
redis-cli --bigkeys和 MEMORY USAGE 定期审计内存使用情况