[一個簡單的秒殺架構的演變]4. 使用分佈式限流

地址

目錄

1. 思路介紹

之前說到樂觀鎖更新操作還是執行了近 100 次 SQL,其實這 100 次裏就只有 10 次扣庫存成功纔是有效請求,其他的都是無效請求,爲了遵從最後落地到數據庫的請求數要儘量少的原則,這裏我們使用限流,把大部分無效請求攔截,儘可能保證最終到達數據庫的都是有效請求

這次我們引入限流,這裏可以先查看一篇文章: 高併發下的限流分析

看完可以瞭解幾種限流算法(計數器(時間窗口),漏桶,令牌桶)以及區別,對比下來,我們這裏使用固定時間窗口最好,這裏使用 Redis + Lua 的分佈式限流方式

2. 限流實現

先寫一個工具類,再寫一個註解封裝,兩種形式都可以使用

2.1. Lua腳本

  • 秒級限流(每秒限制多少請求)
-- 實現原理
-- 每次請求都將當前時間,精確到秒作爲 key 放入 Redis 中
-- 超時時間設置爲 2s, Redis 將該 key 的值進行自增
-- 當達到閾值時返回錯誤,表示請求被限流
-- 寫入 Redis 的操作用 Lua 腳本來完成
-- 利用 Redis 的單線程機制可以保證每個 Redis 請求的原子性

-- 資源唯一標誌位
local key = KEYS[1]
-- 限流大小
local limit = tonumber(ARGV[1])

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

if currentLimit + 1 > limit then
    -- 達到限流大小 返回
    return 0;
else
    -- 沒有達到閾值 value + 1
    redis.call("INCRBY", key, 1)
    -- 設置過期時間
    redis.call("EXPIRE", key, 2)
    return currentLimit + 1
end
  • 自定義參數限流(自定義多少時間限制多少請求)
-- 實現原理
-- 每次請求都去 Redis 取到當前限流開始時間和限流累計請求數
-- 判斷限流開始時間加超時時間戳(限流時間)大於當前請求時間戳
-- 再判斷當前時間窗口請求內是否超過限流最大請求數
-- 當達到閾值時返回錯誤,表示請求被限流,否則通過
-- 寫入 Redis 的操作用 Lua 腳本來完成
-- 利用 Redis 的單線程機制可以保證每個 Redis 請求的原子性

-- 一個時間窗口開始時間(限流開始時間)key名稱
local timeKey = KEYS[1]
-- 一個時間窗口內請求的數量累計(限流累計請求數)key名稱
local requestKey = KEYS[2]
-- 限流大小,限流最大請求數
local maxRequest = tonumber(ARGV[1])
-- 當前請求時間戳
local nowTime = tonumber(ARGV[2])
-- 超時時間戳,一個時間窗口時間(毫秒)(限流時間)
local timeRequest = tonumber(ARGV[3])

-- 獲取限流開始時間,不存在爲0
local currentTime = tonumber(redis.call('get', timeKey) or "0")
-- 獲取限流累計請求數,不存在爲0
local currentRequest = tonumber(redis.call('get', requestKey) or "0")

-- 判斷當前請求時間戳是不是在當前時間窗口中
-- 限流開始時間加超時時間戳(限流時間)大於當前請求時間戳
if currentTime + timeRequest > nowTime then
    -- 判斷當前時間窗口請求內是否超過限流最大請求數
    if currentRequest + 1 > maxRequest then
        -- 在時間窗口內且超過限流最大請求數,返回
        return 0;
    else
        -- 在時間窗口內且請求數沒超,請求數加一
        redis.call("INCRBY", requestKey, 1)
        return currentRequest + 1;
    end
else
    -- 超時後重置,開啓一個新的時間窗口
    redis.call('set', timeKey, nowTime)
    redis.call('set', requestKey, '0')
    -- 設置過期時間
    redis.call("EXPIRE", timeKey, timeRequest / 1000)
    redis.call("EXPIRE", requestKey, timeRequest / 1000)
    -- 請求數加一
    redis.call("INCRBY", requestKey, 1)
    return 1;
end

2.2. 工具類

  • RedisLimitUtil
package com.example.util;

import ...;

/**
 * RedisLimitUtil
 *
 * @author wliduo[[email protected]]
 * @date 2019/11/14 16:44
 */
@Component
public class RedisLimitUtil {

    /**
     * logger
     */
    private static final Logger logger = LoggerFactory.getLogger(RedisLimitUtil.class);

    /**
     * 秒級限流(每秒限制多少請求)字符串腳本
     */
    private static String LIMIT_SECKILL_SCRIPT = null;

    /**
     * 自定義參數限流(自定義多少時間限制多少請求)字符串腳本
     */
    private static String LIMIT_CUSTOM_SCRIPT = null;

    /**
     * redis-key-前綴-limit-限流
     */
    private static final String LIMIT = "limit:";

    /**
     * redis-key-名稱-limit-一個時間窗口內請求的數量累計(限流累計請求數)
     */
    private static final String LIMIT_REQUEST = "limit:request";

    /**
     * redis-key-名稱-limit-一個時間窗口開始時間(限流開始時間)
     */
    private static final String LIMIT_TIME = "limit:time";

    /**
     * 構造方法初始化加載Lua腳本
     */
    public RedisLimitUtil() {
        LIMIT_SECKILL_SCRIPT = getScript("redis/limit-seckill.lua");
        LIMIT_CUSTOM_SCRIPT = getScript("redis/limit-custom.lua");
    }

    /**
     * 秒級限流判斷(每秒限制多少請求)
     *
	 * @param maxRequest 限流最大請求數
     * @return boolean
     * @throws
     * @author wliduo[[email protected]]
     * @date 2019/11/25 17:57
     */
    public Long limit(String maxRequest) {
        // 獲取key名,當前時間戳
        String key = LIMIT + String.valueOf(System.currentTimeMillis() / 1000);
        // 傳入參數,限流最大請求數
        List<String> args = new ArrayList<>();
        args.add(maxRequest);
        return eval(LIMIT_SECKILL_SCRIPT, Collections.singletonList(key), args);
    }

    /**
     * 自定義參數限流判斷(自定義多少時間限制多少請求)
     *
     * @param maxRequest 限流最大請求數
     * @param timeRequest 一個時間窗口(秒)
     * @return boolean
     * @throws
     * @author wliduo[[email protected]]
     * @date 2019/11/25 17:57
     */
    public Long limit(String maxRequest, String timeRequest) {
        // 獲取key名,一個時間窗口開始時間(限流開始時間)和一個時間窗口內請求的數量累計(限流累計請求數)
        List<String> keys = new ArrayList<>();
        keys.add(LIMIT_TIME);
        keys.add(LIMIT_REQUEST);
        // 傳入參數,限流最大請求數,當前時間戳,一個時間窗口時間(毫秒)(限流時間)
        List<String> args = new ArrayList<>();
        args.add(maxRequest);
        args.add(String.valueOf(System.currentTimeMillis()));
        args.add(timeRequest);
        return eval(LIMIT_CUSTOM_SCRIPT, keys, args);
    }

    /**
     * 執行Lua腳本方法
     *
     * @param script
	 * @param keys
	 * @param args
     * @return java.lang.Object
     * @throws
     * @author wliduo[[email protected]]
     * @date 2019/11/26 10:50
     */
    private Long eval(String script, List<String> keys, List<String> args) {
        // 執行腳本
        Object result = JedisUtil.eval(script, keys, args);
        // 結果請求數大於0說明不被限流
        return (Long) result;
    }

    /**
     * 獲取Lua腳本
     *
     * @param path
     * @return java.lang.String
     * @throws
     * @author wliduo[[email protected]]
     * @date 2019/11/25 17:57
     */
    private static String getScript(String path) {
        StringBuilder stringBuilder = new StringBuilder();
        InputStream inputStream = RedisLimitUtil.class.getClassLoader().getResourceAsStream(path);
        try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream))) {
            String str;
            while ((str = bufferedReader.readLine()) != null) {
                stringBuilder.append(str).append(System.lineSeparator());
            }
        } catch (IOException e) {
            logger.error(Arrays.toString(e.getStackTrace()));
            throw new CustomException("獲取Lua限流腳本出現問題: " + Arrays.toString(e.getStackTrace()));
        }
        return stringBuilder.toString();
    }

}

2.3. 註解

  • pom.xml(註解藉助AOP實現)
<!-- AOP -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
  • Limit
package com.example.limit;

import java.lang.annotation.*;

/**
 * 限流注解
 *
 * @author wliduo[[email protected]]
 * @date 2019/11/26 9:59
 */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Limit {

    /**
     * 限流最大請求數
     * @return
     */
    String maxRequest() default "10";

    /**
     * 一個時間窗口(毫秒)
     * @return
     */
    String timeRequest() default "1000";

}
  • LimitAspect
package com.example.limit;

import ...;

/**
 * LimitAspect限流切面
 *
 * @author wliduo[[email protected]]
 * @date 2019/11/26 10:07
 */
@Order(0)
@Aspect
@Component
public class LimitAspect {

    /**
     * logger
     */
    private static final Logger logger = LoggerFactory.getLogger(LimitAspect.class);

    /**
     * 一個時間窗口時間(毫秒)(限流時間)
     */
    private static final String TIME_REQUEST = "1000";

    /**
     * RedisLimitUtil
     */
    @Autowired
    private RedisLimitUtil redisLimitUtil;

    /**
     * 對應註解
     *
     * @param
     * @return void
     * @throws
     * @author wliduo[[email protected]]
     * @date 2019/11/26 10:11
     */
    @Pointcut("@annotation(com.example.limit.Limit)")
    public void aspect() {}

    /**
     * 切面
     *
     * @param proceedingJoinPoint
     * @return java.lang.Object
     * @throws
     * @author wliduo[[email protected]]
     * @date 2019/11/26 10:11
     */
    @Around("aspect() && @annotation(limit)")
    public Object Interceptor(ProceedingJoinPoint proceedingJoinPoint, Limit limit) {
        Object result = null;
        Long maxRequest = 0L;
        // 一個時間窗口(毫秒)爲1000的話默認調用秒級限流判斷(每秒限制多少請求)
        if (TIME_REQUEST.equals(limit.timeRequest())) {
            maxRequest = redisLimitUtil.limit(limit.maxRequest());
        } else {
            maxRequest = redisLimitUtil.limit(limit.maxRequest(), limit.timeRequest());
        }
        // 返回請求數量大於0說明不被限流
        if (maxRequest > 0) {
            // 放行,執行後續方法
            try {
                result = proceedingJoinPoint.proceed();
            } catch (Throwable throwable) {
                throw new CustomException(throwable.getMessage());
            }
        } else {
            // 直接返回響應結果
            throw new CustomException("請求擁擠,請稍候重試");
        }
        return result;
    }

    /**
     * 執行方法前再執行
     *
     * @param limit
     * @return void
     * @throws
     * @author wliduo[[email protected]]
     * @date 2019/11/26 10:10
     */
    @Before("aspect() && @annotation(limit)")
    public void before(Limit limit) {
        // logger.info("before");
    }

    /**
     * 執行方法後再執行
     *
     * @param limit
     * @return void
     * @throws
     * @author wliduo[[email protected]]
     * @date 2019/11/26 10:10
     */
    @After("aspect() && @annotation(limit)")
    public void after(Limit limit) {
        // logger.info("after");
    }

}

2.4. 測試入口

寫個 LimitController 簡單測試下,工具類和註解的使用,可以使用 PostMan 或者 JMeter 測試,都是 Get 請求,也可以直接用瀏覽器窗口打開請求

package com.example.controller;

import ...;

/**
 *  計數器(固定時間窗口)限流接口測試
 *
 * @author wliduo[[email protected]]
 * @date 2019/11/24 19:27
 */
@RestController
@RequestMapping("/limit")
public class LimitController {

    /**
     * logger
     */
    private static final Logger logger = LoggerFactory.getLogger(LimitController.class);

    /**
     * 一個時間窗口內最大請求數(限流最大請求數)
     */
    private static final Long MAX_NUM_REQUEST = 2L;

    /**
     * 一個時間窗口時間(毫秒)(限流時間)
     */
    private static final Long TIME_REQUEST = 5000L;

    /**
     * 一個時間窗口內請求的數量累計(限流請求數累計)
     */
    private AtomicInteger requestNum = new AtomicInteger(0);

    /**
     * 一個時間窗口開始時間(限流開始時間)
     */
    private AtomicLong requestTime = new AtomicLong(System.currentTimeMillis());

    /**
     * RedisLimitUtil
     */
    @Autowired
    private RedisLimitUtil redisLimitUtil;

    /**
     * 計數器(固定時間窗口)請求接口
     *
     * @param
     * @return java.lang.String
     * @throws
     * @author wliduo[[email protected]]
     * @date 2019/11/25 16:19
     */
    @GetMapping
    public String index() {
        long nowTime = System.currentTimeMillis();
        // 判斷是在當前時間窗口(限流開始時間)
        if (nowTime < requestTime.longValue() + TIME_REQUEST) {
            // 判斷當前時間窗口請求內是否限流最大請求數
            if (requestNum.longValue() < MAX_NUM_REQUEST) {
                // 在時間窗口內且請求數量還沒超過最大,請求數加一
                requestNum.incrementAndGet();
                logger.info("請求成功,當前請求是{}次", requestNum.intValue());
                return "請求成功,當前請求是" + requestNum.intValue() + "次";
            }
        } else {
            // 超時後重置(開啓一個新的時間窗口)
            requestTime = new AtomicLong(nowTime);
            requestNum = new AtomicInteger(0);
        }
        logger.info("請求失敗,被限流");
        return "請求失敗,被限流";
    }

    /**
     * 計數器(固定時間窗口)請求接口(限流工具類實現)
     *
     * @param
     * @return java.lang.String
     * @throws
     * @author wliduo[[email protected]]
     * @date 2019/11/25 18:02
     */
    @GetMapping("/redis")
    public String redis() {
        Long maxRequest = redisLimitUtil.limit(MAX_NUM_REQUEST.toString());
        // 結果請求數大於0說明不被限流
        if (maxRequest > 0) {
            logger.info("請求成功,當前請求是{}次", maxRequest);
            return "請求成功,當前請求是" + maxRequest + "次";
        }
        logger.info("請求失敗,被限流");
        return "請求擁擠,請稍候重試";
    }

    /**
     * 計數器(固定時間窗口)請求接口(限流注解實現)
     *
     * @param
     * @return java.lang.String
     * @throws
     * @author wliduo[[email protected]]
     * @date 2019/11/26 9:46
     */
    @Limit(maxRequest = "2", timeRequest = "3000")
    @GetMapping("/annotation")
    public String annotation() {
        logger.info("請求成功");
        return "請求成功";
    }

}

3. 代碼實現

有了上面的註解,我們只需要 Controller 加個方法就行,在 SeckillEvolutionController 添加樂觀鎖加緩存再加限流下單的入口方法

  • SeckillEvolutionController
/**
 * 使用樂觀鎖下訂單,並且添加讀緩存,再添加限流
 *
 * @param id 商品ID
 * @return com.example.common.ResponseBean
 * @throws Exception
 * @author wliduo[[email protected]]
 * @date 2019/11/22 14:24
 */
@Limit
@PostMapping("/createOptimisticLockOrderWithRedisLimit/{id}")
public ResponseBean createOptimisticLockOrderWithRedisLimit(@PathVariable("id") Integer id) throws Exception {
    // 錯誤的,線程不安全
    // Integer orderCount = seckillEvolutionService.createOptimisticLockOrderWithRedisWrong(id);
    // 正確的,線程安全
    Integer orderCount = seckillEvolutionService.createOptimisticLockOrderWithRedisSafe(id);
    return new ResponseBean(HttpStatus.OK.value(), "購買成功", null);
}

添加註解 @Limit 即可,默認限流爲每秒最多請求10次

4. 開始測試

使用 JMeter 測試上面的代碼,JMeter 的使用可以查看: JMeter的安裝使用

我們調用一下商品庫存初始化的方法,我使用的是 PostMan,初始化庫存表商品 10 個庫存,而且清空訂單表

圖片

接着使用 PostMan 調用緩存預熱方法,提前加載好緩存

圖片

這時候可以看到我們的數據,庫存爲 10,賣出爲 0 ,訂單表爲空

圖片

緩存數據也是這樣

圖片

打開 JMeter,添加測試計劃(測試計劃文件在項目的src\main\resources\jmx下),模擬 500 個併發線程測試秒殺 10 個庫存的商品

圖片

PS: 這次我們填寫 Ramp-Up 時間爲 5 秒,意思爲執行 5 秒,每秒執行 100 個併發,因爲如果都在 1S 內執行完,會被限流,然後填寫請求地址,點擊啓動圖標開始

圖片

可以看到 500 個併發線程執行完,數據是正確的

圖片

我們可以看下 Druid 的監控,地址: http://localhost:8080/druid/sql.html

圖片

使用了限流,可以看到樂觀鎖更新不像之前那樣執行 157 次了,只執行了 36 次,很多請求直接被限流了,我們看下後臺日誌,可以看到很多請求直接被限流限制了,這樣就達到了我們的目的

圖片

5. 最後總結

那我們還可以怎麼優化提高吞吐量以及性能呢,我們上文所有例子其實都是同步請求,完全可以利用同步轉異步來提高性能,這裏我們將下訂單的操作進行異步化,利用消息隊列來進行解耦,這樣可以然 DB 異步執行下單

每當一個請求通過了限流和庫存校驗之後就將訂單信息發給消息隊列,這樣一個請求就可以直接返回了,消費程序做下訂單的操作,對數據進行入庫落地,因爲異步了,所以最終需要採取回調或者是其他提醒的方式提醒用戶購買完成

參考

  1. 感謝hllcve_的Spring Boot自定義註解: https://www.jianshu.com/p/e04eeae86cf9
發佈了20 篇原創文章 · 獲贊 20 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章