分布式服务限流实战,已经为你排好坑了

开头:本文由 dbaplus 社群授权转载

一、限流的作用

由于 API 接口无法控制调用方的行为,因此当遇到瞬时请求量激增时,会导致接口占用过多服务器资源,使得其他请求响应速度降低或是超时,更有甚者可能导致服务器宕机。

限流(Ratelimiting)指对应用服务的请求进行限制,例如某一接口的请求限制为 100 个每秒,对超过限制的请求则进行快速失败或丢弃。

限流可以应对:

  • 热点业务带来的突发请求;

  • 调用方 bug 导致的突发请求;

  • 恶意攻击请求。

因此,对于公开的接口最好采取限流措施。

二、为什么要分布式限流

当应用为单点应用时,只要应用进行了限流,那么应用所依赖的各种服务也都得到了保护。

但线上业务出于各种原因考虑,多是分布式系统,单节点的限流仅能保护自身节点,但无法保护应用依赖的各种服务,并且在进行节点扩容、缩容时也无法准确控制整个服务的请求限制。

而如果实现了分布式限流,那么就可以方便地控制整个服务集群的请求限制,且由于整个集群的请求数量得到了限制,因此服务依赖的各种资源也得到了限流的保护。

三、限流的算法

实现限流有很多办法,在程序中时通常是根据每秒处理的事务数(Transactionpersecond)来衡量接口的流量。

本文介绍几种最常用的限流算法:

  • 固定窗口计数器;

  • 滑动窗口计数器;

  • 漏桶;

  • 令牌桶。

1、固定窗口计数器算法

固定窗口计数器算法概念如下:

  • 将时间划分为多个窗口;

  • 在每个窗口内每有一次请求就将计数器加一;

  • 如果计数器超过了限制数量,则本窗口内所有的请求都被丢弃当时间到达下一个窗口时,计数器重置。

固定窗口计数器是最为简单的算法,但这个算法有时会让通过请求量允许为限制的两倍。考虑如下情况:限制 1 秒内最多通过 5 个请求,在第一个窗口的最后半秒内通过了 5 个请求,第二个窗口的前半秒内又通过了 5 个请求。这样看来就是在 1 秒内通过了 10 个请求。

2、滑动窗口计数器算法

滑动窗口计数器算法概念如下:

  • 将时间划分为多个区间;

  • 在每个区间内每有一次请求就将计数器加一维持一个时间窗口,占据多个区间;

  • 每经过一个区间的时间,则抛弃最老的一个区间,并纳入最新的一个区间;

  • 如果当前窗口内区间的请求计数总和超过了限制数量,则本窗口内所有的请求都被丢弃。

滑动窗口计数器是通过将窗口再细分,并且按照时间"滑动",这种算法避免了固定窗口计数器带来的双倍突发请求,但时间区间的精度越高,算法所需的空间容量就越大。

3、漏桶算法

漏桶算法概念如下:

  • 将每个请求视作"水滴"放入"漏桶"进行存储;

  • “漏桶"以固定速率向外"漏"出请求来执行如果"漏桶"空了则停止"漏水”;

  • 如果"漏桶"满了则多余的"水滴"会被直接丢弃。

漏桶算法多使用队列实现,服务的请求会存到队列中,服务的提供方则按照固定的速率从队列中取出请求并执行,过多的请求则放在队列中排队或直接拒绝。

漏桶算法的缺陷也很明显,当短时间内有大量的突发请求时,即便此时服务器没有任何负载,每个请求也都得在队列中等待一段时间才能被响应。

4、令牌桶算法

令牌桶算法概念如下:

  • 令牌以固定速率生成;

  • 生成的令牌放入令牌桶中存放,如果令牌桶满了则多余的令牌会直接丢弃,当请求到达时,会尝试从令牌桶中取令牌,取到了令牌的请求可以执行;

  • 如果桶空了,那么尝试取令牌的请求会被直接丢弃。

令牌桶算法既能够将所有的请求平均分布到时间区间内,又能接受服务器能够承受范围内的突发请求,因此是目前使用较为广泛的一种限流算法。

四、代码实现

作为如此重要的功能,在 Java 中自然有很多实现限流的类库,例如 Google 的开源项目 guava 提供了 RateLimiter 类,实现了单点的令牌桶限流。

而分布式限流常用的则有 Hystrix、resilience4j、Sentinel 等框架,但这些框架都需引入第三方的类库,对于国企等一些保守的企业,引入外部类库都需要经过层层审批,较为麻烦。

分布式限流本质上是一个集群并发问题,而 Redis 作为一个应用广泛的中间件,又拥有单进程单线程的特性,天然可以解决分布式集群的并发问题。本文简单介绍一个通过 Redis 实现单次请求判断限流的功能。

1、脚本编写

经过上面的对比,最适合的限流算法就是令牌桶算法。而为实现限流算法,需要反复调用 Redis 查询与计算,一次限流判断需要多次请求较为耗时。因此我们采用编写 Lua 脚本运行的方式,将运算过程放在 Redis 端,使得对 Redis 进行一次请求就能完成限流的判断。

令牌桶算法需要在 Redis 中存储桶的大小、当前令牌数量,并且实现每隔一段时间添加新的令牌。最简单的办法当然是每隔一段时间请求一次 Redis,将存储的令牌数量递增。

但实际上我们可以通过对限流两次请求之间的时间和令牌添加速度来计算得出上次请求之后到本次请求时,令牌桶应添加的令牌数量。因此我们在 Redis 中只需要存储上次请求的时间和令牌桶中的令牌数量,而桶的大小和令牌的添加速度可以通过参数传入实现动态修改。

由于第一次运行脚本时默认令牌桶是满的,因此可以将数据的过期时间设置为令牌桶恢复到满所需的时间,及时释放资源。

编写完成的 Lua 脚本如下:

local ratelimit_info = redis.pcall('HMGET',KEYS[1],'last_time','current_token')local last_time = ratelimit_info[1]local current_token = tonumber(ratelimit_info[2])local max_token = tonumber(ARGV[1])local token_rate = tonumber(ARGV[2])local current_time = tonumber(ARGV[3])local reverse_time = 1000/token_rateif current_token == nil then  current_token = max_token  last_time = current_timeelse  local past_time = current_time-last_time  local reverse_token = math.floor(past_time/reverse_time)  current_token = current_token+reverse_token  last_time = reverse_time*reverse_token+last_time  if current_token>max_token then    current_token = max_token  endendlocal result = 0if(current_token>0) then  result = 1  current_token = current_token-1endredis.call('HMSET',KEYS[1],'last_time',last_time,'current_token',current_token)redis.call('pexpire',KEYS[1],math.ceil(reverse_time*(max_token-current_token)+(current_time-last_time)))return result
复制代码

2、执行限流

这里使用 SpringDataRedis 来进行 Redis 脚本的调用。

编写 Redis 脚本类:

public class RedisReteLimitScript implements RedisScript<String> {   private static final String SCRIPT =      "local ratelimit_info = redis.pcall('HMGET',KEYS[1],'last_time','current_token') local last_time = ratelimit_info[1] local current_token = tonumber(ratelimit_info[2]) local max_token = tonumber(ARGV[1]) local token_rate = tonumber(ARGV[2]) local current_time = tonumber(ARGV[3]) local reverse_time = 1000/token_rate if current_token == nil then current_token = max_token last_time = current_time else local past_time = current_time-last_time; local reverse_token = math.floor(past_time/reverse_time) current_token = current_token+reverse_token; last_time = reverse_time*reverse_token+last_time if current_token>max_token then current_token = max_token end end local result = '0' if(current_token>0) then result = '1' current_token = current_token-1 end redis.call('HMSET',KEYS[1],'last_time',last_time,'current_token',current_toke  redis.call('pexpire',KEYS[1],math.ceil(reverse_time*(max_tokencurrent_token)+(current_time-last_time))) return result";  @Override   public String getSha1() {    return DigestUtils.sha1Hex(SCRIPT);  }  @Override   public Class<String> getResultType() {     return String.class;  }  @Override   public String getScriptAsString() {     return SCRIPT;  }}
复制代码

通过 RedisTemplate 对象执行脚本:

public boolean rateLimit(String key, int max, int rate) {    List<String> keyList = new ArrayList<>(1);    keyList.add(key);    return "1".equals(stringRedisTemplate        .execute(new RedisReteLimitScript(), keyList, Integer.toString(max), Integer.toString(rate),            Long.toString(System.currentTimeMillis())));  }
复制代码

rateLimit 方法传入的 key 为限流接口的 ID,max 为令牌桶的最大大小,rate 为每秒钟恢复的令牌数量,返回的 boolean 即为此次请求是否通过了限流。为了测试 Redis 脚本限流是否可以正常工作,我们编写一个单元测试进行测试看看。

@Autowired  private RedisManager redisManager;  @Test  public void rateLimitTest() throws InterruptedException {    String key = "test_rateLimit_key";    int max = 10;  //令牌桶大小    int rate = 10; //令牌每秒恢复速度    AtomicInteger successCount = new AtomicInteger(0);    Executor executor = Executors.newFixedThreadPool(10);    CountDownLatch countDownLatch = new CountDownLatch(30);    for (int i = 0; i < 30; i++) {      executor.execute(() -> {        boolean isAllow = redisManager.rateLimit(key, max, rate);        if (isAllow) {          successCount.addAndGet(1);        }        log.info(Boolean.toString(isAllow));        countDownLatch.countDown();      });    }    countDownLatch.await();    log.info("请求成功{}次", successCount.get());  }
复制代码

设置令牌桶大小为 10,令牌桶每秒恢复 10 个,启动 10 个线程在短时间内进行 30 次请求,并输出每次限流查询的结果。日志输出:

[19:12:50,283]true[19:12:50,284]true[19:12:50,284]true[19:12:50,291]true[19:12:50,291]true[19:12:50,291]true[19:12:50,297]true[19:12:50,297]true[19:12:50,298]true[19:12:50,305]true[19:12:50,305]false[19:12:50,305]true[19:12:50,312]false[19:12:50,312]false[19:12:50,312]false[19:12:50,319]false[19:12:50,319]false[19:12:50,319]false[19:12:50,325]false[19:12:50,325]false[19:12:50,326]false[19:12:50,380]false[19:12:50,380]false[19:12:50,380]false[19:12:50,387]false[19:12:50,387]false[19:12:50,387]false[19:12:50,392]false[19:12:50,392]false[19:12:50,392]false[19:12:50,393]请求成功11次
复制代码

可以看到,在 0.1 秒内请求的 30 次请求中,除了初始的 10 个令牌以及随时间恢复的 1 个令牌外,剩下 19 个没有取得令牌的请求均返回了 false,限流脚本正确的将超过限制的请求给判断出来了,业务中此时就可以直接返回系统繁忙或接口请求太过频繁等提示。

3、开发中遇到的问题

1)Lua 变量格式

Lua 中的 String 和 Number 需要通过 tonumber()和 tostring()进行转换。

2)Redis 入参

Redis 的 pexpire 等命令不支持小数,但 Lua 的 Number 类型可以存放小数,因此 Number 类型传递给 Redis 时最好通过 math.ceil()等方式转换以避免存在小数导致命令失败。

3)Time 命令

由于 Redis 在集群下是通过复制脚本及参数到所有节点上,因此无法在具有不确定性的命令后面执行写入命令,因此只能请求时传入时间而无法使用 Redis 的 Time 命令获取时间。

3.2 版本之后的 Redis 脚本支持 redis.replicate_commands(),可以改为使用 Time 命令获取当前时间。

4)潜在的隐患

由于此 Lua 脚本是通过请求时传入的时间做计算,因此务必保证分布式节点上获取的时间同步,如果时间不同步会导致限流无法正常运作。

作者介绍

段然,甜橙金融创新中心开发工程师,目前负责公司平台化建设及媒介能力聚合。

原文链接

https://mp.weixin.qq.com/s/qb3rg_ZpcMcvyaIRsvc1fw

(0)

相关推荐

  • 高并发优雅的做限流

    技术分析 如果你比较关注现在的技术形式,就会知道微服务现在火的一塌糊涂,当然,事物都有两面性,微服务也不是解决技术,架构等问题的万能钥匙.如果服务化带来的利大于弊,菜菜还是推荐将系统服务化.随着服务化 ...

  • 何谓架构?

    前言:在这个知识分享的爆炸时代,鉴于java生态的完整和繁荣,各种框架.中间件和工具包供我们使用.连新培训出来的人都知道ssm,微服务.集群.多线程.队列.高并发等技术,技术的间隔性正变得越来越小,仿 ...

  • 【121期】网关Gateway:限流10万QPS、跨域、过滤器、令牌桶算法

    2020年百日百更原创Java最全面试题库之往期回顾 [000期]Java最全面试题库思维导图 [020期]JavaSE系列面试题汇总(共18篇) [028期]JavaWeb系列面试题汇总(共10篇) ...

  • SpringCloud微服务网关做边缘服务限流方案

    优质文章,第一时间送达 66套java从入门到精通实战课程分享 在高并发的系统中,往往需要在系统中做限流,一方面是为了防止大量的请求使服务器过载,导致服务不可用,另一方面是为了防止网络攻击. 常见的限 ...

  • 服务熔断、隔离、降级、限流 介绍

    服务降级:在高并发的情况下,防止用户一直等待,使用服务降级方式进行处理(返回友好的提示给客户端,fallback回调方法).当服务不可用的时候(正在等待的时候.网络延迟.响应时间过长),客户端会处于一 ...

  • 常用4种限流算法介绍及比较

    常用4种限流算法介绍及比较

  • 分布式与微服务

    0x01:分布式 CAP C:consistency 一致性 分布式系统能够同时访问同一份数据副本 A:availability 可用性 非故障节点能够在合理时间内获得合理的结果 P:Partitio ...

  • 6000多字 | 秒杀系统设计注意点【理论】

    在秒杀的场景中,对于系统的要求其实就三个字:快.准.稳. 本文主要内容: 五个架构原则 数据要尽量少 首先是指用户请求的数据能少就少.请求的数据包括上传给系统的数据和系统返回给用户的数据(通常就是网页 ...

  • 通过Dapr实现一个简单的基于.net的微服务电商系统(七)——一步一步教你如何撸Dapr之服务限流

    曾宇平 dotNET跨平台 今天 在一般的互联网应用中限流是一个比较常见的场景,也有很多常见的方式可以实现对应用的限流比如通过令牌桶通过滑动窗口等等方式都可以实现,也可以在整个请求流程中进行限流比如客 ...

  • Spring Cloud Alibaba | Sentinel: 服务限流高级篇

    Springboot: 2.1.6.RELEASE SpringCloud: Greenwich.SR1 如无特殊说明,本系列文章全采用以上版本 目录 Spring Cloud Alibaba | S ...

  • 编程语言Spring Cloud Gateway 限流实战,终于有人写清楚了!

    编程语言Spring Cloud Gateway 限流实战,终于有人写清楚了! 话说在 Spring Cloud Gateway 问世之前,Spring Cloud 的微服务世界里,网关一定非 Net ...

  • 10张图带你彻底搞懂限流、熔断、服务降级

    在分布式系统中,如果某个服务节点发生故障或者网络发生异常,都有可能导致调用方被阻塞等待,如果超时时间设置很长,调用方资源很可能被耗尽.这又导致了调用方的上游系统发生资源耗尽的情况,最终导致系统雪崩. ...

  • 一文搞定算法和架构,微服务接口限流不用愁!

    目录1.服务限流的概念2.单服务节点限流2.1.漏桶算法2.2.令牌桶算法3.服务集群限流4.限流的难点及注意事项5.作者简介线上系统遇到的一大风险就是流量的暴涨暴跌,尤其是在这个全民上网的时代,一条 ...

  • java版gRPC实战之三:服务端流

    java版gRPC实战之三:服务端流 欢迎访问我的GitHub https://github.com/zq2599/blog_demos 内容:所有原创文章分类汇总及配套源码,涉及Java.Docke ...

  • 微服务和API网关限流熔断实现关键逻辑思路

    作者:人月神话,新浪博客同名 简介:多年SOA规划建设,私有云PaaS平台架构设计经验,长期从事一线项目实践 今天准备谈下微服务架构和API网关中的限流熔断,当前可以看到对于Spring Cloud框 ...