問題
一般發送mq的場景爲:本地修改業務數據,數據修改成功,調用RabbitTemplate.send方法發送mq消息。
1、爲了避免長事務,一般是把RabbitTemplate.send方法放在保存業務數據的事務之外。這樣就可能出現,業務數據修改成功了,但是調用RabbitTemplate方法失敗,導致mq訂閱方不知道數據發生變更。
2、可能出現,業務數據保存成功之後,在發送mq之前,代碼拋出異常或者服務掛了,導致mq消息沒有發送出去。
3、即使調用RabbitTemplate.send方法成功了,但是可能出現mq服務在把消息落盤之前就掛了,導致重啓後消息丟失。
可靠mq最重要的就是解決mq必定被髮送成功的問題。
解決問題的大方向
要讓mq消息一定被髮送出去,可以在本地業務庫創建一張mq發送表,利用mysql的事務來實現,方案大致如下:
1、開啓事務
2、修改本地業務數據
3、事務提交之前,保存下要發送的mq消息,狀態爲“發送中”
4、事務提交之後,調用RabbitTemplate.send方法發送mq消息
5、利用rabbitMq的確認機制,添加消息發送回調監聽:成功到達exchange,刪除步驟1中保存的mq消息;到達exchange失敗,發送失敗次數+1,修改消息狀態(到達最大重試次數,狀態改爲“失敗”)
6、利用定時任務,定時查詢狀態爲“發送中”的mq消息。重新調用RabbitTemplate.send方法發送mq消息
消息發送失敗後,就會一直重複步驟5、6,直到mq消息被成功發送到exchange或者到達最大重試次數。
相關的流程圖如下:
mq-platform實現方式
上述方案實現的難點在於:
1、在事務提交之前保存要發送的mq消息,把mq消息和業務數據作爲一個事務的數據,一起落庫
2、在事務提交之後用RabbitTemplate發送mq消息
看到“提交之前”和“提交之後”這些字眼,很容易就想到做一個攔截器,攔截spring的事務,這樣就可以做我們需要的增強。
spring事務管理其實已經提供了這種機制,不需要我們去造輪子,偉大的org.springframework.transaction.suppor.TransactionSynchronization接口!!!
該接口代碼如下:
public interface TransactionSynchronization extends Flushable {
int STATUS_COMMITTED = 0;
int STATUS_ROLLED_BACK = 1;
int STATUS_UNKNOWN = 2;
//掛起
default void suspend() {}
//恢復
default void resume() {}
//刷新
default void flush() {}
//提交之前,只有不發生異常纔會進入該方法
default void beforeCommit(boolean readOnly) {}
//沒搞懂有啥作用,感覺都被只是出發afterCompletion
default void beforeCompletion() {}
//事務成功提交之後
default void afterCommit() {}
//事務成功提交或回滾之後
default void afterCompletion(int status) {}
}
只要實現這些方法,就可以達到我們想要的效果。例如實現beforeCommit方法,提交前保存mq消息;實現afterCommit,提交事務後發送mq消息。
在mq-platform中,具體的代碼在mq-platform-producer模塊的DatabaseRabbitProducerTransactionListener。
關於步驟5提到的回調,是指實現org.springframework.amqp.rabbit.core.RabbitTemplate$ConfirmCallback接口,實現類在mq-platform-producer模塊的com.fangdd.mqplatform.producer.callback.RabbitConfirmCallback
RabbitConfirmCallback:
@Slf4j
public class RabbitConfirmCallback implements RabbitTemplate.ConfirmCallback {
private ProducerManager<RabbitProducer> producerManager;
private Alarm alarm;
public RabbitConfirmCallback(ProducerManager<RabbitProducer> producerManager, Alarm alarm) {
this.producerManager = producerManager;
this.alarm = alarm;
}
@Override
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
try {
if (Objects.nonNull(correlationData) && !StringUtils.isEmpty(correlationData.getId())) {
String correlationDataJsonString = JSON.toJSONString(correlationData);
log.info("receive publish confirm, correlationData: {}", correlationDataJsonString);
RabbitCorrelationId id = RabbitCorrelationId.parseRabbitCorrelationId(correlationData.getId());
if (Objects.isNull(id)) {
log.info("fail to parse correlationId:{}", correlationData.getId());
return;
}
if (ack) {
log.info("success to send message, correlationId:{}", correlationData.getId());
producerManager.deleteMq(id.getApplication(), id.getMessageId());
} else {
throw new MqPlatformException("fail to send message, correlationId:" + correlationData.getId());
}
} else {
if (ack) {
log.warn("success to send message, but lack of message id");
} else {
throw new MqPlatformException("fail to send message and lack of message id");
}
}
} catch (MqPlatformException e) {
log.warn("send mq confirm fail", e);
if (Objects.nonNull(alarm)) {
alarm.failWhenProduce(e);
}
}
}
}
原理就是發送mq的時候,設置關聯數據,關聯數據中包含了消息是屬於哪個應用和消息id。當接收到回調消息時,再根據關聯數據,操作對應的mq消息(刪除或修改狀態信息)
步驟6中,對於發送到mq服務失敗的消息,則是利用xxl-job來實現輪詢發送失敗的mq消息。相關代在mq-platform-producer模塊的RabbitSendMqJob類
RabbitSendMqJob:
@Slf4j
@JobHandler(value = "rabbitSendMqJobHandler")
public class RabbitSendMqJob extends IJobHandler {
private static final int MAX_BATCH_SIZE = 100;
private ProducerManager<RabbitProducer> producerManager;
private RabbitService rabbitService;
private String application;
private int batchSize;
private int loopCount = 1;
public RabbitSendMqJob(
ProducerManager<RabbitProducer> producerManager,
RabbitService rabbitService,
String application,
int batchSize) {
this.producerManager = producerManager;
this.rabbitService = rabbitService;
this.application = application;
if (batchSize > MAX_BATCH_SIZE) {
this.loopCount = batchSize / MAX_BATCH_SIZE + (batchSize % MAX_BATCH_SIZE > 0 ? 1 : 0);
this.batchSize = MAX_BATCH_SIZE;
} else {
this.batchSize = batchSize;
}
}
@Override
public ReturnT<String> execute(String s) throws Exception {
int i = 0;
while (i < loopCount) {
int size = clearRecord(batchSize);
if (size < 1) {
break;
}
i++;
}
return SUCCESS;
}
private int clearRecord(int batchSize) {
List<RabbitProducer> list;
if (StringUtils.isEmpty(application)) {
list = producerManager.listAllApplicationMq(MessageSendStatusEnum.SENDING, 0, batchSize);
} else {
list = producerManager.listSendingMq(application, 0, batchSize);
}
if (CollectionUtils.isEmpty(list)) {
return 0;
}
for (RabbitProducer producer : list) {
try {
producerManager.failSendMq(application, producer.getMessageId());
rabbitService.send(producer);
} catch (Throwable ignore) {}
}
return list.size();
}
}
用於保存待發送mq消息的表結構爲:
CREATE TABLE `rabbit_producer` (
`id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主鍵',
`type` TINYINT(3) NOT NULL DEFAULT 0 COMMENT '消息類型,0=即時消息,1=順序消息',
`application` VARCHAR(50) NOT NULL COMMENT '發送消息的應用名稱',
`virtual_host` VARCHAR(255) NOT NULL COMMENT '虛擬主機',
`exchange` VARCHAR(255) NULL COMMENT '交換器',
`routing_key` VARCHAR(255) NULL COMMENT '路由key',
`message_id` VARCHAR(50) NOT NULL COMMENT '消息id',
`body` TEXT NOT NULL COMMENT '消息內容',
`group_name` VARCHAR(50) DEFAULT NULL COMMENT '消息分組,同分組內,消息序號按發送順序遞增',
`send_status` TINYINT(3) NOT NULL DEFAULT 0 COMMENT '發送狀態, 0=預提交,1=發送中,2=發送失敗',
`retry_times` SMALLINT(6) NOT NULL DEFAULT 0 COMMENT '重試次數',
`max_retry_times` SMALLINT(6) NOT NULL COMMENT '最大重試次數',
`next_retry_time` DATETIME DEFAULT NULL COMMENT '下一次重試時間',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '創建時間',
`update_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改時間',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_message_app` (`message_id`, `application`)
) ENGINE = InnoDB DEFAULT CHARACTER SET = utf8mb4 COMMENT = 'rabbitMq發送表';
application是爲了以後拓展爲由mq.platform.ip.fdd重發mq消息所預留
group_name是爲了實現順序消費增加的字段
send_status沒有“發送成功”的值,是因爲發送成功就會刪除記錄