目录
Kafka High Level Consumer Rebalance(重新分配消费)
消息生产者、消费者以及消息发布的不同模型
Kafka Producer
- Kafka Producer产生数据发送给KafkaServer,具体的分发逻辑和负载均衡逻辑,全部由producer维护
- Producer不用连接ZooKeeper,而是直接发布信息,然后Topic会跟ZooKeeper更新数据
- 所有的broker构成一个partition的list,partition会跨broker存在副本,副本中会有leader的角色,用来更新所有的副本,用以保证数据的一致性
Kafka Producer消息发送架构图
- Producer有同步发送和异步发送两种策略,异步发送的意思就是客户端有个本地缓存区,消息先存放到本地缓存区,然后由后台进程来发送,在0.8.2版本之后,同步发送由异步发送间接实现
- 异步发送的基本思路就是,send的时候,Producer把消息放到本地的消息队列RecordAccmulator,然后一个后台线程Sender不断循环,把消息发给Kafka集群。要实现这个操作,还得有一个前提条件,就是Producer/Sender都需要获取集群的配置信息Metadata,即每一个Topic的每个Partition对应的broker list,以及其中的leader,follower
- 在以前的Kafka Client中,每条消息称为Message,在Java版Client中,称之为Record,同时又因为有批量发送累积功能,所以又称之为RecordAccumulator,RecordAccumulator最大的一个特性就是batch消息,队列中的多个消息会组成一个RecordBatch,然后由Sender一次性发送出去,由源码可以发现,每个TopicPartition对应一个deque,只有同一个TopicPartition的消息,才可能被batch
public final class RecordAccumulator {
private final ConcurrentMap<TopicPartition, Deque<RecordBatch>> batches;
...
}
- 那么什么时候消息会被batch,什么时候不会呢,可以看Kafka Producer的send方法
//KafkaProducer
public Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback) {
try {
// first make sure the metadata for the topic is available
long waitedOnMetadataMs = waitOnMetadata(record.topic(), this.maxBlockTimeMs);
...
RecordAccumulator.RecordAppendResult result = accumulator.append(tp, serializedKey, serializedValue, callback, remainingWaitMs); //核心函数:把消息放入队列
if (result.batchIsFull || result.newBatchCreated) {
log.trace("Waking up the sender since topic {} partition {} is either full or getting a new batch", record.topic(), partition);
this.sender.wakeup();
}
return result.future;
从上面代码可以看到,batch逻辑都在accumulator.append函数里面
public RecordAppendResult append(TopicPartition tp, byte[] key, byte[] value, Callback callback, long maxTimeToBlock) throws InterruptedException {
appendsInProgress.incrementAndGet();
try {
if (closed)
throw new IllegalStateException("Cannot send after the producer is closed.");
Deque<RecordBatch> dq = dequeFor(tp); //找到该topicPartiton对应的消息队列
synchronized (dq) {
RecordBatch last = dq.peekLast(); //拿出队列的最后1个元素
if (last != null) {
FutureRecordMetadata future = last.tryAppend(key, value, callback, time.milliseconds()); //最后一个元素, 即RecordBatch不为空,把该Record加入该RecordBatch
if (future != null)
return new RecordAppendResult(future, dq.size() > 1 || last.records.isFull(), false);
}
}
int size = Math.max(this.batchSize, Records.LOG_OVERHEAD + Record.recordSize(key, value));
log.trace("Allocating a new {} byte message buffer for topic {} partition {}", size, tp.topic(), tp.partition());
ByteBuffer buffer = free.allocate(size, maxTimeToBlock);
synchronized (dq) {
// Need to check if producer is closed again after grabbing the dequeue lock.
if (closed)
throw new IllegalStateException("Cannot send after the producer is closed.");
RecordBatch last = dq.peekLast();
if (last != null) {
FutureRecordMetadata future = last.tryAppend(key, value, callback, time.milliseconds());
if (future != null) {
// Somebody else found us a batch, return the one we waited for! Hopefully this doesn't happen often...
free.deallocate(buffer);
return new RecordAppendResult(future, dq.size() > 1 || last.records.isFull(), false);
}
}
//队列里面没有RecordBatch,建一个新的,然后把Record放进去
MemoryRecords records = MemoryRecords.emptyRecords(buffer, compression, this.batchSize);
RecordBatch batch = new RecordBatch(tp, records, time.milliseconds());
FutureRecordMetadata future = Utils.notNull(batch.tryAppend(key, value, callback, time.milliseconds()));
dq.addLast(batch);
incomplete.add(batch);
return new RecordAppendResult(future, dq.size() > 1 || batch.records.isFull(), true);
}
} finally {
appendsInProgress.decrementAndGet();
}
}
private Deque<RecordBatch> dequeFor(TopicPartition tp) {
Deque<RecordBatch> d = this.batches.get(tp);
if (d != null)
return d;
d = new ArrayDeque<>();
Deque<RecordBatch> previous = this.batches.putIfAbsent(tp, d);
if (previous == null)
return d;
else
return previous;
}
从上面代码可以看出batch的策略:
1.如果是同步发送,每次去队列取,RecordBatch都会为空,这个时候消息就不会被batch,一个Record作为一个RecordBatch
2.当Producer入队速率 < Sender出队速率 && lingerMs=0
3.Producer 入队速率 > Sender出对速率, 消息会被batch
4.lingerMs > 0,这个时候Sender会等待,直到lingerMs > 0 或者 队列满了,或者超过了一个RecordBatch的最大值,就会发送。这个逻辑在RecordAccumulator的ready函数里面。
ReadyCheckResult ready(Cluster cluster, long nowMs) {
Set<Node> readyNodes = new HashSet<Node>();
long nextReadyCheckDelayMs = Long.MAX_VALUE;
boolean unknownLeadersExist = false;
boolean exhausted = this.free.queued() > 0;
for (Map.Entry<TopicPartition, Deque<RecordBatch>> entry : this.batches.entrySet()) {
TopicPartition part = entry.getKey();
Deque<RecordBatch> deque = entry.getValue();
Node leader = cluster.leaderFor(part);
if (leader == null) {
unknownLeadersExist = true;
} else if (!readyNodes.contains(leader)) {
synchronized (deque) {
RecordBatch batch = deque.peekFirst();
if (batch != null) {
boolean backingOff = batch.attempts > 0 && batch.lastAttemptMs + retryBackoffMs > nowMs;
long waitedTimeMs = nowMs - batch.lastAttemptMs;
long timeToWaitMs = backingOff ? retryBackoffMs : lingerMs;
long timeLeftMs = Math.max(timeToWaitMs - waitedTimeMs, 0);
boolean full = deque.size() > 1 || batch.records.isFull();
boolean expired = waitedTimeMs >= timeToWaitMs;
boolean sendable = full || expired || exhausted || closed || flushInProgress(); //关键的一句话
if (sendable && !backingOff) {
readyNodes.add(leader);
} else {
nextReadyCheckDelayMs = Math.min(timeLeftMs, nextReadyCheckDelayMs);
}
}
}
}
}
return new ReadyCheckResult(readyNodes, nextReadyCheckDelayMs, unknownLeadersExist);
}
(这一部分暂未彻底弄懂,先记结论)
- 为什么要使用Deque(即双端队列,双端队列中的元素可以从两端弹出,其限定插入和删除操作在表的两端进行)?这其实是为了处理“发送失败,重试”的问题,当消息发送失败要重发的时候,需要把消息优先放入队列头部重新发送,这就需要用到双端队列,在头部而不是尾部加入,即便如此,消息发送出去的顺序还是和Producer放进去的顺序不一致了
- Recordbatch被Sender发送给NetworkClient之后,NetworkClient将其封装成类似于Socket通信的存在,即将deque里的batch封装成ClientRequest,NetworkClient是一个内部类,用于实现面向用户的生产者和消费者客户端。然后发送给Selector,传到Cluster
Kafka Consumer
- Consumer以订阅形式获取Kafka数据
- Kafka提供了两种Consumer API,分别是:High Level Consumer API和Lower Level Consumer API(Simple Consumer API)
API | 原理 | 优点 | 缺点 |
High Level Consumer API(入口类:ConsumerConnector) | 将底层具体获取数据、更新offset、设置偏移量等操作屏蔽掉,直接将操作数据流的处理工作提供给编写程序的人员 | 操作简单 | 可操作性差,无法按照自己的业务场景选择处理方式 |
Lower Level Consumer API(入口类:SimpleConsumer) | 通过直接操作底层API获取数据的方式获取Kafka中的数据,需要自行给定分区、偏移量等属性 | 可操作性强 | 代码比较复杂 |
Kafka Consumer Group
- High Level Consumer将从某个Partition读取的最后一条消息的offset存于Zookeeper中(从0.8.2开始同时支持将offset存于Zookeeper中和专用的Kafka Topic中)。
- 这个offset基于客户程序提供给Kafka的名字来保存,这个名字被称为Consumer Group。换句话说,并不是每个topic都会分很多consumer group,每一个consumer group中的consumer都可以消费多个topic,同时,一个topic可以被多个consumer group消费。
- Consumer Group是整个Kafka集群全局唯一的,而非针对某个Topic的。
- 每个High Level Consumer实例都属于一个Consumer Group,若不指定则属于默认的Group。
- 消息被消费后,并不会被删除,只是相应的offset加一(对于p2p消息系统,消息一旦被消费,就会被删除,保证queue比较小,提高效率;但是对于kafka这种发布订阅系统来说,消息被消费后,并不会立即被删除,因为消息是顺序的,而且,删除后,其他的consumer就无法消费了) 。
- 对于每条消息,在同一个Consumer Group里只会被一个Consumer消费。
- 不同Consumer Group可消费同一条消息。
Kafka High Level Consumer Rebalance(重新分配消费)
当有Consumer加入或退出、coodinator挂了(0.9之后用于管理Consumer Group的角色)、以及partition的改变(如broker加入或退出)时会触发rebalance,Consumer Group通过Rebalance提供HA特性
- Consumer启动及Rebalance流程
- High Level Consumer启动时将其ID注册到其Consumer Group下,在Zookeeper上的路径为/consumers/[consumer group]/ids/[consumer id]
- 在/consumers/[consumer group]/ids上注册Watch,看看有没有其他的consumer加入或者退出
- 在/brokers/ids上注册Watch,有没有broker crash了,因为有些broker crash了,他的partition就不可用了或者需要重新分配。
- 如果Consumer通过Topic Filter创建消息流,则它会同时在/brokers/topics上也创建Watch
- 强制自己在其Consumer Group内启动Rebalance流程
- Consumer Rebalance算法
- 将目标Topic下的所有Partirtion排序,存于集合P中
- 对某Consumer Group下所有Consumer排序,存于集合C ,第i个Consumer记为C[i]
- N=size(P)/size(C) ,向上取整
- 解除C[i]对原来分配的Partition的消费权(i从0开始)
- 将第 i∗N 到(i+1)∗N−1个Partition分配给C[i]
举例:
topic有4个partition[p0,p1,p2,p3],2个consumer[c0,c1]
将所有partition排序,存在集合P中,Consumer排序也存在集合C中
N=size(P)/size(C)=4/2=2
根据公式可以知道分配
C[0]->p0,p1
C[2]->p2,p3
当consumer加入[c0,c1,c2,c3]
N=4/4=1
Rebalance之后
C[0]->p0
C[1]->p1
C[2]->p2
C[3]->p3
- Consumer Rebalance算法缺陷及改进
- Herd Effect:任何Broker或者Consumer的增减都会触发所有的Consumer的Rebalance
- Split Brain:每个Consumer分别单独通过Zookeeper判断哪些Broker和Consumer宕机,同时Consumer在同一时刻从Zookeeper“看”到的View可能不完全一样,这是由Zookeeper的特性决定的。
- 调整结果不可控 所有Consumer分别进行Rebalance,彼此不知道对应的Rebalance是否成功
Low Level Consumer
使用Low Level Consumer (Simple Consumer)的主要原因是:用户希望比Consumer Group更好的控制数据的消费,如:
- 同一条消息读多次,方便Replay
- 只消费某个Topic的部分Partition
- 管理事务,从而确保每条消息被处理一次(Exactly once)
与High Level Consumer相对,Low Level Consumer要求用户做大量的额外工作:
- 在应用程序中跟踪处理offset,并决定下一条消费哪条消息
- 获知每个Partition的Leader
- 处理Leader的变化
- 处理多Consumer的协作
参考文章:
Kafka源码深度解析:https://blog.csdn.net/chunlongyu/article/category/6417583
kafka Consumer Pull vs Push & Low level API vs High level API:https://blog.csdn.net/qq_37502106/article/details/80260546
kafka学习笔记:知识点整理:https://www.cnblogs.com/cyfonly/p/5954614.html