业务背景
由于分布式系统架构越来越多地被使用,于是便很容易牵扯到系统间的事务问题;之前介绍过使用Rabbit MQ结合本地表来实现分布式业务数据的幂等,详见 https://blog.csdn.net/Winner941112/article/details/102869015,本文将通过使用Rocket MQ的特性,同样来一定程度上地解决事务问题。此次以用户注册为例,在用户注册后向用户发送一条信息,要实现的目的是用户注册信息入库后,才向用户发送信息。
MQ事务实现原理
从图中可以看出,消息发送方producer在产生数据后:
- 首先producer会先将数据发送给 Rocket MQ,进行一个半事务的消息发送,此时消息会暂时存在mq中,但是消费者无法消费到该数据;
- 接着producer开始处理本地的事务,如将数据插入数据库等操作,当本地的事务成功处理后,producer会向mq发送一个commit请求,此时mq才真正存入数据,消费端才可以消费到数据,若本地事务处理失败,那么producer会向mq发送一个rollback请求,mq收到请求后会将数据删除,消费端不会消费到这条数据;
- 如果程序处理发生意外,比如首先向mq发送了数据,且成功处理了本地事务,但是因为其他原因没有向mq提交,那么mq在等待一段时间后会发送一个检查机制,producer可以通过在数据库中查询mq发送过来的信息,来决定是否向mq发送commit或是rollback。
代码实现
Rocket MQ 的事务的实现主要依赖于两个方法,一个是 execute 方法,该方法内部实现的是本地方法的事务,多用于第一步数据发送完mq后存入MySQL的操作;第二个方法是 check方法,该方法主要用于实现mq服务器长时间接收不到producer端的确认信息或者收到的确认信息不为 commit 或 rollback,而是 unknow 时的对producer端的确认逻辑,在这里是通过mq发送回的消息到MySQL中去查询这条数据是否已入库。
代码实现如下:
- 数据表:
CREATE TABLE `user_info` (
`id` int(10) NOT NULL AUTO_INCREMENT,
`crc32` bigint(20) DEFAULT NULL,
`username` varchar(50) DEFAULT NULL,
`password` varchar(10) DEFAULT NULL,
`age` int(10) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6137 DEFAULT CHARSET=utf8;
- producer 端代码:
/**
* Rocket MQ 事务
*
* @author huying
* @create 2019/11/6 15:50
*/
public class TransactionProducerRocketMQDemo {
public static void main(String[] args) {
ApplicationContext applicationContext = SpringConfigManager.getSpringFileInstance();
// Rocket MQ 参数配置
Properties prop = new Properties();
prop.put(PropertyKeyConst.GROUP_ID, ConfigurationManager.getString(Constants.GROUPID_ROCKETMQ));
prop.put(PropertyKeyConst.AccessKey, ConfigurationManager.getString(Constants.ACCESSKEY_ID_ROCKETMQ));
prop.put(PropertyKeyConst.SecretKey, ConfigurationManager.getString(Constants.ACCESSKEY_SECRET_ROCKETMQ));
prop.put(PropertyKeyConst.NAMESRV_ADDR, ConfigurationManager.getString(Constants.NAMESRV_ADDR));
// 这里模拟一条用户数据的产生
JSONObject jsonObject = new JSONObject();
jsonObject.put("id", 1000);
jsonObject.put("username", "Leo");
jsonObject.put("password", "1000");
jsonObject.put("age", 18);
// 这里使用crc32对数据进行加密处理,保证幂等性
// 如果业务上已经保证了幂等,那么可以忽略这步操作
String key = String.valueOf(HashUtil.crc32Code(jsonObject.toJSONString().getBytes()));
Message msg = new Message("WD_SYS_MQ_TEST", "TagC", key, SerializeUtil.serialize(jsonObject));
TransactionProducer producer = ONSFactory.createTransactionProducer(prop, new LocalTransactionChecker() {
/**
* Broker端对未确定状态的消息发起回查,将消息发送到对应的Producer端(同一个Group的Producer),
* 由Producer根据消息来检查本地事务的状态,进而执行Commit或者Rollback
*
* 当producer端提交的是UNKNOW时,broker会发起确认
*
* @param message
* @return 返回事务状态
*/
@Override
public TransactionStatus check(Message message) {
/**
* 执行回查,可以通过加密的key,从数据库中找到
*/
String key = message.getKey();
IUserInfoRepository iUserInfoRepository = (IUserInfoRepository) applicationContext.getBean("IUserInfoRepository");
// 获得mq发送回来的mq数据后,拿到key,通过crc32加密后的key到数据库中去比对,查看是否含有该条数据
int i = iUserInfoRepository.selectByCrc32(Long.valueOf(key));
// 若数据库中该key的数据存在,即count数大于0,则可以提交该事务,消费者可以消费到该条数据
if (i > 0){
System.out.printf("回查到本地事务已提交,提交消息,id:%s%n", message.getKey());
return TransactionStatus.CommitTransaction;
} else {
// 如果查不到该条数据,那么数据进行回滚,该条数据将会从mq中删除掉
System.out.printf("未查到本地事务状态,回滚消息,id:%s%n", message.getKey());
return TransactionStatus.RollbackTransaction;
}
}
});
producer.start();
try {
SendResult sendResult = producer.send(msg, new LocalTransactionExecuter() {
/**
* 在发送消息成功时执行本地事务
* @param message
* @return 返回事务状态
*
* TransactionStatus.CommitTransaction 提交事务,允许订阅方消费该消息。
* TransactionStatus.RollbackTransaction 回滚事务,消息将被丢弃不允许消费。
* TransactionStatus.Unknow 无法判断状态,期待消息队列 MQ 的 Broker 向发送方再次询问该消息对应的本地事务的状态。
*/
@Override
public TransactionStatus execute(Message message, Object o) {
/**
* 开启事务
*/
//1.获取事务控制管理器
DataSourceTransactionManager transactionManager = applicationContext.getBean("transactionManager", DataSourceTransactionManager.class);
//2.获取事务定义
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
//3.设置事务隔离级别,开启新事务
def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
//4.获得事务状态
org.springframework.transaction.TransactionStatus transactionStatus = transactionManager.getTransaction(def);
try {
/**
* 执行业务逻辑代码,也就是插入数据库操作
* 如果未抛异常,则将事务进行提交
*/
IUserInfoRepository iUserInfoRepository = (IUserInfoRepository) applicationContext.getBean("IUserInfoRepository");
UserInfo userInfo = new UserInfo(jsonObject.getInteger("id"), Long.valueOf(key), jsonObject.getString("username"), jsonObject.getString("password"), jsonObject.getInteger("age"));
iUserInfoRepository.insert(userInfo);
// 数据库事务的提交
transactionManager.commit(transactionStatus);
// mq事务的提交,此时消费者才可以消费到数据
System.out.println("本地事务提交,消息正常处理");
return TransactionStatus.CommitTransaction;
} catch (Exception e) {
/**
* 如果抛出异常,那么进行回滚操作,数据将会从mq中删除
*/
// 数据库事务回滚
transactionManager.rollback(transactionStatus);
// mq事务的回滚,这条消息将会从mq中被删除
System.out.printf("本地事务回滚,回滚消息,id:%s%n", message.getKey());
return TransactionStatus.RollbackTransaction;
}
}
}, null);
} catch (Exception e) {
// TODO 如果消息发送失败,需要进行重试处理,可重新发送这条消息或持久化这条数据进行补偿处理
}
}
}
说明一下,这里在封装Message的时候添加了一个参数unique key,这个key可以作为该topic内的唯一消息进行识别,这个key的值是通过将用户信息进行crc32(也可以使用md5)加密,之后这个加密也会成为数据库中的一个字段连同用户信息存入数据库;当producer发送了这条信息Message到broker却因为其他原因没有commit 或者 rollback时,broker 会返回这条消息,这时我们就可以拿着这条消息的 key 去数据库中查询是否存在这条消息,如果存在,那么提交 commit,如果不存在,则 rollback 回滚让broker删除这条消息,这样就可以一定程度上保证消息的幂等性;当然如果业务本身是幂等的,那么可以忽略这个操作。
- consumer 端代码:
/**
* Rocket mq 消费者
*
* @author huying
* @create 2019/11/7 17:47
*/
public class TransactionConsumerRocketMQDemo {
public static void main(String[] args) {
Properties prop = new Properties();
// Group ID
prop.put(PropertyKeyConst.GROUP_ID, ConfigurationManager.getString(Constants.GROUPID_ROCKETMQ));
prop.put(PropertyKeyConst.AccessKey, ConfigurationManager.getString(Constants.ACCESSKEY_ID_ROCKETMQ));
prop.put(PropertyKeyConst.SecretKey, ConfigurationManager.getString(Constants.ACCESSKEY_SECRET_ROCKETMQ));
prop.put(PropertyKeyConst.NAMESRV_ADDR, ConfigurationManager.getString(Constants.NAMESRV_ADDR));
Consumer consumer = ONSFactory.createConsumer(prop);
consumer.subscribe("WD_SYS_MQ_TEST", "TagC", new MessageListener() {
@Override
public Action consume(Message message, ConsumeContext consumeContext) {
System.out.println("Receive: " + message);
return Action.CommitMessage;
}
});
consumer.start();
System.out.println("Consumer Started");
}
}
- 代码执行结果:
4.1 代码正常运行,本地事务处理成功,事务提交commit:
可以看到,生产者正常发送消息,消费者也消费到了消息,数据库中新增了一条记录。
4.2 现在在producer的本地事务中加上一行代码让本地事务失败,可以看到事务回滚,同时mq的消息也被删除,consumer并未消费到该条数据。
4.3 将 execute 发送方法内的 commit 改为 unknow,模拟本地事务成功但是确认消息未发送的场景。
在发送之后可以看到,虽然本地事务成功了,但是消费端却没有消费到数据,但是等待一段时间后可以发现以下变化:
producer 端接收到了 broker 端的消息回执,并且执行了 check内的逻辑代码,在数据库中查找到了发回的信息后给 rocketmq 补发了一个 commit 操作。需要提到的一点,这里使用的是阿里云的 Rocket MQ 云服务器,在代码封装上可能略微有些差别,但是主要代码未发生太大的变化,execute 和 check 是实现事务的关键,另外在 producer 端的文件配置中也需要加入groupid,否则,消息可以发送出去但是如果发送的确认消息为 unknow 或者未发送确认信息的话,producer 端收不到 broker 端的回执信息。