1. 解耦:在一个复杂的系统中,不同的模块或服务之间可能需要相互依赖,如果直接使用函数调用或者API调用的方式,会造成模块之间的耦合,当其中一个模块发生改变时,需要同时修改调用方和被调用方的代码。而使用消息队列作为中间件,不同的模块可以将消息发送到消息队列中,不需要知道具体的接收方是谁,接收方可以独立地消费消息,实现了模块之间的解耦。
2. 异步:有些操作比较耗时,例如发送邮件、生成报表等,如果使用同步的方式处理,会阻塞主线程或者进程,导致系统的性能下降。而使用消息队列,可以将这些操作封装成消息,放入消息队列中,异步地处理这些操作,不影响主流程的执行,提高了系统的性能和响应速度。
3. 削峰:削峰是一种在高并发场景下平衡系统压力的技术,在削峰的过程中,通常使用消息队列作为缓冲区,将请求放入消息队列中,然后在系统负载低的时候进行处理。这种方式可以将系统的峰值压力分散到较长的时间段内,减少瞬时压力对系统的影响,从而提高系统的稳定性和可靠性。
1、Producer 生产者
生产者负责将消息发布到 kafka 中的一个或多个主题,每个主题包含一个或多个分区,消息保存在各个分区上,每一个分区都是一个顺序的,分区中的消息都被分了一个序列号,称之为偏移量,就是指消息在分区中的位置,所有分区的消息加在一起就是一个主题的所有消息。
分区策略
分区策略 | 说明 |
---|---|
轮询策略 | 按顺序轮流将每条数据分配到每个分区中 |
随机策略 | 每次都随机地将消息分配到每个分区 |
按键保存策略 | 生产者发送数据的时候,可以指定一个key,计算这个key的hashCodet值,按照hashCodel的值对不同消息进行存储 |
如果 topic 有多个 partition,消费数据时就不能保证数据的顺序。严格保证消息的消费顺序的场景下,需要将分区数目设为1 或者指定消息的 key。
消息发送
public Future<RecordMetadata> send(ProducerRecord<K, V> record);
public Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback);
生产者架构图
消息在通过 send 方法发往 broker 的过程中,有可能需要经过拦截器、序列化器、分区器一系列之后才能被真正地发往 broker。整个生产者客户端由两个线程协调运行,这两个线程分别为主线程和Sender发送线程。
拦截器
生产者拦截器既可以用来在消息发送前做一些准备工作,比如按照某个规则过滤不符合要求的消息、修改消息的内容等,也可以用来在发送回调逻辑前做一些定制化的需求,比如统计类工作。通过自定义实现 ProducerInterceptor 接口来使用。
序列化
生产者需要用序列化器把对象转换成字节数组才能通过网络发送给 Kafka。消费者需要用反序列化把从 Kafka 中收到的字节数组转换成相应的对象。自带的有StringSerializer,ByteArray、ByteBuffer、Bytes、Double、Integer、Long等,还可以自定义序列化器。
分区器
如果消息中没有指定 partition 字段,那么就需要依赖分区器,根据 key 这个字段来计算 partition 的值。也可以自定义分区器。
消息累加器
消息累加器主要用来缓存消息以便 Sender线程可以批量发送进而减少网络传输的资源消耗以提升性能。消息累加器的缓存大小可以通过buffer.memory
配置。在消息累加器的内部为每个分区都维护了一个双端队列,主线程发送过来的消息都会被追加到某个双端队列中,队列中的内容就是 ProducerBatch,即Dqueue< ProducerBatch >。
当一条消息流入消息累加器,如果这条消息小于batch.size
参数大小则以batch.size
参数大小创建 ProducerBatch,否则以消息的实际大小创建 ProducerBatch。
② Sender发送线
程负责从消息累加器中获取消息并将其发送到 Kafka 中。后续 Sender 从缓存中获取消息,进行转换,发送到broker。在发送前还会保存到InFlightRequests中,作用是缓存已经发送出去但还没有收到响应的请求,缓存数量由max.in.flight.requests.per.connection
参数确定,默认是5,表示每个连接最多缓存5个未响应的请求。
2、Consumer 消费者
消费者,消息的订阅者,可以订阅一个或多个主题,并且依据消息生产的顺序读取他们,消费者通过检查消息的偏移量来区分已经读取过的消息。消费者一定属于某一个特定的消费组。消息被消费之后,并不被马上删除,这样多个业务就可以重复使用 kafkal 的消息,我们某一个业务也可以通过修改偏移量达到重新读取消息的目的,偏移量由用户控制。消息最终还是会被删除的,默认生命周期为1周(7*24小时)。
订阅主题和分区
通过 subscribe 方法订阅主题具有消费者自动再均衡的功能,在多个消费者的情况下可以根据分区分配政策来自动分配各个消费者与分区的关系,以实现消费者负载均衡和故障自动转移。而通过 assign 方法则没有。
消息消费
Kafka 中的消息是基于推拉模式的。Kafka 中的消息消费是一个不断轮询的过程,消费者所要做的就是重复地调用poll 方法,而 poll 方法返回的是所订阅的主题(分区)上的一组消息。如果没有消息则返回空。
public ConsumerRecords<K, V> (final Duration timeout)
timeout 用于控制 poll 方法的阻塞时间,没有消息时会阻塞。
位移提交
Kafka 中的每条消息都有唯一的 offset,用来标识消息在分区中对应的位置。Kafka 默认的消费唯一的提交方式是自动提交,由enable.auto.commit
配置,默认为true。自动提交不是每一条消息提交一次,而是定期提交,周期由auto.commit.interval.ms
配置,默认为5秒。
自动提交可能发生消息重复或者丢失的情况,Kafka 还提供了手动提交的方式。enable.auto.commit
配置为false开启手动提交。
指定位移消费
在 Kafka 中每当消费者查找不到所记录的消费位移时,就会根据消费者客户端参数auto.offset.reset
的配置来决定从何处开始进行消费。默认值为 lastest,表示从分区末尾开始消费消息;earliest 表示从起始开始消费;none为不进行消费,而是抛出异常。
seek 可以从特定的位移处开始拉去消息,得以追前消费或回溯消费。
public void seek(TopicPartition partition, long offset)
再均衡
再均衡是指分区的所属权从一个消费者转移到另一个消费者的行为,它为消费组具备高可用性和伸缩性提供保障,使我们可以既方便又安全地删除消费组内的消费者或者往消费组内添加消费者。不过在再均衡发生期间,消费组内的消费者是无法读取消息的。再均衡后也可能出现重复消费的情况。所以应尽量避免不必要的再均衡发生。
3、Consumer Group 消费者群组
同一个消费者组中保证每个分区只能被一个消费者使用 ,不会出现多个消费者读取同一个分区的情况,通过这种方式,消费者可以消费包含大量消息的主题。而且如果某个消费者失效,群组里的其他消费者可以接管失效悄费者的工作。
4、Broker 服务器
一个独立的 Kafka 服务器被称为 broker, broker 接收来自生产者的消息,为消息设置偏移量,并提交消息到磁盘保存。
在集群中,每个分区都有一个Leader Broker和多个Follower Broker,只有Leader Broker才能处理生产者和消费者的请求,而Follower Broker只是Leader Broker的备份,用于提供数据的冗余备份和容错能力。如果Leader Broker发生故障,Kafka集群会自动将Follower Broker提升为新的Leader Broker,从而实现高可用性和容错能力。
AR、ISR、OSR
5、 Log 日志存储
一个分区对应一个日志文件(Log),为了防止Log过大,Kafka又引入了日志分段(LogSegment)的概念,将Log 切分为多个 LogSegment,便于消息的维护和清理。Log在物理上只以(命名为topic-partitiom)文件夹的形式存储,而每个LogSegment对应磁盘上的一个日志文件和两个索引文件,以及可能的其他文件。
LogSegment文件由两部分组成,分别为“.index”文件和“.log”文件,分别表示为segment的索引文件和数据文件
消息压缩
一条消息通常不会太大,Kafka 是批量消息压缩,通过compression.type
配置,默认为 producer,还可以配置为gzip、snappy、lz4,uncompressed表示不压缩。
日志索引
Kafka中的索引文件以稀疏索引的方式构造消息的索引,它并不保证每个消息在索引文件中都有对应的索引项。每当写入一定量(log.index.interval.bytes
指定,默认4KB)的消息时,偏移量索引文件和时间戳索引文件分别增加一个偏移量索引文件项和时间戳索引文件项。稀疏索引通过MappedByteBuffer将索引文件映射到内存中,以加快索引的查询速度。
日志清理
Kafka提供两种日志清理策略:
页缓存
页缓存是把磁盘中的数据缓存到内存中,把对磁盘的访问变为对内存的访问,减少对磁盘IO的操作。
零拷贝
所谓的零拷贝是将数据直接从磁盘文件复制到网卡设备中,而不需要经由应用程序之手。减少了数据拷贝的次数和内核和用户模式之间的上下文切换。对于Linux操作系统而言,底层依赖于sendfile()方法实现。
一般的数据流程:磁盘 -> 内核 -> 应用 -> Socket -> 网卡,数据复制4次,上下文切换4次。
通过网卡直接去访问系统的内存,就可以实现现绝对的零拷贝了。这样就可以最大程度提高传输性能。通过“零拷贝”技术,我们可以去掉那些没必要的数据复制操作, 同时也会减少上下文切换次数。
通过上图可以看到,零拷贝技术只用将磁盘文件的数据复制到页面缓存中一次,然后将数据从页面缓存直接发送到网络中(发送给不同的订阅者时,都可以使用同一个页面缓存),避免了重复复制操作。
6、ZooKeeper
ZooKeeper是Kafka集群中使用的分布式协调服务,用于维护Kafka集群的状态和元数据信息,例如主题和分区的分配信息、消费者组和消费者偏移量等。
生产者
生产者调用send方法发送消息之后,消息可能因为网络问题并没有发送过去。所以,我们不能默认在调用send方法发送消息之后消息发送成功了。为了确定消息是发送成功,我们要判断消息发送的结果。可以采用为其添加回调函数的形式,如果消息发送失败的话,可以对失败消息做记录,我们检查失败的原因之后重新发送即可!
// 异步发送消息
producer.send(record, new Callback() {
@Override
public void onCompletion(RecordMetadata metadata, Exception exception) {
if (exception == null) {
System.out.println("发送成功");
} else {
System.out.println("发送失败");
}
if (metadata != null) {
System.out.println("异步方式发送消息结果:" + "topic‐" + metadata.topic() + "|partition‐"
+ metadata.partition() + "|offset‐" + metadata.offset());
}
}
});
同时,我们也可以通过给oroducer设置一些参数来提升发送成功率:
/**
* producer 需要 server 接收到数据之后发出的确认接收的信号,此项配置就是指 procuder需要多少个这样的确认信号。此配置实际上代表了数据备份的可用性。以下设置为常用选项:
* (1)acks=0:生产者在成功写入消息之前不会等待任何来自服务器的响应,消息传递过程中有可能丢失,其实就是保证消息不会重复发送或者重复消费,但是速度最快。同时重试配置不会发生作用。
* (2)acks=1:默认值,只要集群首领节点收到消息,生产者就会收到一个来自服务器的成功响应。
* (3)acks=all:只有当所有参与赋值的节点全部收到消息时,生产者才会收到一个来自服务器的成功响应。
*/
props.put(ProducerConfig.ACKS_CONFIG, "all");
/**
* 如果请求失败,生产者会自动重试,如果启用重试
*/
props.put(ProducerConfig.RETRIES_CONFIG, 3);
/**
* 消息发送超时或失败后,间隔的重试时间
*/
props.put(ProducerConfig.RETRY_BACKOFF_MS_CONFIG, 300);
Broker
在服务端,也有一些参数配置可以调节来避免消息丢失:
replication.factor //表示分区副本的个数,replication.factor>1 当1eader副本挂了,follower副本会被选举为leader继续提供服务。
min.insync.rep1icas //表示ISR最少的副本数量,通常设置min.insync.replicas>1,这样才有可用的fol1ower副本执行替换,保证消息不丢
unclean.leader.election.enable=false //是否可以把非ISR集合中的副本选举为leader副本。
消费者
当消费者拉取到了分区的某个消息之后,消费者会自动提交了 offset。自动提交的话会有一个问题,试想一下,当消费者刚拿到这个消息准备进行真正消费的时候,突然挂掉了,消息实际上并没有被消费,但是 offset 却被自动提交了。
这种情况的解决办法也比较粗暴,我们手动关闭自动提交 offset,每次在真正消费完消息之后之后再自己手动提交 offset 。但是,细心的朋友一定会发现,这样会带来消息被重新消费的问题。比如你刚刚消费完消息之后,还没提交 offset,结果自己挂掉了,那么这个消息理论上就会被消费两次。
while (true) {
/**
* poll() API 是拉取消息的长轮询 比如设置了1000毫秒 并不是在这1秒钟内只拉取一次 而是当没有拉取到数据时 会多次拉取数据 直到拉取到数据 然后继续循环
*/
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
for (ConsumerRecord<String, String> record : records) {
System.out.printf("收到消息:partition = %d,offset = %d, key = %s, value = %s%n", record.partition(),
record.offset(), record.key(), record.value());
}
if (records.count() > 0) {
// 手动同步提交offset,当前线程会阻塞直到offset提交成功
// 一般使用同步提交,因为提交之后一般也没有什么逻辑代码了
// consumer.commitSync();
// 手动异步提交offset,当前线程提交offset不会阻塞,可以继续处理后面的程序逻辑
consumer.commitAsync(new OffsetCommitCallback() {
@Override
public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets, Exception exception) {
if (exception != null) {
System.err.println("Commit failed for " + offsets);
System.err.println("Commit failed exception: " + exception.getStackTrace());
}
}
});
}
}
① 消费消息服务做幂等校验,比如 Redis 的 set、MySQL 的主键等天然的幂等功能。这种方法最有效。这部分主要集中在消费端的编码层面,需要我们在设计代码时以幂等性的角度进行开发设计,保证同一数据无论进行多少次消费,所造成的结果都一样。处理方式可以在消息体中添加唯一标识,在处理消息前先检查下Mysql/Redis是否已经处理过该消息了,消费端进行确认此唯一标识是否已经消费过,如果消费过,则不进行之后处理。从而尽可能的避免了重复消费。
② 提高消费端的处理性能避免触发Balance,比如可以用多线程的方式来处理消息,缩短单个消息消费的时长。或者还可以调整消息处理的超时时间,也还可以减少一次性从Broker上拉取数据的条数。
Kafka的重平衡机制是指在消费者组中新增或删除消费者时,Kafka集群会重新分配主题分区给各个消费者,以保
证每个消费者消费的分区数量尽可能均衡。
重平衡机制的目的是实现消费者的负载均衡和高可用性,以确保每个消费者都能够按照预期的方式消费到消息。
重平衡的3个触发条件:
当Kafka集群要触发重平衡机制时,大致的步骤如下:
Kafka的重平衡机制能够有效地实现消费者的负载均衡和高可用性,提高消息的处理能力和可靠性。但是,由于重
平衡会带来一定的性能开销和不确定性,因此在设计应用时需要考虑到重平衡的影响,并采取一些措施来降低重平
衡的频率和影响。
在重平衡过程中,所有Consumer实例都会停止消费,等待重平衡完成。但是目前并没有什么好的办法来解决重
平衡带来的STW,只能尽量迟避免它的发生。
Controller选举
Kafka要先从所有Broker中选出唯一的一个Controller。用于管理分区的副本分配、leader选举等任务。所有的Broker会尝试在Zookeeper中创建临时节点/controller,谁先创建成功,谁就是Controller。那如果Controller挂掉或者网络出现问题,ZooKeeper上的临时节点就会消失。其他的Broker通过Watch监听到Controller下线的消息后,继续按照先到先得的原则竞选Controller。这个Controller就相当于选举委员会的主席。
Leader选举
Controller确定以后,就可以开始做分区选主的事情。接下来就是找候选人。显然,每个Replication副本都想推荐自己,但不是所有的副本都有竞选资格。只有在ISR保持心跳同步的副本才有资格参与竞选。默认是让ISR中第一个Replication变成Leader。
为了保证高可用,最好是以集群形态来部署 ZooKeeper,这样只要集群中大部分机器是可用的(能够容忍一定的机器故障),那么 ZooKeeper 本身仍然是可用的。通常 3 台服务器就可以构成一个 ZooKeeper 集群了。
ZooKeeper 官方提供的架构图就是一个 ZooKeeper 集群整体对外提供服务。
上图中每一个 Server 代表一个安装 ZooKeeper 服务的服务器。组成 ZooKeeper 服务的服务器都会在内存中维护当前的服务器状态,并且每台服务器之间都互相保持着通信。集群间通过 ZAB 协议来保持数据的一致性。
最典型集群模式: Master/Slave 模式(主备模式)。在这种模式中,通常 Master 服务器作为主服务器提供写服务,其他的 Slave 服务器从服务器通过异步复制的方式获取 Master 服务器最新的数据提供读服务。
我们通常是将 znode 分为 4 大类:
Stat 类中包含了一个数据节点的所有状态信息的字段,包括事务 ID(cZxid)、节点创建时间(ctime) 和子节点个数(numChildren) 等等,如下:
Watcher 为事件监听器,是 zk 非常重要的一个特性,很多功能都依赖于它,它有点类似于订阅的方式,即客户端向服务端注册指定的 watcher ,当服务端符合了 watcher 的某些事件或要求则会向客户端发送事件通知 ,客户端收到通知后找到自己定义的 Watcher 然后执行相应的回调方法 。
可以把 Watcher 理解成客户端注册在某个 Znode 上触发器,当这个 Znode 节点发生变化时(增删改查),就会触发 Znode 对应注册事件,注册客户端就会收到异步通知,然后做出业务改变。
zookeeper 监听原理
zookeeper的监听事件有四种
ZooKeeper Watcher 机制主要包括客户端线程、客户端WatcherManager、Zookeeper 服务器三部分。
当Zookeeper集群中的一台服务器出现以下两种情况之一时,需要进入Leader选举。
服务器启动 Leader 选举
假设一个Zookeeper集群中有5台服务器,id从1到5编号,并且它们都是最新启动的,没有历史数据。
服务器运行期间 Leader 选举
选举Leader规则:①EPOCH大的直接胜出 ②EPOCH相同,事务id大的胜出 ③事务id相同,服务器id大的胜出,总结:择优选取,保证leader是zk集群中数据最完整、最可靠的一台服务器
client端发起请求,读请求由follower和observer直接返回,写请求由它们转发给leader。Leader 首先为这个事务分配一个全局单调递增的唯一事务ID (即 ZXID )。然后发起proposal给follower,Leader 会为每一个 Follower 都各自分配一个单独的队列,然后将需要广播的事务 Proposal 依次放入这些队列中去,并且根据 FIFO策略进行消息发送。每一个 Follower 在接收到这个事务 Proposal 之后,都会首先将其以事务日志的形式写入到本地磁盘中去,并且在成功写入后反馈给 Leader 服务器一个 Ack 响应。当 Leader 服务器接收到超过半数 Follower 的 Ack 响应后,就会广播一个Commit 消息给所有的 Follower 服务器以通知其进行事务提交,同时Leader 自身也会完成对事务的提交。
实现分布式锁要借助临时顺序节点和watch,首先我们要有一个持久节点,客户端获取锁就是在持久节点下创建临时顺序节点。客户端创建的临时顺序节点创建成功后会判断节点是不是最小节点,如果是最小节点那么获取锁成功,否则回去锁失败。如果获取锁失败,则说明有其他客户端已成功获得锁,这时候也不需要循环尝试去加锁,而是给前一个节点注册一个事件监听器,这个监听器作用就是当前一个节点释放后,也就是节点删除后通知自己让自己获得锁,这样的好处是不会通知到所有的节点去争夺锁(避免无效自旋)。所以使用Zookeeper实现的分布式锁是公平锁。
为什么要用临时顺序节点
临时节点相比持久节点,最主要的是对会话失效的情况处理不一样,临时节点会话消失则对应的节点消失。这样的话,如果客户端发生异常导致没来得及释放锁也没关系,会话失效节点自动被删除,不会发生死锁的问题。
使用 Redis 实现分布式锁的时候,我们是通过过期时间来避免锁无法被释放导致死锁问题的,而 ZooKeeper 直接利用临时节点的特性即可。
假设不适用顺序节点的话,所有尝试获取锁的客户端都会对持有锁的子节点加监听器。当该锁被释放之后,势必会造成所有尝试获取锁的客户端来争夺锁,这样对性能不友好。使用顺序节点之后,只需要监听前一个节点就好了,对性能更友好。
为什么要设置对前一个节点的监听
同一时间段内,可能会有很多客户端同时获取锁,但只有一个可以获取成功。如果获取锁失败,则说明有其他的客户端已经成功获取锁。获取锁失败的客户端并不会不停地循环去尝试加锁,而是在前一个节点注册一个事件监听器。
这个事件监听器的作用是: 当前一个节点对应的客户端释放锁之后(也就是前一个节点被删除之后,监听的是删除事件),通知获取锁失败的客户端(唤醒等待的线程,Java 中的 wait/notifyAll ),让它尝试去获取锁,然后就成功获取锁了。
原生API 分布式锁
package com.example.test.other.zk;
import org.apache.zookeeper.*;
import org.apache.zookeeper.data.Stat;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
public class ZookepeerLock1 {
private final String connectionString = "127.0.0.1:2181,127.0.0.1:2182,127.0.0.1:2183";
private final int sessionTimeout = 2000;
private final ZooKeeper zk;
private CountDownLatch countDownLatch = new CountDownLatch(1);
private CountDownLatch waitLatch = new CountDownLatch(1);
private String waitPath;
private String currentMode;
public ZookepeerLock1() throws IOException, InterruptedException, KeeperException {
// 获取连接
zk = new ZooKeeper(connectionString, sessionTimeout, new Watcher() {
@Override
public void process(WatchedEvent watchedEvent) {
// connectLatch 如果连接上zk 可以释放
if (watchedEvent.getState() == Event.KeeperState.SyncConnected) {
countDownLatch.countDown();
}
// waitLatch 需要释放
if (watchedEvent.getType() == Event.EventType.NodeDeleted && watchedEvent.getPath().equals(waitPath)) {
waitLatch.countDown();
}
}
});
// 等待zk正常连接后,往下走程序
countDownLatch.await();
// 判断根节点/locks是否存在
Stat stat = zk.exists("/lockZookeeper", false);
if (stat == null) {
// 创建根节点,这是⼀个完全开放的ACL,持久节点
zk.create("/lockZookeeper", "lockZookeeper".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
}
}
// 对zk加锁
public void zkLock() {
try {
// 创建对应的临时顺序节点
currentMode =
zk.create("/lockZookeeper/" + "seq-", null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
// 判断创建的节点是否是序号最小的节点,如果是获取到锁,如果不是,监听他序号前一个节点
List<String> children = zk.getChildren("/lockZookeeper", false);
if (children.size() == 1) {
return;
} else {
//[seq-0000000016, seq-0000000017]
Collections.sort(children);
// 获取节点名称 /locks/seq-0000000017 -> seq-0000000017
String thisNode = currentMode.substring("/lockZookeeper/".length());
// 通过seq-0000000017获取该节点在children集合的位置
int index = children.indexOf(thisNode);
// 判断
if (index == -1) {
System.out.println("数据异常");
} else if (index == 0) {
// 就一个节点,可以获取锁了
return;
} else {
// 需要监听 他前一个结点的变化
waitPath = "/lockZookeeper/" + children.get(index - 1);
zk.getData(waitPath, true, null);
// 等待监听
waitLatch.await();
}
}
} catch (KeeperException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 解锁
public void unZkLock() {
// 删除节点
try {
zk.delete(currentMode, -1);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (KeeperException e) {
e.printStackTrace();
}
}
}
Curator 分布式锁
package com.example.test.other.zk;
import org.apache.curator.RetryPolicy;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.framework.recipes.locks.InterProcessLock;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.apache.curator.framework.recipes.locks.InterProcessSemaphoreMutex;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.apache.zookeeper.KeeperException;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.io.IOException;
/**
* Description: 1、@Bean单独注解方法时,每次调用方法都是执行方法内的逻辑并返回新创建的对象bean,而且SpringIOC并没有该bean的存在。
* 2、@Bean + @Configuration ,在调用@Bean注解的方法时返回的实例bean是从IOC容器获取的,已经注入的,且是单例的,而不是新创建的。
* 3、@Bean + @Component,虽然@Bean注解的方法返回的实例已经注入到SpringIOC容器中,但是每次调用@Bean注解的方法时,都会创建新的对象实例bean返回,并不会从IOC容器中获取。
* Author: yangjj_tc
* Date: 2023/5/18 13:25
*/
@Configuration
public class ZookeperLock1Test {
private final String ZOOKEEPER_ADDRESS = "127.0.0.1:2181,127.0.0.1:2182,127.0.0.1:2183";
/**
* Description: Author:
* yangjj_tc
* 1、会话连接是异步的,需要自己去处理。比如使用CountDownLatch
* 2、Watch需要重复注册,不然就不能生效
* 3、开发的复杂性还是比较高的
* 4、不支持多节点删除和创建。需要自己去递归
* Date: 2023/5/18 12:48
*/
@Bean
public CuratorFramework getCuratorFramework(){
// 重试策略,重试间隔时间为1秒,重试次数为3次。curator管理了zookeeper的连接,在操作zookeeper的过程中出现连接问题会自动重试。
RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
// 初始化客户端,通过工厂创建连接
// zk地址 会话超时时间,默认60秒 连接超时时间,默认15秒 重试策略
CuratorFramework zkClient = CuratorFrameworkFactory.newClient(ZOOKEEPER_ADDRESS, 5000, 15000, retryPolicy);
// 开始连接
zkClient.start();
return zkClient;
}
public static void main(String[] args) throws InterruptedException, IOException, KeeperException {
// 获得两个客户端
CuratorFramework client1 = new ZookeperLock1Test().getCuratorFramework();
CuratorFramework client2 = new ZookeperLock1Test().getCuratorFramework();
// 可重入锁, 意味着同一个客户端在拥有锁的同时,可以多次获取,不会被阻塞。如想重入,则需要使用同一个InterProcessMutex对象。
final InterProcessLock lock1 = new InterProcessMutex(client1, "/lockCurator");
final InterProcessLock lock2 = new InterProcessMutex(client2, "/lockCurator");
// 不可重入锁,区别在于该锁是不可重入的,在同一个线程中不可重入
final InterProcessSemaphoreMutex lock3 = new InterProcessSemaphoreMutex(client1, "/lockCurator");
final InterProcessSemaphoreMutex lock4 = new InterProcessSemaphoreMutex(client2, "/lockCurator");
// 模拟两个线程
new Thread(() -> {
try {
// 线程加锁
lock3.acquire();
lock3.acquire();
System.out.println("线程1获取锁");
// 线程沉睡
Thread.sleep(5 * 1000);
// 线程解锁
lock1.release();
System.out.println("线程1释放了锁");
} catch (Exception e) {
e.printStackTrace();
}
}).start();
// 线程2
new Thread(() -> {
// 线程加锁
try {
lock2.acquire();
System.out.println("线程2获取到锁");
// 线程沉睡
Thread.sleep(5 * 1000);
lock2.release();
System.out.println("线程2释放锁");
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
锁:把需要的代码块,资源或数据锁上,只允许一个线程去操作,保证了并发时共享数据的一致性。锁有两种类型:可重入锁和不可重入锁。
不可重入锁
若当前线程执行中已经获取了锁,如果再次获取该锁时,就会获取不到被阻塞。我们用测试例子对使用不可重入锁类的情况做下分析
当线程执行methodA()方法首先获取lock,接下来执行methodB()方法,在methodB方法中,也尝试获取lock。当前线程的锁已经被methodA获取,由lock()代码可知,methodB无法获取到锁,并且自旋,产生了死锁。这种情况叫做不可重入锁。
可是我们平时又有需要重入一把锁的需求,怎么办呢?接下来我们对不可重入锁类进行改造。
可重入锁
当线程执行methodA()方法首先获取lock,接下来执行methodB()方法,在methodB方法中,也尝试获取lock。当前线程的锁已经被methodA获取,由lock()代码可知,count加1,并且返回,继续执行methodB代码,最后释放锁unlock,count减1。跳出methodB后再次执行unlock方法,这个时候count等于0,所以currLock完全释放。
这样设计后拿到锁的代码能多次以不同的方式访问临界资源并把加锁次数count加1;在解锁的时候通过count,可以确保所有加锁的过程都解锁了。这就是可重入的能力。
可重入锁也叫递归锁,指的是同一线程外层函数获得锁之后,内层递归函数仍然有获取该锁的代码,但不受影响。
在java环境下,ReentrantLock和synchronized都是可重入锁。
对于一个集群,想要提高这个集群的可用性,通常会采用多机房部署,比如现在有一个由6台zkServer所组成的一个集群,部署在了两个机房:
正常情况下,此集群只会有一个Leader,那么如果机房之间的网络断了之后,两个机房内的zkServer还是可以相互通信的,如果不考虑过半机制,那么就会出现每个机房内部都将选出一个Leader。
这就相当于原本一个集群,被分成了两个集群,出现了两个“大脑”,这就是脑裂。
对于这种情况,我们也可以看出来,原本应该是统一的一个集群对外提供服务的,现在变成了两个集群同时对外提供服务,如果过了一会,断了的网络突然联通了,那么此时就会出现问题了,两个集群刚刚都对外提供服务了,数据该怎么合并,数据冲突怎么解决等等问题。
刚刚在说明脑裂场景时,有一个前提条件就是没有考虑过半机制,所以实际上Zookeeper集群中是不会出现脑裂问题的,而不会出现的原因就跟过半机制有关。
过半机制
举个简单的例子: 如果现在集群中有6台zkServer,也就是说至少要4台zkServer才能选出来一个Leader,才会符合过半机制,才能选出来一个Leader。
所以对于机房1来说它不能选出一个Leader,同样机房2也不能选出一个Leader,这种情况下整个集群当机房间的网络断掉后,整个集群将没有Leader。
如果假设我们现在只有5台机器,也部署在两个机房:
也就是至少要3台服务器才能选出一个Leader,此时机房件的网络断开了,对于机房1来说是没有影响的,Leader依然还是Leader,对于机房2来说是选不出来Leader的,此时整个集群中只有一个Leader。
所以,我们可以总结得出,有了过半机制,对于一个Zookeeper集群,要么没有Leader,要没只有1个Leader,这样就避免了脑裂问题。
综上,何必增加那一个不必要的 ZooKeeper 呢?
Eureka各个节点都是平等的,几个节点挂掉不会影响正常节点的工作,剩余的节点依然可以提供注册和查询服务,Eureka的客户端在向某个Eureka服务端注册时如果发现链接失败,则自动切换到其他节点,只要有一台Eureka活着,就能保证服务可用,强调可用性。只不过查询到的信息可能不是最新的,不能保证一致性。
zk会出现这样一种情况,当master节点因为网络故障与其他节点失去联系时,剩余节点会重新进行leader选举,问题在于,选择leader的时间太长,且选举期间整个zk集群都是不可用的,者就导致在选举期间注册服务瘫痪,虽然服务能够最终恢复,但是漫长的选举时间导致的注册长期不可用,不能保证可用性,但是它因为只从节点的设置,从节点会从主节点同步数据,主从节点数据一致,强调一致性。