實戰:RocketMQ削峯,這一篇就夠了

前言

MQ的主要特點爲解耦異步削峯,該文章主要記錄與分享個人在實際項目中的RocketMQ削峯用法,用於減少數據庫壓力的業務場景,其中RocketMQ的核心組件概念如下:

  • Producer:生產發送消息
  • Broker:存儲Producer發送過來的消息
  • Consumer:從Broker拉取消息並進行消費
  • NameServer:爲Producer或Consumer路由到Broker
    Model.png

其中消費流程有以下幾點是必須注意的:

  • RocketMQ的Consumer獲取消息是通過向Broker發送拉取請求獲取的,而不是由Broker發送Consumer接收的方式。
  • Consumer每次拉取消息時消息都會被均勻分發到消息隊列再進行傳輸,所以RocketMQ中的很多參數都是針對隊列而不是Topic的(這個是重點,順便吐槽下源碼的文檔講的真不清晰,很多都需要自己試錯,但Dashboard做得很好),其中每個Broker消息隊列(ConsumeQueue)的數量都可以通過RocketMQ DashBoard實時更改調整。

rocketmq-spring-boot-starter 用法簡介

當開發中需要快速集成RocketMQ時可以考慮使用 rocketmq-spring-boot-starter 搭建RocketMQ的集成環境,但該框架並不完全具備RocketMQ所有的配置簡化,如需批量消費消息便需要自定義一個DefaultMQPushConsumer bean去消費了。
個人在開發中常用的rocketmq-spring-boot-starter相關類:

  • RocketMQListener接口:消費者都需實現該接口的消費方法onMessage(msg)
  • RocketMQPushConsumerLifecycleListener接口:當@RocketMQMessageListener中的配置不足以滿足我們的需求時,可以實現該接口直接更改消費者類DefaultMQPushConsumer配置
  • @RocketMQMessageListener:被該註解標註並實現了接口RocketMQListener的bean爲一個消費者並監聽指定topic隊列中的消息,該註解中包含消費者的一些常用配置(大部分按默認即可),一般只需更改consumerGroup(消費組)與topic。RocketMQMessageListener中的屬性配置是可以使用Placeholder(佔位符)從配置文件或配置中心獲取的,如下圖:
    RocketMQMessageListener.png

業務案例

有一個點贊業務,不限制用戶的點贊數只需進行記錄(產品需求,開發提議無效),當每個用戶都進行x連擊享受數量猛增的快感時如果數據庫都需要進行x個點贊數據的插入,數據庫毫無疑問會塞死導致崩潰。於是想到可以嘗試下MQ削峯,比如每秒來了5000消息但數據庫只能承受2000,那我消費時每次只拉取消費1600就好了,剩下的放在Broker堆積慢慢消費就好。由於之前的消息中心也在用RocketMQ,於是確認使用RocketMQ來進行削峯。

praise-clap-peak.png

環境配置

文章例子環境:1NameServer + 2Broker + 1Consumer

添加maven依賴

<dependency>
    <groupId>org.apache.rocketmq</groupId>
    <artifactId>rocketmq-spring-boot-starter</artifactId>
</dependency>

application.yml配置

rocketmq:
  name-server: 127.0.0.1:9876
  producer:
    group: praise-group
server:
  port: 10000

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: tiger
    url: jdbc:mysql://localhost:3306/wilson
swagger:
  docket:
    base-package: io.rocket.consumer.controller

點贊接口

PraiseRecord(點贊記錄):

@Data
public class PraiseRecord implements Serializable {
    private Long id;
    private Long uid;
    private Long liveId;
    private LocalDateTime createTime;
}

MessageController(簡單的測試接口):

RestController
@RequestMapping("/message")
public class MessageController {
    @Resource
    private RocketMQTemplate rocketMQTemplate;

    @PostMapping("/praise")
    public ServerResponse praise(@RequestBody PraiseRecordVO vo) {
        rocketMQTemplate.sendOneWay(RocketConstant.Topic.PRAISE_TOPIC, MessageBuilder.withPayload(vo).build());
        return ServerResponse.success();
    }

    // ......

}

由於用戶可以連續點贊,所以考慮可以在點贊消息的處理上寬鬆一點(容許消息丟失)以追求更高的性能,因此選擇使用sendOneyWay()進行消息發送。

RocketMQ的消息發送方式主要含syncSend()同步發送、asyncSend()異步發送、sendOneWay()三種方式,sendOneWay()也是異步發送,區別在於不需等待Broker返回確認,所以可能會存在信息丟失的狀況,但吞吐量更高,具體需根據業務情況選用。性能:sendOneWay > asyncSend > syncSend
RocketMQTemplate的send()方法默認是同步(syncSend)的,更多可看源碼實現。

PraiseListener:點贊消息消費者

@Service
@RocketMQMessageListener(topic = RocketConstant.Topic.PRAISE_TOPIC, consumerGroup = RocketConstant.ConsumerGroup.PRAISE_CONSUMER)
@Slf4j
public class PraiseListener implements RocketMQListener<PraiseRecordVO>, RocketMQPushConsumerLifecycleListener {
    @Resource
    private PraiseRecordService praiseRecordService;

    @Override
    public void onMessage(PraiseRecordVO vo) {
        praiseRecordService.insert(vo.copyProperties(PraiseRecord::new));
    }

    @Override
    public void prepareStart(DefaultMQPushConsumer consumer) {
        // 每次拉取的間隔,單位爲毫秒
        consumer.setPullInterval(2000);
        // 設置每次從隊列中拉取的消息數爲16
        consumer.setPullBatchSize(16);
    }
}

單次pull消息的最大數目受broker存儲的MessageStoreConfig.maxTransferCountOnMessageInMemory(默認爲32)值限制,即若想要消費者從隊列拉取的消息數大於32有效(pullBatchSize>32)則需更改Broker的啓動參數maxTransferCountOnMessageInMemory值。在MQ削峯的配置參數裏,以下幾個DefaultMQPushConsumer的參數是需要注意一下的:

  • pullInterval:每次從Broker拉取消息的間隔,單位爲毫秒
  • pullBatchSize:每次從Broker隊列拉取到的消息數,該參數很容易讓人誤解,一開始我以爲是每次拉取的消息總數,但測試過幾次後確認了實質上是從每個隊列的拉取數(源碼上的註釋文檔真的很差,跟沒有一樣),即Consume每次拉取的消息總數如下:
    EachPullTotal=所有Broker上的寫隊列數和(writeQueueNums=readQueueNums) * pullBatchSize
  • consumeMessageBatchMaxSize:每次消費(即將多條消息合併爲List消費)的最大消息數目,默認值爲1,rocketmq-spring-boot-starter 目前不支持批量消費(2.1.0版本)

在消費者開始消息消費時會先從各隊列中拉取一條消息進行消費,消費成功後再以每次pullBatchSize的數目進行拉取。

PraiseListener中設置了每次拉取的間隔爲2s,每次從隊列拉取的消息數爲16,在搭建了2master broker且broker上writeQueueNums=readQueueNums=4的環境下每次拉取的消息理論數值爲16 * 2 * 4 = 128,在第一次從各隊列拉取1條消息(即共8條)後消費成功後會每次就會拉取最多128條消息進行消費,想驗證下的可以把onMessage()的insert()改爲log.info(“1”)然後統計單位秒內打印的日誌數是否爲128。
praise-topic.png

根據以上配置單Conumer情況下每2s理論消費爲128,即每2秒數據庫新增的點贊數據大概爲128條左右,有20%偏差都在個人可接受範圍內,然後對點贊接口進行簡單壓測1s 2000請求校驗MQ效果,根據消費配置理論上需要16次拉取即需32s才能消費完,壓測後查看數據庫校驗效果:
praise-jmeter.png
praise-jmeter-db.png
由上圖可以看出除第一次2s和最後一次2s外數據庫每2s的插入數據數和一般都在128附近波動,也用了34s(因第一次拉取數較少所以比理論多花費一次拉取)消費的偏差大小可能會受每次拉取數pullBatchSize、Broker上的消息隊列數、網絡波動等情況影響,但需要的目的已經達到了,我只想把單位時間內過多的數據庫操作交給MQ做分隔成多個單位時間內的小批量操作,消息過多就堆積,當請求峯值過了後直到MQ堆積的消息消費完前數據庫的插入數依舊會與峯值期的插入數相差不大,達到了MQ削峯填谷的效果。

上線了但消費效率預估失誤如何動態更改消費效率 ?

當把拉取數pullBatchSize設置Broker的默認最大傳輸值32了,線上又不想重啓Broker更改maxTransferCountOnMessageInMemory參數,如有2個Broker且queue都爲4,那麼拉取消費效率才爲32 * 2 * 4 = 256,如果想要動態調整,可以從Broker數或Broker隊列數下手,可以將Broker的writeQueueNums、readQueueNums增大,如都改爲8,那麼效率就成了32 * 2 * 8 = 512。需要注意的是更改完queues後必須去Dashboard的Topic下的CONSUMER MANAGER查看新增的隊列上是否都有Consumer成功註冊上去了,因爲遇到了在測試與生產上使用rocketmq-spring-boot-starter @RocketMQListener標註消費者不會自動註冊到新隊列上的情況,但沒排除是不是RocketMQ版本的原因(個人本地的版本比環境上的高了一個小版本0.0.1,本地沒出現沒消費者註冊到新隊列上的問題),而是使用了自定義DefaultMQPushConsumer bean(原生的方式都是沒有問題的)的備用方案。當再啓動新的消費者應用時CONSUMER MANAGER(下圖)中就會出現 新Consumer數 * 各Broker隊列數和的隊列行。
praise-topic-consumer.png

如何使用RocketMQ批量消費 ?

雖然點贊業務使用MQ單條插入後TPS已經達到當前業務指標要求了,但考慮到如果後續要求在不添加機器數的情況下增加TPS,且數據量還沒到分庫分表的程度,個人就打算從批量消費下手,由一次插入一條點贊記錄改爲一次性插入多條(insertBatch)。當然能滿足現有需求能不做肯定不做的,過度優化過分礙事,但想多點方案不會壞事。rocketmq-spring-boot-starter並沒有提供批量消費的功能,所以要批量消費消息需要自定義DefaultMQPushConsumer並配置其consumeMessageBatchMaxSize屬性。consumeMessageBatchMaxSize屬性默認值爲1,即每次只消費一條消息,需要注意的是該屬性也會受pullBatchSize影響,如果consumeMessageBatchMaxSize爲32但pullBatchSize只爲12,那麼每次批量消費的最大消息數也就只有12。
如下爲個人測試批量消費Consumer的測試bean:

@Bean
public DefaultMQPushConsumer userMQPushConsumer() throws MQClientException {
    DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(RocketConstant.ConsumerGroup.SPRING_BOOT_USER_CONSUMER);
    consumer.setNamesrvAddr(nameServer);
    consumer.subscribe(RocketConstant.Topic.SPRING_BOOT_USER_TOPIC, "*");
    // 設置每次消息拉取的時間間隔,單位毫秒
    consumer.setPullInterval(1000);
    // 設置每個隊列每次拉取的最大消息數
    consumer.setPullBatchSize(24);
    // 設置消費者單次批量消費的消息數目上限
    consumer.setConsumeMessageBatchMaxSize(12);
    consumer.registerMessageListener((MessageListenerConcurrently) (msgs, context)
            -> {
        List<UserInfo> userInfos = new ArrayList<>(msgs.size());
        Map<Integer, Integer> queueMsgMap = new HashMap<>(8);
        msgs.forEach(msg -> {
            userInfos.add(JSONObject.parseObject(msg.getBody(), UserInfo.class));
            queueMsgMap.compute(msg.getQueueId(), (key, val) -> val == null ? 1 : ++val);
        });
        log.info("userInfo size: {}, content: {}", userInfos.size(), userInfos);
        /*
          處理批量消息,如批量插入:userInfoMapper.insertBatch(userInfos);
         */
        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
    });
    consumer.start();
    return consumer;
}

如果默認配置情況下log打印出的userInfo size恆爲1,但由於設置了consumeMessageBatchMaxSizepullBatchSize,且pullBatchSize較小,所以每次消費的消息數最大值爲12,如下圖:
consume-batch.png

附本文相關信息

  • 確保mqnamesrv與mqbroker已啓動成功,如該文章環境的啓動:
    mqnamesrv -n 127.0.0.1:9876
    mqbroker -c E:\RocketMQ\rocketmq-all-4.5.2-bin-release\bin\2m-noslave\broker-a.properties
    mqbroker -c E:\RocketMQ\rocketmq-all-4.5.2-bin-release\bin\2m-noslave\broker-b.properties
    
  • RocketMQ DashBoard啓動流程可參考官方github文檔或到我的資源裏下載jar包運行
  • 源碼地址,2m-noslave目錄是該文章中例子中的2master broker配置與啓動腳本,spring-boot-consumer-peak目錄爲包含該文章相關代碼的實際例子
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章