缓存的正确使用方式:从设计思想到 Cache Aside 实战解析

引言

为什么缓存几乎是"必选项"?

在真实业务系统中,我们往往面临三个典型矛盾:

  • 数据访问频繁
  • 数据库性能有限
  • 用户对响应时间极其敏感

如果所有读请求都直接打到数据库:

  • 数据库成为瓶颈
  • 延迟高、吞吐低
  • 扩容成本高

缓存的本质目标只有一句话:

用空间换时间,用不那么强的一致性,换取极致的性能

一、缓存是一种"设计思想",而不是 Redis 本身

很多人一提缓存就想到 Redis,但实际上:

  • Redis 只是缓存的实现
  • 缓存是一种系统设计思想

缓存的核心思想包括:

  • 数据分层
  • 冷热数据分离
  • 降低对慢系统的依赖
  • 允许短暂不一致,换取整体性能

这也是为什么缓存几乎一定会带来一致性问题。

二、最经典的缓存模式:Cache Aside(旁路缓存)

1. 什么是 Cache Aside?

Cache Aside,也叫 旁路缓存 ,是目前使用最广泛的缓存模式。

特点是:

  • 应用代码显式控制
  • 缓存与数据库是并列关系
  • 缓存不主动写 DB,DB 也不主动更新缓存

2. 一个最常见的业务场景

以「查询商品信息」为例:

复制代码
getProduct(productId)

三、Cache Aside 的读策略(Read Through 的实现方式)

1. 标准读流程

复制代码
1. 先查缓存
2. 缓存命中 → 直接返回
3. 缓存未命中 → 查数据库
4. 写入缓存
5. 返回结果

2. 示例代码

复制代码
Product getProduct(Long id) {
    Product product = cache.get(id);
    if (product != null) {
        return product;
    }

    product = productMapper.selectById(id);
    if (product != null) {
        cache.set(id, product, 10, TimeUnit.MINUTES);
    }

    return product;
}

这是 99% 系统的真实实现方式。

四、Cache Aside 的写策略:为什么"先更新数据库,再删缓存"?

1. 最直觉的写法(错误示范)

复制代码
1. 更新数据库
2. 更新缓存

2. 这个写法的问题在哪?

看一个并发场景:

时间线示意
复制代码
T1:线程A 更新数据库(新值)
T2:线程B 读取缓存(旧值)
T3:线程A 更新缓存
T4:线程B 把旧值写回缓存(覆盖)

最终结果:

❌ 缓存中是旧数据,数据库是新数据

这就是典型的 缓存不一致问题。

五、正确的 Cache Aside 写策略

1. 标准做法

复制代码
1. 更新数据库
2. 删除缓存

2. 为什么是"删缓存"而不是"更新缓存"?

因为:

  • 删除是幂等的
  • 删除失败可以重试
  • 更新缓存会引入并发覆盖风险

3. 示例代码

复制代码
void updateProduct(Product product) {
    productMapper.update(product);
    cache.delete(product.getId());
}

六、那"先删缓存,再更新数据库"行不行?

1. 看似合理的流程

复制代码
1. 删除缓存
2. 更新数据库

2. 潜在问题:脏读

复制代码
T1:线程A 删除缓存
T2:线程B 读缓存未命中 → 查数据库(旧数据)
T3:线程A 更新数据库
T4:线程B 写缓存(旧数据)

最终:

❌ 缓存再次被写入旧数据

3. 为什么"先更新 DB 再删缓存"概率更低?

因为在现实系统中:

  • 数据库写入通常比缓存慢
  • 删除缓存的时间窗口更短
  • 出现极端并发覆盖的概率更低

所以工程实践中,先更新数据库,再删缓存 是主流选择。

七、Cache Aside 依然存在的问题

1. 写入频繁,缓存命中率下降

在写多读少的场景中:

  • 每次写操作都会删缓存
  • 缓存不断失效
  • 读请求频繁回源数据库

表现为:

  • 缓存"形同虚设"
  • DB 压力上升

2. 典型场景

  • 热点配置频繁修改
  • 用户状态高频变更
  • 实时性要求极高的数据

八、Cache Aside 的优化方案

1. 延迟双删(Double Delete)

复制代码
1. 更新数据库
2. 删除缓存
3. 延迟一段时间
4. 再次删除缓存
java 复制代码
updateDB();
cache.delete(key);
Thread.sleep(500);
cache.delete(key);

目的:

  • 清理并发读写带来的脏缓存

2. 写队列 / Binlog 订阅

  • 通过 MQ / Canal
  • 数据库变更异步通知缓存系统
  • 最终一致性

九、读穿 / 写穿 / 写回 策略简介

1. 读穿(Read Through)

  • 缓存未命中
  • 缓存层负责查 DB
  • 应用层无感知

适合:

  • 缓存组件能力强
  • 应用层简单化

2. 写穿(Write Through)

  • 写请求先到缓存
  • 缓存同步写 DB

特点:

  • 一致性好
  • 写延迟高

3. 写回(Write Back)

  • 写只写缓存
  • 异步刷 DB

适合:

  • 对一致性要求不高
  • 写多读少
  • 日志、统计场景

总结:缓存不是"加了就快"

Cache Aside 的使用法则

  • 读多写少:非常适合

  • 写多读少:慎用,需配合优化

  • 一致性要求极高:考虑不缓存或短 TTL

牢记三句话

缓存不是银弹

一致性是成本

设计永远是权衡

相关推荐
IT大白鼠4 天前
Redis 系列 · 第 01 篇——认知入门:Redis 是什么
redis·nosql
彧azz4 天前
Linux 环境下 Redis 学习总结:数据类型、持久化、锁、事务、主从与缓存问题
linux·redis·笔记·学习·面试
lbb 小魔仙4 天前
OpenClaw + cpolar 实战:远程 NAS、分享小游戏、RDP,再配置公网 AI 入口
数据库·人工智能·redis·oracle·prometheus
夕除4 天前
redis--018
redis
两锭子4 天前
Redis基础
redis
烟沙九洲4 天前
Redis 缓存穿透、缓存击穿、缓存雪崩
数据库·redis
字节探索4 天前
Redis 只会当缓存用?这 10 大实战场景,让你的系统快到飞起
redis·后端
于樱花森上飞舞4 天前
【Redis】哨兵详解
java·开发语言·数据库·redis
Rain的Java大神之路5 天前
如何快速上传10G文件
java·spring boot·redis·后端·mysql·spring cloud·面试
Wang's Blog5 天前
Java 接入Redis: 通用命令与键管理
java·服务器·redis