1.本文章所有内容仅代表个人观点,如存在理解偏差,请指正。
2.本文章所有图片均手工绘制,可能存在误区。
一、Redis概念
Redis是后端架构中使用频率较高的中间件。想要了解Redis,就需要知道为什么要使用Redis以及Redis是什么。
解答这两个问题,我们只需要知道三点:
首先,Redis可以理解为一台数据库,但它不是用于持久化数据的存储,而是临时数据的存储,常作为缓存层或高速数据缓冲区。
下图为Redis缓存决策流程:
其次,Redis基于内存,内存的处理效率远胜于磁盘,并且Redis优化了数据结构(String、List、Set、ZSet、Hash),大大提升了数据处理效率。所以Redis效率极高,轻松处理10万级别QPS(响应/请求),适合应用于高并发服务,性能远远超过基于磁盘的传统数据库。
最后,Redis为防止数据丢失,提供了RDB和AOF持久化机制,数据更加安全;支持集群部署,确保服务高可用与横向扩展;提供原子操作,可使用事务或Lua脚本保证复杂操作的原子性。
二、Redis经典问题
Redis既然这么优秀,那么它有什么缺点嘛,答案显而易见,任何中间件都有优缺点。
首先来了解一个概念,为了减轻数据库的压力,我们常选择对数据库中的高访问量的数据进行提前缓存,这个过程称为缓存预热。
了解完这个概念,就可以继续了解使用Redis过程中会遇到的经典的四个问题。
1.缓存穿透
当客户端请求数据库中不存在的数据时,导致Redis缓存无法命中,直接穿透到数据库,当存在大量这样的请求,数据库压力倍增,不仅影响其他业务,严重情况会导致数据库宕机。
2.缓存雪崩
Redis为了防止数据冗余,通常会设置过期时间,当为大量数据设置相同的过期时间,大量数据将会同一时间过期,恰好此时出现大量请求(若干key),会导致请求全部转发到DB,DB瞬时压力过重雪崩。
3.缓存击穿
当存在一个热点key(例如某一个id),一段时间内出现此热点数据的大量请求,又恰逢此key的缓存已经过期,导致大量的请求涌入到数据库,从而导致数据库压力剧增。
4.数据一致性
数据库中的数据缓存完成后,数据库中的数据发生了修改,也需要对缓存中的数据进行修改,但是Redis和数据库之间通信存在网络和一致性选择问题。
三、解决方案
那么如何解决这些问题?
1.解决缓存击穿
出现缓存击穿的原因:1.Redis无缓存 2.数据库无数据
解决方案1:bitmap(快速解决)
bitmap可以理解为一个二进制数组,以数组偏移量表示为数据的id,从而在相应的偏移量位置设置0(数据无缓存)和1(数据有缓存),并且需要对不存在的数据进行单独处理,只能解决固定id的大量访问。

解决方案2:Bloom过滤器(优选)
①Bloom过滤器概念
Bloom过滤器底层是基于bitmap二进制数组,通过若干个Hash算法对数据进行加工,存储到bitmap中若干个位置中,通过判断位置上的所有的数字为1(数据存在)。 优点:占用内存空间小,将记录的标识以二进制的形式进行保存,极大的减小的内存占比;基于Redis存储,查找速度快。 缺点:因为Hash算法存在Hash冲突,数据存在一定的误判率,但是可以确定数据一定不存在;删除困难,类似数据容易误删,所以不能删除,解决以上问题通常使用重建Bloom过滤器或者当空间不足时进行扩容,都是基于重建的原理实现。
②Bloom初始化
1.@PostConstruct等待RedissonClient客户端对象注入到启动类中。
2.启动类中实现 Command LineRunner 接口,自动执行run()方法。
3.在方法中创建BloomFilter对象,指定过滤器的名字,判断过滤器是否存在,不存在则初始化过滤器参数(预计数据数量,误判率),redisson会根据输入的数据数量计算需要使用的bitmap数组的大小和hash算法的数量。
4.使用Bloom过滤器方法。
@Component
public class BloomFilterInitializer implements CommandLineRunner {
@Autowired
private RedissonClient redissonClient;
// 1. 使用@PostConstruct确保RedissonClient先注入
@PostConstruct
public void init() {
// 前置检查
}
// 2. 实现CommandLineRunner接口,在Spring完全启动后执行
@Override
public void run(String... args) throws Exception {
// 3. 获取或创建布隆过滤器
RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("userBloomFilter");
// 4. 初始化过滤器
if (!bloomFilter.isExists()) {
boolean initialized = bloomFilter.tryInit(
1000000L, // 预计数据数量
0.001 // 误判率
);
if (initialized) {
// 5. Redisson内部会自动计算bitmap数组大小(根据预期数量和误判率)
// 6. 缓存预热(可选)
// bloomFilter.add("Key");
}
}
// 7. 后续使用
// bloomFilter.contains("someKey");
// bloomFilter.add("newKey");
}
}
注意:为什么将@PostConstruct和CommandLineRunner接口配合使用
@PostConstruct标记的方法会在Bean的依赖注入完成之后执行,但此时容器可能并未完全启动,redis连接可能尚未建立成功,所以需要配合CommmandLineRunner使用,他会确保容器完全启动之后才会去执行run方法。
③Bloom过滤器方法
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(name);//初始化bloom过滤器,但并未真正的建立BloomFilter.tryInit(预计数据数量,误判率):初始化bloom过滤器参数
bloomFilter.isExists(); //检测bloom过滤器是否存在
bloomFilter.add(albumId); //加入bloom过滤器
bloomFilter.contains(albumId); //查找bloom过滤器
bloomFilter.delete(); //删除bloom过滤器
bloomFilter.rename(newname); //重命名bloom过滤器
2.解决缓存雪崩
大量缓存同一时间过期。
解决方案1:随机时间
原有失效时间的基础上增加一个随机值(1-3分钟),这样每一个缓存的过期时间的重复率就会降低,几乎不会再度产生雪崩问题。
public class SolutionRandomTime {
private static final Random RANDOM = new Random();
public static int getRandomExpireTime(int baseSeconds, int randomRange) {
// 生成1到randomRange秒的随机值
int randomValue = RANDOM.nextInt(randomRange) + 1;
return baseSeconds + randomValue;
}
public void setCacheWithRandomExpire(String key, Object value) {
int baseExpire = 3600; // 基础过期时间
int randomRange = 180; // 随机范围:1-180秒
int actualExpire = getRandomExpireTime(baseExpire, randomRange);
// 设置缓存
redisTemplate.opsForValue().set(key, value, actualExpire, TimeUnit.SECONDS);
}
}
解决方案2:集群部署
如果Redis单节点宕机,可以采用集群部署方式防止雪崩。

3.解决缓存击穿
大量热点Key访问+缓存过期,核心是并发回源打穿数据库
解决方案1:MySQL分布式锁
核心流程:
1.缓存命中 → 直接返回
2.缓存未命中 → 抢锁表行锁 → 抢到锁查数据表 → 刷新缓存 → 返回
3.缓存未命中 → 抢锁失败 → 重试查缓存 → 返回
问题:锁施放时间不好掌控,增加了MySQL抢锁压力,会产生其他问题。
解决方案2:Zookeeper(仅了解)
博主并未使用过Zookeeper解决问题,仅了解理论。
利用Zookeeper的临时顺序节点和Watcher机制实现分布式锁。
ZNode结构设计
/locks
├── product_detail_1001
│ ├── lock-0000000001 [临时顺序节点]
│ └── lock-0000000002
└── product_detail_1002
└── lock-0000000001
# 也是通过锁解决问题,需要维护Zookeeper集群
解决方案3:Redis分布式锁(经典)
Redis存在多种分布式锁,例如set nx 和 Redission 框架,底层原理一致,使用框架更为方便。
①实现步骤:
1.引入Redisson依赖。
2.配置配置文件。
3.配置配置类,主机地址、端口、密码、超时时间、前缀,注册客户端对象到ioc。
4.调用客户端对象getlock()、lock()、trylock()和unlock()等方法。
@Service
public class CacheService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/* 使用Redisson锁解决缓存击穿 */
public Object getDataWithLock(String key) {
// 1. 先查缓存
Object value = redisTemplate.opsForValue().get(key);
if (value != null) {
return value;
}
// 2. 缓存不存在,准备获取分布式锁
String lockKey = "lock:" + key;
RLock lock = redissonClient.getLock(lockKey);
boolean lockAcquired = false;
try {
// 3. 尝试获取锁(最多等待100ms,锁租期30秒)
lockAcquired = lock.tryLock(100, 30000, TimeUnit.MILLISECONDS);
if (lockAcquired) {
// 4. 双重检查(Double Check)
value = redisTemplate.opsForValue().get(key);
if (value != null) {
return value;
}
// 5. 查询数据库
value = queryFromDatabase(key);
// 6. 写入Redis,设置随机过期时间防止雪崩
int expireTime = 3600 + new Random().nextInt(300);
redisTemplate.opsForValue().set(key, value, expireTime, TimeUnit.SECONDS);
return value;
} else {
// 7. 获取锁失败,等待后重试
Thread.sleep(50);
return getDataWithLock(key); // 递归重试
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("获取数据失败", e);
} finally {
// 8. 释放锁
if (lockAcquired && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
private Object queryFromDatabase(String key) {
// 数据库查询
return "data_from_db_" + key;
}
}
②为什么使用Redission框架不使用原生的 set nx 命令
使用分布式锁的目的是为了:多线程可见、保证排他性、避免死锁、锁服务高可用。 redis常用两种方式实现分布式锁,但是原生分布式锁:redis的 set ex nx 命令,存在一部分缺点:
1.加锁解锁代码手动实现,人力成本高。
2.锁不可重入。
3.锁到期后不会自动续期。
redisson框架:
1.编码简单,可以直接调用客户端方法,进行加锁解锁
2.底层采用Hash(Key:锁的名称;Filed:线程的唯一标识:UUID+线程ID;Value:锁的计数器)作为锁的存储,支持锁的重入。
3.自动续期基于看门狗机制(定时任务)完成。
③Redission加锁底层原理(仅了解)
Ⅰ 加锁底层原理
lock() 加锁方法,底层调用同名lock方法,首先获取当前线程id,并且调用尝试加锁的方法tryAcquire,将等待时间、锁过期时间、线程id传入加锁tryAcquire方法,如果ttl返回null,则代表加锁成功; tryAcquire() 会调用tryAcquireAsync,tryAcquireAsync方法会根据是否传入了锁的持有时间判断如何设置tryLockInnerAsync方法的参数,没有则使用默认的30秒,其中使用lua脚本保证加锁的原子性,只有锁名称不存在或者属于当前线程的锁,才允许创建或者重入。 如果不为空,则开启异步任务,订阅释放锁的消息,redisson使用发布订阅机制实现锁的等待,当锁被释放的时候,持有锁的客户端会释放一条消息,阻塞锁的客户端会收到通知并且尝试重新获取锁。 设值合理的的超时时间,确保长时间获取不到锁,触发超时异常;并且interruptibly属性为false,表示锁在等待的过程中不会被中断,lockInterruptibly()可中断,默认的interruptibly为true。 紧接着,我们会进入一个死循环,继续尝试获取锁,成功则退出,失败则对锁的剩余过期时间进行一个判断,如果大于0,则进入等待状态,直到过期时间到或者被订阅消息唤醒,如果剩余过期时间小于0,则判断是否支持等待时中断,调用方法检测当前线程订阅的监听器的计数器,直到其归零也就是线程调用countdown归零,自己则会被唤醒。 无论如何最后取消订阅。

-
支持中断(
acquire()): 线程在等待计数器归零的过程中,如果收到中断信号,会 立即停止等待,抛出InterruptedException并退出,后续代码不再执行(除非捕获异常处理)。 这相当于 “线程在等待时可以被‘打扰’,收到信号就停止等待”。 -
不支持中断(
acquireUninterruptibly()): 线程在等待过程中,即使收到中断信号,也会 坚持等待到计数器归零,不会提前退出,也不抛异常。但中断信号会被记录(中断状态为true),等待结束后线程可以感知到。
Ⅱ 看门狗机制
看门狗的默认时间可以通过客户端对象中config.setLockWatchdogTimeout()来自定义时长。 仅当超时时间为-1时,才触发看门狗机制,调用scheduleExpirationRenewal(threadId);方法,将线程id传入,指定续期方法renewExpiration,在这个方法中会开启一个定时任务renewExpirationAsync,定时任务每10秒进行一次续期,这个定时任务首先会通过lua脚本判断当前线程是否持有锁,如果持有锁则续期,返回1,自旋执行续期任务,没有持有锁代表锁已经被释放,返回0,取消定时任务。 我们通常不使用看门狗机制,如果线程在执行的过程中发生了阻塞、卡死,那么看门狗会一直续期,导致锁无法释放,发生死锁。

Ⅲ 解锁
调用unlock方法后,底层同样使用lua脚本释放锁,首先通过hexists锁名称和唯一标识判断锁是否存在,存在则使用hincrby对value值减一,判断value值,如果大于0则代表锁还存在重入,pexpire重制剩余过期时间,否则则代表锁已经不存在,del删除锁,并且使用publish发布 通知其他线程竞争锁。

4.解决数据一致性
缓存和数据库之间 需要保证数据的一致性。
解决方案1:延迟双删
1.删除缓存。
2.更新数据库。
3.睡眠一段时间。
4.再次删除缓存。 加了个睡眠时间,主要是为了确保请求 A (更新)在睡眠的时候,请求 B (查询)能够在这这一段时间完成「从数据库读取数据,再把缺失的缓存写入缓存」的操作,然后请求 B 睡眠完,再删除缓存。 但这个睡眠时间不好掌握,一旦睡眠完成,第二个线程的缓存还没有写入,那么就白睡了,出现数据不一致。
@Transactional
public void delayDoubleDelete(Long userId, User userData) {
String cacheKey = "user:" + userId;
// 步骤1:先删除缓存
redisTemplate.delete(cacheKey);
// 步骤2:更新数据库
userRepository.updateUser(userId, userData);
// 步骤3:休眠一段时间(等待其他线程完成读取)
try {
Thread.sleep(500); // 休眠500ms,不好掌握,需要根据业务严格测试
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("延迟双删被中断", e);
}
// 步骤4:再次删除缓存
redisTemplate.delete(cacheKey);
logDoubleDelete(userId);
}
public User getUser(Long userId) {
String cacheKey = "user:" + userId;
// 1. 先查缓存
User cachedUser = (User) redisTemplate.opsForValue().get(cacheKey);
if (cachedUser != null) {
return cachedUser;
}
// 2. 缓存不存在,查询数据库
User dbUser = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在"));
// 3. 写入缓存,存在线程竞争
redisTemplate.opsForValue().set(cacheKey, dbUser, 1, TimeUnit.HOURS);
return dbUser;
}
解决方案2:分布式读写锁
缺点:除 '读读' 外不允许并发,依赖于Redission 框架 和 Redis 分布式锁机制,性能低
//1.创建读写锁对象 锁名称前缀:数据标识
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("myLock:" + id);
RLock lock = readWriteLock.readLock();//RLock lock = readWriteLock.writeLock();
//2.获取读锁成功 执行读操作 读锁持有期间,不允许并发发写,允许并发读
try {
lock.lock(5, TimeUnit.SECONDS);
log.info(Thread.currentThread().getName() + "执行读数据操作");
return id;
} finally {
//3.释放读锁
//lock.unlock();
}
解决方案3:Canal(稳定)
「先更新数据库,再删缓存」策略 Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。 我们可以使用「消息队列来重试缓存的删除」,或者「订阅 MySQL binlog 再操作缓存」,这两种方法有一个共同的特点,都是采用异步操作缓存。
Cancal创建流程:
1.验证MySQL是否开启BinLog确保Value为 ON 。
2.在MySQL新增用户用于监听Binlog日志,在Canal容器中要使用该用户。
3.查询MySQL主节点状态;完成重置。
4.采用Docker等方式创建Canal服务端,促使canal重新跟MySQL建立连接。
5.客户端引入相关依赖canal-spring-boot-starter。
6.配著启动类,排除数据库连接。
7.配置配置文件。
8.配置监听器。
理解Cancal,其实只需要理解Mysql的主从同步机制:
主人记录变化:主人(Master)将所有数据变更写入二进制日志(binlog)文件。
学徒请求同步:学徒(Slave)启动同步后,创建IO线程向主人请求binlog内容。
主人派发日志:主人接到请求,创建Log Dump线程读取并发送binlog给学徒。
学徒接收日志:学徒的IO线程将收到的binlog内容保存到中继日志(relay log)。
学徒执行操作:学徒的SQL线程读取relay log,执行其中的SQL操作,实现数据同步。

然后理解代码:
/**
* 用户表监听器
* 其实Canal就是通过监听器去检测数据库发生增删改操作,然后做出反应
*/
@Component
@CanalTable(value = "user")
@Slf4j
public class UserCanalListener implements EntryHandler<Map<String, String>> {
@Autowired
private CacheService cacheService;
@Override
public void insert(Map<String, String> data) {
log.info("监听到用户表INSERT操作: {}", data);
// 新增用户时,删除相关的缓存
String userId = data.get("id");
if (userId != null) {
// 删除用户详情缓存
cacheService.deleteCacheAsync("user:detail:" + userId);
// 删除用户列表缓存
cacheService.deleteCacheAsync("user:list:*");
}
}
@Override
public void update(Map<String, String> before, Map<String, String> after) {
log.info("监听到用户表UPDATE操作: before={}, after={}", before, after);
// 更新用户时,删除相关的缓存
String userId = after.get("id");
if (userId != null) {
// 删除用户详情缓存
cacheService.deleteCacheAsync("user:detail:" + userId);
// 如果用户名或状态发生变化,删除相关缓存
if (!before.get("username").equals(after.get("username")) ||
!before.get("status").equals(after.get("status"))) {
cacheService.deleteCacheAsync("user:list:*");
}
}
}
@Override
public void delete(Map<String, String> data) {
log.info("监听到用户表DELETE操作: {}", data);
// 删除用户时,删除相关的缓存
String userId = data.get("id");
if (userId != null) {
// 删除用户详情缓存
cacheService.deleteCacheAsync("user:detail:" + userId);
// 删除用户列表缓存
cacheService.deleteCacheAsync("user:list:*");
}
}
}
本文章结束


489

被折叠的 条评论
为什么被折叠?



