Kafka 入門

jrh_2333發表於2021-06-20

一、生產者消費者模型與阻塞佇列

肯德基掃碼點餐流程

大多數人都在 KFC 掃碼點過餐。首先你會掃描二維碼進入小程式點餐,完成付款;付完款之後,你會收到你的取餐碼,等到你的訂單做好了,肯德基的服務員會通知你到前臺取餐。其模型如下圖所示:

生產者消費者模型

上面介紹的肯德基掃碼點餐流程實際上就是一個生產者消費者模型在生活中的經典應用。生產者消費者模型是一種程式設計模式,其被廣泛應用在解耦訊息佇列等場景。

在一個系統中,存在生產者和消費者兩種角色,它們通過記憶體緩衝區進行通訊,生產者產生消費者需要的資料,消費者把資料做成產品。生產者消費者模型如下圖所示:

阻塞佇列

我們要知道,記憶體緩衝區的容量大小不是無窮無盡的,就好比一個停車場,當所有的停車位都被佔滿時,外面的車輛只有等待裡面的車位騰出來才可以進入。

  • 生產者生產資料到緩衝區,消費者從緩衝區中取資料

  • 生產者的生產速度遠遠大於消費者的消費速度時;如果緩衝區已經滿了,那麼生產者的執行緒需要被阻塞

  • 消費者的消費速度遠遠大於生產者的生產速度時;如果緩衝區為空,那麼消費者的執行緒需要被阻塞

如何讓生產者,消費者的執行緒在上述情況中掛起?我們可以使用阻塞佇列(BlockingQueue)

我們來看一個示例程式:

程式碼地址:github.com/jinrunheng/blocking-que...

生產者生產速度大於消費者消費速度

Producer

package com.github.blockingqueuedemo;

import java.util.concurrent.BlockingQueue;

public class Producer implements Runnable {

    private BlockingQueue<Integer> queue;

    public Producer(BlockingQueue<Integer> queue) {
        this.queue = queue;
    }

    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            try {
                Thread.sleep(20);
                queue.put(i);
                System.out.println(Thread.currentThread().getName() + " produce. " + "blocking queue size is :" + queue.size());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

Consumer

package com.github.blockingqueuedemo;

import java.util.Random;
import java.util.concurrent.BlockingQueue;

public class Consumer implements Runnable {
    private BlockingQueue<Integer> queue;

    public Consumer(BlockingQueue<Integer> queue) {
        this.queue = queue;
    }

    @Override
    public void run() {
        while (true) {
            try {
                // 0 - 1000ms 消費一個資料,可以認為 生產速度大於消費速度
                Thread.sleep(new Random().nextInt(1000));
                queue.take();
                System.out.println(Thread.currentThread().getName() + " consume. " + "blocking queue size is :" + queue.size());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

Main

package com.github.blockingqueuedemo;

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

public class Main {
    public static void main(String[] args) {
        // 阻塞佇列的最大長度為 10
        BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);
        // 一個生產者,最多生產 100 個資料
        new Thread(new Producer(queue)).start();

        // 三個消費者執行緒
        new Thread(new Consumer(queue)).start();
        new Thread(new Consumer(queue)).start();
        new Thread(new Consumer(queue)).start();
    }
}

程式執行結果:

Thread-0 produce. blocking queue size is :1
Thread-0 produce. blocking queue size is :2
Thread-0 produce. blocking queue size is :3
Thread-0 produce. blocking queue size is :4
Thread-1 consume. blocking queue size is :3
Thread-0 produce. blocking queue size is :4
Thread-0 produce. blocking queue size is :5
Thread-0 produce. blocking queue size is :6
Thread-0 produce. blocking queue size is :7
Thread-0 produce. blocking queue size is :8
Thread-0 produce. blocking queue size is :9
Thread-0 produce. blocking queue size is :10
Thread-3 consume. blocking queue size is :9
Thread-0 produce. blocking queue size is :10
Thread-2 consume. blocking queue size is :9
Thread-0 produce. blocking queue size is :10
Thread-1 consume. blocking queue size is :9
... ...

我們看到,生產者的生產速度大於消費者消費速度的情況下,生產者會一直生產,直至阻塞佇列為滿,然後進入到阻塞狀態,之後消費者開始消費。

消費者的消費速度大於生產者的生產速度

將消費者的程式碼改動為:

Consumer

package com.github.blockingqueuedemo;

import java.util.Random;
import java.util.concurrent.BlockingQueue;

public class Consumer implements Runnable {
    private BlockingQueue<Integer> queue;

    public Consumer(BlockingQueue<Integer> queue) {
        this.queue = queue;
    }

    @Override
    public void run() {
        while (true) {
            try {
                // 0-10ms 消費一個資料,可以認為,消費速度要大於生產速度
                Thread.sleep(new Random().nextInt(10));
                queue.take();
                System.out.println(Thread.currentThread().getName() + " consume. " + "blocking queue size is :" + queue.size());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

程式執行的結果:

Thread-1 consume. blocking queue size is :0
Thread-0 produce. blocking queue size is :1
Thread-0 produce. blocking queue size is :1
Thread-3 consume. blocking queue size is :0
Thread-0 produce. blocking queue size is :1
Thread-2 consume. blocking queue size is :0
Thread-0 produce. blocking queue size is :1
Thread-1 consume. blocking queue size is :0
Thread-0 produce. blocking queue size is :1
Thread-3 consume. blocking queue size is :0
Thread-0 produce. blocking queue size is :1
Thread-2 consume. blocking queue size is :0
Thread-0 produce. blocking queue size is :1
Thread-1 consume. blocking queue size is :0
Thread-0 produce. blocking queue size is :1
Thread-3 consume. blocking queue size is :0
... ...

我們可以看到,當消費者的消費速度大於生產者的生產速度時,如果阻塞佇列為空,那麼消費者執行緒會進入阻塞狀態。

二、訊息佇列(MQ)

什麼是訊息佇列?

首先,訊息(Message) 指的是我們要傳輸的資料,佇列(Queue)指的就是存放我們傳輸資料的記憶體緩衝區。

訊息佇列從字面意思來看,它就是一個存放資料的容器,其本質還是使用了生產者消費者模型,生產者將資料放入到訊息佇列中,消費者從訊息佇列裡取出資料。

一般來說,訊息佇列是一種非同步的服務間通訊方式,是分散式系統中重要的元件;其主要解決 應用耦合非同步訊息流量削峰 等問題,實現高效能,高可用,可伸縮的一致性框架。常用的訊息佇列框架有:RabbitMQ,Kafka 等。

訊息佇列的使用場景

1. 系統解耦

舉例:使用者下訂單

使用者下訂單後,訂單系統需要通知庫存系統,傳統的做法是訂單系統來呼叫庫存系統的介面

這樣做的缺點是,兩者產生了耦合關係,假如庫存系統無法訪問,那麼訂單減庫存將會失敗,從而導致使用者下訂單失敗。

這個時候,可以使用訊息佇列來解除系統模組之間造成的耦合

使用者下訂單後,訂單系統作為生產者,將訊息寫入訊息佇列,返回使用者訂單下單成功;庫存系統作為消費者訂閱下單的訊息,進行庫存操作。即便庫存系統出現了問題,也不會影響使用者正常下訂單,可以等到庫存系統恢復,再來處理 MQ 中的訊息。這樣一來,我們就實現了訂單系統與庫存系統的解耦。為了保證庫存的數量,可以將訊息佇列的大小設定為當前庫存的數量,這樣就可以保證庫存的商品一定是有的。

2. 非同步通訊

舉例:依舊是使用者下訂單

假設使用者下訂單有幾個流程:

  1. 支付

  2. 優惠券系統

  3. 積分系統

  4. 傳送簡訊

  5. … …

如果使用傳統的序列方式:

我們看到如果使用傳統的方式來做下訂單的邏輯,第一個問題是各個系統之間的耦合,如果優惠券系統錯誤導致新增優惠券失敗,簡訊系統出現問題導致發簡訊失敗,就會導致使用者下訂單失敗;第二個問題是效能問題,使用者下訂單目前已經整合了好幾個系統模組了,如果後續還要新增其他的功能,那麼隨著支付的鏈路越來越長,使用者下訂單就會變成一個非常耗時的操作,嚴重影響使用者的體驗。

我們可以使用訊息佇列將序列的方式變為非同步通訊的方式:

我們使用訊息佇列將訊息非同步分發給各個系統模組,不僅做到了模組之間的解耦,還優化了系統的速度。

3. 流量削峰

舉例:

例如秒殺活動,雙十一活動,在某個時間點,伺服器會一瞬間收到大量的請求,你的伺服器,Redis,MySQL 的承受能力都不一樣,如果不做流量削峰處理,很有可能導致伺服器當機。

我們可以使用訊息佇列做流量削峰,將使用者所有的請求放到 MQ 中,按照自己的伺服器處理能力來設定每秒伺服器能處理多少請求。

訊息佇列的通訊模型

訊息佇列主要有兩種通訊模型:PTP 以及 Pub/Sub 模型

1. PTP

PTP 即 Point to Point ,點對點通訊

生產者傳送一條訊息到 MQ,只有一個消費者才能收到

2. Pub/Sub

Pub/Sub 即:Publisher/Subscriber ,釋出/訂閱通訊模型

釋出者傳送訊息到 Topic,所有訂閱了 Topic 的訂閱者都可以收到訊息

三、Kafka

Kafka是由Apache軟體基金會開發的一個開源流處理平臺,由ScalaJava編寫。該專案的目標是為處理實時資料提供一個統一、高吞吐、低延遲的平臺。其持久化層本質上是一個“按照分散式事務日誌架構的大規模釋出/訂閱訊息佇列”,這使它作為企業級基礎設施來處理流式資料非常有價值。

Kafka 的應用

  • 訊息佇列(採用 Pub/Sub 模型)

  • 日誌收集

  • 使用者行為追蹤

  • 流式處理

  • … …

Kafka 的特點

  • 高吞吐量(可以處理 TB 級的非同步訊息)

  • 訊息持久化

  • 高可靠性

  • 高擴充套件性

Kafka 模型

Kafka 術語

1. Topic

在 Kafka 中,Topic 是一個儲存訊息的邏輯概念,可以認為是一個訊息集合。具體的物理儲存是基於 Partition 來的。一個 Topic 可以劃分為多個分割槽(Partition),且每個 Topic 至少有一個分割槽。

2. Broker

一臺 Kafka 伺服器就是一個 Broker 。一個 Kafka 叢集由多個 Broker 組成,然後通過 Zookeeper 來進行叢集的管理。

如果 Topic 有 N 個 Partition,叢集有 N 個 Broker ,那麼每個 Broker 儲存該 Topic 的一個 Partition。

如果某 Topic 有 N 個 Partition,叢集有 (N+M) 個 Broker,那麼其中有 N 個 Broker儲存該 Topic的一個Partition,剩下的 M 個 Broker 不儲存該 Topic 的 Partition資料。

如果某 Topic有 N 個 Partition,叢集中 Broker數目少於 N 個,那麼一個 Broker 儲存該 Topic 的一個或多個 Partition。在實際生產環境中,儘量避免這種情況的發生,這種情況容易導致 Kafka 叢集資料不均衡。

3. Partition

Partition (分割槽),是 Kafka 下資料儲存的基本單元。Topic 是一個邏輯的概念,Partition 則是物理的概念。分割槽是實際儲存在 Broker 上的。每個 Partition 都是一個有序的佇列,通過提升分割槽數量可以提升同一個 Topic 的資料吞吐量。

4. offset

每個訊息被新增到 Partition 時,都會分配唯一的 offset,以保證 Partition 內訊息的順序性。消費者通過 offset 定位並讀取訊息,且哥哥消費者持有的 offset 是自己的消費進度。

5. Replica

Replica 即副本,也就是 Partition 的一個備份。一個分割槽(Partition)只能有一個leader,但是可以設定多個副本(follower),同一分割槽的副本不能在同一臺機器上。也就是說如果有2 臺 Broker,那麼一個分割槽就最多會有 1 個副本。leader 的主要作用是:完成與生產者、消費者的互動;follower的主要作用是:做資料備份,當 leader 發生故障時,某個 follower 會成為新的 leader,以此來保證 Kafka 的可用性。

Kafka 下載與安裝

Kafka 官網:kafka.apache.org/

Kafka 下載:kafka.apache.org/downloads

Kafka 的基本使用

下載好 Kafka 後,我們來使用基本的生產者消費者模型來傳送,接收訊息。

  1. 啟動 Zookeeper

在 Kafka 的安裝包 bin 目錄下執行命令:

sh zookeeper-server-start.sh ../config/zookeeper.properties

啟動 Zookeeper

  1. 啟動 Kafka

啟動 Zookeeper 後,新開一個 Terminal ,在 Kafka 的安裝包 bin 目錄下執行命令:

sh kafka-server-start.sh ../config/server.properties

啟動 Kafka

  1. 建立一個 Topic

再開一個 Terminal 視窗,進入到 Kafka 安裝包 bin 目錄下執行命令:

sh kafka-topics.sh --create --bootstrap-server localhost:9092 --replication-factor 1 --partitions 1 --topic test

我們建立了一個主題(Topic) test;檢視剛剛建立的主,使用命令:

sh kafka-topics.sh --list --bootstrap-server localhost:9092

可以看到,返回結果:

➜  bin sh kafka-topics.sh --list --bootstrap-server localhost:9092
test

說明我們建立 Topic 成功。

  1. 呼叫生產者傳送訊息

新開一個 Terminal 視窗,進入到 Kafka 安裝包 bin 目錄下,使用命令:

sh kafka-console-producer.sh --broker-list localhost:9092 --topic test

傳送訊息內容如下:

  1. 呼叫消費者接受訊息

新開一個 Terminal 視窗,進入到 Kafka 安裝包 bin 目錄下,使用命令:

sh kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic test --from-beginning

我們可以看到消費者接受到了生產者傳送的訊息:

開啟 Zookeeper 和 Kafka 有嚴謹的順序,一定要先啟動ZooKeeper 再啟動Kafka;先關閉kafka ,再關閉zookeeper ,順序不可以改變。

Spring 整合 Kafka

  • 引入依賴

    <dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
    </dependency>
  • 配置 application,properties

    # KafkaProperties
    spring.kafka.bootstrap-servers=localhost:9092
    spring.kafka.consumer.group-id=test-consumer-group
    # 是否自動提交消費者的偏移量
    spring.kafka.consumer.enable-auto-commit=true
    # 提交的頻率 單位為ms
    spring.kafka.consumer.auto-commit-interval=3000
  • 訪問 Kafka

    • 生產者
      kafkaTemplate.send(topic,data);
    • 消費者
      @KafkaListener(topics = {"test"})
      public void handleMessage(ConsumerRecord record){...}

示例程式

原始碼地址:github.com/jinrunheng/spring-kafka-...

首先開啟 Zookeeper 與 Kafka 服務

KafkaProducer

package com.github.springkafkademo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Component;

@Component
public class KafkaProducer {

    @Autowired
    private KafkaTemplate kafkaTemplate;

    public void sendMessage(String topic, String content) {
        kafkaTemplate.send(topic, content);
    }
}

KafkaConsumer

package com.github.springkafkademo;

import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

@Component
public class KafkaConsumer {

    @KafkaListener(topics = {"test"})
    public void receiveMessage(ConsumerRecord record) {
        System.out.println(record.value());
    }
}

Test

package com.github.springkafkademo;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ContextConfiguration;

@SpringBootTest
@ContextConfiguration(classes = SpringKafkaDemoApplication.class)
class SpringKafkaDemoApplicationTests {

    @Autowired
    private KafkaProducer producer;

    @Test
    public void kafka() {
        producer.sendMessage("test", "hello");
        producer.sendMessage("test", "kafka");

        try {
            Thread.sleep(1000 * 10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

執行測試,程式 10 秒後,消費者接受到生產者的資料

hello
kafka

參考文章

www.zhihu.com/question/54152397

www.cnblogs.com/chentingk/p/649710...

www.cnblogs.com/weixuqin/p/1143098...

www.cnblogs.com/qingyunzong/p/9004...

blog.csdn.net/xiaolyuh123/article/...

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章