大白话分析常见限流算法及实战| 技术创作特训营第一期

2023-08-23 14:57:06 浏览数 (2)

一、背景

小明今天上班,看到最近开的促销活动,发现后台日志有很多重复调用的请求数据,而且还是同个用户的,这个人也抢了很多活动商品,导致其他用户都没法购买到。很显热,活动接口被刷爆了,马上跟大佬商量,十分慌张,大佬说,要加一下限流,做一下防刷处理,缓解一下后台服务。但是,刚入职场的小明,还不了解限流是个啥,无从下手。

所以,今天给初入职场的同学们,介绍一下什么是接口限流?为什么要接口限流?有哪些具体落地的方案?

二、什么是接口限流

接口限流,是指通过控制请求的速率或次数来达到保护服务的目的,在微服务中,我们通常会将它和熔断、降级搭配在一起使用,来避免瞬时的大量请求对系统造成负荷,来达到保护服务平稳运行的目的,也是接口防刷的一个手段之一。

目前,限流的算法也很多种,包括:计数器、固定窗口计数器、滑动窗口计数器、漏桶算法、令牌桶算法。对应的落地方案有:本地缓存实现,第三方缓存实现、Nginx限流、网关限流、Guava的令牌桶RateLimiter、微服务Alibaba的Sentinel中间件。接下来给大家介绍一下各种算法和落地方案实战。

三、接口限流算法

1.计数器

这种是最简单的限流算法,利用的是限定请求次数,每个用户建立一个计数器,从第一次请求的时候开始计数,当达到限定次数,就把改用户放入数据库或者缓存中,一定时间内不允许在调用,时间到期之后,恢复正常,达到限流效果。具体如图所示:

优点:实现简单

缺点:无法应对突发流量

2.固定窗口计数器

固定窗口算法,跟上面计数器有点相似,固定窗口就是把后面的限定时间跟请求次数合在一起,单位时间内允许限制请求数,通过在单位时间内维护一个计数器,能够限制在每个固定的时间段内请求通过的次数,以达到限流的效果。如下,单位时间,每5分钟限制3个请求,超过了直接抛弃。

优点:实现简单

缺点:无法应对突发流量,临界时间,请求数突增,请求总量可能为阈值两倍的问题,限流可能不符合预期。比如每秒允许放行100个请求,但是在0.9秒前都没有请求进来,这就造成了在0.9秒到1秒这段时间内要处理100个请求,而在1秒到1.1秒间可能会再进入100个请求,这就造成了要在0.2秒内处理200个请求。这其实就不符合每秒允许放行100个请求的的预期了。

3.滑动窗口计数器

滑动窗口限流解决固定窗口临界值的问题,对固定窗口的一种改造,将窗口。相对于固定窗口,滑动窗口除了需要引入计数器之外还需要记录时间窗口内每个请求到达的时间点,因此对内存的占用会比较多。

滑动窗口限流原理:拆分成很多个小窗口,每个窗口作为一个限制的时间段,维护了单独的计数器,每次滑动一点进行限制。 假设我们定义时间窗口为1分钟。那么滑动窗口就会把时间窗口进行划分,划分6格,则每格时间窗口代表10秒。 当请求进来时,就会记录当前的请求时间。当前请求时间 - 当前请求时间往前推10s的时间,这个时间段就是滑动窗口算法的时间窗口 当滑动窗口算法的时间窗口,计数器统计的总数大于阈值,则限流,否则请求通过。

优点:解决了固定窗口算法的临界值问题,避免了固定窗口算法在切换窗口时请求总量可能为阈值两倍的问题。 缺点:仍无法解决短时间内高并发的场景,比如限流阈值是100,时间窗口定位为10s,倘若在1s内就把100个请求打满了,那2-9s期间服务器一直无法处理请求。因此处理流量突增的方式还是不够平滑。

4.漏桶算法

漏桶算法原理,模拟器就像水桶一样,水满溢出。水桶底部迅速流水,但是水桶口可以不管快慢任由水流入,当水桶满了之后,水再流入则会超出容量,进而溢出。水桶的水可以视为请求,溢出的水可以视为请求已满,就会被限流。

其实就是一个队列,先见先出,队列满了就抛弃,程序迅速从队列中一个个获取并处理。能够应对有效流量激增的问题,所有进来的请求都会进行排队,不会一下子请求到服务器,造成服务器压力过大。

优点:解决计数器遗留问题(高并发请求突增),所有请求匀速处理

缺点:由于接口处理都是匀速,并不清楚此时是否是高并发,导致所有请求都得进行排队,即使此时服务器的负载处于相对空闲的状态,这样会造成系统资源的浪费。

5.令牌桶算法

令牌桶算法是漏桶算法的基础进行改进,为了充分利用资源,不对所有请求都进行排队。但是又能控制流量突发情况、匀速处理接口请求。

令牌桶算法算法原理:

以恒定的速度生成令牌,并将令牌放入令牌桶中,当令牌桶中满了的时候,再向其中放入的令牌就会被丢弃,不在放入。而每次请求进入时,必须从令牌桶中获取一个令牌,如果没有获取到令牌则被限流拒绝,这样保证了接口可以匀速处理请求。 对于大量请求,不需要排队,从而解决了漏桶算法存在的问题,拿不到令牌说明被限制了,直接离开或者重试等待领取。

总的来说,令牌桶算法是目前用的比较多的,同时也可以利用Guava中的RateLimiter去实现令牌桶算法限流,有现成的Api去调用,操作也十分方便。

四、接口限流落地方案

上述主要给大家分析了限流算法的原理,但是开发中需要落地的方案,接下来会举例子,在实际开发中如何进行限流的。

1.本地缓存实现

这个是基于计数器限流算法进行限流,可以利用数据结构Map或者其他本地缓存框架,这里我推荐本地高性能缓存利器Caffeine。

本地缓存,每个用户同个接口只能访问 30 次,达到不再处理,并且这个限制只有等到 5 分钟后才解除。

代码语言:javascript复制
public class Limiter {
    private static final Logger logger = LoggerFactory.getLogger(Limiter.class);
    private static final Cache<String, Integer> cacheData;
    private static final int limitCount = 30;  // 缓存期间最大访问次数

    static {
        cacheData = Caffeine.newBuilder()
                .maximumSize(10000)
                .expireAfterAccess(5, TimeUnit.MINUTES)
                .build();
    }

    /**
     * 是否限流
     * 
     * @param url 接口地址
     * @param uid 用户ID
     * @return
     */
    public static boolean isLimit(String url, String uid) {
        if (StrUtil.hasBlank(url, uid)) {
            return false;
        }
        String key = String.format("%s?uid=%s", url, uid);
        Integer current = cacheData.getIfPresent(key);
        if (current == null) {
            current = 1;
        } else {
            current  = 1;
        }
        cacheData.put(key, current);
        if (current >= limitCount) {
            if (current % 100 == 0) {
                logger.info("请求受限 [{}] {}", current, key);
            }
            return true;
        } else {
            return false;
        }
    }

    public static void main(String[] args) {

        //  1.模拟突然大量请求,达到阈值,直接拒绝
        for (int i = 0; i < 30; i  ) {
            if(isRefuse("aaaa","123")){
                System.out.println("请求过多,请稍后重试");
                continue;
            }
        }
        // 2.模拟1分钟冷静期,冷静结束,请求的缓存失效了
        try {
            TimeUnit.MINUTES.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        String key = String.format("%s?uid=%s", "aaaa","123");
        Integer current = cacheData.getIfPresent(key);

        // 3.再一次模拟突然大量请求,由于之前缓存结束,只能再次达到阈值拒绝
        System.out.println(current);
        for (int i = 0; i < 30; i  ) {
            if(isRefuse("aaaa","123")){
                System.out.println("请求过多,请稍后重试");
                continue;
            }
            System.out.println("抢购");

        }
    }
}

2.第三方缓存实现

第三方缓存,跟本地缓存有点相似,都是基于计数器,但是是用中间件redis进行存储请求数。创建redis一分钟10次访问限制,如果达到限制,可以放入黑名单中,下次读取先查找是否在黑名单。

代码语言:javascript复制
  /**
     * 接口访问控制
     *
     * @param userId 用户id
     * @return
     */
    private Boolean limitVisit(String userId) {
        // 1.0 判断是否在黑名单
        String blackUserId = sysCacheService.getRewardBlacklist(userId);
        if (StrUtil.isNotBlank(blackUserId)) {
            logger.info("黑名单用户->{},禁止访问", userId);
            return false;
        }
        // 2.0 是否第一次访问,第一次访问,创建redis一分钟10次访问限制,已存在的话递减
        Integer visitTime = sysCacheService.getRewardVisitLimit(userId);
        if (null == visitTime) {
            sysCacheService.setRewardVisitLimit(userId);
        } else if (0 != visitTime) {
            sysCacheService.decrRewardVisitLimit(userId);
        } else {
            // 3.0 访问限制为0,说明一分钟内恶意访问了,放到黑名单,关10分钟
            sysCacheService.setRewardBlacklist(userId);
            logger.info("检测到恶意请求->{},禁止访问", userId);
            return false;
        }

        return true;
    }

3.Nginx限流

Nginx 提供两种限流方式,一是控制速率,二是控制并发连接数。

控制速率: nginx 的 limit_req_zone 和 limit_req 两个指令,限制单个IP的请求处理速率

代码语言:javascript复制
http {
    ####...
    # 定义限流空间方案limit_ldq,缓存区zone 10M,rate 用于设置最大访问速率 
    # 1r/s 表示每秒最多处理1个请求

    limit_req_zone $binary_remote_addr zone=limit_ldq:10m rate=1r/s;
    
    server {
        listen       80;
        server_name  localhost;
        #charset koi8-r;
        access_log  log/host.access.log  main;
        location / {
             root   html;
             index  index.html index.htm;
             # server 块直接指定方案limit_ldq,并且限制最大请求量 burst 为1000
             limit_req zone=limit_ldq burst=1000 nodelay;
        }
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
}

控制并发连接数:nginx的 limit_conn_module 提供了限制连接数的能力,利用 limit_conn_zone 和 limit_conn 两个指令,控制客户端并发请求

代码语言:javascript复制
http{
    limit_conn_zone $binary_remote_addr zone=perip:10m;
    limit_conn_zone $server_name zone=perserver:10m;

    server {
     ...
     limit_conn perip 10;
     limit_conn perserver 100;
    }
}

4.网关限流

网关限流,是微服务Spring Cloud的Gateway组件进行限流的方式,可以在网关层面进行限流,把突发请求拦截到网关前,不会直接全部打到后台接口。Gateway默认可以使用redis进行限流,也可以自己自定义或者整合Sentinel限流组件进行限流。

Gateway网关限流原理: 主要是依靠spring-cloud-gateway-core包中的RedisRateLimiter类,以及META-INF/scripts中的request-rate-limiter.lua这个脚本。

配置前需要导入相关依赖

代码语言:javascript复制
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>

gateway进行yaml文件配置,主要就是配置RedisRateLimiter类的内部类配置Config的两个重要参数: replenishRate:令牌桶每秒填充平均速率,也就是生成令牌的速率 burstCapacity:令牌桶上限

这里设置的桶上限为2,每秒填充1个令牌,那么当第一次令牌用完,如果请求一秒有两个请求,那么其中一个请求将会被限流。

代码语言:javascript复制
spring:
  application:
    name: gateway-test
  cloud:
    gateway:
      routes:
        - id: limit_route
          uri: lb://sentinel-test
          predicates:
          - Path=/sentinel-test/**
          filters:
            - name: RequestRateLimiter
              args:
                # 令牌桶每秒填充平均速率
                redis-rate-limiter.replenishRate: 1
                # 令牌桶上限
                redis-rate-limiter.burstCapacity: 2
                # 指定解析器,使用spEl表达式按beanName从spring容器中获取
                key-resolver: "#{@pathKeyResolver}"
            - StripPrefix=1
  redis:
    host: 127.0.0.1
    port: 6379

配置key-resolver解析器,触发限流处理类:

代码语言:javascript复制
@Slf4j
@Component
public class PathKeyResolver implements KeyResolver {
    public Mono<String> resolve(ServerWebExchange exchange) {
        String path = exchange.getRequest().getPath().toString();
        log.info("Request path: {}",path);
        return Mono.just(path);
    }
}

5.Guava的令牌桶RateLimiter

使用Google的RateLimiter令牌桶算法,需要导入相关的依赖

代码语言:javascript复制
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>29.0-jre</version>
</dependency>

测试就很简单了,有两个方法tryAcquire和acquire。

acquire:是阻塞的且会一直等待到获取令牌为止,它有一个返回值为double型,意思是从阻塞开始到获取到令牌的等待时间。 tryAcquire:可以指定超时时间,返回值为boolean型,即假设线程等待了指定时间后仍然没有获取到令牌,那么就会返回给客户端false。

代码语言:javascript复制
/**
acquire():阻塞获取,没获取到令牌就会一直等待
*/
public void acquireMultiTest(){
    // 创建RateLimiter限流,生成一个令牌
    RateLimiter rateLimiter=RateLimiter.create(1);
    
    for (int i = 0; i <3; i  ) {
        int num = 2 * i   1;
        log.info("获取{}个令牌", num);
        double cost = rateLimiter.acquire(num);
        log.info("获取{}个令牌结束,耗时{}ms",num,cost);
    }
}


/**
tryAcquire(1, TimeUnit.SECONDS):非阻塞获取,可以指定等待时间,等待时间没有获取到直接退出
*/
public void acquireMultiTest2(){
    // 创建RateLimiter限流,生成一个令牌
    RateLimiter rateLimiter=RateLimiter.create(1);
    
     if (!rateLimiter.tryAcquire(1, TimeUnit.SECONDS)) {
            logger.info("发送验证码请求过于频繁");
            return;
        }
     log.info("获取{}个令牌结束,耗时{}ms",num,cost);
}

6.微服务Alibaba的Sentinel中间件

Sentinel是Alibaba版springcloud的一个重要组件,类似Hystrix,可以作为微服务的熔断、降级、限流,同时相对Hystrix来说,配置更加简单,直接可以在可视化页面进行配置。大家如果想要深入了解可以去看一下Alibaba版springcloud。本文主要讲解如何Sentinel进行限流。

Sentinel限流使用起来也非常简单,在service层的方法上添加@SentinelResource注解,通过value指定资源名称,blockHandler指定一个方法,该方法会在原方法被限流、降级、系统保护时被调用。然后利用后台配置对应的流控规则即可。

导入sentinel依赖

代码语言:javascript复制
       <!--        sentinel限流-->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
        </dependency>

yaml文件配置sentinel地址

代码语言:javascript复制
spring:
  cloud:
    sentinel:
      transport:
        dashboard: localhost:9999
        port: 8719

sentinel限流用到很重要的一个注解就是@SentinelResource,只要在对应控制层接口添加该注解即可。

代码语言:javascript复制
    @GetMapping("/byResource")
    @SentinelResource(value = "byResource",blockHandler = "handleException")
    public CommonResult byResource(){
        return new CommonResult(200,"按资源名称限流测试",new Payment(2020L,"serrr001"));
    }

    public CommonResult handleException(BlockException exception){
        return new CommonResult(444,exception.getClass().getCanonicalName() "t 服务不可用");
    }

代码接口配置好,需要到sentinel后台配置对应的流控规则,比如陪孩子QPS=1,即 当每秒请求大于1时,就会触发限流,会跳转到handleException处理方法中。

Sentinel在微服务架构下得到了广泛的使用,能够提供可靠的集群流量控制、服务断路等功能。在使用中,限流可以结合熔断、降级一起使用,成为有效应对三高系统的三板斧,来保证服务的稳定性。

【选题思路】

服务限流,限流是一个必不可少的环节,不管大小公司,都是需要处理,要保证系统的抗压能力和稳定性,有时也是保证业务分配合理,但是选择方案上,需要根据具体业务来,简单的,可能只要用计数器限流即可。限流,虽然可能会造成某些用户的请求被丢弃,但相比于突发流量造成的系统宕机来说,这些损失一般都在可以接受的范围之内。所以,一般限流可以结合熔断、降级一起使用,可以重试处理配抛弃的请求,多管齐下,保证服务的可用性与健壮性。

【创作提纲】

  1. 背景——什么是限流算法
  2. 什么是接口限流,当前常见限流算法
  3. 接口限流算法1:计数器
  4. 接口限流算法2:固定窗口计数器
  5. 接口限流算法3:滑动窗口计数器
  6. 接口限流算法4:漏桶算法
  7. 接口限流算法5:令牌桶算法
  8. 接口限流落地方案1:本地缓存实现
  9. 接口限流落地方案2:第三方缓存实现
  10. 接口限流落地方案3:Nginx限流
  11. 接口限流落地方案4:网关限流
  12. 接口限流落地方案5:Guava的令牌桶RateLimiter
  13. 接口限流落地方案6:微服务Alibaba的Sentinel中间件

0 人点赞