目前,Spring Cloud Gateway是仅次于Spring Cloud Netflix的第二个最受欢迎的Spring Cloud项目(就GitHub上的星级而言)。它是作为Spring Cloud系列中Zuul代理的继任者而创建的。该项目提供了用于微服务体系结构的API网关,并基于反应式Netty和Project Reactor构建。它旨在提供一种简单而有效的方法来路由到API并解决诸如安全性,监视/度量和弹性之类的普遍关注的问题。
基于Redis限流
Spring Cloud Gateway为您提供了许多功能和配置选项。今天,我将集中讨论网关配置的一个非常有趣的方面-速率限制。速率限制器可以定义为一种控制网络上发送或接收的流量速率的方法。我们还可以定义几种类型的速率限制。Spring Cloud Gateway当前提供了一个Request Rate Limiter,它负责将每个用户每秒限制为N个请求。与Spring Cloud Gateway一起 使用时RequestRateLimiter,我们可能会利用Redis。Spring Cloud实现使用令牌桶算法做限速。该算法具有集中式存储桶主机,您可以在其中对每个请求获取令牌,然后将更多的令牌缓慢滴入存储桶中。如果存储桶为空,则拒绝该请求。
项目演示源码地址:
https://github.com/1ssqq1lxr/SpringCloudGatewayTest
引入maven依赖
## spring cloud依赖org.springframework.bootspring-boot-starter-parent2.2.1.RELEASEUTF-8UTF-811Hoxton.RC2org.springframework.cloudspring-cloud-dependencies${spring-cloud.version}pomimport## gateway 依赖org.springframework.cloudspring-cloud-starter-gatewayorg.springframework.bootspring-boot-starter-data-redis-reactiveorg.projectlomboklombokorg.springframework.bootspring-boot-starter-testtestorg.testcontainersmockserver1.12.3testorg.mock-servermockserver-client-java3.10.8testcom.carrotsearchjunit-benchmarks0.7.2test
限流器配置
使用Spring Cloud Gateway默认请求限流GatewayFilter(
org.springframework.cloud.gateway.filter.ratelimit.RedisRateLimiter)。使用默认的Redis限流器方案,你可以通过自定义keyResolver类去决定Redis限流key的生成,下面举常用几个例子:
根据用户: 使用这种方式限流,请求路径中必须携带userId参数
@BeanKeyResolver userKeyResolver() {returnexchange->Mono.just(exchange.getRequest().getQueryParams().getFirst("userId"));}
根据获uri
@BeanKeyResolver apiKeyResolver() {returnexchange->Mono.just(exchange.getRequest().getPath().value());}
由于我们已经讨论了Spring Cloud Gateway速率限制的一些理论方面,因此我们可以继续进行实施。首先,让我们定义主类和非常简单的KeyResolverbean,它始终等于一个。
@SpringBootApplicationpublicclassGatewayApplication{publicstaticvoidmain(String[] args){ SpringApplication.run(GatewayApplication.class,args); }@BeanKeyResolveruserKeyResolver(){returnexchange -> Mono.just("1"); }}
Gateway默认使用
org.springframework.cloud.gateway.filter.ratelimit.RedisRateLimiter 限流器。 现在,如通过模拟Http请求,则会收到以下响应。它包括一些特定的header,其前缀为x-ratelimit。
x-ratelimit-burst-capacity:最大令牌值,
x-ratelimit-replenish-rate:填充的速率值,
x-ratelimit-remaining:剩下可请求数。
yaml配置:
server:port:${PORT:8085}spring:application:name:gateway-serviceredis:host:localhostport:6379cloud:gateway:routes:-id:account-serviceuri:http://localhost:8091predicates:-Path=/account/**filters:-RewritePath=/account/(?.*),/$\{path}-name:RequestRateLimiterargs:redis-rate-limiter.replenishRate:10redis-rate-limiter.burstCapacity:20
Redis限流器实现
关键源码如下:
// routeId也就是我们的服务路由id,id就是限流的keypublicMono isAllowed(String routeId, String id) {// 会判断RedisRateLimiter是否初始化了if(!this.initialized.get()) {thrownew IllegalStateException("RedisRateLimiter is not initialized"); }// 获取routeId对应的限流配置Config routeConfig = getConfig().getOrDefault(routeId, defaultConfig);if(routeConfig ==null) {thrownew IllegalArgumentException("No Configuration found for route "+ routeId); }// 允许用户每秒做多少次请求int replenishRate = routeConfig.getReplenishRate();// 令牌桶的容量,允许在一秒钟内完成的最大请求数int burstCapacity = routeConfig.getBurstCapacity();try{// 限流key的名称(request_rate_limiter.{localhost}.timestamp,request_rate_limiter.{localhost}.tokens)List keys = getKeys(id);// The arguments to the LUA script. time() returns unixtime in seconds.List scriptArgs = Arrays.asList(replenishRate +"", burstCapacity +"", Instant.now().getEpochSecond() +"","1");// allowed, tokens_left = redis.eval(SCRIPT, keys, args)// 执行LUA脚本Flux> flux =this.redisTemplate.execute(this.script, keys, scriptArgs);// .log("redisratelimiter", Level.FINER);returnflux.onErrorResume(throwable -> Flux.just(Arrays.asList(1L, -1L))) .reduce(new ArrayList(), (longs, l) -> { longs.addAll(l);returnlongs; }) .map(results -> { boolean allowed = results.get(0) ==1L;LongtokensLeft = results.get(1); Response response = new Response(allowed, getHeaders(routeConfig, tokensLeft));if(log.isDebugEnabled()) { log.debug("response: "+ response); }returnresponse; }); }catch(Exception e) { log.error("Error determining if user allowed from redis", e); }returnMono.just(new Response(true, getHeaders(routeConfig, -1L)));}
测试Redis限流器
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)@RunWith(SpringRunner.class)publicclassGatewayRateLimiterTest{privatestaticfinalLogger LOGGER = LoggerFactory.getLogger(GatewayRateLimiterTest.class);@RulepublicTestRule benchmarkRun =newBenchmarkRule();@ClassRulepublicstaticMockServerContainer mockServer =newMockServerContainer();@ClassRulepublicstaticGenericContainer redis =newGenericContainer("redis:5.0.6").withExposedPorts(6379);@AutowiredTestRestTemplate template;@BeforeClasspublicstaticvoidinit(){ System.setProperty("spring.cloud.gateway.routes[0].id","account-service"); System.setProperty("spring.cloud.gateway.routes[0].uri","http://localhost:"+ mockServer.getServerPort()); System.setProperty("spring.cloud.gateway.routes[0].predicates[0]","Path=/account/**"); System.setProperty("spring.cloud.gateway.routes[0].filters[0]","RewritePath=/account/(?<path>.*), /$\\{path}"); System.setProperty("spring.cloud.gateway.routes[0].filters[1].name","RequestRateLimiter"); System.setProperty("spring.cloud.gateway.routes[0].filters[1].args.redis-rate-limiter.replenishRate","10"); System.setProperty("spring.cloud.gateway.routes[0].filters[1].args.redis-rate-limiter.burstCapacity","20"); System.setProperty("spring.redis.host","localhost"); System.setProperty("spring.redis.port",""+ redis.getMappedPort(6379));newMockServerClient(mockServer.getContainerIpAddress(), mockServer.getServerPort()) .when(HttpRequest.request() .withPath("/1")) .respond(response() .withBody("{\"id\":1,\"number\":\"1234567890\"}") .withHeader("Content-Type","application/json")); }@Test@BenchmarkOptions(warmupRounds =0, concurrency =6, benchmarkRounds =600)publicvoidtestAccountService(){ ResponseEntity r = template.exchange("/account/{id}", HttpMethod.GET,null, Account.class, 1); LOGGER.info("Received: status->{}, payload->{}, remaining->{}", r.getStatusCodeValue(), r.getBody(), r.getHeaders().get("X-RateLimit-Remaining"));// Assert.assertEquals(200, r.getStatusCodeValue());// Assert.assertNotNull(r.getBody());// Assert.assertEquals(Integer.valueOf(1), r.getBody().getId());// Assert.assertEquals("1234567890", r.getBody().getNumber());}}
执行Test类: 发现超过20之后会被拦截返回429,运行过程中随着令牌的放入会不断有请求成功。
14:20:32.242--- [pool-2-thread-1] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[18]14:20:32.242--- [pool-2-thread-4] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[16]14:20:32.242--- [pool-2-thread-2] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[14]14:20:32.242--- [pool-2-thread-3] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[15]14:20:32.242--- [pool-2-thread-6] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[17]14:20:32.242--- [pool-2-thread-5] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[19]14:20:32.294--- [pool-2-thread-4] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[15]14:20:32.297--- [pool-2-thread-6] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[19]14:20:32.304--- [pool-2-thread-3] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[18]14:20:32.308--- [pool-2-thread-5] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[16]14:20:32.309--- [pool-2-thread-1] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[17]14:20:32.312--- [pool-2-thread-2] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[14]14:20:32.320--- [pool-2-thread-4] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[13]14:20:32.326--- [pool-2-thread-6] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[12]14:20:32.356--- [pool-2-thread-4] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[7]14:20:32.356--- [pool-2-thread-5] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[10]14:20:32.361--- [pool-2-thread-6] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[6]14:20:32.363--- [pool-2-thread-2] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[8]14:20:32.384--- [pool-2-thread-5] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[4]14:20:32.384--- [pool-2-thread-3] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[11]14:20:32.386--- [pool-2-thread-4] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[5]14:20:32.390--- [pool-2-thread-1] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[9]14:20:32.391--- [pool-2-thread-6] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[3]14:20:32.392--- [pool-2-thread-2] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[2]14:20:32.403--- [pool-2-thread-6] : Received: status->429, payload->null, remaining->[0]14:20:32.403--- [pool-2-thread-4] : Received: status->429, payload->null, remaining->[0]........14:20:33.029--- [pool-2-thread-2] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[9]14:20:33.033--- [pool-2-thread-1] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[8]14:20:33.033--- [pool-2-thread-4] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[7]14:20:33.037--- [pool-2-thread-3] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[6]14:20:33.039--- [pool-2-thread-5] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[5]14:20:33.046--- [pool-2-thread-6] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[4]14:20:33.052--- [pool-2-thread-5] : Received: status->429, payload->null, remaining->[0]14:20:33.058--- [pool-2-thread-6] : Received: status->429, payload->null, remaining->[0]14:20:33.058--- [pool-2-thread-1] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[2]14:20:33.060--- [pool-2-thread-5] : Received: status->429, payload->null, remaining->[0]14:20:33.081--- [pool-2-thread-4] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[1]14:20:33.082--- [pool-2-thread-3] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[0]14:20:33.084--- [pool-2-thread-2] : Received: status->200, payload->Account(id=1, number=1234567890), remaining->[3]14:20:33.088--- [pool-2-thread-5] : Received: status->429, payload->null, remaining->[0]
如果默认的限流器不能够满足使用,可以通过继承AbstractRateLimiter实现自定义限流器,然后通过RouteLocator方式去注入拦截器。
Resilience4J熔断器
引入依赖
org.springframework.cloudspring-cloud-starter-gatewayorg.springframework.bootspring-cloud-starter-circuitbreaker-reactor-resilience4j
Resilience4J 断路器介绍
三个一般性状态CLOSED:关闭状态,放过所有请求,记录请求状态。OPEN:打开,异常请求达到阀值数量时,开启熔断,拒绝所有请求。HALF_OPEN:半开,放开一定数量的请求,重新计算错误率。
两个特定状态DISABLED:禁用FORCED_OPEN:强开
状态之间转换启动时断路器为CLOSE状态,在达到一定请求量之后计算请求失败率,达到或高于指定失败率后,断路进入open状态,阻拦所有请求,开启一段时间(自定义)时间后,断路器变为halfOpen状态,重新计算请求失败率。halfOpen错误率低于指定失败率后,断路进入close状态,否则进入open状态。
状态转换
通过Resilience4J启用Spring Cloud Gateway断路器
要启用构建在Resilience4J之上的断路器,我们需要声明一个Customizer传递了的bean
ReactiveResilience4JCircuitBreakerFactory。可以非常简单地去配置设置,下面使用默认配置进行测试
@BeanpublicCustomizerdefaultCustomizer(){returnfactory -> factory.configureDefault(id ->newResilience4JConfigBuilder(id) .circuitBreakerConfig(CircuitBreakerConfig.custom()//统计失败率的请求总数.slidingWindowSize(5)//在半开状态下请求的次数.permittedNumberOfCallsInHalfOpenState(5)//断路器打开的成功率.failureRateThreshold(50.0F)//断路器打开的周期.waitDurationInOpenState(Duration.ofMillis(30))//属于慢请求的周期.slowCallDurationThreshold(Duration.ofMillis(200))//慢请求打开断路器的成功率.slowCallRateThreshold(50.0F) .build()) .timeLimiterConfig(TimeLimiterConfig.custom().timeoutDuration(Duration.ofMillis(200)).build()).build());}
测试Resilience4J断路器
使用默认配置进行测试
@Beanpublic Customizer defaultCustomizer() {returnfactory->factory.configureDefault(id->newResilience4JConfigBuilder(id).circuitBreakerConfig(CircuitBreakerConfig.ofDefaults()).circuitBreakerConfig(CircuitBreakerConfig.custom().slowCallDurationThreshold(Duration.ofMillis(200)).build()).timeLimiterConfig(TimeLimiterConfig.custom().timeoutDuration(Duration.ofMillis(200)).build()).build()); }
执行下面Test用例
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)@RunWith(SpringRunner.class)publicclassGatewayCircuitBreakerTest{privatestaticfinalLogger LOGGER = LoggerFactory.getLogger(GatewayRateLimiterTest.class);@RulepublicTestRule benchmarkRun =newBenchmarkRule();@ClassRulepublicstaticMockServerContainer mockServer =newMockServerContainer();@AutowiredTestRestTemplate template;finalRandom random =newRandom();inti =0;@BeforeClasspublicstaticvoidinit(){ System.setProperty("logging.level.org.springframework.cloud.gateway.filter.factory","TRACE"); System.setProperty("spring.cloud.gateway.routes[0].id","account-service"); System.setProperty("spring.cloud.gateway.routes[0].uri","http://localhost:"+ mockServer.getServerPort()); System.setProperty("spring.cloud.gateway.routes[0].predicates[0]","Path=/account/**"); System.setProperty("spring.cloud.gateway.routes[0].filters[0]","RewritePath=/account/(?<path>.*), /$\\{path}"); System.setProperty("spring.cloud.gateway.routes[0].filters[1].name","CircuitBreaker"); System.setProperty("spring.cloud.gateway.routes[0].filters[1].args.name","exampleSlowCircuitBreaker");// System.setProperty("spring.cloud.gateway.routes[0].filters[1].args.slowCallDurationThreshold", "100");// System.setProperty("spring.cloud.gateway.routes[0].filters[1].args.slowCallRateThreshold", "9.0F");// System.setProperty("spring.cloud.gateway.routes[0].filters[1].args.fallbackUri", "forward:/fallback/account");MockServerClient client =newMockServerClient(mockServer.getContainerIpAddress(), mockServer.getServerPort()); client.when(HttpRequest.request() .withPath("/1")) .respond(response() .withBody("{\"id\":1,\"number\":\"1234567890\"}") .withHeader("Content-Type","application/json"));// client.when(HttpRequest.request()// .withPath("/2"), Times.exactly(3))// .respond(response()// .withBody("{\"id\":2,\"number\":\"1\"}")// .withDelay(TimeUnit.SECONDS, 1000)// .withHeader("Content-Type", "application/json"));client.when(HttpRequest.request() .withPath("/2")) .respond(response() .withBody("{\"id\":2,\"number\":\"1234567891\"}") .withDelay(TimeUnit.SECONDS,200) .withHeader("Content-Type","application/json")); }@Test@BenchmarkOptions(warmupRounds =0, concurrency =1, benchmarkRounds =600)publicvoidtestAccountService(){intgen =1+ (i++ %2); ResponseEntity r = template.exchange("/account/{id}", HttpMethod.GET,null, Account.class,gen); LOGGER.info("{}. Received: status->{}, payload->{}, call->{}", i, r.getStatusCodeValue(), r.getBody(), gen); }}
请求日志如下:当请求达到100次时候,此时失败率为50% 这时候系统开启断路器返回503!
20:07:29.281--- [pool-2-thread-1] :91. Received: status->200, payload->Account(id=1, number=1234567890), call->120:07:30.297--- [pool-2-thread-1] :92. Received: status->504, payload->Account(id=null, number=null), call->220:07:30.316--- [pool-2-thread-1] :93. Received: status->200, payload->Account(id=1, number=1234567890), call->120:07:31.328--- [pool-2-thread-1] :94. Received: status->504, payload->Account(id=null, number=null), call->220:07:31.345--- [pool-2-thread-1] :95. Received: status->200, payload->Account(id=1, number=1234567890), call->120:07:32.359--- [pool-2-thread-1] :96. Received: status->504, payload->Account(id=null, number=null), call->220:07:32.385--- [pool-2-thread-1] :97. Received: status->200, payload->Account(id=1, number=1234567890), call->120:07:33.400--- [pool-2-thread-1] :98. Received: status->504, payload->Account(id=null, number=null), call->220:07:33.414--- [pool-2-thread-1] :99. Received: status->200, payload->Account(id=1, number=1234567890), call->120:07:34.509--- [pool-2-thread-1] :100. Received: status->504, payload->Account(id=null, number=null), call->220:07:34.525--- [pool-2-thread-1] :101. Received: status->503, payload->Account(id=null, number=null), call->120:07:34.533--- [pool-2-thread-1] :102. Received: status->503, payload->Account(id=null, number=null), call->220:07:34.539--- [pool-2-thread-1] :103. Received: status->503, payload->Account(id=null, number=null), call->120:07:34.545--- [pool-2-thread-1] :104. Received: status->503, payload->Account(id=null, number=null), call->220:07:34.552--- [pool-2-thread-1] :105. Received: status->503, payload->Account(id=null, number=null), call->120:07:34.566--- [pool-2-thread-1] :106. Received: status->503, payload->Account(id=null, number=null), call->220:07:34.572--- [pool-2-thread-1] :107. Received: status->503, payload->Account(id=null, number=null), call->120:07:34.576--- [pool-2-thread-1] :108. Received: status->503, payload->Account(id=null, number=null), call->220:07:34.580--- [pool-2-thread-1] :109. Received: status->503, payload->Account(id=null, number=null), call->120:07:34.586--- [pool-2-thread-1] :110. Received: status->503, payload->Account(id=null, number=null), call->220:07:34.591--- [pool-2-thread-1] :111. Received: status->503, payload->Account(id=null, number=null), call->1
这时候我们修改下配置
@BeforeClasspublicstaticvoidinit(){ System.setProperty("logging.level.org.springframework.cloud.gateway.filter.factory","TRACE"); System.setProperty("spring.cloud.gateway.routes[0].id","account-service"); System.setProperty("spring.cloud.gateway.routes[0].uri","http://localhost:"+ mockServer.getServerPort()); System.setProperty("spring.cloud.gateway.routes[0].predicates[0]","Path=/account/**"); System.setProperty("spring.cloud.gateway.routes[0].filters[0]","RewritePath=/account/(?<path>.*), /$\\{path}"); System.setProperty("spring.cloud.gateway.routes[0].filters[1].name","CircuitBreaker"); System.setProperty("spring.cloud.gateway.routes[0].filters[1].args.name","exampleSlowCircuitBreaker");// System.setProperty("spring.cloud.gateway.routes[0].filters[1].args.slowCallDurationThreshold", "100");// System.setProperty("spring.cloud.gateway.routes[0].filters[1].args.slowCallRateThreshold", "9.0F");System.setProperty("spring.cloud.gateway.routes[0].filters[1].args.fallbackUri","forward:/fallback/account"); MockServerClient client =newMockServerClient(mockServer.getContainerIpAddress(), mockServer.getServerPort()); client.when(HttpRequest.request() .withPath("/1")) .respond(response() .withBody("{\"id\":1,\"number\":\"1234567890\"}") .withHeader("Content-Type","application/json")); client.when(HttpRequest.request() .withPath("/2"), Times.exactly(3)) .respond(response() .withBody("{\"id\":2,\"number\":\"1\"}") .withDelay(TimeUnit.SECONDS,1000) .withHeader("Content-Type","application/json")); client.when(HttpRequest.request() .withPath("/2")) .respond(response() .withBody("{\"id\":2,\"number\":\"1234567891\"}")// .withDelay(TimeUnit.SECONDS, 200).withHeader("Content-Type","application/json")); }
新建一个回调接口,用于断路器打开后请求的地址。
@RestController@RequestMapping("/fallback")publicclassGatewayFallback{@GetMapping("/account")publicAccount getAccount() { Account a = new Account(); a.setId(2); a.setNumber("123456");returna; }}
使用默认设置时,前3次请求触发断路器回调,后面正常请求成功
20:20:23.529--- [pool-2-thread-1] :1. Received: status->200, payload->Account(id=1, number=1234567890), call->120:20:23.777--- [pool-2-thread-1] :2. Received: status->200, payload->Account(id=2, number=123456), call->220:20:23.808--- [pool-2-thread-1] :3. Received: status->200, payload->Account(id=1, number=1234567890), call->120:20:24.018--- [pool-2-thread-1] :4. Received: status->200, payload->Account(id=2, number=123456), call->220:20:24.052--- [pool-2-thread-1] :5. Received: status->200, payload->Account(id=1, number=1234567890), call->120:20:24.268--- [pool-2-thread-1] :6. Received: status->200, payload->Account(id=2, number=123456), call->220:20:24.301--- [pool-2-thread-1] :7. Received: status->200, payload->Account(id=1, number=1234567890), call->120:20:24.317--- [pool-2-thread-1] :8. Received: status->200, payload->Account(id=2, number=1234567891), call->220:20:24.346--- [pool-2-thread-1] :9. Received: status->200, payload->Account(id=1, number=1234567890), call->120:20:24.363--- [pool-2-thread-1] :10. Received: status->200, payload->Account(id=2, number=1234567891), call->220:20:24.378--- [pool-2-thread-1] :11. Received: status->200, payload->Account(id=1, number=1234567890), call->120:20:24.392--- [pool-2-thread-1] :12. Received: status->200, payload->Account(id=2, number=1234567891), call->220:20:24.402--- [pool-2-thread-1] :13. Received: status->200, payload->Account(id=1, number=1234567890), call->1
END
至此给大家介绍了Spring Cloud Gateway中断路器跟限流器使用。
网友评论