RabbitMQ
- RabbitMQ
同步两个 SpringBoot
服务数据
参考:RabbitMQ整合SpringBoot API使用教程
前言:
在开发公司项目的时候,客户要求将两台服务的数据进行同步,条件是一台服务器在内网,一台服务器在外网, 两台服务器上部署的代码完全一致
, 内网可以访问外网,但是外网访问不了内网,在此条件下,诞生了本文将要描述的功能。
1、思考
1.1 数据同步中间件的采用
方案1:数据库主从备份
使用数据库主从备份,那么B服务器想要同步A服务器的就比较简单,但是A服务器同步B服务器是不行的,况且做主从备份只能从 master
做写操作,slave
是不能做写操作的,在此限制下,主从备份方案不可取。
方案2:使用 Redis
做数据存储中间件
将Redis作为每次新增/修改时候的内容存储,且将Redis部署在外网环境下,内网既能访问到Redis,外网也能访问到。但是需要在项目中使用定时器定时去扫描 `Redis· 的键,且消费了内容后还需要对内容进行删除,并且每次消费消息都是根据定时器轮询时长进行消费,达不到数据实时同步,且设计性能一点都可观。此方案不可取
方案3:使用消息中间件 Rabbit MQ
使用消息中间件来代替 方案2 中的 Redis
,这样不需要手动去轮询读取消息,节约了时间,达到了约实时同步。将 RabbitMQ
部署在外网环境,内网和外网都可以访问。RabbitMQ
在消费消息的时候,还提供了两种模式 push/pull
消息,如果是 RabbitMQ
服务主动 push
到内网服务器上,肯定是行不通的,那么就需要内网服务器上的服务主动向 RabbitMQ
服务主动 pull
消息,这样就满足了两信道的通讯问题。
1.2 消息传递,到底应该传递什么内容?
既然是数据库的
insert
语句和update
语句和delete
语句,那么我能不能使用Mybatis
的插件,去拦截SQL
语句呢?
方案1:拦截 sql
语句,解析 sql
语句,拼接参数
拦截 SQL
语句,修改 SQL
语句,设计到反射,以及 Mybatis
源码更深层次的探索,由于工期紧张,由不得半点拖沓,况且在解析 SQL
的时候,极容易出错,所以这种方式不可取。
方案2:根据 Mybatis源码 的学习,传递 statementId
与 parameter
由于相同的项目运行在两个不同的服务器上,只是使用的用户不同而已。所以不管是用户端的 restfull
接口还是 管理端的 restfull
接口,都是相同,也就是说,mybatis
的 xml
文件和 mapper
接口是完全一致的。既然完全一致,那么就可以使用该方案进行传递。
2. 配置文件规范
在
yml
配置文件里面添加以下配置,具体请看注释
sync:
## true 表示开启数据同不 fasle 关闭数据同不
state: true
## 0:内网,1:外网
network: 0
3. Mybatis 插件 编写
3.1 插件逻辑
package com.example.sync.intercptor;
import cn.hutool.core.codec.Base64;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.plugin.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.util.Properties;
/**
* <p>
* 使用 mybatis 拦截器,拦截执行 update 方法的 sql 语句
* 把拦截到的sql语句放入到消息队列中,根据内网和外网配置进行
* 区分队列
* </p>
*
* @author zyred
* @since v 0.1
**/
@Slf4j
@Component
@Intercepts(
@Signature(
// 拦截 Excutor 的 update 方法,通过源码可以看出 update 方法有且只有一个
type = Executor.class,
method = "update",
// 并且 update 方法只有两个参数,具体可以进入 Excutor.class 文件中查看 update 方法
args = {MappedStatement.class, Object.class}
)
)
@ConditionalOnProperty(prefix = "sync", name = "state", havingValue = "true")
public class SyncMyBatisInterceptor implements Interceptor {
/** inject fields ~ **/
@Autowired
private RabbitPush rabbitPush;
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
MappedStatement mappedStatement = (MappedStatement) args[0];
String statementId = mappedStatement.getId();
// SQL 类型,也需要进行传递,最终在B端通过此参数判断执行调用的方法
SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType();
Object parameter = args[1];
MessageCarrier carrier = new MessageCarrier(statementId, parameter, sqlCommandType.name());
String jsonBytes = Base64.encode(carrier.toBytes());
this.rabbitPush.pushMessage(carrier, jsonBytes);
return invocation.proceed();
}
@Override
public Object plugin(Object target) {return Plugin.wrap(target, this);}
@Override
public void setProperties(Properties properties) {}
}
3.2 消息载体类
import com.alibaba.fastjson.JSONObject;
import com.example.sync.utils.SerializationUtil;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.io.Serializable;
/**
* <p>
* 消息载体,主要用于 MQ 之间进行传输
* </p>
*
* @author zyred
* @since v 0.1
**/
@Getter
@AllArgsConstructor
public class MessageCarrier implements Serializable {
private static final long serialVersionUID = 1836894984567328087L;
/**
* Mapper.java 类名全路径 + 被执行的接口方法名称
* 例如:org.example.mappers.UserMapper.selectUserByUserName
*/
private final String statementId;
/** sql执行需要的参数 **/
private final Object param;
/** SqlCommandType **/
private final String method;
public byte[] toBytes() {
// 序列化为 byte 数组进行传输,千万不要使用 fastjson,否则会反序列化失败
// fastJson 会存在 Long 类型转换为 Integer 类型
return SerializationUtil.serializer(this);
}
@Override
public String toString() {
// 这里只是为了打印日志调用
return JSONObject.toJSONString(this);
}
}
3.3 JDK
序列化器
这里采用
JDK
序列化器的原因,是因为FastJson
序列化Long
,反序列化会出现Integer
类型,索性直接使用二进制进行传输
import java.io.*;
/**
* <p> JDK 序列化器 </>
*
* @author zyred
* @since v 0.1
*/
public class SerializationUtil {
/**序列化对象*/
public static byte[] serializer(Object obj) {
try (
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos)
) {
oos.writeObject(obj);
return baos.toByteArray();
}catch (Exception ex) {
ex.printStackTrace();
}
return null;
}
/**反序列化对象*/
public static Object deserializer(byte[] bytes) {
try (
ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
ObjectInputStream ois = new ObjectInputStream(bais)
){
return ois.readObject();
}catch (Exception ex ) {
ex.printStackTrace();
}
return null;
}
}
3.4 RabbitMQ Queue Exchange
声明,采用 TOPIC
模式
/**
* <p>
* 申明内网队列和交换机
* </p>
*
* @author zyred
* @since v 0.1
**/
@Slf4j
@Configuration
@ConditionalOnProperty(prefix = "sync", name = "network", havingValue = "0")
public class InnerBindingConfig {
/** 队列名称 **/
public static final String INNER_QUEUE_NAME = "inner_queue";
/** 路由键名称 **/
public static final String INNER_ROUTING_KEY = "inner_key";
@PostConstruct
public void init () {
log.info("内网配置生效.........");
log.info("内网队列名称:{}", INNER_QUEUE_NAME);
log.info("内网交换机名称:{}", RabbitPush.exchange);
}
@Bean(name = INNER_QUEUE_NAME)
public Queue innerQueue () {
return new Queue(INNER_QUEUE_NAME, true);
}
@Bean(name = "innerExchange")
public TopicExchange innerExchange () {
return new TopicExchange(RabbitPush.exchange);
}
@Bean
public Binding bindingMallExchangeMessage() {
return BindingBuilder.bind(this.innerQueue()).to(this.innerExchange()).with(INNER_ROUTING_KEY);
}
}
/**
* <p>
* 申明外网队列和交换机
* </p>
*
* @author zyred
* @since v 0.1
**/
@Slf4j
@Configuration
@ConditionalOnProperty(prefix = "sync", name = "network", havingValue = "1")
public class ExternalBindingConfig {
public static final String EXTERNAL_QUEUE_NAME = "external_queue";
/** 路由键名称 **/
public static final String EXTERNAL_ROUTING_KEY = "external_key";
@PostConstruct
public void init () {
log.info("外网配置生效.........");
log.info("外网队列名称:{}", EXTERNAL_QUEUE_NAME);
log.info("外网交换机名称:{}", RabbitPush.exchange);
}
@Bean(name = EXTERNAL_QUEUE_NAME)
public Queue innerQueue () {
return new Queue(EXTERNAL_QUEUE_NAME, true);
}
@Bean(name = "externalExchange")
public TopicExchange externalExchange () {
return new TopicExchange(RabbitPush.exchange);
}
@Bean public Binding bindingMallExchangeMessage() {
return BindingBuilder.bind(this.innerQueue()).to(this.externalExchange()).with(EXTERNAL_ROUTING_KEY);
}
}
3.5 消息发送逻辑
/**
* <p>
* 发送消息
* </p>
*
* @author zyred
* @since v 0.1
**/
@Slf4j
@Component
public class RabbitPush {
@Autowired
private RabbitTemplate rabbitTemplate;
/** 0:内网 1:外网 **/
public static final Integer innerNet = 0;
public static final Integer externalNet = 1;
/** inner_key: 内网 external_key:外网 **/
public static final String innerRotingKey = "inner_key";
public static final String externalRotingKey = "external_key";
public static final String exchange = "sync_exchange";
/** true: 启用数据同步 **/
@Value("${sync.state}") private boolean syncState;
/** 0:内网 1:外网 **/
@Value("${sync.network}") private Integer network;
public void pushMessage (MessageCarrier carrier, String sqlMsg) {
if (!this.syncState) {
log.debug("未启动数据库同步服务!");
return;
}
boolean isPush = false ;
String key = null;
// 内网发送给外网
if (innerNet.equals(this.network)) {
key = externalRotingKey;
Boolean inner = ExclusiveTransport.innerLocal.get();
if (Objects.nonNull(inner)) {
isPush = inner;
}
log.debug("内网 ThreadLocal : " + inner);
}
// 外网发送给内网
else if (externalNet.equals(this.network)) {
key = innerRotingKey;
Boolean ext = ExclusiveTransport.externalLocal.get();
if (Objects.nonNull(ext)) {
isPush = ext;
}
log.debug("外网 ThreadLocal : " + ext);
} else {
log.error("项目配置错误!");
return ;
}
log.info("-----------------------------------------------");
log.info("决定是否执行sql发送 :{}", isPush ? "否" : "是");
log.info("mybatis-plugin 拦截sql:{}", carrier.toString());
log.info("-----------------------------------------------");
if (isPush) {
return;
}
log.info("mybatis监听到update语句, {} 发起传输的数据为: {}, 路由键为: {}", (this.network == 0 ? "内网" : "外网"), sqlMsg, key);
this.rabbitTemplate.convertAndSend(exchange, key, sqlMsg);
}
/**
* 执行 SQL 公共方法,没地方放了,就放到了这里
**/
public static void executorSql (SqlSession session, MessageCarrier carrier, Channel channel, Message message) throws IOException {
// 手动确认消息被接收,由于没要求保证数据强一致性
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
String msg = carrier.toString();
if (StrUtil.isBlank(msg)) { return ; }
log.info("开始执行SQL..........");
log.info("statementId: {}", carrier.getStatementId());
log.info("执行参数类型:{}", carrier.getParam().getClass());
// 新增
if (SqlCommandType.INSERT.name().equalsIgnoreCase(carrier.getMethod())) {
log.info("执行 INSERT.............");
session.insert(carrier.getStatementId(), carrier.getParam());
}
// 更新
else if (SqlCommandType.UPDATE.name().equalsIgnoreCase(carrier.getMethod())) {
log.info("执行 UPDATE.............");
session.update(carrier.getStatementId(), carrier.getParam());
}
// 删除
else {
log.info("执行 DELETE.............");
session.delete(carrier.getStatementId(), carrier.getParam());
}
log.info("执行SQL完毕..........");
}
}
3.6 解决消息重复被发送问题
举个例子:A 服务器
Mybatis
拦截到了update
语句,使用MQ
进行发送数据,B服务器接收到了消息后,B服务器通过Mybatis
执行update
语句,那么B服务器所携带的插件依然会被拦截到,所以这里需要判断update
语句的来源,如果是通过监听器来源的,这里需要进行拦截,不做再次发送处理
/**
* <p>
* 独占标识
* </p>
*
* @author zyred
* @since v 0.1
**/
public class ExclusiveTransport {
/** 内网服务器发送标识 **/
public static ThreadLocal<Boolean> innerLocal = new ThreadLocal<>();
/** 外网服务器发送标识 **/
public static ThreadLocal<Boolean> externalLocal = new ThreadLocal<>();
}
3.7 消息监听
import cn.hutool.core.codec.Base64;
import com.rabbitmq.client.Channel;
import com.example.sync.ExclusiveTransport;
import com.example.sync.MessageCarrier;
import com.example.sync.RabbitPush;
import com.example.sync.SyncMyBatisInterceptor;
import com.example.sync.config.InnerBindingConfig;
import com.example.util.SerializationUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSessionFactory;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitHandler;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.io.IOException;
import java.util.Objects;
/**
* <p>
* 内网服务器消息消费监听
*
* 内网监听器,只有在标识为内网的时候,才能启动,不能消费自己的消息
* </p>
*
* @author zyred
* @since v 0.1
**/
@Slf4j
@Component
@RabbitListener(queues = InnerBindingConfig.INNER_QUEUE_NAME)
@ConditionalOnBean(SyncMyBatisInterceptor.class)
@ConditionalOnProperty(prefix = "sync", name = "network", havingValue = "0")
public class SyncInnerListener {
@Autowired private SqlSessionFactory factory;
// ************ class methods ~ ****************
@PostConstruct
public void log () {
log.info("内网监听服务器启动......");
}
@RabbitHandler
public void innerSync (String decode, Channel channel, Message message) throws IOException {
// 接 2.6 处,需要设置唯一表示
ExclusiveTransport.innerLocal.set(true);
byte[] bytes = Base64.decode(decode);
Object deserializer = SerializationUtil.deserializer(bytes);
MessageCarrier carrier = (MessageCarrier) deserializer;
if (Objects.isNull(carrier)) {
throw new NullPointerException("数据同步失败!");
}
log.info("内网服务器读取队列消息:{}", carrier);
RabbitPush.executorSql(this.factory.openSession(), carrier, channel, message);
}
}
import cn.hutool.core.codec.Base64;
import com.rabbitmq.client.Channel;
import com.example.sync.ExclusiveTransport;
import com.example.sync.MessageCarrier;
import com.example.sync.RabbitPush;
import com.example.sync.SyncMyBatisInterceptor;
import com.example.sync.config.ExternalBindingConfig;
import com.example.util.SerializationUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSessionFactory;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitHandler;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.io.IOException;
import java.util.Objects;
/**
* <p>
* 外网服务器消息消费监听
*
* 外网监听器,只有在标识为外网的时候,才能启动,不能消费自己的消息
* </p>
*
* @author zyred
* @since v 0.1
**/
@Slf4j
@Component
@RabbitListener(queues = ExternalBindingConfig.EXTERNAL_QUEUE_NAME)
@ConditionalOnBean(SyncMyBatisInterceptor.class)
@ConditionalOnProperty(prefix = "sync", name = "network", havingValue = "1")
public class SyncExternalListener {
@Autowired private SqlSessionFactory factory;
// ************ static fields ~ ****************
public static final String insertSql = "insert";
// ************ class methods ~ ****************
@PostConstruct
public void log () {
log.info("外网监听服务器启动......");
}
@RabbitHandler
public void externalSync (String decode, Channel channel, Message message) throws IOException {
// 2.6 处
ExclusiveTransport.externalLocal.set(true);
byte[] bytes = Base64.decode(decode);
Object deserializer = SerializationUtil.deserializer(bytes);
MessageCarrier carrier = (MessageCarrier) deserializer;
if (Objects.isNull(carrier)) {
throw new NullPointerException("数据同步失败!");
}
log.info("外网服务器读取队列消息:{}", carrier);
RabbitPush.executorSql(this.factory.openSession(), carrier, channel, message);
}
}
4. 编写测试
由于篇幅问题,测试接口与数据库就不做赘述,这个与平常业务相似,自己也可以测,这里就只展示新增,不展示更新了
值得注意的一点:同一套代码必须要启动两个服务,并且占用两个不同端口,例如8080(模拟内网)
8081(模拟外网)
,8080链接数据库 test_0
,8081链接数据库 test_1
4.1 测试新增接口
4.1.1 8080 - 8081
请求新增接口
4.1.2 8080
对应数据库结果
4.1.3 8081
对应数据库结果
5. 总结
不同场景下使用不同的方案解决,此项目由于以开始就采用了 RabbitMQ
作为消息中间件,和 Redis
做为存储,所以我没有考虑使用其他的中间件,这样也是减少了项目的一个运维难度。能够完成此功能主要是归功于学习了 Mybatis
源码,才能明白确定消息传输的时候,具体应该传输什么内容。最开始的时候,我也是使用了最蠢的方式,去解析 SQL
拼接参数的方式,但是后来越想越觉得不对劲,于是直接将逻辑修改为源码方式进行传递,有效的降低了容错几率。开发中我们都应该 善于思考!