写点什么

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

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:0025421
用户头像
dbaplus社群 数据连接未来

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

关注

评论 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
回复
查看更多回复
没有更多了
发现更多内容

RS485通信如何设计EMC电路?

不脱发的程序猿

电路设计 通信总线 RS485 EMC设计 通信抗干扰

“区块链指导意见”重磅发布 场景化应用昭示新蓝海

旺链科技

区块链应用 区块链供应链金融落地

nodejs学习记录

Node

为什么vacuum后表还是继续膨胀?

华为云开发者社区

索引 GaussDB 元组 VACUUM 行存表

限量!Alibaba首发“Java成长笔记”,差距不止一点点

云流

Java 编程 程序员 架构 面试

最强大的内在激励:自我承诺

石云升

激励 职场经验 管理经验 6月日更

测试开发之网络篇-IP地址

禅道项目管理

IP 协议 IP地址

EBean ORM 框架介绍-3.实体草稿功能

Barry的异想世界

jpa ORM Ebean

从五大结构体,带你掌握鸿蒙轻内核动态内存Dynamic Memory

华为云开发者社区

鸿蒙 内存管理 结构体 动态内存 Dynamic Memory

不愧是Alibaba技术官,Kafka的精髓全写这本“限量笔记”里,服了

云流

Java 架构 面试 分布式

自定义 View 功能上线,你的小程序可以更多变

蚂蚁集团移动开发平台 mPaaS

小程序 mPaaS 自定义控件

4个改变你编程技能的小技巧,建议细读

欢喜学安卓

android 程序员 面试 移动开发

从网络平台到城市平台——城市数字化的另类思考

CECBC区块链专委会

马士兵强推面试前必刷:Alibaba内部Java高级架构师380道面试题

Java架构追梦

Java 阿里巴巴 java架构 面试题总结 面试题解析

Python接口自动化之常见用例读取方法介绍

行者AI

测试 #python

网络攻防学习笔记 Day54

穿过生命散发芬芳

网络攻防 6月日更

33岁跳槽无路,濒临绝望之际受贵人指点,成功上岸阿里(Java岗)

互联网架构师小马

Java 面试 阿里 中年危机 大龄程序员

腾讯同事内推的那位Linux C/C++后端开发同学面试没过......

Linux服务器开发

Linux C/C++ Linux服务器开发 Linux后台开发 Linux网络编程

构建WEB项目的 25 个HTML建议

devpoint

html 6 月日更

Rust从0到1-自动化测试-如何编写测试

rust 自动化测试 如何编写测试 Automated Tests

4面字节跳动拿到Offer,灵魂拷问

欢喜学安卓

android 程序员 面试 移动开发

去中心化的互联网,区块链域名如何对抗在线审查

CECBC区块链专委会

一进商场就迷路?ThingJS用室内导航拯救路痴!

森友小锘

前端 可视化 程序员、 3D可视化 数字孪生

高性能计算对生命科学研究有何帮助?

北鲲云

云计算 高性能计算 生命科学 虚拟筛选

一觉醒来,发现自建的数据库被勒索了,好可怕…

华为云数据库小助手

数据库 高可用 安全性 DAS

用node写个简单的脚手架!

Node cli

“阿里爸爸”又出全新大厂面试参考指南,GitHub点赞20k仅是开始

Crud的程序员

Java 程序员 架构

windows11泄露版尝鲜体验新功能!!!

学神来啦

win10 win11 windows10 windows 11

Quick BI的可视分析之路

数智化转型俱乐部

阿里云 数据中台 数据分析 数据可视化 商业分析

CloudQuery 使用教程之《No.1 基础入门》

CloudQuery社区

数据库 程序员 dba 国产数据库 运维开发

企业想要升级生产管理系统,有哪些好用的低代码平台推荐?

优秀

低代码

MySQL 核心特性与优化

MySQL 核心特性与优化

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