原创 吴就业 139 0 2020-09-22
本文为博主原创文章,未经博主允许不得转载。
本文链接:https://wujiuye.com/article/bfd0c72b27084a66b7a3fadfa80e290b
作者:吴就业
链接:https://wujiuye.com/article/bfd0c72b27084a66b7a3fadfa80e290b
来源:吴就业的网络日记
本文为博主原创文章,未经博主允许不得转载。
“引入 Sentinel 带来的性能损耗非常小,只有在业务单机量级超过25W QPS的时候才会有一些显著的影响(5% - 10% 左右),单机QPS不太大的时候损耗几乎可以忽略不计。”
这是官方文档写的一段话,那么性能到底如何呢?本篇我们回顾Sentinel的源码,看看Sentinel在性能方面所做出的努力,最后使用JMH做个简单的基准测试,看看Sentinel表现如何,在此之前也会详细介绍JMH的使用。
Sentinel统计指标数据使用的是滑动窗口:时间窗口+Bucket,通过循环复用Bucket以减少Bucket的创建和销毁。在统计指标数据时,利用当前时间戳定位Bucket,使用LongAdder统计时间窗口内的请求成功数、失败数、总耗时等指标数据优化了并发锁。Sentinel通过定时任务递增时间戳以获取当前时间戳,避免了每次获取时间戳都使用System获取的性能消耗。
Sentinel中随处可见的加锁重新创建Map,例如:
ProcessorSlotChain chain = chainMap.get(resourceWrapper);
if (chain == null) {
synchronized (LOCK) {
chain = chainMap.get(resourceWrapper);
if (chain == null) {
chain = SlotChainProvider.newSlotChain();
// 创建新的map
Map<ResourceWrapper, ProcessorSlotChain> newMap = new HashMap<ResourceWrapper, ProcessorSlotChain>(
chainMap.size() + 1);
// 插入当前map存储的数据
newMap.putAll(chainMap);
// 插入新创建的key-value
newMap.put(resourceWrapper, chain);
// 替换旧的
chainMap = newMap;
}
}
}
return chain;
Sentinel使用Map而非ConcurrentMap是为了尽量避免加锁,大多数场景都是读多写少,以上面代码为例,ProcessorSlotChain的新增只在资源第一次被访问时,例如接口第一次被调用,而后续都不会再写。假设有10个接口,这10个接口在应用启动起来就都被访问过了,那么这个Map后续就不会再出现写的情况,既然不会再有写操作,就没有必须加锁了,所以使用Map而不是使用ConcurrentMap。
RateLimiterController匀速限流控制器的实现只支持最大1000QPS,这是因为Sentinel获取的当前时间戳是通过定时任务累加的,每毫秒一次,所以Sentinel在实现匀速限流或冷启动限流使用的时间戳最小单位都是毫秒。以毫秒为最小单位,那么1秒钟最大能通过的请求数当然也就只有1000,这是出于性能方面的考虑。
可能很多人在使用Sentinel的过程中都发现了,RateLimiterController实现的匀速并不那么严格,例如想要限制每5毫秒通过一个请求,但实际上可能每5毫秒通过好几个请求,这与CPU核心线程数有关,因为Sentinel并不严格控制并发下的排队计时,这也是出于性能的考虑。实现项目中,我们也并不对匀速要求那么严格,所以这些缺点是可以接受的。
WarmUpController冷启动限流效果的实现并不控制每个请求的通过时间间隔,只是每秒钟生产一次令牌,并且在生产令牌后扣减与上一秒通过的请求数相等数量的令牌,Sentinel的作者称这个行为叫令牌自动掉落,这些也都是出于性能方面的考虑。
仅从以上这些细节我们也能看到Sentinel在性能方面所做出的努力,Sentinel尽最大可能降低自身对应用的影响,这些是值得称赞的地方。
基准测试Benchmark是测量、评估软件性能指标的一种测试,对某个特定目标场景的某项性能指标进行定量的和可对比的测试。JMH即Java Microbenchmark Harness,是Java用来做基准测试的一个工具,该工具由OpenJDK提供并维护,测试结果可信度高。
我们可以将JMH直接用在需要进行基准测试的项目中,以单元测试方式使用,需要在项目中引入JMH的jar包,依赖配置如下。
<dependencies>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.23</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.23</version>
</dependency>
</dependencies>
在运行时,注解配置被用于解析生成BenchmarkListEntry配置类实例。一个方法对应一个@Benchmark注解,一个@Benchmark注解对应一个基准测试方法。注释在类上的注解,或者注释在类的字段上的注解,则是类中所有基准测试方法共用的配置。
@Benchmark注解用于声明一个public方法为基准测试方法,如下代码所示。
public class MyTestBenchmark {
@Benchmark
@Test
public void testFunction() {
//
}
}
通过JMH我们可以轻松的测试出某个接口的吞吐量、平均执行时间等指标的数据。假设我们想测试某个方法的平均耗时,那么可以使用@BenchmarkMode注解指定测试维度为Mode.AverageTime,代码如下。
public class MyTestBenchmark {
@BenchmarkMode(Mode.AverageTime)
@Benchmark
@Test
public void testFunction() {
//
}
}
假设我们需要测量五次,那么可以使用@Measurement注解,代码如下。
public class MyTestBenchmark {
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@BenchmarkMode(Mode.AverageTime)
@Benchmark
@Test
public void testFunction() {
//
}
}
@Measurement注解有三个配置项: * iterations:测量次数; * time与timeUnit:测量一次的持续时间,timeUnit指定时间单位,本例中:每次测量持续1秒,1秒内执行的testFunction方法的次数是不固定的,由方法执行耗时和time决定。
为了数据准确,我们可能需要让被测试的方法做下热身运动,一定的预热次数可提升测试结果的准备度。可使用@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() {
//
}
}
@Warmup注解有三个配置项: * iterations:预热次数; * time与timeUnit:预热一次的持续时间,timeUnit指定时间单位。
假设@Measurement指定iterations为100,time为10s,则: 每个线程实际执行基准测试方法的次数等于time除以基准测试方法单次执行的耗时,假设基准测试方法执行耗时为1s,那么一次测量最多只执行10(time为10s / 方法执行耗时1s)次基准测试方法,而iterations为100指的是测试100次(不是执行100次基准测试方法)。
@OutputTimeUnit注解用于指定输出的方法执行耗时的单位。
如果方法执行耗时为毫秒级别,为了便于观察结果,我们可以使用@OutputTimeUnit指定输出的耗时时间单位为毫秒,否则使用默认的秒做单位,会输出10的负几次方这样的数字,不太直观。
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指定进程数为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
指定线程数为2
,那么每次测量都会创建两个线程来执行基准测试方法。
public class MyTestBenchmark {
@Threads(2)
@Fork(1)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@BenchmarkMode(Mode.AverageTime)
@Benchmark
@Test
public void testFunction() {
//
}
}
如果@Measurement注解指定time为1s,基准测试方法的执行耗时为1s,那么如果只使用单个线程,一次测量只会执行一次基准测试方法,如果使用10个线程,一次测量就能执行10次基准测试方法。
假设我们需要在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 testFunction1() {
//
}
@Benchmark
@Test
public void testFunction2() {
//
}
}
下面我们以测试Gson、Jackson两个json解析框架的性能对比为例。
@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)
@State(Scope.Benchmark)
public class JsonBenchmark {
private GsonParser gsonParser = new GsonParser();
private JacksonParser jacksonParser = new JacksonParser();
@Benchmark
@Test
public void testGson() {
gsonParser.fromJson("{\"startDate\":\"2020-04-01 16:00:00\",\"endDate\":\"2020-05-20 13:00:00\",\"flag\":true,\"threads\":5,\"shardingIndex\":0}", JsonTestModel.class);
}
@Benchmark
@Test
public void testJackson() {
jacksonParser.fromJson("{\"startDate\":\"2020-04-01 16:00:00\",\"endDate\":\"2020-05-20 13:00:00\",\"flag\":true,\"threads\":5,\"shardingIndex\":0}", JsonTestModel.class);
}
}
我们可以使用@State注解指定gsonParser、jacksonParser这两个字段的共享域。
在本例中,我们使用@Threads注解声明创建两个线程来执行基准测试方法,假设我们配置@State(Scope.Thread),那么在不同线程中,gsonParser、jacksonParser这两个字段都是不同的实例。
以testGson方法为例,我们可以认为JMH会为每个线程克隆出一个gsonParser对象。如果在testGson方法中打印gsonParser对象的hashCode,你会发现,相同线程打印的结果相同,不同线程打印的结果不同。例如:
@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)
@State(Scope.Thread)
public class JsonBenchmark {
private GsonParser gsonParser = new GsonParser();
private JacksonParser jacksonParser = new JacksonParser();
@Benchmark
@Test
public void testGson() {
System.out.println("current Thread:" + Thread.currentThread().getName() + "==>" + gsonParser.hashCode());
gsonParser.fromJson("{\"startDate\":\"2020-04-01 16:00:00\",\"endDate\":\"2020-05-20 13:00:00\",\"flag\":true,\"threads\":5,\"shardingIndex\":0}", JsonTestModel.class);
}
@Benchmark
@Test
public void testJackson() {
jacksonParser.fromJson("{\"startDate\":\"2020-04-01 16:00:00\",\"endDate\":\"2020-05-20 13:00:00\",\"flag\":true,\"threads\":5,\"shardingIndex\":0}", JsonTestModel.class);
}
}
执行testGson方法输出的结果如下:
current Thread:com.msyc.common.JsonBenchmark.testGson-jmh-worker-1==>2063684770
current Thread:com.msyc.common.JsonBenchmark.testGson-jmh-worker-2==>1629232880
current Thread:com.msyc.common.JsonBenchmark.testGson-jmh-worker-1==>2063684770
current Thread:com.msyc.common.JsonBenchmark.testGson-jmh-worker-2==>1629232880
current Thread:com.msyc.common.JsonBenchmark.testGson-jmh-worker-1==>2063684770
current Thread:com.msyc.common.JsonBenchmark.testGson-jmh-worker-2==>1629232880
current Thread:com.msyc.common.JsonBenchmark.testGson-jmh-worker-1==>2063684770
current Thread:com.msyc.common.JsonBenchmark.testGson-jmh-worker-2==>1629232880
......
使用@Param注解可指定基准方法执行参数,@Param注解只能指定String类型的值,可以是一个数组,参数值将在运行期间按给定顺序遍历。假设@Param注解指定了多个参数值,那么JMH会为每个参数值执行一次基准测试。
例如,我们想测试不同复杂度的json字符串使用Gson框架与使用Jackson框架解析的性能对比,代码如下。
@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)
@State(Scope.Thread)
public class JsonBenchmark {
private GsonParser gsonParser = new GsonParser();
private JacksonParser jacksonParser = new JacksonParser();
// 指定参数有三个值
@Param(value =
{"{\"startDate\":\"2020-04-01 16:00:00\",\"endDate\":\"2020-05-20 13:00:00\",\"flag\":true,\"threads\":5,\"shardingIndex\":0}",
"{\"startDate\":\"2020-04-01 16:00:00\",\"endDate\":\"2020-05-20 14:00:00\"}",
"{\"flag\":true,\"threads\":5,\"shardingIndex\":0}"})
private String jsonStr;
@Benchmark
@Test
public void testGson() {
gsonParser.fromJson(jsonStr, JsonTestModel.class);
}
@Benchmark
@Test
public void testJackson() {
jacksonParser.fromJson(jsonStr, JsonTestModel.class);
}
}
测试结果如下:
Benchmark (jsonStr) Mode Cnt Score Error Units
JsonBenchmark.testGson {"startDate":"2020-04-01 16:00:00","endDate":"2020-05-20 13:00:00","flag":true,"threads":5,"shardingIndex":0} avgt 5 12180.763 ± 2481.973 ns/op
JsonBenchmark.testGson {"startDate":"2020-04-01 16:00:00","endDate":"2020-05-20 14:00:00"} avgt 5 8154.709 ± 3393.881 ns/op
JsonBenchmark.testGson {"flag":true,"threads":5,"shardingIndex":0} avgt 5 9994.171 ± 5737.958 ns/op
JsonBenchmark.testJackson {"startDate":"2020-04-01 16:00:00","endDate":"2020-05-20 13:00:00","flag":true,"threads":5,"shardingIndex":0} avgt 5 15663.060 ± 9042.672 ns/op
JsonBenchmark.testJackson {"startDate":"2020-04-01 16:00:00","endDate":"2020-05-20 14:00:00"} avgt 5 13776.828 ± 11006.412 ns/op
JsonBenchmark.testJackson {"flag":true,"threads":5,"shardingIndex":0} avgt 5 9824.283 ± 311.555 ns/op
通过使用OptionsBuilder构造一个Options,并创建一个Runner,调用Runner的run方法就能执行基准测试。
使用非注解方式实现上面的例子,代码如下。
public class BenchmarkTest{
@Test
public void test() throws RunnerException {
Options options = new OptionsBuilder()
.include(JsonBenchmark.class.getSimpleName())
.exclude("testJackson")
.forks(1)
.threads(2)
.timeUnit(TimeUnit.NANOSECONDS)
.warmupIterations(5)
.warmupTime(TimeValue.seconds(1))
.measurementIterations(5)
.measurementTime(TimeValue.seconds(1))
.mode(Mode.AverageTime)
.build();
new Runner(options).run();
}
}
使用注解与不使用注解其实都是一样,只不过使用注解更加方便。在运行时,注解配置被用于解析生成BenchmarkListEntry配置类实例,而在代码中使用Options配置也是被解析成一个个BenchmarkListEntry配置类实例(每个方法对应一个)。
对于JSON解析框架性能对比我们可以使用单元测试,而如果想要测试web服务的某个接口性能,需要对接口进行压测,就不能使用简单的单元测试方式去测,我们可以独立创建一个接口测试项目,将基准测试代码写在该项目中,然后将写好的基准测试项目打包成jar包丢到linux服务器上执行,测试结果会更准确一些,硬件、系统贴近线上环境、也不受本机开启的应用数、硬件配置等因素影响。
使用java命令即可运行一个基准测试应用:
java -jar my-benchmarks.jar
在idea中,我们可以编写一个单元测试方法,在单元测试方法中创建一个org.openjdk.jmh.runner.Runner,调用Runner的run方法执行基准测试。但JMH不会去扫描包,不会执行每个基准测试方法,这需要我们通过配置项来告知JMH需要执行哪些基准测试方法。
public class BenchmarkTest{
@Test
public void test() throws RunnerException {
Options options = null; // 创建Options
new Runner(options).run();
}
}
完整例子如下:
public class BenchmarkTest{
@Test
public void test() throws RunnerException {
Options options = new OptionsBuilder()
.include(JsonBenchmark.class.getSimpleName())
// .output("/tmp/json_benchmark.log")
.build();
new Runner(options).run();
}
}
Options在前面已经介绍过了,由于本例中JsonBenchmark这个类已经使用了注解,因此Options只需要配置需要执行基准测试的类。如果需要执行多个基准测试类,include方法可以多次调用。如果需要将测试结果输出到文件,可调用output方法配置文件路径,不配置则输出到控制台。
插件源码地址:https://github.com/artyushov/idea-jmh-plugin。
安装:在IDEA中搜索JMH Plugin,安装后重启即可使用。
在方法名称所在行,IDEA会有一个▶️执行符号,右键点击运行即可。如果写的是单元测试方法, IDEA会提示你选择执行单元测试还是基准测试。
在类名所在行,IDEA会有一个▶️
执行符号,右键点击运行,该类下的所有被@Benchmark注解注释的方法都会执行。如果写的是单元测试方法,IDEA会提示你选择执行单元测试还是基准测试。
要测试Sentinel对应用性能的影响,我们需要测试两组数据进行对比,分别是不使用Sentinel的情况下方法的吞吐量、使用Sentinel保护方法后方法的吞吐量。
下面是Sentinel提供的基准测试类部分源码。
@Fork(1)
@Warmup(iterations = 10)
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@State(Scope.Thread)
public class SentinelEntryBenchmark {
@Param({"25", "50", "100", "200", "500", "1000"})
private int length;
private List<Integer> numbers;
@Setup
public void prepare() {
numbers = new ArrayList<>();
for (int i = 0; i < length; i++) {
numbers.add(ThreadLocalRandom.current().nextInt());
}
}
@Benchmark
@Threads(8)
public void doSomething() {
Collections.shuffle(numbers);
Collections.sort(numbers);
}
@Benchmark
@Threads(8)
public void doSomethingWithEntry() {
Entry e0 = null;
try {
e0 = SphU.entry("benchmark");
doSomething();
} catch (BlockException e) {
} finally {
if (e0 != null) {
e0.exit();
}
}
}
}
该基准测试类使用@State指定每个线程使用不同的numbers字段的实例,所以@Setup注解的方法也会执行8次,分别是在每个线程开始执行基准测试方法之前执行,用于完成初始化工作,与Junit中的@Before注解功能相似。
doSomething方法用于模拟业务方法,doSomethingWithEntry方法用于模拟使用Sentinel保护业务方法,分别对这两个方法进行基准测试。将基准测试模式配置为吞吐量模式,使用@Warmup注解配置预热次数为10,使用@OutputTimeUnit指定输出单位为秒,使用@Fork指定进程数为1,使用@Threads指定线程数为8。
doSomething方法吞吐量测试结果如下:
Result "com.alibaba.csp.sentinel.benchmark.SentinelEntryBenchmark.doSomething":
300948.682 ±(99.9%) 33237.428 ops/s [Average]
(min, avg, max) = (295869.456, 300948.682, 316089.624), stdev = 8631.655
CI (99.9%): [267711.254, 334186.110] (assumes normal distribution)
doSomethingWithEntry方法吞吐量测试结果如下:
Result "com.alibaba.csp.sentinel.benchmark.SentinelEntryBenchmark.doSomethingWithEntry":
309934.827 ±(99.9%) 98910.540 ops/s [Average]
(min, avg, max) = (280835.799, 309934.827, 337712.803), stdev = 25686.753
CI (99.9%): [211024.287, 408845.366] (assumes normal distribution)
OPS:每秒执行的操作次数,或每秒执行的方法次数。
从本次测试结果可以看出,doSomething方法的平均吞吐量与doSomethingWithEntry方法平均吞吐量相差约为3%,也就是说,在超过28w OPS(QPS)的情况下,Sentinel对应用性能的影响只有3%不到。实际项目场景,一个服务节点所有接口总的QPS也很难达到25W这个值,而QPS越低,Sentinel对应用性能的影响也越低。
但这毕竟是在没有配置任何限流规则的情况下,只有一个资源且调用链路的深度(调用树的深度)为1的情况下,这个结果只能算个理想的参考值,还是以实际项目中的使用测试为准。
到此,我们基本把Sentinel的核心实现源码大致都分析了一遍。我们也能从Sentinel源码的一些细节上看出Sentinel为性能所作出的努力,并也使用JMH对Sentinel做了一次简单的基准测试,得出Sentinel对应用性能影响非常小结论。
Sentinel支持丰富的流控功能、扩展性极强,以及性能方面的优势,才是Sentinel被广泛使用的原因。
希望本专栏内容对你有所帮助,再见!
声明:公众号、CSDN、掘金的曾用名:“Java艺术”,因此您可能看到一些早期的文章的图片有“Java艺术”的水印。
本篇内容介绍如何使用r2dbc-mysql驱动程序包与mysql数据库建立连接、使用r2dbc-pool获取数据库连接、Spring-Data-R2DBC增删改查API、事务的使用,以及R2DBC Repository。
消息推送服务主要是处理同步给用户推送短信通知或是异步推送短信通知、微信模板消息通知等。本篇介绍如何使用Spring WebFlux + R2DBC搭建消息推送服务。
IDEA有着极强的扩展功能,它提供插件扩展支持,让开发者能够参与到IDEA生态建设中,为更多开发者提供便利、提高开发效率。我们常用的插件有Lombok、Mybatis插件,这些插件都大大提高了我们的开发效率。即便IDEA功能已经很强大,并且也已有很多的插件,但也不可能面面俱到,有时候我们需要自给自足。
Instrumentation之所以难驾驭,在于需要了解Java类加载机制以及字节码,一不小心就能遇到各种陌生的Exception。笔者在实现Java探针时就踩过不少坑,其中一类就是类加载相关的问题,也是本篇所要跟大家分享的。
订阅
订阅新文章发布通知吧,不错过精彩内容!
输入邮箱,提交后我们会给您发送一封邮件,您需点击邮件中的链接完成订阅设置。