跳到主要内容

Redis内存管理与优化策略

Redis内存管理策略

Redis作为内存数据库,合理的内存管理对于系统稳定运行至关重要。Redis提供了完善的过期策略和淘汰策略来管理内存。

过期策略 - 如何删除过期Key

Key过期机制概述

Redis 通过设置过期时间来控制键值对的生命周期。但需要注意的是,Redis 中的 Key 过期后并不一定会立即删除。这是 Redis 在性能和内存之间做出的权衡设计。

过期时间设置方式:

// 设置过期时间的几种方式
EXPIRE key 3600 // 设置3600秒后过期
EXPIREAT key 1735689600 // 设置到指定时间戳过期
SETEX session:token 1800 "abc123" // 创建时直接设置过期时间
PERSIST key // 移除过期时间

// Redis 6.2+ 支持的选项
EXPIRE key 3600 NX // 仅当Key没有过期时间时才设置
EXPIRE key 3600 XX // 仅当Key已有过期时间时才更新
EXPIRE key 3600 GT // 仅当新过期时间大于当前值时才更新
EXPIRE key 3600 LT // 仅当新过期时间小于当前值时才更新

过期时间的特性:

  1. 覆盖行为:使用 SETGETSET 等命令覆盖 Key 的值会清除过期时间
  2. 保留行为:使用 INCRLPUSHHSET 等修改命令不会影响过期时间
  3. 重命名转移:使用 RENAME 命令时,过期时间会转移到新的 Key 名上
  4. 负值删除:设置负数或过去的时间戳会立即删除 Key(触发 del 事件而非 expired 事件)
// 电商会话管理示例
public class SessionManager {

@Autowired
private RedisTemplate<String, String> redisTemplate;

/**
* 创建用户会话,设置30分钟过期
*/
public void createSession(String userId, String sessionData) {
String key = "session:user:" + userId;
redisTemplate.opsForValue().set(key, sessionData, Duration.ofMinutes(30));
}

/**
* 用户活跃时刷新会话过期时间
*/
public void refreshSession(String userId) {
String key = "session:user:" + userId;
// 重新设置30分钟过期时间
redisTemplate.expire(key, Duration.ofMinutes(30));
}

/**
* 更新会话数据(不影响过期时间)
*/
public void updateSessionData(String userId, String field, String value) {
String key = "session:user:" + userId;
// 使用HSET修改,过期时间保持不变
redisTemplate.opsForHash().put(key, field, value);
}
}

惰性删除策略

工作原理:

键过期后不会立即删除,而是在访问时才检查并删除。

优点:

  • 对CPU友好,不消耗额外CPU时间扫描
  • 只在需要时才检查,延迟删除

缺点:

  • 已过期但未访问的Key会一直占用内存
  • 可能导致内存泄漏

实战案例:

// 场景: 验证码过期
SET sms:code:13800138000 "123456" EX 300 // 5分钟过期

// 5分钟后,用户未使用验证码
// Key仍然占用内存,直到:
// 1. 用户尝试验证 -> 触发惰性删除
// 2. 定期删除扫描到

定期删除策略

工作原理:

Redis默认每100ms执行一次过期扫描:

  1. 从过期字典中随机抽取一批Key
  2. 检查这些Key是否过期
  3. 删除已过期的Key
  4. 如果过期Key比例超过25%,重复步骤1

优点:

  • 主动清理过期Key,避免内存泄漏
  • 通过随机采样降低CPU开销

缺点:

  • 占用CPU资源
  • 可能影响Redis性能

组合策略

Redis 同时使用惰性删除和定期删除两种策略:

  • 惰性删除(被动过期):访问时保证返回的一定不是过期数据
  • 定期删除(主动过期):定期清理未被访问的过期数据

这种组合在 CPU 开销和内存占用之间取得了良好平衡。

Key 过期了一定会立即删除吗?

答案是:不一定!

Redis 的过期删除机制决定了过期的 Key 并不会立即被删除,主要有以下原因:

1. 惰性删除的延迟性

被动过期策略下,只有当客户端访问某个 Key 时才会检查是否过期。如果一个 Key 过期后从未被访问,它会一直占用内存,直到被主动删除策略清理。

// 场景示例:用户验证码
// T0: 设置验证码,5分钟过期
SET sms:code:13800138000 "123456" EX 300

// T1 (5分钟后): 验证码已过期
// 但如果用户从未使用验证码,这个Key会继续占用内存

// T2 (10分钟后): 主动删除策略扫描到才被清理
// 这期间,过期的Key一直占用着内存空间

2. 主动删除的采样机制

Redis 的主动删除不会一次性检查所有过期 Key,而是通过采样机制分批处理:

主动删除的执行流程:

  1. Redis 每秒执行 10 次过期扫描(每 100ms 一次)
  2. 从设置了过期时间的 Key 集合中随机抽取 20 个 Key
  3. 删除这 20 个 Key 中所有已过期的 Key
  4. 如果已过期的 Key 占比超过 25%,重复步骤 2
  5. 如果执行时间超过 25ms,退出循环,等待下次执行

这种机制的影响:

// 极端场景:100万个Key同时过期

// T0: 批量设置Key,都在1小时后过期
for (int i = 0; i < 1000000; i++) {
jedis.setex("temp:key:" + i, 3600, "data");
}

// T1 (1小时后): 100万个Key全部过期
// 但主动删除每次只抽取20个Key检查
// 即使一直满足25%的条件,也需要很多轮次才能全部删除

// 计算:假设每次都过期25%以上
// 第1轮:删除约5个(20个中的25%)
// 第2轮:继续删除约5个
// ...
// 需要数百万次循环才能删除完

// 这期间:
// 1. 过期的Key继续占用内存
// 2. 主线程被删除任务占用
// 3. 正常业务请求被阻塞

3. 过期精度的限制

// Redis的过期精度说明

// Redis 2.4及之前:精度为0-1秒
// 即Key可能在过期后最多1秒才被检测到

// Redis 2.6及之后:精度为0-1毫秒
// 精度提升,但仍不是立即删除

// 实际场景
SET order:lock:12345 "locked" PX 100 // 100毫秒过期

// 100ms后,Key过期
// 但可能在101ms时才被检测到并删除
// 这1ms的延迟在高并发场景下可能导致问题

过期删除的策略对比

主动删除 vs 被动删除

对比维度主动删除被动删除
删除时机定期扫描,主动清理访问时检查,被动清理
CPU开销占用CPU进行定期扫描几乎无额外CPU开销
内存占用及时释放内存可能导致内存泄漏
访问延迟可能因大量删除阻塞访问时删除有轻微延迟
适用场景内存敏感场景CPU敏感场景

主动删除的优点:

  1. 及时释放内存:定期清理过期 Key,避免内存被长时间占用
  2. 避免内存泄漏:即使 Key 永不被访问,也会被定期清理
  3. 更好的资源利用:保持内存使用率在合理范围内

主动删除的缺点:

  1. 增加系统开销:定期扫描消耗 CPU 资源
  2. 可能导致延迟:大量 Key 同时过期时,删除操作占用主线程,阻塞正常请求
  3. 性能波动:删除高峰期可能导致 Redis 性能下降

被动删除的优点:

  1. 减少系统开销:不需要定期扫描,节省 CPU 资源
  2. 按需删除:只在访问时删除,避免不必要的操作
  3. 性能稳定:不会因为批量删除导致性能波动

被动删除的缺点:

  1. 内存占用高:过期但未访问的 Key 会一直占用内存
  2. 可能内存泄漏:如果大量 Key 过期后永不被访问,内存会持续增长
  3. 依赖访问:必须等待访问才能清理,不够主动

配置过期删除行为

被动删除策略无需额外配置,当你设置 Key 的过期时间(TTL)时,Redis 会自动处理。

主动删除策略可以通过配置文件调整行为:

# redis.conf 配置

# 每秒执行的定时器频率(默认10,即每100ms执行一次)
# 增加该值可以提高主动删除的频率
hz 10

# Redis的最大内存限制
# 设置合适的最大内存,确保内存不足时触发删除
maxmemory 4gb

# 内存淘汰策略(与过期删除配合使用)
maxmemory-policy allkeys-lru
// 电商系统配置示例
public class RedisConfiguration {

/**
* 配置建议:
* 1. hz设置为10-20之间(默认10)
* - 过低:过期Key清理不及时
* - 过高:CPU开销增大
*
* 2. maxmemory设置为物理内存的70%
* - 预留30%给系统和其他进程
*
* 3. maxmemory-policy根据业务选择
* - 纯缓存:allkeys-lru
* - 部分持久化:volatile-lru
*/
public void configureRedis() {
// 通过配置文件或命令行设置
// maxmemory 4gb
// hz 10
}
}

配置调优建议:

# 场景1:内存敏感型(如缓存服务器)
maxmemory 8gb
hz 15 # 提高删除频率
maxmemory-policy allkeys-lru

# 场景2:性能敏感型(如实时计数)
maxmemory 16gb # 提供充足内存
hz 10 # 标准频率
maxmemory-policy volatile-lru

# 场景3:混合场景
maxmemory 12gb
hz 12 # 适度提高
maxmemory-policy allkeys-lfu # 保留热点数据

过期删除与持久化

AOF 持久化中的过期处理:

// 当Key过期被删除时,Redis会在AOF文件中追加一条DEL命令

// T0: 设置Key
SET user:session:10086 "data" EX 60

// T1 (60秒后): Key过期被删除
// AOF文件中会追加:
DEL user:session:10086

// 这样保证了AOF重放时的一致性

主从复制中的过期处理:

主从过期机制的特点:

  1. 从节点不独立删除:从节点连接到主节点时,不会独立删除过期 Key,而是等待主节点的 DEL 命令
  2. 保持完整状态:从节点仍然维护过期时间的完整状态信息
  3. 主节点提升:当从节点被提升为主节点时,它会独立执行过期删除,完全具备主节点的功能
  4. 一致性保证:这种机制保证了主从之间的数据一致性,避免了时钟不同步导致的问题
// 主从场景示例

// Master节点
SET cache:product:10001 "商品数据" EX 3600

// 3600秒后,Key在Master上过期
// Master执行删除,并生成DEL命令

// Slave节点
// 收到Master的DEL命令后才删除
// 而不是自己检测到过期就删除

// 好处:
// 1. 避免主从时钟不一致导致的问题
// 2. 保证数据一致性
// 3. 简化从节点逻辑

大批量Key过期引发的性能问题

问题描述

当Redis中有大量的Key同时过期时,会导致其他Key的读写效率明显降低,业务访问延时增大。

核心原因:

Redis的定期删除任务在主线程中执行,而不是在后台线程。当大量Key同时过期时:

实战场景

场景1: 秒杀活动结束

// 秒杀活动: 100万个商品库存Key,均在同一时刻过期
// 活动时间: 2023-12-12 20:00:00 ~ 20:05:00

for (int i = 0; i < 1000000; i++) {
SETEX seckill:stock:20231212:product:{i} 300 1000
}
// 300秒后,100万个Key同时过期

// 20:05:00 时刻:
// 定期任务扫描到大量过期Key
// 需要删除100万个Key,占用主线程数秒时间

// 这期间的业务请求:
GET user:info:10086 // 需要等待删除任务完成
SET order:status:123456 "paid" // 被阻塞
// 用户感知: 系统卡顿数秒

场景2: 整点批量缓存失效

// 凌晨2:00:00 批量刷新缓存,500万个Key设置同一过期时间

// 每天凌晨2点定时任务
for (Product p : getAllProducts()) {
// 错误: 所有商品都在明天6:00过期
SETEX product:cache:{p.id} 14400 p.toJson() // 4小时后过期
}

// 第二天6:00:00:
// 500万个Key同时过期
// 定期删除任务占用CPU,引发性能拖活
// 同时大量用户访问触发缓存重建 -> 缓存雪崩

性能影响分析

// 测试数据: 100万个Key同时过期

// 正常情况:
GET user:info:10086 // 响应时间: 0.5ms

// 大量删除期间:
GET user:info:10086 // 响应时间: 50-100ms (增长100-200倍)

// 原因:
// 1. 单线程模型,删除和查询串行执行
// 2. 大量删除操作占据主线程
// 3. 正常业务请求被迫等待

解决方案

方案1: 设置随机过期时间

核心思想: 将过期时间打散,避免集中过期

// 错误做法: 固定过期时间
for (int i = 0; i < 1000000; i++) {
SETEX cache:product:{i} 3600 data // 所有Key 1小时后过期
}

// 正确做法: 随机过期时间
import java.util.Random;

Random random = new Random();
for (int i = 0; i < 1000000; i++) {
// 基础时间 3600秒 ± 随机300秒 (3300-3900秒)
int expireTime = 3600 + random.nextInt(600) - 300;
jedis.setex("cache:product:" + i, expireTime, data);
}

// 效果:
// - 过期时间分散在600秒范围内
// - 每次定期扫描只删除小批量Key
// - 避免瞬时高并发删除

方案2: 使用惰性删除

适用场景: 对内存占用不敏感,优先保证性能

// 策略: 不设置过期时间,依赖内存淘汰策略

// 配置内存淘汰
maxmemory 4gb
maxmemory-policy allkeys-lru // 内存满时LRU淘汰

// 业务代码
for (int i = 0; i < 1000000; i++) {
// 不设置过期时间
jedis.set("cache:product:" + i, data);
}

// 优点:
// - 无大量同时过期问题
// - 惰性删除,不占用主线程
// 缺点:
// - 内存占用可能较高
// - 需要合理设置maxmemory

方案3: 分批设置过期时间

适用场景: 大量数据初始化

// 场景: 秒杀活动预热100万个商品

// 错误: 一次性全部设置相同过期时间
for (int i = 0; i < 1000000; i++) {
SETEX seckill:stock:{i} 3600 1000
}

// 正确: 分批设置不同过期时间
int batchSize = 10000; // 每批次处理1万
int baseExpire = 3600;

for (int batch = 0; batch < 100; batch++) {
// 每批次增加10秒的过期时间偏移
int expireOffset = batch * 10;

for (int i = 0; i < batchSize; i++) {
int productId = batch * batchSize + i;
int expire = baseExpire + expireOffset + random.nextInt(10);
jedis.setex("seckill:stock:" + productId, expire, "1000");
}
}

// 效果:
// - 100万Key的过期时间分散在10分钟内
// - 避免瞬时大量删除

方案4: 使用Redis Cluster分散压力

适用场景: 数据量巨大,单机无法承载

// 使用Redis Cluster自动分散数据

JedisCluster cluster = new JedisCluster(nodes);

// 数据自动分散到10个节点
for (int i = 0; i < 10000000; i++) {
cluster.setex("product:" + i, 3600, data);
}

// 优势:
// - 每个节点只负责100万Key
// - 删除压力分散到10个节点
// - 单节点影响降低90%

最佳实践总结

场景推荐方案理由
秒杀活动随机过期 + 分批设置避免活动结束后集中删除
商品缓存随机过期时间分散缓存失效时间点
会话存储惰性删除 + LRU内存可控,性能优先
大数据量Redis Cluster分散压力,水平扩展
临时数据短过期 + 随机快速释放,避免积压

内存释放的真相

即使Redis删除了Key,内存也不一定立即归还操作系统。

内存分配器的工作机制

Redis使用jemalloc(或tcmalloc)等内存分配器,而非直接使用malloc/free。

为什么要池化:

  1. 性能优化: 避免频繁的系统调用(malloc/free开销大)
  2. 快速复用: 内存分配器缓存的内存可快速分配给新数据
  3. 减少碎片: 分配器可以更好地管理内存碎片

实际表现:

# 删除大量数据后
INFO memory
used_memory: 500MB # Redis认为的使用量(减少了)
used_memory_rss: 800MB # 操作系统看到的物理内存(未明显减少)

# 原因: 500MB空闲内存在jemalloc的内存池中,未归还OS

何时归还OS:

  • 空闲内存块非常大
  • 总空闲内存超过阈值
  • 满足分配器的特定条件

内存淘汰策略

当Redis内存达到maxmemory限制时,需要通过淘汰策略删除部分数据腾出空间。

八种淘汰策略

配置方式:

maxmemory 2gb                    # 设置最大内存
maxmemory-policy allkeys-lru # 设置淘汰策略

不淘汰策略

noeviction (默认):

  • 不删除任何Key
  • 内存满时返回错误
  • 只读操作仍可执行

适用场景: 对数据完整性要求极高,宁可拒绝服务也不丢数据

LRU策略(最近最少使用)

allkeys-lru:

  • 从所有Key中淘汰最近最少使用的
  • 推荐用于缓存场景

volatile-lru:

  • 从设置了过期时间的Key中淘汰
  • 适合半缓存半持久化场景

原理:

// 模拟LRU淘汰
// 场景: 商品详情缓存,最大容量100个

GET product:detail:10001 // 访问时间: T1
GET product:detail:10002 // 访问时间: T2
GET product:detail:10003 // 访问时间: T3

// 内存满时,淘汰最久未访问的10001

LFU策略(最不经常使用)

allkeys-lfu (Redis 4.0+):

  • 淘汰访问频率最低的Key
  • 适合热点数据明显的场景

volatile-lfu:

  • 从有过期时间的Key中淘汰访问频率最低的

LRU vs LFU:

// LRU场景
Key A: 1小时前访问1000,最近未访问 -> 被淘汰
Key B: 刚刚访问1-> 保留

// LFU场景
Key A: 总访问1000-> 保留
Key B: 总访问1-> 被淘汰

// LFU更适合识别真正的热点数据

随机淘汰策略

allkeys-random:

  • 从所有Key中随机淘汰

volatile-random:

  • 从有过期时间的Key中随机淘汰

适用场景: 所有Key访问概率相同,或无明显热点

TTL策略

volatile-ttl:

  • 淘汰剩余过期时间最短的Key
  • 优先删除即将过期的数据
// 场景示例
SET cache:news:001 "内容1" EX 60 // 剩余60秒
SET cache:news:002 "内容2" EX 300 // 剩余300秒
SET cache:news:003 "内容3" EX 120 // 剩余120秒

// 内存不足时,先淘汰cache:news:001

淘汰策略选择指南

场景推荐策略理由
纯缓存allkeys-lru默认最优,淘汰最久未使用
热点数据明显allkeys-lfu保留高频访问数据
部分数据需持久volatile-lru只淘汰缓存数据
临时会话存储volatile-ttl优先删除即将过期
所有数据同等重要allkeys-random随机淘汰,公平对待

云厂商默认配置:

  • 阿里云Redis: volatile-lru
  • 腾讯云Redis: noeviction (建议修改为allkeys-lru)

实战案例

// 电商场景: 商品详情缓存
@Configuration
public class RedisConfig {

@Bean
public RedisTemplate<String, Object> redisTemplate() {
// 配置连接
RedisTemplate<String, Object> template = new RedisTemplate<>();

// 建议配置
// maxmemory: 根据服务器内存设置,预留30%给系统
// maxmemory-policy: allkeys-lru

return template;
}
}

// 业务代码
public ProductDetail getProductDetail(Long productId) {
String key = "product:detail:" + productId;

// 1. 尝试从Redis读取
ProductDetail detail = redisTemplate.opsForValue().get(key);

if (detail != null) {
return detail;
}

// 2. Redis未命中,查询数据库
detail = productMapper.selectById(productId);

// 3. 写入Redis,不设置过期时间
// 依靠allkeys-lru自动淘汰冷数据
redisTemplate.opsForValue().set(key, detail);

return detail;
}

Key和Value设计原则

合理的Key/Value设计能显著提升Redis性能并降低内存占用。

Key设计原则

1. 可读性优先

// 推荐: 清晰的命名
user:profile:10086
order:detail:20231201001
product:stock:sku:12345

// 不推荐: 含糊不清
u:p:10086
o:20231201001

2. 使用命名空间

通过冒号分隔不同层级:

// 格式: 业务模块:数据类型:业务ID:字段
user:basic:10086:name // 用户基本信息-姓名
user:basic:10086:age // 用户基本信息-年龄
user:order:10086:list // 用户订单列表
user:order:10086:count // 用户订单数量

3. 简洁性

// 好: 简洁但有意义
session:token:abc123

// 不好: 过于冗长
user_session_authentication_token_for_mobile_app:abc123

4. 避免特殊字符

允许的字符:

  • 大小写字母
  • 数字
  • 下划线(_)
  • 连字符(-)
  • 点号(.)
  • 冒号(:)

避免使用:

  • 空格
  • 中文
  • 特殊符号(@#$%等)

5. 长度控制

// Key过长的问题:
// 1. 占用额外内存
// 2. 影响查询性能
// 3. 降低可读性

// 建议长度: 20-50个字符
// 超过100个字符需要优化

Value设计原则

1. 选择合适的数据类型

// 场景: 存储用户信息

// 方案1: String (不推荐)
SET user:10086 '{"name":"张三","age":25,"city":"北京"}'

// 方案2: Hash (推荐)
HSET user:10086 name "张三"
HSET user:10086 age 25
HSET user:10086 city "北京"

// Hash优势:
// - 可单独修改字段
// - 内存占用更少(小对象时)
// - 操作更灵活

2. 避免大Key问题

什么是大Key:

  • String类型: value > 10KB
  • List/Set/ZSet: 元素数量 > 5000
  • Hash: field数量 > 5000

大Key的危害:

解决方案:

// 问题: 用户浏览历史,可能有上万条
// 不好的设计
LPUSH user:browse:10086 "商品1"
LPUSH user:browse:10086 "商品2"
// ... 10000条数据,形成大Key

// 好的设计: 拆分
// 只保留最近100条
LPUSH user:browse:10086 "商品1"
LTRIM user:browse:10086 0 99 // 只保留最新100条

// 历史数据降级到MySQL
INSERT INTO user_browse_history VALUES (10086, "商品1", now());

3. 数据压缩

// 场景: 存储商品详情(大量文本)

// 方案1: 直接存储JSON (可能10KB+)
SET product:10001 "{...大量JSON数据...}"

// 方案2: 压缩后存储
String json = toJson(product);
byte[] compressed = gzip(json);
SET product:10001 compressed

// 读取时解压
byte[] data = redisTemplate.get("product:10001");
String json = gunzip(data);

4. 设置合理的过期时间

// 不同数据类型的过期时间建议

// 验证码: 短期
SET sms:code:138xxx "123456" EX 300 // 5分钟

// 用户会话: 中期
SET session:token:abc123 "..." EX 7200 // 2小时

// 商品缓存: 长期
SET product:detail:10001 "..." EX 86400 // 24小时

// 配置数据: 不设置过期,手动更新
SET system:config:xxx "..." // 无过期时间

5. 利用编码优化

Redis会根据数据特点自动选择编码:

// ZipList优化条件
hash-max-ziplist-entries 512
hash-max-ziplist-value 64

// 利用这个特性
// 好: 小对象使用Hash,自动用ZipList
HSET user:profile:10086 name "张三"
HSET user:profile:10086 age "25"

// 不好: 超过阈值,无法使用ZipList
HSET user:data:10086 big_field "超过64字节的大量数据..."

综合实战

// 场景: 电商秒杀活动

// Key设计
seckill:stock:20231212:product:10001 // 库存
seckill:order:20231212:user:10086 // 用户订单
seckill:limit:20231212:user:10086 // 限购控制

// Value设计
// 1. 库存: String类型,简单高效
SET seckill:stock:20231212:product:10001 1000

// 2. 限购: String类型+过期时间
SET seckill:limit:20231212:user:10086 1 EX 86400

// 3. 订单列表: List类型,控制长度
LPUSH seckill:order:20231212:user:10086 "order:001"
LTRIM seckill:order:20231212:user:10086 0 9 // 只保留最新10单

// 4. 活动结束后自动清理
EXPIRE seckill:stock:20231212:product:10001 172800 // 2天后过期

通过合理的内存管理和Key/Value设计,可以让Redis在有限的内存资源下发挥最大性能。