可靠mq(一)——mq可靠消費

問題:

  • 解決問題的大方向
  • 爲什麼直接把消息發回消息隊列
  • 數據保存在本地 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();
    }
 
}

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章