写在前面
本文是 Redis 学习笔记系列的第二篇,介绍缓存的使用模式、过期和淘汰策略,以及缓存穿透、击穿、雪崩三大问题的解决方案。前置知识:基础与数据类型(第一篇)。
一、缓存模式
1.1 Cache Aside(旁路缓存)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
最常用的缓存模式,应用代码同时管理缓存和数据库。
读取:
1. 先查缓存
2. 缓存命中 → 直接返回
3. 缓存未命中 → 查数据库 → 写入缓存 → 返回
更新:
1. 先更新数据库
2. 再删除缓存(不是更新缓存)
为什么是删除而不是更新缓存?
- 更新缓存在并发场景下可能导致数据不一致
- 删除更简单,下次读取时自然会重建
|
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
|
public class ProductService
{
private readonly IDatabase _redis;
private readonly IProductRepository _repo;
public ProductService(IConnectionMultiplexer redis, IProductRepository repo)
{
_redis = redis.GetDatabase();
_repo = repo;
}
// 读取:先缓存后数据库
public async Task<Product?> GetProductAsync(int id)
{
var cacheKey = $"cache:product:{id}";
// 1. 查缓存
var cached = await _redis.StringGetAsync(cacheKey);
if (cached.HasValue)
{
return JsonSerializer.Deserialize<Product>(cached!);
}
// 2. 查数据库
var product = await _repo.GetByIdAsync(id);
if (product == null) return null;
// 3. 写入缓存(过期时间 30 分钟)
var json = JsonSerializer.Serialize(product);
await _redis.StringSetAsync(cacheKey, json, TimeSpan.FromMinutes(30));
return product;
}
// 更新:先数据库后删缓存
public async Task UpdateProductAsync(Product product)
{
// 1. 更新数据库
await _repo.UpdateAsync(product);
// 2. 删除缓存
await _redis.KeyDeleteAsync($"cache:product:{product.Id}");
}
}
|
1.2 Read/Write Through
1
2
3
4
5
6
7
8
9
|
缓存代理层负责读写数据库,应用只和缓存交互。
读取:
应用 → 缓存 → (未命中)缓存层自动查数据库并回填
写入:
应用 → 缓存 → 缓存层同步更新数据库
特点:应用代码更简单,但需要缓存中间件支持
|
1.3 Write Behind(异步写回)
1
2
3
4
5
|
写入时只更新缓存,异步批量写入数据库。
优点:写入性能极高
缺点:数据可能丢失(宕机时未落盘的数据丢失)
适用:写密集、允许少量丢失的场景
|
二、过期策略
2.1 设置过期时间
1
2
3
4
5
6
7
8
9
10
11
12
13
|
# 设置时指定过期
SET cache:key "data" EX 1800 # 秒
SETEX cache:key 1800 "data" # 等价
# 对已存在的 key 设置过期
EXPIRE cache:key 1800 # 秒
PEXPIRE cache:key 1800000 # 毫秒
# 指定过期时间点
EXPIREAT cache:key 1740000000 # Unix 时间戳
# 移除过期时间
PERSIST cache:key
|
1
2
3
|
// StackExchange.Redis
await db.StringSetAsync("cache:key", "data", TimeSpan.FromMinutes(30));
await db.KeyExpireAsync("cache:key", TimeSpan.FromMinutes(30));
|
2.2 过期删除机制
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
Redis 采用两种策略结合:
1. 惰性删除(Lazy Expiration)
- 访问 key 时检查是否过期
- 过期了才删除
- 优点:CPU 友好
- 缺点:过期了但没人访问的 key 会占用内存
2. 定期删除(Periodic Expiration)
- 每秒执行 10 次(默认)
- 随机检查一批设置了过期的 key
- 删除已过期的
- 如果过期比例超过 25%,继续检查
- 控制在 25ms 以内(避免阻塞)
两者结合:
定期删除保证内存不会被大量过期 key 占满
惰性删除保证访问时一定能拿到正确结果
|
三、淘汰策略
当内存使用达到 maxmemory 限制时,Redis 如何选择删除哪些 key。
3.1 配置
1
2
3
|
# redis.conf 或运行时设置
CONFIG SET maxmemory 1gb
CONFIG SET maxmemory-policy allkeys-lru
|
3.2 8 种淘汰策略
1
2
3
4
5
6
7
8
9
10
11
12
13
|
不淘汰(默认):
noeviction — 内存满了直接报错,不删除任何 key
只淘汰设置了过期的 key:
volatile-lru — 淘汰设置了过期的、最久未使用的 key
volatile-lfu — 淘汰设置了过期的、使用频率最低的 key(Redis 4.0+)
volatile-ttl — 淘汰设置了过期的、TTL 最短的 key
volatile-random — 随机淘汰设置了过期的 key
淘汰所有 key:
allkeys-lru — 淘汰最久未使用的 key(缓存场景最常用)
allkeys-lfu — 淘汰使用频率最低的 key(Redis 4.0+)
allkeys-random — 随机淘汰
|
3.3 策略选择
1
2
3
4
5
6
7
8
9
|
缓存场景(Redis 主要做缓存):
allkeys-lru — 推荐,淘汰最不常用的数据
allkeys-lfu — 更精确,但要更多内存记录频率
缓存 + 持久化场景:
volatile-lru — 只淘汰有过期时间的,持久化的数据不受影响
不确定:
allkeys-lru — 最稳妥的选择
|
四、缓存穿透
4.1 问题描述
1
2
3
4
5
6
7
|
查询一个数据库中不存在的数据,缓存中也没有。
流程:
查缓存 → 没有 → 查数据库 → 也没有 → 返回 null → 不写缓存
问题:每次请求都会打到数据库。
如果有恶意攻击,大量请求查询不存在的数据,数据库可能被打挂。
|
4.2 解决方案
方案一:缓存空值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
public async Task<Product?> GetProductAsync(int id)
{
var cacheKey = $"cache:product:{id}";
var cached = await _redis.StringGetAsync(cacheKey);
if (cached.HasValue)
{
if (cached == "NULL") return null; // 命中空值缓存
return JsonSerializer.Deserialize<Product>(cached!);
}
var product = await _repo.GetByIdAsync(id);
if (product == null)
{
// 缓存空值,短过期时间(防止占用太多内存)
await _redis.StringSetAsync(cacheKey, "NULL", TimeSpan.FromMinutes(5));
return null;
}
await _redis.StringSetAsync(cacheKey, JsonSerializer.Serialize(product),
TimeSpan.FromMinutes(30));
return product;
}
|
1
2
3
4
5
|
优点:简单直接
缺点:
- 占用额外内存
- 过期时间短,频繁查数据库
- 攻击者换不同的 key 就失效了
|
方案二:布隆过滤器
1
2
3
4
5
6
7
8
9
10
11
|
在缓存前面加一层布隆过滤器:
- 所有可能存在的数据哈希到布隆过滤器中
- 请求先过布隆过滤器
- 布隆过滤器说不存在 → 一定不存在,直接返回
- 布隆过滤器说存在 → 可能有,再去查缓存和数据库
优点:内存占用极小
缺点:
- 需要预先加载数据
- 有误判率(说存在但实际不存在)
- 删除困难
|
方案三:参数校验
1
2
3
4
5
6
|
最简单的方式:在入口处校验请求参数。
- ID 不合法直接返回
- 不存在的枚举值直接返回
- 不合理的范围直接返回
这是第一道防线,必须做。
|
五、缓存击穿
5.1 问题描述
1
2
3
4
5
6
7
|
一个热点 key 过期的瞬间,大量并发请求同时到达。
流程:
key 存在时 → 所有请求命中缓存 → 正常
key 过期瞬间 → 大量请求同时查数据库 → 数据库压力暴增
典型场景:热点新闻、秒杀商品
|
5.2 解决方案
方案一:互斥锁
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
|
public async Task<Product?> GetProductWithLockAsync(int id)
{
var cacheKey = $"cache:product:{id}";
var lockKey = $"lock:cache:product:{id}";
// 1. 查缓存
var cached = await _redis.StringGetAsync(cacheKey);
if (cached.HasValue)
{
if (cached == "NULL") return null;
return JsonSerializer.Deserialize<Product>(cached!);
}
// 2. 获取分布式锁(只有一个请求能查数据库)
var lockAcquired = await _redis.StringSetAsync(
lockKey, "1", TimeSpan.FromSeconds(10),
When.NotExists); // NX
if (lockAcquired)
{
try
{
// 获得锁,查数据库
var product = await _repo.GetByIdAsync(id);
if (product == null)
{
await _redis.StringSetAsync(cacheKey, "NULL", TimeSpan.FromMinutes(5));
return null;
}
await _redis.StringSetAsync(cacheKey,
JsonSerializer.Serialize(product),
TimeSpan.FromMinutes(30));
return product;
}
finally
{
await _redis.KeyDeleteAsync(lockKey);
}
}
else
{
// 没获得锁,等一下再重试
await Task.Delay(100);
return await GetProductWithLockAsync(id); // 递归重试
}
}
|
方案二:逻辑过期(不设置真正过期时间)
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
|
// 缓存对象中包含逻辑过期时间
public class CacheData<T>
{
public T? Data { get; set; }
public DateTime ExpireTime { get; set; }
}
public async Task<Product?> GetProductWithLogicalExpireAsync(int id)
{
var cacheKey = $"cache:product:{id}";
var cached = await _redis.StringGetAsync(cacheKey);
if (!cached.HasValue)
{
// 缓存中没有,查数据库并缓存(不过期)
return await LoadToCacheAsync(id);
}
var cacheData = JsonSerializer.Deserialize<CacheData<Product>>(cached!);
// 判断逻辑过期
if (cacheData!.ExpireTime > DateTime.UtcNow)
{
return cacheData.Data; // 未过期,直接返回
}
// 逻辑过期,异步更新缓存(不阻塞当前请求)
_ = Task.Run(() => LoadToCacheAsync(id));
// 返回旧数据(用户能接受短暂的旧数据)
return cacheData.Data;
}
private async Task<Product?> LoadToCacheAsync(int id)
{
var product = await _repo.GetByIdAsync(id);
var cacheData = new CacheData<Product>
{
Data = product,
ExpireTime = DateTime.UtcNow.AddMinutes(30)
};
await _redis.StringSetAsync(
$"cache:product:{id}",
JsonSerializer.Serialize(cacheData));
return product;
}
|
1
2
3
4
5
6
7
|
互斥锁 vs 逻辑过期:
互斥锁:保证数据一致,但并发高时部分请求等待
逻辑过期:不阻塞请求,但可能返回旧数据
选择:
数据一致性要求高 → 互斥锁
高性能优先、允许短暂旧数据 → 逻辑过期
|
方案三:热点 key 永不过期
1
2
|
对确定的热点 key 不设置过期时间,由后台任务定期更新。
简单但有风险:后台任务挂了数据就不更新了。
|
六、缓存雪崩
6.1 问题描述
1
2
3
4
5
6
7
8
|
大量缓存 key 在同一时刻过期,或者 Redis 宕机。
场景1:大量 key 设置了相同的过期时间(如 30 分钟)
→ 30 分钟后这些 key 同时过期
→ 请求全部打到数据库
场景2:Redis 节点宕机
→ 所有请求打到数据库
|
6.2 解决方案
方案一:过期时间加随机值
1
2
3
4
5
|
// 过期时间 = 基础时间 + 随机时间
var baseExpiry = TimeSpan.FromMinutes(30);
var randomExpiry = TimeSpan.FromSeconds(Random.Shared.Next(1, 300));
await _redis.StringSetAsync(cacheKey, json, baseExpiry + randomExpiry);
// 30 分钟 ~ 35 分钟内随机过期,避免同时失效
|
方案二:多级缓存
1
2
3
4
5
6
7
8
9
10
|
L1 缓存(本地内存)→ L2 缓存(Redis)→ 数据库
请求流程:
本地缓存命中 → 返回
本地缓存未命中 → Redis 缓存命中 → 写入本地缓存 → 返回
Redis 也未命中 → 数据库 → 写入 Redis + 本地缓存 → 返回
工具:
.NET MemoryCache 做本地缓存
缓存一致性由过期时间控制
|
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
|
public class MultiLevelCacheService
{
private readonly IMemoryCache _localCache;
private readonly IDatabase _redis;
public MultiLevelCacheService(IMemoryCache localCache, IConnectionMultiplexer redis)
{
_localCache = localCache;
_redis = redis.GetDatabase();
}
public async Task<T?> GetAsync<T>(string key) where T : class
{
// L1: 本地缓存
if (_localCache.TryGetValue(key, out T? localValue))
{
return localValue;
}
// L2: Redis
var redisValue = await _redis.StringGetAsync(key);
if (redisValue.HasValue)
{
var result = JsonSerializer.Deserialize<T>(redisValue!);
// 回填本地缓存(短过期)
_localCache.Set(key, result, TimeSpan.FromMinutes(1));
return result;
}
return null;
}
}
|
方案三:高可用部署
1
2
3
4
|
Redis 宕机导致雪崩的预防:
- 主从复制 + 哨兵(自动故障转移)
- Redis Cluster(分片 + 高可用)
- 限流降级(数据库前加限流,防止被打挂)
|
七、缓存最佳实践
7.1 过期时间设置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
短过期(1-5 分钟):
- 验证码
- Session
- 短时效的查询结果
中过期(30-60 分钟):
- 商品信息
- 用户资料
- 配置信息
长过期(1-24 小时):
- 不常变化的数据
- 字典数据
- 静态内容
永远不过期:
- 热点数据(由应用主动更新)
|
7.2 缓存粒度
1
2
3
4
|
粒度太粗:缓存整个页面 → 更新频繁,命中率低
粒度太细:缓存每个字段 → 管理复杂,请求次数多
推荐:按业务实体缓存(一个商品、一个用户)
|
7.3 缓存预热
1
2
3
4
5
6
|
系统启动时,主动加载热点数据到缓存:
- 启动时加载排行榜 TOP 100
- 启动时加载热门商品
- 启动时加载系统配置
避免:系统刚启动时大量请求直接打到数据库
|
八、三大问题对比
1
2
3
4
5
|
问题 原因 解决方案
──────── ────────────────── ──────────────
穿透 查询不存在的数据 缓存空值、布隆过滤器、参数校验
击穿 热点 key 过期瞬间 互斥锁、逻辑过期、永不过期
雪崩 大量 key 同时过期 过期时间加随机、多级缓存、高可用
|
九、小结
本文学习了缓存实战:
- 缓存模式(Cache Aside、Read/Write Through)
- 过期策略(惰性删除 + 定期删除)
- 淘汰策略(LRU、LFU、TTL 等 8 种)
- 缓存穿透及解决方案(缓存空值、布隆过滤器)
- 缓存击穿及解决方案(互斥锁、逻辑过期)
- 缓存雪崩及解决方案(随机过期、多级缓存)
- 缓存最佳实践
下一篇将学习分布式锁:实现原理、锁续期和 .NET 封装。