分散式限流

crossoverJie發表於2018-04-28

分散式限流

前言

本文接著上文應用限流進行討論。

之前談到的限流方案只能針對於單個 JVM 有效,也就是單機應用。而對於現在普遍的分散式應用也得有一個分散式限流的方案。

基於此嘗試寫了這個元件:

github.com/crossoverJi…

DEMO

以下采用的是

github.com/crossoverJi…

來做演示。

在 Order 應用提供的介面中採取了限流。首先是配置了限流工具的 Bean:

@Configuration
public class RedisLimitConfig {


    @Value("${redis.limit}")
    private int limit;


    @Autowired
    private JedisConnectionFactory jedisConnectionFactory;

    @Bean
    public RedisLimit build() {
        RedisClusterConnection clusterConnection = jedisConnectionFactory.getClusterConnection();
        JedisCluster jedisCluster = (JedisCluster) clusterConnection.getNativeConnection();
        RedisLimit redisLimit = new RedisLimit.Builder<>(jedisCluster)
                .limit(limit)
                .build();

        return redisLimit;
    }
}
複製程式碼

接著在 Controller 使用元件:

    @Autowired
    private RedisLimit redisLimit ;

    @Override
    @CheckReqNo
    public BaseResponse<OrderNoResVO> getOrderNo(@RequestBody OrderNoReqVO orderNoReq) {
        BaseResponse<OrderNoResVO> res = new BaseResponse();

        //限流
        boolean limit = redisLimit.limit();
        if (!limit){
            res.setCode(StatusEnum.REQUEST_LIMIT.getCode());
            res.setMessage(StatusEnum.REQUEST_LIMIT.getMessage());
            return res ;
        }

        res.setReqNo(orderNoReq.getReqNo());
        if (null == orderNoReq.getAppId()){
            throw new SBCException(StatusEnum.FAIL);
        }
        OrderNoResVO orderNoRes = new OrderNoResVO() ;
        orderNoRes.setOrderId(DateUtil.getLongTime());
        res.setCode(StatusEnum.SUCCESS.getCode());
        res.setMessage(StatusEnum.SUCCESS.getMessage());
        res.setDataBody(orderNoRes);
        return res ;
    }
    
複製程式碼

為了方便使用,也提供了註解:

    @Override
    @ControllerLimit
    public BaseResponse<OrderNoResVO> getOrderNoLimit(@RequestBody OrderNoReqVO orderNoReq) {
        BaseResponse<OrderNoResVO> res = new BaseResponse();
        // 業務邏輯
        return res ;
    }
複製程式碼

該註解攔截了 http 請求,會再請求達到閾值時直接返回。

普通方法也可使用:

@CommonLimit
public void doSomething(){}
複製程式碼

會在呼叫達到閾值時丟擲異常。

為了模擬併發,在 User 應用中開啟了 10 個執行緒呼叫 Order(限流次數為5) 介面(也可使用專業的併發測試工具 JMeter 等)。

    @Override
    public BaseResponse<UserResVO> getUserByFeign(@RequestBody UserReqVO userReq) {
        //呼叫遠端服務
        OrderNoReqVO vo = new OrderNoReqVO();
        vo.setAppId(1L);
        vo.setReqNo(userReq.getReqNo());

        for (int i = 0; i < 10; i++) {
            executorService.execute(new Worker(vo, orderServiceClient));
        }

        UserRes userRes = new UserRes();
        userRes.setUserId(123);
        userRes.setUserName("張三");

        userRes.setReqNo(userReq.getReqNo());
        userRes.setCode(StatusEnum.SUCCESS.getCode());
        userRes.setMessage("成功");

        return userRes;
    }
    

    private static class Worker implements Runnable {

        private OrderNoReqVO vo;
        private OrderServiceClient orderServiceClient;

        public Worker(OrderNoReqVO vo, OrderServiceClient orderServiceClient) {
            this.vo = vo;
            this.orderServiceClient = orderServiceClient;
        }

        @Override
        public void run() {

            BaseResponse<OrderNoResVO> orderNo = orderServiceClient.getOrderNoCommonLimit(vo);
            logger.info("遠端返回:" + JSON.toJSONString(orderNo));

        }
    }    
複製程式碼

為了驗證分散式效果啟動了兩個 Order 應用。

分散式限流

效果如下:

分散式限流

分散式限流

分散式限流

實現原理

實現原理其實很簡單。既然要達到分散式全侷限流的效果,那自然需要一個第三方元件來記錄請求的次數。

其中 Redis 就非常適合這樣的場景。

  • 每次請求時將當前時間(精確到秒)作為 Key 寫入到 Redis 中,超時時間設定為 2 秒,Redis 將該 Key 的值進行自增。
  • 當達到閾值時返回錯誤。
  • 寫入 Redis 的操作用 Lua 指令碼來完成,利用 Redis 的單執行緒機制可以保證每個 Redis 請求的原子性。

Lua 指令碼如下:

--lua 下標從 1 開始
-- 限流 key
local key = KEYS[1]
-- 限流大小
local limit = tonumber(ARGV[1])

-- 獲取當前流量大小
local curentLimit = tonumber(redis.call('get', key) or "0")

if curentLimit + 1 > limit then
    -- 達到限流大小 返回
    return 0;
else
    -- 沒有達到閾值 value + 1
    redis.call("INCRBY", key, 1)
    redis.call("EXPIRE", key, 2)
    return curentLimit + 1
end
複製程式碼

Java 中的呼叫邏輯:

    public boolean limit() {
        String key = String.valueOf(System.currentTimeMillis() / 1000);
        Object result = null;
        if (jedis instanceof Jedis) {
            result = ((Jedis) this.jedis).eval(script, Collections.singletonList(key), Collections.singletonList(String.valueOf(limit)));
        } else if (jedis instanceof JedisCluster) {
            result = ((JedisCluster) this.jedis).eval(script, Collections.singletonList(key), Collections.singletonList(String.valueOf(limit)));
        } else {
            //throw new RuntimeException("instance is error") ;
            return false;
        }

        if (FAIL_CODE != (Long) result) {
            return true;
        } else {
            return false;
        }
    }
複製程式碼

所以只需要在需要限流的地方呼叫該方法對返回值進行判斷即可達到限流的目的。

當然這只是利用 Redis 做了一個粗暴的計數器,如果想實現類似於上文中的令牌桶演算法可以基於 Lua 自行實現。

Builder 構建器

在設計這個元件時想盡量的提供給使用者清晰、可讀性、不易出錯的 API。

比如第一步,如何構建一個限流物件。

最常用的方式自然就是建構函式,如果有多個域則可以採用重疊構造器的方式:

public A(){}
public A(int a){}
public A(int a,int b){}
複製程式碼

缺點也是顯而易見的:如果引數過多會導致難以閱讀,甚至如果引數型別一致的情況下客戶端顛倒了順序,但不會引起警告從而出現難以預測的結果。

第二種方案可以採用 JavaBean 模式,利用 setter 方法進行構建:

A a = new A();
a.setA(a);
a.setB(b);
複製程式碼

這種方式清晰易讀,但卻容易讓物件處於不一致的狀態,使物件處於執行緒不安全的狀態。

所以這裡採用了第三種建立物件的方式,構建器:

public class RedisLimit {

    private JedisCommands jedis;
    private int limit = 200;

    private static final int FAIL_CODE = 0;

    /**
     * lua script
     */
    private String script;

    private RedisLimit(Builder builder) {
        this.limit = builder.limit ;
        this.jedis = builder.jedis ;
        buildScript();
    }


    /**
     * limit traffic
     * @return if true
     */
    public boolean limit() {
        String key = String.valueOf(System.currentTimeMillis() / 1000);
        Object result = null;
        if (jedis instanceof Jedis) {
            result = ((Jedis) this.jedis).eval(script, Collections.singletonList(key), Collections.singletonList(String.valueOf(limit)));
        } else if (jedis instanceof JedisCluster) {
            result = ((JedisCluster) this.jedis).eval(script, Collections.singletonList(key), Collections.singletonList(String.valueOf(limit)));
        } else {
            //throw new RuntimeException("instance is error") ;
            return false;
        }

        if (FAIL_CODE != (Long) result) {
            return true;
        } else {
            return false;
        }
    }


    /**
     * read lua script
     */
    private void buildScript() {
        script = ScriptUtil.getScript("limit.lua");
    }


    /**
     *  the builder
     * @param <T>
     */
    public static class Builder<T extends JedisCommands>{
        private T jedis = null ;

        private int limit = 200;


        public Builder(T jedis){
            this.jedis = jedis ;
        }

        public Builder limit(int limit){
            this.limit = limit ;
            return this;
        }

        public RedisLimit build(){
            return new RedisLimit(this) ;
        }

    }
}
複製程式碼

這樣客戶端在使用時:

RedisLimit redisLimit = new RedisLimit.Builder<>(jedisCluster)
                .limit(limit)
                .build();
複製程式碼

更加的簡單直接,並且避免了將建立過程分成了多個子步驟。

這在有多個構造引數,但又不是必選欄位時很有作用。

因此順便將分散式鎖的構建器方式也一併更新了:

github.com/crossoverJi…

更多內容可以參考 Effective Java

API

從上文可以看出,使用過程就是呼叫 limit 方法。

   //限流
    boolean limit = redisLimit.limit();
    if (!limit){
       //具體限流邏輯
    }
複製程式碼

為了減少侵入性,也為了簡化客戶端提供了兩種註解方式。

@ControllerLimit

該註解可以作用於 @RequestMapping 修飾的介面中,並會在限流後提供限流響應。

實現如下:

@Component
public class WebIntercept extends WebMvcConfigurerAdapter {

    private static Logger logger = LoggerFactory.getLogger(WebIntercept.class);


    @Autowired
    private RedisLimit redisLimit;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new CustomInterceptor())
                .addPathPatterns("/**");
    }


    private class CustomInterceptor extends HandlerInterceptorAdapter {
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                                 Object handler) throws Exception {


            if (redisLimit == null) {
                throw new NullPointerException("redisLimit is null");
            }

            if (handler instanceof HandlerMethod) {
                HandlerMethod method = (HandlerMethod) handler;

                ControllerLimit annotation = method.getMethodAnnotation(ControllerLimit.class);
                if (annotation == null) {
                    //skip
                    return true;
                }

                boolean limit = redisLimit.limit();
                if (!limit) {
                    logger.warn("request has bean limit");
                    response.sendError(500, "request limit");
                    return false;
                }

            }

            return true;

        }
    }
}
複製程式碼

其實就是實現了 SpringMVC 中的攔截器,並在攔截過程中判斷是否有使用註解,從而呼叫限流邏輯。

前提是應用需要掃描到該類,讓 Spring 進行管理。

@ComponentScan(value = "com.crossoverjie.distributed.intercept")
複製程式碼

@CommonLimit

當然也可以在普通方法中使用。實現原理則是 Spring AOP (SpringMVC 的攔截器本質也是 AOP)。

@Aspect
@Component
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class CommonAspect {

    private static Logger logger = LoggerFactory.getLogger(CommonAspect.class);

    @Autowired
    private RedisLimit redisLimit ;

    @Pointcut("@annotation(com.crossoverjie.distributed.annotation.CommonLimit)")
    private void check(){}

    @Before("check()")
    public void before(JoinPoint joinPoint) throws Exception {

        if (redisLimit == null) {
            throw new NullPointerException("redisLimit is null");
        }

        boolean limit = redisLimit.limit();
        if (!limit) {
            logger.warn("request has bean limit");
            throw new RuntimeException("request has bean limit") ;
        }

    }
}
複製程式碼

很簡單,也是在攔截過程中呼叫限流。

當然使用時也得掃描到該包:

@ComponentScan(value = "com.crossoverjie.distributed.intercept")
複製程式碼

總結

限流在一個高併發大流量的系統中是保護應用的一個利器,成熟的方案也很多,希望對剛瞭解這一塊的朋友提供一些思路。

以上所有的原始碼:

感興趣的朋友可以點個 Star 或是提交 PR。

號外

最近在總結一些 Java 相關的知識點,感興趣的朋友可以一起維護。

地址: github.com/crossoverJi…

分散式限流

相關文章