業務背景
由於分佈式系統架構越來越多地被使用,於是便很容易牽扯到系統間的事務問題;之前介紹過使用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 端的回執信息。