Redis 数据过期删除策略与内存淘汰策略

Redis 作为高性能内存数据库,广泛用于缓存、会话管理和实时数据处理。由于内存资源有限,Redis 提供了数据过期删除策略和内存淘汰策略来管理内存。本文将深入剖析 Redis 的数据过期删除机制(包括惰性删除和定时删除)以及 8 种内存淘汰策略,结合生活化例子、Go 代码示例和教学风格,带你全面理解 Redis 的内存管理之道。无论你是初学者还是资深开发者,这篇文章都将为你提供清晰、实用的指导。

一、Redis 数据过期删除策略

Redis 支持为键设置过期时间(TTL,Time To Live),通过 EXPIRESETEX 等命令实现。过期键不会立即删除,而是通过特定的策略清理,以平衡性能和内存使用。Redis 的过期删除策略主要包括 惰性删除定时删除,两者结合使用。

1. 惰性删除(Lazy Deletion)

原理

惰性删除是指 Redis 在访问键时检查其是否过期,如果过期则立即删除并返回空(nil)。这是一种被动清理机制,核心实现在 expire.clookupKeyReadexpireIfNeeded 函数中。

为什么用惰性删除?

  • 性能优先:不主动扫描所有键,减少 CPU 开销。
  • 延迟清理:仅在必要时(如读写操作)删除过期键,避免无用操作。

生活化例子

想象你的冰箱(Redis 内存)里有些食物(键)标了保质期(TTL)。你不每天检查每件食物是否过期(主动清理),而是在想吃时(访问键)才看一眼保质期。如果过期(TTL 到期),直接扔掉(删除)。这就是惰性删除,省时省力。

实现细节

  • 过期时间存储:Redis 在键的元数据中存储过期时间戳(expire 字段,毫秒级),通过 dictEntry 关联。
  • 检查逻辑:每次访问键时,调用 expireIfNeeded 检查当前时间是否超过过期时间戳。
  • 删除操作:如果键过期,调用 dbDelete 删除键值对,并传播删除事件(AOF 和复制)。

Go 代码示例(模拟惰性删除):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package main

import (
	"fmt"
	"time"
)

type RedisDB struct {
	data map[string]struct {
		value string
		expireAt time.Time
	}
}

func NewRedisDB() *RedisDB {
	return &RedisDB{
		data: make(map[string]struct {
			value string
			expireAt time.Time
		}),
	}
}

func (db *RedisDB) Set(key, value string, ttl time.Duration) {
	db.data[key] = struct {
		value string
		expireAt time.Time
	}{
		value: value,
		expireAt: time.Now().Add(ttl),
	}
}

func (db *RedisDB) Get(key string) (string, bool) {
	if entry, exists := db.data[key]; exists {
		// 惰性删除:检查是否过期
		if time.Now().After(entry.expireAt) {
			delete(db.data, key)
			fmt.Printf("键 %s 已过期,删除\n", key)
			return "", false
		}
		return entry.value, true
	}
	return "", false
}

func main() {
	db := NewRedisDB()
	db.Set("user:123", "Alice", 2*time.Second)
	fmt.Println("设置键 user:123,TTL=2秒")

	// 立即读取
	if val, ok := db.Get("user:123"); ok {
		fmt.Printf("获取键 user:123: %s\n", val)
	}

	// 等待过期
	time.Sleep(3 * time.Second)
	if _, ok := db.Get("user:123"); !ok {
		fmt.Println("键 user:123 不存在")
	}
}

优缺点

  • 优点:简单高效,只在访问时处理过期键,CPU 开销低。
  • 缺点:如果键长期未访问,过期键可能占用内存,直到被访问或定时删除。

2. 定时删除(Periodic Deletion)

原理

定时删除是指 Redis 定期扫描部分键,主动删除已过期的键。核心实现在 expire.cactiveExpireCycle 函数中,通过 serverCron 定时调用。

为什么用定时删除?

  • 内存回收:主动清理未被访问的过期键,释放内存。
  • 渐进式清理:每次扫描少量键,避免阻塞主线程。

生活化例子

你每周清理一次冰箱(定时删除),检查每件食物的保质期,扔掉过期的。你不一次检查所有食物(全量扫描),而是每次看几件(采样),慢慢清理。Redis 的定时删除就像这种定期整理,保持冰箱整洁。

实现细节

  • 采样扫描:每次调用 activeExpireCycle,从数据库中随机抽样一批键(默认 20 个,ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP)。
  • 过期检查:检查采样键的过期时间戳,删除已过期键。
  • 时间限制:每次扫描受时间预算限制(默认 25% 的 server.hz 周期),避免影响主线程。
  • 数据库轮询:依次扫描每个数据库(db0dbN),确保公平性。
  • 快慢模式
    • 慢模式:正常扫描,受时间预算限制。
    • 快模式:内存压力大时(如接近 maxmemory),加速扫描。

Go 代码示例(模拟定时删除):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
package main

import (
	"fmt"
	"math/rand"
	"time"
)

type RedisDB struct {
	data map[string]struct {
		value    string
		expireAt time.Time
	}
}

func NewRedisDB() *RedisDB {
	return &RedisDB{
		data: make(map[string]struct {
			value    string
			expireAt time.Time
		}),
	}
}

func (db *RedisDB) Set(key, value string, ttl time.Duration) {
	db.data[key] = struct {
		value    string
		expireAt time.Time
	}{value, time.Now().Add(ttl)}
}

func (db *RedisDB) ActiveExpireCycle(sampleSize int, maxDuration time.Duration) {
	start := time.Now()
	keys := make([]string, 0, len(db.data))
	for key := range db.data {
		keys = append(keys, key)
	}

	// 随机采样
	rand.Shuffle(len(keys), func(i, j int) {
		keys[i], keys[j] = keys[j], keys[i]
	})
	if sampleSize > len(keys) {
		sampleSize = len(keys)
	}

	// 检查和删除过期键
	for i := 0; i < sampleSize; i++ {
		key := keys[i]
		if time.Now().After(db.data[key].expireAt) {
			fmt.Printf("定时删除键 %s\n", key)
			delete(db.data, key)
		}
		if time.Since(start) > maxDuration {
			break
		}
	}
}

func main() {
	db := NewRedisDB()
	db.Set("user:123", "Alice", 2*time.Second)
	db.Set("user:456", "Bob", 5*time.Second)
	fmt.Println("设置键 user:123 和 user:456")

	// 模拟定时任务
	for i := 0; i < 3; i++ {
		fmt.Printf("第 %d 次定时扫描\n", i+1)
		db.ActiveExpireCycle(2, 100*time.Millisecond)
		time.Sleep(1 * time.Second)
	}
}

优缺点

  • 优点:主动回收内存,适合长期未访问的过期键。
  • 缺点:扫描需要 CPU 资源,频繁扫描可能影响性能。

3. 惰性删除与定时删除的协同工作

  • 互补机制:惰性删除处理活跃键,定时删除清理冷门键。
  • 性能平衡:惰性删除零开销,定时删除渐进式执行,避免阻塞。
  • 配置调整:通过 hz 参数调整定时删除频率(默认 10,范围 1-500)。

生活化例子
冰箱清理(内存管理)结合了你用时检查(惰性删除)和每周整理(定时删除)。用时扔掉坏食物很快(低开销),每周慢慢整理角落的过期品(渐进清理)。Redis 用这两种方式,保持冰箱(内存)整洁高效。

二、Redis 内存淘汰策略

当 Redis 内存使用量接近 maxmemory 配置的上限时,触发内存淘汰策略,删除部分键以释放空间。Redis 提供 8 种淘汰策略,实现在 evict.cevict.c 文件中,通过 performEvictions 函数执行。

1. noeviction(不淘汰)

  • 原理:不删除任何键,当内存不足时,返回错误(如 OOM)。
  • 适用场景:内存充足或严格要求数据不丢失(如主数据库)。
  • 生活化例子:冰箱满了,你拒绝放新食物(返回错误),宁愿去买个新冰箱。

2. volatile-lru(对过期键使用 LRU)

  • 原理:从设置了过期时间的键中,选择最近最少使用(LRU)的键删除。
  • 适用场景:希望保留非过期键,优先删除不活跃的过期键。
  • 生活化例子:冰箱里只扔掉标了保质期的食物(过期键),挑最久没吃的(LRU)。

3. volatile-random(随机删除过期键)

  • 原理:从设置了过期时间的键中,随机选择键删除。
  • 适用场景:过期键较多,LRU 开销不可接受时。
  • 生活化例子:冰箱里随机扔掉一件标了保质期的食物,省去挑拣时间。

4. volatile-ttl(优先删除剩余TTL最短的键)

  • 原理:从设置了过期时间的键中,选择剩余 TTL 最短的键删除。
  • 适用场景:希望尽快删除即将过期的键,释放内存。
  • 生活化例子:冰箱里扔掉保质期最快到的食物(TTL 最小)。

5. allkeys-lru(对所有键使用 LRU)

  • 原理:从所有键中,选择最近最少使用的键删除。
  • 适用场景:缓存场景,数据可重建,优先保留活跃键。
  • 生活化例子:冰箱里扔掉最久没吃的食物,不管有没有保质期。

6. allkeys-random(随机删除所有键)

  • 原理:从所有键中,随机选择键删除。
  • 适用场景:数据均匀访问,LRU 开销高时。
  • 生活化例子:冰箱里随机扔掉一件食物,简单快速。

7. allkeys-lfu(对所有键使用 LFU)

  • 原理:从所有键中,选择最不经常使用(LFU,Least Frequently Used)的键删除。
  • 适用场景:希望保留高频访问的键,适合热点数据场景。
  • 生活化例子:冰箱里扔掉最少吃的食物(访问频率低)。

8. volatile-lfu(对过期键使用 LFU)

  • 原理:从设置了过期时间的键中,选择最不经常使用的键删除。
  • 适用场景:只对过期键应用 LFU,保护非过期键。
  • 生活化例子:冰箱里只扔掉标了保质期且最少吃的食物。

Go 代码示例(模拟 LRU 淘汰策略):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
package main

import (
	"fmt"
	"time"
)

type CacheEntry struct {
	key       string
	value     string
	lastUsed  time.Time
}

type Cache struct {
	data     map[string]*CacheEntry
	capacity int
}

func NewCache(capacity int) *Cache {
	return &Cache{
		data:     make(map[string]*CacheEntry),
		capacity: capacity,
	}
}

func (c *Cache) Put(key, value string) {
	if len(c.data) >= c.capacity && c.data[key] == nil {
		// 触发 LRU 淘汰
		var lruKey string
		var oldestTime time.Time
		for k, entry := range c.data {
			if lruKey == "" || entry.lastUsed.Before(oldestTime) {
				lruKey = k
				oldestTime = entry.lastUsed
			}
		}
		fmt.Printf("淘汰 LRU 键: %s\n", lruKey)
		delete(c.data, lruKey)
	}

	c.data[key] = &CacheEntry{
		key:      key,
		value:    value,
		lastUsed: time.Now(),
	}
}

func (c *Cache) Get(key string) (string, bool) {
	if entry, exists := c.data[key]; exists {
		// 更新访问时间
		entry.lastUsed = time.Now()
		return entry.value, true
	}
	return "", false
}

func main() {
	cache := NewCache(2)
	cache.Put("user:123", "Alice")
	cache.Put("user:456", "Bob")
	fmt.Println("添加 user:123 和 user:456")

	cache.Get("user:123") // 更新 LRU
	cache.Put("user:789", "Charlie") // 触发淘汰
	fmt.Println("添加 user:789,触发淘汰")

	if val, ok := cache.Get("user:456"); !ok {
		fmt.Println("user:456 已淘汰")
	}
}

实现细节

  • LRU 近似算法:Redis 使用随机采样(默认 5 个键,maxmemory-samples)近似 LRU,降低开销。
  • LFU 实现:通过 objectLFULog 字段记录访问频率,使用对数计数器(Logarithmic Counter)优化内存。
  • 淘汰触发:当 used_memory 超过 maxmemory,调用 performEvictions 执行淘汰。
  • 配置参数
    • maxmemory:设置内存上限。
    • maxmemory-policy:指定淘汰策略。
    • maxmemory-samples:采样大小。

选择淘汰策略的建议

  • 缓存场景:推荐 allkeys-lruallkeys-lfu,优先保留活跃数据。
  • 混合场景:使用 volatile-lruvolatile-ttl,保护非过期键。
  • 性能敏感:选择 volatile-randomallkeys-random,减少计算开销。
  • 严格数据保留:使用 noeviction,搭配监控告警。

生活化例子
冰箱空间有限(maxmemory),你根据策略扔食物:

  • noeviction:不扔,宁愿买新冰箱。
  • volatile-lru:扔最久没吃的过期食物。
  • allkeys-lfu:扔最少吃的食物,保留常吃的。

三、过期删除与内存淘汰的协同工作

  • 分工明确
    • 过期删除:处理设置了 TTL 的键,释放内存。
    • 内存淘汰:处理内存不足时的键删除,适用所有键或部分键。
  • 触发条件
    • 过期删除:键访问(惰性)或定时任务(定时)。
    • 内存淘汰:内存使用量超过 maxmemory
  • 性能优化
    • 过期删除渐进式执行,降低主线程阻塞。
    • 内存淘汰使用采样算法,减少 CPU 开销。

生活化例子
冰箱管理(内存)有两个机制:你用时扔过期食物(惰性删除),每周整理过期品(定时删除)。如果冰箱满了(maxmemory),按策略扔食物(淘汰),如最久没吃的(LRU)或最少吃的(LFU)。

四、实际应用与最佳实践

应用场景

  1. 缓存系统:设置短 TTL(如 1 小时),结合 volatile-lru,清理不活跃缓存。
  2. 会话管理:为会话键设置 TTL(如 30 分钟),用 volatile-ttl 优先删除快过期会话。
  3. 实时分析:使用 allkeys-lfu,保留高频访问的热点数据。
  4. 消息队列:设置 TTL 避免消息堆积,搭配 noeviction 确保消息不丢失。

最佳实践

  1. 合理设置 TTL:为临时数据设置适当过期时间,减少内存占用。
  2. 监控内存:通过 INFO MEMORY 检查 used_memorymaxmemory,设置告警。
  3. 调整采样:增大 maxmemory-samples(如 10)提高 LRU/LFU 准确性,注意 CPU 开销。
  4. 测试策略:在测试环境模拟高内存压力,选择合适的淘汰策略。
  5. 结合集群:在 Redis 集群中,确保各节点 maxmemory 配置一致。

案例
一个电商网站使用 Redis 缓存商品详情,键格式为 product:{ID},TTL 为 1 小时,内存上限 10GB,使用 volatile-lru 策略。访问高峰期,Redis 自动删除不活跃的过期键,保持内存稳定,平均延迟 0.5ms。

五、总结

Redis 的内存管理通过 数据过期删除策略内存淘汰策略 高效平衡性能和内存使用:

  1. 过期删除策略
    • 惰性删除:访问时清理过期键,零开销。
    • 定时删除:定期采样删除,渐进式回收内存。
  2. 内存淘汰策略
    • 8 种策略(noevictionvolatile-lru 等),适配不同场景。
    • 使用 LRU/LFU 近似算法和随机采样,兼顾性能和准确性。

评论 0