CC 咖啡猫的工作空间 Coding Space

缓存深入原理

缓存是性能优化的核心手段。核心原理:以空间换时间,把频繁访问的数据放在快速存储中,减少对慢速存储(数据库)的访问。


1. 缓存 vs 数据库

对比项 缓存(Redis/Memory) 数据库(MySQL)
响应时间 纳秒~微秒级 毫秒级
容量 GB 级(内存) TB 级(磁盘)
持久化 可选(Redis AOF/RDB) 默认持久化
数据结构 KV、List、Set、Hash 等 关系型、行列存储
适用场景 热数据、高频访问 全量数据、事务、关联查询

经典读取模式

查询请求
    ↓
先查缓存(Redis)
    ↓
命中(Cache Hit)→ 直接返回 ✅
    ↓
未命中(Cache Miss)→ 查数据库 → 写入缓存 → 返回

2. 缓存读写策略

2.1 Cache Aside(旁路缓存):最常用

读:
1. 读缓存,命中直接返回
2. 未命中,读数据库
3. 写回缓存

写:
1. 写数据库
2. 删除缓存(而不是更新缓存)

为什么要删除而不是更新?

// 如果用更新缓存:
updateDb();        // 更新数据库
updateCache();    // 更新缓存

// 问题:并发下可能脏写
线程A:updateDb(user=100)      → user=100
线程A:updateCache(user=100)    → 缓存=100
线程B:updateDb(user=200)      → user=200
线程B:updateCache(user=200)   → 缓存=200

// 看起来没问题,但实际执行顺序可能是:
线程A:updateDb(user=100)
线程B:updateDb(user=200)       → 数据库=200
线程A:updateCache(user=100)    → 缓存=100 ❌(数据库=200)

删除缓存不会出现这个问题,因为下次读会从数据库加载最新值。

2.2 Read Through(读穿透)

缓存作为代理,调用方不感知数据库:

调用方 → 缓存 → 缓存未命中 → 自动查数据库 → 写入缓存 → 返回

调用方只跟缓存打交道,不关心数据从哪来。

2.3 Write Through(写穿透)

写操作同时写缓存和数据库:

写请求 → 写数据库 → 写缓存 → 返回

强一致,但写入性能差,实际很少用。

2.4 Write Behind(异步写回)

写入时只写缓存,异步批量写回数据库:

写请求 → 写缓存 → 返回 ✅(异步写回数据库)

性能最高,但风险最大:缓存数据还没写回数据库时宕机,会丢数据。


3. 缓存三大经典问题

3.1 缓存击穿(Cache Break)

场景:某个热点 key 过期,大并发同时穿透到数据库

热门商品 Key 过期瞬间
    ↓
10000 并发请求同时发现缓存未命中
    ↓
全部穿透到数据库
    ↓
数据库瞬间被打爆 ❌

解决方案:互斥锁 / SETNX

String getProduct(String productId) {
    String cacheKey = "product:" + productId;
    String cached = redis.get(cacheKey);
    if (cached != null) return cached;

    // SETNX 加互斥锁
    String lockKey = "lock:product:" + productId;
    if (redis.setNX(lockKey, "1", 10)) {  // 10秒锁过期
        try {
            String product = db.getProduct(productId);
            redis.setex(cacheKey, 3600, product);
            return product;
        } finally {
            redis.del(lockKey);
        }
    } else {
        // 拿不到锁,短暂等待后重试
        Thread.sleep(50);
        return getProduct(productId);
    }
}

解决方案:热点数据永不过期

// 异步更新:缓存不过期,但定期刷新
String getProduct(String productId) {
    String cacheKey = "product:" + productId;
    String cached = redis.get(cacheKey);
    if (cached != null) return cached;

    String product = db.getProduct(productId);
    // 用旧值再设置一次,延长TTL
    redis.setex(cacheKey, 3600, product);

    // 异步更新缓存(后台线程)
    schedule(() -> {
        String fresh = db.getProduct(productId);
        redis.setex(cacheKey, 3600, fresh);
    });
    return product;
}

3.2 缓存穿透(Cache Miss Attack)

场景:查询数据库根本不存在的数据,缓存无法命中,每次都查数据库

攻击者大量请求:productId = -1 或不存在的 ID
    ↓
缓存查不到
    ↓
数据库也查不到(正常返回空)
    ↓
缓存永不被写入
    ↓
每次请求都打数据库 ❌

解决方案1:布隆过滤器(Bloom Filter)

Redis 中存一个 Bloom Filter
    ↓
查询前先问 Bloom Filter:这个 key 可能存在吗?
    ↓
Bloom Filter 说"不存在" → 直接返回空 ✅(不查数据库)
Bloom Filter 说"可能存在" → 查 Redis → 未命中 → 查 DB

布隆过滤器原理:多个哈希函数映射到位数组,存在可能(可能有 false positive),不存在则一定不存在。

解决方案2:空值缓存

String getProduct(String productId) {
    String cacheKey = "product:" + productId;
    String cached = redis.get(cacheKey);
    if (cached != null) {
        if (cached.equals("NULL")) return null;  // 空值缓存
        return cached;
    }

    Product product = db.getProduct(productId);
    if (product == null) {
        redis.setex(cacheKey, 60, "NULL");  // 空值缓存60秒
    } else {
        redis.setex(cacheKey, 3600, product);
    }
    return product;
}

注意:空值缓存的 TTL 不能太长,否则真实数据写入后还要等 TTL 过期。

3.3 缓存雪崩(Cache Avalanche)

场景:大量 key 同时过期,或者 Redis 宕机

大量 key 同时过期:午夜 0 点整
    ↓
大量请求穿透到数据库
    ↓
数据库被打爆 ❌
Redis 宕机
    ↓
所有请求直接打数据库
    ↓
应用全部报错 ❌

解决方案:过期时间加随机值

// 不要统一设置 TTL = 3600
// 随机偏移量
int ttl = 3600 + random.nextInt(600);  // 3600~4200 秒
redis.setex(cacheKey, ttl, value);

解决方案:Redis 高可用

Redis Sentinel(哨兵)→ 主从自动切换
Redis Cluster → 数据分片 + 高可用

解决方案:本地缓存兜底

Redis 崩了 → 读取本地缓存(Guava Cache / Caffeine)
         → 服务仍可用(数据可能稍旧)

4. 多级缓存架构

4.1 二级缓存:本地 + 分布式缓存

请求 → Nginx(Lua 缓存)→ 应用内存(Local Cache)→ Redis → MySQL
         L1                                  L2
级别 响应 容量 共享 适用场景
L1 本地缓存 纳秒 MB 否(进程隔离) 极高频、不变的数据
L2 分布式缓存 微秒 GB 跨进程共享
数据库 毫秒 TB 最终数据源

4.2 Guava Cache / Caffeine 本地缓存

LoadingCache<String, Product> cache = Caffeine.newBuilder()
    .maximumSize(1000)                    // 最大条目数
    .expireAfterWrite(10, TimeUnit.MINUTES) // 写入后10分钟过期
    .build(key -> db.getProduct(key));    // 未命中时加载

// 使用
Product product = cache.get(productId);

Guava Cache vs Caffeine:Caffeine 是 Guava Cache 的升级版,性能更好,支持异步加载。


5. 缓存与数据库的一致性

5.1 为什么很难保证强一致

时间线:
T1: 线程A 更新数据库(user=200)
T2: 线程B 查询缓存(返回旧值100)
T3: 线程A 删除缓存
T4: 线程B 查询数据库(user=200)→ 写入缓存(user=200)
结果:缓存=200 ✅(最终一致)

但在 T2~T3 期间,线程 B 返回了脏数据。

5.2 最终一致性方案

延迟双删(业界最常用):

public void updateUser(User user) {
    // 第一步:删缓存
    redis.del("user:" + user.getId());

    // 第二步:更新数据库
    db.updateUser(user);

    // 第三步:延迟再删(等读请求把旧数据写回缓存)
    Thread.sleep(100);
    redis.del("user:" + user.getId());
}

订阅 Binlog 方案(更解耦):

数据库变更 → Binlog 日志
                ↓
        Canal 订阅 Binlog
                ↓
        解析变更内容 → 更新 Redis 缓存

阿里中间件团队用 Canal 监听 MySQL Binlog,解析后更新 Redis,业务代码完全不感知。


6. Redis 持久化与恢复

6.1 RDB(快照)

每隔 N 分钟/小时,fork 进程dump内存到磁盘
    ↓
生成 rdb 文件(压缩二进制)
    ↓
恢复快,但可能丢失最后一次快照之后的数据

优点:恢复快(直接加载 RDB 文件) 缺点:可能丢失最后一次快照后的数据

6.2 AOF(Append Only File)

每次写操作 → 追加到 AOF 文件
    ↓
Redis 重启时重放 AOF 命令恢复数据
    ↓
三种刷盘策略:
  appendfsync=always    每次写都刷盘(最安全,性能差)
  appendfsync=everysec 每秒刷盘(默认,推荐)
  appendfsync=no        由系统决定(最快,可能丢一秒数据)

优点:数据安全(everysec 只丢一秒数据) 缺点:AOF 文件比 RDB 大,恢复慢

6.3 混合持久化(4.0+)

RDB + AOF 混合模式
    ↓
重写时生成 RDB 格式的 base + AOF 的增量
    ↓
恢复时先加载 RDB 再重放 AOF
    ↓
兼顾性能和数据安全

7. 缓存监控与优化

7.1 关键监控指标

指标 说明 健康值
内存使用率 info memory < 70%
缓存命中率 info stats / keyspace_hits > 80%
慢查询 slowlog get 10 < 1ms
连接数 info clients < maxclients
AOF 文件大小 ls -lh appendonly.aof 监控增长率

7.2 缓存大 Key 问题

# 找出大 Key(超过 10MB)
redis-cli --bigkeys

# 扫描大 Key
redis-cli --scan --pattern * | xargs -I{} redis-cli --bigkeys {}

大 Key(String 类型超过 10MB,Set 超过 5 万元素)会导致:

  1. 读取慢(网络传输大)
  2. 过期删除时阻塞(Redis 单线程)

解决方案:拆分大 Key(String 改为 Hash,按字段拆分)


8. 缓存策略选择总结

数据特点 推荐策略
数据频繁变化 不用缓存
热数据、读多写少 Cache Aside
写多读少 Write Through
需要强一致 不用缓存,或延迟双删
极高一致性要求 数据库是唯一真相源
允许短暂不一致 缓存 + TTL