写点什么

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

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

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

关注

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

修复一个BaseRecyclerViewAdapterHelper漏洞

Changing Lin

11月日更

linux重要的目录之etc

入门小站

Linux

Pulsar 在2.8升级过程中需要注意的TopicPolicy问题

Zike Yang

Apache Pulsar 11月日更

历史上最伟大的一次 Git 代码提交

沉默王二

git

Spring Boot的前世今生以及它和Spring Cloud的关系详解

Java高级开发

Java 架构 springboot SpringCloud

区块链将掀开人类的伟大时代

CECBC

在线文本对比工具

入门小站

工具

大厂算法面试之leetcode精讲7.双指针

全栈潇晨

LeetCode 算法面试

Flutter - TabController监听index

坚果

flutter 11月日更

按需引入ant-design-vue组件

石云升

Vue 11月日更

阿里大牛最新公开压轴的“Redis深度笔记”,GitHub已标星81.6K

热爱java的分享家

Java 架构 面试 程序人生 编程语言

Linux 中的 15 个强大的 firewall-cmd 命令,牛牛牛!

Ethereal

Linux 运维 防火墙 Firewalld防火墙

人脸检测实战进阶:使用 OpenCV 进行活体检测

AI浩

看完了阿里大牛的Leetcode刷题笔记, 我成功拿到了字节跳动的offer

热爱java的分享家

Java 面试 算法 LeetCode 经验分享

对元宇宙 我们期待什么?

CECBC

.NET6新东西--插值字符串优化

喵叔

11月日更

路由器或交换机配置中line vty 0 4到底是什么意思?

Ethereal

交换机 路由器 网络技术

听说版本会说话,你相信吗?

程序那些事

版本控制 程序那些事 版本管理 版本升级 11月日更

【高并发】浅谈AQS中的ReentrantLock、ReentrantReadWriteLock、StampedLock与Condition

冰河

Java 并发编程 多线程 高并发 异步编程

Go语言学习查缺补漏ing Day7

Regan Yue

Go 语言 11月日更

Prometheus Exporter (十一)Kafka Exporter

耳东@Erdong

kafka Prometheus exporter 11月日更

【死磕Java并发】-----J.U.C之重入锁:ReentrantLock

chenssy

11月日更 死磕 Java 死磕 Java 并发

终于有腾讯架构师把困扰我多年的《计算机网络原理》全部讲明白了

热爱java的分享家

Java 面试 编程语言 网络协议 经验分享

数字人民币的基础:共识与信任!

CECBC

低代码实现探索(二)低代码中的数据

零道云-混合式低代码平台

低代码

字节大牛把算法常见面试:哈希、链表、队列、递归全部总结出来了

热爱java的分享家

Java 面试 程序人生 编程语言 经验分享

面试不慌,拿这70张思维导图,怒怼面试官

奔着腾讯去

c++ golang 数据结构 思维导图 TCP/IP

简述以太坊P2P网络之UDP

devpoint

区块链 以太坊 udp 11月日更

25 K8S之Endpoint对象

穿过生命散发芬芳

k8s 11月日更

不是吧,都2021年了你别说你还不会Spring MVC基本应用

热爱java的分享家

Java 架构 程序人生 编程语言 经验分享

什么是IS-IS中间系统到中间系统?网工、运维必看!

Ethereal

网络技术

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