0%

引言

在探究 Kafka 核心知识之前,我们先思考一个问题:什么场景会促使我们使用 Kafka? 说到这里,我们头脑中或多或少会蹦出异步解耦削峰填谷等字样,是的,这就是 Kafka 最重要的落地场景。

  • 异步解耦:同步调用转换成异步消息通知,实现生产者和消费者的解耦。想象一个场景,在商品交易时,在订单创建完成之后,需要触发一系列其他的操作,比如进行用户订单数据的统计、给用户发送短信、给用户发送邮件等等。如果所有操作都采用同步方式实现,将严重影响系统性能。针对此场景,我们可以利用消息中间件解耦订单创建操作和其他后续行为。
  • 削峰填谷:利用 broker 缓冲上游生产者瞬时突发的流量,使消费者消费流量整体平滑。对于发送能力很强的上游系统,如果没有消息中间件的保护,下游系统可能会直接被压垮导致全链路服务雪崩。想象秒杀业务场景,上游业务发起下单请求,下游业务执行秒杀业务(库存检查,库存冻结,余额冻结,生成订单等等),下游业务处理的逻辑是相当复杂的,并发能力有限,如果上游服务不做限流策略,瞬时可能把下游服务压垮。针对此场景,我们可以利用 MQ 来做削峰填谷,让高峰流量填充低谷空闲资源,达到系统资源的合理利用。

通过上述例子可以发现交易、支付等场景常需要异步解耦削峰填谷功能解决问题,而交易、支付等场景对性能、可靠性要求特别高。那么,我们本文的主角 Kafka 能否满足相应要求呢?下面我们来探讨下。

Kafka 宏观认知

在探究 Kafka 的高性能、高可靠性之前,我们从宏观上来看下 Kafka 的系统架构:

图片

如上图所示,Kafka 由 Producer、Broker、Consumer 以及负责集群管理的 ZooKeeper 组成,各部分功能如下:

  • Producer:生产者,负责消息的创建并通过一定的路由策略发送消息到合适的 Broker;
  • Broker:服务实例,负责消息的持久化、中转等功能;
  • Consumer :消费者,负责从 Broker 中拉取(Pull)订阅的消息并进行消费,通常多个消费者构成一个分组,消息只能被同组中的一个消费者消费;
  • ZooKeeper:负责 broker、consumer 集群元数据的管理等;(注意:Producer 端直接连接 broker,不在 zk 上存任何数据,只是通过 ZK 监听 broker 和 topic 等信息

上图消息流转过程中,还有几个特别重要的概念—主题(Topic)、分区(Partition)、分段(segment)、位移(offset)。

  • topic:消息主题。Kafka 按 topic 对消息进行分类,我们在收发消息时只需指定 topic。

  • partition:分区。为了提升系统的吞吐,一个 topic 下通常有多个 partition,partition 分布在不同的 Broker 上,用于存储 topic 的消息,这使 Kafka 可以在多台机器上处理、存储消息,给 kafka 提供给了并行的消息处理能力和横向扩容能力。另外,为了提升系统的可靠性,partition 通常会分组,且每组有一个主 partition、多个副本 partition,且分布在不同的 broker 上,从而起到容灾的作用。

  • segment:分段。宏观上看,一个 partition 对应一个日志(Log)。由于生产者生产的消息会不断追加到 log 文件末尾,为防止 log 文件过大导致数据检索效率低下,Kafka 采取了分段和索引机制,将每个 partition 分为多个 segment,同时也便于消息的维护和清理。每个 segment 包含一个.log 日志文件、两个索引(.index、timeindex)文件以及其他可能的文件。每个 Segment 的数据文件以该段中最小的 offset 为文件名,当查找 offset 的 Message 的时候,通过二分查找快找到 Message 所处于的 Segment 中。

  • offset:消息在日志中的位置,消息在被追加到分区日志文件的时候都会分配一个特定的偏移量。offset 是消息在分区中的唯一标识,是一个单调递增且不变的值。Kafka 通过它来保证消息在分区内的顺序性,不过 offset 并不跨越分区,也就是说,Kafka 保证的是分区有序而不是主题有序

Kafka 高可靠性、高性能探究

在对 Kafka 的整体系统框架及相关概念简单了解后,下面我们来进一步深入探讨下高可靠性、高性能实现原理。

Kafka 高可靠性探究

Kafka 高可靠性的核心是保证消息在传递过程中不丢失,涉及如下核心环节:

  • 消息从生产者可靠地发送至 Broker;– 网络、本地丢数据;
  • 发送到 Broker 的消息可靠持久化;– Pagecache 缓存落盘、单点崩溃、主从同步跨网络;
  • 消费者从 Broker 消费到消息且最好只消费一次 – 跨网络消息传输 。
*消息从生产者可靠地发送至 Broker*

为了保障消息从生产者可靠地发送至 Broker,我们需要确保两点;

  1. Producer 发送消息后,能够收到来自 Broker 的消息保存成功 ack;
  2. Producer 发送消息后,能够捕获超时、失败 ack 等异常 ack 并做处理。
ack 策略

针对问题 1,Kafka 为我们提供了三种 ack 策略,

  • Request.required.acks = 0:请求发送即认为成功,不关心有没有写成功,常用于日志进行分析场景;
  • Request.required.acks = 1:当 leader partition 写入成功以后,才算写入成功,有丢数据的可能;
  • Request.required.acks= -1:ISR 列表里面的所有副本都写完以后,这条消息才算写入成功,强可靠性保证;

为了实现强可靠的 kafka 系统,我们需要设置 Request.required.acks= -1,同时还会设置集群中处于正常同步状态的副本 follower 数量 min.insync.replicas>2,另外,设置 unclean.leader.election.enable=false 使得集群中 ISR 的 follower 才可变成新的 leader,避免特殊情况下消息截断的出现。

消息发送策略

针对问题 2,kafka 提供两类消息发送方式:同步(sync)发送和异步(async)发送,相关参数如下:

图片

以 sarama 实现为例,在消息发送的过程中,无论是同步发送还是异步发送都会涉及到两个协程–负责消息发送的主协程和负责消息分发的 dispatcher 协程。

异步发送

对于异步发送(ack != 0 场景,等于 0 时不关心写 kafka 结果,后文详细讲解)而言,其流程大概如下:

图片

  1. 主协程中调用异步发送 kafka 消息的时候,其本质是将消息体放进了一个 input 的 channel,只要入 channel 成功,则这个函数直接返回,不会产生任何阻塞。相反,如果入 channel 失败,则会返回错误信息。因此调用 async 写入的时候返回的错误信息是入 channel 的错误信息,至于具体最终消息有没有发送到 kafka 的 broker,我们无法从返回值得知。
  2. 当消息进入 input 的 channel 后,会有另一个dispatcher 的协程负责遍历 input,来真正发送消息到特定 Broker 上的主 Partition 上。发送结果通过一个异步协程进行监听,循环处理 err channel 和 success channel,出现了 error 就记一个日志。因此异步写入场景时,写 kafka 的错误信息,我们暂时仅能够从这个错误日志来得知具体发生了什么错,并且也不支持我们自建函数进行兜底处理,这一点在 trpc-go 的官方也得到了承认。

同步发送

同步发送(ack != 0 场景)是在异步发送的基础上加以条件限制实现的。同步消息发送在 newSyncProducerFromAsyncProducer 中开启两个异步协程处理消息成功与失败的“回调”,并使用 waitGroup 进行等待,从而将异步操作转变为同步操作,其流程大概如下:

图片

通过上述分析可以发现,kafka 消息发送本质上都是异步的,不过同步发送通过 waitGroup 将异步操作转变为同步操作。同步发送在一定程度上确保了我们在跨网络向 Broker 传输消息时,消息一定可以可靠地传输到 Broker。因为在同步发送场景我们可以明确感知消息是否发送至 Broker,若因网络抖动、机器宕机等故障导致消息发送失败或结果不明,可通过重试等手段确保消息至少一次(at least once) 发送到 Broker。另外,Kafka(0.11.0.0 版本后)还为 Producer 提供两种机制来实现精确一次(exactly once) 消息发送:幂等性(Idempotence)和事务(Transaction)。

图片

小结

通过 ack 策略配置、同步发送、事务消息组合能力,我们可以实现exactly once 语意跨网络向 Broker 传输消息。但是,Producer 收到 Broker 的成功 ack,消息一定不会丢失吗?为了搞清这个问题,我们首先要搞明白 Broker 在接收到消息后做了哪些处理。

发送到 Broker 的消息可靠持久化

为了确保 Producer 收到 Broker 的成功 ack 后,消息一定不在 Broker 环节丢失,我们核心要关注以下几点:

  • Broker 返回 Producer 成功 ack 时,消息是否已经落盘;
  • Broker 宕机是否会导致数据丢失,容灾机制是什么;
  • Replica 副本机制带来的多副本间数据同步一致性问题如何解决;
Broker 异步刷盘机制

kafka 为了获得更高吞吐,Broker 接收到消息后只是将数据写入 PageCache 后便认为消息已写入成功,而 PageCache 中的数据通过 linux 的 flusher 程序进行异步刷盘(刷盘触发条:主动调用 sync 或 fsync 函数、可用内存低于阀值、dirty data 时间达到阀值),将数据顺序写到磁盘。消息处理示意图如下:

图片

由于消息是写入到 pageCache,单机场景,如果还没刷盘 Broker 就宕机了,那么 Producer 产生的这部分数据就可能丢失。为了解决单机故障可能带来的数据丢失问题,Kafka 为分区引入了副本机制。

Replica 副本机制

Kafka 每组分区通常有多个副本,同组分区的不同副本分布在不同的 Broker 上,保存相同的消息(可能有滞后)。副本之间是“一主多从”的关系,其中 leader 副本负责处理读写请求,follower 副本负责从 leader 拉取消息进行同步。分区的所有副本统称为 AR(Assigned Replicas),其中所有与 leader 副本保持一定同步的副本(包括 leader 副本在内)组成 ISR(In-Sync Replicas),与 leader 同步滞后过多的副本组成 OSR(Out-of-Sync Replicas),由此可见,AR=ISR+OSR。

follower 副本是否与 leader 同步的判断标准取决于 Broker 端参数 replica.lag.time.max.ms(默认为 10 秒),follower 默认每隔 500ms 向 leader fetch 一次数据,只要一个 Follower 副本落后 Leader 副本的时间不连续超过 10 秒,那么 Kafka 就认为该 Follower 副本与 leader 是同步的。在正常情况下,所有的 follower 副本都应该与 leader 副本保持一定程度的同步,即 AR=ISR,OSR 集合为空。

当 leader 副本所在 Broker 宕机时,Kafka 会借助 ZK 从 follower 副本中选举新的 leader 继续对外提供服务,实现故障的自动转移,保证服务可用。为了使选举的新 leader 和旧 leader 数据尽可能一致,当 leader 副本发生故障时,默认情况下只有在 ISR 集合中的副本才有资格被选举为新的 leader,而在 OSR 集合中的副本则没有任何机会(可通过设置 unclean.leader.election.enable 改变)。

当 Kafka 通过多副本机制解决单机故障问题时,同时也带来了多副本间数据同步一致性问题。Kafka 通过高水位更新机制、副本同步机制、 Leader Epoch 等多种措施解决了多副本间数据同步一致性问题,下面我们来依次看下这几大措施。

HW 和 LEO

首先,我们来看下两个和 Kafka 中日志相关的重要概念 HW 和 LEO:

  • HW: High Watermark,高水位,表示已经提交(commit)的最大日志偏移量,Kafka 中某条日志“已提交”的意思是 ISR 中所有节点都包含了此条日志,并且消费者只能消费 HW 之前的数据;
  • LEO: Log End Offset,表示当前 log 文件中下一条待写入消息的 offset;![图片](data:image/svg+xml,<%3Fxml version=’1.0’ encoding=’UTF-8’%3F>)

如上图所示,它代表一个日志文件,这个日志文件中有 8 条消息,0 至 5 之间的消息为已提交消息,5 至 7 的消息为未提交消息。日志文件的 HW 为 6,表示消费者只能拉取到 5 之前的消息,而 offset 为 5 的消息对消费者而言是不可见的。日志文件的 LEO 为 8,下一条消息将在此处写入。

注意:所有副本都有对应的 HW 和 LEO,只不过 Leader 副本比较特殊,Kafka 使用 Leader 副本的高水位来定义所在分区的高水位。换句话说,分区的高水位就是其 Leader 副本的高水位。Leader 副本和 Follower 副本的 HW 有如下特点:

  • Leader HW:min(所有副本 LEO),为此 Leader 副本不仅要保存自己的 HW 和 LEO,还要保存 follower 副本的 HW 和 LEO,而 follower 副本只需保存自己的 HW 和 LEO;
  • Follower HW:min(follower 自身 LEO,leader HW)。

注意:为方便描述,下面Leader HW简记为HWL,Follower HW简记为F,Leader LEO简记为LEOL ,Follower LEO简记为LEOF。

下面我们演示一次完整的 HW / LEO 更新流程:

图片

  1. 初始状态

HWL=0,LEOL=0,HWF=0,LEOF=0。

图片

  1. Follower 第一次 fetch
  • Leader收到Producer发来的一条消息完成存储, 更新LEOL=1;

  • Follower从Leader fetch数据, Leader收到请求,记录follower的LEOF =0,并且尝试更新HWL =min(全部副本LEO)=0;

  • eade返回HWL=0和LEOL=1给Follower,Follower存储消息并更新LEOF =1, HW=min(LEOF,HWL)=0。

    图片

  1. Follower 第二次 fetch
  • Follower再次从Leader fetch数据, Leader收到请求,记录follower的LEOF =1,并且尝试更新HWL =min(全部副本LEO)=1;

  • leade返回HWL=1和LEOL=1给Follower,Leader收到请求,更新自己的 HW=min(LEOF,HWL)=1。

上述更新流程中 Follower 和 Leader 的 HW 更新有时间 GAP。如果 Leader 节点在此期间发生故障,则 Follower 的 HW 和 Leader 的 HW 可能会处于不一致状态,如果 Followe 被选为新的 Leader 并且以自己的 HW 为准对外提供服务,则可能带来数据丢失或数据错乱问题。

KIP-101 问题:数据丢失&数据错乱 ^参 5^

数据丢失

图片

第 1 步:

  1. 副本 B 作为 leader 收到 producer 的 m2 消息并写入本地文件,等待副本 A 拉取。
  2. 副本 A 发起消息拉取请求,请求中携带自己的最新的日志 offset(LEO=1),B 收到后更新自己的 HW 为 1,并将 HW=1 的信息以及消息 m2 返回给 A。
  3. A 收到拉取结果后更新本地的 HW 为 1,并将 m2 写入本地文件。发起新一轮拉取请求(LEO=2),B 收到 A 拉取请求后更新自己的 HW 为 2,没有新数据只将 HW=2 的信息返回给 A,并且回复给 producer 写入成功。此处的状态就是图中第一步的状态。

第 2 步:

此时,如果没有异常,A 会收到 B 的回复,得知目前的 HW 为 2,然后更新自身的 HW 为 2。但在此时 A 重启了,没有来得及收到 B 的回复,此时 B 仍然是 leader。A 重启之后会以 HW 为标准截断自己的日志,因为 A 作为 follower 不知道多出的日志是否是被提交过的,防止数据不一致从而截断多余的数据并尝试从 leader 那里重新同步。

第 3 步:

B 崩溃了,min.isr 设置的是 1,所以 zookeeper 会从 ISR 中再选择一个作为 leader,也就是 A,但是 A 的数据不是完整的,从而出现了数据丢失现象。

问题在哪里?在于 A 重启之后以 HW 为标准截断了多余的日志。不截断行不行?不行,因为这个日志可能没被提交过(也就是没有被 ISR 中的所有节点写入过),如果保留会导致日志错乱。

数据错乱

图片

在分析日志错乱的问题之前,我们需要了解到 kafka 的副本可靠性保证有一个前提:在 ISR 中至少有一个节点。如果节点均宕机的情况下,是不保证可靠性的,在这种情况会出现数据丢失,数据丢失是可接受的。这里我们分析的问题比数据丢失更加槽糕,会引发日志错乱甚至导致整个系统异常,而这是不可接受的。

第 1 步:

  1. A 和 B 均为 ISR 中的节点。副本 A 作为 leader,收到 producer 的消息 m2 的请求后写入 PageCache 并在某个时刻刷新到本地磁盘。
  2. 副本 B 拉取到 m2 后写入 PageCage 后(尚未刷盘)再次去 A 中拉取新消息并告知 A 自己的 LEO=2,A 收到更新自己的 HW 为 1 并回复给 producer 成功。
  3. 此时 A 和 B 同时宕机,B 的 m2 由于尚未刷盘,所以 m2 消息丢失。此时的状态就是第 1 步的状态。

第 2 步:

由于 A 和 B 均宕机,而 min.isr=1 并且 unclean.leader.election.enable=true(关闭 unclean 选择策略),所以 Kafka 会等到第一个 ISR 中的节点恢复并选为 leader,这里不幸的是 B 被选为 leader,而且还接收到 producer 发来的新消息 m3。注意,这里丢失 m2 消息是可接受的,毕竟所有节点都宕机了。

第 3 步:

A 恢复重启后发现自己是 follower,而且 HW 为 2,并没有多余的数据需要截断,所以开始和 B 进行新一轮的同步。但此时 A 和 B 均没有意识到,offset 为 1 的消息不一致了。

问题在哪里?在于日志的写入是异步的,上面也提到 Kafka 的副本策略的一个设计是消息的持久化是异步的,这就会导致在场景二的情况下被选出的 leader 不一定包含所有数据,从而引发日志错乱的问题。

Leader Epoch

为了解决上述缺陷,Kafka 引入了 Leader Epoch 的概念。leader epoch 和 raft 中的任期号的概念很类似,每次重新选择 leader 的时候,用一个严格单调递增的 id 来标志,可以让所有 follower 意识到 leader 的变化。而 follower 也不再以 HW 为准,每次奔溃重启后都需要去 leader 那边确认下当前 leader 的日志是从哪个 offset 开始的。下面看下 Leader Epoch 是如何解决上面两个问题的。

数据丢失解决

图片

这里的关键点在于副本 A 重启后作为 follower,不是忙着以 HW 为准截断自己的日志,而是先发起 LeaderEpochRequest 询问副本 B 第 0 代的最新的偏移量是多少,副本 B 会返回自己的 LEO 为 2 给副本 A,A 此时就知道消息 m2 不能被截断,所以 m2 得到了保留。当 A 选为 leader 的时候就保留了所有已提交的日志,日志丢失的问题得到解决。

如果发起 LeaderEpochRequest 的时候就已经挂了怎么办?这种场景下,不会出现日志丢失,因为副本 A 被选为 leader 后不会截断自己的日志,日志截断只会发生在 follower 身上。

数据错乱解决

图片

这里的关键点还是在第 3 步,副本 A 重启作为 follower 的第一步还是需要发起 LeaderEpochRequest 询问 leader 当前第 0 代最新的偏移量是多少,由于副本 B 已经经过换代,所以会返回给 A 第 1 代的起始偏移(也就是 1),A 发现冲突后会截断自己偏移量为 1 的日志,并重新开始和 leader 同步。副本 A 和副本 B 的日志达到了一致,解决了日志错乱。

小结

Broker 接收到消息后只是将数据写入 PageCache 后便认为消息已写入成功,但是,通过副本机制并结合 ACK 策略可以大概率规避单机宕机带来的数据丢失问题,并通过 HW、副本同步机制、 Leader Epoch 等多种措施解决了多副本间数据同步一致性问题,最终实现了 Broker 数据的可靠持久化。

消费者从 Broker 消费到消息且最好只消费一次

Consumer 在消费消息的过程中需要向 Kafka 汇报自己的位移数据,只有当 Consumer 向 Kafka 汇报了消息位移,该条消息才会被 Broker 认为已经被消费。因此,Consumer 端消息的可靠性主要和 offset 提交方式有关,Kafka 消费端提供了两种消息提交方式:

图片

正常情况下我们很难实现 exactly once 语意的消息,通常是通过手动提交+幂等实现消息的可靠消费。

Kafka 高性能探究

Kafka 高性能的核心是保障系统低延迟、高吞吐地处理消息,为此,Kafaka 采用了许多精妙的设计:

  • 异步发送
  • 批量发送
  • 压缩技术
  • Pagecache 机制&顺序追加落盘
  • 零拷贝
  • 稀疏索引
  • broker & 数据分区
  • 多 reactor 多线程网络模型

异步发送

如上文所述,Kafka 提供了异步和同步两种消息发送方式。在异步发送中,整个流程都是异步的。调用异步发送方法后,消息会被写入 channel,然后立即返回成功。Dispatcher 协程会从 channel 轮询消息,将其发送到 Broker,同时会有另一个异步协程负责处理 Broker 返回的结果。同步发送本质上也是异步的,但是在处理结果时,同步发送通过 waitGroup 将异步操作转换为同步。使用异步发送可以最大化提高消息发送的吞吐能力。

批量发送

Kafka 支持批量发送消息,将多个消息打包成一个批次进行发送,从而减少网络传输的开销,提高网络传输的效率和吞吐量。Kafka 的批量发送消息是通过以下两个参数来控制的:

  1. batch.size:控制批量发送消息的大小,默认值为 16KB,可适当增加 batch.size 参数值提升吞吐。但是,需要注意的是,如果批量发送的大小设置得过大,可能会导致消息发送的延迟增加,因此需要根据实际情况进行调整
  2. linger.ms:控制消息在批量发送前的等待时间,默认值为 0。当 linger.ms 大于 0 时,如果有消息发送,Kafka 会等待指定的时间,如果等待时间到达或者批量大小达到 batch.size,就会将消息打包成一个批次进行发送。可适当增加 linger.ms 参数值提升吞吐,比如 10 ~ 100。

在 Kafka 的生产者客户端中,当发送消息时,如果启用了批量发送,Kafka 会将消息缓存到缓冲区中。当缓冲区中的消息大小达到 batch.size 或者等待时间到达 linger.ms 时,Kafka 会将缓冲区中的消息打包成一个批次进行发送。如果在等待时间内没有达到 batch.size,Kafka 也会将缓冲区中的消息发送出去,从而避免消息积压。

压缩技术

Kafka 支持压缩技术,通过将消息进行压缩后再进行传输,从而减少网络传输的开销(压缩和解压缩的过程会消耗一定的 CPU 资源,因此需要根据实际情况进行调整。),提高网络传输的效率和吞吐量。Kafka 支持多种压缩算法,在 Kafka2.1.0 版本之前,仅支持 GZIP,Snappy 和 LZ4,2.1.0 后还支持 Zstandard 算法(Facebook 开源,能够提供超高压缩比)。这些压缩算法性能对比(两指标都是越高越好)如下:

  • 吞吐量:LZ4>Snappy>zstd 和 GZIP,压缩比:zstd>LZ4>GZIP>Snappy。

在 Kafka 中,压缩技术是通过以下两个参数来控制的:

  1. compression.type:控制压缩算法的类型,默认值为 none,表示不进行压缩。
  2. compression.level:控制压缩的级别,取值范围为 0-9,默认值为-1。当值为-1 时,表示使用默认的压缩级别。

在 Kafka 的生产者客户端中,当发送消息时,如果启用了压缩技术,Kafka 会将消息进行压缩后再进行传输。在消费者客户端中,如果消息进行了压缩,Kafka 会在消费消息时将其解压缩。注意:Broker 如果设置了和生产者不通的压缩算法,接收消息后会解压后重新压缩保存。Broker 如果存在消息版本兼容也会触发解压后再压缩。

Pagecache 机制&顺序追加落盘

kafka 为了提升系统吞吐、降低时延,Broker 接收到消息后只是将数据写入PageCache后便认为消息已写入成功,而 PageCache 中的数据通过 linux 的 flusher 程序进行异步刷盘(避免了同步刷盘的巨大系统开销),将数据顺序追加写到磁盘日志文件中。由于 pagecache 是在内存中进行缓存,因此读写速度非常快,可以大大提高读写效率。顺序追加写充分利用顺序 I/O 写操作,避免了缓慢的随机 I/O 操作,可有效提升 Kafka 吞吐。

图片

如上图所示,消息被顺序追加到每个分区日志文件的尾部。

零拷贝

Kafka 中存在大量的网络数据持久化到磁盘(Producer 到 Broker)和磁盘文件通过网络发送(Broker 到 Consumer)的过程,这一过程的性能直接影响 Kafka 的整体吞吐量。传统的 IO 操作存在多次数据拷贝和上下文切换,性能比较低。Kafka 利用零拷贝技术提升上述过程性能,其中网络数据持久化磁盘主要用 mmap 技术,网络数据传输环节主要使用 sendfile 技术。

索引加速之 mmap

传统模式下,数据从网络传输到文件需要 4 次数据拷贝、4 次上下文切换和两次系统调用。如下图所示:

图片

为了减少上下文切换以及数据拷贝带来的性能开销,Kafka使用mmap来处理其索引文件。Kafka中的索引文件用于在提取日志文件中的消息时进行高效查找。这些索引文件被维护为内存映射文件,这允许Kafka快速访问和搜索内存中的索引,从而加速在日志文件中定位消息的过程。mmap 将内核中读缓冲区(read buffer)的地址与用户空间的缓冲区(user buffer)进行映射,从而实现内核缓冲区与应用程序内存的共享,省去了将数据从内核读缓冲区(read buffer)拷贝到用户缓冲区(user buffer)的过程,整个拷贝过程会发生 4 次上下文切换,1 次CPU 拷贝和 2次 DMA 拷贝。

图片

网络数据传输之 sendfile

传统方式实现:先读取磁盘、再用 socket 发送,实际也是进过四次 copy。如下图所示:

图片

为了减少上下文切换以及数据拷贝带来的性能开销,Kafka 在 Consumer 从 Broker 读数据过程中使用了 sendfile 技术。具体在这里采用的方案是通过 NIO 的 transferTo/transferFrom 调用操作系统的 sendfile 实现零拷贝。总共发生 2 次内核数据拷贝、2 次上下文切换和一次系统调用,消除了 CPU 数据拷贝,如下:

图片

稀疏索引

为了方便对日志进行检索和过期清理,kafka 日志文件除了有用于存储日志的.log 文件,还有一个位移索引文件.index和一个时间戳索引文件.timeindex 文件,并且三文件的名字完全相同,如下:

图片

Kafka 的索引文件是按照稀疏索引的思想进行设计的。稀疏索引的核心是不会为每个记录都保存索引,而是写入一定的记录之后才会增加一个索引值,具体这个间隔有多大则通过 log.index.interval.bytes 参数进行控制,默认大小为 4 KB,意味着 Kafka 至少写入 4KB 消息数据之后,才会在索引文件中增加一个索引项。可见,单条消息大小会影响 Kakfa 索引的插入频率,因此 log.index.interval.bytes 也是 Kafka 调优一个重要参数值。由于索引文件也是按照消息的顺序性进行增加索引项的,因此 Kafka 可以利用二分查找算法来搜索目标索引项,把时间复杂度降到了 O(lgN),大大减少了查找的时间。

位移索引文件.index

位移索引文件的索引项结构如下:

图片

相对位移:保存于索引文件名字上面的起始位移的差值,假设一个索引文件为:00000000000000000100.index,那么起始位移值即 100,当存储位移为 150 的消息索引时,在索引文件中的相对位移则为 150 - 100 = 50,这么做的好处是使用 4 字节保存位移即可,可以节省非常多的磁盘空间

文件物理位置:消息在 log 文件中保存的位置,也就是说 Kafka 可根据消息位移,通过位移索引文件快速找到消息在 log 文件中的物理位置,有了该物理位置的值,我们就可以快速地从 log 文件中找到对应的消息了。下面我用图来表示 Kafka 是如何快速检索消息:

图片

假设 Kafka 需要找出位移为 3550 的消息,那么 Kafka 首先会使用二分查找算法找到小于 3550 的最大索引项:[3528, 2310272],得到索引项之后,Kafka 会根据该索引项的文件物理位置在 log 文件中从位置 2310272 开始顺序查找,直至找到位移为 3550 的消息记录为止。

时间戳索引文件.timeindex

Kafka 在 0.10.0.0 以后的版本当中,消息中增加了时间戳信息,为了满足用户需要根据时间戳查询消息记录,Kafka 增加了时间戳索引文件,时间戳索引文件的索引项结构如下:

图片

时间戳索引文件的检索与位移索引文件类似,如下快速检索消息示意图:

图片

broker & 数据分区

Kafka 集群包含多个 broker。一个 topic 下通常有多个 partition,partition 分布在不同的 Broker 上,用于存储 topic 的消息,这使 Kafka 可以在多台机器上处理、存储消息,给 kafka 提供给了并行的消息处理能力和横向扩容能力。

多 reactor 多线程网络模型

多 Reactor 多线程网络模型 是一种高效的网络通信模型,可以充分利用多核 CPU 的性能,提高系统的吞吐量和响应速度。Kafka 为了提升系统的吞吐,在 Broker 端处理消息时采用了该模型,示意如下:

图片

SocketServerKafkaRequestHandlerPool是其中最重要的两个组件:

  • SocketServer:实现 Reactor 模式,用于处理多个 Client(包括客户端和其他 broker 节点)的并发请求,并将处理结果返回给 Client
  • KafkaRequestHandlerPool:Reactor 模式中的 Worker 线程池,里面定义了多个工作线程,用于处理实际的 I/O 请求逻辑。

整个服务端处理请求的流程大致分为以下几个步骤:

  1. Acceptor 接收客户端发来的请求
  2. 轮询分发给 Processor 线程处理
  3. Processor 将请求封装成 Request 对象,放到 RequestQueue 队列
  4. KafkaRequestHandlerPool 分配工作线程,处理 RequestQueue 中的请求
  5. KafkaRequestHandler 线程处理完请求后,将响应 Response 返回给 Processor 线程
  6. Processor 线程将响应返回给客户端

其他知识探究

负载均衡

生产者负载均衡

Kafka 生产端的负载均衡主要指如何将消息发送到合适的分区。Kafka 生产者生产消息时,根据分区器将消息投递到指定的分区中,所以 Kafka 的负载均衡很大程度上依赖于分区器。Kafka 默认的分区器是 Kafka 提供的 DefaultPartitioner。它的分区策略是根据 Key 值进行分区分配的:

  • 如果 key 不为 null:对 Key 值进行 Hash 计算,从所有分区中根据 Key 的 Hash 值计算出一个分区号;拥有相同 Key 值的消息被写入同一个分区,顺序消息实现的关键
  • 如果 key 为 null:消息将以轮询的方式,在所有可用分区中分别写入消息。如果不想使用 Kafka 默认的分区器,用户可以实现 Partitioner 接口,自行实现分区方法。
消费者负载均衡

在 Kafka 中,每个分区(Partition)只能由一个消费者组中的一个消费者消费。当消费者组中有多个消费者时,Kafka 会自动进行负载均衡,将分区均匀地分配给每个消费者。在 Kafka 中,消费者负载均衡算法可以通过设置消费者组的 partition.assignment.strategy 参数来选择。目前主流的分区分配策略以下几种:

  • range: 在保证均衡的前提下,将连续的分区分配给消费者,对应的实现是 RangeAssignor;
  • round-robin:在保证均衡的前提下,轮询分配,对应的实现是 RoundRobinAssignor;
  • 0.11.0.0 版本引入了一种新的分区分配策略 StickyAssignor,其优势在于能够保证分区均衡的前提下尽量保持原有的分区分配结果,从而避免许多冗余的分区分配操作,减少分区再分配的执行时间。

集群管理

Kafka 借助 ZooKeeper 进行集群管理。Kafka 中很多信息都在 ZK 中维护,如 broker 集群信息、consumer 集群信息、 topic 相关信息、 partition 信息等。Kafka 的很多功能也是基于 ZK 实现的,如 partition 选主、broker 集群管理、consumer 负载均衡等,限于篇幅本文将不展开陈述,这里先附一张网上截图大家感受下:

图片

参考文献

  1. https://www.cnblogs.com/arvinhuang/p/16437948.html
  2. https://segmentfault.com/a/1190000039133960
  3. http://matt33.com/2018/11/04/kafka-transaction/
  4. https://blog.51cto.com/u_14020077/5836698
  5. https://t1mek1ller.github.io/2020/02/15/kafka-leader-epoch/
  6. https://cwiki.apache.org/confluence/display/KAFKA/KIP-101+-+Alter+Replication+Protocol+to+use+Leader+Epoch+rather+than+High+Watermark+for+Truncation
  7. https://xie.infoq.cn/article/c06fea629926e2b6a8073e2f0
  8. https://xie.infoq.cn/article/8191412c8da131e78cbfa6600
  9. https://mp.weixin.qq.com/s/iEk0loXsKsMO_OCVlUsk2Q
  10. https://cloud.tencent.com/developer/article/1657649
  11. https://www.cnblogs.com/vivotech/p/16347074.html

引言

线上排查、性能优化等概念也是面试过程中的“常客”,而对于线上遇到的“疑难杂症”,需要通过理性的思维去分析问题、排查问题、定位问题、解决问题,同时,如果解决掉所遇到的问题或瓶颈后,也可以在能力范围之内尝试最优解以及适当考虑拓展性。

在本章中会先讲明线上排查问题的思路,再接着会对于JVM常用的排查工具进行阐述,最后会对于JVM线上常遇的一些故障问题进行全面剖析。

一、JVM线上环境时常见故障与排查思路分析

在开发过程中,如果遇到JVM问题时,通常都有各种各样的本地可视化工具支持查看。但开发环境中编写出的程序迟早会被部署在生产环境的服务器上,而线上环境偶尔也容易遇到一些突发状况,比如JVM在线上环境往往会出现以下几个问题:

  • ①JVM内存泄漏。

  • ②JVM内存溢出。

  • ③业务线程死锁。

  • ④应用程序异常宕机。

  • ⑤线程阻塞/响应速度变慢。

  • ⑥CPU利用率飙升或100%。

    当程序在线上环境发生故障时,就不比开发环境那样,可以通过可视化工具监控、调试,线上环境往往会“恶劣”很多,那当遇到这类问题时又该如何处理呢?首先在碰到这类故障问题时,得具备良好的排查思路,再建立在理论知识的基础上,通过经验+数据的支持依次分析后加以解决。

1.1、线上排查及其解决问题的思路

相对而言,解决故障问题也好,处理性能瓶颈也罢,通常思路大致都是相同的,步骤如下:

  • ①分析问题:根据理论知识+经验分析问题,判断问题可能出现的位置或可能引起问题的原因,将目标缩小到一定范围。

  • ②排查问题:基于上一步的结果,从引发问题的“可疑性”角度出发,从高到低依次进行排查,进一步排除一些选项,将目标范围进一步缩小。

  • ③定位问题:通过相关的监控数据的辅助,以更“细粒度”的手段,将引发问题的原因定位到精准位置。

  • ④解决问题:判断到问题出现的具体位置以及引发的原因后,采取相关措施对问题加以解决。

  • ⑤尝试最优解(非必须):将原有的问题解决后,在能力范围内,且环境允许的情况下,应该适当考虑问题的最优解(可以从性能、拓展性、并发等角度出发)。

    当然,上述过程是针对特殊问题以及经验老道的开发者而言的,作为“新时代的程序构建者”,那当然得学会合理使用工具来帮助我们快速解决问题:

  • ①摘取或复制问题的关键片段。

  • ②打开百度谷歌后粘贴搜索。

  • ③观察返回结果中,选择标题与描述与自己问题较匹配的资料进入。

  • ④多看几个后,根据其解决方案尝试解决问题。

  • ⑤成功解决后皆大欢喜,尝试无果后“找人/问群”。

  • ⑥“外力”无法解决问题时自己动手,根据之前的步骤依次排查解决。

前面给出了两套解决问题的步骤,面试/学习推荐前者,实际开发推荐后者,毕竟面试的时候人家问你怎么解决问题的,你总不能说靠百度。
同时还有关键一点要明白:“能够搜索出来的资料也是人写出来的,你为何不能成为写的那人呢”

1.2、线上排查的方向

通常情况下来说,系统部署在线上出现故障,经过分析排查后,最终诱发问题的根本原因无非在于如下几点:

  • 应用程序本身导致的问题

    • 程序内部频繁触发GC,造成系统出现长时间停顿,导致客户端堆积大量请求。
    • JVM参数配置不合理,导致线上运行失控,如堆内存、各内存区域太小等。
    • Java程序代码存在缺陷,导致线上运行出现Bug,如死锁/内存泄漏、溢出等。
    • 程序内部资源使用不合理,导致出现问题,如线程/DB连接/网络连接/堆外内存等。
  • 上下游内部系统导致的问题

    • 上游服务出现并发情况,导致当前程序请求量急剧增加,从而引发问题拖垮系统。
    • 下游服务出现问题,导致当前程序堆积大量请求拖垮系统,如Redis宕机/DB阻塞等。
  • 程序所部署的机器本身导致的问题

    • 服务器机房网络出现问题,导致网络出现阻塞、当前程序假死等故障。
    • 服务器中因其他程序原因、硬件问题、环境因素(如断电)等原因导致系统不可用。
    • 服务器因遭到入侵导致Java程序受到影响,如木马病毒/矿机、劫持脚本等。
  • 第三方的RPC远程调用导致的问题

    • 作为被调用者提供给第三方调用,第三方流量突增,导致当前程序负载过重出现问题。
    • 作为调用者调用第三方,但因第三方出现问题,引发雪崩问题而造成当前程序崩溃。

    万变不离其宗,虽然上述中没有将所有可能会发生问题的位置写到,但总的来说,发生问题排查时,也就是这几个大的方向,先将发生问题的大体定位,然后再逐步推导出具体问题的位置,从而加以解决。

二、Java提供的程序监控及性能调优工具

碰到问题时,首先要做的就是定位问题。而一般定位问题是都会基于数据来进行,比如:程序运行日志、异常堆栈信息、GC日志记录、线程快照文件、堆内存快照文件等。同时,数据的收集又离不开监控工具的辅助,所以当JVM在线上运行过程中出现问题后,自然避免不了使用一些JDK自带以及第三方提供的工具,如:jps、jstat、jstack、jmap、jhat、hprof、jinfo、arthas等,接下来我们逐个认识这些工具。

jps、jstat、jstack、jmap、jhat、jinfo等命令都是安装JDK后自带的工具,它们的功能主要是调用%JAVA_HOME%/lib/tools.jar包里面的Java方法来实现的,所以如果你想自己打造一个属于自己的JVM监控系统,那在Java程序内部调用该jar包的方法即可实现。
JDK官方提供的JDK工具参考文档,当然,如果你不会使用这些工具,也可以通过参数:tool -help来查看它的使用方法,如:jps -help

PS:对于JDK提供的这些工具了解的可以直接跳到第三阶段。

2.1、进程监控工具 - jps

jps工具的主要作用是用来查看机器上运行的Java进程,类似于Linux系统的ps -aux|grep java命令。jps工具也支持查看其他机器的Java进程,命令格式如下:

1
2
jps [ options ] [ hostid ]`
查看指令的用法:`jps -help

其中[options]主要有-q、-m、-l、-v、-V几个选项:

  • jps -q:查看机器所有运行的Java进程,但只显示进程号(lvmid)。
  • jps -m:~,只显示传递给main方法的参数。
  • jps -l:~,只显示运行程序主类的包名,或者运行程序jar包的完整路径。
  • jps -v:~,单独显示JVM启动时,显式指定的参数。
  • jps -V:~,显示主类名或者jar包名。

其中[hostid]是用来连接其他机器查看Java进程的远程ID。

JPS工具实际使用方式:jps [pid]

2.2、配置信息查看工具 - jinfo

jinfo工具主要用于实时查看JVM的运行参数,也可以在运行时动态的调整一些参数。命令格式如下:

1
2
jinfo [ option1 ] [ option2 ]`
查看指令的用法:`jinfo -help / jinfo -h

其中[option1]可选项如下:

  • <no option>:第一个参数不写,默认输出JVM的全部参数和系统属性。
  • -flag <name>:输出与指定名称<name>对应的所有参数,以及参数值。
  • -flag [+|-]<name>:开启或者关闭与指定名称<name>对应的参数。
  • -flag <name>=<value>:设置与指定名称<name>对应参数的值。
  • -flags:输出JVM全部的参数。
  • -sysprops:输出JVM全部的系统属性。

其中[option2]可选项如下:

  • <pid>:对应的JVM进程ID(必需参数),指定一个jinfo要操作的Java进程。

  • executable <core:输出打印堆栈跟踪的核心文件。

  • [server-id@]<remote server IP or hostname>
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155

    :远程操作的地址。

    - `server-id`:远程`debug`服务的进程ID;
    - `remote server IP/hostname`:远程`debug`服务的主机名 或 IP地址;

    > `Jinfo`工具实际使用方式:`jinfo -flags [pid]`。

    > PS:对于每个不同选项的效果就不再演示了,感兴趣的小伙伴可以自行在本地开个Java进程,然后使用上述的选项进行调试观察。

    ### 2.3、信息统计监控工具 - jstat

    `jstat`工全称为“`Java Virtual Machine statistics monitoring tool`”,该工具可以利用JVM内建的指令对Java程序的资源以及性能进行实时的命令行的监控,监控范围包含:堆空间的各数据区、垃圾回收状况以及类的加载与卸载状态。

    > 命令格式:`jstat -<option> [-t] [-h<lines>] <vmid> [<interval> [<count>]]`

    其中每个参数的释义如下:

    - `[option]`:监控参数选项。
    - `-t`:在输出结果中加上`Timestamp`列,显示系统运行的时间。
    - `-h`:可以在周期性数据输出的时候,指定间隔多少行数据后输出一次表头。
    - `vmid`:`Virtual Machine ID`虚拟ID,也就是指定一个要监控的Java进程ID。
    - `interval`:每次执行的间隔时间,默认单位为`ms`。
    - `count`:用于指定输出多少条数据,默认情况下会一直输出。

    执行命令`jstat -option`后,可以看到存在很多选项,如下:

    - `-class`:输出类加载`ClassLoad`相关的信息。
    - `-compiler`:显示与JIT即时编译相关的信息。
    - `-gc`:显示与GC相关的信息。
    - `-gccapacity`:显示每个分代空间的容量以及使用情况。
    - `-gcmetacapacity`:输出元数据空间相关的信息。
    - `-gcnew`:显示新生代空间相关的信息。
    - `-gcnewcapacity`:显示新生代空间的容量大小以及使用情况。
    - `-gcold`:输出年老代空间的信息。
    - `-gcoldcapacity`:输出年老代空间的容量大小以及使用情况。
    - `-gcutil`:显示垃圾回收信息。
    - `-gccause`:和`-gcutil`功能相同,但是会额外输出最后一次或本次GC的诱因。
    - `-printcompilation`:输出JIT即时编译的方法信息。

    所以`jstat`的实际使用方式如下:

    > `jstat -gc -t -h30 9895 1s 300`
    > -gc:监控GC的状态
    > -t:显示系统运行的时间
    > -h30:间隔30行数据,输出一次表头
    > 9895:Java进程ID
    > 1s:时间间隔
    > 300:本次输出的数据行数

    最终执行效果如下:
    ![jstat工具-命令行监控](/images/timestamp.png)
    统计列各字段含义如下:

    | 字段名称 | 字段释义 |
    | --------- | -------------------------------------------------- |
    | Timestamp | 系统运行的时间 |
    | S0C | 第一个`Survivor`区的总容量大小 |
    | S1C | 第二个`Survivor`区的总容量大小 |
    | S0U | 第二个`Survivor`区的已使用大小 |
    | S1U | 第二个`Survivor`区的已使用大小 |
    | EC | `Eden`区的总容量大小 |
    | EU | `Eden`区的已使用大小 |
    | OC | `Old`区的总容量大小 |
    | OU | `Old`区的已使用大小 |
    | MC | `Metaspace`区的总容量大小 |
    | MU | `Metaspace`区的已使用大小 |
    | CCSC | `CompressedClassSpace`空间的总大小 |
    | CCSU | `CompressedClassSpace`空间的已用大小 |
    | YGC | 从程序启动到采样时,期间发生的新生代GC次数 |
    | YGCT | 从程序启动到采样时,期间新生代GC总耗时 |
    | FGC | 从程序启动到采样时,期间发生的整堆GC(FullGC)次数 |
    | FGCT | 从程序启动到采样时,期间整堆GC(FullGC)总耗时 |
    | GCT | 从程序启动到采样时,程序发生GC的总耗时 |

    而除此之外,`[options]`指定其他选项时,也会出现不同的统计列字段,如下:

    | 字段名称 | 字段释义 |
    | -------- | ----------------------------------------------- |
    | S0 | 第一个`Survivor`区的使用率(`S0U/S0C`) |
    | S1 | 第二个`Survivor`区的使用率(`S1U/S1C`) |
    | E | `Eden`区的使用率(`EU/EC`) |
    | O | `Old`区的使用率(`OU/OC`) |
    | M | `Metaspace`区的使用率(`MU/MC`) |
    | CCS | `CompressedClassSpace`区的使用率(`CCSU/CCSC`) |
    | NGCMN | 新生代空间初始容量 |
    | NGCMX | 新生代空间最大容量 |
    | S0CMN | 第一个`Survivor`区的初始容量 |
    | S0CMX | 第一个`Survivor`区的最大容量 |
    | S1CMN | 第二个`Survivor`区的初始容量 |
    | S1CMX | 第二个`Survivor`区的最大容量 |
    | OGCMN | 年老代空间初始容量 |
    | OGCMX | 年老代空间最大容量 |
    | MCMN | 元数据空间初始容量 |
    | MCMX | 元数据空间最大容量 |
    | CCSMN | 类压缩空间初始容量 |
    | CCSMX | 类压缩空间最大容量 |
    | TT | 对象晋升的最小年龄阈值 |
    | MTT | 对象晋升的最大年龄阈值 |
    | DSS | 期望的`Survivor`区总大小 |

    > CCS全称为“`CompressedClassSpace`”,主要是指存储类压缩指针的空间,具体可以看[这个](https://link.juejin.cn?target=https%3A%2F%2Fblog.csdn.net%2Fqq_27093465%2Farticle%2Fdetails%2F106760961)。

    除开堆空间和GC相关的统计列信息之外,`jstat`工具还可以类加载与卸载的状态、监控JIT即时编译,执行`jstat -class [pid]`、`jstat -compiler [pid]`指令即可,效果如下:
    ![jstat工具监控类信息、JIT信息](/images/pic2.png)
    类加载与卸载相关的监控数据统计列字段解读:

    | 字段名称 | 字段释义 |
    | -------- | ------------------------ |
    | Loaded | JVM已经装载的类数量 |
    | Bytes | 已装载的类占用字节数大小 |
    | Unloaded | 已经卸载的类数量 |
    | Bytes | 已卸载的类占用字节数大小 |
    | Time | 卸载和装载类共耗时 |

    JIT即时编译相关的监控数据统计列字段解读:

    | 字段名称 | 字段释义 |
    | ------------ | ------------------------------------ |
    | Compiled | 编译任务执行的总次数 |
    | Failed | 编译任务执行失败的次数 |
    | Invalid | 编译任务执行失效的次数 |
    | Bytes | 已卸载的类占用字节数大小 |
    | Time | 所有编译任务的总耗时 |
    | FailedType | 最后一个编译失败的任务类型 |
    | FailedMethod | 最后一个编译失败的任务所在的类及方法 |

    > 对于`jstat`工具执行不同指令后,每个统计列的含义都已在上述中解释清楚,如若之后在线上环境采用`jstat`工具排查性能瓶颈时,对于不理解的统计列皆可参考如上释义。

    ### 2.4、堆内存统计分析工具 - jmap

    `jmap`是一个多功能的工具,主要是用于查看堆空间的使用情况,通常会配合`jhat`工具一起使用,它可以用于生成Java堆的`Dump`文件。但除此之外,也可以查看`finalize`队列、元数据空间的详细信息,Java堆中对象统计信息,如每个分区的使用率、当前装配的GC收集器等。

    > 命令格式:> `jmap [ option1 ] [ option2 ]`

    其中`[option1]`可选项有:

    - `[no option]`:查看进程的内存映像信息,与`Solaris pmap`类似。
    - `-heap`:显示Java堆空间的详细信息。
    - `-histo[:live]`:显示Java堆中对象的统计信息。
    - `-clstats`:显示类加载相关的信息。
    - `-finalizerinfo`:显示`F-Queue`队列中等待`Finalizer`线程执行`finalizer`方法的对象。
    - `-dump:<dump-options>`:生成堆转储快照。
    - `-F`:当正常情况下`-dump`和`-histo`执行失效时,前面加`-F`可以强制执行。
    - `-help`:显示帮助信息。
    - `-J<flag>`:指定传递给运行`jmap`的JVM参数。

    其中`[option2]`与`jinfo`工具的相差无几,可选项如下:

    - `<pid>`:对应的JVM进程ID(必需参数),指定一个`jinfo`要操作的Java进程。

    - `executable <core`:输出打印堆栈跟踪的核心文件。

    - ```
    [server-id@]<remote server IP or hostname>:远程操作的地址。
    - `server-id`:远程`debug`服务的进程ID; - `remote server IP/hostname`:远程`debug`服务的主机名 或 IP地址;

jmap工具实际使用方式:jmap -clstats [pid]jmap -dump:live,format=b,file=Dump.phrof [pid]等。
堆快照导出命令解析:
live:导出堆中存活对象快照;format:指定输出格式;file:指定输出的文件名及其格式(.dat、.phrof等格式)。

当然,具体的每个选项的效果也不再演示,大家感兴趣可以自行调试后观测。

不过值得一提的是:大部分JDK提供的工具与JVM通信方式都是通过的Attach机制实现的,该机制可以针对目标JVM进程进行一些操作,比如获取内存Dump、线程Dump、类信息统计、动态加载Agent、动态设置JVM参数、打印JVM参数、获取系统属性等。有兴趣可以去深入研究一下,具体源码位置位于:com.sun.tools.attach包,里面存在一系列Attach机制相关的代码。

在最后对于histo选项做个简单调试,histo选项主要作用是打印堆空间中对象的统计信息,包括对象实例数、内存空间占用大小等。因为在histo:live前会进行FullGC,所以带上live只会统计存活对象。因此,不加live的堆大小要大于加live堆的大小(因为带live会强制触发一次FullGC),如下:
jmap -histo:live对象统计信息
上图中,class name是对象的类型,但有些是缩写,对象的缩写类型与真实类型对比如下:

缩写类型 B C D F I J Z [ L+类型
真实类型 byte char double float int long boolean 数组 其他对象

2.5、堆内存快照分析工具 - jhat

jhat工具一般配合jmap工具使用,主要用于分析jmap工具导出的Dump文件,其中也内嵌了一个微型的HTTP/HTML服务器,所以当jhat工具分析完Dump文件后,可以支持在浏览器中查看分析结果。
不过在线上环境中一般不会直接使用jhat工具对Dump文件进行解析,因为jhat解析Dump文件,尤其是大体积的Dump时,是一个非常耗时且占用硬件资源的过程。所以为了防止占用服务器过多的资源,通常都会将Dump文件copy到其他机器或本地中分析。

不过话说回来,到了本地一般也不会使用jhat,因为分析之后生成的结果通过浏览器观察时很难看,一般都会选择MAT(Eclipse Memory Analyzer)、IBM HeapAnalyzerVisualVMJprofile等工具。

1
jhat`命令格式:`jhat [-stack <bool>] [-refs <bool>] [-port <port>] [-baseline <file>] [-debug <int>] [-version] [-h|-help] <file>

jhat的这条指令有点长,其中可以选择填写很多参数,释义如下:

  • -stack:默认为true,是否开启对象分配调用栈跟踪。
  • -refs:默认为true,是否开启对象引用跟踪
  • -port:默认为7000,设置jhat工具浏览器访问的端口号。
  • -baseline:指定基准堆转储Dump文件,在两个Dump文件中有相同对象时,会被标记为旧对象,不同的对象会被标记为新对象,主要用于对比分析两个不同的Dump文件。
  • -debug:默认为0,设置debug级别,0表示不输出调试信息,值越大信息越详细。
  • -version:显示版本信息。
  • -help:查看帮助信息。
  • <file>:要分析的Dump文件。
  • -J<flag>jhat工具实际上也是启动了一个JVM进程来执行的,可以通过-J指令为该JVM传递一些JVM参数,如:-J-Xmx128m这类的。

jhat实际应用方式:jhat HeapDump.dat,效果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码> jmap -dump:live,format=b,file=HeapDump.dat 7452
Dumping heap to HeapDump.dat ...
Heap dump file created

> jhat HeapDump.dat
Reading from HeapDump.dat...
Dump file created Wed Mar 09 14:50:06 CST 2022
Snapshot read, resolving...
Resolving 7818 objects...
Chasing references, expect 1 dots.
Eliminating duplicate references.
Snapshot resolved.
Started HTTP server on port 7000
Server is ready.

上述过程中,首先通过jmap工具导出了Java堆的内存dump文件,紧接着使用jhat工具对导出的dump文件进行分析,分析完成后可以打开浏览器,输入http://localhost:7000查看jhat分析后生成的结果,如下:
jhat-浏览器分析结果界面
其中提供了不少选项,从上至下分别为:

  • ①按照包路径查看不同类的具体对象实例。

  • ②查看堆中的所有Roots节点的集合。

  • ③查看所有类的对象实例数量(包括了JVM自身的类)。

  • ④查看所有类的对象实例数量(除去了JVM自身的类)。

  • ⑤查看Java堆中实例对象的统计直方图(和jmap的对象统计信息差不多)。

  • ⑥查看JVM的finalizer相关信息。

  • ⑦通过

    1
    jhat

    工具提供的

    1
    QQL

    对象查询语言获取指定对象的实例信息。

    • QQL具体的语法可以直接访问http://localhost:7000/oqlhelp查看。

其实本质上而言,jhat提供的浏览器界面也不怎么方便我们去排除问题。因此,实际分析堆Dump文件时,通常都会采用一些更为直观方便的工具,如:MAT、Jconsole、IBM HeapAnalyzer、visualVm等。

2.6、堆栈跟踪工具 - jstack

jstack工具主要用于捕捉JVM当前时刻的线程快照,线程快照是JVM中每条线程正在执行的方法堆栈集合。在线上情况时,生成线程快照文件可以用于定位线程出现长时间停顿的原因,如线程死锁、死循环、请求外部资源无响应等等原因导致的线程停顿。

当线程出现停顿时,可以通过jstack工具生成线程快照,从快照信息中能查看到Java程序内部每条线程的调用堆栈情况,从调用堆栈信息中就可以清晰明了的看出:发生停顿的线程目前在干什么,在等待什么资源等。
同时,当Java程序崩溃时,如果配置好了参数,生成了core文件,咱们也可以通过jstack工具从core文件中提取Java虚拟机栈相关的信息,从而进一步定位程序崩溃的原因。

1
jstack`工具命令格式:`jstack [-F] [option1] [option2]

其中[option1]可选项为:

  • -l:除开显示堆栈信息外,额外输出关于锁相关的附加信息(用于排查死锁问题)。
  • -m:如果线程调用到本地方法栈中的本地方法,也显示C/C++的堆栈信息。

其中[option2]可选项如下:

  • <pid>:对应的JVM进程ID(必需参数),指定一个jinfo要操作的Java进程。

  • executable <core:输出打印堆栈跟踪的核心文件。

  • [server-id@]<remote server IP or hostname>
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79

    :远程操作的地址。

    - `server-id`:远程`debug`服务的进程ID;
    - `remote server IP/hostname`:远程`debug`服务的主机名 或 IP地址;

    > `jstack`工具实际使用方式:`jstack -l [pid]`。

    同时,`jstack`工具的`-F`参数与`jmap`的作用相同,当正常执行失效时,加上`-F`可以强制执行`jstack`指令。

    最后,`jstack`工具导出的`Dump`日志值得留意的状态:

    | 状态 | 释义 |
    | ---------------------------- | -------------------- |
    | Deadlock | 线程出现死锁 |
    | Runnable | 线程正在执行中 |
    | Waiting on condition | 线程等待资源 |
    | Waiting on monitor entry | 线程等待获取监视器锁 |
    | Suspended | 线程暂停 |
    | Object.wait()、TIMED_WAITING | 线程挂起 |
    | Blocked | 线程阻塞 |
    | Parked | 线程停止 |

    ### 2.7、JVM排查工具小结

    上述分析的工具都是JDK自带的工具,每个不同的工具都拥有各自的作用,可以在不同维度对JVM运行时的状况进行监控,也能够帮助我们在线上环境时快速去定位排除问题。但除开JDK官方提供的一些工具之外,也有非常多第三方工具用起来非常顺手,如`arthas、jprofilter、perfino、Yourkit、Perf4j、JProbe、MAT、Jconsole、visualVm`等,这些工具往往都比前面分析提到的那些JDK工具更实用且功能更加强大。

    ## 三、JVM线上故障问题“大合集”与排查实战

    程序上线后,线上遇到突发状况无疑是一件令人头疼的事情,但作为一位合格的开发者,不是仅会敲出一手流利的代码就足够了,线上排错这项技能也额外重要。但线上排错的能力强弱更取决于经验的丰富与否,**丰富的实操经验与理论知识储备+理性的排错思路**才是线上排查中最为重要的。

    接下来会对线上环境中发生最为频繁的故障问题进行全方位剖析及实战,如**JVM内存泄漏、内存溢出、业务线程死锁、应用程序异常宕机、线程阻塞/响应速度变慢、CPU利用率飙升或100%** 等。

    ### 3.1、线上排查的“前夕”

    在排查问题时,诱发问题的原因也有可能来自于上下游系统。因此,当出现问题时,首先得定位出现问题的节点,然后针对该节点进行排错。但无论是哪个节点(Java应用、DB、上下游Java系统等),出现问题的原因无非就几个方向:**代码、CPU、磁盘、内存以及网络问题**,所以遇到线上问题时,合理采用OS与JVM提供的工具(如`df、free、top、jstack、jmap、ps`等),将这些方面依次排查一遍即可。

    > 不过需要额外注意:JVM提供的大部分工具在使用时会影响性能,所以如果你的程序是以单机的模式部署,那最好在排查问题之前做好流量迁移(改DNS、Nginx配置等)。如果你的程序是以集群模式部署,那么可以将其中一台机器隔离出来,用于保留现场,也为了更方便的调试问题。
    > 同时,如果线上的机器已经无法提供正常服务,那么在排查问题之前首先要做到的是“及时止损”,可以采用版本回滚、服务降级、重启应用等手段让当前节点恢复正常服务。

    ### 3.2、JVM内存溢出(OOM)

    先来理解一下内存溢出:

    > 举例:一个木桶只能装`40L`水,但此时往里面倒入`50L`水,多出来的水会从桶顶溢出。换到程序的内存中,这种情况就被称为内存溢出。

    内存溢出(OOM)在线上排查中是一个比较常见的问题,同时在Java内存空间中,也会有多块区域会发生OOM问题,如堆空间、元空间、栈空间等,具体可参考前面的[深入理解JVM运行时数据区](https://link.juejin.cn?target=https%3A%2F%2Fwww.jianshu.com%2Fp%2Fcf89ef2689c9)这一章节。通常情况下,线上环境产生内存溢出的原因大致上有三类:

    - ①为JVM分配的内存太小,不足以支撑程序正常执行时的数据增长。
    - ②编写的Java程序内部存在问题、有Bug,导致GC回收速率跟不上分配速率。
    - ③自己的代码或引入的第三方依赖存在内存溢出问题,导致可用内存不足。

    上述②③问题皆是由于编写的Java程序代码不严谨导致的OOM,由于Java内存中产生了大量垃圾对象,导致新对象没有空闲内存分配,从而产生的溢出。

    > 在排查OOM问题时,核心是:哪里OOM了?为什么OOM了?怎么避免出现的OOM?
    > 同时,在排查过程中,应当要建立在数据的分析之上,也就是指`Dump`数据。
    > 获取堆`Dump`文件方式有两种:
    > ①启动时设置`-XX:HeapDumpPath`,事先指定OOM出现时,自动导出`Dump`文件。
    > ②重启并在程序运行一段时间后,通过工具导出,如前面的`jmap`或第三方工具。

    #### 3.2.1、Java线上OOM排查实操

    模拟案例如下:

    ```java
    java复制代码// JVM启动参数:-Xms64M -Xmx64M -XX:+HeapDumpOnOutOfMemoryError
    // -XX:HeapDumpPath=/usr/local/java/java_code/java_log/Heap_OOM.hprof
    public class OOM {
    // 测试内存溢出的对象类
    public static class OomObject{}

    public static void main(String[] args){
    List<OomObject> OOMlist = new ArrayList<>();
    // 死循环:反复往集合中添加对象实例
    for(;;){
    OOMlist.add(new OomObject());
    }
    }
    }

在Linux上,先以后台运行的方式启动上述的Java程序:

1
2
shell复制代码root@localhost ~]# java -Xms64M -Xmx64M -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/local/java/java_code/java_log/Heap_OOM.hprof OOM &
[1] 78645

等待一段时间后,可以看到在/usr/local/java/java_code/java_log/目录下,已经自动导出了堆Dump文件,接下来我们只需要把这个Dump文件直接往Eclipse MAT(Memory Analyzer Tool)工具里面一丢,然后它就能自动帮你把OOM的原因分析出来,然后根据它分析的结果改善对应的代码即可。

其实上述这个案例中,你运行之后过一会儿就会给你输出一句OOM异常信息:

1
2
3
4
5
6
7
8
shell复制代码Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.Arrays.copyOf(Arrays.java:3181)
at java.util.ArrayList.grow(ArrayList.java:261)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227)
at java.util.ArrayList.add(ArrayList.java:458)
at OOM.main(OOM.java:13)

在最后面都已经明确告诉了你,导致OOM的代码位置,因此这个案例没有太大的参考价值,其实也包括大部分他人的OOM排查过程,相对来说参考价值都并非太大,因为排查OOM问题时只需要自己具备理性的思维,步骤都大概相同的,所以接下来重点阐明排查OOM的思路即可。


线上OOM问题排查思路:

  • ①首先获取Dump文件,最好是上线部署时配置了,这样可以保留第一现场,但如若未配置对应参数,可以调小堆空间,然后重启程序的时候重新配置参数,争取做到“现场”重现。
  • ②如果无法通过配置参数获得程序OOM自然导出的Dump文件,那则可以等待程序在线上运行一段时间,并协调测试人员对各接口进行压测,而后主动式的通过jmap等工具导出堆的Dump文件(这种方式没有程序自动导出的Dump文件效果好)。
  • ③将Dump文件传输到本地,然后通过相关的Dump分析工具分析,如JDK自带的jvisualvm,或第三方的MAT工具等。
  • ④根据分析结果尝试定位问题,先定位问题发生的区域,如:确定是堆外内存还是堆内空间溢出,如果是堆内,是哪个数据区发生了溢出。确定了溢出的区域之后,再分析导致溢出的原因(后面会列出一下常见的OOM原因)。
  • ⑤根据定位到的区域以及原因,做出对应的解决措施,如:优化代码、优化SQL等。

3.2.2、线上内存溢出问题小结

Java程序在线上出现问题需要排查时,内存溢出问题绝对是“常客”,但通常情况下,OOM大多是因为代码问题导致的,在程序中容易引发OOM的情况:

  • ①一次性从外部将体积过于庞大的数据载入内存,如DB读表、读本地报表文件等。

  • ②程序中使用容器(Map/List/Set等)后未及时清理,内存紧张而GC无法回收。

  • ③程序逻辑中存在死循环或大量循环,或单个循环中产生大量重复的对象实例。

  • ④程序中引入的第三方依赖中存在BUG问题,因此导致内存出现故障问题。

  • ⑤程序中存在内存泄露问题,一直在蚕食可用内存,GC无法回收导致内存溢出。

  • ⑥第三方依赖加载大量类库,元空间无法载入所有类元数据,因而诱发OOM。

  • ⑦……..

    上述都是程序内代码引发OOM的几种原因,在线上遇到这类情况时,要做的就是定位问题代码,而后修复代码后重新上线即可。同时,除开代码诱发的OOM情况外,有时因为程序分配的内存过小也会引发OOM,这种情况是最好解决的,重新分配更大的内存空间就能解决问题。

不过Java程序中,堆空间、元空间、栈空间等区域都可能出现OOM问题,其中元空间的溢出大部分原因是由于分配空间不够导致的,当然,也不排除会存在“例外的类库”导致OOM。真正意义上的栈空间OOM在线上几乎很难遇见,所以实际线上环境中,堆空间OOM是最常见的,大部分需要排查OOM问题的时候,几乎都是堆空间发生了溢出。

3.3、JVM内存泄漏

先来理解一下内存泄漏:

举例:一个木桶只能装40L水,但此刻我往里面丢块2KG的金砖,那该水桶在之后的过程中,最多只能装38L的水。此时这种情况换到程序的内存中,就被称为内存泄漏。
PS:不考虑物体密度的情况,举例说明不要死磕!

内存泄漏和内存溢出两个概念之间,总让人有些混淆,但本质上是两个完全不同的问题。不过在发生内存溢出时,有可能是因为内存泄漏诱发的,但内存泄漏绝对不可能因为OOM引发。

线上的Java程序中,出现内存泄漏主要分为两种情况:

  • ①堆内泄漏:由于代码不合理导致内存出现泄漏,如垃圾对象与静态对象保持着引用、未正确的关闭外部连接等。

  • ②堆外泄漏:申请buffer流后未释放内存、直接内存中的数据未手动清理等。

    而一般在线上排查时并不能直接检测出内存泄漏问题,因为是否存在内存溢出问题除非监控了堆空间的对象变化,否则在正常情况下很难发觉。因此,通常情况下线上遇到泄漏问题时,都是伴随着OOM问题出现的,也就是:

排查OOM问题时,发现是由于内存泄漏一直在蚕食可用的空闲内存,最终导致新对象分配时没有空闲内存可用于分配,而造成的内存溢出。

3.3.1、JVM内存泄漏排查小实战

内存溢出的模拟案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码// JVM启动参数:-Xms64M -Xmx64M -XX:+HeapDumpOnOutOfMemoryError 
// -XX:HeapDumpPath=/usr/local/java/java_code/java_log/Heap_MemoryLeak.hprof
// 如果不做限制,想要观测到内存泄漏导致OOM问题需要很长时间。
public class MemoryLeak {
// 长生命周期对象,静态类型的root节点
static List<Object> ROOT = new ArrayList<>();

public static void main(String[] args) {
// 不断创建新的对象,使用后不手动将其从容器中移除
for (int i = 0;i <= 999999999;i++) {
Object obj = new Object();
ROOT.add(obj);
obj = i;
}
}
}

先启动该程序:

1
2
shell复制代码root@localhost ~]# java -Xms64M -Xmx64M -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/local/java/java_code/java_log/Heap_MemoryLeak.hprof OOM &
[1] 78849

等待片刻后,也会出现异常信息如下:

1
2
3
4
5
6
7
8
shell复制代码Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.Arrays.copyOf(Arrays.java:3181)
at java.util.ArrayList.grow(ArrayList.java:261)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227)
at java.util.ArrayList.add(ArrayList.java:458)
at MemoryLeak.main(MemoryLeak.java:14)

乍一看,这跟之前分析的OOM问题没啥区别,但却并非如此。在Java程序中,理论上那些创建出来的Object对象在使用完成后,内存不足时,GC线程会将其回收,不过由于这些创建出来的对象在最后与静态的成员ROOT建立起了引用关系,而静态成员在JVM中又被作为GcRoots节点来对待的。
因此,所有创建出来的Object对象在使用完成后,因为与ROOT成员存在引用关系,所以都是可以通过根可达的对象,最终导致GC机制无法回收这些“无效”对象。

该案例中,从程序的执行结果来看,表象是内存溢出,但实则却是内存泄漏。

当然,上述案例只是简单模拟复现内存泄漏这种情况,实际开发过程中可能会更为复杂很多,如:

一个对象在某次业务逻辑执行过程中,与某个静态成员建立了连接,但该对象使用一次后不会再次使用,但因为没有手动去断开与静态成员的引用,因此导致这个“废弃对象”所占用的内存空间一直不会被GC回收。

所以,大家在开发编码过程中,应当刻意留意:当自己创建出的对象需要与静态对象建立连接,但使用一次之后明确清楚该对象不会再被使用,应当手动清空该对象与静态节点的引用,也就是手动置空或移除。如上述案例中,最后应该要ROOT.remove(obj)才可。

3.3.2、线上内存泄漏问题小结

如果线上遇到因内存泄露而造成的OOM问题时,应当首先确认是堆内存泄漏,还是堆外内存泄漏,毕竟堆空间和元空间都有可能存在内存泄漏的隐患,搞清楚内存溢出的位置后再进行排查,处理问题会事半功倍。

常见的内存泄漏例子:
①外部临时连接对象使用后未合理关闭,如DB连接、Socket连接、文件IO流等。
②程序内新创建的对象与长生命周期对象建立引用使用完成后,未及时清理或断开连接,导致新对象一直存在着引用关系,GC无法回收。如:与静态对象、单例对象关联上了。
③申请堆外的直接内存使用完成后,未手动释放或清理内存,从而导致内存泄漏,如:通过魔法类Unsafe申请本地内存、或使用Buffer缓冲区后未清理等。

不过在理解内存泄漏时有个误区,大家千万不要被误导,先来看这么个说法:

“在Java中,多个非根对象之间相互引用,保持着存活状态,从而造成引用循环,导致GC机制无法回收该对象所占用的内存区域,从而造成了内存泄漏。”

上述这句话乍一听好像没太大问题,但实则该说法在Java中并不成立。因为Java中GC判断算法采用的是可达性分析算法,对于根不可达的对象都会判定为垃圾对象,会被统一回收。因此,就算在堆中有引用循环的情况出现,也不会引发内存泄漏问题。

3.4、业务线程死锁

死锁是指两个或两个以上的线程(或进程)在运行过程中,因为资源竞争而造成相互等待的现象,若无外力作用则不会解除等待状态,它们之间的执行都将无法继续下去。举个栗子:

某一天竹子和熊猫在森林里捡到一把玩具弓箭,竹子和熊猫都想玩,原本说好一人玩一次的来,但是后面竹子耍赖,想再玩一次,所以就把弓一直拿在自己手上,而本应该轮到熊猫玩的,所以熊猫跑去捡起了竹子前面刚刚射出去的箭,然后跑回来之后便发生了如下状况:
熊猫道:竹子,快把你手里的弓给我,该轮到我玩了…..
竹子说:不,你先把你手里的箭给我,我再玩一次就给你…..
最终导致熊猫等着竹子的弓,竹子等着熊猫的箭,双方都不肯退步,结果陷入僵局场面…….

这个情况在程序中发生时就被称为死锁状况,如果出现后则必须外力介入,然后破坏掉死锁状态后推进程序继续执行。如上述的案例中,此时就必须第三者介入,把“违反约定”的竹子手中的弓拿过去给熊猫,从而打破“僵局”。

3.4.1、线上死锁排查小实战

上个简单例子感受一下死锁情景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
java复制代码public class DeadLock implements Runnable {
public boolean flag = true;

// 静态成员属于class,是所有实例对象可共享的
private static Object o1 = new Object(), o2 = new Object();

public DeadLock(boolean flag){
this.flag = flag;
}

@Override
public void run() {
if (flag) {
synchronized (o1) {
System.out.println("线程:" + Thread.currentThread()
.getName() + "持有o1....");
try {
Thread.sleep(500);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("线程:" + Thread.currentThread()
.getName() + "等待o2....");
synchronized (o2) {
System.out.println("true");
}
}
}
if (!flag) {
synchronized (o2) {
System.out.println("线程:" + Thread.currentThread()
.getName() + "持有o2....");
try {
Thread.sleep(500);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("线程:" + Thread.currentThread()
.getName() + "等待o1....");
synchronized (o1) {
System.out.println("false");
}
}
}
}

public static void main(String[] args) {
Thread t1 = new Thread(new DeadLock(true),"T1");
Thread t2 = new Thread(new DeadLock(false),"T2");
// 因为线程调度是按时间片切换决定的,
// 所以先执行哪个线程是不确定的,也就代表着:
// 后面的t1.run()可能在t2.run()之前运行
t1.start();
t2.start();
}
}

如上是一个简单的死锁案例,在该代码中:

  • flag==true时,先获取对象o1的锁,获取成功之后休眠500ms,而发生这个动作的必然是t1,因为在main方法中,我们将t1任务的flag显式的置为了true
  • 而当t1线程睡眠时,t2线程启动,此时t2任务的flag=false,所以会去获取对象o2的锁资源,然后获取成功之后休眠500ms
  • 此时t1线程睡眠时间结束,t1线程被唤醒后会继续往下执行,然后需要获取o2对象的锁资源,但此时o2已经被t2持有,此时t1会阻塞等待。
  • 而此刻t2线程也从睡眠中被唤醒会继续往下执行,然后需要获取o1对象的锁资源,但此时o1已经被t1持有,此时t2会阻塞等待。
  • 最终导致线程t1、t2相互等待对象的资源,都需要获取对方持有的资源之后才可继续往下执行,最终导致死锁产生。

执行结果如下:

1
2
3
4
5
6
Linux复制代码D:\> javac -encoding utf-8 DeadLock.java  
D:\> java DeadLock
线程:T1持有o1....
线程:T2持有o2....
线程:T2等待o1....
线程:T1等待o2....

在上述案例中,实际上T1永远获取不到o1,而T2永远也获取不到o2,所以此时发生了死锁情况。那假设如果在线上我们并不清楚死锁是发生在那处代码呢?其实可以通过多种方式定位问题:

  • ①通过jps+jstack工具排查。

  • ②通过jconsole工具排查。

  • ③通过jvisualvm工具排查。

    当然你也可以通过其他一些第三方工具排查问题,但前面方式都是JDK自带的工具,不过一般Java程序都是部署在Linux系统上,所以对于后面两种可视化工具则不太方便使用。因此,线上环境中,更多采用的是第一种jps+jstack方式排查。


接下来我们用jps+jstack的方式排查死锁,此时保持原先的cmd/shell窗口不关闭,再新开一个窗口,输入jps指令:

1
2
3
linux复制代码D:\> jps
19552 Jps
2892 DeadLock

jps作用是显示当前系统的Java进程情况及其进程ID,可以从上述结果中看出:ID2892的进程是刚刚前面产生死锁的Java程序,此时我们可以拿着这个ID再通过jstack工具查看该进程的dump日志,如下:

1
2
linux
复制代码D:\> jstack -l 2892

显示结果如下:
jstack工具查看死锁

可以从dump日志中明显看出,jstack工具从该进程中检测到了一个死锁问题,是由线程名为T1、T2的线程引起的,而死锁问题的诱发原因可能是DeadLock.java:41、DeadLock.java:25行代码引起的。而到这一步之后其实就已经确定了死锁发生的位置,我们就可以跟进代码继续去排查程序中的问题,优化代码之后就可以确保死锁不再发生。

PS:偷了个懒,死锁的排查小实战是基于Windows系统,但Linux系统的操作也是同样的步骤。

3.4.2、死锁问题小结

Java程序中的死锁问题通常都是由于代码不规范导致的,所以在排查死锁问题时,需要做的就是定位到引发死锁问题的具体代码,然后加以改进后重新上线即可。

3.5、应用程序异常宕机

Java应用被部署上线后,程序宕机情况在线上也不是个稀罕事,但程序宕机的原因可能是由于多方面引起的,如:机房环境因素、服务器本身硬件问题、系统内其他上下游节点引发的雪崩、Java应用自身导致(频繁GC、OOM、流量打崩等)、服务器中被植入木马或矿机脚本等情况,都有可能导致程序出现异常宕机问题。
处理这类宕机情况,由于原因的不确定性,这个问题更多的是由开发、运维和网安人员来协同解决的,我们需要做的就是能够保证出现情况时,确保程序可以立即重启且能够及时通知运维人员协助排错。所以这种情况下,你可以采用keepalived来解决该问题。

3.5.1、线上Java应用宕机处理小实战

keepalived是个做热备、高可用不错的程序,大家可以自行安装一下,该程序的主要功能:可定期执行脚本、出现故障时给指定邮箱发送信件、主机宕机可以做漂移等,我们主要使用它的警报以及定期执行脚本功能。

安装keepalived完成后,可以使用vi命令编辑一下keeplived.conf文件,然后将其内部的监控脚本配置的模块改为如下:

1
2
3
4
5
6
shell复制代码vrrp_script chk_nginx_pid {
# 运行该脚本,脚本内容:Java程序宕机以后,自动开启服务
script "/usr/local/src/scripts/check_java_pid.sh"
interval 4 #检测时间间隔(4秒)
weight -20 #如果条件成立的话,则权重 -20
}

check_java_pid.sh文件的脚本代码如下:

1
2
3
4
5
6
7
8
9
10
shell复制代码java_count=`ps -C java --no-header | wc -l`
if [ $java_count -eq 0 ];then
java /usr/local/java_code/HelloWorld
sleep 1

# 这个是用来做漂移的(不用管)
if [ `ps -C java --no-header | wc -l` -eq 0 ];then
/usr/local/src/keepalived/etc/rc.d/init.d/keepalived stop
fi
fi

HelloWorld.java文件代码如下:

1
2
3
4
5
6
java复制代码public class HelloWorld{
public static void main(String[] args){
System.out.println("hello,Java!");
for(;;){}
}
}

上述的环境搭建完成后,可以测试效果,先启动一个Java应用HelloWorld

1
2
3
4
5
6
7
shell复制代码# 启动Java应用
[root@localhost ~]# java /usr/local/java_code/HelloWorld

# 查看Java进程
[root@localhost ~]# ps aux | grep java
root 69992 0.1 0.7 153884 7968 ? SS 16:36 0:21 java
root 73835 0.0 0.0 112728 972 pts/0 S+ 16:37 0:00 grep --color=auto java

然后再开启脚本执行权限并启动keeplived

1
2
3
4
5
6
7
8
9
10
11
12
shell复制代码# 开启脚本执行权限(我的是root用户,这步其实可以省略)
[root@localhost ~]# chmod +x /usr/local/src/scripts/check_java_pid.sh

# 进入到keepalived安装目录并启动keepalived应用
[root@localhost ~]# cd /usr/local/src/keepalived/
[root@localhost keepalived]# keepalived-1.2.22/bin/keepalived etc/keepalived

# 查看keepalived后台进程
[root@localhost keepalived]# ps aux | grep keepalived
root 73908 0.0 0.1 42872 1044 ? Ss 17:01 0:00 keepalived
root 73909 0.0 0.1 42872 1900 ? S 17:01 0:00 keepalived
root 73910 0.0 0.1 42872 1272 ? S 17:01 0:00 keepalived

前面所有程序都跑起来之后,现在手动默认Java应用宕机,也就是使用kill杀掉Java进程,如下:

1
2
3
4
5
6
7
8
9
10
11
shell复制代码# kill -9 69992:强杀Java进程(69992是前面启动Java应用时的进程ID)
[root@localhost ~]# kill -9 69992

# 查询Java后台进程(此时已经没有Java进程了,因为刚刚被kill了)
[root@localhost ~]# ps aux | grep java
root 76621 0.0 0.0 112728 972 pts/0 S+ 17:03 0:00 grep --color=auto java

# 间隔三秒左右再次查询Java后台进程
[root@localhost ~]# ps aux | grep java
root 79921 0.1 0.7 153884 7968 ? SS 17:08 0:21 java
root 80014 0.0 0.0 112728 972 pts/0 S+ 17:08 0:00 grep --color=auto java

此时你可以观测到结果,本来被强杀的Java进程,过了几秒后再次查询,会发现后台的Java应用再次复活了!

3.5.2、线上Java应用宕机小结

keepalived是一个比较好用的工具,你还可以配置它的邮件提醒服务,当出现问题或重启时,都可以发送邮件给指定邮箱。但这种重启是治标不治本的手段,如果要彻底解决宕机的问题,还需要从根源点出发,从根本上解决掉导致程序宕机的原因。

3.6、线程阻塞/响应速度变慢

响应速度变慢和线程出现阻塞,这两者之间的关系的密不可分的,Java服务中的线程由于执行过程中遇到突发状况导致阻塞,那么对于客户端而言,直接反馈过去的就是响应的速度变慢,所以线程阻塞时必然会造成客户端响应缓慢甚至无响应,但反过来,线程阻塞却不是造成响应速度变慢唯一原因。
响应速度变慢和Java应用宕机同样,属于“复合型”的问题,Java应用中线程阻塞、TCP连接爆满、SQL执行时间过长、硬件机器硬盘/CPU/内存资源紧张、上游系统流量过大、第三方中间件或接口出现异常情况、应用并非处理静态资源或同一时刻加载资源过多等情况都可能造成响应速度变慢,所以排查这类问题时,也是个靠经验来处理的问题。不过排查无响应或响应速度过慢问题时,也有规律可言:

  • ①系统整体响应缓慢:如果程序整体响应过慢,那么则是由于压力过大、下游系统存在异常情况、当前Java应用存在问题、当前机器存在问题(网络/硬件/所在环境)、当前程序所在系统存在问题等等情况导致的。也就是说,只有当应用系统中某一个层面出现全面瘫痪或故障,才有可能导致程序整体出现响应缓慢的问题。

  • ②单个接口响应缓慢:如果程序中某个接口或某类接口响应速度过慢,但其他接口响应正常,这点毫无疑问,绝对是因为SQL问题、接口内部实现存在问题等原因导致的,如查询的数量过大、内部调用的第三方接口出现问题、内部代码逻辑不正确导致线程阻塞、线程出现死锁情况等等。

    上述两种其实可以理解为点和面的区别,一个是“全面”性质的,而另外一种则是“单点”性质的。除开可以从范围角度区分外,也可以从发生阶段的角度划分,如可分为:持续性响应缓慢、间接性响应缓慢、偶发性响应缓慢

因为响应缓慢这个问题,诱发的原因有多种,所以在线上遇到这类情况时,理性的分析出问题诱发的原因,再在不同层面根据不同情况加以优化,如:**多线程执行、异步回调通知、引入缓存中间件、MQ削峰填谷、读写分离、静态分离、集群部署、加入搜索引擎…….**,都可被理解成是优化响应速度的方案。

3.7、CPU利用率居高不下或飙升100%

CPU飙升100%和OOM内存溢出是Java面试中老生常谈的话题,CPU100%倒是个比较简单的线上问题,因为毕竟范围已经确定了,CPU100%就只会发生在程序所在的机器上,因此省去了确定问题范围的步骤,所以只需要在单台机器上定位具体的导致CPU飙升的进程,然后再排查问题加以解决即可。

3.7.1、线上CPU100%排查小实战

模拟的Java案例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码public class CpuOverload {
public static void main(String[] args) {
// 启动十条休眠线程(模拟不活跃的线程)
for(int i = 1;i <= 10;i++){
new Thread(()->{
System.out.println(Thread.currentThread().getName());
try {
Thread.sleep(10*60*1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
},"InactivityThread-"+i).start();
}

// 启动一条线程不断循环(模拟导致CPU飙升的线程)
new Thread(()->{
int i = 0;
for (;;) i++;
},"ActiveThread-Hot").start();
}
}

首先新建一个shell-SSH窗口,启动该Java应用模拟CPU飙升的情景:

1
2
shell复制代码[root@localhost ~]# javac CpuOverload.java
[root@localhost ~]# java CpuOverload

紧接着再在另外一个窗口中,通过top指令查看系统后台的进程状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
shell复制代码[root@localhost ~]# top
top - 14:09:20 up 2 days, 16 min, 3 users, load average: 0.45, 0.15, 0.11
Tasks: 98 total, 1 running, 97 sleeping, 0 stopped, 0 zombie
%Cpu(s):100.0 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 997956 total, 286560 free, 126120 used, 585276 buff/cache
KiB Swap: 2097148 total, 2096372 free, 776 used. 626532 avail Mem

PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
77915 root 20 0 2249432 25708 11592 S 99.9 2.6 0:28.32 java
636 root 20 0 298936 6188 4836 S 0.3 0.6 3:39.52 vmtoolsd
1 root 20 0 46032 5956 3492 S 0.0 0.6 0:04.27 systemd
2 root 20 0 0 0 0 S 0.0 0.0 0:00.07 kthreadd
3 root 20 0 0 0 0 S 0.0 0.0 0:04.21 ksoftirqd/0
5 root 0 -20 0 0 0 S 0.0 0.0 0:00.00 kworker/0:0H
7 root rt 0 0 0 0 S 0.0 0.0 0:00.00 migration/0
8 root 20 0 0 0 0 S 0.0 0.0 0:00.00 rcu_bh
9 root 20 0 0 0 0 S 0.0 0.0 0:11.97 rcu_sched
.......

从如上结果中不难发现,PID77915的Java进程对CPU的占用率达到99.9%,此时就可以确定,机器的CPU利用率飙升是由于该Java应用引起的。

此时可以再通过top -Hp [PID]命令查看该Java进程中,CPU占用率最高的线程:

1
2
3
4
5
6
7
8
9
10
11
12
shell复制代码[root@localhost ~]# top -Hp 77915
.....省略系统资源相关的信息......
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
77935 root 20 0 2249432 26496 11560 R 99.9 2.7 3:43.95 java
77915 root 20 0 2249432 26496 11560 S 0.0 2.7 0:00.00 java
77916 root 20 0 2249432 26496 11560 S 0.0 2.7 0:00.08 java
77917 root 20 0 2249432 26496 11560 S 0.0 2.7 0:00.00 java
77918 root 20 0 2249432 26496 11560 S 0.0 2.7 0:00.00 java
77919 root 20 0 2249432 26496 11560 S 0.0 2.7 0:00.00 java
77920 root 20 0 2249432 26496 11560 S 0.0 2.7 0:00.00 java
77921 root 20 0 2249432 26496 11560 S 0.0 2.7 0:00.01 java
.......

top -Hp 77915命令的执行结果中可以看出:其他线程均为休眠状态,并未持有CPU资源,而PID为77935的线程对CPU资源的占用率却高达99.9%

到此时,导致CPU利用率飙升的“罪魁祸首”已经浮现水面,此时先将该线程的PID转换为16进制的值,方便后续好进一步排查日志信息:

1
2
shell复制代码[root@localhost ~]# printf %x 77935
1306f

到目前为止,咱们已经初步获得了“罪魁祸首”的编号,而后可以再通过前面分析过的jstack工具查看线程的堆栈信息,并通过刚刚拿到的16进制线程ID在其中搜索:

1
2
3
shell复制代码[root@localhost ~]# jstack 77915 | grep 1306f
"ActiveThread-Hot" #18 prio=5 os_prio=0 tid=0x00007f7444107800
nid=0x1306f runnable [0x00007f7432ade000]

此时,从线程的执行栈信息中,可以明确看出:ID为1306f的线程,线程名为ActiveThread-Hot。同时,你也可以把线程栈信息导出,然后在日志中查看详细信息,如下:

1
2
3
4
5
6
7
8
9
shell复制代码[root@localhost ~]# jstack 77915 > java_log/thread_stack.log
[root@localhost ~]# vi java_log/thread_stack.log
-------------然后再按/,输入线程ID:1306f-------------
"ActiveThread-Hot" #18 prio=5 os_prio=0 tid=0x00007f7444107800
nid=0x1306f runnable [0x00007f7432ade000]
java.lang.Thread.State: RUNNABLE
at CpuOverload.lambda$main$1(CpuOverload.java:18)
at CpuOverload$$Lambda$2/531885035.run(Unknown Source)
at java.lang.Thread.run(Thread.java:745)

在线程栈的log日志中,对于线程名称、线程状态、以及该线程的哪行代码消耗的CPU资源最多,都在其中详细列出,接下来要做的就是根据定位到的代码,去Java应用中修正代码重新部署即可。

当然,如果执行jstack 77915 | grep 1306f命令后,出现的是““VM Thread” os_prio=0 tid=0x00007f871806e000 nid=0xa runnable”这类以“VM Thread”开头的信息,那么则代表这是JVM执行过程中,虚拟机自身的线程造成的,这种情况有需要进一步排查JVM自身的线程了,如GC线程、编译线程等。

3.7.2、CPU100%排查小结

CPU100%问题排查步骤几乎是死的模板:

  • top指令查看系统后台进程的资源占用情况,确定是否是Java应用造成的。

  • ②使用

    1
    top -Hp [pid]

    进一步排查Java程序中,CPU占用率最高的线程。

    • 保存CPU占用率最高的线程PID,并将其转换为16进制的值。
  • ③通过jstack工具导出Java应用的堆栈线程快照信息。

  • ④通过前面转换的16进制线程ID,在线程栈信息中搜索,定位导致CPU飙升的具体代码。

  • ⑤确认引发CPU飙升的线程是虚拟机自带的VM线程,还是业务线程。

  • ⑥如果是业务线程就是代码问题,根据栈信息修改为正确的代码后,将程序重新部署上线。

  • ⑦如果是VM线程,那可能是由于频繁GC、频繁编译等JVM的操作导致的,此时需要进一步排查。

CPU飙升这类问题,一般而言只会有几种原因:
①业务代码中存在问题,如死循环或大量递归等。
②Java应用中创建的线程过多,造成频繁的上下文切换,因而消耗CPU资源。
③虚拟机的线程频繁执行,如频繁GC、频繁编译等。

3.8、其他线上问题浅谈

前面的内容中详细的阐述了线上的多种故障问题及其解决方案,但实则线上也同样还会出现各种各样的“毛病”,如磁盘使用率100%、DNS劫持、数据库被勒索、木马病毒入侵、矿机脚本植入、网络故障等等。同时,处理这些问题的手段都需要从经验中去积累,这也是开发者在工作中应当学习的 “宝贵财富”

四、线上排查总结

线上排查这项技能更多的是根据经验而谈的,经验越丰富的开发者遇到这类问题时,处理起来会更为得心应手,当线上排查的经验丰富后,就算遇到一些没碰到过的问题,也能排查一二,而不会茫然的束手无策。

总归而言,线上排查各类问题,没有所谓的千篇一律的方法可教,丰富的经验+强大的工具+理性的思维才是处理这类问题的唯一办法,但排查的思路却是不会变化的,步骤也大致相同,也既是开篇所提及到的:

分析问题、排查问题、定位问题、解决问题、尝试最优解

在分布式系统中,如果某个服务节点发生故障或者网络发生异常,都有可能导致调用方被阻塞等待,如果超时时间设置很长,调用方资源很可能被耗尽。这又导致了调用方的上游系统发生资源耗尽的情况,最终导致系统雪崩。

如下图:

微信图片_20221212182819.png如果D服务发生了故障不能响应,B服务调用D时只能阻塞等待。假如B服务调用D服务设置超时时间是10秒,请求速率是每秒100个,那10秒内就会有1000个请求线程被阻塞等待,如果B的线程池大小设置1000,那B系统因为线程资源耗尽已经不能对外提供服务了。而这又影响了入口系统A的服务,最终导致系统全面崩溃。

提高系统的整体容错能力是防止系统雪崩的有效手段。

Martin FowlerJames Lewis的文章 《Microservices: a definition of this new architectural term》[1]中,提出了微服务的9个特征,其中一个是容错设计。

要防止系统发生雪崩,就必须要有容错设计。如果遇到突增流量,一般的做法是对非核心业务功能采用熔断和服务降级的措施来保护核心业务功能正常服务,而对于核心功能服务,则需要采用限流的措施。

今天我们来聊一聊系统容错中的限流、熔断和服务降级。

1 限流

当系统的处理能力不能应对外部请求的突增流量时,为了不让系统奔溃,必须采取限流的措施。

1.1 限流指标

1.1.1 TPS

系统吞吐量是衡量系统性能的关键指标,按照事务的完成数量来限流是最合理的。

但是对实操性来说,按照事务来限流并不现实。在分布式系统中完成一笔事务需要多个系统的配合。比如我们在电商系统购物,需要订单、库存、账户、支付等多个服务配合完成,有的服务需要异步返回,这样完成一笔事务花费的时间可能会很长。如果按照TPS来进行限流,时间粒度可能会很大大,很难准确评估系统的响应性能。

1.1.2 HPS

每秒请求数,指每秒钟服务端收到客户端的请求数量。

❝如果一个请求完成一笔事务,那TPSHPS是等同的。但在分布式场景下,完成一笔事务可能需要多次请求,所以TPSHPS指标不能等同看待。❞

1.1.3 QPS

服务端每秒能够响应的客户端查询请求数量。

❝如果后台只有一台服务器,那HPSQPS是等同的。但是在分布式场景下,每个请求需要多个服务器配合完成响应。❞

❝目前主流的限流方法多采用HPS作为限流指标。❞

1.2 限流方法

1.2.1 流量计数器

这是最简单直接的方法,比如限制每秒请求数量100,超过100的请求就拒绝掉。

但是这个方法存在2个明显的问题:

  • 单位时间(比如1s)很难把控,如下图:

微信图片_20221212182912.png

  • 这张图上,从下面时间看,HPS没有超过100,但是从上面看HPS超过100了。
  • 有一段时间流量超了,也不一定真的需要限流,如下图,系统HPS限制50,虽然前3s流量超了,但是如果读超时时间设置为5s,并不需要限流。

微信图片_20221212182933.png

1.2.2 滑动时间窗口

滑动时间窗口算法是目前比较流行的限流算法,主要思想是把时间看做是一个向前滚动的窗口,如下图:

微信图片_20221212182955.png

开始的时候,我们把t1~t5看做一个时间窗口,每个窗口1s,如果我们定的限流目标是每秒50个请求,那t1~t5这个窗口的请求总和不能超过250个。

这个窗口是滑动的,下一秒的窗口成了t2~t6,这时把t1时间片的统计抛弃,加入t6时间片进行统计。这段时间内的请求数量也不能超过250个。

滑动时间窗口的优点是解决了流量计数器算法的缺陷,但是也有2个问题:

  • 流量超过就必须抛弃或者走降级逻辑
  • 对流量控制不够精细,不能限制集中在短时间内的流量,也不能削峰填谷

1.2.3 漏桶算法

漏桶算法的思想如下图:

微信图片_20221212183016.png

在客户端的请求发送到服务器之前,先用漏桶缓存起来,这个漏桶可以是一个长度固定的队列,这个队列中的请求均匀的发送到服务端。

如果客户端的请求速率太快,漏桶的队列满了,就会被拒绝掉,或者走降级处理逻辑。这样服务端就不会受到突发流量的冲击。

漏桶算法的优点是实现简单,可以使用消息队列来削峰填谷。

但是也有3个问题需要考虑:

  • 漏桶的大小,如果太大,可能给服务端带来较大处理压力,太小可能会有大量请求被丢弃。
  • 漏桶给服务端的请求发送速率。
  • 使用缓存请求的方式,会使请求响应时间变长。

漏桶大小和发送速率这2个值在项目上线初期都会根据测试结果选择一个值,但是随着架构的改进和集群的伸缩,这2个值也会随之发生改变。

1.2.4 令牌桶算法

令牌桶算法就跟病人去医院看病一样,找医生之前需要先挂号,而医院每天放的号是有限的。当天的号用完了,第二天又会放一批号。

算法的基本思想就是周期性的执行下面的流程:

微信图片_20221212183039.png

客户端在发送请求时,都需要先从令牌桶中获取令牌,如果取到了,就可以把请求发送给服务端,取不到令牌,就只能被拒绝或者走服务降级的逻辑。如下图:

微信图片_20221212183103.png

令牌桶算法解决了漏桶算法的问题,而且实现并不复杂,使用信号量就可以实现。在实际限流场景中使用最多,比如googleguava中就实现了令牌桶算法限流,感兴趣可以研究一下。

1.2.5 分布式限流

如果在分布式系统场景下,上面介绍的4种限流算法是否还适用呢?

以令牌桶算法为例,假如在电商系统中客户下了一笔订单,如下图:

微信图片_20221212183126.png

如果我们把令牌桶单独保存在一个地方(比如redis中)供整个分布式系统用,那客户端在调用组合服务,组合服务调用订单、库存和账户服务都需要跟令牌桶交互,交互次数明显增加了很多。

有一种改进就是客户端调用组合服务之前首先获取四个令牌,调用组合服务时减去一个令牌并且传递给组合服务三个令牌,组合服务调用下面三个服务时依次消耗一个令牌。

1.2.6 hystrix限流

hystrix可以使用信号量和线程池来进行限流。

1.2.6.1 信号量限流

hystrix可以使用信号量进行限流,比如在提供服务的方法上加下面的注解。这样只能有20个并发线程来访问这个方法,超过的就被转到了errMethod这个降级方法。

1
2
3
4
5
6
7
@HystrixCommand(
commandProperties= {
@HystrixProperty(name="execution.isolation.strategy", value="SEMAPHORE"),
@HystrixProperty(name="execution.isolation.semaphore.maxConcurrentRequests", value="20")
},
fallbackMethod = "errMethod"
)

1.2.6.2 线程池限流

hystrix也可以使用线程池进行限流,在提供服务的方法上加下面的注解,当线程数量

1
2
3
4
5
6
7
8
9
10
11
12
13
@HystrixCommand(
commandProperties = {
@HystrixProperty(name = "execution.isolation.strategy", value = "THREAD")
},
threadPoolKey = "createOrderThreadPool",
threadPoolProperties = {
@HystrixProperty(name = "coreSize", value = "20"),
@HystrixProperty(name = "maxQueueSize", value = "100"),
@HystrixProperty(name = "maximumSize", value = "30"),
@HystrixProperty(name = "queueSizeRejectionThreshold", value = "120")
},
fallbackMethod = "errMethod"
)

这里要注意:在java的线程池中,如果线程数量超过coreSize,创建线程请求会优先进入队列,如果队列满了,就会继续创建线程直到线程数量达到maximumSize,之后走拒绝策略。但在hystrix配置的线程池中多了一个参数queueSizeRejectionThreshold,如果queueSizeRejectionThreshold < maxQueueSize,队列数量达到queueSizeRejectionThreshold就会走拒绝策略了,因此maximumSize失效了。如果queueSizeRejectionThreshold > maxQueueSize,队列数量达到maxQueueSize时,maximumSize是有效的,系统会继续创建线程直到数量达到maximumSize。Hytrix线程池设置坑[2]

2 熔断

相信大家对断路器并不陌生,它就相当于一个开关,打开后可以阻止流量通过。比如保险丝,当电流过大时,就会熔断,从而避免元器件损坏。

服务熔断是指调用方访问服务时通过断路器做代理进行访问,断路器会持续观察服务返回的成功、失败的状态,当失败超过设置的阈值时断路器打开,请求就不能真正地访问到服务了。

为了更好地理解,我画了下面的时序图:

微信图片_20221212183221.png

可以参考Martin Fowler的论文《CircuitBreaker》[3]。

2.1 断路器的状态

断路器有3种状态:

  • CLOSED:默认状态。断路器观察到请求失败比例没有达到阈值,断路器认为被代理服务状态良好。
  • OPEN:断路器观察到请求失败比例已经达到阈值,断路器认为被代理服务故障,打开开关,请求不再到达被代理的服务,而是快速失败。
  • HALF OPEN:断路器打开后,为了能自动恢复对被代理服务的访问,会切换到半开放状态,去尝试请求被代理服务以查看服务是否已经故障恢复。如果成功,会转成CLOSED状态,否则转到OPEN状态。

断路器的状态切换图如下:

微信图片_20221212183257.png

2.2 需要考虑的问题

使用断路器需要考虑一些问题:

  • 针对不同的异常,定义不同的熔断后处理逻辑。
  • 设置熔断的时长,超过这个时长后切换到HALF OPEN进行重试。
  • 记录请求失败日志,供监控使用。
  • 主动重试,比如对于connection timeout造成的熔断,可以用异步线程进行网络检测,比如telenet,检测到网络畅通时切换到HALF OPEN进行重试。
  • 补偿接口,断路器可以提供补偿接口让运维人员手工关闭。
  • 重试时,可以使用之前失败的请求进行重试,但一定要注意业务上是否允许这样做。

2.3 使用场景

  • 服务故障或者升级时,让客户端快速失败
  • 失败处理逻辑容易定义
  • 响应耗时较长,客户端设置的read timeout会比较长,防止客户端大量重试请求导致的连接、线程资源不能释放

3 服务降级

前面讲了限流和熔断,相比来说,服务降级是站在系统全局的视角来考虑的。

在服务发生熔断后,一般会让请求走事先配置的处理方法,这个处理方法就是一个降级逻辑。

服务降级是对非核心、非关键的服务进行降级。

3.1 使用场景

  • 服务处理异常,把异常信息直接反馈给客户端,不再走其他逻辑
  • 服务处理异常,把请求缓存下来,给客户端返回一个中间态,事后再重试缓存的请求
  • 监控系统检测到突增流量,为了避免非核心业务功能耗费系统资源,关闭这些非核心功能
  • 数据库请求压力大,可以考虑返回缓存中的数据
  • 对于耗时的写操作,可以改为异步写
  • 暂时关闭跑批任务,以节省系统资源

3.2 使用hystrix降级

3.2.1 异常降级

hystrix降级时可以忽略某个异常,在方法上加上@HystrixCommand注解:

下面的代码定义降级方法是errMethod,对ParamErrorExceptionBusinessTypeException这两个异常不做降级处理。

1
2
3
4
@HystrixCommand(
fallbackMethod = "errMethod",
ignoreExceptions = {ParamErrorException.class, BusinessTypeException.class}
)

3.2.2 调用超时降级

专门针对调用第三方接口超时降级。

下面的方法是调用第三方接口3秒未收到响应就降级到errMethod方法。

1
2
3
4
5
6
7
@HystrixCommand(
commandProperties = {
@HystrixProperty(name="execution.timeout.enabled", value="true"),
@HystrixProperty(name="execution.isolation.thread.timeoutInMilliseconds", value="3000"),
},
fallbackMethod = "errMethod"
)

总结

限流、熔断和服务降级是系统容错的重要设计模式,从一定意义上讲限流和熔断也是一种服务降级的手段。

熔断和服务降级主要是针对非核心业务功能,而核心业务如果流程超过预估的峰值,就需要进行限流。

对于限流,选择合理的限流算法很重要,令牌桶算法优势很明显,也是使用最多的限流算法。

在系统设计的时候,这些模式需要配合业务量的预估、性能测试的数据进行相应阈值的配置,而这些阈值最好保存在配置中心,方便实时修改。

摘要:一致性哈希是什么,使用场景,解决了什么问题?

如何分配请求?

大多数网站背后肯定不是只有一台服务器提供服务,因为单机的并发量和数据量都是有限的,所以都会用多台服务器构成集群来对外提供服务。

但是问题来了,现在有那么多个节点(后面统称服务器为节点,因为少一个字),要如何分配客户端的请求呢?

cke_129.png

其实这个问题就是「负载均衡问题」。解决负载均衡问题的算法很多,不同的负载均衡算法,对应的就是不同的分配策略,适应的业务场景也不同。

最简单的方式,引入一个中间的负载均衡层,让它将外界的请求「轮流」的转发给内部的集群。比如集群有 3 个节点,外界请求有 3 个,那么每个节点都会处理 1 个请求,达到了分配请求的目的。

cke_130.png

考虑到每个节点的硬件配置有所区别,我们可以引入权重值,将硬件配置更好的节点的权重值设高,然后根据各个节点的权重值,按照一定比重分配在不同的节点上,让硬件配置更好的节点承担更多的请求,这种算法叫做加权轮询。

加权轮询算法使用场景是建立在每个节点存储的数据都是相同的前提。所以,每次读数据的请求,访问任意一个节点都能得到结果。

但是,加权轮询算法是无法应对「分布式系统」的,因为分布式系统中,每个节点存储的数据是不同的。

当我们想提高系统的容量,就会将数据水平切分到不同的节点来存储,也就是将数据分布到了不同的节点。比如一个分布式 KV(key-valu) 缓存系统,某个 key 应该到哪个或者哪些节点上获得,应该是确定的,不是说任意访问一个节点都可以得到缓存结果的。

因此,我们要想一个能应对分布式系统的负载均衡算法。

使用哈希算法有什么问题?

有的同学可能很快就想到了:哈希算法。因为对同一个关键字进行哈希计算,每次计算都是相同的值,这样就可以将某个 key 确定到一个节点了,可以满足分布式系统的负载均衡需求。

哈希算法最简单的做法就是进行取模运算,比如分布式系统中有 3 个节点,基于 hash(key) % 3 公式对数据进行了映射。

如果客户端要获取指定 key 的数据,通过下面的公式可以定位节点:

1
hash(key) % 3复制

如果经过上面这个公式计算后得到的值是 0,就说明该 key 需要去第一个节点获取。

但是有一个很致命的问题,如果节点数量发生了变化,也就是在对系统做扩容或者缩容时,必须迁移改变了映射关系的数据,否则会出现查询不到数据的问题。

举个例子,假设我们有一个由 A、B、C 三个节点组成分布式 KV 缓存系统,基于计算公式 hash(key) % 3 将数据进行了映射,每个节点存储了不同的数据:

cke_131.png

现在有 3 个查询 key 的请求,分别查询 key-01,key-02,key-03 的数据,这三个 key 分别经过 hash() 函数计算后的值为 hash( key-01) = 6、hash( key-02) = 7、hash(key-03) = 8,然后再对这些值进行取模运算。

通过这样的哈希算法,每个 key 都可以定位到对应的节点。

cke_132.png

当 3 个节点不能满足业务需求了,这时我们增加了一个节点,节点的数量从 3 变化为 4,意味取模哈希函数中基数的变化,这样会导致大部分映射关系改变,如下图:

cke_133.png

比如,之前的 hash(key-01) % 3 = 0,就变成了 hash(key-01) % 4 = 2,查询 key-01 数据时,寻址到了节点 C,而 key-01 的数据是存储在节点 A 上的,不是在节点 C,所以会查询不到数据。

同样的道理,如果我们对分布式系统进行缩容,比如移除一个节点,也会因为取模哈希函数中基数的变化,可能出现查询不到数据的问题。

要解决这个问题的办法,就需要我们进行迁移数据,比如节点的数量从 3 变化为 4 时,要基于新的计算公式 hash(key) % 4 ,重新对数据和节点做映射。

假设总数据条数为 M,哈希算法在面对节点数量变化时,**最坏情况下所有数据都需要迁移,所以它的数据迁移规模是 O(M)**,这样数据的迁移成本太高了。

所以,我们应该要重新想一个新的算法,来避免分布式系统在扩容或者缩容时,发生过多的数据迁移。

使用一致性哈希算法有什么问题?

一致性哈希算法就很好地解决了分布式系统在扩容或者缩容时,发生过多的数据迁移的问题。

一致哈希算法也用了取模运算,但与哈希算法不同的是,哈希算法是对节点的数量进行取模运算,而一致哈希算法是对 2^32 进行取模运算,是一个固定的值

我们可以把一致哈希算法是对 2^32 进行取模运算的结果值组织成一个圆环,就像钟表一样,钟表的圆可以理解成由 60 个点组成的圆,而此处我们把这个圆想象成由 2^32 个点组成的圆,这个圆环被称为哈希环,如下图:

cke_134.png

一致性哈希要进行两步哈希:

  • 第一步:对存储节点进行哈希计算,也就是对存储节点做哈希映射,比如根据节点的 IP 地址进行哈希;
  • 第二步:当对数据进行存储或访问时,对数据进行哈希映射;

所以,一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上

问题来了,对「数据」进行哈希映射得到一个结果要怎么找到存储该数据的节点呢?

答案是,映射的结果值往顺时针的方向的找到第一个节点,就是存储该数据的节点。

举个例子,有 3 个节点经过哈希计算,映射到了如下图的位置:

cke_135.png

接着,对要查询的 key-01 进行哈希计算,确定此 key-01 映射在哈希环的位置,然后从这个位置往顺时针的方向找到第一个节点,就是存储该 key-01 数据的节点。

比如,下图中的 key-01 映射的位置,往顺时针的方向找到第一个节点就是节点 A。

cke_136.png

所以,当需要对指定 key 的值进行读写的时候,要通过下面 2 步进行寻址:

  • 首先,对 key 进行哈希计算,确定此 key 在环上的位置;
  • 然后,从这个位置沿着顺时针方向走,遇到的第一节点就是存储 key 的节点。

知道了一致哈希寻址的方式,我们来看看,如果增加一个节点或者减少一个节点会发生大量的数据迁移吗?

假设节点数量从 3 增加到了 4,新的节点 D 经过哈希计算后映射到了下图中的位置:

cke_137.png

你可以看到,key-01、key-03 都不受影响,只有 key-02 需要被迁移节点 D。

假设节点数量从 3 减少到了 2,比如将节点 A 移除:

cke_138.png

你可以看到,key-02 和 key-03 不会受到影响,只有 key-01 需要被迁移节点 B。

因此,在一致哈希算法中,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响

上面这些图中 3 个节点映射在哈希环还是比较分散的,所以看起来请求都会「均衡」到每个节点。

但是一致性哈希算法并不保证节点能够在哈希环上分布均匀,这样就会带来一个问题,会有大量的请求集中在一个节点上。

比如,下图中 3 个节点的映射位置都在哈希环的右半边:

cke_139.png

这时候有一半以上的数据的寻址都会找节点 A,也就是访问请求主要集中的节点 A 上,这肯定不行的呀,说好的负载均衡呢,这种情况一点都不均衡。

另外,在这种节点分布不均匀的情况下,进行容灾与扩容时,哈希环上的相邻节点容易受到过大影响,容易发生雪崩式的连锁反应。

比如,上图中如果节点 A 被移除了,当节点 A 宕机后,根据一致性哈希算法的规则,其上数据应该全部迁移到相邻的节点 B 上,这样,节点 B 的数据量、访问量都会迅速增加很多倍,一旦新增的压力超过了节点 B 的处理能力上限,就会导致节点 B 崩溃,进而形成雪崩式的连锁反应。

所以,一致性哈希算法虽然减少了数据迁移量,但是存在节点分布不均匀的问题

如何通过虚拟节点提高均衡度?

要想解决节点能在哈希环上分配不均匀的问题,就是要有大量的节点,节点数越多,哈希环上的节点分布的就越均匀。

但问题是,实际中我们没有那么多节点。所以这个时候我们就加入虚拟节点,也就是对一个真实节点做多个副本。

具体做法是,不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点映射到实际节点,所以这里有「两层」映射关系。

比如对每个节点分别设置 3 个虚拟节点:

  • 对节点 A 加上编号来作为虚拟节点:A-01、A-02、A-03
  • 对节点 B 加上编号来作为虚拟节点:B-01、B-02、B-03
  • 对节点 C 加上编号来作为虚拟节点:C-01、C-02、C-03

引入虚拟节点后,原本哈希环上只有 3 个节点的情况,就会变成有 9 个虚拟节点映射到哈希环上,哈希环上的节点数量多了 3 倍。

cke_140.png

你可以看到,节点数量多了后,节点在哈希环上的分布就相对均匀了。这时候,如果有访问请求寻址到「A-01」这个虚拟节点,接着再通过「A-01」虚拟节点找到真实节点 A,这样请求就能访问到真实节点 A 了。

上面为了方便你理解,每个真实节点仅包含 3 个虚拟节点,这样能起到的均衡效果其实很有限。而在实际的工程中,虚拟节点的数量会大很多,比如 Nginx 的一致性哈希算法,每个权重为 1 的真实节点就含有160 个虚拟节点。

另外,虚拟节点除了会提高节点的均衡度,还会提高系统的稳定性。当节点变化时,会有不同的节点共同分担系统的变化,因此稳定性更高

比如,当某个节点被移除时,对应该节点的多个虚拟节点均会移除,而这些虚拟节点按顺时针方向的下一个虚拟节点,可能会对应不同的真实节点,即这些不同的真实节点共同分担了节点变化导致的压力。

而且,有了虚拟节点后,还可以为硬件配置更好的节点增加权重,比如对权重更高的节点增加更多的虚拟机节点即可。

因此,带虚拟节点的一致性哈希方法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景

总结

不同的负载均衡算法适用的业务场景也不同的。

轮训这类的策略只能适用与每个节点的数据都是相同的场景,访问任意节点都能请求到数据。但是不适用分布式系统,因为分布式系统意味着数据水平切分到了不同的节点上,访问数据的时候,一定要寻址存储该数据的节点。

哈希算法虽然能建立数据和节点的映射关系,但是每次在节点数量发生变化的时候,最坏情况下所有数据都需要迁移,这样太麻烦了,所以不适用节点数量变化的场景。

为了减少迁移的数据量,就出现了一致性哈希算法。

一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响。

但是一致性哈希算法不能够均匀的分布节点,会出现大量请求都集中在一个节点的情况,在这种情况下进行容灾与扩容时,容易出现雪崩的连锁反应。

为了解决一致性哈希算法不能够均匀的分布节点的问题,就需要引入虚拟节点,对一个真实节点做多个副本。不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点映射到实际节点,所以这里有「两层」映射关系。

引入虚拟节点后,可以会提高节点的均衡度,还会提高系统的稳定性。所以,带虚拟节点的一致性哈希方法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景。