QCon 全球软件开发大会倒计时2个月!讲师已确认60%+ >>> 了解详情
写点什么

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

  • 2019-08-16
  • 本文字数:4712 字

    阅读完需:约 15 分钟

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

开头:本文由 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


2019-08-16 08:0040337
用户头像
dbaplus社群 数据连接未来

发布了 176 篇内容, 共 69.9 次阅读, 收获喜欢 598 次。

关注

评论 9 条评论

发布
用户头像
集群中不同机器的时间戳有差异,会不会有问题?
2023-03-30 16:12 · 广东
回复
用户头像
redis.call('pexpire',KEYS[1],math.ceil(reverse_time*(max_token-current_token)+(current_time-last_time)))
return result
剩余存活时间直接用剩余令牌生成时间不行吗,为什么要加(current_time-last_time)))这段
2021-05-26 00:39
回复
用户头像
经过测试发现current_token能减为负数,不是说redis的lua脚本是原子性的吗,有哪位大神能解答一下
2021-03-04 09:38
回复
用户头像
lua脚本中1000/token_rate是什么意思
2019-11-11 15:45
回复
不好意思当时理解错了,入参单位是秒,lua脚本单位是毫秒,故需要转换下。
2019-11-11 15:59
回复
带入下面的公式,local reverse_token = math.floor(past_time/reverse_time),懂了吗
2020-08-02 19:49
回复
没错,这个是配合后面公式使用的 local reverse_token = math.floor(past_time/reverse_time)
2020-08-02 19:51
回复
查看更多回复
没有更多了
发现更多内容

盲盒app开发源码搭建小程序app

2021年10月云短信报告出炉,腾讯云蝉联冠军

博睿数据

直播预告:云智能分支(Cloud Smart Branch)网络运维研究

阿里巴巴 阿里云

助力政企自动化自然生长,华为WeAutomate RPA是怎么做到的?

盲盒开发盲盒小程序源码搭建app

模块四作业

panxiaochun

架构实战营

openGauss以科技加速走向高效,低碳环保之路

openGauss

OpenInfra基金会年度用户调查报告已出炉!OpenInfra软件应用需求激增

盲盒一番赏APP开发源码搭建

一文搞定前端错误捕获和上报

GrowingIO技术专栏

Web js 数据采集 错误处理

【网络安全】这份近200页应急响应文档,帮助了不少安全逆子

网络安全学海

网络安全 信息安全 渗透测试 WEB安全 安全漏洞

腾讯WeTest即将亮相MTSC2021中国互联网测试开发大会

WeTest

openGauss社区理事会正式成立!3大运营商、7大头部银行等18家理事单位加入

openGauss

华为发布5G专网集成与边缘DC解决方案2.0,奏响5G toB大合唱

Windows音频引擎架构汇总

grolier

Windows 音频引擎

openGauss内存管理初探

openGauss

虚拟办公、虚拟展会、虚拟偶像,RTE+XR 还能做什么?

声网

人工智能 vr Metaverse

用户任务三步法:教你读懂用户

石云升

11月日更 产品创新

Android C++系列:Linux文件IO操作(二)

轻口味

c++ android jni 11月日更

直播预告:云智能分支(Cloud Smart Branch)方案介绍和演示

阿里巴巴 阿里云

🎅Less快速学习(上)🎅

空城机

CSS 大前端 less 11月日更

HAProxy适配openGauss使用指导书

openGauss

patroni For openGauss高可用方案基本原理

openGauss

进化政企智能化,华为WeAutomate 3.0重磅发布

盲盒开发盲盒源码搭建系统开发

盲盒开发蛋趣芒趣一番赏源码搭建

盲盒app开发小程序源码定制

自定义View:ViewGroup与View的事件传递

Changing Lin

11月日更

DB4AI:使能数据库原生AI计算,助力数据湖场景业务成功

openGauss

直播预告:智能云分支(Cloud Smart Branch)场景和解决方案介绍

阿里巴巴 阿里云

OpenMLDB 11 月份社区会议纪要

第四范式开发者社区

第四范式 开源社区 OpenMLDB 机器学习数据库

分布式服务限流实战,已经为你排好坑了_技术管理_dbaplus社群_InfoQ精选文章