我们在做mysql与redis的数据同步时,往往采用的是代码层实现,或者通过spring-cache等缓存框架。但是仍然有某些场景,比如说原项目无源码,或者不能进行二开时,就需要独立的第三方来实现数据同步。
我们需要一种无代码入侵式的数据同步,完全由第三方组件管理。这就需要借助canal来实现mysql到redis的数据同步
canal是阿里巴巴旗下的一款开源项目,纯Java开发。基于数据库增量日志解析,提供增量数据订阅&消费,目前主要支持了MySQL(也支持mariaDB)
其工作原理主要是模仿MySQL复制:
Canal架构如下:
大致的解析过程如下:
canal分成服务端deployer和客户端adapter,我们可以部署多个,同时为了方便管理还提供了一个管理端admin。
因为目前canal还不能直接通过配置就实现对redis的数据同步,因此我们需要自定义一下canal客户端,通过服务端将数据同步到客户端后,由客户端自定义操作同步到redis
canal支持多种安装方式,本文提供了windows环境下和docker环境下的安装方式,详情请移步:《Canal安装教程》
在此基础上,本文相关配置如下:
SQL:
SET FOREIGN_KEY_CHECKS=0;
-- ----------------------------
-- Table structure for tb_user
-- ----------------------------
DROP TABLE IF EXISTS `tb_user`;
CREATE TABLE `tb_user` (
`user_id` int NOT NULL AUTO_INCREMENT,
`user_name` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`age` int DEFAULT NULL,
`sex` char(2) COLLATE utf8_bin DEFAULT NULL,
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb3 COLLATE=utf8_bin;
重新启动canal-server服务即可
进入 canal-admin 的控制台,如果你的配置正确,server 列表里会自动出现启动:
在 instance 管理中新建一个 instance,注意名字要和刚才复制的文件夹名字对应:
过几秒钟以后,如果你的配置正确,instance 列表里,如果列表里的状态是停止,可以在操作里手动启动
为了节约文章篇幅,本文只贴出核心代码,完整代码可在文末进行查看或下载
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-testartifactId>
<scope>testscope>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>com.alibabagroupId>
<artifactId>fastjsonartifactId>
<version>1.2.72version>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
<optional>trueoptional>
dependency>
<dependency>
<groupId>com.alibaba.ottergroupId>
<artifactId>canal.clientartifactId>
<version>1.1.4version>
dependency>
由于我们需要对数据库操作来观察Redis中的变化,建议大家集成一下Mybatis或Mybatis-Plus实际操作一下:
#redis配置
# Redis服务器地址
spring.redis.host=192.168.0.104
# Redis服务器连接端口
spring.redis.port=6379
# Redis服务器连接密码(默认为空)
spring.redis.password=
# Redis数据库索引(默认为0)
spring.redis.database=0
# 连接池最大连接数(使用负值表示没有限制)
spring.redis.jedis.pool.max-active=8
# 连接池最大阻塞等待时间(使用负值表示没有限制)
spring.redis.jedis.pool.max-wait=-1
# 连接池中的最大空闲连接
spring.redis.jedis.pool.max-idle=8
# 连接池中的最小空闲连接
spring.redis.jedis.pool.min-idle=0
# 连接超时时间(毫秒)
spring.redis.timeout=0
spring.redis.lettuce.shutdown-timeout=0
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.Component;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.io.Serializable;
import java.util.concurrent.TimeUnit;
@Service
public class RedisUtils {
@Autowired
private RedisTemplate redisTemplate;
/**
* 写入缓存 * @param key * @param value * @return
*/
public boolean set(final String key, Object value) {
boolean result = false;
try {
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
operations.set(key, value);
result = true;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
/**
* 写入缓存设置时效时间 * @param key * @param value * @return
*/
public boolean setEx(final String key, Object value, Long expireTime) {
boolean result = false;
try {
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
operations.set(key, value);
redisTemplate.expire(key, expireTime, TimeUnit.SECONDS);
result = true;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
/**
* 判断缓存中是否有对应的value * @param key * @return
*/
public boolean exists(final String key) {
return redisTemplate.hasKey(key);
}
/**
* 读取缓存 * @param key * @return
*/
public Object get(final String key) {
Object result = null;
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
result = operations.get(key);
return result;
}
/**
* 删除对应的value * @param key
*/
public boolean remove(final String key) {
if (exists(key)) {
Boolean delete = redisTemplate.delete(key);
return delete;
}
return false;
}
}
新建一个canal 客户端,并且依赖 ApplicationRunner,在 Spring 容器启动完成后开启守护线程同步任务(注意 import 时选择 canal 包下的类):
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.example.demo.util.RedisUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import java.net.InetSocketAddress;
import java.util.List;
@Slf4j
@Component
public class CanalClient implements ApplicationRunner {
@Autowired
RedisUtils redisUtils;
private static final String TABLE_NAME = "tb_user";
private static final String PRIMARY_KEY = "user_id";
private static final String SEPARATOR = ":";
private static final String CANAL_SERVER_HOST = "192.168.0.104";
private static final int CANAL_SERVER_PORT = 11111;
private static final String CANAL_INSTANCE = "redis";
private static final String USERNAME = "canal";
private static final String PASSWORD = "canal";
@Override
public void run(ApplicationArguments args) throws Exception {
this.initCanal();
}
public void initCanal() {
// 创建链接
CanalConnector connector = CanalConnectors.newSingleConnector(
new InetSocketAddress(CANAL_SERVER_HOST, CANAL_SERVER_PORT),
CANAL_INSTANCE, USERNAME, PASSWORD);
int batchSize = 1000;
try {
log.info("启动 canal 数据同步...");
connector.connect();
connector.subscribe(".*\\..*");
connector.rollback();
while (true) {
// 获取指定数量的数据
Message message = connector.getWithoutAck(batchSize);
long batchId = message.getId();
int size = message.getEntries().size();
if (batchId == -1 || size == 0) {
try {
// 时间间隔1000毫秒
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
syncEntry(message.getEntries());
}
connector.ack(batchId); // 提交确认
// connector.rollback(batchId); // 处理失败, 回滚数据
}
} finally {
connector.disconnect();
}
}
private void syncEntry(List<CanalEntry.Entry> entrys) {
for (CanalEntry.Entry entry : entrys) {
if (entry.getEntryType() == CanalEntry.EntryType.TRANSACTIONBEGIN
|| entry.getEntryType() == CanalEntry.EntryType.TRANSACTIONEND) {
continue;
}
CanalEntry.RowChange rowChange;
try {
rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
} catch (Exception e) {
throw new RuntimeException("ERROR data:" + entry.toString(), e);
}
CanalEntry.EventType eventType = rowChange.getEventType();
log.info("================> binlog[{}:{}] , name[{},{}] , eventType : {}",
entry.getHeader().getLogfileName(), entry.getHeader().getLogfileOffset(),
entry.getHeader().getSchemaName(), entry.getHeader().getTableName(),
eventType);
String tableName = entry.getHeader().getTableName();
if (!TABLE_NAME.equalsIgnoreCase(tableName)) continue;
for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
if (eventType == CanalEntry.EventType.INSERT) {
printColumn(rowData.getAfterColumnsList());
redisInsert(tableName, rowData.getAfterColumnsList());
} else if (eventType == CanalEntry.EventType.UPDATE) {
printColumn(rowData.getAfterColumnsList());
redisUpdate(tableName, rowData.getAfterColumnsList());
} else if (eventType == CanalEntry.EventType.DELETE) {
printColumn(rowData.getBeforeColumnsList());
redisDelete(tableName, rowData.getBeforeColumnsList());
}
}
}
}
private void redisInsert(String tableName, List<CanalEntry.Column> columns) {
JSONObject json = new JSONObject();
for (CanalEntry.Column column : columns) {
json.put(column.getName(), column.getValue());
}
for (CanalEntry.Column column : columns) {
if (PRIMARY_KEY.equalsIgnoreCase(column.getName())) {
String key = tableName + SEPARATOR + column.getValue();
redisUtils.set(key, json.toJSONString());
log.info("redis数据同步新增,key:" + key);
break;
}
}
}
private void redisUpdate(String tableName, List<CanalEntry.Column> columns) {
JSONObject json = new JSONObject();
for (CanalEntry.Column column : columns) {
json.put(column.getName(), column.getValue());
}
for (CanalEntry.Column column : columns) {
if (PRIMARY_KEY.equalsIgnoreCase(column.getName())) {
String key = tableName + SEPARATOR + column.getValue();
redisUtils.set(key, json.toJSONString());
log.info("redis数据同步更新,key:" + key);
break;
}
}
}
private void redisDelete(String tableName, List<CanalEntry.Column> columns) {
for (CanalEntry.Column column : columns) {
if (PRIMARY_KEY.equalsIgnoreCase(column.getName())) {
String key = tableName + SEPARATOR + column.getValue();
redisUtils.remove(key);
log.info("redis数据同步删除,key:" + key);
break;
}
}
}
private void printColumn(List<CanalEntry.Column> columns) {
for (CanalEntry.Column column : columns) {
log.info(column.getName() + " : " + column.getValue() + " update=" + column.getUpdated());
}
}
}
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
public class TbUserController {
@Autowired
ITbUserService tbUserService;
@PostMapping("insert")
public Object insert(@RequestBody TbUser tbUser) {
tbUserService.save(tbUser);
return "数据插入完成";
}
@PostMapping("update")
public Object update(@RequestBody TbUser tbUser) {
tbUserService.updateById(tbUser);
return "数据更新完成";
}
@PostMapping("delete")
public Object delete(@RequestParam(value = "id") Integer id) {
tbUserService.removeById(id);
return "数据删除完成";
}
}
Canal 的好处在于对业务代码没有侵入,因为是基于监听 binlog 日志去进行同步数据的。实时性也能做到准实时,其实是很多企业一种比较常见的数据同步的方案。
以上只是一个测试的案例。
Canal 根据偏移量增量同步 MySQL 的 binlog,可以为每个 instance 配置路由规则,只同步部分内容,业务代码也可以自行修改,不仅仅同步到 Redis,也可以同步到其他存储介质中,不仅仅同步相同数据,可以自定义数据模型结构进行转换。
上述代码中,我们得到 binlog 同步数据对象 proto,格式主要如下:
由此可见,可以根据 DDL(data definition language) 数据定义语言, DML(data manipulation language) 数据操纵语言 区分自己不同的操作,DML 操作也能得到具体数据库名表名以及字段名和数据变更前变更后的各种详细数据信息,我们可以选择性地结合业务来同步数据。
当然这仅仅只是入门,实际项目中一般配置 MQ 模式,配合 RocketMQ 或者 Kafka,Canal 会把数据发送到 MQ 的 topic 中,然后通过消息队列的消费者进行处理。Canal 的部署也是支持集群的,需要配合 ZooKeeper 进行集群管理。
项目地址:https://gitee.com/ninesuntec/canal.git