侧边栏壁纸
博主头像
CoderKim

一名后端开发工程师,喜欢学习和分享。

  • 累计撰写 50 篇文章
  • 累计创建 75 个标签
  • 累计收到 7 条评论

目 录CONTENT

文章目录

CRUD好像也没有那么简单2️⃣——加入缓存并保障数据一致性

CoderKim
2023-07-13 / 0 评论 / 0 点赞 / 453 阅读 / 2,408 字 / 正在检测是否收录...

前言

CRUD 是指 Create、Read、Update、Delete 这四个单词的缩写。后端程序员经常被称为“CRUD Boy”,因为很多工作无非就是增删改查,技术含量较低。
最近工作中遇到一个标准的 CRUD 需求,简单来说就是管理员往运营平台上添加一些快捷按钮,用户就可以在客户端页面上查看并点击这些快捷按钮了。
是不是很简单,就是对快捷按钮这个对象类进行增删改查操作,没有什么难度。

引入缓存

那么,我们便来加点难度。
我们都知道从数据库获取数据是一个比较耗时的操作,那如何让用户更快获取到数据呢?
对,答案就是引入缓存机制,从缓存中获取数据耗时会降低很多,能大大提升用户体验。
通常数据库用的 MySQL,缓存用的是 Redis,本文也采用这样的技术栈进行介绍,当然选用其他数据库和缓存也可以,原理是相同的。
然而,如何才能保障 MySQL 和 Redis 的数据一致性呢?

保障一致性

1. 先写 Redis,再写 MySQL

首先想到的就是,将数据在 Redis 和 MySQL 中都写一遍,我们来画个图看看这种方式可能出现的问题。
先写Redis后写MySQL

这是一副时序图,描述了请求的先后调用顺序。
请求 A、B 都是先写 Redis,然后再写 MySQL,在高并发情况下,如果请求 A 在写 MySQL 时卡了一会儿,请求 B 已经依次率先完成了数据的更新,就会出现 Redis 和 MySQL 数据不一致的问题。
此方案不可用,那么换个写入顺序试试。

2. 先写 MySQL,再写 Redis

先写MySQL后写Redis

如图,请求 A 有可能在写 Redis 时卡顿,造成最终不一致的情况。

看来两种先后写入数据库和缓存的方案都不可用,我们得考虑一下其他方案。
其实写入缓存这个操作不一定要在操作数据库前后进行,也可以在用户读取数据时进行。
具体来说,对于读请求,先去读 Redis,如果有就直接返回;如果没有,再去读 MySQL,之后将数据回写入 Redis
那么我们就可以在操作 MySQL 前或后,先将 Redis 删掉,等待读请求去回写。于是就有了以下两种方案。

3. 先删 Redis,再写 MySQL

先删Redis后写MySQL
我们假设现在 MySQL 中的数据是 10,请求 A 要将此数据更新为 11。请求 B 是一个读请求。
请求 A 先删除缓存,可能因为卡顿,请求 B 完成查询后,请求 A 的数据才更新到 MySQL,导致 MySQL 和 Redis 的数据不一致。
这种情况出现的概率比较大,因为一般情况下查询时长小于更新时长。请求 A 是更新操作,可能耗时会比较长,而请求 B 的前两步都是查询,会非常快。
此方案也不可用,那么换个顺序再看看。

4. 先写 MySQL,再删 Redis

先写MySQL后删Redis1
假设请求 B 的第一次查询发生在请求 A 更新完 MySQL 但还未删除缓存时,请求 B 查询到的数据是 10,但是 MySQL 的数据已经更新为 11了,发生了不一致的情况。
但只存在这一次不一致的情况,对于不是强一致性要求的业务,可以容忍。(那什么情况下不能容忍呢,比如秒杀业务、库存服务等。)

当请求 B 进行第二次查询时,因为没有命中 Redis,会重新查一次 DB,然后再回写到 Reids。此时 Redis 和 MySQL 中的数据一致,也就是说,这种方式保障了数据的最终一致性。

还有以下这种情况也会导致不一致:
先写MySQL后删Redis2
请求 B 查询时,缓存已经超时失效,所以从 MySQL 中查询,之后回写 Redis 用时较长,回写前请求 A 已经执行完了更新 MySQL 操作和删除 Redis 操作了,从而导致了数据不一致情况的发生。
我们都知道更新操作肯定比查询耗时要长,请求 A 更新MySQL + 删除 Redis 的耗时大概率大于请求 B 查询和回写的时间,所以出现这个情况的概率很小,同时满足缓存超时失效的概率就更小了。

这个方案总算能满足需求了,开始愉快的编码吧。

编码

添加依赖和配置

pom.xml 中添加 Redis 相关依赖

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <!-- 对象池,使用redis时引入 -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>

在配置文件中写上 Redis 的连接信息

spring:
  redis:
    host: 127.0.0.1
    password: 123456
    # 连接超时时间(记得添加单位,Duration)
    timeout: 10000ms
    # Redis默认情况下有16个分片,这里配置具体使用的分片
    database: 0
    lettuce:
      pool:
        # 连接池最大连接数(使用负值表示没有限制) 默认 8
        max-active: 8
        # 连接池最大阻塞等待时间(使用负值表示没有限制) 默认 -1
        max-wait: -1ms
        # 连接池中的最大空闲连接 默认 8
        max-idle: 8
        # 连接池中的最小空闲连接 默认 0
        min-idle: 0

数据更新

在Service层实现类中的新增、修改、删除方法后都加上删除redis的代码。
同时,需要加上事务,如果 Redis 删除失败,MySQL 的更新操作也需要回滚,避免查询时读取到脏数据。

    /**
     * 新增数据
     *
     * @param quickButton 实例对象
     * @return 实例对象
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public QuickButton insert(QuickButton quickButton) {
        // 先写MySQL
        this.quickButtonDao.insert(quickButton);
        // 后删Redis
        redisManager.delete(REDIS_KEY);
        return quickButton;
    }

    /**
     * 修改数据
     *
     * @param quickButton 实例对象
     * @return 实例对象
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public QuickButton update(QuickButton quickButton) {
        // 先写MySQL
        this.quickButtonDao.update(quickButton);
        // 后删Redis
        redisManager.delete(REDIS_KEY);
        return this.queryById(quickButton.getId());
    }

    /**
     * 通过主键删除数据
     *
     * @param id 主键
     * @return 是否成功
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean deleteById(Integer id) {
        // 先写MySQL
        boolean result = this.quickButtonDao.deleteById(id) > 0;
        // 后删Redis
        redisManager.delete(REDIS_KEY);
        return result;
    }

数据获取

先去读 Redis,如果有就直接返回;如果没有,再去读 MySQL,之后将数据回写入 Redis。
此外,在写入 Redis 时,为了兜底,我们加了一个过期时间。这样万一两者不一致,缓存过期后,数据会重新更新到缓存。

    public List<QuickButton> queryAll() {
        // 先查Redis
        String buttonListStr = redisManager.get(REDIS_KEY);
        if (StrUtil.isNotEmpty(buttonListStr)) {
            // 如果有,直接返回
            return JSONUtil.toBean(buttonListStr, new TypeReference<List<QuickButton>>() {}, false);
        } else {
            // 没有,从MySQL中查
            List<QuickButton> buttonList = this.quickButtonDao.queryAll();
            // 写入Redis,并设置过期时间
            redisManager.set(REDIS_KEY, JSONUtil.toJsonStr(buttonList), 10, TimeUnit.MINUTES);
            return buttonList;
        }
    }

测试

先给出代码:

    @Test
    public void save(){
        QuickButton quickButton = new QuickButton();
        quickButton.setTitle("LLM");
        quickButton.setDescription("语言大模型");
        quickButton.setCategoryId(0);
        quickButton.setUrl("https://openai.com");
        quickButtonService.insert(quickButton);
        log.info("入库成功:{}", quickButton);
    }

    @Test
    public void query(){
        log.info("查询结果:{}",quickButtonService.queryAll());
    }

我们先执行一下 save 方法:
image-1689388159061
Redis里此时没有缓存。
再来执行一下 query 方法:
image-1689388348579
查询 Redis, 发现已经写入了:
image-1689388431806

用AOP技术优化代码

上面的代码已经实现了我们想要的功能,但是不够完美。
Service 实现类中每个对数据库进行改动的方法都加了删除 Redis 的操作。本例作为演示,已经有了3行重复代码,实际项目中,对数据库改动的方法远大于3个,会造成大量冗余代码。
那怎么办呢,这里我使用了Spring AOP,执行完操作 DB 的方法后,在切面中执行删除 Redis 操作。

@Aspect
@Component
public class RedisDeleteAspect {

    public static final String REDIS_KEY = "QUICK_BUTTON:ALL";
    @Resource
    private RedisUtil redisManager;

    @Pointcut("execution(* com.xuqiming.cache.service.impl.QuickButtonServiceImpl.insert*(..)) "
        + "|| execution(* com.xuqiming.cache.service.impl.QuickButtonServiceImpl.update*(..)) "
        + "|| execution(* com.xuqiming.cache.service.impl.QuickButtonServiceImpl.delete*(..))")
    public void redisDeletePointcut() {}

    @After("redisDeletePointcut()")
    public void deleteFromRedis() {
        // 删除Redis操作
        redisManager.delete(REDIS_KEY);
    }
}

总结

经过以上分析,我们得出一条结论:在满足实时性的条件下,不存在两者完全保持一致的方案,只有最终一致性方案。
采用“先写 MySQL,再删除 Redis”的策略,这种情况虽然也会存在两者不一致,但是发生的概率非常低,所以是满足实时性条件下,能尽量满足一致性的最优解。
还有一些其他优秀的方案,如“缓存双删”、“监听 MySQL 的 Binlog 异步更新 Redis”等,感兴趣的读者可查看文末的参考资料进行学习。

参考资料

  1. 如何保障 MySQL 和 Redis 的数据一致性?—— 作者:楼仔
0

评论区