Redis + Lua 脚本实现计数器限流

· 默认分类

Redis + Lua 脚本实现计数器限流

限流

系统高并发的三大保障有:缓存、降级、限流
限流:限制系统或者某个服务在单位时间内处理请求的数量,防止系统超负荷,保障服务正常运行。常用算法有:计数器、令牌桶和漏桶等。

限流中的两个概念:

目前比较主流的两个限流方案

//计数器限流示意
public class Limiter{
    private final int limit = 10;
    private final int timeWindow = 1000;
    private long time;
    private AtomicInteger reqCount = new AtomicInteger(0);
       
    public Limiter(){
        this.time = System.currentTimeMillis();
    }
    // 每次请求前使用
    public boolean limit(){
        long now = System.currentTimeMillis();
        if(now < time + timeWindow){
            reqCount.addAndGet(1);
            return reqCount.get() < limit;
        }else{
            // 超出时间窗口,重新计数
            time = now;
            reqCount = new AtomicInteger(0);
            return true;
        }
    }
}

什么是Lua脚本?

一种轻量级、嵌入式脚本语言,常用游戏开发、脚本编程和嵌入式系统中。Redis从2.6开始支持Lua脚本,可以通过 EVAL 命令执行Lua脚本,实现原子操作,避免复杂的多步操作。
Lua脚本类似于MySQL的事务,存储一组指令,这些指令要么全部执行成功,要么全部失败,具有原子性,在开发中使用 Lua 脚本,可以将其看作是一段具有业务逻辑的代码块,因为我们需要用到原子性的一组操作,必然是业务需求。

Lua脚本在Redis中的好处:

常见使用场景:

项目应用

在项目中应用Redis+Lua脚本实现计数器限流,避免频繁等重复或者恶意的提交,减少系统的异常压力。我们不仅实现限流,还应用自定义注解 + 切面,实现灵活的限流。

Lua 脚本配置

首先将脚本配置注入

public class RedisRateLimiterConfig {
    @Bean
    public DefaultRedisScript<Long> limitScript() {
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        redisScript.setScriptText(limitScriptText());
        redisScript.setResultType(Long.class);
        return redisScript;
    }
    /**
     * 限流Lua脚本(基于计数器算法)
     */
    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)";
    }
}

Lua 脚本流程:

  1. 读取key计数
  2. 如果key存在,且值超过限制,返回计数,代表超出阈值
  3. 计数+1,如果不存在,计数会被设置为1
  4. 如果计数==1,意味着是第一次访问,设置过期时间
  5. 返回计数

自定义注解

@Target(ElementType.METHOD) // 表示只能用于方法
@Retention(RetentionPolicy.RUNTIME) // 表示运行时存活,可被反射获取
@Documented
public @interface RateLimiter {

    /**
     * 限流key(前缀)
     */
    String key() default "RATE_LIMIT:";

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

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

    /**
     * 限流类型(默认/按IP)
     */
    LimitType limitType() default LimitType.DEFAULT;
}

切面

private RedisTemplate<Object, Object> redisTemplate;
    private RedisScript<Long> limitScript;

    @Autowired
    public void setRedisTemplate(RedisTemplate<Object, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Autowired
    public void setLimitScript(RedisScript<Long> limitScript) {
        this.limitScript = limitScript;
    }

    @Before("@annotation(rateLimiter)")
    public void doBefore(JoinPoint point, RateLimiter rateLimiter) {
        int time = rateLimiter.time();
        int count = rateLimiter.count();
        String key = buildKey(rateLimiter, point);

        List<Object> keys = Collections.singletonList(key);
        Long current = redisTemplate.execute(limitScript, keys, count, time);

        if (current == null || current.intValue() > count) {
            throw new ServiceException("访问过于频繁,请稍候再试");
        }
        log.info("🔒 限流:key={}, 当前请求次数={}, 限制次数={}", key, current, count);
    }

    private String buildKey(RateLimiter rateLimiter, JoinPoint point) {
        StringBuilder sb = new StringBuilder(rateLimiter.key());
        if (rateLimiter.limitType() == LimitType.IP) {
            sb.append(IpUtils.getIpAddr(ServletUtils.getRequest())).append(":");
        }
        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();
        sb.append(method.getDeclaringClass().getName()).append(".").append(method.getName());
        return sb.toString();
    }

定义切面,执行带有@RateLimiter注解的方法之前,会先执行 doBefore,doBefore就是我们定义的限流逻辑:

  1. 从注解ratelimiter类获取限流配置信息(底层是反射,这就是为什么注解周期要设置成运行时
  2. 构造key,这里加入IP来构造,并且通过反射获取方法的所在类和方法名,结合构造,最终是RATE_LIMIT:IP:com.example.test.login 这种形式,以达到限制同一IP请求同一方法频率的目的。
  3. 执行Lua脚本限流逻辑,如果超出限制,抛出异常。

如何分析改进策略?