前置文章: 某网站Redis与MySql同步方案分析
使用Canal的主要目的:让自动同步代替部分手动同步,降低开发人员工作量,避免部分数据一致性问题。
本文主要讲解如何配置Canal,以保证某网站的Redis与MySql的数据自动同步。
下面列出一些本项目的开发原则:
项目名称-模块名称-对象名称-主键id
例如:baidu-news-user-0000000001。
有部分数据需要提前初始化到Redis之中,所以需要提供一个Redis初始化方法redisInit().
对于时效性要求较高的数据,采用手动编码的方式保证Redis与MySql的同步:
对于时效性要求一般的数据,通过Canal的方式保证Redis与MySql的同步:
经过前面的分析,我们已经知道:Canal是根据MySql中的数据表的增量变化情况去更新Redis数据库的。
所以,如果MySql的表结构与Redis的缓存结构一一对应,则我们在编写缓存自动同步方法时,会节省很多工作。
所以,在某网站项目中,我们统一要求:
举例说明缓存中间表的作用:
person[id,number,name]
,分别存储了字段:个人编号、身份证号码和姓名。score[id,score]
,分别存储了字段:个人编号、考试分数。{key:sfzhm,value=score}
,用于通过身份证号码快速查询考试分数,其字段为:身份证号码和考试分数。person[grbh,sfzhm,name]
发生变化。{key:sfzhm,value=score}
。person[grbh,sfzhm,name]
变化的原因可能有多种,与缓存结构{key:sfzhm,value=score}
相关的可能只是一种情况。person_score[sfzhm,score]
person[grbh,sfzhm,name]
发生变化,无需理会。person_score[sfzhm,score]
发生变化,则直接修改Redis中的{key:sfzhm,value=score}
即可。person_score[sfzhm,score]
变化的肯定与{key:sfzhm,value=score}
相关。下面对Canal同步方案的使用方法进行说明,主要分为以下几个步骤:
备注:此说明参考了其他文献,但是当时没有记录转载作者的好习惯,故而这里没有提及。如果有相关建议,请多多指教。
因为Canal是伪装成MySql Slave去收集MySql Master发送的binlog(Binary Logs),所以,需要开启MySql的binlog模式。
切换到mysql的安装路径,找到my.cnf(Linux)/my.ini(windows),加入如下内容:
[mysqld]
log-bin=mysql-bin #添加这一行就ok 开启binary log
binlog-format=ROW #选择row模式 不记录每条sql语句的上下文信息,仅需记录哪条数据被修改了,修改成什么样了
server_id=1 #配置主从复制(mysql replaction)需要定义,不能和canal的slaveId(默认1234)重复
配置完成后,需要重启数据库。
创建canal用户,用来管理canal的访问权限。
# 创建canal用户,用来管理canal的访问权限
CREATE USER canal IDENTIFIED BY 'canal';
# 分配权限
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
FLUSH PRIVILEGES;
# 解除权限
# REVOKE SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* FROM 'canal'@'%';
# FLUSH PRIVILEGES;
https://github.com/alibaba/canal/releases/
配置文件有两个:
一般情况下,canal.properties 文件保持默认配置即可,所以我们仅对instance.properties 进行修改:
## mysql serverId
# Canal伪装成MySql Slave的id
canal.instance.mysql.slaveId = 1234
# position info
canal.instance.master.address = ***.***.***.***:3306 #改成自己的数据库地址 一般情况下只需要修改此处
canal.instance.master.journal.name =
canal.instance.master.position =
canal.instance.master.timestamp =
#canal.instance.standby.address =
#canal.instance.standby.journal.name =
#canal.instance.standby.position =
#canal.instance.standby.timestamp =
# username/password
canal.instance.dbUsername = canal #改成自己的数据库信息 默认即可
canal.instance.dbPassword = canal #改成自己的数据库信息 默认即可
canal.instance.defaultDatabaseName = #改成自己的数据库信息 默认即可
canal.instance.connectionCharset = UTF-8 #改成自己的数据库信息 默认即可
# table regex
canal.instance.filter.regex = .*\\..*
# table black regex
canal.instance.filter.black.regex =
./canal/startup.sh
startup.bat
通过查看logs/canal/canal.log 和logs/example/example.log日志来判断canal是否启动成功。
正常启动情况下,canal/logs/canal/canal.log的内容类似如下:
2017-07-24 16:43:43.983 [main] INFO com.alibaba.otter.canal.deployer.CanalLauncher - ## start the canal server.
2017-07-24 16:43:44.336 [main] INFO com.alibaba.otter.canal.deployer.CanalController - ## start the canal server[130.10.7.37:11111]
2017-07-24 16:43:46.203 [main] INFO com.alibaba.otter.canal.deployer.CanalLauncher - ## the canal server is running now ......
正常启动情况下,canal/logs/example/example.log的内容类似如下:
2017-07-24 16:43:44.976 [main] INFO c.a.o.c.i.spring.support.PropertyPlaceholderConfigurer - Loading properties file from class path resource [canal.properties]
2017-07-24 16:43:45.007 [main] INFO c.a.o.c.i.spring.support.PropertyPlaceholderConfigurer - Loading properties file from class path resource [example/instance.properties]
2017-07-24 16:43:45.236 [main] WARN org.springframework.beans.TypeConverterDelegate - PropertyEditor [com.sun.beans.editors.EnumEditor] found through deprecated global PropertyEditorManager fallback - consider using a more isolated form of registration, e.g. on the BeanWrapper/BeanFactory!
2017-07-24 16:43:45.388 [main] INFO c.a.otter.canal.instance.spring.CanalInstanceWithSpring - start CannalInstance for 1-example
2017-07-24 16:43:45.800 [main] INFO c.a.otter.canal.instance.core.AbstractCanalInstance - subscribe filter change to .*\..*
2017-07-24 16:43:45.801 [main] INFO c.a.otter.canal.instance.core.AbstractCanalInstance - start successful....
如果出现报错信息,请根据报错信息进行调试。
./canal/stop.sh
或者关闭startup.bat
Canal服务的作用是:通过解析binlog读取MySql数据库的增量变化情况。
Canal服务本身并不能进行Redis与MySql的数据同步。
为了实现Redis与MySql的数据同步,我们还需要额外编写同步服务,这个服务的作用如下:
为了便于称呼,我将这个服务命名为RedisAutoSync.
下面分几个介绍RedisAutoSync的重点代码:
RedisAutoSync代码如下:
/**
* Title: Redis自动同步:读取Canal提供的MySql增量数据,进行Redis同步
* @author 韩超 2018/4/2 17:18
*/
public class RedisAutoSync {
//日志
private Logger logger = LoggerFactory.getLogger(RedisAutoSync.class);
/**
* Title: 连接Canal服务并获取增量数据
*
* @author 韩超 2018/4/2 16:31
*/
public void connectAndGetIncrementData() throws UnsupportedEncodingException {
//连接Canal服务--默认参数,无需配置
CanalConnector connector = CanalConnectors.newSingleConnector(new InetSocketAddress(AddressUtils.getHostIp(),
11111), "example", "", "");
//一个批次获取的最大数据量
int batchSize = 1000;
//如果连接有效,则进行增量数据获取
if (connector.checkValid()) {
logger.info("CanalConnector is valid");
try {
//连接
connector.connect();
//订购
connector.subscribe(".*\\..*");
//回滚
connector.rollback();
logger.info("开始获取增量日志,bachSize:{}", batchSize);
while (true) {
// 获取指定数量的数据
Message message = connector.getWithoutAck(batchSize);
//获取批次id
long batchId = message.getId();
//获取数据大小
int size = message.getEntries().size();
if (batchId == -1 || size == 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
//进行数据表筛选和增量数据处理
filterAndProcessIncrementData(message.getEntries());
}
connector.ack(batchId); // 提交确认
// connector.rollback(batchId); // 处理失败, 回滚数据
}
} finally {
connector.disconnect();
}
} else {
logger.info("CanalConnector is invalid!");
}
}
/**
* Title:进行数据表筛选和增量数据处理
*
* @author 韩超 2018/4/2 16:44
*/
private void filterAndProcessIncrementData(List entrys) throws UnsupportedEncodingException {
//并不是数据库的每个表的增量变化都需要进行相应的处理
//自定义一个工具类,用于存储需要监测的数据表
String allowdb = CanalDbConfig.getAllowDb();
//获取数据库名与数据表名的MAP
Map allowTable = CanalDbConfig.getAllowTable();
//对增量数据进行循环处理
for (Entry entry : entrys) {
//如果是事务开始或、事务结束或者查询,则跳过本次循环
if (entry.getEntryType() == EntryType.TRANSACTIONBEGIN || entry.getEntryType() == EntryType.TRANSACTIONEND) {
continue;
}
//定义一行数据变化
RowChange rowChage;
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();
//从增量数据中获取数据库名
String dbname = entry.getHeader().getSchemaName();
//如果存在[运行监测的库和表]相关配置,则先判断数据库名和表名是否正确,然后处理缓存
if (!"".equals(allowdb)) {
//如果是允许监控的数据库
if (dbname.matches(allowdb)) {
//如果是运行监控的表
if (entry.getHeader().getTableName().matches(allowTable.get(dbname))) {
//按策略处理增量数据
dealIncrementDataStrategically(entry, rowChage, eventType);
}
}
} else {
//如果不存在[运行监测的库和表]相关配置,则表示要监测所有库和表
//按策略处理增量数据
dealIncrementDataStrategically(entry, rowChage, eventType);
}
}
}
/**
* Title: 按策略处理增量数据
*
* @author 韩超 2018/4/2 16:52
*/
private void dealIncrementDataStrategically(Entry entry, RowChange rowChage, EventType eventType) throws UnsupportedEncodingException {
String templog = String.format("binlog[%s:%s] , name[%s,%s] , eventType : %s",
entry.getHeader().getLogfileName(), entry.getHeader().getLogfileOffset(),
entry.getHeader().getSchemaName(), entry.getHeader().getTableName(),
eventType);
logger.info(templog);
//按照行进行遍历
for (RowData rowData : rowChage.getRowDatasList()) {
//如果是Delete操作,则删除(del)缓存
if (eventType == EventType.DELETE) {
delRedis(entry.getHeader().getTableName(), rowData.getBeforeColumnsList());
} else {//其他操作,则更新(set)缓存
setRedis(entry.getHeader().getTableName(), rowData.getAfterColumnsList());
}
}
}
/**
* Title: 更新缓存
*
* @author 韩超 2018/4/2 16:55
*/
private void setRedis(String tableName, List columns) throws UnsupportedEncodingException {
//定义json串用于存储增量数据的值
JSONObject json = new JSONObject();
//获取增量列和json串
columns = dataFormatAndTransCode(columns, json);
//定义 键 值
String key, value;
//如果存在数据,则继续处理
if (columns.size() > 0) {
//获取key值
key = columns.get(0).getValue();
//获取value值
value = json.toJSONString();
//根据表名决定操作方式
switch (tableName) {
//如果是特殊表,则进行特殊处理
case "special_table01":
//进行特殊处理,如key值格式化等等
//...
//设置缓存:以gyrlzyw-rmzw为值的缓存
RedisUtils.set(key, value);
//打印日志信息
logger.info("设置缓存:key = " + key + ",value = " + value);
break;
case "special_table02":
//...
break;
//如果是普通的缓存中间表,则不需要额外处理,直接更新对应缓存即可
default:
//设置缓存
RedisUtils.set(key, value);
//打印日志信息
logger.info("设置缓存:key = " + key + ",value = " + value);
break;
}
}
}
/**
* Title: 删除缓存
*
* @author 韩超 2018/4/2 16:56
*/
private void delRedis(String tableName, List columns) throws UnsupportedEncodingException {
//定义
JSONObject json = new JSONObject();
//获取增量列
dataFormatAndTransCode(columns, json);
//定义 键
String key;
//如果存在数据,则继续处理
if (columns.size() > 0) {
//获取key值
key = columns.get(0).getValue();
//根据表名决定操作方式
switch (tableName) {
//如果是特殊表,则进行特殊处理
case "special_table01":
//删除缓存
RedisUtils.del(key);
//打印日志信息
logger.info("删除缓存:key = " + key);
break;
case "special_table02":
//...
break;
//如果是普通的缓存中间表,则不需要额外处理,直接删除对应缓存即可
default:
//删除缓存
RedisUtils.del(key);
//打印日志信息
logger.info("删除缓存:key = " + key);
break;
}
}
}
/**
* Title: 对增量数据进行格式化与编码转换
*
*
可以按照项目编码和项目字段进行自定义
*
* @author 韩超 2018/4/2 16:56
*/
private List dataFormatAndTransCode(List columns, JSONObject json) throws UnsupportedEncodingException {
//按列遍历(其实就是按字段遍历)
for (Column column : columns) {
//如果字段类型是blob,则进行转码
if (column.getMysqlType().contains("blob")) {
json.put(column.getName(), new String(column.getValue().getBytes("ISO-8859-1"), "gbk"));
} else {//如果是其他字段,不用处理
json.put(column.getName(), column.getValue());
}
}
return columns;
}
}
备注:
系统中关于MySql和Redis的服务有很多:MySql、Redis、Canal、RedisAutoSync以及主体系统WebApp。
这些服务的启动顺序(不包括其他系统如Solr等等)如下:
service mysql start
)redis-server /opt/redis/redis.conf
)./canal/startup.sh
)./startup.sh
)./startup.sh
)在开发和调试的过程中,需要多次启动服务、关闭服务。经常这么做会导致canal解析进度与binlog日志不匹配的问题发生。
此时,再启动Canal服务,canal.log和example.log就会报错。
可以通过以下方式移除canal的解析进度记录文件,从而解决问题:
\canal.deployer-1.0.24\conf\example\meta.dat
文件注意:此操作会导致部分增量数据的丢失,但是考虑到此问题只产生于开发和测试过程中,这些损失可以接受。
如果您有更好的解决方法,请多多指教。
通过Canal进行Redis与MySql的自动同步,会导致MySql的日志激增。
导致日志激增来源于2个方面:
为了应对MySql日志激增问题,我们采取的是MySql日志自动清除配置。
您可以参考如下的文章:《MySql自动清除binary logs日志》