目录
前言
Redis 作为缓存与数据库之间的通信模式能够显著提升系统性能,减少数据库的压力。
通过合理使用 Redis 进行数据存取,并结合适当的缓存失效与更新策略,可以确保数据一致性和系统的高效性。
如下图所示:
1、缓存问题
引入缓存提高性能,在提高性能的同时,也会引入数据不一致性和并发问题,通常引入更多组件也会加大业务系统的复杂度。
1、简单业务场景
在简单业务数据量不大的场景,无论是读请求还是写请求,直接操作数据库即可。
如下图所示:
2、项目引入缓存
随着项目请求量越来越大,这时如果每次都从数据库中读数据,会有性能问题。
这个阶段通常的做法是,引入「缓存」来提高读性能。
如下图所示:
3、数据同步
在引入redis作为缓存之后,就会出现redis数据和数据库数据如何同步的现象。
相对于简单的方案:全量导入缓存
-
数据库的数据,全量刷入缓存(不设置失效时间)
-
写请求只更新数据库,不更新缓存
-
启动一个定时任务,定时把数据库的数据,更新到缓存中
优点:
所有读请求都可以直接「命中」缓存,不需要再查数据库,性能非常高。
缺点:
缓存利用率可能不高(部分redis-key),受定时任务影响导致数据会出现不一致(时间有关)。
4、缓存利用率
想要缓存利用率「最大化」,可以容易想到的方案是,缓存中只保留最近访问的「热数据」。如下图所示:
设置缓存的失效时间。
处理方案:
-
写请求依旧只写数据库
-
读请求先读缓存,如果缓存不存在,则从数据库读取,并重建缓存
-
同时,写入缓存中的数据,都设置失效时间
随着时间的推移,不常用的key都会逐渐「过期」淘汰掉,最终缓存中保留的,都是经常被访问的「热数据」,缓存利用率得以最大化。
2、更新缓存
1、数据一致性
基于上面介绍的定时任务虽然也可以做到数据同步,但是局限性比较大,为了实现缓存利用率最大化。
因此,需要在更新数据库后,即使更新缓存,否则缓存和数据库还是会有时间差,导致查询redis缓存的值是旧值,存在利用率不高的现象。
期望:
当数据发生更新时,我们不仅要操作数据库,还要一并操作缓存。
当数据库和缓存都更新,又存在先后问题,那对应的方案就有 2 个:
-
先更新缓存,后更新数据库
-
先更新数据库,后更新缓存
此时也有可能存在先后操作的时候,出现失败的场景。
1) 先更新缓存,后更新数据库
如果缓存更新成功了,但数据库更新失败,那么此时缓存中是最新值,但数据库中是「旧值」。
虽然此时读请求可以命中缓存,拿到正确的值,但是,一旦缓存「失效」,就会从数据库中读取到「旧值」,重建缓存也是这个旧值。
这时用户会发现自己之前修改的数据又「变回去」了,对业务造成影响。
2) 先更新数据库,后更新缓存
如果数据库更新成功了,但缓存更新失败,那么此时数据库中是最新值,缓存中是「旧值」。之后的读请求读到的都是旧数据,只有当缓存「失效」后,才能从数据库中得到正确的值。
这时用户会发现,自己刚刚修改了数据,但却看不到变更,一段时间过后,数据才变更过来,对业务也会有影响。
可见,无论谁先谁后,但凡后者发生异常,就会对业务造成影响。
2、并发场景
并发引发的一致性问题
假设我们采用「先更新数据库,再更新缓存」的方案,并且两步都可以「成功执行」的前提下,如果存在并发。
有线程 A 和线程 B 两个线程,需要更新「同一条」数据,会发生这样的场景:
-
线程 A 更新数据库(X = 1)
-
线程 B 更新数据库(X = 2)
-
线程 B 更新缓存(X = 2)
-
线程 A 更新缓存(X = 1)
最终 X 的值在缓存中是 1,在数据库中是 2,发生不一致。
从分析可知,尽管A 虽然先于 B 发生,但 B 操作数据库和缓存的时间,却要比 A 的时间短,执行时序发生「错乱」,最终这条数据结果是不符合预期的。
而另外先更新缓存、再更新数据库也会出现相似的场景。
总结:
因为每次数据发生变更,都「无脑」更新缓存,但是缓存中的数据不一定会被「马上读取」,这就会导致缓存中可能存放了很多不常访问的数据,浪费缓存资源。
3、删除缓存
对于删除缓存,也有对应的方案 2 种:
-
先删除缓存,后更新数据库
-
先更新数据库,后删除缓存
首先先分析一下,当删除缓存、数据库任意一部出现失败的情况,都会导致读取不一致。
如下图所示:
当两者都成功的话,也会出现并发问题。如下图所示:
如果是先删缓存、再更新数据库,在没有出现失败时可能会导致数据的不一致。
如果在实际的应用中,出于某些考虑我们需要选择这种方式,那有办法解决这个问题吗?答案是有的,那就是采用延时双删的策略,延时双删的基本思路如下:
- 删除缓存;
- 更新数据库;
- sleep N毫秒;
- 再次删除缓存。
public void write(String key, Object data) {
Redis.delKey(key);
db.updateData(data);
Thread.sleep(1000);
Redis.delKey(key);
}
而先更新数据库,后删除缓存这⼀种情况也会出现问题,比如更新数据库成功了,但是在删除缓存的阶段出错了没有删除成功,那么此时再读取缓存的时候每次都是错误的数据了。
此时可以:
引入重试机制。
如果无限制的重试,会增加性能开销,同时需要合理设置重试次数。
基于上面的考虑,采用异步重试--MQ组件。
-
消息队列保证可靠性:写到队列中的消息,成功消费之前不会丢失(重启项目也不担心)
-
消息队列保证消息成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者(符合我们重试的场景)
此时解决方案就是利用消息队列进行删除的补偿。
4、最终方案
对于为什么引入mq的本质,防止删除缓存失败的时候,数据库重启,导致操作任务丢失。
由于基于先更新数据库,后删除缓存。
有⼀个缺点就是会对业务代码造成大量的侵入,因此对 Mysql 数据库更新操作后再从 binlog 日志中找到相应的操作。
可以订阅 Mysql 数据库的 binlog 日志对缓存进行操作。
如下图所示:
具体的业务逻辑⽤语⾔描述如下:
- 请求 线程A 先对数据库进行更新操作;
- 在对 Redis 进行删除操作的时候发现报错,删除失败;
- 此时将Redis 的 key 作为消息体发送到消息队列中;
- 系统接收到消息队列发送的消息后再次对 Redis 进行删除操作;
当然在读写分离 + 主从复制延迟的情况下,可以结合延迟双删的策略,最大程度的减少缓存不一致问题。
以下对延迟双删的策略进行介绍:
1.工作原理
-
第一次删除缓存:
- 在更新数据库之前,应用程序首先从 Redis 中删除相关缓存。这可以确保未来的读取请求不会查询到过期或错误的数据。
-
更新数据库:
- 然后,程序对数据库进行写操作,更新数据。
-
引入短暂延迟:
- 在成功更新数据库后,引入一个短暂的延迟(例如 50 毫秒),这段时间允许主从复制机制完成对数据的同步,对数据变化有所“缓冲”。
-
第二次删除缓存:
- 在延迟后,再次删除 Redis 中的缓存。此时,即使在从数据库中读取数据的请求仍然存在,经过这段延迟,主从复制完成后,确保主数据库的数据更新已经卷入从库。
2. 解决主从复制问题的机制
数据一致性
-
防止旧数据读取:通过第一次删除缓存和随后引入的延迟,可以有效防止在数据库更新后短时间内的旧数据读取。如果数据在从数据库上的复制尚未完成,这段时间的延迟将确保后续查询能够处理正确的请求。
-
确保完整更新:第二次删除缓存是一个冗余的步骤,用于确保即使在并发操作下,过期的数据不会被保留在缓存中。
系统的健壮性
- 异步处理:由于这种策略允许数据库在处理完更新后再进行第二次删除操作,系统可以更灵活地应对并发请求,并维护更高的一致性。
总结
延迟双删策略通过结合数据库更新、缓存删除和引入短暂延迟,可以有效解决缓存与主从复制之间的一致性问题。
通过确保在删除缓存之前,数据库的写操作已经完成,并赋予时间对主从数据库进行同步,从而避免读取到过时的数据。
总结:
1、想要提高应用的性能,可以引入「缓存」来解决。
2、引入缓存后,需要考虑缓存和数据库一致性问题,可选的方案有:「更新数据库 + 更新缓存」、「更新数据库 + 删除缓存」。
3、更新数据库 + 更新缓存方案,在「并发」场景下无法保证缓存和数据一致性,且存在「缓存资源浪费」和「机器性能浪费」的情况发生。
4、在更新数据库 + 删除缓存的方案中,「先删除缓存,再更新数据库」在「并发」场景下依旧有数据不一致问题,解决方案是「延迟双删」,但这个延迟时间很难评估,所以推荐用「先更新数据库,再删除缓存」的方案。
5、在「先更新数据库,再删除缓存」方案下,为了保证两步都成功执行,需配合「消息队列」或「订阅变更日志」的方案来做,本质是通过「重试」的方式保证数据一致性。
6、在「先更新数据库,再删除缓存」方案下,「读写分离 + 主从库延迟」也会导致缓存和数据库不一致,缓解此问题的方案是「延迟双删」,凭借经验发送「延迟消息」到队列中,延迟删除缓存,同时也要控制主从库延迟,尽可能降低不一致发生的概率。
5、缓存分类
在使用缓存机制过程中,也会出现一些比较特殊的场景。
如下图所示:
5.1、缓存穿透
1.定义:
缓存穿透是指查询一个不存在的数据。例如,当用户请求一个不存在的 ID(如查询一个未注册的用户)时,系统会直接查询数据库,因为缓存中没有相关数据。
由于这些请求始终发往数据库,可能导致数据库压力过大。
2.特征:
- 主要关注的是请求的数据在缓存中完全不存在。
- 每个请求都必然直接查询数据库,无法利用缓存。
3.解决方案:
- 校验请求参数(如非法请求参数直接返回错误)。
- 引入防刷机制(如验证码)来限制请求频率。
- 布隆过滤器:在请求到来时,首先检查布隆过滤器。布隆过滤器可以快速判断某项数据是否可能存在,避免无效请求直接到达数据库。
- 缓存空值:在得到空的查询结果时,可以将这个空值结果存储到 Redis 中,并设定一个过期时间。之后再访问同样的用户 ID 时,可以直接返回空值,而不再访问数据库。
如下图所示:
原理:
布隆过滤器的基本原理如下:
-
位数组:布隆过滤器使用一个位数组(bit array)来表示集合,位数组的每个位置可以存储为0或1。
-
哈希函数:布隆过滤器使用多个不同的哈希函数将元素映射到位数组中的几个位置。每个哈希函数都会根据输入元素计算出一个索引值。
-
添加元素:要添加元素时,通过每个哈希函数计算出多个索引,然后将位数组中这些索引对应的位置设为1。
-
查询元素:要查询元素时,同样使用哈希函数计算出对应的索引。如果所有这些索引在位数组中都是1,则可以认为这个元素可能在集合中;如果有任何一个索引是0,则可以确定这个元素不在集合中。
特性
- 可能产生误判:布隆过滤器可能会返回“存在”的错误结果(即假阳性),但如果返回“不存在”则准确。
- 不可删除:一旦加入元素,无法将其删除,这也是设计上的 trade-off。
代码示例:
import java.util.BitSet;
import java.util.Random;
public class BloomFilter {
private BitSet bitSet; // 位数组
private int size; // 位数组的大小
private int hashCount; // 哈希函数的数量
public BloomFilter(int size, int hashCount) {
this.size = size;
this.hashCount = hashCount;
this.bitSet = new BitSet(size); // 初始化位数组
}
// 添加元素
public void add(String item) {
// 针对每个哈希函数位置哈希值并设置位数组
for (int i = 0; i < hashCount; i++) {
int hash = hash(item, i);
bitSet.set(hash); // 设置该位置为1
}
}
// 查询元素
public boolean mightContain(String item) {
// 检查通过所有哈希函数计算的位数组位置
for (int i = 0; i < hashCount; i++) {
int hash = hash(item, i);
if (!bitSet.get(hash)) {
return false; // 如果有任意一个位置为0,返回不在集合中
}
}
return true; // 所有位置为1,返回可能在集合中
}
// 哈希函数
private int hash(String item, int seed) {
// 使用简单的算法计算哈希值,实际上可以使用更复杂的哈希函数
return (item.hashCode() + seed) % size;
}
public static void main(String[] args) {
BloomFilter bloomFilter = new BloomFilter(100, 5);
// 添加元素
bloomFilter.add("apple");
bloomFilter.add("banana");
// 检查元素
System.out.println(bloomFilter.mightContain("apple")); // 输出: true
System.out.println(bloomFilter.mightContain("banana")); // 输出: true
System.out.println(bloomFilter.mightContain("orange")); // 输出: false(可能在集合中)
}
}
5.2、缓存击穿
1.定义:
缓存击穿是指某个热点数据的缓存失效(或过期),在失效的瞬间,同时有大量请求涌入,导致数据库的压力急剧增加。
这种情况通常发生在用户频繁请求某些数据,并在这些数据的缓存失效时。
2.特征:
- 通常是针对某些热门数据的请求。
- 多个并发请求在数据刚过期的瞬间直接访问数据库。
3.解决方案:
-
加锁机制:
对缓存中失效的数据的请求进行分布式锁控制。只有第一个请求会去数据库加载数据,其余请求则等待。当数据加载完成后,所有请求都能从缓存中读取数据。 -
设置更长的缓存有效期:
针对热点数据,适当延长其缓存时间,减少过期次数,从而降低击穿的概率。 -
预热缓存:
在应用程序的低峰期,主动将热门数据预先加载到缓存中,确保在高峰期不会因缓存失效导致击穿。
5.2、缓存雪崩
1.定义:
缓存雪崩是在特定时刻,多个缓存数据同时失效,导致大量请求同时涌向数据库,造成数据库压力骤增。
这种情况通常发生在缓存系统的集中失效,比如一段时间内大批缓存设置了相同的过期时间。
2.特征:
- 涉及到多个缓存数据的失效,而不仅仅是单一数据。
- 在短时间内,都发起请求的情况。
3.解决方案:
- 为缓存设置不同的过期时间,避免集中失效的现象。
- 采用随机过期时间策略,使缓存的失效时间分散。
- 监控缓存健康状况,及时发现并处理异常。
6、示例
下面是一个结合 延迟双删 策略的代码示例,该策略用于确保在更新数据库后,缓存中的数据保持一致。这个示例使用 Java 和 Redis 的操作示例,
假设使用 Spring Data Redis 来进行数据库操作。
示例场景
假设有一个用户数据更新的场景,涉及到更新用户信息并且需要确保 Redis 缓存中的用户信息是最新的。
代码示例
以下是一个简单的 Java 方法,其中实现了先更新数据再删除缓存,并结合延迟双删策略:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class UserService {
@Autowired
private UserRepository userRepository; // 假设有一个 JPA 仓库来处理 User 数据库操作
@Autowired
private RedisTemplate<String, User> redisTemplate; // Redis 模板
// 更新用户信息
@Transactional // 确保数据库操作是原子性的
public void updateUser(Long userId, User updatedUser) {
// 1. 删除缓存(第一次删除)
String cacheKey = "user:" + userId;
redisTemplate.delete(cacheKey);
// 2. 更新数据库
userRepository.save(updatedUser); // 假设 save 是一个更新操作
// 3. 延迟
try {
Thread.sleep(50); // 短暂延迟,给主从同步提供时间
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 4. 再次删除缓存(第二次删除)
redisTemplate.delete(cacheKey);
}
// 获取用户信息
public User getUser(Long userId) {
String cacheKey = "user:" + userId;
// 尝试从缓存中获取
User user = redisTemplate.opsForValue().get(cacheKey);
if (user != null) {
return user;
}
// 如果缓存未命中,则从数据库加载
user = userRepository.findById(userId).orElse(null);
// 同时,将从数据库中获得的用户信息放入缓存
if (user != null) {
redisTemplate.opsForValue().set(cacheKey, user);
}
return user;
}
}
代码解析
-
删除缓存(第一次删除):
- 在更新数据库之前,应用从 Redis 缓存中删除与用户 ID 相关的缓存。这有助于确保后续的读取请求不会读取到过时的数据。
-
更新数据库:
- 通过 JPA 仓库更新用户信息。
-
延迟:
- 使用
Thread.sleep(50)
引入短暂延迟,以允许主从数据库之间的数据同步。这里的延迟时间可以根据系统性能和网络延迟进行调整。
- 使用
-
再次删除缓存(第二次删除):
- 再次删除缓存项,以确保若有并发操作(其他请求可能在延迟期间发生)存在,也能保持一致性。
-
获取用户信息:
- 在获取用户信息时,首先尝试从缓存中获取,未命中则从数据库加载并更新缓存。
延迟双删策略结合 Redis 缓存和数据库更新,提供了一种有效的方式来管理数据一致性,确保系统在处理高并发情况下能够可靠地保持缓存的数据准确性。
参考文章: