针对软件系统来说,限流就是对请求的速率进行限制,避免瞬时的大量请求击垮软件系统。毕竟,软件系统的处理能力是有限的。如果说超过了其处理能力的范围,软件系统可能直接就挂掉了。

限流可能会导致用户的请求无法被正确处理,不过,这往往也是权衡了软件系统的稳定性之后得到的最优解。

现实生活中,处处都有限流的实际应用,就比如排队买票是为了避免大量用户涌入购票而导致售票员无法处理

常见限流算法

固定窗口计数器算法

固定窗口其实就是时间窗口。固定窗口计数器算法 规定了我们单位时间处理的请求数量。

假如我们规定系统中某个接口 1 分钟只能访问 33 次的话,使用固定窗口计数器算法的实现思路如下:

  • 给定一个变量 counter 来记录当前接口处理的请求数量,初始值为 0(代表接口当前 1 分钟内还未处理请求)。
  • 1 分钟之内每处理一个请求之后就将 counter+1 ,当 counter=33 之后(也就是说在这 1 分钟内接口已经被访问 33 次的话),后续的请求就会被全部拒绝。
  • 等到 1 分钟结束后,将 counter 重置 0,重新开始计数。

这种限流算法无法保证限流速率,因而无法保证突然激增的流量。

就比如说我们限制某个接口 1 分钟只能访问 1000 次,该接口的 QPS 为 500,前 55s 这个接口 1 个请求没有接收,后 1s 突然接收了 1000 个请求。然后,在当前场景下,这 1000 个请求在 1s 内是没办法被处理的,系统直接就被瞬时的大量请求给击垮了。

滑动窗口计数器算法

滑动窗口计数器算法 算的上是固定窗口计数器算法的升级版。

滑动窗口计数器算法相比于固定窗口计数器算法的优化在于:它把时间以一定比例分片

漏桶算法

我们可以把发请求的动作比作成注水到桶中,我们处理请求的过程可以比喻为漏桶漏水。我们往桶中以任意速率流入水,以一定速率流出水。当水超过桶流量则丢弃,因为桶容量是不变的,保证了整体的速率。

实现这个算法,准备一个队列用来保存请求,然后我们定期从队列中拿请求来执行就好了(和消息队列削峰/限流的思想是一样的)。

漏桶算法

令牌桶算法

和漏桶算法算法一样,我们的主角还是桶(这限流算法和桶过不去啊)。不过现在桶里装的是令牌了,请求在被处理之前需要拿到一个令牌,请求处理完毕之后将这个令牌丢弃(删除)。我们根据限流大小,按照一定的速率往桶里添加令牌。如果桶装满了,就不能继续往里面继续添加令牌了。

令牌桶算法

限流实现

工具类

1.Google Guava 自带的限流工具类 RateLimiter

RateLimiter 基于令牌桶算法,可以应对突发流量。

除了最基本的令牌桶算法(平滑突发限流)实现之外,Guava 的RateLimiter还提供了 平滑预热限流 的算法实现。

平滑突发限流就是按照指定的速率放令牌到桶里,而平滑预热限流会有一段预热时间,预热时间之内,速率会逐渐提升到配置的速率。

Guava 地址:https://github.com/google/guava

2.Bucket4j

Bucket4j 是一个非常不错的基于令牌/漏桶算法的限流库

Bucket4j 地址:https://github.com/vladimir-bukhtoyarov/bucket4j

3.Resilience4j

Resilience4j 是一个轻量级的容错组件,其灵感来自于 Hystrix。自Netflix 宣布不再积极开发 Hystrixopen in new window 之后,Spring 官方和 Netflix 都更推荐使用 Resilience4j 来做限流熔断。

Resilience4j 地址: https://github.com/resilience4j/resilience4j

自定义实现

实现固定窗口计数器算法

**Redis+Lua **

减少了网络开销:我们可以利用 Lua 脚本来批量执行多条 Redis 命令,这些 Redis 命令会被提交到 Redis 服务器一次性执行完成,大幅减小了网络开销。

原子性:一段 Lua 脚本可以视作一条命令执行,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰。

RedisConfig

在redis的配置中编写Lua脚本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Bean
public DefaultRedisScript<Long> limitScript()
{
    DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
    redisScript.setScriptText(limitScriptText());
    redisScript.setResultType(Long.class);
    return redisScript;
}

/**
 * 限流脚本
 */
private String limitScriptText()
{
    return "local key = KEYS[1]\n" +
            "local count = tonumber(ARGV[1])\n" +
            "local time = tonumber(ARGV[2])\n" +
            "local current = redis.call('get', key);\n" +
            "if current and tonumber(current) > count then\n" +
            "    return tonumber(current);\n" +
            "end\n" +
            "current = redis.call('incr', key)\n" +
            "if tonumber(current) == 1 then\n" +
            "    redis.call('expire', key, time)\n" +
            "end\n" +
            "return tonumber(current);";
}

RateLimiterAspect

定义一个切面,实现基于注解的流量控制

1
2
3
4
5
6
7
// 在对应的注解中通过传入参数key的名称,限制次数,过期时间,来进行统计
Long number = redisTemplate.execute(limitScript, keys, count, time);
if (StringUtils.isNull(number) || number.intValue() > count)
{
    throw new ServiceException("访问过于频繁,请稍候再试");
}
log.info("限制请求'{}',当前请求'{}',缓存key'{}'", count, number.intValue(), combineKey);

首先获得对应的key当前存储的值,如果当前存储的值大于限制次数,直接返回,如果不大于,那么此值调用incr命令去做加1操作。 如果加1之后current为1,给它设置一个过期时间,从而保证它在限制时间后可以被销毁,从而进行一个新的统计。

限流注解

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimiter
{
    /**
     * 限流key
     */
    public String key() default CacheConstants.RATE_LIMIT_KEY;

    /**
     * 限流时间,单位秒
     */
    public int time() default 60;

    /**
     * 限流次数
     */
    public int count() default 100;

    /**
     * 限流类型
     */
    public LimitType limitType() default LimitType.DEFAULT;
}