問題:
- 解決問題的大方向
- 爲什麼直接把消息發回消息隊列
- 數據保存在本地 VS 保存在mq.platform.ip.fdd
- 保存在本地的好處:
- 保存本地的缺點:
- 保存在mq.platform.ip.fdd的好處:
- 保存在mq.platform.ip.fdd的缺點:
- 最終選擇
- 重試方式:定時任務 VS 起工作線程
- 定時任務
- 工作線程
- mq-platform實現方式
- 消費端
- 服務端
問題
我們基本都採用自動應答,但是自動應答默認消費失敗會將消息重新放回隊列。
如果沒有catch異常,則消息會被不斷消費,但是又消費不成功,可能導致日誌文件將硬盤撐爆。
爲了避免這種情況,大家可能會在消費mq的業務代碼最外圍用try catch包住,等於消費永遠成功(這種行爲,等價於將defaultRequeueRejected設置爲false)。
這種做法,在出現異常的時候,只是簡單打一個異常日誌,容易丟失mq消息,導致業務問題。
解決問題的大方向
大致的方案是:增加攔截器,消費失敗的時候,持久化存儲mq消息,到達指定的重消費時間,重新消費mq消息;到達最大重試次數依然不能消費成功,放棄消息。
方案流程圖:
爲什麼直接把消息發回消息隊列
如果選擇將消息失敗的消息保存在本地,那麼重新消費mq消息,就會有兩種選擇:
1、將消息重放回隊列,重新觸發消息邏輯
2、在保存mq消息到本地的時候,也記錄下消費mq消息的對象和方法,重新消費的時候,直接用反射調用處理方法
由於消費mq消息的代碼千奇百怪,沒辦法簡單的拿到消費mq消息的對象和方法,使用@RabbitListener註解的情況下,有點希望,但還涉及到數據轉換等問題。
而直接將消息重發到隊列就簡單得多,發回原來的隊列就行了。
數據保存在本地 VS 保存在mq.platform.ip.fdd
保存在本地的好處:
1、每個服務的數據都是獨立的,不會相互影響
2、使用定時任務來實現重發mq消息的時候,大家可以各自控制頻率
保存本地的缺點:
1、每個服務都需要在自己的業務庫裏創建一個保存消費失敗mq的表,不方便升級,有的服務也沒有數據庫
2、重發mq如果依靠定時任務的方式,那每個服務都需要引入xxl-job
保存在mq.platform.ip.fdd的好處:
1、各個服務不需要建表,只是通過dubbo接口把消息保存到mq.platform.ip.fdd,,方便統一管理
2、各個服務不需要引入xxl-job這些額外的東西
保存在mq.platform.ip.fdd的缺點:
1、mq.platform.ip.fdd掛了,那麼會丟失mq消息(不過告警信息裏,可以把mq消息體帶上,真出問也可以用告警信息裏的內容手動重發)
2、業務服務都必須引入dubbo(我們本就就都會引入dubbo服務,感覺沒影響)
最終選擇
選擇保存在mq.platform.ip.fdd,由mq.platform.ip.fdd統一來實現重試
重試方式:定時任務 VS 起工作線程
定時任務
可以選擇在mq.platform.ip.fdd中引入xxl-job這個分佈式任務調度,定時X每秒查詢“待發送”的任務,但這會存在下面的問題:
1、X設置得太大,重試的時間準確性就降低,例如10秒才重試一次,那麼最大有可能晚10秒重試
2、X設置得太小,可能上一次的任務還沒執行完,下一次重試開始執行,把已經執行過一遍的任務重新執行
工作線程
可以在spring容器啓動後,開啓一個工作線程。線程在一個死循環裏查詢“待發送”的任務,然後發送出去。因爲線程不停的工作,所以有這樣的優勢:
1、重發的時間較爲準確
2、效率比定時任務高
不過用工作線程需要解決多個實例的問題,多個實體,會啓動多個線程,多個線程一起工作的話,可能會導致重複發送的問題。
可以用比較簡單的方式解決該問題:
1、引入zk分佈式鎖
2、線程開始工作前,先獲取zk的鎖,如果獲取不到,就一直等待
3、獲取到鎖之後,進入while死循環,一直工作到容器退出才退出循環,釋放zk的分佈式鎖
4、這其實就形成了一個主備,如果主服務不掛,就會一直由主服務的線程進行消息的重發;如果主服務掛了,備服務就會獲取到zk的鎖,成爲主服務,然後進行工作
mq-platform實現方式
消費端:
在MqPlatformRabbitConsumerConfiguration配置類中,定義了mq.platform.ip.fdd接口的消費者,用於消費失敗的時候,將mq消息保存到mq.platform.ip.fdd
/**
* apache.dubbo的消費者定義
*/
@ConditionalOnClass(org.apache.dubbo.config.spring.context.annotation.EnableDubbo.class)
protected static class ApacheDubboConsumeServiceConfiguration {
private String dubboApplicationName;
private String dubboRegistryAddress;
public ApacheDubboConsumeServiceConfiguration(@Value("${dubbo.application.name:}") String dubboApplicationName,
@Value("${dubbo.registry.address}") String dubboRegistryAddress,
MqPlatformProperties properties) {
this.dubboApplicationName = StringUtils.isEmpty(dubboApplicationName) ? properties.getApplication() :
dubboApplicationName;
this.dubboRegistryAddress = dubboRegistryAddress;
}
/**
* 定義mq-platform-server的ConsumeService消費者
*
* @return ConsumeService消費者
*/
@Bean
public ConsumeService consumeService() {
com.alibaba.dubbo.config.ApplicationConfig applicationConfig = new com.alibaba.dubbo.config.ApplicationConfig();
applicationConfig.setName(dubboApplicationName);
com.alibaba.dubbo.config.ConsumerConfig consumerConfig = new com.alibaba.dubbo.config.ConsumerConfig();
consumerConfig.setCheck(false);
consumerConfig.setTimeout(3000);
consumerConfig.setRetries(0);
consumerConfig.setFilter("consumerCatFilter,default");
com.alibaba.dubbo.config.RegistryConfig registryConfig = new com.alibaba.dubbo.config.RegistryConfig();
registryConfig.setAddress(dubboRegistryAddress);
com.alibaba.dubbo.config.ReferenceConfig<ConsumeService> referenceConfig =
new com.alibaba.dubbo.config.ReferenceConfig<>();
referenceConfig.setApplication(applicationConfig);
referenceConfig.setConsumer(consumerConfig);
referenceConfig.setRegistry(registryConfig);
referenceConfig.setInterface(ConsumeService.class);
return referenceConfig.get();
}
}
/**
* alibaba.dubbo的消費者定義
*/
@ConditionalOnClass(com.alibaba.dubbo.config.spring.context.annotation.EnableDubbo.class)
protected static class AlibabaDubboConsumeServiceConfiguration {
private String dubboApplicationName;
private String dubboRegistryAddress;
public AlibabaDubboConsumeServiceConfiguration(@Value("${dubbo.application.name:}") String dubboApplicationName,
@Value("${dubbo.registry.address}") String dubboRegistryAddress,
MqPlatformProperties properties) {
this.dubboApplicationName = StringUtils.isEmpty(dubboApplicationName) ? properties.getApplication() :
dubboApplicationName;
this.dubboRegistryAddress = dubboRegistryAddress;
}
/**
* 定義mq-platform-server的ConsumeService消費者
*
* @return ConsumeService消費者
*/
@Bean
@ConditionalOnMissingBean(ConsumeService.class)
public ConsumeService consumeService() {
com.alibaba.dubbo.config.ApplicationConfig applicationConfig =
new com.alibaba.dubbo.config.ApplicationConfig();
applicationConfig.setName(dubboApplicationName);
com.alibaba.dubbo.config.ConsumerConfig consumerConfig = new com.alibaba.dubbo.config.ConsumerConfig();
consumerConfig.setCheck(false);
consumerConfig.setTimeout(3000);
consumerConfig.setRetries(0);
consumerConfig.setFilter("consumerCatFilter,default");
com.alibaba.dubbo.config.RegistryConfig registryConfig = new com.alibaba.dubbo.config.RegistryConfig();
registryConfig.setAddress(dubboRegistryAddress);
com.alibaba.dubbo.config.ReferenceConfig<ConsumeService> referenceConfig =
new com.alibaba.dubbo.config.ReferenceConfig<>();
referenceConfig.setApplication(applicationConfig);
referenceConfig.setConsumer(consumerConfig);
referenceConfig.setRegistry(registryConfig);
referenceConfig.setInterface(ConsumeService.class);
return referenceConfig.get();
}
}
因爲2.70之後的是apache版本,所以寫了兩份定義,兼容2.7.x和2.6.x
MqPlatformRabbitConsumerConfiguration中定義了可靠消費的攔截器RelyOperationsInterceptor,當有異常拋出(即消費失敗的時候),會觸發告警,然後就用上面定義的ConsumeService消費者,將消息保存到mq.platform.ip.fdd
/**
* rabbit可靠消費(消費失敗告警和保存)的配置
*/
@ConditionalOnProperty(prefix = "mq-platform.consumer.rely", name = "enabled", havingValue = "true")
@EnableConfigurationProperties(MqPlatformProperties.class)
protected static class MqPlatformRabbitRelyConsumeConfiguration {
private MqPlatformProperties properties;
private MqPlatformConsumerRely relyProperties;
public MqPlatformRabbitRelyConsumeConfiguration(
MqPlatformProperties properties,
MqPlatformConsumerRely relyProperties
) {
this.properties = properties;
this.relyProperties = relyProperties;
}
/**
* 定義消費失敗後保存消息的interceptor
*
* @param alarmProvider 告警bean提供者
* @param consumeServiceProvider 消費服務提供者
* @return 失敗重試的interceptor
*/
@Bean(BeanNameConstants.INTERNAL_RELY_OPERATIONS_INTERCEPTOR)
@ConditionalOnMissingBean(RelyOperationsInterceptor.class)
public RelyOperationsInterceptor retryOperationsInterceptor(
ObjectProvider<Alarm> alarmProvider,
ObjectProvider<ConsumeService> consumeServiceProvider
) {
return new RelyOperationsInterceptor(properties.getApplication(), relyProperties.getMaxAttempts(),
relyProperties.getInitialInterval().toMillis(), relyProperties.getMaxInterval().toMillis(),
relyProperties.getMultiplier(), consumeServiceProvider, alarmProvider);
}
}
服務端
服務端主要的業務代碼在ConsumeRetryRunner,通過zk分佈式鎖實現主備,然後主服務不斷查詢數據庫中的任務記錄,重新發送mq消息
/**
* @author wanglongzhao
* @2020-02-10 11:14
* description mq消息重試機制
*/
@Slf4j
@Component
public class ConsumeRetryRunner implements ApplicationRunner {
private static final String CONSUME_RETRY_DISTRIBUTION_LOCK_KEY = "retryLockKey";
/**
* 使用分佈式鎖的時候,模塊名稱
*/
@Value("${app.id: mq.platform.ip.fdd}")
private String moduleName;
/**
* 每次循環最多處理多少個任務
*/
@Value("${mq-platform-server.consume.retry-batch:30}")
private int consumeRetryBatch = 30;
/**
* 沒有任務的時候,工作線程的睡眠時間
*/
@Value("${mq-platform-server.consume.sleep-time:1000}")
private long sleepTime = 1000;
/**
* 檢查異常任務的週期
*/
@Value("${mq-platform-server.consume.check-period:5000}")
private long checkPeriod = 5000;
private volatile boolean working = true;
@Autowired
private LockManager lockManager;
@Autowired
private RabbitConsumeFailRecordManager manager;
@Autowired
private SendRabbitMqService sendRabbitMqService;
@Override
public void run(ApplicationArguments args) throws Exception {
AsyncHandlerUtil.excute(() -> {
executeConsumeRetry();
return Boolean.TRUE;
});
}
public void stopWorking() {
working = false;
}
private void executeConsumeRetry() {
DistributionLock lock = createLock();
while (!getDistributionLock(lock)) {}
// 獲取鎖成功,開始執行任務
long lastFetchRetryingJobTime = 0;
try {
while (working) {
try {
long currentTime = System.currentTimeMillis();
// 每過{checkPeriod}毫秒,就檢索“重試中”的任務,發送消息
if (currentTime - lastFetchRetryingJobTime > checkPeriod) {
lastFetchRetryingJobTime = currentTime;
fetchRetryingJobAndSendMessage();
continue;
}
// 檢索“待重試”的任務,發送消息
fetchToRetryJobAndSendMessage();
} catch (Exception e) {
try {
//延時3s
Thread.sleep(3000);
} catch (InterruptedException ignore) {}
}
}
} finally {
this.releaseDistributionLock(lock);
}
}
private void fetchToRetryJobAndSendMessage() {
//查詢失敗待重試的任務
ListRabbitConsumeFailRecordFilter toRetryFilter = new ListRabbitConsumeFailRecordFilter();
toRetryFilter.setRetryStatus(ConsumeRetryStatusEnum.TO_RETRY.getValue());
toRetryFilter.setEndNextRetryTime(new Date());
List<RabbitConsumeFailRecord> toRetryRecords = manager.listRecords(toRetryFilter, consumeRetryBatch);
if (CollectionUtils.isEmpty(toRetryRecords)) {
try {
Thread.sleep(sleepTime);
} catch (InterruptedException ignore) {}
return;
}
//更新數據庫記錄的重試狀態,待重試更新爲重試中
toRetryRecords.forEach(record -> record.setRetryStatus(ConsumeRetryStatusEnum.RETRYING.getValue()));
manager.updateBatchById(toRetryRecords);
toRetryRecords.forEach(this::sendMessage2Queue);
}
private void fetchRetryingJobAndSendMessage() {
// 查詢一分鐘前重試中(考慮到有可能上一次重試但是還未回調,所以增加一分鐘前的過濾條件)的mq記錄
ListRabbitConsumeFailRecordFilter retryingFilter = new ListRabbitConsumeFailRecordFilter();
retryingFilter.setRetryStatus(ConsumeRetryStatusEnum.RETRYING.getValue());
retryingFilter.setEndNextRetryTime(DateUtils.addMinutes(new Date(), -1));
List<RabbitConsumeFailRecord> retryingRecords = manager.listRecords(retryingFilter, consumeRetryBatch);
if (CollectionUtils.isEmpty(retryingRecords)) {
return;
}
retryingRecords.forEach(this::sendMessage2Queue);
}
private void sendMessage2Queue(RabbitConsumeFailRecord record) {
try {
Message message = ServerRabbitUtils.recover2Message(record);
CorrelationData correlationData = new CorrelationData();
correlationData.setId(record.getRecordId().toString());
sendRabbitMqService.send(record.getVirtualHost(), record.getQueue(), message, correlationData);
} catch (Throwable e) {
log.error("fail to requeue message, record=" + JSON.toJSONString(record), e);
}
}
private DistributionLock createLock() {
LockInfo lockInfo = new LockInfo();
lockInfo.setId(CONSUME_RETRY_DISTRIBUTION_LOCK_KEY);
lockInfo.setModule(moduleName);
//單位:ms 超時獲取時間
lockInfo.setWaitTime(5000);
lockInfo.setLockProvider(LockProviderTypeEnum.ZOOKEEPER);
return lockManager.createLock(lockInfo);
}
private Boolean getDistributionLock(DistributionLock lock) {
try {
while (!lock.tryLock()) {
}
} catch (Exception e) {
log.error("分佈式鎖獲取失敗", e);
return Boolean.FALSE;
}
return Boolean.TRUE;
}
private void releaseDistributionLock(DistributionLock lock) {
lock.unlock();
}
}