RabbitMQ - 网络单向畅通的情况进行数据同步

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源码 的学习,传递 statementIdparameter

由于相同的项目运行在两个不同的服务器上,只是使用的用户不同而已。所以不管是用户端的 restfull 接口还是 管理端的 restfull 接口,都是相同,也就是说,mybatisxml 文件和 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_08081链接数据库 test_1

4.1 测试新增接口

4.1.1 8080 - 8081 请求新增接口

发送请求

4.1.2 8080 对应数据库结果

8080 对应数据库

4.1.3 8081 对应数据库结果

8081 对应数据库

5. 总结

不同场景下使用不同的方案解决,此项目由于以开始就采用了 RabbitMQ 作为消息中间件,和 Redis 做为存储,所以我没有考虑使用其他的中间件,这样也是减少了项目的一个运维难度。能够完成此功能主要是归功于学习了 Mybatis 源码,才能明白确定消息传输的时候,具体应该传输什么内容。最开始的时候,我也是使用了最蠢的方式,去解析 SQL 拼接参数的方式,但是后来越想越觉得不对劲,于是直接将逻辑修改为源码方式进行传递,有效的降低了容错几率。开发中我们都应该 善于思考!

  • 7
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 7
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值