地址
- 0. 整體流程
- 1. 傳統(tǒng)方式
- 2. 使用樂觀鎖
- 3. 使用緩存
- 4. 使用分布式限流
- 5. 使用隊(duì)列異步下單
1. 思路介紹
之前說到樂觀鎖更新操作還是執(zhí)行了近 100 次 SQL抚芦,其實(shí)這 100 次里就只有 10 次扣庫存成功才是有效請求样悟,其他的都是無效請求鸟顺,為了遵從最后落地到數(shù)據(jù)庫的請求數(shù)要盡量少的原則珍坊,這里我們使用限流理肺,把大部分無效請求攔截,盡可能保證最終到達(dá)數(shù)據(jù)庫的都是有效請求
這次我們引入限流闷哆,這里可以先查看一篇文章: 高并發(fā)下的限流分析
看完可以了解幾種限流算法(計(jì)數(shù)器(時(shí)間窗口)礁遵,漏桶,令牌桶)以及區(qū)別鸡典,對比下來源请,我們這里使用固定時(shí)間窗口最好枪芒,這里使用 Redis + Lua 的分布式限流方式
2. 限流實(shí)現(xiàn)
先寫一個(gè)工具類彻况,再寫一個(gè)注解封裝,兩種形式都可以使用
2.1. Lua腳本
- 秒級限流(每秒限制多少請求)
-- 實(shí)現(xiàn)原理
-- 每次請求都將當(dāng)前時(shí)間舅踪,精確到秒作為 key 放入 Redis 中
-- 超時(shí)時(shí)間設(shè)置為 2s纽甘, Redis 將該 key 的值進(jìn)行自增
-- 當(dāng)達(dá)到閾值時(shí)返回錯(cuò)誤,表示請求被限流
-- 寫入 Redis 的操作用 Lua 腳本來完成
-- 利用 Redis 的單線程機(jī)制可以保證每個(gè) Redis 請求的原子性
-- 資源唯一標(biāo)志位
local key = KEYS[1]
-- 限流大小
local limit = tonumber(ARGV[1])
-- 獲取當(dāng)前流量大小
local currentLimit = tonumber(redis.call('get', key) or "0")
if currentLimit + 1 > limit then
-- 達(dá)到限流大小 返回
return 0;
else
-- 沒有達(dá)到閾值 value + 1
redis.call("INCRBY", key, 1)
-- 設(shè)置過期時(shí)間
redis.call("EXPIRE", key, 2)
return currentLimit + 1
end
- 自定義參數(shù)限流(自定義多少時(shí)間限制多少請求)
-- 實(shí)現(xiàn)原理
-- 每次請求都去 Redis 取到當(dāng)前限流開始時(shí)間和限流累計(jì)請求數(shù)
-- 判斷限流開始時(shí)間加超時(shí)時(shí)間戳(限流時(shí)間)大于當(dāng)前請求時(shí)間戳
-- 再判斷當(dāng)前時(shí)間窗口請求內(nèi)是否超過限流最大請求數(shù)
-- 當(dāng)達(dá)到閾值時(shí)返回錯(cuò)誤抽碌,表示請求被限流悍赢,否則通過
-- 寫入 Redis 的操作用 Lua 腳本來完成
-- 利用 Redis 的單線程機(jī)制可以保證每個(gè) Redis 請求的原子性
-- 一個(gè)時(shí)間窗口開始時(shí)間(限流開始時(shí)間)key名稱
local timeKey = KEYS[1]
-- 一個(gè)時(shí)間窗口內(nèi)請求的數(shù)量累計(jì)(限流累計(jì)請求數(shù))key名稱
local requestKey = KEYS[2]
-- 限流大小,限流最大請求數(shù)
local maxRequest = tonumber(ARGV[1])
-- 當(dāng)前請求時(shí)間戳
local nowTime = tonumber(ARGV[2])
-- 超時(shí)時(shí)間戳货徙,一個(gè)時(shí)間窗口時(shí)間(毫秒)(限流時(shí)間)
local timeRequest = tonumber(ARGV[3])
-- 獲取限流開始時(shí)間左权,不存在為0
local currentTime = tonumber(redis.call('get', timeKey) or "0")
-- 獲取限流累計(jì)請求數(shù),不存在為0
local currentRequest = tonumber(redis.call('get', requestKey) or "0")
-- 判斷當(dāng)前請求時(shí)間戳是不是在當(dāng)前時(shí)間窗口中
-- 限流開始時(shí)間加超時(shí)時(shí)間戳(限流時(shí)間)大于當(dāng)前請求時(shí)間戳
if currentTime + timeRequest > nowTime then
-- 判斷當(dāng)前時(shí)間窗口請求內(nèi)是否超過限流最大請求數(shù)
if currentRequest + 1 > maxRequest then
-- 在時(shí)間窗口內(nèi)且超過限流最大請求數(shù)痴颊,返回
return 0;
else
-- 在時(shí)間窗口內(nèi)且請求數(shù)沒超赏迟,請求數(shù)加一
redis.call("INCRBY", requestKey, 1)
return currentRequest + 1;
end
else
-- 超時(shí)后重置,開啟一個(gè)新的時(shí)間窗口
redis.call('set', timeKey, nowTime)
redis.call('set', requestKey, '0')
-- 設(shè)置過期時(shí)間
redis.call("EXPIRE", timeKey, timeRequest / 1000)
redis.call("EXPIRE", requestKey, timeRequest / 1000)
-- 請求數(shù)加一
redis.call("INCRBY", requestKey, 1)
return 1;
end
2.2. 工具類
- RedisLimitUtil
package com.example.util;
import ...;
/**
* RedisLimitUtil
*
* @author wliduo[i@dolyw.com]
* @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;
/**
* 自定義參數(shù)限流(自定義多少時(shí)間限制多少請求)字符串腳本
*/
private static String LIMIT_CUSTOM_SCRIPT = null;
/**
* redis-key-前綴-limit-限流
*/
private static final String LIMIT = "limit:";
/**
* redis-key-名稱-limit-一個(gè)時(shí)間窗口內(nèi)請求的數(shù)量累計(jì)(限流累計(jì)請求數(shù))
*/
private static final String LIMIT_REQUEST = "limit:request";
/**
* redis-key-名稱-limit-一個(gè)時(shí)間窗口開始時(shí)間(限流開始時(shí)間)
*/
private static final String LIMIT_TIME = "limit:time";
/**
* 構(gòu)造方法初始化加載Lua腳本
*/
public RedisLimitUtil() {
LIMIT_SECKILL_SCRIPT = getScript("redis/limit-seckill.lua");
LIMIT_CUSTOM_SCRIPT = getScript("redis/limit-custom.lua");
}
/**
* 秒級限流判斷(每秒限制多少請求)
*
* @param maxRequest 限流最大請求數(shù)
* @return boolean
* @throws
* @author wliduo[i@dolyw.com]
* @date 2019/11/25 17:57
*/
public Long limit(String maxRequest) {
// 獲取key名蠢棱,當(dāng)前時(shí)間戳
String key = LIMIT + String.valueOf(System.currentTimeMillis() / 1000);
// 傳入?yún)?shù)锌杀,限流最大請求數(shù)
List<String> args = new ArrayList<>();
args.add(maxRequest);
return eval(LIMIT_SECKILL_SCRIPT, Collections.singletonList(key), args);
}
/**
* 自定義參數(shù)限流判斷(自定義多少時(shí)間限制多少請求)
*
* @param maxRequest 限流最大請求數(shù)
* @param timeRequest 一個(gè)時(shí)間窗口(秒)
* @return boolean
* @throws
* @author wliduo[i@dolyw.com]
* @date 2019/11/25 17:57
*/
public Long limit(String maxRequest, String timeRequest) {
// 獲取key名甩栈,一個(gè)時(shí)間窗口開始時(shí)間(限流開始時(shí)間)和一個(gè)時(shí)間窗口內(nèi)請求的數(shù)量累計(jì)(限流累計(jì)請求數(shù))
List<String> keys = new ArrayList<>();
keys.add(LIMIT_TIME);
keys.add(LIMIT_REQUEST);
// 傳入?yún)?shù),限流最大請求數(shù)糕再,當(dāng)前時(shí)間戳量没,一個(gè)時(shí)間窗口時(shí)間(毫秒)(限流時(shí)間)
List<String> args = new ArrayList<>();
args.add(maxRequest);
args.add(String.valueOf(System.currentTimeMillis()));
args.add(timeRequest);
return eval(LIMIT_CUSTOM_SCRIPT, keys, args);
}
/**
* 執(zhí)行Lua腳本方法
*
* @param script
* @param keys
* @param args
* @return java.lang.Object
* @throws
* @author wliduo[i@dolyw.com]
* @date 2019/11/26 10:50
*/
private Long eval(String script, List<String> keys, List<String> args) {
// 執(zhí)行腳本
Object result = JedisUtil.eval(script, keys, args);
// 結(jié)果請求數(shù)大于0說明不被限流
return (Long) result;
}
/**
* 獲取Lua腳本
*
* @param path
* @return java.lang.String
* @throws
* @author wliduo[i@dolyw.com]
* @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限流腳本出現(xiàn)問題: " + Arrays.toString(e.getStackTrace()));
}
return stringBuilder.toString();
}
}
2.3. 注解
- pom.xml(注解借助AOP實(shí)現(xiàn))
<!-- 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[i@dolyw.com]
* @date 2019/11/26 9:59
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Limit {
/**
* 限流最大請求數(shù)
* @return
*/
String maxRequest() default "10";
/**
* 一個(gè)時(shí)間窗口(毫秒)
* @return
*/
String timeRequest() default "1000";
}
- LimitAspect
package com.example.limit;
import ...;
/**
* LimitAspect限流切面
*
* @author wliduo[i@dolyw.com]
* @date 2019/11/26 10:07
*/
@Order(0)
@Aspect
@Component
public class LimitAspect {
/**
* logger
*/
private static final Logger logger = LoggerFactory.getLogger(LimitAspect.class);
/**
* 一個(gè)時(shí)間窗口時(shí)間(毫秒)(限流時(shí)間)
*/
private static final String TIME_REQUEST = "1000";
/**
* RedisLimitUtil
*/
@Autowired
private RedisLimitUtil redisLimitUtil;
/**
* 對應(yīng)注解
*
* @param
* @return void
* @throws
* @author wliduo[i@dolyw.com]
* @date 2019/11/26 10:11
*/
@Pointcut("@annotation(com.example.limit.Limit)")
public void aspect() {}
/**
* 切面
*
* @param proceedingJoinPoint
* @return java.lang.Object
* @throws
* @author wliduo[i@dolyw.com]
* @date 2019/11/26 10:11
*/
@Around("aspect() && @annotation(limit)")
public Object Interceptor(ProceedingJoinPoint proceedingJoinPoint, Limit limit) {
Object result = null;
Long maxRequest = 0L;
// 一個(gè)時(shí)間窗口(毫秒)為1000的話默認(rèn)調(diào)用秒級限流判斷(每秒限制多少請求)
if (TIME_REQUEST.equals(limit.timeRequest())) {
maxRequest = redisLimitUtil.limit(limit.maxRequest());
} else {
maxRequest = redisLimitUtil.limit(limit.maxRequest(), limit.timeRequest());
}
// 返回請求數(shù)量大于0說明不被限流
if (maxRequest > 0) {
// 放行,執(zhí)行后續(xù)方法
try {
result = proceedingJoinPoint.proceed();
} catch (Throwable throwable) {
throw new CustomException(throwable.getMessage());
}
} else {
// 直接返回響應(yīng)結(jié)果
throw new CustomException("請求擁擠突想,請稍候重試");
}
return result;
}
/**
* 執(zhí)行方法前再執(zhí)行
*
* @param limit
* @return void
* @throws
* @author wliduo[i@dolyw.com]
* @date 2019/11/26 10:10
*/
@Before("aspect() && @annotation(limit)")
public void before(Limit limit) {
// logger.info("before");
}
/**
* 執(zhí)行方法后再執(zhí)行
*
* @param limit
* @return void
* @throws
* @author wliduo[i@dolyw.com]
* @date 2019/11/26 10:10
*/
@After("aspect() && @annotation(limit)")
public void after(Limit limit) {
// logger.info("after");
}
}
2.4. 測試入口
寫個(gè) LimitController 簡單測試下殴蹄,工具類和注解的使用,可以使用 PostMan 或者 JMeter 測試猾担,都是 Get 請求饶套,也可以直接用瀏覽器窗口打開請求
package com.example.controller;
import ...;
/**
* 計(jì)數(shù)器(固定時(shí)間窗口)限流接口測試
*
* @author wliduo[i@dolyw.com]
* @date 2019/11/24 19:27
*/
@RestController
@RequestMapping("/limit")
public class LimitController {
/**
* logger
*/
private static final Logger logger = LoggerFactory.getLogger(LimitController.class);
/**
* 一個(gè)時(shí)間窗口內(nèi)最大請求數(shù)(限流最大請求數(shù))
*/
private static final Long MAX_NUM_REQUEST = 2L;
/**
* 一個(gè)時(shí)間窗口時(shí)間(毫秒)(限流時(shí)間)
*/
private static final Long TIME_REQUEST = 5000L;
/**
* 一個(gè)時(shí)間窗口內(nèi)請求的數(shù)量累計(jì)(限流請求數(shù)累計(jì))
*/
private AtomicInteger requestNum = new AtomicInteger(0);
/**
* 一個(gè)時(shí)間窗口開始時(shí)間(限流開始時(shí)間)
*/
private AtomicLong requestTime = new AtomicLong(System.currentTimeMillis());
/**
* RedisLimitUtil
*/
@Autowired
private RedisLimitUtil redisLimitUtil;
/**
* 計(jì)數(shù)器(固定時(shí)間窗口)請求接口
*
* @param
* @return java.lang.String
* @throws
* @author wliduo[i@dolyw.com]
* @date 2019/11/25 16:19
*/
@GetMapping
public String index() {
long nowTime = System.currentTimeMillis();
// 判斷是在當(dāng)前時(shí)間窗口(限流開始時(shí)間)
if (nowTime < requestTime.longValue() + TIME_REQUEST) {
// 判斷當(dāng)前時(shí)間窗口請求內(nèi)是否限流最大請求數(shù)
if (requestNum.longValue() < MAX_NUM_REQUEST) {
// 在時(shí)間窗口內(nèi)且請求數(shù)量還沒超過最大,請求數(shù)加一
requestNum.incrementAndGet();
logger.info("請求成功垒探,當(dāng)前請求是{}次", requestNum.intValue());
return "請求成功妓蛮,當(dāng)前請求是" + requestNum.intValue() + "次";
}
} else {
// 超時(shí)后重置(開啟一個(gè)新的時(shí)間窗口)
requestTime = new AtomicLong(nowTime);
requestNum = new AtomicInteger(0);
}
logger.info("請求失敗,被限流");
return "請求失敗圾叼,被限流";
}
/**
* 計(jì)數(shù)器(固定時(shí)間窗口)請求接口(限流工具類實(shí)現(xiàn))
*
* @param
* @return java.lang.String
* @throws
* @author wliduo[i@dolyw.com]
* @date 2019/11/25 18:02
*/
@GetMapping("/redis")
public String redis() {
Long maxRequest = redisLimitUtil.limit(MAX_NUM_REQUEST.toString());
// 結(jié)果請求數(shù)大于0說明不被限流
if (maxRequest > 0) {
logger.info("請求成功蛤克,當(dāng)前請求是{}次", maxRequest);
return "請求成功,當(dāng)前請求是" + maxRequest + "次";
}
logger.info("請求失敗夷蚊,被限流");
return "請求擁擠构挤,請稍候重試";
}
/**
* 計(jì)數(shù)器(固定時(shí)間窗口)請求接口(限流注解實(shí)現(xiàn))
*
* @param
* @return java.lang.String
* @throws
* @author wliduo[i@dolyw.com]
* @date 2019/11/26 9:46
*/
@Limit(maxRequest = "2", timeRequest = "3000")
@GetMapping("/annotation")
public String annotation() {
logger.info("請求成功");
return "請求成功";
}
}
3. 代碼實(shí)現(xiàn)
有了上面的注解,我們只需要 Controller 加個(gè)方法就行惕鼓,在 SeckillEvolutionController 添加樂觀鎖加緩存再加限流下單的入口方法
- SeckillEvolutionController
/**
* 使用樂觀鎖下訂單筋现,并且添加讀緩存,再添加限流
*
* @param id 商品ID
* @return com.example.common.ResponseBean
* @throws Exception
* @author wliduo[i@dolyw.com]
* @date 2019/11/22 14:24
*/
@Limit
@PostMapping("/createOptimisticLockOrderWithRedisLimit/{id}")
public ResponseBean createOptimisticLockOrderWithRedisLimit(@PathVariable("id") Integer id) throws Exception {
// 錯(cuò)誤的箱歧,線程不安全
// Integer orderCount = seckillEvolutionService.createOptimisticLockOrderWithRedisWrong(id);
// 正確的矾飞,線程安全
Integer orderCount = seckillEvolutionService.createOptimisticLockOrderWithRedisSafe(id);
return new ResponseBean(HttpStatus.OK.value(), "購買成功", null);
}
添加注解 @Limit 即可,默認(rèn)限流為每秒最多請求10次
4. 開始測試
使用 JMeter 測試上面的代碼呀邢,JMeter 的使用可以查看: JMeter的安裝使用
我們調(diào)用一下商品庫存初始化的方法洒沦,我使用的是 PostMan,初始化庫存表商品 10 個(gè)庫存价淌,而且清空訂單表
接著使用 PostMan 調(diào)用緩存預(yù)熱方法申眼,提前加載好緩存
這時(shí)候可以看到我們的數(shù)據(jù),庫存為 10蝉衣,賣出為 0 括尸,訂單表為空
緩存數(shù)據(jù)也是這樣
打開 JMeter,添加測試計(jì)劃(測試計(jì)劃文件在項(xiàng)目的src\main\resources\jmx下
)病毡,模擬 500 個(gè)并發(fā)線程測試秒殺 10 個(gè)庫存的商品
PS: 這次我們填寫 Ramp-Up 時(shí)間為 5 秒濒翻,意思為執(zhí)行 5 秒,每秒執(zhí)行 100 個(gè)并發(fā)剪验,因?yàn)槿绻荚?1S 內(nèi)執(zhí)行完肴焊,會被限流前联,然后填寫請求地址,點(diǎn)擊啟動圖標(biāo)開始
可以看到 500 個(gè)并發(fā)線程執(zhí)行完娶眷,數(shù)據(jù)是正確的
我們可以看下 Druid 的監(jiān)控似嗤,地址: http://localhost:8080/druid/sql.html
使用了限流,可以看到樂觀鎖更新不像之前那樣執(zhí)行 157 次了届宠,只執(zhí)行了 36 次烁落,很多請求直接被限流了,我們看下后臺日志豌注,可以看到很多請求直接被限流限制了伤塌,這樣就達(dá)到了我們的目的
5. 最后總結(jié)
那我們還可以怎么優(yōu)化提高吞吐量以及性能呢,我們上文所有例子其實(shí)都是同步請求轧铁,完全可以利用同步轉(zhuǎn)異步來提高性能每聪,這里我們將下訂單的操作進(jìn)行異步化,利用消息隊(duì)列來進(jìn)行解耦齿风,這樣可以然 DB 異步執(zhí)行下單
每當(dāng)一個(gè)請求通過了限流和庫存校驗(yàn)之后就將訂單信息發(fā)給消息隊(duì)列药薯,這樣一個(gè)請求就可以直接返回了,消費(fèi)程序做下訂單的操作救斑,對數(shù)據(jù)進(jìn)行入庫落地童本,因?yàn)楫惒搅耍宰罱K需要采取回調(diào)或者是其他提醒的方式提醒用戶購買完成
參考
- 感謝hllcve_的Spring Boot自定義注解: http://www.reibang.com/p/e04eeae86cf9