一、整合Kafka
1.1 引入依賴
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
1.2 設置yml文件
spring:
application:
name: demo
kafka:
bootstrap-servers: 52.82.98.209:10903,52.82.98.209:10904
producer: # producer 生產者
retries: 0 # 重試次數
acks: 1 # 應答級別:多少個分區副本備份完成時向生產者發送ack確認(可選0、1、all/-1)
batch-size: 16384 # 批量大小
buffer-memory: 33554432 # 生產端緩沖區大小
key-serializer: org.apache.kafka.common.serialization.StringSerializer
# value-serializer: com.itheima.demo.config.MySerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
consumer: # consumer消費者
group-id: javagroup # 默認的消費組ID
enable-auto-commit: true # 是否自動提交offset
auto-commit-interval: 100 # 提交offset延時(接收到消息后多久提交offset)
# earliest:當各分區下有已提交的offset時,從提交的offset開始消費;無提交的offset時,從頭開始消費
# latest:當各分區下有已提交的offset時,從提交的offset開始消費;無提交的offset時,消費新產生的該分區下的數據
# none:topic各分區都存在已提交的offset時,從offset后開始消費;只要有一個分區不存在已提交的offset,則拋出異常
auto-offset-reset: latest
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
# value-deserializer: com.itheima.demo.config.MyDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
二、Hello Kafka
2.1 簡單生產者
@RestController
public class KafkaProducer {
@Autowired
private KafkaTemplate<String, Object> kafkaTemplate;
// 發送消息
@GetMapping("/kafka/normal/{message}")
public void sendMessage1(@PathVariable("message") String normalMessage) {
kafkaTemplate.send("topic1", normalMessage);
}
}
2.2 簡單消費
@Component
public class KafkaConsumer {
// 消費監聽
@KafkaListener(topics = {"topic1"})
public void onMessage1(ConsumerRecord<?, ?> record){
// 消費的哪個topic、partition的消息,打印出消息內容
System.out.println("簡單消費:"+record.topic()+"-"+record.partition()+"-"+record.value());
}
}
上面示例創建了一個生產者,發送消息到topic1,消費者監聽topic1消費消息。監聽器用@KafkaListener注解,topics表示監聽的topic,支持同時監聽多個,用英文逗號分隔。
三、生產者
3.1 帶回調的生產者
kafkaTemplate提供了一個回調方法addCallback,我們可以在回調方法中監控消息是否發送成功 或 失敗時做補償處理,有兩種寫法
3.1.1 方式一
@GetMapping("/kafka/callbackOne/{message}")
public void sendMessage2(@PathVariable("message") String callbackMessage) {
kafkaTemplate.send("topic1", callbackMessage).addCallback(success -> {
// 消息發送到的topic
String topic = success.getRecordMetadata().topic();
// 消息發送到的分區
int partition = success.getRecordMetadata().partition();
// 消息在分區內的offset
long offset = success.getRecordMetadata().offset();
System.out.println("發送消息成功:" + topic + "-" + partition + "-" + offset);
}, failure -> {
System.out.println("發送消息失敗:" + failure.getMessage());
});
}
3.1.2 方式二
@GetMapping("/kafka/callbackTwo/{message}")
public void sendMessage3(@PathVariable("message") String callbackMessage) {
kafkaTemplate.send("topic1", callbackMessage).addCallback(new ListenableFutureCallback<SendResult<String, Object>>() {
@Override
public void onFailure(Throwable ex) {
System.out.println("發送消息失敗:"+ex.getMessage());
}
@Override
public void onSuccess(SendResult<String, Object> result) {
System.out.println("發送消息成功:" + result.getRecordMetadata().topic() + "-"
+ result.getRecordMetadata().partition() + "-" + result.getRecordMetadata().offset());
}
});
}
3.2 自定義分區器
我們知道,kafka中每個topic被劃分為多個分區,那么生產者將消息發送到topic時,具體追加到哪個分區呢?這就是所謂的分區策略,Kafka 為我們提供了默認的分區策略,同時它也支持自定義分區策略。其路由機制為:
若發送消息時指定了分區(即自定義分區策略),則直接將消息append到指定分區;
若發送消息時未指定 patition,但指定了 key(kafka允許為每條消息設置一個key),則對key值進行hash計算,根據計算結果路由到指定分區,這種情況下可以保證同一個 Key 的所有消息都進入到相同的分區;
patition 和 key 都未指定,則使用kafka默認的分區策略,輪詢選出一個 patition;
我們來自定義一個分區策略,將消息發送到我們指定的partition,首先新建一個分區器類實現Partitioner接口,重寫方法,其中partition方法的返回值就表示將消息發送到幾號分區
public class CustomizePartitioner implements Partitioner {
@Override
public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
// 自定義分區規則(這里假設全部發到0號分區)
// ......
return 0;
}
@Override
public void close() {
}
@Override
public void configure(Map<String, ?> configs) {
}
}
在application.propertise中配置自定義分區器,配置的值就是分區器類的全路徑名
# 自定義分區器
spring.kafka.producer.properties.partitioner.class=com.felix.kafka.producer.CustomizePartitioner
3.3 Kafka事務提交
如果在發送消息時需要創建事務,可以使用 KafkaTemplate 的 executeInTransaction 方法來聲明事務
@GetMapping("/kafka/transaction")
public void sendMessage7(){
// 聲明事務:后面報錯消息不會發出去
kafkaTemplate.executeInTransaction(operations -> {
operations.send("topic1","test executeInTransaction");
throw new RuntimeException("fail");
});
// 不聲明事務:后面報錯但前面消息已經發送成功了
kafkaTemplate.send("topic1","test executeInTransaction");
throw new RuntimeException("fail");
}
3.4 生產者發送監聽器
異步發送的消息怎么確認發送情況呢?我們可以使用注冊監聽。
@Configuration
public class KafkaListener {
private final static Logger logger = LoggerFactory.getLogger(KafkaListener.class);
@Autowired
KafkaTemplate kafkaTemplate;
//配置監聽
@PostConstruct
private void listener() {
kafkaTemplate.setProducerListener(new ProducerListener<String, Object>() {
@Override
public void onSuccess(ProducerRecord<String, Object> producerRecord, RecordMetadata recordMetadata) {
logger.info("ok,message={}", producerRecord.value());
}
@Override
public void onError(ProducerRecord<String, Object> producerRecord, Exception exception) {
logger.error("error!message={}", producerRecord.value());
}
});
}
}
異步發送失敗的消息會被回調給注冊過的listener。
四、消費者
4.1 指定topic、partition、offset消費
前面我們在監聽消費topic1的時候,監聽的是topic1上所有的消息,如果我們想指定topic、指定partition、指定offset來消費呢?也很簡單,@KafkaListener注解已全部為我們提供。
/**
* @Title 指定topic、partition、offset消費
* @Description 同時監聽topic1和topic2,監聽topic1的0號分區、topic2的 "0號和1號" 分區,指向1號分區的offset初始值為8
* @Author long.yuan
* @Date 2020/3/22 13:38
* @Param [record]
* @return void
**/
@KafkaListener(id = "consumer1",groupId = "felix-group",topicPartitions = {
@TopicPartition(topic = "topic1", partitions = { "0" }),
@TopicPartition(topic = "topic2", partitions = "0", partitionOffsets = @PartitionOffset(partition = "1", initialOffset = "8"))
})
public void onMessage2(ConsumerRecord<?, ?> record) {
System.out.println("topic:"+record.topic()+"|partition:"+record.partition()+"|offset:"+record.offset()+"|value:"+record.value());
}
屬性解釋
id:消費者ID;
groupId:消費組ID;
topics:監聽的topic,可監聽多個;
topicPartitions:可配置更加詳細的監聽信息,可指定topic、parition、offset監聽。
上面onMessage2監聽的含義:監聽topic1的0號分區,同時監聽topic2的0號分區和topic2的1號分區里面offset從8開始的消息。
注意:topics和topicPartitions不能同時使用。
4.2 批量消費
設置application.prpertise開啟批量消費即可
# 設置批量消費
spring.kafka.listener.type=batch
# 批量消費每次最多消費多少條消息
spring.kafka.consumer.max-poll-records=50
接收消息時用List來接收,監聽代碼如下
@KafkaListener(id = "consumer2",groupId = "felix-group", topics = "topic1")
public void onMessage3(List<ConsumerRecord<?, ?>> records) {
System.out.println(">>>批量消費一次,records.size()="+records.size());
for (ConsumerRecord<?, ?> record : records) {
System.out.println(record.value());
}
}
4.3 ConsumerAwareListenerErrorHandler異常處理器
通過異常處理器,我們可以處理consumer在消費時發生的異常。新建一個 ConsumerAwareListenerErrorHandler 類型的異常處理方法,用@Bean注入,BeanName默認就是方法名,然后我們將這個異常處理器的BeanName放到@KafkaListener注解的errorHandler屬性里面,當監聽拋出異常的時候,則會自動調用異常處理器。
// 新建一個異常處理器,用@Bean注入
@Bean
public ConsumerAwareListenerErrorHandler consumerAwareErrorHandler() {
return (message, exception, consumer) -> {
System.out.println("消費異常:"+message.getPayload());
return null;
};
}
// 將這個異常處理器的BeanName放到@KafkaListener注解的errorHandler屬性里面
@KafkaListener(topics = {"topic1"},errorHandler = "consumerAwareErrorHandler")
public void onMessage4(ConsumerRecord<?, ?> record) throws Exception {
throw new Exception("簡單消費-模擬異常");
}
// 批量消費也一樣,異常處理器的message.getPayload()也可以拿到各條消息的信息
@KafkaListener(topics = "topic1",errorHandler="consumerAwareErrorHandler")
public void onMessage5(List<ConsumerRecord<?, ?>> records) throws Exception {
System.out.println("批量消費一次...");
throw new Exception("批量消費-模擬異常");
}
4.4 消息過濾器
消息過濾器可以在消息抵達consumer之前被攔截,在實際應用中,我們可以根據自己的業務邏輯,篩選出需要的信息再交由KafkaListener處理,不需要的消息則過濾掉。配置消息過濾只需要為 監聽器工廠 配置一個RecordFilterStrategy(消息過濾策略),返回true的時候消息將會被拋棄,返回false時,消息能正常抵達監聽容器。
@Component
public class KafkaConsumer {
@Autowired
ConsumerFactory consumerFactory;
// 消息過濾器
@Bean
public ConcurrentKafkaListenerContainerFactory filterContainerFactory() {
ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory();
factory.setConsumerFactory(consumerFactory);
// 被過濾的消息將被丟棄
factory.setAckDiscarded(true);
// 消息過濾策略
factory.setRecordFilterStrategy(consumerRecord -> {
if (Integer.parseInt(consumerRecord.value().toString()) % 2 == 0) {
return false;
}
//返回true消息則被過濾
return true;
});
return factory;
}
// 消息過濾監聽
@KafkaListener(topics = {"topic1"},containerFactory = "filterContainerFactory")
public void onMessage6(ConsumerRecord<?, ?> record) {
System.out.println(record.value());
}
}
上面實現了一個"過濾奇數、接收偶數"的過濾策略,我們向topic1發送0-99總共100條消息,看一下監聽器的消費情況,可以看到監聽器只消費了偶數。
4.5 消息轉發
在實際開發中,我們可能有這樣的需求,應用A從TopicA獲取到消息,經過處理后轉發到TopicB,再由應用B監聽處理消息,即一個應用處理完成后將該消息轉發至其他應用,完成消息的轉發。在SpringBoot集成Kafka實現消息的轉發也很簡單,只需要通過一個@SendTo注解,被注解方法的return值即轉發的消息內容。
/**
* @Title 消息轉發
* @Description 從topic1接收到的消息經過處理后轉發到topic2
* @Author long.yuan
* @Date 2020/3/23 22:15
* @Param [record]
* @return void
**/
@KafkaListener(topics = {"topic1"})
@SendTo("topic2")
public String onMessage7(ConsumerRecord<?, ?> record) {
return record.value()+"-forward message";
}
4.6 定時啟動、停止監聽器
默認情況下,當消費者項目啟動的時候,監聽器就開始工作,監聽消費發送到指定topic的消息,那如果我們不想讓監聽器立即工作,想讓它在我們指定的時間點開始工作,或者在我們指定的時間點停止工作,該怎么處理呢——使用KafkaListenerEndpointRegistry,下面我們就來實現:
禁止監聽器自啟動;
創建兩個定時任務,一個用來在指定時間點啟動定時器,另一個在指定時間點停止定時器;
新建一個定時任務類,用注解@EnableScheduling聲明,KafkaListenerEndpointRegistry 在SpringIO中已經被注冊為Bean,直接注入,設置禁止KafkaListener自啟動。
@EnableScheduling
@Component
public class CronTimer {
/**
* @KafkaListener注解所標注的方法并不會在IOC容器中被注冊為Bean,
* 而是會被注冊在KafkaListenerEndpointRegistry中,
* 而KafkaListenerEndpointRegistry在SpringIOC中已經被注冊為Bean
**/
@Autowired
private KafkaListenerEndpointRegistry registry;
@Autowired
private ConsumerFactory consumerFactory;
// 監聽器容器工廠(設置禁止KafkaListener自啟動)
@Bean
public ConcurrentKafkaListenerContainerFactory delayContainerFactory() {
ConcurrentKafkaListenerContainerFactory container = new ConcurrentKafkaListenerContainerFactory();
container.setConsumerFactory(consumerFactory);
//禁止KafkaListener自啟動
container.setAutoStartup(false);
return container;
}
// 監聽器
@KafkaListener(id="timingConsumer",topics = "topic1",containerFactory = "delayContainerFactory")
public void onMessage1(ConsumerRecord<?, ?> record){
System.out.println("消費成功:"+record.topic()+"-"+record.partition()+"-"+record.value());
}
// 定時啟動監聽器
@Scheduled(cron = "0 42 11 * * ? ")
public void startListener() {
System.out.println("啟動監聽器...");
// "timingConsumer"是@KafkaListener注解后面設置的監聽器ID,標識這個監聽器
if (!registry.getListenerContainer("timingConsumer").isRunning()) {
registry.getListenerContainer("timingConsumer").start();
}
//registry.getListenerContainer("timingConsumer").resume();
}
// 定時停止監聽器
@Scheduled(cron = "0 45 11 * * ? ")
public void shutDownListener() {
System.out.println("關閉監聽器...");
registry.getListenerContainer("timingConsumer").pause();
}
}
4.7 序列化
消費者使用:KafkaConsumer.java
@Component
public class KafkaConsumer {
private final Logger logger = LoggerFactory.getLogger(KafkaConsumer.class);
//不指定group,默認取yml里配置的
@KafkaListener(topics = {"test"})
public void onMessage1(ConsumerRecord<?, ?> consumerRecord) {
Optional<?> optional = Optional.ofNullable(consumerRecord.value());
if (optional.isPresent()) {
Object msg = optional.get();
logger.info("message:{}", msg);
}
}
}
4.7.1 序列化詳解
前面用到的是Kafka自帶的字符串序列化器(org.apache.kafka.common.serialization.StringSerializer)。除此之外還有:ByteArray、ByteBuffer、Bytes、Double、Integer、Long等。這些序列化器都實現了接口(org.apache.kafka.common.serialization.Serializer)。基本上,可以滿足絕大多數場景。
4.7.2 自定義序列化
自己實現,實現對應的接口即可,有以下方法:
public interface Serializer<T> extends Closeable {
default void configure(Map<String, ?> configs, Boolean isKey) {
}
//理論上,只實現這個即可正常運行
byte[] serialize(String var1, T var2);
//默認調上面的方法
default byte[] serialize(String topic, Headers headers, T data) {
return this.serialize(topic, data);
}
default void close() {
}
}
我們來自己實現一個序列化器:MySerializer.java
public class MySerializer implements Serializer {
@Override
public byte[] serialize(String s, Object o) {
String json = JSON.toJSONString(o);
return json.getBytes();
}
}
4.7.3 解碼
MyDeserializer.java,實現方式與編碼器幾乎一樣
public class MyDeserializer implements Deserializer {
private final static Logger logger = LoggerFactory.getLogger(MyDeserializer.class);
@Override
public Object deserialize(String s, byte[] bytes) {
try {
String json = new String(bytes,"utf-8");
return JSON.parse(json);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return null;
}
}
在yaml中配置自己的編碼器、解碼器。
4.8 位移提交
4.8.1 自動提交
前面的案例中,我們設置了以下兩個選項,則kafka會按延時設置自動提交
enable-auto-commit: true # 是否自動提交offset
auto-commit-interval: 100 # 提交offset延時(接收到消息后多久提交offset,默認單位為ms)
4.8.2 手動提交
有些時候,我們需要手動控制偏移量的提交時機,比如確保消息嚴格消費后再提交,以防止丟失或重復。下面我們自己定義配置,覆蓋上面的參數,代碼參考:MyOffsetConfig.java
@Configuration
public class MyOffsetConfig {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
@Value("${spring.kafka.bootstrap-servers}")
private String bootstrapServers;
@Bean
public KafkaListenerContainerFactory<?> manualKafkaListenerContainerFactory() {
Map<String, Object> configProps = new HashMap<>();
configProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
configProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
configProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
// 注意這里!!!設置手動提交
configProps.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
ConcurrentKafkaListenerContainerFactory<String, String> factory =
new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(new DefaultKafkaConsumerFactory<>(configProps));
// ack模式:
// AckMode針對ENABLE_AUTO_COMMIT_CONFIG=false時生效,有以下幾種:
//
// RECORD
// 每處理一條commit一次
//
// BATCH(默認)
// 每次poll的時候批量提交一次,頻率取決于每次poll的調用頻率
//
// TIME
// 每次間隔ackTime的時間去commit(跟auto commit interval有什么區別呢?)
//
// COUNT
// 累積達到ackCount次的ack去commit
//
// COUNT_TIME
// ackTime或ackCount哪個條件先滿足,就commit
//
// MANUAL
// listener負責ack,但是背后也是批量上去
//
// MANUAL_IMMEDIATE
// listner負責ack,每調用一次,就立即commit
factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);
return factory;
}
}
然后通過在消費端的Consumer來提交偏移量,MyOffsetConsumer:
@Component
public class MyOffsetConsumer {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
@KafkaListener(topics = "test", groupId = "myoffset-group-1", containerFactory = "manualKafkaListenerContainerFactory")
public void manualCommit(@Payload String message,
@Header(KafkaHeaders.RECEIVED_PARTITION_ID) int partition,
@Header(KafkaHeaders.RECEIVED_TOPIC) String topic,
Consumer consumer,
Acknowledgment ack) {
logger.info("手動提交偏移量 , partition={}, msg={}", partition, message);
// 同步提交
consumer.commitSync();
//異步提交
//consumer.commitAsync();
// ack提交也可以,會按設置的ack策略走(參考MyOffsetConfig.java里的ack模式)
// ack.acknowledge();
}
@KafkaListener(topics = "test", groupId = "myoffset-group-2", containerFactory = "manualKafkaListenerContainerFactory")
public void noCommit(@Payload String message,
@Header(KafkaHeaders.RECEIVED_PARTITION_ID) int partition,
@Header(KafkaHeaders.RECEIVED_TOPIC) String topic,
Consumer consumer,
Acknowledgment ack) {
logger.info("忘記提交偏移量, partition={}, msg={}", partition, message);
// 不做commit!
}
/**
* 現實狀況:
* commitSync和commitAsync組合使用
* <p>
* 手工提交異步 consumer.commitAsync();
* 手工同步提交 consumer.commitSync()
* <p>
* commitSync()方法提交最后一個偏移量。在成功提交或碰到無怯恢復的錯誤之前,
* commitSync()會一直重試,但是commitAsync()不會。
* <p>
* 一般情況下,針對偶爾出現的提交失敗,不進行重試不會有太大問題
* 因為如果提交失敗是因為臨時問題導致的,那么后續的提交總會有成功的。
* 但如果這是發生在關閉消費者或再均衡前的最后一次提交,就要確保能夠提交成功。否則就會造成重復消費
* 因此,在消費者關閉前一般會組合使用commitAsync()和commitSync()。
*/
// @KafkaListener(topics = "test", groupId = "myoffset-group-3",containerFactory = "manualKafkaListenerContainerFactory")
public void manualOffset(@Payload String message,
@Header(KafkaHeaders.RECEIVED_PARTITION_ID) int partition,
@Header(KafkaHeaders.RECEIVED_TOPIC) String topic,
Consumer consumer,
Acknowledgment ack) {
try {
logger.info("同步異步搭配 , partition={}, msg={}", partition, message);
//先異步提交
consumer.commitAsync();
//繼續做別的事
} catch (Exception e) {
System.out.println("commit failed");
} finally {
try {
consumer.commitSync();
} finally {
consumer.close();
}
}
}
/**
* 甚至可以手動提交,指定任意位置的偏移量
* 不推薦日常使用!!!
*/
// @KafkaListener(topics = "test", groupId = "myoffset-group-4",containerFactory = "manualKafkaListenerContainerFactory")
public void offset(ConsumerRecord record, Consumer consumer) {
logger.info("手動指定任意偏移量, partition={}, msg={}", record.partition(), record);
Map<TopicPartition, OffsetAndMetadata> currentOffset = new HashMap<>();
currentOffset.put(new TopicPartition(record.topic(), record.partition()),
new OffsetAndMetadata(record.offset() + 1));
consumer.commitSync(currentOffset);
}
}
4.8.3 重復消費問題
如果手動提交模式被打開,一定不要忘記提交偏移量。否則會造成重復消費。
4.8.4 經驗與總結
commitSync()方法,即同步提交,會提交最后一個偏移量。在成功提交或碰到無怯恢復的錯誤之前,commitSync()會一直重試,但是commitAsync()不會。這就造成一個陷阱:
如果異步提交,針對偶爾出現的提交失敗,不進行重試不會有太大問題,因為如果提交失敗是因為臨時問題導致的,那么后續的提交總會有成功的。只要成功一次,偏移量就會提交上去。
但是,如果這是發生在關閉消費者時的最后一次提交,就要確保能夠提交成功,如果還沒提交完就停掉了進程。就會造成重復消費。因此,在消費者關閉前一般會組合使用commitAsync()和commitSync()。詳細代碼參考:MyOffsetConsumer.manualOffset()。