redis-缓存-双写一致性

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中间件咋用?快教教老王!!!*
相关推荐
原来是好奇心5 分钟前
用户登录Token缓存Redis实践:提升SpringBoot应用性能
spring boot·redis·缓存
做一个AC梦12 分钟前
MiniOB环境部署开发(使用Docker)
数据库·sql·miniob·ob·海扬数据库
ALLSectorSorft2 小时前
搭子交友 app 动态分享与打卡系统设计实现
java·服务器·数据库·人工智能·oracle·交友
wuyunhang1234562 小时前
Redis---事务
数据库·redis·缓存
满目8282 小时前
MongoDB 从入门到实践:全面掌握文档型 NoSQL 数据库核心操作
数据库·mongodb·非关系型数据库·文档型数据库·基础查询命令·进阶查询
Tacy02132 小时前
Redis 安装教程
数据库·redis·缓存
smilejingwei4 小时前
数据分析编程第二步: 最简单的数据分析尝试
数据库·算法·数据分析·esprocspl
bing.shao5 小时前
gRPC 选型 etcd 的核心优势分析
数据库·微服务·云原生·golang·etcd
TDengine (老段)6 小时前
TDengine IDMP 应用场景:微电网监控
大数据·数据库·物联网·ai·时序数据库·tdengine·涛思数据