Redis 学习笔记(二):缓存实战

写在前面

本文是 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 封装。