前言
寫這篇文章的起因是由於之前的一篇關於Kafka
異常消費,當時為了解決問題不得不使用臨時的方案。
總結起來歸根結底還是對Kafka不熟悉導致的,加上平時工作的需要,之後就花些時間看了Kafka
相關的資料。
何時使用MQ
談到Kafka
就不得不提到MQ,是屬於訊息佇列的一種。作為一種基礎中介軟體在網際網路專案中有著大量的使用。
一種技術的產生自然是為了解決某種需求,通常來說是以下場景:
- 需要跨程式通訊:B系統需要A系統的輸出作為輸入引數。
- 當A系統的輸出能力遠遠大於B系統的處理能力。
針對於第一種情況有兩種方案:
- 使用
RPC
遠端呼叫,A直接呼叫B。 - 使用
MQ
,A釋出訊息到MQ
,B訂閱該訊息。
當我們的需求是:A呼叫B實時響應,並且實時關心響應結果則使用RPC
,這種情況就得使用同步呼叫。
反之當我們並不關心呼叫之後的執行結果,並且有可能被呼叫方的執行非常耗時,這種情況就非常適合用MQ
來達到非同步呼叫目的。
比如常見的登入場景就只能用同步呼叫的方式,因為這個過程需要實時的響應結果,總不能在使用者點了登入之後排除網路原因之外再額外的等幾秒吧。
但類似於使用者登入需要獎勵積分的情況則使用MQ
會更好,因為登入並不關係積分的情況,只需要發個訊息到MQ
,處理積分的服務訂閱處理即可,這樣還可以解決積分系統故障帶來的雪崩效應。
MQ
還有一個基礎功能則是限流削峰,這對於大流量的場景如果將請求直接呼叫到B系統則非常有可能使B系統出現不可用的情況。這種場景就非常適合將請求放入MQ
,不但可以利用MQ
削峰還儘可能的保證系統的高可用。
Kafka簡介
本次重點討論下Kafka
。
簡單來說Kafka
是一個支援水平擴充套件,高吞吐率的分散式訊息系統。
Kafka
的常用知識:
Topic
:生產者和消費者的互動都是圍繞著一個Topic
進行的,通常來說是由業務來進行區分,由生產消費者協商之後進行建立。Partition
(分割槽):是Topic
下的組成,通常一個Topic
下有一個或多個分割槽,訊息生產之後會按照一定的演算法負載到每個分割槽,所以分割槽也是Kafka
效能的關鍵。當發現效能不高時便可考慮新增分割槽。
結構圖如下:
建立Topic
Kafka
的安裝官網有非常詳細的講解。這裡談一下在日常開發中常見的一些操作,比如建立Topic
:
sh bin/kafka-topics.sh --create --zookeeper localhost:2181 --replication-factor 1 --partitions 3 --topic `test`複製程式碼
建立了三個分割槽的test
主題。
使用
sh bin/kafka-topics.sh --list --zookeeper localhost:2181複製程式碼
可以列出所有的Topic
。
Kafka生產者
使用kafka
官方所提供的Java API
來進行訊息生產,實際使用中編碼實現更為常用:
/** Kafka生產者
* @author crossoverJie
*/
public class Producer {
private static final Logger LOGGER = LoggerFactory.getLogger(Producer.class);
/**
* 消費配置檔案
*/
private static String consumerProPath;
public static void main(String[] args) throws IOException {
// set up the producer
consumerProPath = System.getProperty("product_path");
KafkaProducer<String, String> producer = null;
try {
FileInputStream inputStream = new FileInputStream(new File(consumerProPath));
Properties properties = new Properties();
properties.load(inputStream);
producer = new KafkaProducer<String, String>(properties);
} catch (IOException e) {
LOGGER.error("load config error", e);
}
try {
// send lots of messages
for (int i=0 ;i<100 ; i++){
producer.send(new ProducerRecord<String, String>(
"topic_optimization", i+"", i+""));
}
} catch (Throwable throwable) {
System.out.printf("%s", throwable.getStackTrace());
} finally {
producer.close();
}
}
}複製程式碼
再配合以下啟動引數即可傳送訊息:
-Dproduct_path=/xxx/producer.properties複製程式碼
以及生產者的配置檔案:
#叢集地址,可以多個
bootstrap.servers=10.19.13.51:9094
acks=all
retries=0
batch.size=16384
auto.commit.interval.ms=1000
linger.ms=0
key.serializer=org.apache.kafka.common.serialization.StringSerializer
value.serializer=org.apache.kafka.common.serialization.StringSerializer
block.on.buffer.full=true複製程式碼
具體的配置說明詳見此處:kafka.apache.org/0100/docume…
流程非常簡單,其實就是一些API
的呼叫。
訊息發完之後可以通過以下命令檢視佇列內的情況:
sh kafka-consumer-groups.sh --bootstrap-server localhost:9094 --describe --group group1複製程式碼
其中的
lag
便是佇列裡的訊息數量。
Kafka消費者
有了生產者自然也少不了消費者,這裡首先針對單執行緒消費:
/**
* Function:kafka官方消費
*
* @author crossoverJie
* Date: 2017/10/19 01:11
* @since JDK 1.8
*/
public class KafkaOfficialConsumer {
private static final Logger LOGGER = LoggerFactory.getLogger(KafkaOfficialConsumer.class);
/**
* 日誌檔案地址
*/
private static String logPath;
/**
* 主題名稱
*/
private static String topic;
/**
* 消費配置檔案
*/
private static String consumerProPath ;
/**
* 初始化引數校驗
* @return
*/
private static boolean initCheck() {
topic = System.getProperty("topic") ;
logPath = System.getProperty("log_path") ;
consumerProPath = System.getProperty("consumer_pro_path") ;
if (StringUtil.isEmpty(topic) || logPath.isEmpty()) {
LOGGER.error("system property topic ,consumer_pro_path, log_path is required !");
return true;
}
return false;
}
/**
* 初始化kafka配置
* @return
*/
private static KafkaConsumer<String, String> initKafkaConsumer() {
KafkaConsumer<String, String> consumer = null;
try {
FileInputStream inputStream = new FileInputStream(new File(consumerProPath)) ;
Properties properties = new Properties();
properties.load(inputStream);
consumer = new KafkaConsumer<String, String>(properties);
consumer.subscribe(Arrays.asList(topic));
} catch (IOException e) {
LOGGER.error("載入consumer.props檔案出錯", e);
}
return consumer;
}
public static void main(String[] args) {
if (initCheck()){
return;
}
int totalCount = 0 ;
long totalMin = 0L ;
int count = 0;
KafkaConsumer<String, String> consumer = initKafkaConsumer();
long startTime = System.currentTimeMillis() ;
//消費訊息
while (true) {
ConsumerRecords<String, String> records = consumer.poll(200);
if (records.count() <= 0){
continue ;
}
LOGGER.debug("本次獲取:"+records.count());
count += records.count() ;
long endTime = System.currentTimeMillis() ;
LOGGER.debug("count=" +count) ;
if (count >= 10000 ){
totalCount += count ;
LOGGER.info("this consumer {} record,use {} milliseconds",count,endTime-startTime);
totalMin += (endTime-startTime) ;
startTime = System.currentTimeMillis() ;
count = 0 ;
}
LOGGER.debug("end totalCount={},min={}",totalCount,totalMin);
/*for (ConsumerRecord<String, String> record : records) {
record.value() ;
JsonNode msg = null;
try {
msg = mapper.readTree(record.value());
} catch (IOException e) {
LOGGER.error("消費訊息出錯", e);
}
LOGGER.info("kafka receive = "+msg.toString());
}*/
}
}
}複製程式碼
配合以下啟動引數:
-Dlog_path=/log/consumer.log -Dtopic=test -Dconsumer_pro_path=consumer.properties複製程式碼
其中採用了輪詢的方式獲取訊息,並且記錄了消費過程中的資料。
消費者採用的配置:
bootstrap.servers=192.168.1.2:9094
group.id=group1
# 自動提交
enable.auto.commit=true
key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
value.deserializer=org.apache.kafka.common.serialization.StringDeserializer
# fast session timeout makes it more fun to play with failover
session.timeout.ms=10000
# These buffer sizes seem to be needed to avoid consumer switching to
# a mode where it processes one bufferful every 5 seconds with multiple
# timeouts along the way. No idea why this happens.
fetch.min.bytes=50000
receive.buffer.bytes=262144
max.partition.fetch.bytes=2097152複製程式碼
為了簡便我採用的是自動提交offset
。
訊息存放機制
談到offset
就必須得談談Kafka的訊息存放機制.
Kafka
的訊息不會因為消費了就會立即刪除,所有的訊息都會持久化到日誌檔案,並配置有過期時間,到了時間會自動刪除過期資料,並且不會管其中的資料是否被消費過。
由於這樣的機制就必須的有一個標誌來表明哪些資料已經被消費過了,offset(偏移量)
就是這樣的作用,它類似於指標指向某個資料,當消費之後offset
就會線性的向前移動,這樣一來的話訊息是可以被任意消費的,只要我們修改offset
的值即可。
消費過程中還有一個值得注意的是:
同一個consumer group(group.id相等)下只能有一個消費者可以消費,這個剛開始確實會讓很多人踩坑。
多執行緒消費
針對於單執行緒消費實現起來自然是比較簡單,但是效率也是要大打折扣的。
為此我做了一個測試,使用之前的單執行緒消費120009條資料的結果如下:
總共花了12450毫秒。
那麼換成多執行緒消費怎麼實現呢?
我們可以利用partition
的分割槽特性來提高消費能力,單執行緒的時候等於是一個執行緒要把所有分割槽裡的資料都消費一遍,如果換成多執行緒就可以讓一個執行緒只消費一個分割槽,這樣效率自然就提高了,所以執行緒數coreSize<=partition
。
首先來看下入口:
public class ConsumerThreadMain {
private static String brokerList = "localhost:9094";
private static String groupId = "group1";
private static String topic = "test";
/**
* 執行緒數量
*/
private static int threadNum = 3;
public static void main(String[] args) {
ConsumerGroup consumerGroup = new ConsumerGroup(threadNum, groupId, topic, brokerList);
consumerGroup.execute();
}
}複製程式碼
其中的ConsumerGroup
類:
public class ConsumerGroup {
private static Logger LOGGER = LoggerFactory.getLogger(ConsumerGroup.class);
/**
* 執行緒池
*/
private ExecutorService threadPool;
private List<ConsumerCallable> consumers ;
public ConsumerGroup(int threadNum, String groupId, String topic, String brokerList) {
ThreadFactory namedThreadFactory = new ThreadFactoryBuilder()
.setNameFormat("consumer-pool-%d").build();
threadPool = new ThreadPoolExecutor(threadNum, threadNum,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(1024), namedThreadFactory, new ThreadPoolExecutor.AbortPolicy());
consumers = new ArrayList<ConsumerCallable>(threadNum);
for (int i = 0; i < threadNum; i++) {
ConsumerCallable consumerThread = new ConsumerCallable(brokerList, groupId, topic);
consumers.add(consumerThread);
}
}
/**
* 執行任務
*/
public void execute() {
long startTime = System.currentTimeMillis() ;
for (ConsumerCallable runnable : consumers) {
Future<ConsumerFuture> future = threadPool.submit(runnable) ;
}
if (threadPool.isShutdown()){
long endTime = System.currentTimeMillis() ;
LOGGER.info("main thread use {} Millis" ,endTime -startTime) ;
}
threadPool.shutdown();
}
}複製程式碼
最後真正的執行邏輯ConsumerCallable
:
public class ConsumerCallable implements Callable<ConsumerFuture> {
private static Logger LOGGER = LoggerFactory.getLogger(ConsumerCallable.class);
private AtomicInteger totalCount = new AtomicInteger() ;
private AtomicLong totalTime = new AtomicLong() ;
private AtomicInteger count = new AtomicInteger() ;
/**
* 每個執行緒維護KafkaConsumer例項
*/
private final KafkaConsumer<String, String> consumer;
public ConsumerCallable(String brokerList, String groupId, String topic) {
Properties props = new Properties();
props.put("bootstrap.servers", brokerList);
props.put("group.id", groupId);
//自動提交位移
props.put("enable.auto.commit", "true");
props.put("auto.commit.interval.ms", "1000");
props.put("session.timeout.ms", "30000");
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
this.consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList(topic));
}
/**
* Computes a result, or throws an exception if unable to do so.
*
* @return computed result
* @throws Exception if unable to compute a result
*/
@Override
public ConsumerFuture call() throws Exception {
boolean flag = true;
int failPollTimes = 0 ;
long startTime = System.currentTimeMillis() ;
while (flag) {
// 使用200ms作為獲取超時時間
ConsumerRecords<String, String> records = consumer.poll(200);
if (records.count() <= 0){
failPollTimes ++ ;
if (failPollTimes >= 20){
LOGGER.debug("達到{}次數,退出 ",failPollTimes);
flag = false ;
}
continue ;
}
//獲取到之後則清零
failPollTimes = 0 ;
LOGGER.debug("本次獲取:"+records.count());
count.addAndGet(records.count()) ;
totalCount.addAndGet(count.get()) ;
long endTime = System.currentTimeMillis() ;
if (count.get() >= 10000 ){
LOGGER.info("this consumer {} record,use {} milliseconds",count,endTime-startTime);
totalTime.addAndGet(endTime-startTime) ;
startTime = System.currentTimeMillis() ;
count = new AtomicInteger();
}
LOGGER.debug("end totalCount={},min={}",totalCount,totalTime);
/*for (ConsumerRecord<String, String> record : records) {
// 簡單地列印訊息
LOGGER.debug(record.value() + " consumed " + record.partition() +
" message with offset: " + record.offset());
}*/
}
ConsumerFuture consumerFuture = new ConsumerFuture(totalCount.get(),totalTime.get()) ;
return consumerFuture ;
}
}複製程式碼
理一下邏輯:
其實就是初始化出三個消費者例項,用於三個執行緒消費。其中加入了一些統計,最後也是消費120009條資料結果如下。
由於是並行執行,可見消費120009條資料可以提高2秒左右,當資料以更高的數量級提升後效果會更加明顯。
但這也有一些弊端:
- 靈活度不高,當分割槽數量變更之後不能自適應調整。
- 消費邏輯和處理邏輯在同一個執行緒,如果處理邏輯較為複雜會影響效率,耦合也較高。當然這個處理邏輯可以再通過一個內部佇列發出去由另外的程式來處理也是可以的。
總結
Kafka
的知識點還是較多,Kafka
的使用也遠不這些。之後會繼續分享一些關於Kafka
監控等相關內容。
個人部落格:crossoverjie.top。