原文連結:Pulsar の 保證訊息的順序性、冪等性和可靠性

一、背景

前面兩篇文章,已經介紹了關於Pulsar消費者的詳細使用自研的Pulsar元件

接下來,將簡單分析如何保證訊息的順序性、冪等性和可靠性;但並不會每個分析都會進行程式碼實戰,進行程式碼實戰的都是比較有意思的點,如消費訊息如何保證順序性和冪等性,而其他的其實都是比較簡單的,就不做程式碼實戰了。

二、特性分析

2.1、順序性

保證訊息是按順序傳送,按順序消費,一個接著一個。

2.1.1、活動圖

2.1.2、分析

producer:

傳送者保證訊息的順序性其實是比較簡單的:

  1. 利用單佇列傳送

    • 一個業務對應一個佇列
    • 一個佇列只能由一個消費者監聽消費
  2. 利用 Pulsar 的分割槽Topic
    • producer傳送訊息時需要指定key屬性,Pulsar自動會根據Key值將訊息分配到指定的分割槽中
    • 支援多個消費者消費,多個消費者可以監聽同一個分割槽,但是相同的Key只會分配給同一個消費者

生產者這裡就不做什麼實戰的,都是比較簡單的點,沒啥好說的。

consumer:

消費者保證訊息的順序性有下面兩種方式:

  1. 當前執行緒執行

    • 單執行緒執行保證了消費的順序性
    • 消費效率低
  2. 自定義執行緒池列表非同步併發消費
    • 如果直接使用執行緒池,那麼雖然能提高消費效率,但是並不能保證順序性
    • 這裡我們會自定義執行緒池列表,列表中的執行緒池的核心執行緒數和最大執行緒數都是1,保證順序消費
    • Producer傳送的訊息體中,需指定key,我們會根據key#hashCode定位到對應的執行緒池,這裡參考HashMap的做法。

2.1.3、程式碼實戰

消費者保證訊息順序性的第二點的實現還是比較有意思的:如何自定義執行緒池列表、如何根據訊息的key來定位執行緒池。

程式碼如下:

  1. 傳送訊息:
/**
* 指定key傳送訊息
* @author winfun
**/
@Slf4j
public class ThirdProducerDemo { public static void main(String[] args) throws PulsarClientException {
PulsarClient client = PulsarClient.builder()
.serviceUrl("pulsar://127.0.0.1:6650")
.build(); ProducerBuilder<String> productBuilder = client.newProducer(Schema.STRING).topic("winfun/study/test-topic3")
.blockIfQueueFull(Boolean.TRUE).batchingMaxMessages(100).enableBatching(Boolean.TRUE).sendTimeout(3, TimeUnit.SECONDS); Producer<String> producer = productBuilder.create();
for (int i = 0; i < 100; i++) {
MsgDTO msgDTO = new MsgDTO();
String content = "hello"+i;
String key;
if (content.contains("1")){
key = "k213e434y1df";
}else if (content.contains("2")){
key = "keasdgashgfy2";
}else {
key = "other";
}
msgDTO.setId(key);
msgDTO.setContent(content);
producer.send(JSONUtil.toJsonStr(msgDTO));
}
producer.close();
}
}
  1. 消費訊息
/**
* 順序性消費-消費者demo
* @author: winfun
**/
@Slf4j
@PulsarListener(topics = {"test-topic3"})
public class SuccessionConsumerListener extends BaseMessageListener { List<ExecutorService> executorServiceList = new ArrayList<>(); /**
* 初始化自定義執行緒池列表
*/
@PostConstruct
public void initCustomThreadPool(){
for (int i = 0; i < 10; i++) {
/**
* 1、核心執行緒數和最大執行緒數都為1,避免多執行緒消費導致順序被打亂
* 2、使用有界佇列,設定最大長度,避免無限任務數導致OOM
* 3、使用CallerRunsPolicy拒絕策略,讓當前執行緒執行,避免訊息丟失,也可以直接讓消費者執行當前任務,阻塞住其他任務,也能保證順序性
*/
ExecutorService threadPoolExecutor = new ThreadPoolExecutor(
1,
1,
60,
TimeUnit.MINUTES,
new LinkedBlockingDeque<>(100),
new ThreadFactoryBuilder().setNameFormat(String.format("custom-thread-pool-%d",i)).get(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
this.executorServiceList.add(threadPoolExecutor);
}
} /**
* 消費訊息
* 自定義監聽器實現方法
* 訊息如何響應由開發者決定:
* Consumer#acknowledge
* Consumer#reconsumeLater
* Consumer#negativeAcknowledge
*
* @param consumer 消費者
* @param msg 訊息
*/
@Override
protected void doReceived(Consumer<String> consumer, Message<String> msg) {
String value = msg.getValue();
MsgDTO msgDTO = JSONUtil.toBean(value, MsgDTO.class);
// 匹配列表中對應的執行緒池
int index = (this.executorServiceList.size()-1)&this.spreed(msgDTO.getId().hashCode());
log.info("成功獲取執行緒池列表索引,msgId is {}, index is {}",msgDTO.getId(),index);
ExecutorService executorService = this.executorServiceList.get(index);
executorService.execute(()->{
log.info("成功消費訊息,threadName is {},msg is {}",Thread.currentThread().getName(),msg);
consumer.acknowledgeAsync(msg);
});
} /**
* hashCode擴充套件,保證hashCode前後十六位都能完美進行位運算
* @param hashCode
* @return
*/
private int spreed(int hashCode){
return (hashCode ^ (hashCode >>> 16)) & hashCode;
} /***
* 是否開啟非同步消費,預設開啟
* @return {@link Boolean }
**/
@Override
public Boolean enableAsync() {
// 首先關閉執行緒池非同步併發消費
return Boolean.FALSE;
}
}

2.2、冪等性

冪等性的話,我們主要是分析一下消費者的,如何保證消費者只正確消費一次訊息還是非常重要的。

2.2.1、活動圖

2.2.2、分析

producer:

生產者如何保證冪等性,感覺這個話題沒什麼好討論的,如果發生失敗就重新發送,否則就正常傳送就好了。

consumer:

消費者保證訊息冪等性,最主要是利用中間表來儲存消費記錄:

  1. 本地新增表來儲存訊息消費記錄
  2. 在訊息消費前,先判斷MessageId判斷是否存在消費記錄
  3. 如果存在,直接響應
  4. 如果不存在,則開啟本地事務,接著進行訊息消費
  5. 當成功消費時提交事務,否則回滾

2.2.3、程式碼實戰

如何利用消費記錄表和本地事務來完成訊息消費的冪等性,看下面程式碼:

  1. 傳送訊息
**
*
* @author winfun
**/
@Slf4j
public class FourthProducerDemo { public static void main(String[] args) throws PulsarClientException {
PulsarClient client = PulsarClient.builder()
.serviceUrl("pulsar://127.0.0.1:6650")
.build(); ProducerBuilder<String> productBuilder = client.newProducer(Schema.STRING).topic("winfun/study/test-topic4")
.blockIfQueueFull(Boolean.TRUE).batchingMaxMessages(100).enableBatching(Boolean.TRUE).sendTimeout(3, TimeUnit.SECONDS); Producer<String> producer = productBuilder.create();
for (int i = 0; i < 20; i++) {
MsgDTO msgDTO = new MsgDTO();
String content = "hello"+i;
String key;
if (content.contains("1")){
key = "k213e434y1df";
}else if (content.contains("2")){
key = "keasdgashgfy2";
}else {
key = "other";
}
msgDTO.setId(key);
msgDTO.setContent(content);
producer.send(JSONUtil.toJsonStr(msgDTO));
}
producer.close();
}
}
  1. 消費訊息
package com.github.howinfun.consumer.idempotent;

import cn.hutool.json.JSONUtil;
import com.github.howinfun.core.entity.MessageConsumeRecord;
import com.github.howinfun.core.service.MessageConsumeRecordService;
import com.github.howinfun.dto.MsgDTO;
import io.github.howinfun.listener.BaseMessageListener;
import io.github.howinfun.listener.PulsarListener;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import javax.annotation.PostConstruct;
import jodd.util.concurrent.ThreadFactoryBuilder;
import lombok.extern.slf4j.Slf4j;
import org.apache.pulsar.client.api.Consumer;
import org.apache.pulsar.client.api.Message;
import org.apache.pulsar.client.api.PulsarClientException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional; /**
* 冪等性消費-消費者demo
* @author: winfun
* @date: 2021/9/2 12:49 下午
**/
@Slf4j
@PulsarListener(topics = {"test-topic4"})
public class IdempotentConsumerListener extends BaseMessageListener { List<ExecutorService> executorServiceList = new ArrayList<>(); @Autowired
private MessageConsumeRecordService service; /**
* 初始化自定義執行緒池列表
*/
@PostConstruct
public void initCustomThreadPool(){
for (int i = 0; i < 10; i++) {
/**
* 1、核心執行緒數和最大執行緒數都為1,避免多執行緒消費導致順序被打亂
* 2、使用有界佇列,設定最大長度,避免無限任務數導致OOM
* 3、使用CallerRunsPolicy拒絕策略,讓當前執行緒執行,避免訊息丟失,也可以直接讓消費者執行當前任務,阻塞住其他任務,也能保證順序性
*/
ExecutorService threadPoolExecutor = new ThreadPoolExecutor(
1,
1,
60,
TimeUnit.MINUTES,
new LinkedBlockingDeque<>(100),
new ThreadFactoryBuilder().setNameFormat(String.format("custom-thread-pool-%d",i)).get(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
this.executorServiceList.add(threadPoolExecutor);
}
} /**
* 消費訊息
* 自定義監聽器實現方法
* 訊息如何響應由開發者決定:
* Consumer#acknowledge
* Consumer#reconsumeLater
* Consumer#negativeAcknowledge
*
* @param consumer 消費者
* @param msg 訊息
*/
@Override
protected void doReceived(Consumer<String> consumer, Message<String> msg) {
boolean flag = preReceived(msg);
if (Boolean.FALSE.equals(flag)){
String value = msg.getValue();
MsgDTO msgDTO = JSONUtil.toBean(value, MsgDTO.class);
int index = (this.executorServiceList.size()-1)&this.spreed(msgDTO.getId().hashCode());
log.info("成功獲取執行緒池列表索引,msgId is {}, index is {}",msgDTO.getId(),index);
ExecutorService executorService = this.executorServiceList.get(index);
executorService.execute(()->{
try {
this.doInnerReceived(consumer,msg);
} catch (PulsarClientException e) {
log.error("訊息消費失敗",e);
}
});
}else {
log.info("此訊息的消費記錄已存在,直接響應,messageId is {}", msg.getMessageId().toString());
try {
consumer.acknowledge(msg);
} catch (PulsarClientException e) {
log.error("訊息提交失敗",e);
}
}
} /**
* 消費前判斷,messageId是否存在對應的消費記錄
* @param msg 訊息
* @return 存在結果
*/
private boolean preReceived(Message<String> msg){
MessageConsumeRecord record = this.service.getByMessageId(msg.getMessageId().toString());
if (Objects.isNull(record)){
return false;
}
return true;
} /**
* 訊息消費
* @param consumer 消費者
* @param msg 訊息
*/
@Transactional(rollbackFor = Exception.class)
public void doInnerReceived(Consumer<String> consumer,Message<String> msg) throws PulsarClientException {
String messageContent = msg.getValue();
String messageId = msg.getMessageId().toString();
log.info("成功消費訊息,threadName is {},msg is {}",Thread.currentThread().getName(),messageContent);
this.service.save(new MessageConsumeRecord()
.setMessageId(messageId)
.setMessageContent(messageContent)
.setCreateTime(new Date()));
// 模擬重複消費,如果訊息內容包含8,則插入資料庫,但是不響應
if (messageContent.contains("8")){
log.info("訊息已被消費入庫,但不響應,模擬重複消費,messageId is {},messageContent is {}",messageId,messageContent);
}else {
consumer.acknowledge(msg);
}
} /**
* hashCode擴充套件,保證hashCode前後十六位都能完美計算
* @param hashCode
* @return
*/
private int spreed(int hashCode){
return (hashCode ^ (hashCode >>> 16)) & hashCode;
} /***
* 是否開啟非同步消費,預設開啟
* @return {@link Boolean }
**/
@Override
public Boolean enableAsync() {
// 首先關閉執行緒池非同步併發消費
return Boolean.FALSE;
}
}

2.3、可靠性

2.3.1、活動圖

生產者:

消費者:

關於保證訊息的可靠性,我們只分析 Producer 和 Consuemr,Pulsar伺服器就不分析了。

2.3.2、分析

producer:

生產者主要還是利用中間表來保證訊息傳送的可靠性:

  1. 傳送訊息前,先插入一條傳送記錄表
  2. 接著開啟本地事務,開始傳送訊息
  3. 傳送完畢,接到broker返回的響應
  4. 更新發送記錄為已傳送
  5. 開啟定時任務,定時掃描未傳送的記錄,重新進行傳送

consumer:

消費者保證訊息的可靠性,只需要利用Pulsar提供的重試策略即可:

  1. 開啟重試策略,指定重試次數、重試佇列和死信佇列
  2. 捕獲異常,呼叫reconsumeLater方法進行重新消費
  3. 監控死信佇列,即使進行訊息消費異常人工處理