写点什么

微服务高并发基础知识:Sentinel 性能压测

  • 2023-06-09
    湖南
  • 本文字数:5276 字

    阅读完需:约 17 分钟

Sentinel 性能压测

“引入 Sentinel 带来的性能损耗非常小,只有在业务单机量级超过 25 万 QPS 的时候才会有一些显著的影响(5%~10%左右),单机 QPS 不太大的时候损耗几乎可以忽略不计。”这是 Sentinel 官方文档中的一句话,本节将通过基准测试验证这句话。

JMH 基准测试

基准测试(Benchmark)是测量、评估软件性能指标的一种测试,用来对某个特定目标场景的某项性能指标进行定量的和可对比的测试。


JMH 即 Java Microbenchmark Harness,是 Java 用来做基准测试的一个工具。该工具由 OpenJDK 提供并维护,其测试结果的可信度高。


我们可以将 JMH 用于需要进行基准测试的项目中,以单元测试方式使用。

1. 添加依赖

在需要进行基准测试的项目中引入 JMH 的 jar 包,依赖配置如下:

<dependencies>	<dependency>		<groupId>org.openjdk.jmh</groupId>		<artifactId>imh-core</artifactId>		<version>1.23</version>	</dependency>	<dependency>		<groupId>org.openjdk.jmh</groupId>		<artifactId>jmh-generator-annprocess</artifactId>		<version>1.23</version>	</dependency></dependencies>
复制代码

注意:1.23 版本是 JMH 目前最新的版本。

2. 使用注解方式

JMH 提供了一系列注解,用于简化代码的编写。在运行时,注解配置被用于解析生成 BenchmarkListEntry 配置类实例。被 @Benchmark 注解注释的方法为基准测试方法,而注释在类上的注解或注释在类的字段上的注解,则是类中所有基准测试方法共用的配置。

@Benchmark

@Benchmark 注解用于声明一个 public 方法为基准测试方法,@Benchmark 注解的使用如下述代码所示:

public class MyTestBenchmark {	Benchmark	@Test	public void testFunction() {	}}
复制代码
@BenchmarkMode

使用 JMH 可以轻松地测试出某个接口的吞吐量、平均执行时间等指标的数据。


如果需要测试某个方法的平均耗时,那么可以使用 @BenchmarkMode 注解并指定基准测试的模式为 AverageTime,代码如下:

publicclass MyTestBenchmark{	@BenchmarkMode(Mode.AverageTime)  @Benchmark	@Test	public void testFunction() {	}}
复制代码

常用的基准测试模式如下:

  • AverageTime:测量平均耗时。

  • Throughput:测量吞吐量。

@Measurement

@Measurement 注解用于指定测量次数及每次测量的持续时间。测量次数越多且每次测量的持续时间越长,测试结果的可信度就越高。


@Measurement 注解有如下 3 个配置项:

  • iterations:测量次数

  • time 与 timeUnit:测量一次的持续时间

  • timeUnit:用于指定时间单位


@Measurement 注解的使用如下述代码所示:

public class MyTestBenchmark {	@Measurement(iterations = 5,time = 1,timeUnit = TimeUnit.SECONDS)  @BenchmarkMode(Mode.AverageTime)	@Benchmark	@Test	public void testFunction() {	}}
复制代码

在本例中,我们配置总的测量次数为 5,每次测量的持续时间为 1 秒。1 秒内执行 testFunction 方法的次数是不固定的,由方法执行耗时和 time 决定。


每个线程实际执行基准测试方法的次数等于 time 除以基准测试方法单次执行的耗时。假设基准测试方法单次执行的耗时为 1 秒,并使用 @Measurement 注解指定 iterations 为 100 次、time 为 10 秒,那么一次测量最多只能执行 10(即 10 秒/1 秒)次基准测试方法,而 iterations 为 100 次指的是测量 100 次。


注意:iterations 指的是测量总次数,而不是执行基准测试方法的次数。

@Warmup

采用一定的预热次数可以提升测试结果的准确度,而使用 @Warmup 注解可以声明需要预热的次数及每次预热的持续时间。


@Warmup 注解有如下 3 个配置项:

  • iterations:预热次数。

  • time 与 timeUnit:预热一次的持续时间

  • timeUnit:用于指定时间单位。


@Warmup 注解的使用如下述代码所示:

public class MyTestBenchmark {	@Warmup(iterations = 5,time = 1,timeUnit = TimeUnit.SECONDS)  @Measurement(iterations = 5,time = 1,timeUnit = TimeUnit.SECONDS)  @BenchmarkMode(Mode.AverageTime)	@Benchmark	@Test	public void testFunction() {	}}
复制代码

在本例中,我们配置总的预热次数为 5,每次预热持续时间为 1 秒。

@OutputTimeUnit

@OutputTimeUnit 注解用于指定输出方法执行耗时的单位。


如果方法执行耗时为毫秒级别,则为了便于观察结果,可以使用 @OutputTimeUnit 注解指定输出的耗时时间单位为毫秒,代码如下:

public class MyTestBenchmark {	@OutputTimeUnit(TimeUnit.NANOSECONDS)	@Warmup(iterations = 5,time = 1,timeUnit = TimeUnit.SECONDS)  @Measurement(iterations = 5,time = 1,timeUnit = TimeUnit.SECONDS)  @BenchmarkMode(Mode.AverageTime)	@Benchmark	@Test	public void testFunction() {	}}
复制代码
@Fork

@Fork 注解用于指定 Fork 多少个子进程来执行同一基准测试方法。


如果不需要 Fork 多个子进程,则可以使用 @Fork 注解指定进程数为 1,代码如下:

public class MyTestBenchmark {	@Fork(1)	@OutputTimeUnit(TimeUnit.NANOSECONDS)	@Warmup(iterations = 5,time = 1,timeUnit = TimeUnit.SECONDS)  @Measurement(iterations = 5,time = 1,timeUnit = TimeUnit.SECONDS)  @BenchmarkMode(Mode.AverageTime)	@Benchmark	@Test	public void testFunction() {	}}
复制代码
@Threads

@Threads 注解用于指定使用多少个线程来执行基准测试方法。


如果使用 @Threads 注解指定线程数为 2,则每次测量都会创建两个线程来执行基准测试方法,代码如下:

public class MyTestBenchmark {	@Threads(2)	@Fork(1)	@OutputTimeUnit(TimeUnit.NANOSECONDS)	@Warmup(iterations = 5,time = 1,timeUnit = TimeUnit.SECONDS)  @Measurement(iterations = 5,time = 1,timeUnit = TimeUnit.SECONDS)  @BenchmarkMode(Mode.AverageTime)	@Benchmark	@Test	public void testFunction() {	}}
复制代码

假设 @Measurement 注解指定 time 为 1 秒,则基准测试方法单次执行的耗时为 1 秒。若只使用单个线程测量,则一次测量将只会执行一次基准测试方法,而若使用 10 个线程测量,则一次测量将能执行 10 次基准测试方法。


@Threads 注解可以用来模拟高并发,一般用于测量基准测试方法的吞吐量。

公共注解

如果需要在 MyTestBenchmark 类中创建两个基准测试方法——testFunction1 和 testFunction2,并且这两个方法分别调用不同的支付接口,用于对比两个接口的性能,那么可以将除 @Benchmark 注解外的其他注解都声明到类上,让两个基准测试方法都使用同样的配置,代码如下:

@BenchmarkMode(Mode.AverageTime)@Fork(1)@Threads(2)@OutputTimeUnit(TimeUnit.NANOSECONDS)@Warmup(iterations = 5,time = 1,timeUnit = TimeUnit.SECONDS)@Measurement(iterations = 5,time = 1,timeUnit = TimeUnit.SECONDS)public class MyTestBenchmark {
@Benchmark @Test public void testFunction10) { } @Benchmark @Test public void testFunction20){ }}
复制代码

我们可以使用 JMH 对 JSON 序列化框架进行基准测试。以测量分别使用 Gson、Jackson 反序列化同一 JSON 字符串的平均耗时为例,编写基准测试用例,代码如下:

在本例中,使用 @Threads 注解声明创建两个线程来执行基准测试方法,使用 @State 注解指定 gsonParser、jacksonParser 这两个字段的共享域为 Scope.Thread,即在不同线程中,gsonParser、jacksonParser 这两个字段都是不同的实例。


以 testGson 方法为例,我们可以认为 JMH 会为每个线程克隆出一个 gsonParser 对象。如果在 testGson 方法中打印 gsonParser 对象的 hashCode,就会发现相同线程打印的结果相同,不同线程打印的结果不同,代码如下:

执行 testGson 方法输出的结果如下:

@Param

使用 @Param 注解可以指定基准测试方法的执行参数,并且只能指定 String 类型的值。该参数值可以是一个数组,将在程序运行期间按给定顺序被遍历。


如果使用 @Param 注解指定了多个参数值,则 JMH 会为每个参数值执行一次基准测试。


例如,测试不同复杂度的 JSON 字符串使用 Gson 框架与使用 Jackson 框架解析的性能对比,代码如下:

测试结果输出如下:

3. 使用非注解方式

使用注解与不使用注解在本质上并没有区别,只是使用注解更加方便。在运行时,注解配置被用于解析生成 BenchmarkListEntry 配置类实例,而不使用注解最终也需要构造 BenchmarkListEntry 配置类实例。每一个基准测试方法对应一个 BenchmarkListEntry 配置类实例。


Options 实例用于配置基准测试参数,Runner 实例用于执行基准测试方法。首先使用 OptionsBuilder 方法构造一个 Options 实例,并指定基准测试方法及执行基准测试方法的参数,然后创建 Runner 实例并调用 Runner 实例的 run 方法,即可执行基准测试。


使用非注解方式实现上面的例子,代码如下:

OptionsBuilder 类提供的方法如下:

  • include:导入一个基准测试类,参数为类的简单名称,JMH 默认会把 include 导入的类的每个 public 方法都当作基准测试方法。

  • exclude:排除不需要参与基准测试的方法。

  • forks:对应 @Fork 注解,指定 Fork 多少个子进程来执行同一基准测试方法。

  • threads:对应 @Threads 注解,指定使用多少个线程来执行基准测试方法。

  • timeUnit:对应 @OutputTimeUnit 注解,指定输出方法执行耗时的单位。

  • warmupIterations:对应 @Warmup 注解,指定预热次数。

  • warmupTime:对应 @Warmup 注解,指定每次预热的持续时间。

  • measurementIterations:对应 @Measurement 注解,指定测量次数。

  • measurementTime:对应 @Measurement 注解,指定每次测量的持续时间。

  • mode:对应 @BenchmarkMode 注解,指定测量模式。

4. 打包成 jar 包放到服务器上执行

我们可以使用单元测试方式对 JSON 解析框架进行性能对比。若想要测试 Web 服务的某个接口性能,则需要对接口进行压测,而不能使用简单的单元测试方式进行测试。因此我们可以独立创建一个接口测试项目,将基准测试代码写在该项目中,然后将写好的基准测试项目打包成 jar 包放到 Linux 服务器上执行,这样测试结果会更加准确。


使用 java 命令即可运行一个基准测试应用,代码如下:

Java -jar my-benchmarks.jar
复制代码

5. 在 IDEA 中执行

在 IDEA 中,我们可以编写一个单元测试方法,并在单元测试方法中创建一个 Runner 实例,然后调用 Runner 实例的 run 方法执行基准测试。但 JMH 不会扫描包,不会执行每个基准测试方法,这就需要我们通过配置项来告知 JMH 需要执行哪些基准测试方法,代码如下:

完整代码如下:

由于本例中的 JsonBenchmark 类已经使用了注解,因此 Options 实例只需要配置需要执行基准测试的类即可。


如果需要执行多个基准测试类,则可以多次调用 include 方法;如果需要将测试结果输出到文件中,则可以调用 output 方法配置文件路径,否则输出到控制台中。

6. 在 IDEA 中使用 JMH Plugin 插件执行

安装 JMH Plugin 插件:在 IDEA 中搜索 JMH Plugin,安装后重启即可使用。

1)只执行单个基准测试方法

在方法名称所在行中,IDEA 会有一个▶执行符号,右击该符号并在弹出的快捷菜单中选择“运行”命令即可。

2)执行一个类中的所有基准测试方法

在类名所在行中,IDEA 会有一个▶执行符号,右击该符号并在弹出的快捷菜单中选择“运行”命令即可,该类下的所有被 @Benchmark 注解注释的方法都会被执行。


注意:如果写的是单元测试方法,则 IDEA 会提示选择执行单元测试还是基准测试。

使用 JMH 进行 Sentinel 压测

如果要测试 Sentinel 对应用性能的影响,则需要测试两组数据并进行对比。这两组数据分别是不使用 Sentinel 的情况下方法的吞吐量及使用 Sentinel 的情况下方法的吞吐量。


Sentinel 提供的基准测试类的部分源码如下:

该基准测试类通过 @State 注解来指定每个线程使用不同的 numbers 字段的实例,所以 @Setup 注解的方法也会执行 8 次,并分别在每个线程开始执行基准测试方法之前执行,用于完成初始化工作,与 JUnit 中的 @Before 注解功能相似。


分别对 doSomething 方法和 doSomethingWithEntry 方法进行基准测试,其中 doSomething 方法用于模拟业务方法,doSomethingWithEntry 方法用于模拟使用 Sentinel 保护业务方法。


将准测试模式配置为吞吐量模式,使用 @Warmup 注解指定预热次数为 10 次,使用 @OutputTimeUnit 注解指定输出单位为秒,使用 @Fork 注解指定进程数为 1 个,使用 @Threads 注解指定线程数为 8 个。


使用 doSomething 方法进行吞吐量测试的结果如下:

由结果可知,最小 OPS 为 295869.456,平均 OPS 为 300948.682,最大 OPS 为 316089.624。

使用 doSomethingWithEntry 方法进行吞吐量测试的结果如下:

由结果可知,最小 OPS 为 280835.799,平均 OPS 为 309934.827,最大 OPS 为 337712.803。


其中,OPS 表示每秒执行的操作次数或每秒执行的方法次数。


从本次测试结果可以看出,doSomething 方法的平均吞吐量与 doSomethingWithEntry 方法的平均吞吐量相差约为 3%,也就是说,在超过 28 万 OPS(QPS)的情况下,Sentinel 对应用性能的影响不到 3%。在实际项目场景中,一个服务节点所有接口总的 QPS 也很难达到 25 万这个值,而 QPS 越低,Sentinel 对应用性能的影响也越低。


但这毕竟是在没有配置任何限流规则、只有一个资源且调用链路的深度为 1 的情况下进行的测试,因此这个结果只能算一个理想的参考值,在实际项目场景中还是以项目实际的使用测试结果为准。

小结

通过本章的学习,读者对服务降级、限流、熔断、流量效果控制这些概念有了基本的了解,也了解了 Sentinel 的一些特性,学习了如何使用基础测试工具 JMH,并使用 JMH 对 Sentinel 进行了简单场景下的性能压测。

用户头像

加VX:bjmsb02 凭截图即可获取 2020-06-14 加入

公众号:程序员高级码农

评论

发布
暂无评论
微服务高并发基础知识:Sentinel性能压测_Java_互联网架构师小马_InfoQ写作社区