Redis的自增也能实现滑动窗口限流?

限流是大家开发之路上一定会遇到的需求。比如:限制一定时间内,接口请求请求频率;一定时间内用户发言、评论次数等等,类似于滑动窗口算法。这里分享一份拿来即用的代码,一起看看如何利用常见的 Redis 实现一个实用并且原理简单的限流器。

限流核心原理以及代码

这个限流器的原理是使用 Redis 的incr命令来累计次数,key 的过期时间作为时间滑动窗口来实现。比如限制每5秒最多请求10次,那么就将 key 的过期时间设置为5秒,每次执行前对这个 key 自增,5秒内的次数将累计到这一个 key 上,如果自增的结果没有超过10次,代表没有被限流。5秒过后 key 将被 Redis 清除,后续次数将重新累计。

这里大家需要了解下incr使用的一些细节。incr每次执行都是将 key 的值自增1,并返回自增后的结果,比如对key=1执行incr结果为2;如果 key 不存在,将设置这个 key 值为1,返回结果自然也是1,并且这个 key 是没有过期时间的。

Redis 的incr不能在自增的同时设置过期时间,这就意味着自增和设置过期时间要分两步做,在第一次incr完成之后,紧接着使用expire指令来给这个 key 设置过期时间。非原子方式会带来并发问题,如果incr成功,而expire失败将导致生成了一个永不过期的 key,次数一直累计到最大值,永远进入限流状态。这个问题我们可以用个兜底逻辑来解决,在incr前获取这个 key 的过期时间,如果没有那就删掉。

看到这,有了解过 Redis lua 脚本的同学可能会提出,既然这么麻烦,为何不用 lua 脚本自己实现一个自增且同时能够同时设置过期时间的功能?这个思路很棒,代码量不大且 Redis 也是完全可以支持的。但是在大点的公司,运维可能会禁止开发使用 lua 这种扩展方式,Redis 只有一个主线程执行执行命令,如果脚本中的逻辑执行时间过长将导致后续指令排队等待,它们响应时间自然也会变长,这种不可控的风险运维肯定不愿意承担。当然如果公司允许,并且有其他手段可以控制这个风险,lua 实现还是非常可行的。

为何不直接使用JDK实现而要借助中间件?因为实现出来只能在当前进程有有效,集群情况下不能累计到一起。

下面是具体代码,可以直接使用,代码关键处有详细的注释:

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.lang.NonNull;

import java.util.Objects;
import java.util.concurrent.TimeUnit;

/**
 * 用Redis实现的限流器,用于限制方法或者接口请求频率。比如:限制接口每秒请求次数;某个用户请求接口的次数,属于滑动窗口算法。
 * 核心方法是 {@link #acquire(RedisTemplate, String, long, long)}
 */
public abstract class RedisIncrLimiter {

    /**
     * 限制每秒次数,参考 {@link #acquire(RedisTemplate, String, long, long)}
     */
    public static boolean acquireLimitPerSecond(@NonNull RedisTemplate<String, String> redisTemplate,
                                                @NonNull String limiterKey, long maxTimes) {
        return acquire(redisTemplate, limiterKey, 1, maxTimes);
    }

    /**
     * 限制每分钟次数,参考 {@link #acquire(RedisTemplate, String, long, long)}
     */
    public static boolean acquireLimitPerMinute(@NonNull RedisTemplate<String, String> redisTemplate,
                                                @NonNull String limiterKey, long maxTimes) {
        return acquire(redisTemplate, limiterKey, 60, maxTimes);
    }

    /**
     * 限制每小时次数,参考 {@link #acquire(RedisTemplate, String, long, long)}
     */
    public static boolean acquireLimitPerHour(@NonNull RedisTemplate<String, String> redisTemplate,
                                              @NonNull String limiterKey, long maxTimes) {
        return acquire(redisTemplate, limiterKey, 3600, maxTimes);
    }

    /**
     * 限制每天次数,参考 {@link #acquire(RedisTemplate, String, long, long)}
     */
    public static boolean acquireLimitPerDay(@NonNull RedisTemplate<String, String> redisTemplate,
                                             @NonNull String limiterKey, long maxTimes) {
        return acquire(redisTemplate, limiterKey, 86400, maxTimes);
    }

    /**
     * 执行限流逻辑前,调用这个方法获取一个令牌,如果返回 true 代表没被限流,可以执行。比如:
     * <pre>{@code
     * // 限制每秒最多发10次消息
     * if (RedisIncrLimiter.acquire(redisTemplate, "sendMessage", 1, 10)) {
     *     // 发消息
     * } else {
     *     // 被限流后的操作
     * }
     * }</pre>
     * 如果限流粒度是用户级,可以将用户的ID或者唯一身份标识加到限流Key中。<br>
     * 这个也是限流核心方法,利用 Redis incr 命令累计次数,KEY过期时间作为时间窗口实现。<br>
     * 相同的限流KEY、时间窗口和最大次数才会累计到一起,三个参数任一不一致会分开累计,
     * 参考{@link #buildFinalLimiterKey(String, long, long)}
     *
     * @param redisTemplate    redisTemplate
     * @param limiterKey       限流Key(代表限流逻辑的字符串)
     * @param timeWindowSecond 时间窗口
     * @param maxTimes         时间窗口内最大次数
     * @return true-没有被限流
     */
    public static boolean acquire(@NonNull RedisTemplate<String, String> redisTemplate,
                                  @NonNull String limiterKey, long timeWindowSecond, long maxTimes) {
        limiterKey = buildFinalLimiterKey(limiterKey, timeWindowSecond, maxTimes);

        /*
        如果异常情况下产生了没有过期时间的KEY,将导致次数不断累积到最大值(被限流)而无法解除。
        这个兜底操作就是为了避免这个问题,清除没有过期时间的KEY
         */
        Long ttl = redisTemplate.getExpire(limiterKey);
        if (ttl == null || ttl == -1L) {
            redisTemplate.delete(limiterKey);
            return true;
        }

        Long incr = redisTemplate.opsForValue().increment(limiterKey);
        Objects.requireNonNull(incr);

        // 在第一次请求的时候设置过期时间(时间窗口)
        if (incr == 1L) {
            redisTemplate.expire(limiterKey, timeWindowSecond, TimeUnit.SECONDS);
        }

        return incr <= maxTimes;
    }

    /**
     * @param limiterKey       限流Key
     * @param timeWindowSecond 时间窗口
     * @param maxTimes         时间窗口内最大次数
     * @return 构建最终的限流 Redis Key,格式为:限流Key:时间窗口:最多次数
     */
    private static String buildFinalLimiterKey(String limiterKey, long timeWindowSecond, long maxTimes) {
        return limiterKey + ":" + timeWindowSecond + ":" + maxTimes;
    }
}

基于Spring切面实现的注解版本

注解版使用起来比较方便,只需要在限流的方法上指定时间三个关键的参数就行,底层逻辑还是上面的代码。比如:

// 每5秒最多10次
@RedisIncrLimit(limiterKey = "test", timeWindowSecond = 5L, maxTimes = 10L)
public String test() {
      return "ok";
}

RedisIncrLimit只用来标记限流方法,接收限流参数。

import java.lang.annotation.*;

/**
 * {@link RedisIncrLimiter} 注解版
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RedisIncrLimit {

    /**
     * @return 限流KEY
     */
    String limiterKey();

    /**
     * @return 时间窗口
     */
    long timeWindowSecond();

    /**
     * @return 时间窗口内最大次数
     */
    long maxTimes();
}

下面切面逻辑doBefore()会在加了RedisIncrLimit注解的方法前执行,先判断是否被限流。

import javax.annotation.Resource;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class RedisLimiterAspect {

    @Resource
    private RedisTemplate<String, String> redisTemplate;

    @Pointcut("@annotation(redisLimit)")
    public void pointcut(RedisIncrLimit redisLimit) {
    }

    @Before("pointcut(redisLimit)")
    public void doBefore(RedisIncrLimit redisLimit) {
        if (!RedisIncrLimiter.acquire(
                redisTemplate, redisLimit.limiterKey(), redisLimit.timeWindowSecond(), redisLimit.maxTimes())) {
            throw new IllegalStateException("rate limit");
        }
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,258评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,335评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,225评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,126评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,140评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,098评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,018评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,857评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,298评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,518评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,678评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,400评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,993评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,638评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,801评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,661评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,558评论 2 352

推荐阅读更多精彩内容