Sentinel 性能影响深度分析:到底损耗了多少性能?

图片[1]-Sentinel 性能影响深度分析:到底损耗了多少性能? - 速优课-速优课

本文导读

在引入任何一个框架之前,开发者都会关心一个问题:它对性能的影响有多大?

Sentinel 作为一个服务保护框架,需要嵌入到每个请求的调用链路中,它的性能表现直接关系到系统的整体吞吐量。官方文档给出的结论是:

“引入 Sentinel 带来的性能损耗非常小,只有在业务单机量级超过 25W QPS 的时候才会有一些显著的影响(5%~10% 左右),单机 QPS 不太大的时候损耗几乎可以忽略不计。”

这个结论靠谱吗?Sentinel 又是怎么做到低损耗的?

本文将从以下几个维度深入分析 Sentinel 的性能:

  1. Sentinel 的性能优化手段:从源码看 Sentinel 在性能上做了哪些努力
  2. JMH 基准测试入门:快速掌握 Java 基准测试工具 JMH 的使用
  3. Sentinel 性能基准测试:实际测试看看 Sentinel 的性能表现
  4. 实际使用建议:如何在项目中最大程度降低 Sentinel 的影响

通过本文,你不仅能了解 Sentinel 的性能,还能学会 JMH 基准测试的方法。


一、Sentinel 在性能方面的努力

评价一个框架的性能,不能只看最终的测试结果,更要看它在设计和实现上为性能做了哪些努力。Sentinel 在性能优化上可谓下足了功夫。

1.1 滑动窗口的性能优化

指标统计是 Sentinel 的核心功能,也是最频繁的操作。在滑动窗口的实现上,Sentinel 做了多层优化。

1. 循环复用 Bucket

滑动窗口采用”时间窗口 + Bucket”的结构,通过循环复用 Bucket 来减少对象的创建和销毁。Bucket 一旦创建就不会被销毁,只是不断被重置和复用。

2. LongAdder 优化并发统计

统计指标数据时,使用 LongAdder 而非 AtomicLong 来统计请求成功数、失败数、总耗时等指标。

LongAdder 的优势:在高并发场景下,通过将竞争分散到多个 Cell 中,减少了 CAS 操作的冲突,从而提升了并发更新的吞吐量。

3. 定时任务维护时间戳

Sentinel 通过定时任务递增时间戳,而不是每次都调用 System.currentTimeMillis() 获取。

为什么要这么做?

  • System.currentTimeMillis() 是一个 native 方法,调用开销相对较大
  • 在高 QPS 场景下,每次请求都调用一次的累积开销不容小觑
  • 用定时任务维护时间戳,虽然牺牲了一点精度,但换来了性能提升

精度换取性能,这是 Sentinel 在性能优化上的一个典型思路。

1.2 读多写少场景下的 Map 选择

在 Sentinel 源码中,你会看到很多这样的代码模式:

ProcessorSlotChain chain = chainMap.get(resourceWrapper);
if (chain == null) {
    synchronized (LOCK) {
        chain = chainMap.get(resourceWrapper);
        if (chain == null) {
            chain = SlotChainProvider.newSlotChain();
            // 创建新的 HashMap
            Map<ResourceWrapper, ProcessorSlotChain> newMap = new HashMap<>(
                chainMap.size() + 1);
            // 复制旧数据
            newMap.putAll(chainMap);
            // 插入新数据
            newMap.put(resourceWrapper, chain);
            // 整体替换
            chainMap = newMap;
        }
    }
}
return chain;

为什么不用 ConcurrentHashMap?

答案是:读多写少的场景下,普通 Map 反而更快

ProcessorSlotChain 的创建为例:

  • 只有在资源第一次被访问时才会创建(写操作)
  • 之后的所有访问都是读操作
  • 假设有 10 个接口,应用启动后很快都被访问过了,之后这个 Map 就几乎不会再有写操作

既然没有写操作,就不需要加锁,也不需要 ConcurrentHashMap 的同步开销。用普通的 HashMap,在读操作上性能更好。

写操作的实现方式:

  • 加锁(synchronized)
  • 创建新的 HashMap
  • 复制所有旧数据 + 新数据
  • 用新 Map 整体替换旧 Map

这就是典型的写时复制(Copy-On-Write)思想,虽然写操作开销大,但读操作完全无锁,非常适合读多写少的场景。

1.3 流量效果控制的取舍

在流量效果控制上,Sentinel 也做了一些性能优先的取舍。

1. 匀速限流的精度取舍

RateLimiterController(匀速限流控制器)有一个隐含的限制:最大支持约 1000 QPS 的匀速限流

为什么是 1000?

  • 因为 Sentinel 使用的时间戳最小单位是毫秒
  • 以毫秒为单位,1 秒钟最多有 1000 个时间点
  • 所以理论上每秒最多只能精确控制 1000 个请求通过

这也是为什么很多人觉得 Sentinel 的匀速限流”不那么准”——比如限制每 5ms 一个请求,但实际上可能 5ms 内通过了好几个请求。

这是因为 Sentinel 并没有严格控制并发下的排队计时,而是用更轻量的方式实现。对于大多数业务场景来说,这种精度已经足够了,完全没必要为了极致的精确而牺牲性能。

2. 冷启动的令牌更新频率

WarmUpController(冷启动控制器)的实现也有性能考量:

  • 不是每个请求都更新令牌桶
  • 而是每秒只生产一次令牌
  • 生产令牌后,一次性扣减上一秒通过的请求数(官方称之为”令牌自动掉落”)

这样做的好处是:

  • 减少了 CAS 更新令牌桶的次数
  • 降低了对应用性能的影响
  • 虽然精度稍差,但对于冷启动场景完全够用

从这些细节可以看出,Sentinel 的设计哲学是:在保证功能可用的前提下,尽最大可能降低自身对应用性能的影响


二、JMH 基准测试工具入门

在进行性能测试之前,我们先来了解一下 Java 领域最权威的基准测试工具:JMH

2.1 什么是 JMH

JMH(Java Microbenchmark Harness)是 OpenJDK 官方提供的 Java 微基准测试工具。它的优势在于:

  • 可信度高:由 JVM 团队开发维护,深知 JVM 的各种优化机制
  • 结果准确:自动处理预热、GC、JIT 编译等影响因素
  • 功能强大:支持吞吐量、平均耗时、采样时间等多种测试模式

2.2 引入依赖

在项目中添加 JMH 的依赖:

<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>

1.23 是撰写本文时的最新版本,建议使用最新稳定版。

2.3 常用注解详解

JMH 的使用主要依靠注解,下面是最常用的几个注解。

@Benchmark

声明一个 public 方法为基准测试方法。每个被 @Benchmark 标注的方法对应一个基准测试用例。

public class MyBenchmark {
    @Benchmark
    public void testMethod() {
        // 要测试的代码
    }
}

@BenchmarkMode

指定基准测试的模式,即测试哪些指标。常用模式:

模式 说明
Mode.Throughput 吞吐量:每秒执行多少次操作
Mode.AverageTime 平均耗时:每次操作平均耗时
Mode.SampleTime 采样耗时:随机采样,统计 p50、p99 等
Mode.All 所有模式都测一遍
@BenchmarkMode(Mode.AverageTime) 
@Benchmark
public void testMethod() {
    // 测试平均耗时
}

@Measurement

配置测量参数:

参数 说明
iterations 测量次数(轮次)
time 每轮测量的持续时间
timeUnit 时间单位
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Benchmark
public void testMethod() {
    // 测量 5 轮,每轮 1 秒
}

@Warmup

配置预热参数。JVM 有 JIT 编译,代码执行多次后会被优化,所以需要先预热再测量,结果才准确。

@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Benchmark
public void testMethod() {
    // 预热 5 轮,再测量 5 轮
}

注意区分:iterations 是”轮次”,不是”方法执行次数”。每轮持续 time 时间,在这段时间内方法会被不断调用,调用次数取决于方法本身的耗时。

@OutputTimeUnit

指定输出结果的时间单位。如果方法很快,建议用微秒或纳秒,更直观。

@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
@Benchmark
public void testMethod() {
    // 结果以纳秒为单位输出
}

@Fork

指定 fork 多少个子进程来执行测试。每个进程独立运行,避免相互影响。

@Fork(1)
@Benchmark
public void testMethod() {
    // 只用 1 个进程测试
}

@Threads

指定用多少个线程并发执行测试方法。

@Threads(2)
@Benchmark
public void testMethod() {
    // 2 个线程并发执行
}

公共配置

如果一个类中有多个基准测试方法,且配置相同,可以把注解写在类上,所有方法共享配置:

@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 MyBenchmark {

    @Benchmark
    public void testMethod1() {
        // 使用类上的配置
    }

    @Benchmark
    public void testMethod2() {
        // 使用类上的配置
    }
}

2.4 @State:对象共享范围

@State 注解用于指定类中字段的共享范围:

范围 说明
Scope.Thread 每个线程有独立的实例(线程隔离)
Scope.Benchmark 所有线程共享同一个实例
Scope.Group 同一线程组内共享

举个例子,测试 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.Benchmark)  // 所有线程共享
public class JsonBenchmark {

    private GsonParser gsonParser = new GsonParser();
    private JacksonParser jacksonParser = new JacksonParser();

    @Benchmark
    public void testGson() {
        gsonParser.fromJson(jsonString, JsonTestModel.class);
    }

    @Benchmark
    public void testJackson() {
        jacksonParser.fromJson(jsonString, JsonTestModel.class);
    }
}

如果换成 @State(Scope.Thread),那么每个线程都会有自己独立的 gsonParserjacksonParser 实例。

2.5 @Param:参数化测试

@Param 注解可以为测试方法指定多组参数,JMH 会为每组参数分别执行测试。

@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({
        "{\"name\":\"zhangsan\",\"age\":20}",
        "{\"name\":\"zhangsan\",\"age\":20,\"address\":\"...\"}",
        // 更长的 JSON...
    })
    private String jsonStr;

    @Benchmark
    public void testGson() {
        gsonParser.fromJson(jsonStr, JsonTestModel.class);
    }

    @Benchmark
    public void testJackson() {
        jacksonParser.fromJson(jsonStr, JsonTestModel.class);
    }
}

测试结果会分别输出每组参数对应的性能数据,方便对比不同输入下的性能表现。

2.6 非注解方式

除了注解,还可以通过代码方式配置:

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();
    }
}

注解方式和代码方式本质上是一样的,运行时注解会被解析成配置对象。注解方式更简洁,代码方式更灵活。

2.7 运行方式

JMH 有几种常见的运行方式:

  1. 打 jar 包在服务器运行:最准确,贴近生产环境

    java -jar my-benchmarks.jar
    
  2. IDEA 中写单元测试运行:开发调试方便
  3. IDEA JMH Plugin 插件:最方便,直接点方法上的运行按钮

    • 插件名:JMH Plugin
    • 安装后在 @Benchmark 方法旁会出现运行按钮

建议:开发阶段用插件快速验证,最终结果在服务器上测试,避免本机环境的干扰。


三、Sentinel 性能基准测试

了解了 JMH 之后,我们来看看 Sentinel 自身的基准测试结果。

3.1 测试设计

要衡量 Sentinel 对性能的影响,需要两组数据做对比:

  • 对照组:不使用 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);
    }

    // 实验组:用 Sentinel 保护
    @Benchmark
    @Threads(8)
    public void doSomethingWithEntry() {
        Entry e0 = null;
        try {
            e0 = SphU.entry("benchmark");
            doSomething();
        } catch (BlockException e) {
            // ignore
        } finally {
            if (e0 != null) {
                e0.exit();
            }
        }
    }
}

测试说明:

  • doSomething:模拟业务方法,做 shuffle + sort 操作
  • doSomethingWithEntry:用 Sentinel 包装业务方法
  • 8 个线程并发,吞吐量模式,预热 10 轮
  • @Setup 方法在每轮测试前执行初始化,类似 JUnit 的 @Before

3.2 测试结果

对照组(无 Sentinel):

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)
  • 平均:约 30.1 万 ops/s
  • 最小:约 29.6 万 ops/s
  • 最大:约 31.6 万 ops/s

实验组(有 Sentinel):

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)
  • 平均:约 31.0 万 ops/s
  • 最小:约 28.1 万 ops/s
  • 最大:约 33.8 万 ops/s

OPS = Operations Per Second,每秒操作数,相当于 QPS。

3.3 结果分析

从测试数据来看:

  • 无 Sentinel:平均约 30.1 万 ops/s
  • 有 Sentinel:平均约 31.0 万 ops/s
  • 差异:约 3% 左右

等等,有 Sentinel 反而更快?这显然不合理。原因可能是:

  • 测试的业务方法本身比较重(shuffle + sort)
  • Sentinel 的开销相对于业务逻辑可以忽略不计
  • 测试误差范围内的波动

但至少说明一点:在超过 28 万 QPS 的场景下,Sentinel 的性能影响也只有约 3%,甚至在误差范围内

而实际项目中,单个服务节点所有接口加起来的总 QPS 很难达到 25 万。QPS 越低,Sentinel 的相对影响就越小。

3.4 注意事项

这个测试结果是理想情况下的参考值,实际情况可能不同:

  • 没有配置任何限流规则:配置规则后会有额外的规则匹配开销
  • 只有一个资源:资源越多,规则匹配的开销越大
  • 调用链路深度为 1:嵌套调用越深,Sentinel 的调用次数越多

所以:以实际项目中的压测结果为准。

但无论如何,Sentinel 的性能开销对于绝大多数业务场景来说都是可以接受的。


四、总结与实践建议

4.1 Sentinel 性能优化手段回顾

优化点 实现方式 效果
滑动窗口复用 循环复用 Bucket 减少对象创建销毁
并发统计 LongAdder 高并发下统计更快
时间戳缓存 定时任务维护 减少 System.currentTimeMillis() 调用
读多写少 Map 写时复制 + 普通 Map 读操作完全无锁
匀速限流 毫秒级精度 牺牲精度换性能
冷启动令牌 每秒更新一次 减少 CAS 操作

4.2 实际使用建议

虽然 Sentinel 的性能已经很好了,但在使用时还是可以注意一些细节,进一步降低影响:

1. 合理控制资源数量

  • 资源名不要用动态生成的字符串(如带 ID 的 URL)
  • 使用 UrlCleaner 将 RESTful URL 归一化
  • 资源越多,规则匹配和统计的开销越大

2. 合理配置规则数量

  • 只给核心接口配置规则
  • 利用系统自适应限流做全局兜底,不用每个接口都配
  • 规则越多,每次请求的规则遍历开销越大

3. 注意调用链路深度

  • 避免过深的嵌套 SphU.entry 调用
  • 每次 entry/exit 都有开销,链路越深累积开销越大

4. 选择合适的流控效果

  • 快速失败性能最好(默认)
  • 匀速排队会有线程 sleep 的开销
  • 冷启动有令牌桶计算开销

5. 结合实际压测

  • 上线前压测,评估 Sentinel 对系统的实际影响
  • 关注高并发场景下的 CPU 和内存占用
  • 对比开启/关闭 Sentinel 时的吞吐量差异

4.3 最终结论

Sentinel 的性能表现:

  • 低 QPS 场景(几万以内):损耗几乎可以忽略不计
  • 中 QPS 场景(十万级):损耗在 1%~3% 左右
  • 高 QPS 场景(25 万以上):损耗约 5%~10%

对于绝大多数业务系统来说,Sentinel 带来的性能损耗远远小于它所提供的价值。服务保护是一项必要的投入,而 Sentinel 已经把这项投入的成本降到了很低的水平。


全文总结

到这里,我们的 Sentinel 深入理解系列就接近尾声了。从最初的服务雪崩问题,到滑动窗口、责任链模式、SPI 机制,再到限流、熔断、系统自适应限流、热点参数限流、自定义扩展、动态数据源、框架适配、集群限流……我们一起走完了 Sentinel 核心实现的完整旅程。

Sentinel 值得我们学习的不仅是功能,还有它的设计思想:

  • 扩展性:SPI 机制、Slot 责任链、动态数据源
  • 性能优先:各种细节上的性能考量和取舍
  • 解耦设计:统计、限流、熔断各模块职责清晰
  • 框架适配:利用各框架的扩展点实现低侵入集成

希望通过这个系列,你不仅学会了 Sentinel 的使用和原理,更能从中汲取优秀框架的设计思路,应用到自己的项目中。

最后一篇番外,我们将聊聊 Sentinel 1.8.0 版本在熔断降级上的新特性,看看 Sentinel 是如何持续进化的。

© 版权声明
THE END
喜欢就支持一下吧
点赞7
评论 抢沙发

请登录后发表评论

    请登录后查看评论内容

温馨提示:
1、本内容转载于网络,版权归原作者所有!
2、本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
3、本内容若侵犯到你的版权利益,请联系我们,会尽快给予删除处理!