|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +categories: [Gateway] |
| 4 | +description: none |
| 5 | +keywords: Gateway |
| 6 | +--- |
| 7 | +# 网关Gateway源码10限流 |
| 8 | + |
| 9 | +## 概述 |
| 10 | +通过 RequestRateLimiterGatewayFilterFactory ,可以创建一个GatewayFilter的匿名内部类实例,它的内部使用Redis+Lua实现限流。限流规则由KeyResolver接口的具体实现类来决定,比如通过IP、url等来进行限流。由于用到redis,所以在项目工程里需要配置redis依赖: |
| 11 | +``` |
| 12 | +<dependency> |
| 13 | + <groupId>org.springframework.boot</groupId> |
| 14 | + <artifactId>spring-boot-starter-data-redis</artifactId> |
| 15 | +</dependency> |
| 16 | +``` |
| 17 | +yml配置示例: |
| 18 | +``` |
| 19 | +spring: |
| 20 | + cloud: |
| 21 | + gateway: |
| 22 | + routes: |
| 23 | + - id: user-service |
| 24 | + uri: http://127.0.0.1:8081 |
| 25 | + order: 10000 |
| 26 | + predicates: |
| 27 | + - Path=/user/** |
| 28 | + filters: |
| 29 | + # 令牌桶容量=100个,补充令牌速率=20个/s,限流key解析器Bean对象的名字 |
| 30 | + # 根据#{@beanName},从BeanFactory中获取Bean,\为转义符,避免被解析成注释... |
| 31 | + - RequestRateLimiter=100, 20, \#{@principalNameKeyResolver} |
| 32 | +``` |
| 33 | +最后别忘了在yml里配redis... 这里节约篇幅就不加了哈。 |
| 34 | + |
| 35 | +## RequestRateLimiterGatewayFilterFactory核心源码 |
| 36 | +``` |
| 37 | +public GatewayFilter apply(Config config) { |
| 38 | + // snipped... 意思是有些跟主要逻辑无关的代码略过了 |
| 39 | + // 这里的resolver就是KeyResolver的具体实现,用于解析限流key |
| 40 | + // 默认为PrincipalNameKeyResolver,resolve()方法内容为: |
| 41 | + // return exchange.getPrincipal().flatMap(p -> Mono.justOrEmpty(p.getName())); |
| 42 | + // 如果用过Shiro做鉴权,应该是比较熟悉principal()这个词的,其实就是拿到当前登录用户 |
| 43 | + // 所以这里是取用户名作为限流key |
| 44 | + return (exchange, chain) -> resolver.resolve(exchange).defaultIfEmpty(EMPTY_KEY) |
| 45 | + .flatMap(key -> { |
| 46 | + // snipped... |
| 47 | + // limiter这里默认只有RedisRateLimiter的实现 |
| 48 | + return limiter.isAllowed(routeId, key).flatMap(response -> { |
| 49 | + // snipped... |
| 50 | + // 如果允许访问,再往下走过滤器链 |
| 51 | + if (response.isAllowed()) { |
| 52 | + return chain.filter(exchange); |
| 53 | + } |
| 54 | + // 被限流了,不允许访问,直接返回 |
| 55 | + setResponseStatus(exchange, config.getStatusCode()); |
| 56 | + return exchange.getResponse().setComplete(); |
| 57 | + }); |
| 58 | + }); |
| 59 | +} |
| 60 | +``` |
| 61 | + |
| 62 | +## RedisRateLimiter核心源码 |
| 63 | +``` |
| 64 | +public Mono<Response> isAllowed(String routeId, String id) { |
| 65 | + // 1.加载Route的配置 |
| 66 | + Config routeConfig = loadConfiguration(routeId); |
| 67 | + // 令牌补充速度 |
| 68 | + // 官方注释此处直译为:一秒钟允许通过的请求数,为何? |
| 69 | + // 我每小时只充一格电,那这小时只能用一格电,尽管有时手机是满电 |
| 70 | + // 我疯狂玩原神,掉电飞快,没过多久就搞没了,电量归零 |
| 71 | + // 那我还是变成了从0开始,充一格用一格的状态 |
| 72 | + // 由此得出一个结论,只要控制了产出就控制了消耗 |
| 73 | + // 虽然听着很废话的感觉,但这样很好理解 |
| 74 | + // 如果单纯的用补充速度这个词,不加解释,可能无法马上想到它造成的结果 |
| 75 | + // 我觉得这点官方的注释还是非常好的,只是第一次读有点绕不过来,还以为是不是写错了地方呢 |
| 76 | + int replenishRate = routeConfig.getReplenishRate(); |
| 77 | + // 令牌桶容量 |
| 78 | + int burstCapacity = routeConfig.getBurstCapacity(); |
| 79 | + // 每个请求申请多少令牌,或者说消耗/取出多少令牌,默认是1个 |
| 80 | + int requestedTokens = routeConfig.getRequestedTokens(); |
| 81 | +
|
| 82 | + try { |
| 83 | + // 构造tokenKey和时间戳key,都是通过前缀+用户ID拼出来的,用于到redis内去取令牌 |
| 84 | + List<String> keys = getKeys(id); |
| 85 | + // 2.封装Lua脚本参数列表 |
| 86 | + // Instant.now().getEpochSecond()获得的是从1970-01-01 00:00:00开始的秒数 |
| 87 | + List<String> scriptArgs = Arrays.asList(replenishRate + "", |
| 88 | + burstCapacity + "", Instant.now().getEpochSecond() + "", |
| 89 | + requestedTokens + ""); |
| 90 | + // 3.allowed, tokens_left = redis.eval(SCRIPT, keys, args) |
| 91 | + // 执行结果里有两个返回值:是否获取令牌成功(1-成功,0-失败), 剩余令牌数 |
| 92 | + Flux<List<Long>> flux = this.redisTemplate.execute(this.script, keys, |
| 93 | + scriptArgs); |
| 94 | +
|
| 95 | + // 4.返回执行结果 |
| 96 | + return flux.onErrorResume(throwable -> { |
| 97 | + // Redis执行Lua脚本发生异常时,忽略异常,直接返回[成功,剩余令牌数:-1] |
| 98 | + // 避免Redis故障导致无法申请到令牌,所有请求直接挂了 |
| 99 | + return Flux.just(Arrays.asList(1L, -1L)); |
| 100 | + // 将返回的Flux<List<Long>>类型转换成 Mono<List<Long>>类型 |
| 101 | + // 顺带回顾下Flux和Mono的基础知识: |
| 102 | + // 1.在开发过程中,不再返回简单的POJO对象,而必须返回其他内容,在结果可用的时候返回。 |
| 103 | + // 2.在响应式流的规范中,被称为发布者(Publisher)。发布者有一个subscribe()方法,该方法允许使用者在POJO可用时获取它。 |
| 104 | + // 3.发布者可以通过以下两种形式返回结果: |
| 105 | + // - Flux返回0个或多个结果,可能是无限个 |
| 106 | + // - Mono返回0个或1个结果 |
| 107 | + // Redis执行lua脚本只会返回一次List<Long>,失败时填充默认值也是一次,所以转成Mono |
| 108 | + }).reduce(new ArrayList<Long>(), (longs, l) -> { |
| 109 | + longs.addAll(l); |
| 110 | + return longs; |
| 111 | + }).map(results -> { |
| 112 | + // 取出Lua脚本的运行结果:是否获取令牌成功(1-成功,0-失败), 剩余令牌数 |
| 113 | + // 5.塞到response的headers里返回,这里result里一共塞了四个参数: |
| 114 | + // 剩余令牌数、每秒补充的令牌数、令牌桶容量、每个请求申请的令牌数 |
| 115 | + boolean allowed = results.get(0) == 1L; |
| 116 | + Long tokensLeft = results.get(1); |
| 117 | + Response response = new Response(allowed, |
| 118 | + getHeaders(routeConfig, tokensLeft)); |
| 119 | + return response; |
| 120 | + }); |
| 121 | + } |
| 122 | + catch (Exception e) { |
| 123 | + // 虽然redis不是天天抽风,但是万一真发生了这种事,还是留个日志告警 |
| 124 | + log.error("Error determining if user allowed from redis", e); |
| 125 | + } |
| 126 | + // 6.最后的兜底尿布,碰到任何奇葩情况导致上面的执行失败了,都给个默认返回:申请令牌成功,剩余令牌数-1 |
| 127 | + return Mono.just(new Response(true, getHeaders(routeConfig, -1L))); |
| 128 | +} |
| 129 | +``` |
| 130 | +其实代码写的挺复杂的,参数还很多。我们碰到这种代码不要慌,就先当它里面的一堆字段和小方法不存在。因为字段很多都是定义的final字段,比如一个header的名字或者跟主体逻辑并不是有很强关联的传值字段。小方法有挺多都是组装字段到某个类或者做一些处理,我们可以先关注方法名来了解下它的作用。然后只看关键方法,一些什么异常处理、打日志、读取值的都可以先忽略。重点看它的核心逻辑是在干嘛。 |
| 131 | + |
| 132 | +比如这篇里,实际代码我已经省略很多了,但它总体无外乎干了这些事: |
| 133 | + |
| 134 | +准备一个限流容器:一个令牌桶。你可以把它放redis,也可以自定义放到mysql、内存或者其它地方,这是可以自己实现的; |
| 135 | +准备一个限流key解析器:默认就是拿到request里的用户名作为限流key; |
| 136 | +准备一个limiter来实现限流逻辑:这里是redis实现,所以是根据限流规则的配置,从redis里存的令牌桶中拿令牌。 |
| 137 | +然后没了,是不是就是这么简单? |
| 138 | + |
| 139 | +学习源码,重点是不要先把自己吓死了,删繁就简的看核心逻辑,看它大概是在干啥,然后再选择性的深入细节,比如各种分支情况的处理,异常情况的处理... |
| 140 | + |
| 141 | + |
| 142 | + |
| 143 | + |
| 144 | + |
| 145 | + |
| 146 | + |
| 147 | + |
| 148 | + |
| 149 | + |
| 150 | + |
| 151 | + |
| 152 | + |
| 153 | + |
| 154 | + |
| 155 | + |
| 156 | + |
| 157 | + |
| 158 | + |
| 159 | + |
| 160 | + |
| 161 | + |
| 162 | + |
| 163 | + |
0 commit comments