本文基于上一篇文章的改造,实现系统之间的双向数据传输。
一: 需求背景
甲公司的A系统和乙公司的B系统(乙公司是甲方),简称AB系统,通过RabbitMQ进行数据传输,因为原来B系统已经通过MQ对接过其他系统,所以对于A系统也使用MQ的方式进行对接,这样对于B系统来说改动量基本为0
优点
1:应用解耦
系统之间没有强耦合性
2:异步
不需要两个系统之间实时响应,提高用户体验和系统吞吐量
3:削峰
提高系统的稳定性
4:多系统对接代码成本低
如果A系统后期再于C、D系统交互,A系统基本不需要重新开发,只需要把数据复制一份发送到新队里即可,开发效率高
缺点
1:系统复杂度提高
需要依赖MQ中间件,以及中间服务,对于中间服务和MQ的高可用要求高
2:维护成本高
开发人员需要熟悉相关MQ知识以及中间服务的可读性,发生人员变更后不好交接,对于开发人员要求高
适用场景
1:如果你的系统只需要对接一个外部系统,那么不建议使用这种方式,如果你的系统对接多个系统或者前期规划后面会对接多个,那么建议这种方式
2:如果你的系统需要对接企业微信、钉钉、飞书等综合协同办公软件系统,比如把你们系统的审批消息、其他消息推送过去,那么建议这种方式
二: 涉及到的系统
1:内部系统
甲公司的自研系统、平台系统(后面统一叫A系统)
2:三方系统
乙公司的自研系统(后面统一叫B系统)、ERP系统
三:技术栈
1:Jdk8、Spring Boot2.7.15
2:Redis7.0.11 一主两从三哨兵模式
3:Mysql8.0 主从复制、读写分离
4:Nginx1.24.0
5:RabbitMQ3.7.4(三方已提供,不需要自己搭建)
6:阿里云服务器3台、16核8G
四:架构设计
甲公司的自研系统和平台系统自己内部有对接,不需要咱们处理(平台系统可以看做是个对接平台,专门用于对接其他系统,本文章平台系统对接的是咱们自己写的【中间服务】系统)
乙公司的自研系统和ERP系统内部有对接,不需要咱们处理(乙公司的自研系统和上面的甲公司平台系统功能类似,专门用于对接其他系统)
RabbitMQ是乙公司原来就有的,咱们直接使用
我们需要做的部分就是写一个【中间服务】,通过REST服务连接甲公司的平台系统,通过MQ连接乙公司的自研系统(为了方便,后面统一叫系统A和系统B),架构图如下
整体架构设计图1
因为他们自己的内部都有自己的处理对接,不需要咱们处理,所以为了方便理解,精简一下如下,可以理解为A系统和B系统通过中间服务以及MQ进行数据传输
整体架构设计图2
中间服务部署信息
腾讯云部署图
最外面是腾讯云负载均衡到3个服务器,运维负责搭建,下面是咱们自己的部署
服务器1:Nginx、中间服务、Redis主、Redis哨兵1、Mysql数据库
服务器2:Nginx、中间服务、Redis从1、Redis哨兵2、Mysql从数据库1
服务器3:Nginx、中间服务、Redis从2、Redis哨兵3、Mysql从数据库2
五:详细架构
1:如果保证消息丢失问题(消息可靠性)
新建2个表,主表用于定时任务的轮询、日志查看、提供手动重试策略 ,子表用于保存实际的消息体内容
主表:id,mq_unique_id(消息唯一键),push_status(推送状态),retry_count(重试次数),err_msg(错误信息),push_to(推送方向)
子表:id,pid(主表Id),content(消息内容),bill_name(单据名称),queue_name(消息队列名称)
无论接收到系统A或者MQ的消息,首先落库(保证可靠性),落库成功后再进行后续处理,失败则返回失败消息到系统A或者通知MQ,由他们进行处理。
2:如果保证消息重复性问题
解析双方的json数据,通过唯一消息id进行处理,可设置唯一索引,java代码捕获DuplicateKeyException异常, 不进行后续处理。
3: 数据库设计
主表:mq_master
CREATE TABLE `mq_master` (
`id` bigint NOT NULL COMMENT 'id',
`mq_unique_id` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL COMMENT '消息唯一id',
`push_status` smallint NOT NULL DEFAULT '0' COMMENT '推送状态(0未推送1推送成功2推送失败)',
`retry_count` smallint DEFAULT '0' COMMENT '重试次数',
`err_msg` varchar(2000) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '失败信息',
`push_to` varchar(32) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '推送方向(0推送、1接收)',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
KEY `push_status` (`push_status`) USING BTREE
) ENGINE=InnoD
子表:mq_slave
CREATE TABLE `mq_slave` (
`id` bigint NOT NULL,
`pid` bigint NOT NULL COMMENT '主表id',
`content` text CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL COMMENT '消息体',
`bill_name` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL COMMENT '单据名称',
`queue_name` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL COMMENT '队列名称',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `pid` (`pid`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
4:中间服务与系统A的对接架构
在系统A和中间服务之间增加Nginx做负载均衡,因为服务部署了3台服务器
主要逻辑:
1:A系统调用后先入库,入库成功后异步发送消息到MQ,发送消息到MQ的时候,使用唯一id加分布式锁,预防发送的时候正好定时任务同时处理了此消息
/**
* @Tutle: null.java
* @description: rest服务统一入口,所有的rest服务都写在此处
* @author: Carry
* @create: 2023-07-20 10:03
* @Version 1.0
**/
@Slf4j
@RestController
@RequestMapping("/middleware/sync")
public class RestRouterController {
@Autowired
IMqService mqService;
@Autowired
ThreadPoolTaskExecutor threadPoolTaskExecutor;
/*** @Author Carry
* @Description rest服务统一入口,发送消息到MQ,为了方便,调用前需要约定调用格式
* @Date 17:32 2023/11/9
* @Param [params]
* @return
**/
@PostMapping(value = "/start")
public Result start(@RequestBody CommonDto commonDto) {
Result result = new Result();
String errMsg = "";
result.setErrorCode(ResultEnum.SYSTEM_ERROR.getErrorCode());
result.setErrorMessage(errMsg);
if (commonDto.getBillName() == null) {
errMsg = "单据名称必传";
log.error(errMsg);
return result;
}
String billName = commonDto.getBillName();
// 根据不同的单据,获取不同的处理实现类
IBusinessService businessService = BusinessBeanFactory.getBusinessBeanByBillName(billName);
if (businessService == null) {
errMsg = "未定义此业务处理类";
log.error(errMsg);
return result;
}
// 入库操作以及获取发送到MQ的转换后的数据
Map<String, Object> trans2MqData;
try {
trans2MqData = businessService.getDataToMq(commonDto);
} catch (Exception e) {
errMsg = "信息入库中间服务失败,失败信息:" + e.getMessage();
log.error(errMsg, e);
return result;
}
Boolean saveMsgFlag = (Boolean) trans2MqData.get("saveMsgFlag");
if (!saveMsgFlag) {
String saveMsgErrMsg = String.valueOf(trans2MqData.get("saveMsgErrMsg"));
errMsg = "信息入库中间服务失败:" + saveMsgErrMsg;
log.error(errMsg);
return result;
}
try {
String routingKey = String.valueOf(trans2MqData.get("routingKey"));
String msg = String.valueOf(trans2MqData.get("msg"));
String uniqueId = String.valueOf(trans2MqData.get("uniqueId").toString());
threadPoolTaskExecutor.execute(() -> mqService.sendMsgToMq(routingKey, msg, uniqueId));
} catch (Exception e) {
// 捕获异常 交给定时任务执行
errMsg = "信息入库中间服务成功,发送至MQ失败,失败信息:" + e.getMessage();
log.error(errMsg, e);
return result;
}
return new Result();
}
}
2:定时任务加粒度大的分布式锁,预防多个服务同时启动定时任务
/**
* @Tutle: null.java
* @description: 定时任务轮询处理
* @author: Carry
* @create: 2023-08-04 08:41
* @Version 1.0
**/
@Component
@EnableScheduling
public class EventTask implements IBusinessService {
private static final Logger log = LoggerFactory.getLogger(EventTask.class);
@Autowired
IMqMasterService mqMasterService;
@Autowired
IMqSlaveService mqSlaveService;
@Autowired
Sender sender;
@Autowired
private RedissonClient redissonClient;
/**
* @return void
* @Author Carry
* @Description 30s
* @Date 8:42 2023/8/4
* @Param []
**/
@Scheduled(cron = "0/30 * * * * ?")
public void toAMqTask() {
RLock rLock = redissonClient.getLock(Constants.LOCK_TASK_MQ);
try {
boolean isLocked = rLock.tryLock(Constants.EXPIRATION_60S, TimeUnit.SECONDS);
if (!isLocked) {
log.error("toAMqTask定时任务未获取到分布式锁,放弃执行任务。。。。。。。");
return;
}
String pushTo = "0";
// 获取待发送到三方的数据
List<MqMaster> mqMasterList = this.getDataList(pushTo);
if (CollectionUtils.isEmpty(mqMasterList)) {
return;
}
this.doprocess(mqMasterList, pushTo);
} catch (Exception e) {
log.error("toAMqTask error :{},################################", e.getMessage(), e);
} finally {
if (rLock != null && rLock.isHeldByCurrentThread()) {
rLock.unlock();
}
}
}
/**
* @return void
* @Author Carry
* @Description 30s
* @Date 8:42 2023/8/4
* @Param []
**/
@Scheduled(cron = "0/30 * * * * ?")
public void toSelfTask() {
RLock rLock = redissonClient.getLock(Constants.LOCK_TASK_MQ);
try {
boolean isLocked = rLock.tryLock(Constants.EXPIRATION_60S, TimeUnit.SECONDS);
if (!isLocked) {
log.error("toSelfTask定时任务未获取到分布式锁,放弃执行任务。。。。。。。");
return;
}
String pushTo = "1";
List<MqMaster> mqMasterList = this.getDataList(pushTo);
if (CollectionUtils.isEmpty(mqMasterList)) {
return;
}
this.doprocess(mqMasterList, pushTo);
} catch (Exception e) {
log.error("toSelfTask error :{},################################", e.getMessage(), e);
} finally {
if (rLock != null && rLock.isHeldByCurrentThread()) {
rLock.unlock();
}
}
}
public List<MqMaster> getDataList(String direction) {
MqMaster mqMaster = new MqMaster();
mqMaster.setPushTo(direction);
mqMaster.setRetryCount(5);
// 获取待发送的数据
List<MqMaster> mqMasterList = mqMasterService.selecListToPush(mqMaster);
return mqMasterList;
}
/**
* @return
* @Author Carry
* @Description 统一处理
* @Date 12:33 2023/8/16
* @Param
**/
public void doprocess(List<MqMaster> mqMasterList, String pushTo) {
List<String> pidList = mqMasterList.stream().map(MqMaster::getId).collect(Collectors.toList());
List<MqSlave> msgContentList = mqSlaveService.selectListByPidList(pidList);
if (CollectionUtils.isEmpty(msgContentList)) {
return;
}
msgContentList.stream().forEach(x -> {
String uniqueId = x.getPid();
String msg = x.getContent();
String routingKey;
String queueName = x.getQueueName();
String billName = x.getBillName();
// 发送到MQ
if ("0".equals(pushTo)) {
// 根据billName 获取路由key 直接发送 因为数据库已经是转换后的格式
routingKey = BillName2QueueConstant.billName2QueueMap.get(billName);
sender.toMq(routingKey, msg, String.valueOf(uniqueId));
} else {
// 不同队列不同实现类处理
try {
IBusinessService businessService = BusinessBeanFactory.getBusinessBeanByQueueName(queueName);
businessService.getDataToSelf(msg, uniqueId);
} catch (Exception e) {
log.error("定时任务发送到系统出错 error :{},################################", e.getMessage(), e);
}
}
});
}
}
3:消息处理成功或者失败更新消息表,通过RabbitMQ的reliableCallback机制处理
/**
* @Tutle: null.java
* @description: 生产者端可靠性配置记录
* @author: Carry
* @create: 2023-07-20 17:21
* @Version 1.0
**/
@Configuration
@Component
public class ReliableConfig {
private static final Logger log = LoggerFactory.getLogger(ReliableConfig.class);
@Autowired
IMqMasterService mqMasterService;
/**
* @return org.springframework.amqp.rabbit.core.RabbitTemplate
* @Author Carry
* @Description 生产者消息可靠性确认
* @Date 9:37 2023/8/18
* @Param [connectionFactory]
**/
@Bean
public RabbitTemplate reliableCallback(ConnectionFactory connectionFactory) {
RabbitTemplate rabbitTemplate = new RabbitTemplate();
rabbitTemplate.setConnectionFactory(connectionFactory);
// 设置开启Mandatory,才能触发回调函数,无论消息推送结果怎么样都强制调用回调函数
rabbitTemplate.setMandatory(true);
// 消息可靠性确认
rabbitTemplate.setConfirmCallback(new RabbitTemplate.ConfirmCallback() {
@Override
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
String uniqueId = String.valueOf(correlationData.getId());
if (ack) {
log.error("数据发送到MQ成功,消息ID============" + uniqueId);
mqMasterService.successToUpdate(uniqueId);
} else {
log.error("ReliableConfigConfirmCallbackFalse:原因:" + cause);
mqMasterService.errorToUpdate(uniqueId, cause);
}
}
});
}
中间服务详细设计图1
5:中间服务与MQ的对接架构
为了实现高效率以及减少网络开销,使用rabbitMQ的推模式,即使用Spring AMQP的SimpleMessageListenerContainer进行处理。
此模式的特点是消费者具有一个缓冲区会缓存这些消息,所以会导致总是有一堆在内存中待处理的消息,当消息体过大时,会导致缓冲区溢出、消耗大量内存问题,为了防止此问题,建议折中处理,设置prefetch为1
@Bean
public SimpleMessageListenerContainer simpleMessageListenerContainer() {
SimpleMessageListenerContainer container = new SimpleMessageListenerContainer(connectionFactory);
// 最少消费者数量
container.setConcurrentConsumers(2);
// 最大消费者数量
container.setMaxConcurrentConsumers(5);
// spring-amqp 2.0版开始,默认的prefetch值是250,提高吞吐量,【但是处理速度比较慢的大消息时】,消息可能在内存中大量堆积,消耗大量内存,
// 最大吞吐量 max-concurrency*prefetch
container.setPrefetchCount(1);
// RabbitMQ默认是自动确认 , AcknowledgeMode.NONE,这里改为手动确认消息
container.setAcknowledgeMode(AcknowledgeMode.MANUAL);
container.setTaskExecutor(taskExecutor());
// 设置多个如下: 前提是队列都是必须已经创建存在的
container.setQueueNames(QueueConstant.XXXXX,QueueConstant.XXXXX);
// 消息接收处理类 AckRecivicerListener
container.setMessageListener(ackRecivicerListener);
return container;
}
主要逻辑和前面类似:
1:监听到MQ的 消息后先入库,入库成功后异步发送消息到A系统,发送消息的时候,使用唯一id加分布式锁,预防发送的时候正好定时任务同时处理了此消息
2:定时任务加粒度大的分布式锁,预防多个服务同时启动定时任务
3:消息处理成功或者失败更新消息表
中间服务详细设计图2
六:代码结构
模块依赖关系,api模块为最外层,打包打此模块就行
1. api ----对外暴露接口
2. base ----公共类
3. business ----业务操作
4. consumer ----消费者
5. producer ----生产者
6. scheduled-task ----定时任务
代码依赖图
七:模块详设
1:api
api为主服务,提供rest服务接口调用,auth认证,以及resources配置文件,后面打jar包直接打此模块即可
2:scheduled-task
此服务主要用于定时任务轮训,服务重试使用
3:producer
生产者服务,对于MQ来说,咱们的中间服务就是生产者,发送到MQ的消息依赖此服务
4:consumer
消费者服务,对于MQ来说,咱们的中间服务也是消费者,接收MQ的消息依赖此服务
5:business
业务模块,中间服务最重要的模块,代码的绝大部分都在此模块里,包含增删改查等功能以及业务单据的转换等,通用的消息相关的CRUD已经实现可以不用关注,主要关注自己的业务逻辑即可,比如转换成MQ的消息格式、接收MQ的消息转换成系统需要的格式等
里面很多通用代码都已经实现,需要自己实现的都加了TODO注释,比如获取数据入库以及发送到MQ的部分,使用了模版方法模式,使用者只需要关注重写的部分即可:
package com.carry.middleservice.business.service.trans.impl.tothird;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.carry.middleservice.business.service.common.impl.MsgServiceImpl;
import com.carry.middleservice.base.utils.SpringUtils;
import java.util.*;
/**
* @Tutle: null.java
* @description: 统一业务处理类,发送到MQ
* @author: Carry
* @create: 2023-07-25 09:42
* @Version 1.0
**/
public abstract class AbstractMqBusinessService {
/**
* @return java.util.Map<java.lang.String, java.lang.Object>
* @Author Carry
* @Description 组装发送到MQ的消息
* @Date 15:32 2023/11/10
* @Param [msgMap]
**/
public final Map<String, Object> trans2MqData(Map<String, Object> msgMap) {
Map<String, Object> result = new HashMap<>();
// 1入库
result = this.self2Mysql(msgMap);
// 2 获取路由
String routingKey = this.getRoutingKey();
result.put("routingKey", routingKey);
return result;
}
/**
* @return java.lang.String
* @Author Carry
* @Description 获取路由信息,不同的单据自己实现此方法
* @Date 15:32 2023/11/10
* @Param []
**/
public abstract String getRoutingKey();
/**
* @return java.util.Map<java.lang.String, java.lang.Object>
* @Author Carry
* @Description 主数据 主表数据通用处理
* @Date 10:57 2023/8/15
* @Param [syncData]
**/
public Map<String, Object> self2Mysql(Map<String, Object> selfData) {
Map<String, Object> result = new HashMap<>();
String uniqueId = String.valueOf(selfData.get("uniqueId"));
MsgServiceImpl msgService = SpringUtils.getBean(MsgServiceImpl.class);
String selfDataStr = JSON.toJSONString(selfData, SerializerFeature.WriteMapNullValue, SerializerFeature.QuoteFieldNames);
// 入库
boolean saveMsgFlag = msgService.saveMsg(null, selfDataStr, "0", uniqueId);
// TODO 返回信息(根据自己系统的约定进行设计)
result.put("saveMsgFlag", saveMsgFlag);
result.put("uniqueId", uniqueId);
result.put("msg", selfDataStr);
return result;
}
}
6:base
公共方法模块