背景
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
配置
- 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方法,接来下就是愉快的测试了;
测试
- 在数据库进行增删该查,建表操作,你会发现在程序的控制台会有如下日志打印,说明Canal 已经订阅到了mysql 的binlog日志信息;
- 观察redis 缓存,会发现,数据已经写入了缓存;
总结
基于Canal 实现redis 和mysql 的一致性,效果上优于延时双删策略的;基于mysql binlog 日志的思想其实可以用到很多地方,比如不同库之间的数据同步;