写点什么

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

2019 年 8 月 16 日

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

开头:本文由 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 年 8 月 16 日 08:0022732
用户头像
dbaplus社群 数据连接未来

发布了 162 篇内容, 共 43.5 次阅读, 收获喜欢 450 次。

关注

评论 8 条评论

发布
用户头像
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
回复
查看更多回复
没有更多了
发现更多内容

拆分电商系统为微服务

Vincent

架构训练营

10大流行软件测试工具

百度开发者中心

测试工具

极狐(GitLab)开课了!实践进阶五步走,助你成为DevOps专家

极狐GitLab

DevOps认证

【案例】星环科技助力华夏基金大数据平台建设

星环科技

【FlinkSQL】Flink SQL Query(三)- Join

Alex🐒

flink 翻译 FlinkSQL flink1.13

从零开始学习3D可视化之场景层级(2)

森友小锘

前端 可视化 数字孪生

50道大厂经典Spring面试题,你能答出来几题?

Java架构师

Java spring 面试 springmvc

百分点数据科学实验室:烟草行业市场信息采集数据质量评估体系研究探索

百分点大数据团队

Boss直聘转发超90W次,Java面试突击手册 火遍全网,却遭封杀

Java架构师迁哥

Java从业者如果不懂这些,面试80%都会挂在这些核心知识上面

程序员改bug

Java spring 程序员 面试

股价预测的基本思路(1)

Qien Z.

6月日更 量化投资 股价预测

限量!阿里首发“微服务容器化参考指南”,差距不止一点点!

程序员小毕

Java 程序员 面试 微服务 容器化

推理综艺的正确打开方式!爱奇艺玩转智能技术,“互动+内容”引爆迷综季

爱奇艺技术产品团队

综艺节目 智能 影视制作

动手实践,Linux安装php-vld全过程实录

架构精进之路

插件 6 月日更 笔记分享

这款百度前端图片合成工具库开源啦!

百度开发者中心

百度 开源工具

WWDC21: Swift 5.5 新特性解读

阿里巴巴淘系技术

swift WWDC21

这套 Github 上 85K+star 力扣刷题笔记,可以帮你搞定 80% 以上的 算法 面试

神奇小汤圆

Java 程序员 架构 面试 算法

凭借阿里技术官最新版Java核心开发笔记,已斩获阿里offer

Crud的程序员

Java 架构 编程语言 后端开发

百分点科技助力中国环境监测总站“生态环境质量会商平台”上线

百分点大数据团队

阿里大佬离职带出内网专属“高并发系统设计”学习笔记

Java架构师迁哥

一问Kafka就心慌?我却凭着这份《Kafka源码实战》碾压面试官!

不秃顶的Java程序员

Java kafka 程序员 架构 中间件

ipfs矿机一天收益如何?ipfs挖矿如何做到日入2000以上?

投资矿机v:IPFS1234

ipfs矿机一天收益如何 ipfs挖矿如何日入2000以上

全面互联网时代背景下,一个好的Java程序员需要掌握哪些核心技术

Crud的程序员

Java 程序员 编程语言

教学相长,物联网赋能教育数字化!

IoT云工坊

人工智能 物联网 智慧校园 智慧教室 智慧操场

一图读懂丨索信达灵枢如何助力金融机构提升模型管理效能

索信达控股

金融科技 监管平台 大数据平台 模型开发 数据管理平台

Vue3.0 组合式 API 分析与实践

百度开发者中心

开发者

又到一年“粽子节”,快来测测你包的粽子颜值几分

华为云开发者社区

端午节 华为云 modelarts 粽子

高并发场景创建JedisPool有哪些注意事项?

BUG侦探

并发 Jedis commons-pool

花了60天的时间肝出了这些spring,jvm,并发编程等学习笔记,春暖花开再战大厂!

Java架构师迁哥

亮相智源大会,字节跳动自研同传系统的技术实现

字节跳动技术团队

渣硕春招首站告捷,仅凭这数套的Java刷题PDF,便成功“混进”腾讯T3

不秃顶的Java程序员

Java 程序员 面试 跳槽 春招

Leader修炼指“北”:管理路上的大小Boss

Leader修炼指“北”:管理路上的大小Boss

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