0%

Redis:三种常用的缓存读写策略

  • Cache Aside Pattern(旁路缓存模式)
  • Read/Write Through Pattern(读写穿透)
  • Write Behind Pattern(异步缓存写入)

Intro

Redis简介

Redis (REmote DIctionary Server)是一个基于 C 语言开发的开源 NoSQL 数据库(BSD 许可)。与传统数据库不同的是,Redis 的数据是保存在内存中的(内存数据库,支持持久化),因此读写速度非常快,被广泛应用于分布式缓存方向。并且,Redis 存储的是 KV 键值对数据。

为了满足不同的业务场景,Redis 内置了多种数据类型实现(比如 String、Hash、Sorted Set、Bitmap、HyperLogLog、GEO)。并且,Redis 还支持事务、持久化、Lua 脚本、发布订阅模型、多种开箱即用的集群方案(Redis Sentinel、Redis Cluster)。

Redis 内部做了非常多的性能优化,使得其读写速度极快,主要包括以下四点:

  • 纯内存操作 (Memory-Based Storage) :这是最主要的原因。Redis 数据读写操作都发生在内存中,访问速度是纳秒级别,而传统数据库频繁读写磁盘的速度是毫秒级别,两者相差数个数量级。
  • 高效的 I/O 模型 (I/O Multiplexing & Single-Threaded Event Loop) :Redis 使用单线程事件循环配合 I/O 多路复用技术,让单个线程可以同时处理多个网络连接上的 I/O 事件(如读写),避免了多线程模型中的上下文切换和锁竞争问题。虽然是单线程,但结合内存操作的高效性和 I/O 多路复用,使得 Redis 能轻松处理大量并发请求(Redis 线程模型会在后文中详细介绍到)。
  • 优化的内部数据结构 (Optimized Data Structures) :Redis 提供多种数据类型(如 String, List, Hash, Set, Sorted Set 等),其内部实现采用高度优化的编码方式(如 ziplist, quicklist, skiplist, hashtable 等)。Redis 会根据数据大小和类型动态选择最合适的内部编码,以在性能和空间效率之间取得最佳平衡。
  • 简洁高效的通信协议 (Simple Protocol - RESP) :Redis 使用的是自己设计的 RESP (REdis Serialization Protocol) 协议。这个协议实现简单、解析性能好,并且是二进制安全的。客户端和服务端之间通信的序列化/反序列化开销很小,有助于提升整体的交互速度。

除了访问速度更快之外,Redis 缓存的并发也更高。一般像 MySQL 这类的数据库的 QPS 大概都在 4k 左右(4 核 8g),但是使用 Redis 缓存之后很容易达到 5w+,甚至能达到 10w+。由此可见,直接操作缓存能够承受的数据库请求数量是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。

3种常用的缓存读写策略

尽管 Redis 速度极快,但它无法取代数据库,在生产环境中,单存储架构(只用数据库或只用 Redis)通常无法兼顾性能与容量,我们通常只将一部分频繁读写的数据放置于redis。但当我们同时拥有内存和磁盘两份数据拷贝时,如何保证它们的一致性成为了重要的问题,因此,需要引入三种常用的缓存读写策略:

  • Cache Aside Pattern(旁路缓存模式)

  • Read/Write Through Pattern(读写穿透)

  • Write Behind Pattern(异步缓存写入)

Cache Aside Pattern(旁路缓存模式)

这是我们日常开发中最常用、最经典的一种模式,几乎是互联网应用缓存方案的事实标准,尤其适合读多写少的业务场景。

这个模式之所以被称为旁路 ,是因为应用程序的写操作完全绕过了缓存,直接操作数据库。在这个模式下,应用程序需要同时维护 Cache 和 DB 两个数据源。

读操作的顺序为:

  1. 应用先从 Cache 读取数据
  2. 如果命中(Hit),则直接返回
  3. 如果未命中(Miss),则从 DB 读取数据,成功读取后,将数据写回 Cache,然后返回

写操作的顺序为:

  1. 应用先更新 DB
  2. 然后直接删除 Cache中对应的数据

我们基于SpringBoot,举一个简单的例子

首先是读操作:

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
@Service
public class UserService {

@Autowired
private UserRepository userRepository;
@Autowired
private RedisTemplate<String, User> redisTemplate;

public User getUserById(Long id) {
String key = "user:" + id;

// 1. 尝试从缓存获取
User user = redisTemplate.opsForValue().get(key);
if (user != null) {
return user; // 缓存命中 (Hit)
}

// 2. 缓存未命中 (Miss),查数据库
user = userRepository.findById(id).orElse(null);

// 3. 将结果写回缓存,并设置过期时间
if (user != null) {
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
}

return user;
}
}

其次是写操作:

1
2
3
4
5
6
7
8
9
10
public void updateUser(User user) {
String key = "user:" + user.getId();

// 1. 先更新数据库
userRepository.save(user);

// 2. 更新成功后,直接删除缓存
// 强制下一次读请求触发 Cache Miss,从而从 DB 加载最新值
redisTemplate.delete(key);
}

我们注意到,在写操作时,是“删除”而不是“更新”缓存。如果不慎写成 redisTemplate.opsForValue().set(key, user) 去更新缓存,在并发环境下会发生以下情况:

  • 线程 A 更新了数据库。
  • 线程 B 更新了数据库。
  • 由于网络原因,线程 B 先把缓存更新了。
  • 线程 A 随后才更新了缓存。

结果就是,数据库里是 B 的值,缓存里是 A 的旧值,导致了数据不一致。而删除操作是幂等的,它消除了更新顺序带来的竞争条件,相对安全的多。

同时,更新数据库和删除缓存的顺序也有说法,如果违背了先后原则,先删除了缓存,再更新数据库,在并发环境下会发生以下情况:

  • 线程 A: 先将 Cache 中的数据删除。
  • 线程 B: 此时发现 Cache 为空,于是去 DB 读取旧值,并准备写入 Cache。
  • 线程 A : 将新值写入 DB。
  • 线程 B: 将之前读到的旧值写入了 Cache。
    结果就是,DB 中是新值,而 Cache 中是旧值,也会导致数据不一致。

Cache Aside 虽然解决了同步逻辑,但如果遇到一个极热点的 Key 在失效瞬间,成千上万个请求同时发现 Miss,全部打到数据库,就会发生缓存击穿。为了防止缓存击穿发生,我们可以给请求逻辑加锁,将并行的请求串行化,等待第一个请求数据的线程拿到数据后,其会将数据写入Redis,那么后续的请求可以从Cache中而非DB取数据了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public User getUser(Long id) {
User user = redis.get(id);
if (user != null) return user;

// 获取分布式锁
RLock lock = redisson.getLock("lock:" + id);
try {
if (lock.tryLock(10, TimeUnit.SECONDS)) {
user = redis.get(id);
if (user != null) return user;

user = db.get(id);
redis.set(id, user);
}
} finally {
lock.unlock();
}
return user;
}

在 Spring Boot 中,官方提供了@Cacheable 注解中的 sync 属性以实现上述功能。默认情况下,@Cacheable 是不加锁的。当多个线程同时访问同一个失效的 Key 时,它们都会执行业务方法(查库)。但当你设置 sync = true 时,对于同一个 Key,多个并发请求进入时,Spring 会确保只有一个线程去执行目标方法(查库)

1
2
3
4
5
6
7
8
9
10
11
@Service
public class UserService {

// sync = true 开启同步锁,防止缓存击穿
@Cacheable(value = "userCache", key = "#id", sync = true)
public User getUserById(Long id) {
// 只有第一个线程会进入这里,其余线程会等待并直接从缓存取值
System.out.println("正在从数据库加载用户数据... ID: " + id);
return userRepository.findById(id).orElse(null);
}
}

Read/Write Through Pattern(读写穿透)

Cache Aside 是由应用程序同时管理缓存和数据库,Read/Write Through 模式则是通过一个中间层(通常是缓存服务或 Data Provider)将数据库操作隐藏起来。

在这个模式下,应用程序将缓存视为唯一的数据源。它不关心底层数据是如何存储的,所有的读写请求都直接发往缓存,由缓存层负责与数据库进行同步。

读操作(Read Through)的顺序为:

  1. 应用查询 Cache
  2. 如果命中(Hit),直接返回数据
  3. 如果未命中(Miss),由 Cache 组件负责从 DB 加载数据,写入 Cache 后返回给应用

写操作(Write Through)的顺序为:

  1. 应用向 Cache 写入数据
  2. Cache 组件同步地将数据写入 DB
  3. 待 DB 更新成功后,写操作才算完成,返回成功

在 Java 生态中,原生的 Redis 并不直接支持与 MySQL 的这种穿透同步。通常我们需要在业务层抽象出一个 Cache Store 层。以下是一个基于 Spring Boot 的概念性实现:

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
@Service
public class UserCacheManager {

@Autowired
private UserRepository userRepository;
@Autowired
private RedisTemplate<String, User> redisTemplate;

/**
* Read Through
*/
public User get(Long id) {
String key = "user:" + id;
User user = redisTemplate.opsForValue().get(key);

if (user == null) {
// 由缓存管理器组件负责从数据库加载,业务层不感知
user = userRepository.findById(id).orElse(null);
if (user != null) {
redisTemplate.opsForValue().set(key, user);
}
}
return user;
}

/**
* Write Through
*/
public void put(User user) {
String key = "user:" + user.getId();
// 1. 写缓存
redisTemplate.opsForValue().set(key, user);
// 2. 同步写数据库,保证强一致性
userRepository.save(user);
}
}

Read/Write Through 的核心价值在于关注点分离(Separation of Concerns)。业务逻辑变得极度纯粹,因为它不再需要处理繁琐的缓存失效和补偿逻辑。

  1. 强一致性保障:由于写操作是同步进行的(写缓存 + 写数据库全部成功才返回),这种模式能最大程度保证缓存和数据库的数据一致。

  2. 写延迟(Latency)问题:这是该模式的最大痛点。写操作的吞吐量受限于数据库的磁盘 I/O。每一次写请求都要经历两次网络交互和一次磁盘写入,遵循木桶效应,系统的整体性能取决于最慢的一环(DB)。

  3. 数据预热(Warming Up):由于读操作在 Miss 时会自动加载,这种模式天生具备热点数据预热的能力。

在实际工程中,本地缓存(如 Guava Cache 或 Caffeine)通常提供现成的 CacheLoader 机制来完美支持 Read Through。但在 Redis 场景下,由于 Redis 无法主动去抓取 MySQL 的数据,这种模式往往需要开发者自行封装中间服务来实现。

Write Behind Pattern(异步缓存写入)

Write Behind(也常被称为 Write-Back) Pattern 和 Read/Write Through Pattern 很相似,两者都是由 Cache 服务来负责 Cache 和 DB 的读写。

但是,两个又有很大的不同:Read/Write Through 是同步更新 Cache 和 DB,而 Write Behind 则是只更新缓存,不直接更新 DB,而是改为异步批量的方式来更新 DB。

读操作的顺序为:

  1. 同 Read Through
  2. 如果命中则返回;如果未命中,则由缓存层同步从 DB 加载数据,写回缓存并返回

写操作的顺序为:

  1. 应用向 Cache 写入数据
  2. Cache 立即返回写成功信号
  3. 缓存层异步地(批量或定时)将变更的数据刷入 DB

基于 Spring Boot,我们通常利用 消息队列 (MQ)定时任务 来模拟这种异步合并写入的逻辑:

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
@Service
public class WriteBehindService {

@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private UserRepository userRepository;

// 写操作:只写缓存,然后扔进异步任务队列
public void updateUser(User user) {
String key = "user:" + user.getId();
// 1. 更新缓存
redisTemplate.opsForValue().set(key, user.toString());

// 2. 将需要更新的 ID 放入一个待刷新队列(可以是 Redis 的 List 或 Set)
redisTemplate.opsForSet().add("dirty_user_ids", user.getId().toString());

// 3. 立即返回成功
}

// 后台异步任务:批量刷入数据库
@Scheduled(fixedDelay = 5000) // 每 5 秒批量同步一次
public void flushToDb() {
Set<String> dirtyIds = redisTemplate.opsForSet().members("dirty_user_ids");
if (dirtyIds == null || dirtyIds.isEmpty()) return;

for (String id : dirtyIds) {
// 从缓存取最新值,写入数据库
User user = getUserFromCache(Long.parseLong(id));
userRepository.save(user);
// 同步成功后移除脏标记
redisTemplate.opsForSet().remove("dirty_user_ids", id);
}
}
}

写操作合并(Write Coalescing) 是该模式最强大的地方。如果在 5 秒内某个用户被更新了 10 次,Write Behind 模式只会将最后一次的结果刷入数据库,大大减轻了可能存在的负担。

但是,如果 Redis 在异步刷盘前宕机,内存中尚未同步到 DB 的数据会永久丢失。因此,这种模式绝不能用于涉及钱财、订单等核心金融场景。

缓存雪崩,击穿与穿透

我们在旁路缓存模式中了解到,如果遇到一个极热点的 Key 在失效瞬间,成千上万个请求同时发现 Miss,全部打到数据库,就会发生缓存击穿。事实上,只要引入了缓存层,就会面临缓存异常,且不只是缓存击穿,还包括缓存雪崩、缓存穿透

缓存雪崩

通常我们为了保证缓存中的数据与数据库中的数据一致性,会给 Redis 里的数据设置过期时间,当缓存数据过期后,用户访问的数据如果不在缓存里,业务系统需要重新生成缓存,因此就会访问数据库,并将数据更新到 Redis 里,这样后续请求都可以直接命中缓存。

那么,当大量缓存数据在同一时间过期(失效)或者 Redis 故障宕机时,如果此时有大量的用户请求,都无法在 Redis 中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是缓存雪崩的原因。

因此,缓存雪崩的原因主要就是:

  • 大量数据同时过期
  • Redis故障宕机

我们分别总结这两种原因的应对策略:

大量数据同时过期的应对策略

均匀设置过期时间 :在基础过期时间(TTL)上增加随机扰动值,将缓存失效的时间点离散化,避免集中失效。

互斥锁机制 :当缓存失效时,利用锁机制确保同一时刻只有一个线程能访问数据库,其他线程等待或重试。

后台更新缓存 :业务线程不处理缓存更新,而是由独立的后台线程定时或异步检测并刷新缓存,实现逻辑上的永不过期。

Redis故障宕机的应对策略

服务熔断或请求限流机制 :可以启动服务熔断机制,暂停业务应用对缓存服务的访问,直接返回错误,不用再继续访问数据库,从而降低对数据库的访问压力。或者启动请求限流机制,只允许少量的请求访问。

构建 Redis 缓存高可靠集群 :通过主从节点方式构建Redis 缓存高可靠集群后,如果 Redis 缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务,避免了由于 Redis 故障宕机而导致的缓存雪崩问题。

缓存击穿

我们的业务通常会有几个数据会被频繁地访问,比如秒杀活动,这类被频地访问的数据被称为热点数据

如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿

可以发现缓存击穿跟缓存雪崩很相似,你可以认为缓存击穿是缓存雪崩的一个子集。应对缓存击穿可以采取前面说到两种方案,即互斥锁过期时间

缓存穿透

当发生缓存雪崩或击穿时,数据库中还是保存了应用要访问的数据,一旦缓存恢复相对应的数据,就可以减轻数据库的压力,而缓存穿透就不一样了。

当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。那么当有大量这样的请求到来时,数据库的压力骤增,这就是缓存穿透

缓存穿透的发生一般有这两种情况:

  • 业务误操作,缓存中的数据和数据库中的数据都被误删除了。
  • 黑客恶意攻击,故意大量访问某些读取不存在数据的业务。 黑客太坏了。

应对缓存穿透,常见的方案有三种:

  • 限制非法请求
  • 缓存空值或者默认值
  • 使用布隆过滤器快速判断数据是否存在

我们分别总结这三种应对策略:

大量数据同时过期的应对策略

限制非法请求 :在 API 入口层进行严格检查。对于 ID 小于 0、格式错误或逻辑不符的请求,以及未登录或无权访问的非法请求,直接在前端或网关层拦截,不给缓存和数据库造成压力。

缓存空值或者默认值 :当查询数据库未命中时,也将一个空对象(null)或特定的默认值写入缓存,防止同一个 ID 重复攻击数据库。同时为这些空值设置一个较短的过期时间(如 5 分钟),以保证如果后续该数据真的被创建了,缓存能及时更新。

使用布隆过滤器快速判断 :在访问 Redis 之前,先查询布隆过滤器。利用其高效的数据结构,快速判断该 Key 是否可能存在。如果布隆过滤器判断 Key 一定不存在,则直接返回,彻底隔绝请求与底层存储的接触。

什么是布隆过滤器?

布隆过滤器(Bloom Filter)本质上是由一个很长的二进制向量(位图/BitMap)和 一系列随机映射函数(Hash Functions)组成的。

假设我们有一个长度为 $m$ 的位数组,初始全为 0,当我们要把元素 $X$ 放进去时:

  1. 使用 $k$ 个不同的哈希函数对 $X$ 进行计算,得到 $k$ 个哈希值。
  2. 把位数组中这 $k$ 个位置的 bit 都置为 1

当我们要查询元素 $Y$ 是否存在时:

  1. 同样用那 $k$ 个哈希函数对 $Y$ 进行计算。
  2. 检查位数组中这 $k$ 个位置的 bit:
    • 如果全都是 1:说明 $Y$ 可能存在(因为这些 1 可能是由其他元素 A、B、C 共同凑出来的)。
    • 如果有任何一个是 0:说明 $Y$ 一定不存在(因为如果存过 $Y$,这些位置肯定早就被置为 1 了)。

随着存入的数据越来越多,越来越多的 bit 被置为 1。 当我们查询一个没存过的元素 $Z$ 时,它算出来的 $k$ 个位置,可能恰好被之前存进去的元素 $A$ 和 $B$ 把坑占了。因此,布隆过滤器的特点就是:
它说不存在,那就一定不存在;它说存在,那有可能不存在。它可以做到以极小的内存代价,快速判断一个元素是否在一个集合中。

Reference

3种常用的缓存读写策略详解 https://javaguide.cn/database/redis/3-commonly-used-cache-read-and-write-strategies.html

Cache-Aside Pattern https://www.geeksforgeeks.org/system-design/cache-aside-pattern/

【🔥缓存与数据库双写一致性的终极指南】旁路缓存下,我们如何避免“脏数据”灾难? https://www.cnblogs.com/sun-10387834/p/19001248

数据库和缓存如何保证一致性? https://www.xiaolincoding.com/redis/architecture/mysql_redis_consistency.html