Kafka的体系结构及性能优化

219 阅读7分钟

Kafka体系结构

    1. 体系结构
    1. 消息追加写入
    1. Replica多副本架构
    1. 生产者客户端的整体架构
    1. Broker分配策略

体系结构

消息追加写入

Replica多副本架构

通过增加副本数量,实现故障的自动转移,保证服务可用,提升容灾能力。 同一分区的不同副本中保存的是相同的消息(同一时刻,副本之间并非完全一样)。 副本之间是“一主多从”的关系,leader副本负责处理读写请求,follower副本只负责与leader副本的消息同步。 副本处于不同的broker中,若leader副本故障,从follower副本中重新选举新的leader副本对外提供服务。 很多时候,follower副本中的消息相对leader副本而言,会有一定的滞后。

AR(Assigned Replicas):分区中的所有副本。

ISR(In-Sync Replicas):所有与leader副本保持一定程度同步的副本(包括leader副本在内)。ISR是AR的一个子集。消息会先发送到leader副本,然后follower副本才能从leader副本中拉取消息进行同步,同步期间follower副本相对于leader副本而言会有一定程度的滞后(滞后范围可通过参数配置);

OSR(Out-of-Sync Replicas):与leader副本同步滞后过多的副本(不包括leader副本)。 AR = ISR + OSR。正常情况所有follower副本都应该与leader副本保持一定程度的同步,即AR = ISR,OSR集合为空。

HW(High Watermark):高水位,标识一个特定的消息偏移量(offset),消费者只能拉取到这个offset之前的消息。

LEO(Log End Offset):标识当前日志文件中下一条待写入消息的offset。

  • leader副本负责维护和跟踪ISR集合中所有follower副本的滞后状态,当follower副本落后太多或失效时,leader副本会把它从ISR集合中剔除;
  • 如果OSR集合中有follower副本“追上”leader副本,则leader副本会把它从OSR集合转移到ISR集合。
  • 默认,当leader副本发生故障时,只有在ISR集合中的副本才有资格被选举为新的leader,而在OSR集合中的副本则没有任何机会(也可通过配置来改变)。
  • ISR集合的每个副本都会维护自身的LEO,ISR集合中最小的LEO即为分区的HW,对消费者而言,只能消费HW之前的消息。

Kafka的复制机制即不是完全的同步复制,也不是单纯的异步复制。

  • 同步复制工作的follower副本都复制完,这条消息才会被确认为已成功提交,该方式影响性能。
  • 异步复制,follower副本异步从leader副本中复制数据,数据只要被leader副本写入就被认为已经成功提交。可能导致数据丢失。

生产者客户端的整体架构

消息收集器: 1)缓存消息,以便Sender线程可以批量发送,进而减少网络传输的资源消耗,以提升性能。

2)主线程发送的消息都被追加到RecordAccumulator的某个双端队列Deque的尾部,Sender线程读取消息时,从双端队列的头部读取。

3)RecordAccumulator内部为每个分区都维护了一个双端队列,队列的内容就是ProducerBatch,即Deque。

4)ProducerBatch中可以包含一至多个ProduerRecord。即ProducerRecord是生产者创建的消息,而ProducerBatch是指一个消息批次,使字节更加紧凑,减少网络请求次数,提升整体吞吐量。

5)当一条消息流入RecordAccumulator时,会先寻找与消息分区对应的双端队列,没有则新建。再从双端队列尾部获取一个ProducerBatch,没有则新建。如果ProducerBatch可以写入消息则写入,不可以则需要创建一个新的ProducerBatch。

6)Sender从RA中获取缓存的消息之后,进一步将<分区, Deque>转变为<Node, List>形式,其中Node为Kafka集群的broker节点。也就是生产者客户端是向具体的broker节点发送消息,而并不关心消息属于哪一个分区。在此做一个应用逻辑到网络I/O层的转换。

7)进一步封装成<Node, Request>的形式,将Request发往各Node。其中Request是指Kafka的各种协议请求,即具体的ProduceRequest。

8)请求在发往Kafka之前还会保存到InFlightRequests中,保存形式Map<NodeId, Deque>,缓存已经发出去但还没有收到响应的请求。

Broker分配策略

# 无机架分配
fixedStartIndex = -1 => startIndex = random; => 起始分配的brokerId
startPartitionId = -1 => currentPartitionId = 0 => 默认创建主题时,总从编号为0的分区依次轮询进行分配

brokerArray = {0, 1, 2}, replicas = 3, partitions = 6

partitionId = 0 
nextReplicaShift = rand.nextInt(brokerArray.length) = 1
startIndex = random = 2
=> firstReplicaIndex = (currentPartitionId + startIndex) % brokerArray.length
					= (0 + 2) % 3 = 2 => 第一个副本位置
=> secondReplicaIndex = replicaIndex(firstReplicaIndex, secondReplicaShift, replicaIndex, nBrokers)
		= replicaIndex(firstReplicaIndex, nextReplicaShift + 1, replicaIndex, brokerArray.length)
			shift = 1 + (secondReplicaShift + replicaIndex) % (nBrokers - 1)
			=> (firstReplicaIndex + shift) % nBrokers
		= replicaIndex(2, 1 + 1, 0, 3) => replicaIndex = startIndex = 0
			shift = 1 + (2 + 0) % ( 3 - 1) = 1
			=> (2 + 1) % 3 = 0
=> thirdReplicaIndex = replicaIndex(firstReplicaIndex, secondReplicaShift, replicaIndex, nBrokers)
			replicaIndex = startIndex + 1 = 1
		= replicaIndex(firstReplicaIndex, nextReplicaShift + 1, replicaIndex, brokerArray.length)
			shift = 1 + (secondReplicaShift + replicaIndex) % (nBrokers - 1)
			=> (firstReplicaIndex + shift) % nBrokers
		= replicaIndex(2, 1 + 1, 1, 3) => replicaIndex = startIndex + 1 = 1
			shift = 1 + (2 + 1) % (3 - 1) = 2
			=> (2 + 2) % 3 = 1
=> (2, 0, 1)

# 有机架分配
racks  brokers
rack1: 0, 1, 2
rack2: 3, 4, 5
rack3: 6, 7, 8
=> brokerArray = {0, 3, 6, 1, 4, 7, 2, 5, 8}

性能调优

5.1.Kafka参数调优 假设kafka服务器的配置:

        ip  arch cpu memory
kafka	ip1	X86	32	128
		ip2	X86	32	128
		ip3	X86	32	128
  • 针对JVM的优化: 一般HEAP SIZE的大小不超过主机内存的50%。
KAFKA_HEAP_OPTS="-Xmx64G -Xms64G"
  • 网络和ios操作线程配置优化
num.network.threads 主要用于处理网络io,读写缓冲区数据,基本没有io等待,配置线程数量为 cpu核数+1=33;
num.io.threads 主要用于磁盘io操作,高峰期可能有些io等待,因此配置要大些,配置线程数为cpu的两倍=64
  • 关于副本的配置: 有条件的话可以设置副本数为2(高可用考虑)。 一般受限于服务器数量也可设置为1。
offsets.topic.replication.factor=2
replication.factor=2
  • 分区数量配置 修改配置: 默认patition数量为1,如果topic在创建时没有指定partition的数量,默认使用此值。 num.partitions=32 (理论公式:吞吐量/消费速度,实践中采用cpu数量)
  • LOG数据文件刷盘
log.flush.interval.messages=10000(flush条数)
log.flush.interval.ms=1000flush时间)
  • 日志保留策略
log.retention.hours=72(根据时间情况跟修改保留时间)
log.segment.bytes=1072741824(segment配置1g,如果文件过小,小文件数量过多)
  • 生产者配置
buffer.memory:生产者最大可用缓存 (默认:3355443232M)
producer.buffer.memory=33554432

buffer.memoty与batchSize的配置浅析

#org.apache.kafka.clients.producer.internals.RecordAccumulator(记录累加器 )
TopicPartition tp, 
Deque<ProducerBatch> dq = this.getOrCreateDeque(tp);
synchronized(dq) {
    if (this.closed) {
        throw new IllegalStateException("Cannot send after the producer is closed.");
    }

    RecordAccumulator.RecordAppendResult appendResult = this.tryAppend(timestamp, key, value, headers, callback, dq);
    if (appendResult != null) {
        RecordAccumulator.RecordAppendResult var14 = appendResult;
        return var14;
    }
}
由于每个TopicPartition构建一个 ProducerBatch的队列Deque,buffer.memory这个参数是同一个生产者所使用的内存最大限制,因些当tp很多时,
buffer.memory如果小于所有tp.batchsize之和,那么就容易导致每一条记录都要面临内存不够,要申请;
buffer = this.free.allocate(size, maxTimeToBlock);这个里面有一个条件锁,申请一块内存用时约50MS;

取值逻辑: buffer.memory=tp*batchSize*1.5 左右;批量发送大小5m(默认是16K,达到阀值就会提交)

producer.batch.size=2097152 批量发送大小5m
#org.apache.kafka.clients.producer.internals.RecordAccumulator(记录累加器 )
   byte maxUsableMagic = this.apiVersions.maxUsableProduceMagic();
   int size = Math.max(this.batchSize, AbstractRecords.estimateSizeInBytesUpperBound(maxUsableMagic, this.compression, key, value, headers));
   this.log.trace("Allocating a new {} byte message buffer for topic {} partition {}", new Object[]{size, tp.topic(), tp.partition()});
   buffer = this.free.allocate(size, maxTimeToBlock);

分析:记录进来时,空间不足的情况下,申请内存时,通过方法Math.max(批次大小,记录真实需要内存大小)取较大值,所以BatchSize不要设置太小,不然容易触地申请内存动作,也不能取最大那个,会浪费内存

 取值逻辑: 假设一条记录2000B(2K),那么1000条记录就是2M,那设个5M就是约能放5000条了;