通过Canal保证某网站的Redis与MySql的数据自动同步

前置文章: 某网站Redis与MySql同步方案分析


使用Canal的主要目的:让自动同步代替部分手动同步,降低开发人员工作量,避免部分数据一致性问题。

本文主要讲解如何配置Canal,以保证某网站的Redis与MySql的数据自动同步。

1.Java开发原则

下面列出一些本项目的开发原则:

1.1.Redis的KEY命名规范

项目名称-模块名称-对象名称-主键id

例如:baidu-news-user-0000000001。

1.2.Redis初始化

有部分数据需要提前初始化到Redis之中,所以需要提供一个Redis初始化方法redisInit().

1.3.对于时效性要求较高的数据

对于时效性要求较高的数据,采用手动编码的方式保证Redis与MySql的同步:

  • 对于读操作
    • 先读Redis,如果有记录则返回结果;如果没有记录,则读取MySql。
    • 读取MySql,如果有记录则更新Redis,并返回结果;如果没有记录,则返回无结果。
  • 对于写操作
    • 先写MySql,再更新Redis。

4.对于时效性要求一般的数据

对于时效性要求一般的数据,通过Canal的方式保证Redis与MySql的同步:

  • 对于读操作
    • 读取Redis,如果有记录则返回结果,如果没有记录,则返回无结果。
  • 对于写操作
    • 直接写MySql。
  • 通过Canal解析MySql的binlog,来进行Redis和MySql的自动同步。

2.MySql设计原则

经过前面的分析,我们已经知道:Canal是根据MySql中的数据表的增量变化情况去更新Redis数据库的。

所以,如果MySql表结构Redis缓存结构一一对应,则我们在编写缓存自动同步方法时,会节省很多工作。

所以,在某网站项目中,我们统一要求:

  • 设计MySql表结构时,尽量与Redis缓存结构一一对应。
  • 如果因为某些原因无法做到一一对应,则添加一张专用的缓存中间表
  • 缓存中间表缓存结构一一对应,专门用来存储经过业务处理产生的最终需要缓存的数据。

举例说明缓存中间表的作用:

  • 背景
    • MySql中有人员基础信息表person[id,number,name],分别存储了字段:个人编号、身份证号码和姓名。
    • MySql中有人员考试成绩信息表score[id,score],分别存储了字段:个人编号、考试分数。
    • Redis设计了一个缓存结构{key:sfzhm,value=score},用于通过身份证号码快速查询考试分数,其字段为:身份证号码和考试分数。
  • 如果不设计缓存中间表
    • Canal读取增量数据变化,发现person[grbh,sfzhm,name]发生变化。
    • 这时,需要去做其他业务计算,判断是否同步修改Redis中{key:sfzhm,value=score}
    • 因为引起person[grbh,sfzhm,name]变化的原因可能有多种,与缓存结构{key:sfzhm,value=score}相关的可能只是一种情况。
  • 如果添加了缓存中间表person_score[sfzhm,score]
    • Canal读取增量数据变化,发现person[grbh,sfzhm,name]发生变化,无需理会。
    • Canal读取增量数据变化,发现person_score[sfzhm,score]发生变化,则直接修改Redis中的{key:sfzhm,value=score}即可。
    • 因为person_score[sfzhm,score]变化的肯定与{key:sfzhm,value=score}相关。

3.Canal同步方案的部署教程

下面对Canal同步方案的使用方法进行说明,主要分为以下几个步骤:

  • 配置Mysql开启binlog模式
  • 配置Mysql创建并授权Canal用户
  • 部署并启动Canal
  • 编写Redis自动同步服务

备注:此说明参考了其他文献,但是当时没有记录转载作者的好习惯,故而这里没有提及。如果有相关建议,请多多指教

3.1.配置Mysql开启binlog模式

因为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)重复

配置完成后,需要重启数据库。

3.2.配置Mysql创建并授权Canal用户

创建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;

3.3.部署并启动Canal

3.3.1.下载部署包

https://github.com/alibaba/canal/releases/

3.3.2.配置Canal

配置文件有两个:

  • canal/conf/example/instance.properties
  • canal/conf/canal.properties。

一般情况下,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 =
3.3.3.启动canal
  • Linux:./canal/startup.sh
  • Windows:startup.bat
3.3.4.查看启动状态

通过查看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....

如果出现报错信息,请根据报错信息进行调试。

3.3.5.关闭canal

./canal/stop.sh或者关闭startup.bat

4.编写Redis自动同步服务(RedisAutoSync)

4.1.服务说明

Canal服务的作用是:通过解析binlog读取MySql数据库的增量变化情况。

Canal服务本身并不能进行Redis与MySql的数据同步。

为了实现Redis与MySql的数据同步,我们还需要额外编写同步服务,这个服务的作用如下:

  • 连接Canal服务
  • 轮循的去获取Canal解析出来的MySql的增量数据
  • 根据MySql的增量数据,相应的做出Redis的更新和删除操作

为了便于称呼,我将这个服务命名为RedisAutoSync.

4.2.重点代码

下面分几个介绍RedisAutoSync的重点代码:

  • connectAndGetIncrementData():连接Canal服务,获取增量数据。
  • filterAndProcessIncrementData():根据表名进行筛选,并处理增量数据
  • dealIncrementDataStrategically():按策略处理增量数据
  • setRedis():更新缓存
  • delRedis():删除缓存
  • dataFormatAndTransCode():数据格式和编码转换

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; } }

备注:

  • 为了便于查看,部分编码进行了省略。
  • 当时我们使用的版本应该是老版本了,更多内容请参考https://github.com/alibaba/canal

5.注意事项

5.1.服务启动顺序

系统中关于MySql和Redis的服务有很多:MySql、Redis、Canal、RedisAutoSync以及主体系统WebApp。

这些服务的启动顺序(不包括其他系统如Solr等等)如下:

  1. 启动MySql服务 (如 service mysql start)
  2. 启动Redis服务 (如 redis-server /opt/redis/redis.conf)
  3. 启动Canal服务 (如 ./canal/startup.sh)
  4. 启动RedisAutoSync服务 (如 ./startup.sh)
  5. 启动WebApp服务 (如 ./startup.sh)
5.2.Canal解析进度与binlog日志不匹配问题解决

在开发和调试的过程中,需要多次启动服务、关闭服务。经常这么做会导致canal解析进度与binlog日志不匹配的问题发生。

此时,再启动Canal服务,canal.log和example.log就会报错。

可以通过以下方式移除canal的解析进度记录文件,从而解决问题:

  • 删除\canal.deployer-1.0.24\conf\example\meta.dat文件
  • 重启Canal服务

注意:此操作会导致部分增量数据的丢失,但是考虑到此问题只产生于开发和测试过程中,这些损失可以接受。

如果您有更好的解决方法,请多多指教。

5.3.MySql日志激增

通过Canal进行Redis与MySql的自动同步,会导致MySql的日志激增。

导致日志激增来源于2个方面:

  • 开启了binlog,binary log用于记录数据库增量变化,必然会很大。
  • binlog使用的是ROW模式,此模式虽然能够有效避免主从复制的主从不一致问题,但是相对于其他主从模式,产生的日志更多。

为了应对MySql日志激增问题,我们采取的是MySql日志自动清除配置。

您可以参考如下的文章:《MySql自动清除binary logs日志》

你可能感兴趣的:(MySql,Redis)