• 从0到1构造自定义限流组件


    一 背景

    在系统高可用设计中,接口限流是一个非常重要环节,一方面是出于对自身服务器资源的保护,另一方面也是对依赖资源的一种保护措施。比如对于 Web 应用,我限制单机只能处理每秒 1000 次的请求,超过的部分直接返回错误给客户端。虽然这种做法损害了用户的使用体验,但是它是在极端并发下的无奈之举,是短暂的行为,因此是可以接受的。

    二 设计思路

    常见的限流有2种思路

    • 第一种是限制总量,也就是限制某个指标的累积上限,常见的是限制当前系统服务的用户总量,例如:某个抢购活动商品数量只有 100 个,限制参与抢购的用户上限为 1 万个,1 万以后的用户直接拒绝。

    • 第二种是限制时间量,也就是限制一段时间内某个指标的上限,例如 1 分钟内只允许 10000 个用户访问;每秒请求峰值最高为 10 万。

    三 限流算法

    目前实现限流算法主要分为3类,这里不详细展开介绍:

    1)时间窗口

    固定时间窗口算法是最简单的限流算法,它的实现原理就是控制单位时间内请求的数量,但是这个算法有个缺点就是临界值问题。
    为了解决临界值的问题,又推出滑动时间窗口算法,其实现原理大致上是将时间分为一个一个小格子,在统计请求数量的时候,是通过统计滑动时间周期内的请求数量。

    2)漏斗算法

    漏斗算法的核心是控制总量,请求流入的速率不确定,超过流量部分益出,该算法比较适用于针对突发流量,想要尽可能的接收全部请求的场景。其缺点也比较明显,这个总量怎么评估,大小怎么配置,而且一旦初始化也没法动态调整。

    3)令牌桶算法

    令牌桶算法的核心是控制速率,令牌产生的速度是关键,不断的请求获取令牌,获取不到就丢弃。该算法比较适用于针对突发流量,以保护自身服务资源以及依赖资源为主,支持动态调整速率。缺点的话实现比较复杂,而且会丢弃很多请求。

    四 实现步骤

    我们自定义的这套限流组件有是基于guava RateLimiter封装的,采用令牌桶算法以控制速率为主,支持DUCC动态配置,同时支持限流后的降级措施。接下来看一下整体实现方案

    1、自定义RateLimiter Annotation标签

    这里主要对限流相关属性的一个定义,包括每秒产生的令牌数、获取令牌超时时间、降级逻辑实现以及限流开关等内容

    @Documented
    @Target({ElementType.METHOD, ElementType.TYPE})
    @Retention(RetentionPolicy.RUNTIME)
    public @interface SysRateLimit {
    
        /**
         * 每秒产生的令牌数 默认500
         *
         * @return
         */
        double permitsPerSecond() default 500D;
    
        /**
         * 获取令牌超时时间 默认100
         *
         * @return
         */
        long timeout() default 100;
    
        /**
         * 获取令牌超时时间单位 默认毫秒
         *
         * @return
         */
        TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
    
        /**
         * 服务降级方法名称 Spring bean id
         *
         * @return
         */
        String fallbackBeanId() default "";
    
        /**
         * 限流key 唯一
         *
         * @return
         */
        String limitKey() default "";
    }
    

    2、基于Spring Aspect 构造切面

    首先就是我们需要构造一个Aspect切面用于扫描我们自定义的SysRateLimit标签

    @Slf4j
    @EnableAspectJAutoProxy
    @Aspect
    public class SysRateLimitAspect {
        
        /**
         * 自定义切入点
         */
        @Pointcut("@annotation(com.jd.smb.service.ratelimiter.annotation.SysRateLimit)")
        public void pointCut() {
    
        }
    
        /**
         * 方法前执行限流方案
         *
         * @param joinPoint
         * @return
         * @throws Throwable
         */
        @Around("pointCut()")
        public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
            MethodSignature signature = (MethodSignature) joinPoint.getSignature();
            // 如果未获取到对象,直接执行方法
            if (signature == null) {
                return joinPoint.proceed();
            }
    
            try {
                Method method = joinPoint.getTarget().getClass().getDeclaredMethod(signature.getName(), signature.getMethod().getParameterTypes());
                // 获取注解对象
                SysRateLimit sysRateLimit = method.getAnnotation(SysRateLimit.class);
                if (sysRateLimit == null) {
                    return joinPoint.proceed();
                }
                
            } catch (Exception e) {
                // todo log
            }
            return joinPoint.proceed();
        }
    }
    

    获取自定义SysRateLimit标签的各种属性

     // 限流key
    String limitKey = sysRateLimit.limitKey();
    if (StringUtils.isBlank(limitKey)) {
        return joinPoint.proceed();
    }
    // 令牌桶数量
    double permitsPerSecond = sysRateLimit.permitsPerSecond();
    // 获取令牌超时时间
    long timeout = sysRateLimit.timeout();
    // 获取令牌超时时间单位
    TimeUnit timeUnit = sysRateLimit.timeUnit();
    

    将我们自定义的SysRateLimiter 和 Guava RateLimiter 进行整合

    1. 首先我们需要构造一个全局Map,用于存储我们开启限流的方法,key就是我们定义的limitKey, value就是我们转换后的Guava RateLimiter
     /**
     * 存储RateLimiter(key: limitKey value:RateLimiter )
     */
    private static final Map<String, RateLimiter> LIMITER_MAP = new ConcurrentHashMap<>();
    
    1. 接着就是核心逻辑:这里首先从我们创建的Map中获取Guava RateLimiter,获取不到就创建RateLimiter.create(permitsPerSecond) ;然后调用RateLimiter.tryAcquire()尝试获取令牌桶,获取成功则执行后续的逻辑,这里重点获取失败后,我们需要执行我们的降级方法。(注意:Guava RateLimiter 有很多API,这里我们不展开讨论,后续会针对Guava限流的源码进行详细的解析)
    RateLimiter rateLimiter;
    // Map中是否存在 存在直接获取
    if (LIMITER_MAP.containsKey(limitKey)) {
        rateLimiter = LIMITER_MAP.get(limitKey);
    } else {
        // 不存在创建后放到Map中
        rateLimiter = RateLimiter.create(permitsPerSecond);
        LIMITER_MAP.put(limitKey, rateLimiter);
    }
    // 尝试获取令牌
    if (!rateLimiter.tryAcquire(timeout, timeUnit)) {
        // todo 限流后降级措施
        return this.fallBack(sysRateLimit, joinPoint, signature);
    }
    

    降级方案执行

    上面我们在获取令牌桶超时后,需要执行我们的降级逻辑,怎么做呢?也很简单,我们在定义SysRateLimiter的时候有个fallBackBeanId,这个就是我们执行降级逻辑的bean对象Id,需要我们提前进行创建。接着我们看一下是怎么实现的。

        /**
         * 执行降级逻辑
         *
         * @param sysRateLimit
         * @param joinPoint
         * @param signature
         * @return
         */
        private Object fallBack(SysRateLimit sysRateLimit, ProceedingJoinPoint joinPoint, MethodSignature signature) {
            String fallbackBeanId = sysRateLimit.fallbackBeanId();
            // 当没有配置具体的降级实现方案的时候 可以结合业务世纪情况设置限流错误码
            if (StringUtils.isBlank(fallbackBeanId)) {
                // 自定义的 可以结合自己系统里的进行设置
                return ApiResult.error(ResultCode.REACH_RATE_LIMIT);
            }
    
            try {
                // SpringContext中通过BeanId获取对象 SpringUtils只是获取bean对象的工具类 有多种实现方式 可自行百度
                Object bean = SpringUtils.getBean(fallbackBeanId);
                Method method = bean.getClass().getMethod(signature.getName(), signature.getParameterTypes());
                // 执行对应的方法
                return method.invoke(bean, joinPoint.getArgs());
            } catch (Exception e) {
                // todo error log
            }
            return ApiResult.error(ResultCode.REACH_RATE_LIMIT);
        }
    

    这样我们大概的一个架子就弄好了。 接下来我们看看实际该如何使用

    3、具体应用

    在方法入口引入SysRateLimiter标签

    @Slf4j
    @RestController
    @RequestMapping("/api/user")
    @RequiredArgsConstructor
    public class UserQueryController extends AbstractController {
    
        /**
         * 查询用户信息
         *
         * @param request
         * @return
         */
        @GetMapping("/info/{id}")
        @SysRateLimit(permitsPerSecond = 500, limitKey = "UserQueryController.info", fallbackBeanId = "userQueryControllerFallBack",
                timeout = 100, timeUnit = TimeUnit.MILLISECONDS)
        public ApiResult info(@PathVariable Long id, HttpServletRequest request) {
            // todo 业务逻辑查询 这里不展开
            return ApiResult.success();
        }
    }
    

    设置降级方法

    @Service
    public class UserQueryControllerFallBack {
    
        /**
         * 降级后执行的逻辑
         *
         * @param request
         * @return
         */
        public ApiResult info(Long id, HttpServletRequest request) {
            // todo 编写限流降级后的逻辑 可以是降级码 也可以是默认对象
            return ApiResult.success(null);
        }
    }
    

    当请求进来的时候,会结合我们设置的阈值进行令牌桶的获取,获取失败后会执行限流,这里我们进行了限流后的降级处理。其实到这里我们完成限流组件的简单封装和使用,但是仍有一些点需要我们进行处理,例如如何动态设置令牌的数量,接下来我们就看一下如何实现令牌的动态设置。

    4、动态设置令牌数量

    通过DUCC配置令牌数量 我们需要定义一个DUCC配置,这里面内容很简单,配置我们设置limitKey的令牌数量

    @Data
    @Slf4j
    @Component
    public class RateLimitConfig {
    
        /**
         * 配置config key: limitKey value: 数量
         */
        private Map<String, Integer> limitConfig;
    
        /**
         * 监听ducc配置
         *
         * @param json
         */
        @LafValue(key = "rate.limit.conf")
        public void setConfig(String json) {
            if (StringUtils.isBlank(json)) {
                return;
            }
            Map<String, Integer> map = JsonModelUtils.getModel(json, Map.class, null);
            if (map != null) {
                Wrapper.wrapperBean(map, this, true);
            }
        }
    }
    

    通过DUCC配置获取指定limitKey的令牌数量,获取失败则采用方法设置默认数量,这样我们后面设置令牌数量就可以通过DUCC动态的配置了

     /**
         * 获取令牌桶数量
         *
         * @param sysRateLimit
         * @return
         */
        private double getPermitsPerSecond(SysRateLimit sysRateLimit) {
            // 方法默认令牌数量
            double defaultValue = sysRateLimit.permitsPerSecond();
            if (rateLimitConfig == null || rateLimitConfig.getLimitConfig() == null) {
                return defaultValue;
            }
            // 配置的令牌数量
            Integer value = rateLimitConfig.getLimitConfig().get(sysRateLimit.limitKey());
            if (value == null) {
                return defaultValue;
            }
            return value;
        }
    

    5、后续其他配置

    其实后续我们的其他属性都可以通过DUCC动态化的来配置,这里呢因为和令牌桶数量类似,就不再展开描述了。感兴趣的小伙伴可以自行设置,根据我们的使用,使用默认配置即可。

    作者:京东零售 王磊

    来源:京东云开发者社区

  • 相关阅读:
    Java.lang.Class类 getGenericSuperclass()方法有什么功能呢?
    Ghostscript 在 Linux 和 Windows 系统的应用与问题解决
    linux tcp 半连接队列和全连接队列
    scratch成绩查询 电子学会图形化编程scratch等级考试四级真题和答案解析2022年6月
    怎么将电脑excel文档内的数据转换为图片形式
    Flink SQL Hudi 实战
    高并发下秒杀商品,你必须知道的9个细节
    Lombok安装及Lombok使用
    day13学习总结
    Nginx(动静分离、分配缓冲区、资源缓存、防盗链、资源压缩、IP黑白名单、大文件传输配置、跨域配置、高可用、性能优化)
  • 原文地址:https://www.cnblogs.com/jingdongkeji/p/17494250.html