缓存双写一致性之更新策略

一、问题的引入

在redis面试的时候,一般会遇到如下问题:

  1. 只要使用了缓存,就会涉及到redis缓存和数据库双存储双写,只要是双写,就一定有数据一致性的问题,那么针对数据一致性的问题如何解决;
  2. 双写一致性在实施的时候是先动缓存,还是MySQL数据库,哪一个?
  3. 在生产中遇到这么一种情况,微服务查询redis没有数据,而mysql有数据,为了保证数据双写一致性回写redis你需要注意什么?
  4. 双检加锁策略你了解吗?如何避免缓存击穿?
  5. redis和MySQL双写100%会出问题,做不强一致性,如何保证最终一致性?

二、缓存双写一致性说明

1.如果redis中有数据,需要和数据库中的值相同

2.如果redis中无数据,数据库中的值要是最新值,且准备回写redis

3.缓存按照操作来分,分为两种:

3.1.只读缓存

3.2.读写缓存

(1).同步直写缓存:
  • 写数据库后也同步写入到redis缓存,缓存和数据库中的数据一致
  • 对于读写缓存来说,要想保证缓存和数据库中的数据一致,就要采用同步直写策略
(2).异步缓写策略
  • 正常业务运行中,MySQL数据变动了,但是可以在业务上容许出现一定时间后才作用于redis,例如:仓储物流
  • 异常情况出现了,不得不将失败的动作重新修补,有可能需要借助kafka或者rebbitMQ等中间件,实现重试重写

4.双检加锁策略

4.1.业务问题,下面的操作怎么实现?

1.输入redis有数据,则直接从redis读取数据;
2.redis没有数据,则需要从MySQL获取数据;
3.步骤二从MySQL读取数据完成后,将MySQL数据回写进入redis;

4.2.双检加锁策略实现上述操作?

多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个 互斥锁来锁住它。其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存。

案例代码如下:下面的findUserById2方法就是采用的双检加锁

便笺
package com.augus.redis.service;

import com.augus.redis.entities.User;
import com.augus.redis.mapper.UserMapper;
import io.swagger.models.auth.In;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.PathVariable;

import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;

@Service
@Slf4j
public class UserService {
    public static final String CACHE_KEY_USER = "user:";
    @Resource
    private UserMapper userMapper;
    @Resource
    private RedisTemplate redisTemplate;

    /**
     * 对于公司(QPS《=1000)可以使用,但是大厂则是不行
     * @param id
     * @return
     */
    public User findUserById(Integer id)
    {
        User user = null;
        String key = CACHE_KEY_USER+id;

        //1 先从redis里面查询,如果有直接返回结果,如果没有再去查询mysql
        user = (User) redisTemplate.opsForValue().get(key);

        if(user == null)
        {
            //2 redis里面无,继续查询mysql
            user = userMapper.selectByPrimaryKey(id);
            if(user == null)
            {
                //3.1 redis+mysql 都无数据
                //这里需要在具体细化,防止多次穿透,业务规定,记录下导致穿透的这个key回写redis
                return user;
            }else{
                //3.2 mysql有,需要将数据写回redis,保证下一次的缓存命中率
                redisTemplate.opsForValue().set(key,user);
            }
        }
        return user;
    }


    /**
     * 加强补充,避免突然key失效了,导致mysql宕机,做一下预防,尽量不出现击穿的情况。
     * @param id
     * @return
     */
    public User findUserById2(Integer id)
    {
        User user = null;
        String key = CACHE_KEY_USER+id;

        //1 先从redis里面查询,如果有直接返回结果,如果没有再去查询mysql,
        // 第1次查询redis,加锁前
        user = (User) redisTemplate.opsForValue().get(key);
        if(user == null) {
            //2对于高QPS的优化,进来就先加锁,保证一个请求操作,让外面的redis等待一下,避免击穿mysql
            synchronized (UserService.class){
                //第2次查询redis,加锁后
                user = (User) redisTemplate.opsForValue().get(key);
                //3 二次查redis还是null,可以去查mysql了(mysql默认有数据)
                if (user == null) {
                    //4 查询mysql拿数据(mysql默认有数据)
                    user = userMapper.selectByPrimaryKey(id);
                    if (user == null) {
                        return null;
                    }else{
                        //5 mysql里面有数据的,需要回写redis,完成数据一致性的同步工作
                        redisTemplate.opsForValue().setIfAbsent(key,user,7L,TimeUnit.DAYS);
                    }
                }
            }
        }
        return user;
    }

}

三、数据库和缓存一致性的更新策略

3.1.数据库和缓存一致性的更新策略的作用是什么?

目的在于保证数据的一致性,给缓存设置过期时间,定期清理缓存并回写,是保证最终一致性的解决方案。

可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。也就是说如果数据库写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存,达到一致性,切记,要以mysql的数据库写入库为准。

上述方案和后续落地案例是调研后的主流+成熟的做法,但是考虑到各个公司业务系统的差距,不是100%绝对正确,不保证绝对适配全部情况。

3.2.数据更新的策略

3.2.1.策略选择

数据更新策略采用的思路是:DB拥有数据正确性的唯一解释权,所以一定要先操作DB,否则大概率出现并发问题。唯一可用策略:先更新数据库,后删除缓存

  • 为了防止删缓存失败,引入MQ等中间件保障删缓存的一致性。
  • 如果真的要求一致性应该考虑是否真的引入缓存。
  • 如果真的需要引入缓存,只能通过分布式锁来解决。
  • 常见的分布式事务问题,只要保证最终的数据一致性即可,例如:充值花费,先短信通知充值成功,在过几分钟只要充值成功就可以。

3.2.2.上述策略(先更新数据库,后删除缓存)的解决方案:

实现思路工作流程如下:

  1. 更新数据库中数据;
  2. 数据库会将数据的变更操作信息写入binlog日志当中;
  3. 订阅程序提取出所需要的数据以及key;
  4. 另起一段非业务代码,获得该信息;
  5. 尝试删除缓存操作,发现删除失败;
  6. 将这些信息发送至消息队列;
  7. redis重新从消息队列中获得该数据,重试操作。

上述解决方案需要注意的内容:

  • 可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用Kafka/RabbitMQ等)。
  • 当程序没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。
  • 如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致了,否则还需要再次进行重试
  • 如果重试超过的一定次数后还是没有成功,我们就需要向业务层发送报错信息了,通知运维人员。

3.2.3.canal

上述的订阅binlog程序在mysql中有现成的中间件叫canal,可以完成订阅binlog日志的功能。参考地址:https://github.com/alibaba/canal/wiki/,它可以监听MySQLbinlog日志中记录的数据变更,如果发现MySQL中数据发生了变化,立即写入到redis中

四.canal

4.1.1.canal是什么?

canal,主要用途是用于 MySQL 数据库增量日志数据的订阅、消费和解析,是阿里巴巴开发并开源的,采用Java语言开发;早期阿里巴巴因为杭州和美国双机房部署,存在跨机房数据同步的业务需求,实现方式主要是基于业务 trigger(触发器) 获取增量变更。从2010年开始,阿里巴巴逐步尝试采用解析数据库日志获取增量变更进行同步,由此衍生出

4.1.2.canal能做什么?

  • 数据库镜像
  • 数据库实时备份
  • 索引构建和实时维护(拆分异构索引、倒排索引等)
  • 业务cache刷新
  • 带业务逻辑的增量数据处理

4.1.3.canal工作原理

工作原理

  • canal 模拟 MySQL slave 的交互协议,伪装自己为 MySQL slave ,向 MySQL master 发送 dump 协议
  • MySQL master 收到 dump 请求,开始推送 binary log 给 slave (即 canal )
  • canal 解析 binary log 对象(原始为 byte 流)

五、MySQL、canal和redis实现实现双写一致性

5.1.MySQL设置

这里推荐大家使用高版本的MySQL,本次案例使用的是MySQL8.0版本,

5.1.1.开启MySQL的binlog写入功能

在 MySQL配置文件 my.ini 中添加如下内容:

log-bin=mysql-bin #开启 binlog
binlog-format=ROW #选择 ROW 模式
server_id=1    #配置MySQL replaction需要定义,不要和canal的 slaveId重复

对于模式选择说明如下:

  • ROW模式: 除了记录sql语句之外,还会记录每个字段的变化情况,能够清楚的记录每行数据的变化历史,但会占用较多的空间。
  • STATEMENT模式:只记录了sql语句,但是没有记录上下文信息,在进行数据恢复的时候可能会导致数据的丢失情况;
  • MIX模式:比较灵活的记录,理论上说当遇到了表结构变更的时候,就会记录为statement模式。当遇到了数据更新或者删除情况下就会变为row模式;

如下图:

5.1.2.查看是否开启了MySQL的binlog写入功能

上面配置完成后需要重启MySQL服务,然后再执行命令查看:

SHOW VARIABLES LIKE 'log_bin';

如下图:ON表示已经打开

5.1.3.创建MySQL账户canal

默认是没有canal账户的,创建账户授权,用于canal连接MySQL,创建的SQL语句如下:

DROP USER IF EXISTS 'canal'@'%';
CREATE USER 'canal'@'%' IDENTIFIED BY 'canal';
GRANT ALL PRIVILEGES ON
*.* TO 'canal'@'%' IDENTIFIED BY 'canal'; FLUSH PRIVILEGES;

创建后查询是否创建该账户:

SELECT * FROM mysql.user;

如下图所示:

5.2.canal服务端配置

5.2.1.下载

下载地址如下:https://github.com/alibaba/canal/releases

 

5.2.2.解压

在Linux根目录下创建 /mycanal 目录,将下载下来的文件上传,然后解压后如下:

5.2.3.配置

修改/mycanal/conf/example路径下instance.properties文件内容,需要设置有如下内容:

  • 设置连接的MySQL的master主机的ip地址
  • 设置连接MySQL的账户名和密码

5.2.4.启动

 进入到 /mycanal/bin 目录下,执行如下命令进行启动:

5.2.5.查看

判断canal是否启动成功,查看server日志:

查看样例example日志:

5.3.canal客户端实现

5.3.1.创建表

在MySQL中db2022库中创建表 t_user,代码如下:

DROP TABLE IF EXISTS `t_user`;
CREATE TABLE `t_user`  (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `userName` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
  `passwd` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 18 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = COMPACT;

5.3.2.创建springboot模块

在项目中创建springboot模块,操作如下:

设置模块信息:

 设置模块名称:

5.3.3.导入依赖

在pom中导入依赖如下:

<properties>
        <java.version>1.8</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
        <junit.version>4.12</junit.version>
        <log4j.version>1.2.17</log4j.version>
        <lombok.version>1.16.18</lombok.version>
        <mysql.version>5.1.47</mysql.version>
        <druid.version>1.1.16</druid.version>
        <mapper.version>4.1.5</mapper.version>
        <mybatis.spring.boot.version>1.3.0</mybatis.spring.boot.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

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

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <!--canal-->
        <dependency>
            <groupId>com.alibaba.otter</groupId>
            <artifactId>canal.client</artifactId>
            <version>1.1.4</version>
        </dependency>

        <!--swagger2-->
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
            <version>2.9.2</version>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
            <version>2.9.2</version>
        </dependency>
        <!--SpringBoot与Redis整合依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>

        <!--Mysql数据库驱动-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.47</version>
        </dependency>
        <!--SpringBoot集成druid连接池-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.1.10</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid</artifactId>
            <version>${druid.version}</version>
        </dependency>
        <!--mybatis和springboot整合-->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>${mybatis.spring.boot.version}</version>
        </dependency>

        <!--jedis-->
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
            <version>3.8.0</version>
        </dependency>
    </dependencies>

5.3.4.修改配置文件

在 application.yml 配置文件中添加内容如下:

server:
  port: 5556

# ========================druid=====================
spring:
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://localhost:3306/db2022?useUnicode=true&characterEncoding=utf-8&useSSL=false
    username: root
    password: 123456
    druid:
      test-while-idle: false

5.3.5.创建配置类

创建包 utils在下面创建  RedisUtils 类,用于连接操作redis,代码如下:

package com.canal.demo.utils;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

public class RedisUtils {
    public static final String  REDIS_IP_ADDR = "192.168.42.132";
    public static final String  REDIS_pwd = "123456";
    public static JedisPool jedisPool;

    static {
        JedisPoolConfig jedisPoolConfig=new JedisPoolConfig();
        jedisPoolConfig.setMaxTotal(20);
        jedisPoolConfig.setMaxIdle(10);
        jedisPool=new JedisPool(jedisPoolConfig,REDIS_IP_ADDR,6379,10000,REDIS_pwd);
    }

    public static Jedis getJedis() throws Exception {
        if(null!=jedisPool){
            return jedisPool.getResource();
        }
        throw new Exception("Jedispool is not ok");
    }
}

5.3.6.创建canal配置类

创建包 biz 在下面创建  RedisCanalClientExample 类,用于连接操作redis,代码如下:

package com.canal.demo.biz;

import com.alibaba.fastjson.JSONObject;
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;
import com.alibaba.otter.canal.protocol.CanalEntry.*;
import com.alibaba.otter.canal.protocol.Message;
import com.canal.demo.utils.RedisUtils;
import redis.clients.jedis.Jedis;
import java.net.InetSocketAddress;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

public class RedisCanalClientExample {
    public static final Integer _60SECONDS = 60;
    public static final String  REDIS_IP_ADDR = "192.168.42.132";

    private static void redisInsert(List<Column> columns)
    {
        JSONObject jsonObject = new JSONObject();
        for (Column column : columns)
        {
            System.out.println(column.getName() + " : " + column.getValue() + "    update=" + column.getUpdated());
            jsonObject.put(column.getName(),column.getValue());
        }
        if(columns.size() > 0)
        {
            try(Jedis jedis = RedisUtils.getJedis())
            {
                jedis.set(columns.get(0).getValue(),jsonObject.toJSONString());
            }catch (Exception e){
                e.printStackTrace();
            }
        }
    }


    private static void redisDelete(List<Column> columns)
    {
        JSONObject jsonObject = new JSONObject();
        for (Column column : columns)
        {
            jsonObject.put(column.getName(),column.getValue());
        }
        if(columns.size() > 0)
        {
            try(Jedis jedis = RedisUtils.getJedis())
            {
                jedis.del(columns.get(0).getValue());
            }catch (Exception e){
                e.printStackTrace();
            }
        }
    }

    private static void redisUpdate(List<Column> columns)
    {
        JSONObject jsonObject = new JSONObject();
        for (Column column : columns)
        {
            System.out.println(column.getName() + " : " + column.getValue() + "    update=" + column.getUpdated());
            jsonObject.put(column.getName(),column.getValue());
        }
        if(columns.size() > 0)
        {
            try(Jedis jedis = RedisUtils.getJedis())
            {
                jedis.set(columns.get(0).getValue(),jsonObject.toJSONString());
                System.out.println("---------update after: "+jedis.get(columns.get(0).getValue()));
            }catch (Exception e){
                e.printStackTrace();
            }
        }
    }

    public static void printEntry(List<Entry> entrys) {
        for (Entry entry : entrys) {
            if (entry.getEntryType() == EntryType.TRANSACTIONBEGIN || entry.getEntryType() == EntryType.TRANSACTIONEND) {
                continue;
            }

            RowChange rowChage = null;
            try {
                //获取变更的row数据
                rowChage = RowChange.parseFrom(entry.getStoreValue());
            } catch (Exception e) {
                throw new RuntimeException("ERROR ## parser of eromanga-event has an error,data:" + entry.toString(),e);
            }
            //获取变动类型
            EventType eventType = rowChage.getEventType();
            System.out.println(String.format("================&gt; binlog[%s:%s] , name[%s,%s] , eventType : %s",
                    entry.getHeader().getLogfileName(), entry.getHeader().getLogfileOffset(),
                    entry.getHeader().getSchemaName(), entry.getHeader().getTableName(), eventType));

            for (RowData rowData : rowChage.getRowDatasList()) {
                if (eventType == EventType.INSERT) {
                    redisInsert(rowData.getAfterColumnsList());
                } else if (eventType == EventType.DELETE) {
                    redisDelete(rowData.getBeforeColumnsList());
                } else {//EventType.UPDATE
                    redisUpdate(rowData.getAfterColumnsList());
                }
            }
        }
    }


    public static void main(String[] args)
    {
        System.out.println("--------- main方法-----------");

        //=================================
        // 创建链接canal服务端
        /**
         * REDIS_IP_ADDR 这个是redis的服务器ip
         * 11111 则是一个没有被占用的端口号即可
         */
        CanalConnector connector = CanalConnectors.newSingleConnector(new InetSocketAddress(REDIS_IP_ADDR,
                11111), "example", "", "");
        int batchSize = 1000;
        //空闲空转计数器
        int emptyCount = 0;
        System.out.println("---------------------canal 初始化完成,开始监听mysql------");
        try {
            connector.connect();
            //connector.subscribe(".*\\..*");
            connector.subscribe("db2022.t_user"); //需要监听的是那个表
            connector.rollback();
            int totalEmptyCount = 10 * _60SECONDS;
            while (emptyCount < totalEmptyCount) {
                System.out.println("我是canal,每秒一次正在监听:"+ UUID.randomUUID().toString());
                Message message = connector.getWithoutAck(batchSize); // 获取指定数量的数据
                long batchId = message.getId();
                int size = message.getEntries().size();
                if (batchId == -1 || size == 0) {
                    emptyCount++;
                    try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
                } else {
                    //计数器重新置零
                    emptyCount = 0;
                    printEntry(message.getEntries());
                }
                connector.ack(batchId); // 提交确认
                // connector.rollback(batchId); // 处理失败, 回滚数据
            }
            System.out.println("已经监听了"+totalEmptyCount+"秒,无任何消息,请重启重试......");
        } finally {
            connector.disconnect();
        }
    }
}

5.3.7.测试

启动redis和canal,确保连通,然后启动springboot模块,执行上面的main方法,如下图:

打开Navicat ,连接MySQL数据,在t_user表中添加数据,后查看idea控制台,会显示本次的操作以及对应的数据:

查看Redis,发现当MySQL中出现数据变化的时候,canal会监控该变化,将其写入到redis中去:

补充java程序下connector.subscribe配置的过滤正则

posted @ 2023-03-29 21:14  酒剑仙*  阅读(202)  评论(0)    收藏  举报