redis-缓存-双写一致性
一、来因宫
1.1、案件情景再现
双写一致:保持Reids和Mysql数据在修改的时的一致性;
请看案例~!
xml
老板:"小李,下周搞个活动,把app首页的轮播图换一下~"
小李:"收到"
小李开始忙乎起来,登录后台,准备替换新的活动轮播图,几分钟的时间检查无误后就搞定发布了
小李:"换好了,老板"
老板:"你换到哪里去了?怎么还是原来的图!"
老板气冲冲的拿着手机指着屏幕对小李说道
小李看着手机上显示的旧图,十分委屈,自己明明已经替换过了!怎么没有更新?
隔壁的秃顶老王听到两人吵闹声,默默的打开了IDEA
java
// 1. 构建缓存Key
String cacheKey = BANNER_CACHE_KEY_PREFIX + updatedBanner.getId();
// 2. 删除Redis中需要更新的Banner缓存
Boolean deleted = redisTemplate.delete(cacheKey);
if (deleted != null && deleted) {
System.out.println("成功删除Redis中Banner缓存: " + cacheKey);
} else {
System.out.println("Redis中不存在该Banner缓存或删除失败: " + cacheKey);
}
// 3. 将更新后的数据同步到MySQL
Banner savedBanner = bannerRepository.save(updatedBanner);
System.out.println("Banner数据已成功更新到MySQL: " + savedBanner.getId());
xml
紧张的汗水从老王的头顶冒了出来,心中暗自琢磨:这代码怎么了?有啥问题么?
先删除,在插入数据库....
用户访问app再去查询mysql
随后更新到redis
没问题啊!
xml
老王猛的睁大眼睛,悄咪咪的抹掉头上的汗水
"咳咳,老板你别急,小李你再重新操作下,刚才监控到有网络延迟,可能是没有同步过去"
老王转过身在小李一侧说道
小李听到老王的话,又重新操作了一遍,亲自打开手机先看一下,果然新的轮播图已经出现了!
"哇,王哥,你真厉害~果然可以了"
小李拿着手机摇晃的说道
"赶紧干活!"
老板重新点进去看轮播图已经更新,说了一句话就走开了
"王哥,你是好人,写信出,请你喝杯新品雪王!嘻嘻"小李笑呵呵的对老王说道
看的老王一愣一愣的,尴尬的在那抓头
1.2、案件分析
xml
下班后
"老王,下班了,还不走" 准备下班的老板看着坐在工位上的老王问了句
"啊,老板,今天网络延迟问题,我等等看能不能复现看看哪里问题"老王眼珠子一转说道
"哈哈,当初招你进来,就看你靠谱,好好干!"老板说了声转头就走了
......
老王喝着小李妹妹雪王新品,默默戴上耳机,盯着屏幕上的代码,双手飞快的敲击着!
yml
"要不是app日活达到了20多人,这个问题可能还真暴漏不出来!"老王小声的嘀咕着
*麦芒掉进针眼里--巧极了!*
*小李妹妹发布的时候,程序刚刚把redis缓存的数据删除,程序还没有往下执行!正好有人访问了app,*
*查询的逻辑又开始了!查到缓存没有数据,又从还未更新的mysql查询放到缓存了!*
导致发布完成后轮播图缓存还是旧的!

二、上手段
2.1、删除-存储-延迟删除
yml
"看来流量大了也不是好事啊!耽误俺老王下班!"
......
*"那就让轮播图放入数据库成功之后,再把缓存删除一遍!"*
*"我可真是个机灵鬼!嘿嘿嘿"*
java
/**
* 更新Banner数据,保证Redis和MySQL一致性
* 采用先删缓存、更新数据库、再删缓存的策略
*/
@Transactional(rollbackFor = Exception.class)
public Banner updateBanner(Banner banner) {
if (banner.getId() == null) {
throw new IllegalArgumentException("Banner ID不能为空");
}
// 1. 先删除Redis中的旧缓存
String cacheKey = BANNER_CACHE_KEY_PREFIX + banner.getId();
redisTemplate.delete(cacheKey);
System.out.println("第一次删除Redis缓存: " + cacheKey);
try {
// 2. 查询数据库中是否存在该Banner
Banner existingBanner = bannerRepository.findById(banner.getId())
.orElseThrow(() -> new RuntimeException("Banner不存在,ID: " + banner.getId()));
// 3. 更新Banner数据
existingBanner.setTitle(banner.getTitle());
......
......
// 4. 保存更新到数据库
Banner updatedBanner = bannerRepository.save(existingBanner);
System.out.println("Banner数据已更新到数据库,ID: " + updatedBanner.getId());
// 5. 再次删除Redis缓存,防止并发场景下的缓存脏数据
redisTemplate.delete(cacheKey);
System.out.println("第二次删除Redis缓存: " + cacheKey);
return updatedBanner;
} catch (Exception e) {
// 发生异常时可以考虑记录日志,进行补偿操作等
System.err.println("更新Banner失败: " + e.getMessage());
throw e;
}
}
yml
"大功告成!搞定,我真是个小天才,哈哈"老王看着自己的代码欣赏的说道!

yml
*"这他娘的,数据库现在用的主从模式,同步也需要时间啊!直接删除了,万一删除完,*
*另外的节点有查询还是旧数据啊!"*
*"难不住俺!延迟一会再删。桀桀桀"*
java
public void delayDeleteCache(String cacheKey, long delay) {
try {
// 延迟指定时间
TimeUnit.MILLISECONDS.sleep(delay);
// 第二次删除缓存
redisTemplate.delete(cacheKey);
System.out.println("延迟删除缓存成功: " + cacheKey);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.err.println("延迟删除缓存失败: " + e.getMessage());
}
}
2.2、加锁
yml
"这要延迟多久呢?我咋知道它啥时候能同步完数据了呢?"
"不行,还得再改改"
老王拿出抽屉钥匙🔑,从中拿出一包破旧烟盒,拿出一根邹邹巴巴的烟点上,叼在嘴里
将烟和打火机放进抽屉,上锁拔掉钥匙
"叮、叮、叮"
老王拿着钥匙在桌面上有节奏的敲击着
"有了!上锁啊!"
老王猛吸一口,将烟放在烟灰缸上
java
import com.example.entity.Banner;
import com.example.mapper.BannerMapper;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.concurrent.TimeUnit;
@Service
public class BannerService {
@Resource
private BannerMapper bannerMapper;
@Resource
private RedisTemplate<String, Object> redisTemplate;
// 读写锁:排他锁(写锁)和共享锁(读锁)
private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
// 缓存键前缀
private static final String BANNER_CACHE_KEY = "banner:";
/**
* 更新Banner数据
* 使用排他锁,确保更新期间其他线程无法读取
*/
@Transactional
public Banner updateBanner(Banner banner) {
// 获取写锁(排他锁)
readWriteLock.writeLock().lock();
try {
// 1. 先删除Redis中的旧缓存
String cacheKey = BANNER_CACHE_KEY + banner.getId();
redisTemplate.delete(cacheKey);
// 2. 更新数据库
if (banner.getId() == null) {
bannerMapper.insert(banner);
} else {
bannerMapper.updateById(banner);
}
// 3. 异步延迟删除缓存,解决主从同步延迟可能带来的问题
asyncDelayDeleteCache(cacheKey, 1000); // 延迟1秒
return banner;
} finally {
// 释放写锁
readWriteLock.writeLock().unlock();
}
}
/**
* 查询Banner数据
* 使用共享锁,允许多个线程同时读取
*/
public Banner getBannerById(Long id) {
String cacheKey = BANNER_CACHE_KEY + id;
// 获取读锁(共享锁)
readWriteLock.readLock().lock();
try {
// 1. 先查缓存
Banner banner = (Banner) redisTemplate.opsForValue().get(cacheKey);
if (banner != null) {
return banner;
}
// 2. 缓存未命中,查数据库
banner = bannerMapper.selectById(id);
if (banner != null) {
// 3. 写入缓存,设置过期时间
redisTemplate.opsForValue().set(cacheKey, banner, 30, TimeUnit.MINUTES);
}
return banner;
} finally {
// 释放读锁
readWriteLock.unlock();
}
}
/**
* 异步延迟删除缓存
*/
@Async
public void asyncDelayDeleteCache(String cacheKey, long delayMillis) {
try {
// 延迟指定时间
Thread.sleep(delayMillis);
// 再次删除缓存
redisTemplate.delete(cacheKey);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
共享锁readWriteLock.readLock().lock()
加锁后其他的线程还能继续读数据,不影响响应速度
排他锁readWriteLock.writeLock().lock()
加锁之后,其他的线程就不能执行了!得等它操作完,这样数据就一样了
yml
"这下是没问题了,上了两把锁,绝对一致!" 老王乐滋滋
老王想起没抽完的香烟,低头一看,放在烟灰缸上的烟已经燃尽
老王拿起钥匙开锁 、拿烟、点燃、上锁
老王反常的皱起眉头:*"加锁安全了,但是这开锁、上锁的,有点影响时间啊!"*
2.3、异步通知
yml
"轮播图也不是啥非得要求强一致性,直接延迟删除就行了呗,但是这方法有点..."
再想想!
"脑子不行了!查查网上有啥好解决办法没!"
在业务要求强一致性的情况下,例如涉及到金钱安全问题,那我们使用加锁是完全没有问题的;
像 其他热点缓存没有要求强一致性,就可以采用异步通知的方案进行解决
yml
"还得是网上的大神们啊!这方法给写的板板正正,MQ消息中间件,正好公司有,研究研究"
"可以在处理逻辑中向MQ发送消息"
"监听到这个消息就会进行同步更新缓存"

yml
老王上唇不自觉地向上提拉,脸颊的皮肤都绷成一张浅弓
随后随着悠长的 "哈 ------" 声
老王看了看时间,已经9点了!
"卧槽,赶紧回家,再晚一点赶不上末班车了!"

yml
*cannel中间件咋用?快教教老王!!!*