8. 「Java大师」教你如何用Spring Boot轻松实现高效「限流」!

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Tair(兼容Redis),内存型 2GB
简介: 8. 「Java大师」教你如何用Spring Boot轻松实现高效「限流」!

前言: Java作为一门广泛应用于后端开发的语言,其丰富的开源生态系统与庞大的社区在不断推动Java的发展。但是,随着互联网应用的不断发展,高并发、大流量等问题开始变得越来越突出,如何保证系统的稳定性和鲁棒性成为了Java开发中不可避免的问题。本文将从Java限流的概念开始,详细讲解Java限流的原理和实现方法,并结合Spring Boot框架进行代码演示,帮助读者更好地了解如何有效地实现限流



限流简介

每个系统都有服务的上线,所以当流量超过服务极限能力时,系统可能会出现卡死、崩溃的情况,所以就有了降级和限流。限流其实就是:当高并发或者瞬时高并发时,为了保证系统的稳定性、可用性,系统以牺牲部分请求为代价或者延迟处理请求为代价,保证系统整体服务可用。

算法

令牌桶(Token Bucket)、漏桶(leaky bucket)和计数器算法是最常用的三种限流的算法。

分类

应用级 - 单机

应用级限流方式只是单应用内的请求限流,不能进行全局限流。

  1. 限流总资源数
  2. 限流总并发/连接/请求数
  3. 限流某个接口的总并发/请求数
  4. 限流某个接口的时间窗请求数
  5. 平滑限流某个接口的请求数
  6. Guava RateLimiter
分布式

我们需要分布式限流接入层限流来进行全局限流。

  1. redis+lua实现中的lua脚本
  2. 使用Nginx+Lua实现的Lua脚本
  3. 使用 OpenResty 开源的限流方案
  4. 限流框架,比如Sentinel实现降级限流熔断

方案一:令牌桶方式(Token Bucket)

令牌桶算法是网络流量整形(Traffic Shaping)和速率限制(Rate Limiting)中最常使用的一种算法。先有一个木桶,系统按照固定速度,往桶里加入Token,如果桶已经满了就不再添加。当有请求到来时,会各自拿走一个Token,取到Token 才能继续进行请求处理,没有Token 就拒绝服务。

这里如果一段时间没有请求时,桶内就会积累一些Token,下次一旦有突发流量,只要Token足够,也能一次处理,所以令牌桶算法的特点是允许突发流量

举例:Guava RateLimiter - 平滑突发限流(SmoothBursty)

Guava RateLimiter提供了令牌桶算法实现:平滑突发限流(SmoothBursty)和平滑预热限流(SmoothWarmingUp)实现。

  • Case 1
package com.example.throttle;
import com.google.common.util.concurrent.RateLimiter;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
public class GuavaLimiterTest {
    @Test
    public void bucket() {
        RateLimiter limiter = RateLimiter.create(1); // 表示桶容量为1且每秒新增1个令牌,即每隔200毫秒新增一个令牌;
        for (int i = 0; i < 10; i++) {
            System.out.println(limiter.acquire());
        }
    }
}
// 0.0
// 0.999589
// 0.994164
// 0.993863
// 0.997597

1、RateLimiter.create(1)表示桶容量为1且每秒新增1个令牌;

2、limiter.acquire()表示消费一个令牌,如果当前桶中有足够令牌则成功(返回值为0),如果桶中没有令牌则暂停一段时间,比如发令牌间隔是1秒,则等待1秒后再去消费令牌(如上测试用例返回的为0.99,差不多等待了1秒桶中才有令牌可用),这种实现将突发请求速率平均为了固定请求速率。如果结构不想等待可以采用tryAcquire立刻返回!

  • Case 2 - RateLimiter的突发情况处理:
RateLimiter limiter = RateLimiter.create(5);
System.out.println(limiter.acquire(5));
System.out.println(limiter.acquire(1));
System.out.println(limiter.acquire(1))
// 将得到类似如下的输出:
0.0
0.98745
0.183553
0.199909

limiter.acquire(5)表示桶的容量为5且每秒新增5个令牌,令牌桶算法允许一定程度的突发,所以可以一次性消费5个令牌,但接下来的limiter.acquire(1)将等待差不多1秒桶中才能有令牌,且接下来的请求也整形为固定速率了。

  • Case 3 - RateLimiter的突发情况处理:
RateLimiter limiter = RateLimiter.create(5);
System.out.println(limiter.acquire(10));
System.out.println(limiter.acquire(1));
System.out.println(limiter.acquire(1));
// 将得到类似如下的输出:
0.0
1.997428
0.192273
0.200616

同上边的例子类似,第一秒突发了10个请求,令牌桶算法也允许了这种突发(允许消费未来的令牌),但接下来的limiter.acquire(1)将等待差不多2秒桶中才能有令牌,且接下来的请求也整形为固定速率了。

  • Case 4
RateLimiter limiter = RateLimiter.create(2);
System.out.println(limiter.acquire());
Thread.sleep(2000L);
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
// 将得到类似如下的输出:
0.0
0.0
0.0
0.0
0.499876
0.495799

1、创建了一个桶容量为2且每秒新增2个令牌;

2、首先调用limiter.acquire()消费一个令牌,此时令牌桶可以满足(返回值为0);

3、然后线程暂停2秒,接下来的两个limiter.acquire()都能消费到令牌,第三个limiter.acquire()也同样消费到了令牌,到第四个时就需要等待500毫秒了。

此处可以看到我们设置的桶容量为2(即允许的突发量),这是因为SmoothBursty中有一个参数:最大突发秒数(maxBurstSeconds)默认值是1s,突发量/桶容量=速率*maxBurstSeconds,所以本示例桶容量/突发量为2,例子中前两个是消费了之前积攒的突发量,而第三个开始就是正常计算的了。令牌桶算法允许将一段时间内没有消费的令牌暂存到令牌桶中,留待未来使用,并允许未来请求的这种突发.

SmoothBursty通过平均速率和最后一次新增令牌的时间计算出下次新增令牌的时间的,另外需要一个桶暂存一段时间内没有使用的令牌(即可以突发的令牌数)。另外RateLimiter还提供了tryAcquire方法来进行无阻塞或可超时的令牌消费。

因为SmoothBursty允许一定程度的突发,会有人担心如果允许这种突发,假设突然间来了很大的流量,那么系统很可能扛不住这种突发。因此需要一种平滑速率的限流工具,从而系统冷启动后慢慢的趋于平均固定速率(即刚开始速率小一些,然后慢慢趋于我们设置的固定速率)。Guava也提供了SmoothWarmingUp来实现这种需求类似漏桶算法;

举例:Guava RateLimiter - SmoothWarmingUp

SmoothWarmingUp创建方式:RateLimiter.create(doublepermitsPerSecond, long warmupPeriod, TimeUnit unit)

permitsPerSecond表示每秒新增的令牌数,warmupPeriod 表示在从冷启动速率过渡到平均速率的时间间隔。

RateLimiter limiter = RateLimiter.create(5,1000, TimeUnit.MILLISECONDS);
for(inti =1; i < 5;i++) {
    System.out.println(limiter.acquire());
}
Thread.sleep(1000L);
for(inti =1; i < 5;i++) {
    System.out.println(limiter.acquire());
}
// 将得到类似如下的输出:
0.0
0.51767
0.357814
0.219992
0.199984
0.0
0.360826
0.220166
0.199723
0.199555

速率是梯形上升速率的,也就是说冷启动时会以一个比较大的速率慢慢到平均速率;然后趋于平均速率(梯形下降到平均速率)。可以通过调节warmupPeriod参数实现一开始就是平滑固定速率。

方案二:漏桶方式

水(请求)先进入到漏桶里,漏桶以一定的速度出水(接口有响应速率),当水流入速度过大会直接溢出(访问频率超过接口响应速率),然后就拒绝请求,可以看出漏桶算法能强行限制数据的传输速率。

可见这里有两个变量,一个是桶的大小,支持流量突发增多时可以存多少的水(burst),另一个是水桶漏洞的大小(rate)。

因为漏桶的漏出速率是固定的参数,所以,即使网络中不存在资源冲突(没有发生拥塞),漏桶算法也不能使流突发(burst)到端口速率.因此,漏桶算法对于存在突发特性的流量来说缺乏效率.

令牌桶和漏桶对比

  • 令牌桶是按照固定速率往桶中添加令牌,请求是否被处理需要看桶中令牌是否足够,当令牌数减为零时则拒绝新的请求;
  • 漏桶则是按照常量固定速率流出请求,流入请求速率任意,当流入的请求数累积到漏桶容量时,则新流入的请求被拒绝;
  • 令牌桶限制的是平均流入速率(允许突发请求,只要有令牌就可以处理,支持一次拿3个令牌,4个令牌),并允许一定程度突发流量;
  • 漏桶限制的是常量流出速率(即流出速率是一个固定常量值,比如都是1的速率流出,而不能一次是1,下次又是2),从而平滑突发流入速率;
  • 令牌桶允许一定程度的突发,而漏桶主要目的是平滑流入速率;
  • 两个算法实现可以一样,但是方向是相反的,对于相同的参数得到的限流效果是一样的。

方案三:计数器方式

计数器限流算法也是比较常用的,主要用来限制总并发数,比如数据库连接池大小、线程池大小、程序访问并发数等都是使用计数器算法。也是最简单粗暴的算法。

采用AtomicInteger

使用AomicInteger来进行统计当前正在并发执行的次数,如果超过域值就简单粗暴的直接响应给用户,说明系统繁忙,请稍后再试或其它跟业务相关的信息。

弊端:使用 AomicInteger 简单粗暴超过域值就拒绝请求,可能只是瞬时的请求量高,也会拒绝请求。

package com.example.throttle;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.concurrent.atomic.AtomicInteger;
/**
 * 计算器限流
 */
@SpringBootTest
public class AtomicIntegerTest {
    @Test
    void simple() {
        var atomicInteger = new AtomicInteger(0);
        var throttle = 10;
        for (int i = 0; i < 100; i++) {
            try {
                new Thread(() -> {
                    try {
                        if (atomicInteger.incrementAndGet() > throttle) {
                            System.out.println("太快啦");
                            throw new RuntimeException("拒绝");
                        }
                        System.out.println(Thread.currentThread().getId() + "处理啦");
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        System.out.println("请求被拒绝");
                    } finally {
                        int i1 = atomicInteger.decrementAndGet();
                    }
                }).start();
            } catch (Exception e) {
                System.out.println(e);
            }
        }
    }
}

采用令牌Semaphore

使用Semaphore信号量来控制并发执行的次数,如果超过域值信号量,则进入阻塞队列中排队等待获取信号量进行执行。如果阻塞队列中排队的请求过多超出系统处理能力,则可以在拒绝请求。

相对Atomic优点:如果是瞬时的高并发,可以使请求在阻塞队列中排队,而不是马上拒绝请求,从而达到一个流量削峰的目的。

package com.example.throttle;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
/**
 * 信号量实现单接口限流
 */
@SpringBootTest
public class SemaphoreTest {
    @Test
    void test() throws InterruptedException {
        SemaphoreTest test = new SemaphoreTest();
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                test.myMethod();
            }).start();
        }
        Thread.sleep(1500);
    }
    private final Semaphore semaphore = new Semaphore(10);
    public void myMethod() {
        try {
            if (semaphore.tryAcquire(500, TimeUnit.MILLISECONDS)) {
                System.out.println(Thread.currentThread().getId() + "处理中");
                Thread.sleep(500);
                semaphore.release();
            } else {
                System.out.println(Thread.currentThread().getId() + "被拒绝");
                throw new RuntimeException("请求被拒绝");
            }
        } catch (Exception e) {
            System.out.println();
            Thread.currentThread().interrupt();
        }
    }
}

方案四、限流某个接口的时间窗请求数

对于限流某个接口的时间窗请求数,可以采用以下方法进行实现:

LoadingCache<Long, AtomicLong> counter =
        CacheBuilder.newBuilder()
                .expireAfterWrite(2, TimeUnit.SECONDS)
                .build(new CacheLoader<Long, AtomicLong>() {
                    @Override
                    public AtomicLong load(Long seconds) throws Exception {
                        return new AtomicLong(0);
                    }
                });
long limit = 1000;
while(true) {
    //得到当前秒
    long currentSeconds = System.currentTimeMillis() / 1000;
    if(counter.get(currentSeconds).incrementAndGet() > limit) {
        System.out.println("限流了:" + currentSeconds);
        continue;
    }
    //业务处理
}

这里统计每秒内的请求次数总和,过期时间设置为大于1s的时间即可,保证1s内的统计不会过期。这种方法不受业务执行快慢影响,只统计1s钟内执行了多少次。

方案五、其他

1. 使用Hystrix实现Java限流

Hystrix是一个开源的容错框架,用于控制系统中的访问速度。通过设置系统的最大并发请求量和超时时间,我们可以有效地控制系统的负载,防止系统资源被耗尽。下面是使用Hystrix实现Java限流的示例代码:

@RestController
public class RateLimitController {
    // 创建一个HystrixCommand配置
    private HystrixCommand.Setter commandConfig = HystrixCommand.Setter
            .withGroupKey(HystrixCommandGroupKey.Factory.asKey("group1"))
            .andCommandKey(HystrixCommandKey.Factory.asKey("command1"))
            .andThreadPoolKey(HystrixThreadPoolKey.Factory.asKey("threadPool1"))
            .andCommandPropertiesDefaults(
                    HystrixCommandProperties.Setter()
                            .withExecutionIsolationSemaphoreMaxConcurrentRequests(10)
                            .withExecutionTimeoutInMilliseconds(500)
                            .withFallbackIsolationSemaphoreMaxConcurrentRequests(100)
            );
    // 创建一个HystrixCommand实例
    private HystrixCommand<String> command = new HystrixCommand<String>(commandConfig) {
        @Override
        protected String run() throws Exception {
            // 执行业务逻辑
            return "success";
        }
        @Override
        protected String getFallback() {
            // 返回失败信息
            return "fail";
        }
    };
    @GetMapping("/test")
    public String test() throws Exception {
        // 执行HystrixCommand实例
        return command.execute();
    }
}

在上述代码中,我们通过创建一个HystrixCommand配置,并设置最大并发请求量和超时时间来控制系统的负载。在执行业务逻辑时,我们通过HystrixCommand实例来执行业务逻辑,如果执行成功,则返回成功信息;如果执行失败,则返回失败信息。

2. 使用Redis实现Java限流

Redis是一个开源的内存数据库,用于存储大量的数据和缓存数据。通过使用Redis中的计数器和过期时间,我们可以实现Java限流的功能。下面是使用Redis实现Java限流的示例代码:

@RestController
public class RateLimitController {
    // 创建Redis实例
    private RedisTemplate<String, Integer> redisTemplate = new RedisTemplate<>();
    private ValueOperations<String, Integer> valueOperations = redisTemplate.opsForValue();
    @GetMapping("/test")
    public String test() {
        // 获取当前时间
        long current = System.currentTimeMillis();
        // 获取Redis中的计数器值
        Integer count = valueOperations.get("count");
        // 如果计数器不存在,则初始化计数器
        if (count == null) {
            valueOperations.set("count", 0, Duration.ofSeconds(1));
            count = 0;
        }
        // 如果计数器达到最大值,则限流
        if (count >= 10) {
            return "fail";
        }
        // 更新计数器的值
        valueOperations.increment("count");
        // 执行业务逻辑
        return "success";
    }
}

在上述代码中,我们通过创建一个Redis实例,并使用Redis中的计数器来实现Java限流的功能。在每次请求处理之前,我们首先获取Redis中的计数器值,如果计数器不存在,则初始化计数器。然后,我们判断计数器的值是否达到最大值,如果达到最大值,则限流。最后,我们更新计数器的值,并执行业务逻辑。

相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
4天前
|
监控 Java 应用服务中间件
高级java面试---spring.factories文件的解析源码API机制
【11月更文挑战第20天】Spring Boot是一个用于快速构建基于Spring框架的应用程序的开源框架。它通过自动配置、起步依赖和内嵌服务器等特性,极大地简化了Spring应用的开发和部署过程。本文将深入探讨Spring Boot的背景历史、业务场景、功能点以及底层原理,并通过Java代码手写模拟Spring Boot的启动过程,特别是spring.factories文件的解析源码API机制。
16 2
|
16天前
|
人工智能 前端开发 Java
基于开源框架Spring AI Alibaba快速构建Java应用
本文旨在帮助开发者快速掌握并应用 Spring AI Alibaba,提升基于 Java 的大模型应用开发效率和安全性。
基于开源框架Spring AI Alibaba快速构建Java应用
|
24天前
|
前端开发 Java 数据库连接
Spring 框架:Java 开发者的春天
Spring 框架是一个功能强大的开源框架,主要用于简化 Java 企业级应用的开发,由被称为“Spring 之父”的 Rod Johnson 于 2002 年提出并创立,并由Pivotal团队维护。
43 1
Spring 框架:Java 开发者的春天
|
16天前
|
监控 前端开发 Java
Java SpringBoot –性能分析与调优
Java SpringBoot –性能分析与调优
|
24天前
|
Java 数据库连接 开发者
Spring 框架:Java 开发者的春天
【10月更文挑战第27天】Spring 框架由 Rod Johnson 在 2002 年创建,旨在解决 Java 企业级开发中的复杂性问题。它通过控制反转(IOC)和面向切面的编程(AOP)等核心机制,提供了轻量级的容器和丰富的功能,支持 Web 开发、数据访问等领域,显著提高了开发效率和应用的可维护性。Spring 拥有强大的社区支持和丰富的生态系统,是 Java 开发不可或缺的工具。
|
19天前
|
JavaScript Java 项目管理
Java毕设学习 基于SpringBoot + Vue 的医院管理系统 持续给大家寻找Java毕设学习项目(附源码)
基于SpringBoot + Vue的医院管理系统,涵盖医院、患者、挂号、药物、检查、病床、排班管理和数据分析等功能。开发工具为IDEA和HBuilder X,环境需配置jdk8、Node.js14、MySQL8。文末提供源码下载链接。
|
24天前
|
JSON Java Maven
实现Java Spring Boot FCM推送教程
本指南介绍了如何在Spring Boot项目中集成Firebase云消息服务(FCM),包括创建项目、添加依赖、配置服务账户密钥、编写推送服务类以及发送消息等步骤,帮助开发者快速实现推送通知功能。
61 2
|
28天前
|
存储 人工智能 Java
将 Spring AI 与 LLM 结合使用以生成 Java 测试
AIDocumentLibraryChat 项目通过 GitHub URL 为指定的 Java 类生成测试代码,支持 granite-code 和 deepseek-coder-v2 模型。项目包括控制器、服务和配置,能处理源代码解析、依赖加载及测试代码生成,旨在评估 LLM 对开发测试的支持能力。
36 1
|
1月前
|
Java BI 调度
Java Spring的定时任务的配置和使用
遵循上述步骤,你就可以在Spring应用中轻松地配置和使用定时任务,满足各种定时处理需求。
123 1
|
11天前
|
安全 Java 测试技术
Java开发必读,谈谈对Spring IOC与AOP的理解
Spring的IOC和AOP机制通过依赖注入和横切关注点的分离,大大提高了代码的模块化和可维护性。IOC使得对象的创建和管理变得灵活可控,降低了对象之间的耦合度;AOP则通过动态代理机制实现了横切关注点的集中管理,减少了重复代码。理解和掌握这两个核心概念,是高效使用Spring框架的关键。希望本文对你深入理解Spring的IOC和AOP有所帮助。
23 0