Redis + Lua 分布式限流

一,前言

单机版限流仅能保护自身节点,但无法保护应用依赖的各种服务,并且在进行节点扩容、缩容时也无法准确控制整个服务的请求限制。而分布式限流,以集群为维度,可以方便的控制这个集群的请求限制,从而保护下游依赖的各种服务资源。

分布式限流最关键的是要将限流服务做成原子化 ,我们可以借助 Redis 的计数器,Lua 执行的原子性,进行分布式限流。具体实现上存储了两个 key,一个用于计时,一个用于计数。请求每调用一次,计数器加1,若在计时器时间内计数器未超过阈值,则放行。

Redis + Lua 限流基于的是令牌桶算法,系统以恒定速率向桶里放入令牌,当请求来的时候,从桶里拿走一个令牌,如果桶里没有令牌则阻塞或者拒绝新的请求,它允许突发的流量。令牌桶的另外一个好处是可以方便的改变放入桶中的令牌的速率,可以方便实现动态限流

为什么使用 Lua 脚本?

Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能,Redis 支持 Lua 脚本,所以通过 Lua 实现限流的算法。Lua本身就是一种编程语言(脚本语言),Redis 脚本使用 Lua 解释器来执行脚本。虽然Redis 官方没有直接提供限流相应的API,但却支持了 Lua 脚本的功能,可以使用它实现复杂的令牌桶或漏桶算法,也是分布式系统中实现限流的主要方式之一。

Lua 脚本实现算法对比操作 Redis 实现算法的优点:

  • 减少网络开销:使用Lua脚本,无需向Redis 发送多次请求,执行一次即可,减少网络传输;

  • 原子操作:Redis 将整个 Lua 脚本作为一个命令执行,原子,无需担心并发;

  • 复用:Lua脚本一旦执行,会永久保存 Redis 中,其他客户端可复用;

二,实现步骤

2.1,项目中引入 Redis(已配置即跳过)

maven:

 
  1. <dependency> 
  2.     <groupId>org.springframework.boot</groupId> 
  3.     <artifactId>spring-boot-starter-data-redis</artifactId> 
  4. </dependency> 

Redis配置类:

 
  1. import com.fasterxml.jackson.annotation.JsonAutoDetect; 
  2. import com.fasterxml.jackson.annotation.PropertyAccessor; 
  3. import com.fasterxml.jackson.databind.ObjectMapper; 
  4. import org.springframework.context.annotation.Bean; 
  5. import org.springframework.context.annotation.Configuration; 
  6. import org.springframework.data.redis.connection.RedisConnectionFactory; 
  7. import org.springframework.data.redis.core.RedisTemplate; 
  8. import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; 
  9. import org.springframework.data.redis.serializer.StringRedisSerializer; 
  10. @Configuration 
  11. public class RedisConfigurtion { 
  12.     @Bean 
  13.     @SuppressWarnings("all") 
  14.     public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { 
  15.         RedisTemplate<String, Object> template = new RedisTemplate<String, Object>(); 
  16.         template.setConnectionFactory(factory); 
  17.         Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class); 
  18.         ObjectMapper om = new ObjectMapper(); 
  19.         om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); 
  20.         om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL); 
  21.         jackson2JsonRedisSerializer.setObjectMapper(om); 
  22.         StringRedisSerializer stringRedisSerializer = new StringRedisSerializer(); 
  23.         // key采用String的序列化方式 
  24.         template.setKeySerializer(stringRedisSerializer); 
  25.         // hash的key也采用String的序列化方式 
  26.         template.setHashKeySerializer(stringRedisSerializer); 
  27.         // value序列化方式采用jackson 
  28.         template.setValueSerializer(jackson2JsonRedisSerializer); 
  29.         // hash的value序列化方式采用jackson 
  30.         template.setHashValueSerializer(jackson2JsonRedisSerializer); 
  31.         template.afterPropertiesSet(); 
  32.         return template; 
  33.     } 

2.2,创建自定义限流注解

通过切面拦截带有自定义限流注解的接口或方法,通过注解方式就可轻松给接口加上限流操作。

 
  1. import java.lang.annotation.; 
  2. @Target({ElementType.METHOD, ElementType.TYPE}) 
  3. @Retention(RetentionPolicy.RUNTIME) 
  4. @Inherited 
  5. @Documented 
  6. public @interface MyRedisLimiter { 
  7.     //限流key,缓存到redis的key=prefix()+key() 
  8.     String key(); 
  9.     //Key的前缀 
  10.     String prefix() default "limiter:"; 
  11.     //给定的时间范围 单位(秒) 默认1秒 即1秒内超过count次的请求将会被限流 
  12.     int period() default 1; 
  13.     // period()时间内最多访问的次数 
  14.     int count(); 

2.3,编写 limit.lua 脚本

通过 limit.lua 脚本实现限流逻辑,将 limit.lua 文件放到项目 resources 目录下。

local count
count = redis.call('get',KEYS[1])
-- 不超过阈值,则直接返回
if count and tonumber(count) > tonumber(ARGV[1]) then
    return count;
end
    -- 自加
    count = redis.call('incr',KEYS[1])
if tonumber(count) == 1 then
    -- 从第一次调用开始限流,设置对应key的过期时间
    redis.call('expire',KEYS[1],ARGV[2])
end
return count;

2.3,切面实现限流业务逻辑

通过 redisTemplate.execute() 执行 lua 脚本返回周期时间内的请求次数,根据次数判断是否触发限流。

import com.google.common.collect.ImmutableList;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.io.IOException;
import java.lang.reflect.Method;

@Slf4j
@Aspect
@Component
public class RedisLimitAspect {

    @Autowired
    private RedisTemplate redisTemplate;

    private static final String LIMIT_LUA_PATH = "limit.lua";
    private DefaultRedisScript<Number> redisScript;

    /**
      初始化加载Lua脚本
     /
    @PostConstruct
    public void init() {
        redisScript = new DefaultRedisScript<>();
        redisScript.setResultType(Number.class);
        ClassPathResource classPathResource = new ClassPathResource(LIMIT_LUA_PATH);
        try {
            //探测资源是否存在
            classPathResource.getInputStream(); 
            redisScript.setScriptSource(new ResourceScriptSource(classPathResource));
        } catch (IOException e) {
            log.error("未找到文件:{}", LIMIT_LUA_PATH);
        }
    }

    /**
      对@MyRedisLimiter注解拦截
     /
    @Pointcut("@annotation(com.ym.framework.limit.MyRedisLimiter)")
    public void pointcut(){}

    /**
      对切点进行继续处理
     */
    @Around("pointcut()")
    public Object limit(ProceedingJoinPoint pjp) {
        log.info("[限流切面]进入限流逻辑");
        MethodSignature signature = (MethodSignature) pjp.getSignature();
        Method method = signature.getMethod();
        MyRedisLimiter limitAnnotation = method.getAnnotation(MyRedisLimiter.class);
        int limitPeriod = limitAnnotation.period();
        int limitCount = limitAnnotation.count();
        String limitKey = limitAnnotation.key();

        String key = limitAnnotation.prefix() + limitKey;
        ImmutableList<String> keys = ImmutableList.of(key);
        try {
            // 执行lua脚本,返回count:limitPeriod内的请求次数
            Number count = (Number)redisTemplate.execute(redisScript, keys, limitCount, limitPeriod);
            log.info("[限流切面]try to access, this time count is {} for key: {}", count, key);
            if (count != null && count.intValue() <= limitCount) {
                //放行请求
                return pjp.proceed();
            } else {
                //触发限流,降级或抛异常
                throw new Exception("服务器繁忙,请稍后再试");
            }
        } catch (Throwable e) {
            if (e instanceof RuntimeException) {
                throw new RuntimeException(e.getLocalizedMessage());
            }
            throw new RuntimeException("[限流切面]服务器繁忙,请稍后再试");
        }
    }
}

2.4,接口测试

@Api(tags = "限流测试接口")
@Validated
@RestController
@CrossOrigin
@RequestMapping("/api/limit")
public class TestLimiterController {

    // 1秒内限制10个请求,key=limiter:limitTest
    @MyRedisLimiter(key = "limitTest", count = 10)
    @GetMapping(value = "/test1")
    public String test1() {
        System.out.println("test1");
        return "test1";
    }

    // 10秒内限制5个请求,key=limiter:REDPACKET
    @MyRedisLimiter(key = "REDPACKET", period = 10, count = 5)
    @GetMapping("/test2")
    public String test2() {
        System.out.println("test2");
        return "test2";
    }

    // 10秒内限制5个请求,key=limiter:SECKILL
    @MyRedisLimiter(key = "SECKILL", period = 10, count = 5)
    @GetMapping("/test3")
    public String test3() {
        System.out.println("test3");
        return "test3";
    }
}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值