一、單執行緒順序消費
為了避免有的小夥伴第一次接觸順序消費的概念,我還是先介紹一下順序消費是個什麼東西。
雙十一,大量的使用者搶在0點下訂單。為了使用者的友好體驗,我們把訂單生成邏輯與支付邏輯包裝成一個個的MQ訊息傳送到Kafka中,讓kafka積壓部分訊息,防止瞬間的流量壓垮服務。
那麼這裡的問題就出現了,訂單生成與支付都被包裝成了訊息。這兩個訊息是有嚴格的先後順序的,訂單生成邏輯肯定在支付之前。
那麼kafka怎麼保證它們的順序呢?
不同topic:
如果支付與訂單生成對應不同的topic,你只能在consumer層面去處理了。而因為consumer是分散式的,所以你為了保證順序消費,只能找一箇中間方(比如redis的佇列)來維護MQ的順序,成本太大,邏輯太噁心。
同一個topic:
如果我們把訊息傳送到同一個topic呢?我們知道一個topic可以對應多個分割槽,分別對應了多個consumer。其實與不同topic沒什麼本質上的差別。
同一個topic,同一個分割槽:
Kafka的訊息在分割槽內是嚴格有序的。也就是說我們可以把同一筆訂單的所有訊息,按照生成的順序一個個傳送到同一個topic的同一個分割槽。那麼consumer就能順序的消費到同一筆訂單的訊息。
生產者在傳送訊息時,將訊息對應的id進行取模處理,相同的id傳送到相同的分割槽。訊息在分割槽內有序,一個分割槽對應了一個消費者,保證了訊息消費的順序性。
二、多執行緒順序消費
單執行緒順序消費已經解決了順序消費的問題,但是它的擴充套件能力很差。為了提升消費者的處理速度,但又要保證順序性,我們只能橫向擴充套件分割槽數,增加消費者。
這就意味著需要加機器來增加你的系統處理能力。
emmm,是的,那你離開除不遠了。
不行就加機器,老闆給你死。
所以說我們必然要在消費者端接收到kafka訊息後做併發處理。
我們來捋一下,如果我們拿到訊息,直接把訊息扔到執行緒池呢?
不合理,執行緒的處理速度有快慢,還是會導致支付訊息快於訂單訊息處理。
我是不是可以模仿一下kafka的分割槽思想操作。將接收到的kafka資料進行hash取模(注意注意,你kafka分割槽接受訊息已經是取模的了,這裡一定要對id做一次hash再取模)
傳送到不同的佇列,然後我們開啟多個執行緒去消費對應佇列裡面的資料。
蕪湖,nice~
三、多執行緒消費程式碼實現
整體思路:
- 在應用啟動時初始化對應業務的順序消費執行緒池(demo中為訂單消費執行緒池)
- 訂單監聽類拉取訊息提交任務至執行緒池中對應的佇列
- 執行緒池的執行緒處理繫結佇列中的任務資料
- 每個執行緒處理完任務後增加待提交的offsets標識數
- 監聽類中校驗待提交的offsets數與拉取到的記錄數是否相等,如果相等則手動提交offset
3.1.順序消費執行緒池定義
我們可以透過指定消費的執行緒數來提升訊息的處理能力。
/** * kafka順序消費工具類執行緒池1.0 * * 平滑擴容縮容待設計,stopped的鉤子可以支援 * * @author baiyan * @date 2022/01/19 */ @Slf4j @Data public class KafkaConsumerPool<E> { /** * 執行緒併發級別 */ private Integer concurrentSize; /** * 工作執行緒執行緒 */ private List<Thread> workThreads; /** * 任務處理佇列 */ private List<ConcurrentLinkedQueue<E>> queues; /** * 是否全量停止任務,留個鉤子,以便後續動態擴容 */ private volatile boolean stopped; /** * 待提交的記錄數 */ private AtomicLong pendingOffsets; /** * kafka執行緒名字首 */ private final static String KAFKA_CONSUMER_WORK_THREAD_PREFIX = "kafka-sort-consumer-thread-"; /** * 順序消費任務池初始化 * * @param config 業務配置 */ public KafkaConsumerPool(KafkaSortConsumerConfig<E> config){ this.concurrentSize = config.getConcurrentSize(); //初始化任務佇列 this.initQueue(); this.workThreads = new ArrayList<>(); this.stopped = false; this.pendingOffsets = new AtomicLong(0L); //初始化執行緒 this.initWorkThread(config.getBizName(),config.getBizService()); } /** * 初始化佇列 */ private void initQueue(){ this.queues = new ArrayList<>(); for (int i = 0; i < this.concurrentSize; i++) { this.queues.add(new ConcurrentLinkedQueue<>()); } } /** * 初始化工作執行緒 */ private void initWorkThread(String bizName, Consumer<E> bizService){ //建立規定的執行緒 for (int i = 0; i < this.concurrentSize; i++) { String threadName = KAFKA_CONSUMER_WORK_THREAD_PREFIX + bizName + i; int num = i; Thread workThread = new Thread(()->{ //如果佇列不為空 或者 執行緒標識為false則進入迴圈 while (!queues.get(num).isEmpty() || !stopped){ try{ E task = pollTask(threadName,bizName); if(Objects.nonNull(task)){ //模擬業務處理耗時 bizService.accept(task); log.info("執行緒:{},執行任務:{},成功",threadName, GsonUtil.beanToJson(task)); //執行完成的任務加1 pendingOffsets.incrementAndGet(); } }catch (Exception e){ log.error("執行緒:{},執行任務:{},失敗",threadName,e); } } log.info("執行緒:{}退出",threadName); },threadName); //加入執行緒管理 workThreads.add(workThread); //開啟執行緒 workThread.start(); } } /** * 根據id取模,將需要保證順序的任務新增至同一佇列 * * @param id 能夠取模的鍵 * @param task 需要提交處理的任務 */ public void submitTask(Long id, E task){ ConcurrentLinkedQueue<E> taskQueue = queues.get((int) (id % this.concurrentSize)); taskQueue.offer(task); } /** * 根據執行緒名獲取對應的待執行的任務 * * @param threadName 執行緒名稱 * @return 佇列內的任務 */ private E pollTask(String threadName,String bizName){ int threadNum = Integer.valueOf(threadName.replace(KAFKA_CONSUMER_WORK_THREAD_PREFIX+bizName, "")); ConcurrentLinkedQueue<E> taskQueue = queues.get(threadNum); return taskQueue.poll(); } }
流程圖
3.2.消費者端
一個消費者可以消費多個topic,所以說,每個需要多執行緒順序處理的監聽類都需要單獨繫結一個順序消費執行緒池。
在監聽類接受到訊息之後透過執行緒池提交待執行的任務執行。
這裡我們需要關閉kafka的自動提交,待本次拉取到的任務處理完成之後再提交位移。
/** * 訂單消費者 * * @author baiyan * @date 2022/01/19 */ @Component @Slf4j @ConfigurationProperties(prefix = "kafka.order") @Data @EqualsAndHashCode(callSuper = false) public class OrderKafkaListener extends AbstractConsumerSeekAware { @Autowired private OrderService orderService; /** * 順序消費併發級別 */ private Integer concurrent; /** * order業務順序消費池 */ private KafkaConsumerPool<OrderDTO> kafkaConsumerPool; /** * 初始化順序消費池 */ @PostConstruct public void init(){ KafkaSortConsumerConfig<OrderDTO> config = new KafkaSortConsumerConfig<>(); config.setBizName("order"); config.setBizService(orderService::solveRetry); config.setConcurrentSize(concurrent); kafkaConsumerPool = new KafkaConsumerPool<>(config); } @KafkaListener(topics = {"${kafka.order.topic}"}, containerFactory = "baiyanCommonFactory") public void consumerMsg(List<ConsumerRecord<?, ?>> records, Acknowledgment ack){ if(records.isEmpty()){ return; } records.forEach(consumerRecord->{ OrderDTO order = GsonUtil.gsonToBean(consumerRecord.value().toString(), OrderDTO.class); kafkaConsumerPool.submitTask(order.getId(),order); }); // 當執行緒池中任務處理完成的計數達到拉取到的記錄數時提交 // 注意這裡如果存在部分業務阻塞時間很長,會導致位移提交不上去,務必做好一些熔斷措施 while (true){ if(records.size() == kafkaConsumerPool.getPendingOffsets().get()){ ack.acknowledge(); log.info("offset提交:{}",records.get(records.size()-1).offset()); kafkaConsumerPool.getPendingOffsets().set(0L); break; } } } }
對應資料處理流程圖
3.3.擴充套件點
demo中我們提供的思路是定死的併發級別數去處理訊息。
但是比如叫車軟體,早高峰跟晚高峰的時候是流量的高峰期,對應的叫車訊息負載會很高。而平峰的時候流量就會小很多。
所以我們應該在高峰期設定一個相對較高的併發級別數用來快速處理訊息,平峰期設定一個較小的併發級別數來讓出系統資源。
難道我們要不斷的重啟應用去修改併發級別數?太麻瓜了。
我在如何使用nacos在分散式環境下同步全域性配置提到過,美團提供了一種配置中心修改配置動態設定執行緒池引數的思路。
我們同樣可以模仿這個思路去實現動態的擴容或者縮容順序消費執行緒池。
我的demo中為了讓大家更好的理解並沒有實現這部分的邏輯,但是我留了一個鉤子。
在KafkaConsumerPool中有一個屬性是stopped,將它設定為true是可以中斷啟動中的執行緒池,但是會將待執行的任務執行完畢再退出。
因此如果我們要實現動態擴容,可以透過配置中心重新整理OrderKafkaListener監聽類中的配置concurrent的值,在透過set方法修改concurrent的值時,先修改stopped的值去停止當前正在執行的執行緒池。執行完畢後透過新的併發級別數新建一個新的執行緒池,實現了動態擴容與縮容。
不過這裡需要注意哦,擴容階段的時候,記得阻塞kafka的資料的消費提交,會報錯哦~
最後,貼上流程圖
四、總結
本文為大家介紹了kafka單執行緒與多執行緒順序消費的思路。兩者都是透過將訊息繫結到定向的分割槽或者佇列來保證順序性,透過增加分割槽或者執行緒來提升消費能力。