面试官:如何实现Redis和Mysql 保持数据强一致性?不知道啊,回去等消息吧

背景

Redis 缓存如何保证和mysql 的数据一致性,算是在面试过程中一个老生常谈的问题,什么?你不知道,那回去等通知吧! 其实这个问题,不管是先删除缓存在修改数据;还是先修改数据在更新缓存都是存在问题的;

场景一:先删除缓存在修改数据

这种方式在并发量小的时候是没有问题的,如果在高并发量的环境下,删除缓存,还没有完成写库,另一个请求来了,发现缓存为空,从数据库获取数据然后更新缓存,那么这个时候缓存中的数据其实是脏数据;

场景二: 先修改数据后删缓存

这种方式,主要是极端情况下,已经完成了数据库写库,但是恰巧线程宕掉了,此时缓存和数据库就没有保持一致性

解决方案

1. 延时双删策略

伪代码

redis.del(key);
db.update(data);
Thread.sleep(100);
redis.del(key);

这个延时的时间,需要根据具体业务中,读库的时间进行确定,还需要考虑到数据库及redis 主从同步的时间;
优点: 操作比较简单,一定程度可以保证缓存和db 数据一致性;
缺点: 在休眠时间内数据存在不一致,而且又增加了写请求的耗时。

2. 基于mysql binlog 日志进行异步更新缓存

基于mysql binlog 的日志进行分析,mysql 的操作都会记录在binlog 日志中,所以进行分析binlog日志中的行为,然后根据不同的行为及业务进行异步更新缓存,可以保证redis 缓存和mysql 数据库的强一致性;
目前mysql binlog 分析工具挺多:
开源工具:mysql-binlog-connector-java
Ali Canal: Canal
本文主要是基于Ali Canal 进行实现mysql 和redis 数据一致性;

Canal 订阅实现redis 和mysql 数据一致性

环境

操作系统: windows
mysql: mysql-8.0.11
jdk:1.8
redis:3.2.100
Canal:1.1.5

配置
  1. mysql:
    mysql-8.0.11 默认开启了binlog日志,所以不需要进行手动开启配置,但是需要指定当前机器的服务id,及server-id.
    配置如下:
[mysqld]
# 设置3306端口
port=3306
# 设置mysql的安装目录
basedir=D:\mysql
# 设置mysql数据库的数据的存放目录
datadir=D:\mysql\Data
# 允许最大连接数
max_connections=200
# 允许连接失败的次数。
max_connect_errors=10
# 服务端使用的字符集默认为utf8mb4
character-set-server=utf8mb4
# 创建新表时将使用的默认存储引擎
default-storage-engine=INNODB
# 默认使用“mysql_native_password”插件认证
#mysql_native_password
default_authentication_plugin=mysql_native_password
#mysql server-id
server-id=1  //当前机器的服务id,集群环境,id不能重复
注: mysql-5.x 系列,需要手动修改配置文件开启binlog日志,查询是是否开启binlog语句为:
**show variables like '%log_bin%';**
结果如下: log_bin 为on表示开启了binlog日志

在这里插入图片描述
2. Canal
a. 下载: 下载canal.deployer-1.1.5-SNAPSHOT.tar.gz:Canal安装包
b. 配置: 解压后,打开\conf\example 下的instance.properties 配置文件,主要找到下面属性进行修改,修改如下:

## mysql serverId , v1.0.26+ will autoGen
## 1,指定机器的服务id ,该id不能和mysql 端一致
canal.instance.mysql.slaveId=2    
# position info
## 2.指定mysql 的ip及端口
canal.instance.master.address=localhost:3306
# username/password
# 3.需要订阅数据库的用户名,密码,编码格式,以及默认的数据库;注:
canal.instance.dbUsername=xxx
canal.instance.dbPassword=xxx
canal.instance.connectionCharset = UTF-8
canal.instance.defaultDatabaseName =xxx

c. 启动: 点击 \bin\startup.bat 进行启动,启动后观察\logs\example 下的日志文件输出以下内容才算Canal启动成功:
在这里插入图片描述
到此,Canal 进行订阅mysql 的binlog日志配置就配置完毕了,下面就需要程序端连接Canal ,进行获取binlog 日志详情进行业务操作;

编码

逻辑: 启动Canal客户端获取binlog日志详情信息–> 逻辑判断–>缓存
Canal Client实现:

import java.net.InetSocketAddress;
import java.util.List;

import com.alibaba.fastjson.JSONObject;
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.common.utils.AddressUtils;
import com.alibaba.otter.canal.protocol.Message;
import com.alibaba.otter.canal.protocol.CanalEntry.Column;
import com.alibaba.otter.canal.protocol.CanalEntry.Entry;
import com.alibaba.otter.canal.protocol.CanalEntry.EntryType;
import com.alibaba.otter.canal.protocol.CanalEntry.EventType;
import com.alibaba.otter.canal.protocol.CanalEntry.RowChange;
import com.alibaba.otter.canal.protocol.CanalEntry.RowData;
import com.alibaba.otter.canal.client.*;

public class CanalClient{
    public static void main(String args[]) {
        CanalConnector connector = CanalConnectors.newSingleConnector(new InetSocketAddress(AddressUtils.getHostIp(),
                11111), "example", "", "");
        int batchSize = 1000;
        try {
            connector.connect();
            connector.subscribe(".*\\..*");
            connector.rollback();
            while (true) {
                Message message = connector.getWithoutAck(batchSize); // 获取指定数量的数据
                long batchId = message.getId();
                int size = message.getEntries().size();
                try {
                    if(batchId == -1 || size ==0){
                        // 未获取binlog 日志信息,睡眠1s
                        Thread.sleep(1000);
                    }else {
                        printEntry(message.getEntries());
                    }
                    connector.ack(batchId); // 提交确认
                }catch (Exception e){
                    connector.rollback(batchId); // 处理失败, 回滚数据
                }

            }
        } finally {
            connector.disconnect();
        }
    }

    /**
     *@描述 业务操作类
     *@参数
     *@返回值
     *@创建人  zj
     *@创建时间  2020/5/27
     */
    private static void printEntry( List<Entry> entrys) {
        for (Entry entry : entrys) {
            if (entry.getEntryType() == EntryType.TRANSACTIONBEGIN || entry.getEntryType() == EntryType.TRANSACTIONEND) {
                continue;
            }
            RowChange rowChage = null;
            try {
                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("================> 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.DELETE) {
                    // 数据删除
                    redisDelete(rowData.getBeforeColumnsList(),entry.getHeader().getTableName());
                } else if (eventType == EventType.INSERT) {
                    // 数据添加
                    redisInsert(rowData.getAfterColumnsList(),entry.getHeader().getTableName());
                } else {
                    // 数据修改
                    System.out.println("-------> before");
                    printColumn(rowData.getBeforeColumnsList());
                    System.out.println("-------> after");
                    redisUpdate(rowData.getAfterColumnsList(),entry.getHeader().getTableName());
                }
            }
        }
    }

    private static void printColumn( List<Column> columns) {
        for (Column column : columns) {
            System.out.println(column.getName() + " : " + column.getValue() + "    update=" + column.getUpdated());
        }
    }

    private static void redisInsert( List<Column> columns,String tableName){
        JSONObject json=new JSONObject();
        for (Column column : columns) {
            json.put(column.getName(), column.getValue());
        }
        if(columns.size()>0){
            RedisUtil.stringSet(tableName+":"+ columns.get(0).getValue(),json.toJSONString());
        }
    }

    private static  void redisUpdate( List<Column> columns,String tableName){
        JSONObject json=new JSONObject();
        for (Column column : columns) {
            json.put(column.getName(), column.getValue());
        }
        if(columns.size()>0){
            RedisUtil.stringSet(tableName+":"+ columns.get(0).getValue(),json.toJSONString());
        }
    }

    private static  void redisDelete( List<Column> columns,String tableName){
        JSONObject json=new JSONObject();
        for (Column column : columns) {
            json.put(column.getName(), column.getValue());
        }
        if(columns.size()>0){
            RedisUtil.delKey(tableName+":"+ columns.get(0).getValue());
        }
    }
}  

redis client 实现

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

public class RedisUtil {

    // Redis服务器IP
    private static String ADDR = "127.0.0.1";

    // Redis的端口号
    private static int PORT = 6379;
    
    // 可用连接实例的最大数目,默认值为8;
    // 如果赋值为-1,则表示不限制;如果pool已经分配了maxActive个jedis实例,则此时pool的状态为exhausted(耗尽)。
    private static int MAX_ACTIVE = 1024;

    // 控制一个pool最多有多少个状态为idle(空闲的)的jedis实例,默认值也是8。
    private static int MAX_IDLE = 200;

    // 等待可用连接的最大时间,单位毫秒,默认值为-1,表示永不超时。如果超过等待时间,则直接抛出JedisConnectionException;
    private static int MAX_WAIT = 10000;

    // 过期时间
    protected static int  expireTime = 60 * 60 *24;

    // 连接池
    protected static JedisPool pool;

    /**
     * 静态代码,只在初次调用一次
     */
    static {
        JedisPoolConfig config = new JedisPoolConfig();
        //最大连接数
        config.setMaxTotal(MAX_ACTIVE);
        //最多空闲实例
        config.setMaxIdle(MAX_IDLE);
        //超时时间
        config.setMaxWaitMillis(MAX_WAIT);
        //
        config.setTestOnBorrow(false);
        pool = new JedisPool(config, ADDR, PORT, 1000);
    }

    /**
     * 获取jedis实例
     */
    protected static synchronized Jedis getJedis() {
        Jedis jedis = null;
        try {
            jedis = pool.getResource();
        } catch (Exception e) {
            e.printStackTrace();
            if (jedis != null) {
                pool.returnBrokenResource(jedis);
            }
        }
        return jedis;
    }

    /**
     * 释放jedis资源
     * @param jedis
     * @param isBroken
     */
    protected static void closeResource(Jedis jedis, boolean isBroken) {
        try {
            if (isBroken) {
                pool.returnBrokenResource(jedis);
            } else {
                pool.returnResource(jedis);
            }
        } catch (Exception e) {

        }
    }

    
    /**
     * 删除key
     * @param key
     */
    public static void delKey(String key) {
        Jedis jedis = null;
        boolean isBroken = false;
        try {
            jedis = getJedis();
            jedis.select(0);
            jedis.del(key);
        } catch (Exception e) {
            isBroken = true;
        } finally {
            closeResource(jedis, isBroken);
        }
    }

    
    /**
     * 添加string数据
     * @param key
     * @param value
     */
    public static String stringSet(String key, String value) {
        Jedis jedis = null;
        boolean isBroken = false;
        String lastVal = null;
        try {
            jedis = getJedis();
            jedis.select(0);
            lastVal = jedis.set(key, value);
            jedis.expire(key, expireTime);
        } catch (Exception e) {
            e.printStackTrace();
            isBroken = true;
        } finally {
            closeResource(jedis, isBroken);
        }
        return lastVal;
    }
}

编码部分结束,首先启动Canal ,然后在执行Canal Client 的main方法,接来下就是愉快的测试了;

测试
  1. 在数据库进行增删该查,建表操作,你会发现在程序的控制台会有如下日志打印,说明Canal 已经订阅到了mysql 的binlog日志信息;
    在这里插入图片描述
  2. 观察redis 缓存,会发现,数据已经写入了缓存;
总结

基于Canal 实现redis 和mysql 的一致性,效果上优于延时双删策略的;基于mysql binlog 日志的思想其实可以用到很多地方,比如不同库之间的数据同步;

  • 1
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值