当前位置: 首页 > 科技观察

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

时间:2023-03-14 18:55:03 科技观察

1。限流的作用由于API接口无法控制调用者的行为,当遇到请求量突然激增时,该接口会占用过多的服务器资源,导致其他请求变慢或超时,甚至可能导致服务器宕机。限速是指限制对应用服务的请求。比如某个接口的请求被限制为每秒100个,超过限制的请求很快就会失败或者被丢弃。限流可以应对:热点服务引起的突发请求;由调用者错误引起的突然请求;恶意攻击请求。因此,公共接口应采取限流措施。2、为什么要分布式限流当应用是单点应用时,只要应用进行了限流,应用所依赖的各种服务也都得到了保护。但是由于线上业务的种种原因,大部分都是分布式系统。单个节点的限流只能保护自己的节点,不能保护应用所依赖的各种服务,扩容或缩容节点时也不能准确。控制整个服务的请求限制。如果实现了分布式限流,那么整个服务集群的请求限额就可以很容易的得到控制,而且由于整个集群的请求数量是有限的,所以服务所依赖的各种资源也都受到了限流的保护。3.限流算法实现限流的方法有很多种。在程序中,接口的流量通常是根据每秒处理的事务数(Transactionpersecond)来衡量的。本文介绍几种常用的限流算法:固定窗口计数器;推拉窗柜台;漏桶;令牌桶。1.固定窗口计数器算法固定窗口计数器算法的概念如下:将时间划分为多个窗口;每次在每个窗口中有一个请求时,将计数器加一;如果计数器超过限制次数,则丢弃该窗口中的所有请求,并在时间到达下一个窗口时重置计数器。固定窗口计数器是最简单的算法,但这种算法有时会使允许通过的请求量超过限制的两倍。考虑以下情况:1秒内最多传递5个请求,第一个窗口的后半秒传递了5个请求,第二个窗口的前半秒又传递了5个请求。所以好像1秒传递了10个请求。2.滑动窗口计数器算法滑动窗口计数器算法的概念如下:将时间分成多个区间;每个区间每有一个请求,计数器加1,维持一??个时间窗,占用多个区间;时间,丢弃最旧的间隔并包括最新的间隔;如果当前窗口内所有区间的请求计数总和超过限制,则丢弃该窗口内的所有请求。滑动窗口计数器对窗口进行细分,根据时间“滑动”。该算法避免了固定窗口计数器带来的双突发请求,但时间间隔精度越高,算法需要的空间容量也越大。大。3.漏桶算法漏桶算法的概念是:把每个请求都当成“水滴”,放入“漏桶”中存储;“漏水桶”“泄漏”以固定速率请求执行。如果它是空的,“泄漏”就会停止;“漏水桶”满了,多余的“水滴”直接丢弃。漏桶算法主要使用队列来实现。服务请求会存储在队列中,服务提供者会从队列中取出请求,并以固定的速率执行。过多的请求会在队列中排队或直接拒绝。漏桶算法的缺陷也很明显。当短时间内有大量突发请求时,即使此时服务器没有任何负载,每个请求也必须在队列中等待一段时间才能得到响应。4.令牌桶算法令牌桶算法的概念如下:令牌以固定的速率产生;生成的令牌存储在令牌桶中。如果令牌桶满了,多余的令牌直接丢弃。当请求到达时,会尝试从令牌桶中获取令牌,拿到令牌的请求才能执行;如果桶为空,则试图获取令牌的请求将被直接丢弃。令牌桶算法不仅可以在时间间隔内均匀分配所有请求,而且可以在服务器能够承受的范围内接受突发请求,因此是目前应用广泛的限流算法。4.代码实现这么重要的功能,Java中自然有很多实现限速的类库。例如Google的开源项目guava提供了RateLimiter类,实现了单点令牌桶限速。对于分布式限流,常用的有Hystrix、resilience4j、Sentinel等框架,但是这些框架需要引入第三方类库。对于国企等一些保守的企业来说,引入外部类库需要层层审批,比较麻烦。.分布式限流本质上是集群并发问题,而Redis作为应用广泛的中间件,具有单进程单线程的特点,自然可以解决分布式集群的并发问题。本文简单介绍一个通过Redis实现单次请求判断限流的功能。1.脚本编写经过以上比较,最适合的限流算法是令牌桶算法。为了实现限流算法,需要反复调用Redis查询和计算,多次请求一个限流判断非常耗时。因此,我们采用编写Lua脚本运行的方式,将计算过程放在Redis这边,这样对Redis的一个请求就可以完成限流判断。令牌桶算法需要在Redis中存储桶的大小,当前的令牌数量,实现每隔一段时间添加新的令牌。最简单的方法当然是每隔一段时间请求Redis来增加存储的令牌数量。但实际上,我们可以通过计算两次限流请求之间的时间和令牌添加速度来计算从上一次请求到本次请求应该添加到令牌桶中的令牌数量。因此,我们只需要在Redis中存储上次请求的时间和令牌桶中令牌的数量,桶的大小和添加令牌的速度可以通过传入参数动态修改。由于脚本第一次运行时默认令牌桶已满,您可以将数据过期时间设置为令牌桶恢复满所需的时间,及时释放资源。Lua特性的默认设置:localratelimit_info=redis.pcall('HMGET',KEYS[1],'last_time','current_token')locallast_time=ratelit_info[1]localcurrent_token=tonumber(ratelimit_info[2])localmax_token=tonumber(ARGV).[1])localtoken_rate=tonumber(ARGV[2])localcurrent_time=tonumber(ARGV[3])localreverse_time=1000/token_rateifcurrent_token==nilthencurrent_token=max_tokenlast_time=current_timeelcalpast_time=current_time-last_timecalreverse_token=math.floor(过去时间/reverse_current_time)current_token.token+reverse_tokenlast_time=reverse_time*reverse_token+last_timeifcurrent_token>max_tokenthencurrent_token=max_tokenendlocalresult=0if(current_token>0)thenresult=1current_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)))returnresult2编写Redis脚本类:publicclassRedisReteLimitScriptimplementsRedisScript{privatestaticfinalStringSCRIPT="localratelimit_info=redis.pcall('HMGET',KEYS[1],'last_time','current_token')locallast_time=ratelimit_info[1]localcurrent_token=tonumber(ratelimit_info[2])localmax_token=tonumber(ARGV[1])localtoken_rate=tonumber(ARGV[2])localcurrent_time=tonumber(ARGV[3])localreverse_time=1000/token_rateifcurrent_token==nilthencurrent_token=max_tokenlast_time=current_timeelselocalpast_time=current_time-last_time;localreverse_token=math.floor(过去时间/反向时间)current_token=current_token+reverse_token;last_time=reverse_time*reverse_token+last_timeifcurrent_token>max_tokenthencurrent_token=max_tokenendendlocalresult='0'if(current_token>0)thenresult='1'current_token=current_token-1endredis.call('HMSET',KEYS[1],'last_time',last_time,'current_token',current_tokeredis.call('pexpire',KEYS[1],math.ceil(reverse_time*(max_tokencurrent_token)+(current_time-last_time)))returnresult";@OverridepublicStringgetSha1(){returnDigestUtils.sha1Hex(SCRIPT);}@OverridepublicClassgetResultType(){returnString.class;}@OverridepublicStringgetScriptAsString(){returnSCRIPT;}}通过RedisTemplate对象:publicbooleanrateLimit(Stringkey,intmax,intrate){ListkeyList=newArrayList<>(1);keyList.add(key);return"1".equals(stringRedisTemplate.execute(newRedisReteLimitScript(),keyList,Integer.toString(max),Integer.toString(rate),Long.toString(System.currentTimeMillis())));}rateLimit方法传入的key是限流接口的ID,max是最大size令牌桶的数量,rate是秒恢复的令牌数,返回的boolean是请求是否过限流。为了测试Redis脚本限流是否能正常工作,我们写了一个单元测试测试它。@AutowiredprivateRedisManagerredisManager;@TestpublicvoidrateLimitTest()throwsInterruptedException{Stringkey="test_rateLimit_key";intmax=10;//令牌桶大小inrate=10;//每秒令牌恢复速度AtomicIntegersuccessCount=newAtomicInteger(0);Executorexecutor=Executors.newFixedThreadPool(10);CountDownLatchcountDownLatch=newCountDownLatch(30);for(inti=0;i<30;i++){executor.execute(()->{booleanisAllow=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]真[19:12:50,297]真[19:12:50,297]真[19:12:50,298]真[19:12:50,305]真[19:12:50,305]假[19:12:50,305]真[19:12:50,312]假[19:12:50,312]假[19:12:50,312]假[19:12:50,319]假[19:12:50,319]假[19:12:50,319]假[19:12:50,325]假[19:12:50,325]假[19:12:50,326]假[19:12:50,380]假[19:12:50,380]假[19:12:50,380]假[19:12:50,387]假[19:12:50,387]假[19:12:50,387]假[19:12:50,392]假[19:12:50,392]假[19:12:50,392]假[19:12:50,393]请求成功11次。可以看出,在0.1秒内请求的30个请求中,除了最初的10个token和1个随时间恢复的token外,其余19个未获取到token的请求均返回False,限流脚本正确判断了超过的请求极限。这时候业务可以直接返回系统繁忙或者接口请求过于频繁等提示。三、开发中遇到的问题1)Lua中的Lua变量格式String和Number需要通过tonumber()和tostring()进行转换。2)Redis的入参Redis的pexpire等命令不支持小数,但是Lua的Number类型可以存储小数,所以向Redis传递Number类型时,最好使用math。3)时间命令由于Redis会将脚本和参数复制到集群下的所有节点,无法在不确定的命令后执行write命令,所以请求时只能传入时间,不能使用Redis的Time命令获取时间。3.2版本后的Redis脚本支持redis.replicate_commands(),可以使用Time命令获取当前时间。4)潜在隐患由于这个Lua脚本是按照请求时传入的时间计算的,所以需要保证在分布式节点上获取的时间是同步的。如果时间不同步,限流将无法正常工作。笔者介绍段冉,甜橙金融创新中心开发工程师,目前负责公司平台建设和媒体能力聚合。