宣告:本人并未參與過真正的秒殺系統設計,以下是本人學習筆記,自測通過,但可能并不完善,僅供參考,若用于生產出現問題,本人概不負責,
本文內容有:
- 秒殺系統設計思路;
- 核心代碼;
- 壓測配置:
- 總結;
- 專案原始碼地址
本文主要講思路,沒有將所有代碼貼出來,需要代碼的文末有原始碼地址,
一、設計思路
秒殺系統的特點就是并發量大,一秒鐘就可能幾千幾萬的請求進來了,如果不使點兒手段,系統分分鐘就垮了,下面就探討一下如何設計一個能打的秒殺系統,
1、限流
首先不考慮業務邏輯,假如有如下一個最簡單的介面:
@GetMapping("/test")
public String test() {
return "success";
}
這是一個最簡單的沒有任何邏輯的介面,但是如果同時有成千上萬的請求去訪問這個介面,服務器一樣會崩掉,所以,高并發系統該做的第一件事就是限流,springcloud專案可以使用hystrix進行限流,springcloud alibaba可以使用sentinel進行限流,那么非springcloud專案呢?guava為我們提供了一個RateLimiter工具類,可以做限流,它主要有漏桶演算法和令牌桶演算法,
-
漏桶演算法:一個有洞洞的桶子在水龍頭下裝水,裝一點兒就漏一點兒,但是如果水龍頭的水很大,桶里的水遲早會溢位的,溢位就限流,這種適合做限制上傳下載速率一類的,
-
令牌桶演算法:以恒定的速率往桶中放入令牌,每次請求進來,要先從桶中拿令牌,如果沒有拿到令牌,請求就被擋掉,這種適合做限流,即限制QPS,
這里應該使用令牌桶演算法進行限流,如果沒拿到令牌,直接回傳“人太多了,擠不進去”的提示,
2、檢查用戶是否登錄
經過第一步的限流,進來的請求應該檢查用戶是否登錄,本專案使用JWT,即先請求登錄介面,登錄后回傳token,請求其他所有介面都在請求頭中帶上token,然后通過token就可以拿到用戶資訊,如果沒拿到用戶資訊,就回傳“無效的token,請重新登錄”的提示,
3、檢查商品是否賣完
通過了前兩步的校驗,就應該檢查一下商品是否賣完了,如果賣完了就回傳“來遲了,商品已秒殺完”的提示,注意,檢查商品是否賣完不能查資料庫,否則會很慢,我們可以搞個map,商品id作為key,如果賣完,值就設定為true,否則就是false,
4、將參加秒殺的商品加到redis中
首先搞個ISINREDIS的key,表示商品是否已經加到redis中了,避免每個請求進來都重復此操作,如果ISINREDIS值為false,表示redis中還沒有秒殺商品,那么就查詢出所有參加秒殺的商品,商品id作為key,商品庫存作為value,存到redis中,同時將商品id作為key,false作為value,放到第三步的map中,表示該商品沒有售完,最后將ISINREDIS的值設定為true,表示已經將所有參加秒殺的商品加到redis中了,
5、預扣庫存
利用redis的decr對商品進行自減,然后對自減后的結果進行判斷,如果自減后結果小于0,表示商品已經賣完了,那么就將map中對應的商品id的值設定為true,并且回傳“來遲了,商品已秒殺完”的提示,
6、判斷是否重復秒殺
如果用戶秒殺成功,在秒殺訂單入庫后,會將用戶id和商品id作為key,true作為value存入redis中,表示該用戶已經秒殺過該商品了,所以在這里就根據用戶id和商品id去redis中判斷是否重復秒殺,如果是,就回傳“請勿重復秒殺”的提示,
7、異步處理
如果以上校驗都通過了,那么就可以處理秒殺了,但是,如果處理每個秒殺請求我們都在資料庫進行扣庫存、創建訂單的操作,也是非常慢的,還有可能壓垮資料庫,所以我們可以異步處理,即通過了以上校驗,就將用戶id和商品id作為message發送到MQ中,然后立即給用戶回傳“排隊中”的提示,然后在MQ的消費者端對訊息進行消費,拿到用戶id和商品id,可以根據商品id查詢庫存,再次確保庫存充足;然后也可以再次判斷是否重復秒殺,通過了判斷后,就操作資料庫,扣減庫存,創建秒殺訂單,注意扣減庫存和創建秒殺訂單需要在同一個事務中,
8、超賣問題
超賣問題就是商品庫存出現負數的情況,比如庫存剩余1了,然后10個用戶同時秒殺,在判斷庫存的時候都是1,所以10個人都能下單成功,最后庫存為-9,如何解決?其實本系統中根本就不會出現這樣的問題,因為一開始用redis進行了庫存預減,而redis命令核心模塊是單執行緒的,所以可以保證不會超賣,如果沒有用到redis,也可以給該商品增加一個version欄位,每次扣減庫存前先查其version,扣減庫存的sql加上一個條件,就是version要等于剛才查出來的version,
二、核心代碼
@RestController
@RequestMapping("/seckill")
public class SeckillController {
@Autowired
private UserService userService;
@Autowired
private SeckillService seckillService;
@Autowired
private RabbitMqSender mqSender;
// 用來標記商品是否已經加入到redis中的key
private static final String ISINREDIS = "isInRedis";
// 用goodsId作為key,標記該商品是否已經賣完
private Map<Integer, Boolean> seckillOver = new HashMap<Integer, Boolean>();
// 用RateLimiter做限流,create(10),可以理解為QPS閾值為10
private RateLimiter rateLimiter = RateLimiter.create(10);
@PostMapping("/{sgId}")
public JsonResult<?> seckillGoods(@PathVariable("sgId") Integer sgId, HttpServletRequest httpServletRequest){
// 1. 如果QPS閾值超過10,即1秒鐘內沒有拿到令牌,就回傳“人太多了,擠不進去”的提示
if (!rateLimiter.tryAcquire(1, TimeUnit.SECONDS)) {
return new JsonResult<>(SeckillGoodsEnum.TRY_AGAIN.getCode(), SeckillGoodsEnum.TRY_AGAIN.getMessage());
}
// 2. 檢查用戶是否登錄(用戶登錄后,訪問每個介面都應該在請求頭帶上token,根據token再去拿user)
String token = httpServletRequest.getHeader("token");
String userId = JWT.decode(token).getAudience().get(0);
User user = userService.findUserById(Integer.valueOf(userId));
if (user == null) {
return new JsonResult<>(SeckillGoodsEnum.INVALID_TOKEN.getCode(), SeckillGoodsEnum.INVALID_TOKEN.getMessage());
}
// 3. 如果商品已經秒殺完了,就不執行下面的邏輯,直接回傳商品已秒殺完的提示
if (!seckillOver.isEmpty() && seckillOver.get(sgId)) {
return new JsonResult<>(SeckillGoodsEnum.SECKILL_OVER.getCode(), SeckillGoodsEnum.SECKILL_OVER.getMessage());
}
// 4. 將所有參加秒殺的商品資訊加入到redis中
if (!RedisUtil.isExist(ISINREDIS)) {
List<SeckillGoods> goods = seckillService.getAllSeckillGoods();
for (SeckillGoods seckillGoods : goods) {
RedisUtil.set(String.valueOf(seckillGoods.getSgId()), seckillGoods.getSgSeckillNum());
seckillOver.put(seckillGoods.getSgId(), false);
}
RedisUtil.set(ISINREDIS, true);
}
// 5. 先自減,預扣庫存,判斷預扣后庫存是否小于0,如果是,表示秒殺完了
Long stock = RedisUtil.decr(String.valueOf(sgId));
if (stock < 0) {
// 標記該商品已經秒殺完
seckillOver.put(sgId, true);
return new JsonResult<>(SeckillGoodsEnum.SECKILL_OVER.getCode(), SeckillGoodsEnum.SECKILL_OVER.getMessage());
}
// 6. 判斷是否重復秒殺(成功秒殺并創建訂單后,會將userId和goodsId作為key放到redis中)
if (RedisUtil.isExist(userId + sgId)) {
return new JsonResult<>(SeckillGoodsEnum.REPEAT_SECKILL.getCode(), SeckillGoodsEnum.REPEAT_SECKILL.getMessage());
}
// 7. 以上校驗都通過了,就將當前請求加入到MQ中,然后回傳“排隊中”的提示
String msg = userId + "," + sgId;
mqSender.send(msg);
return new JsonResult<>(SeckillGoodsEnum.LINE_UP.getCode(), SeckillGoodsEnum.LINE_UP.getMessage());
}
}
三、壓測
用jmeter模擬并發請求,測驗高并發情況下系統能否扛得住,由于只有一個id為1的商品,所以商品id固定寫死1,但是每個用戶都要先請求登錄介面獲取到token才能進行秒殺請求,有點兒麻煩,所以可以先把jwt模塊注釋掉,把userId當成引數傳進去,jmeter配置如下圖:


四、總結
秒殺系統的核心就是限流,防止高流量沖垮系統;redis預減庫存,解決超賣問題;然后是異步下單,及時回傳提示給用戶,提升用戶體驗,同時也減輕資料庫的壓力,
原始碼地址:https://github.com/zsllsz/seckill.git
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/398803.html
標籤:其他
上一篇:redisTemplate.opsForValue().setIfAbsent()使用
下一篇:QT 在視頻上繪制
