电竞比分网-中国电竞赛事及体育赛事平台

分享

如何設(shè)計一個能打的秒殺系統(tǒng)

 貪挽懶月 2022-06-20 發(fā)布于廣東

聲明:本人并未參與過真正的秒殺系統(tǒng)設(shè)計,以下是本人學(xué)習(xí)筆記,自測通過,但可能并不完善,僅供參考,若用于生產(chǎn)出現(xiàn)問題,本人概不負(fù)責(zé)。

本文內(nèi)容有:

  • 秒殺系統(tǒng)設(shè)計思路;
  • 核心代碼;
  • 壓測配置:
  • 總結(jié);
  • 項目源碼地址

本文主要講思路,沒有將所有代碼貼出來,需要代碼的文末有源碼地址。

一、設(shè)計思路

秒殺系統(tǒng)的特點(diǎn)就是并發(fā)量大,一秒鐘就可能幾千幾萬的請求進(jìn)來了,如果不使點(diǎn)兒手段,系統(tǒng)分分鐘就垮了。下面就探討一下如何設(shè)計一個能打的秒殺系統(tǒng)。

1、限流:

首先不考慮業(yè)務(wù)邏輯,假如有如下一個最簡單的接口:

@GetMapping("/test")
public String test() {
 return "success";
}

這是一個最簡單的沒有任何邏輯的接口,但是如果同時有成千上萬的請求去訪問這個接口,服務(wù)器一樣會崩掉。所以,高并發(fā)系統(tǒng)該做的第一件事就是限流。springcloud項目可以使用hystrix進(jìn)行限流,springcloud alibaba可以使用sentinel進(jìn)行限流,那么非springcloud項目呢?guava為我們提供了一個RateLimiter工具類,可以做限流。它主要有漏桶算法和令牌桶算法。

  • 漏桶算法:一個有洞洞的桶子在水龍頭下裝水,裝一點(diǎn)兒就漏一點(diǎn)兒,但是如果水龍頭的水很大,桶里的水遲早會溢出的,溢出就限流。這種適合做限制上傳下載速率一類的。

  • 令牌桶算法:以恒定的速率往桶中放入令牌,每次請求進(jìn)來,要先從桶中拿令牌,如果沒有拿到令牌,請求就被擋掉。這種適合做限流,即限制QPS。

這里應(yīng)該使用令牌桶算法進(jìn)行限流,如果沒拿到令牌,直接返回“人太多了,擠不進(jìn)去”的提示。

2、檢查用戶是否登錄:

經(jīng)過第一步的限流,進(jìn)來的請求應(yīng)該檢查用戶是否登錄,本項目使用JWT,即先請求登錄接口,登錄后返回token,請求其他所有接口都在請求頭中帶上token,然后通過token就可以拿到用戶信息。如果沒拿到用戶信息,就返回“無效的token,請重新登錄”的提示。

3、檢查商品是否賣完:

通過了前兩步的校驗,就應(yīng)該檢查一下商品是否賣完了,如果賣完了就返回“來遲了,商品已秒殺完”的提示。注意,檢查商品是否賣完不能查數(shù)據(jù)庫,否則會很慢。我們可以搞個map,商品id作為key,如果賣完,值就設(shè)置為true,否則就是false。

4、將參加秒殺的商品加到redis中:

首先搞個ISINREDIS的key,表示商品是否已經(jīng)加到redis中了,避免每個請求進(jìn)來都重復(fù)此操作。如果ISINREDIS值為false,表示redis中還沒有秒殺商品。那么就查詢出所有參加秒殺的商品,商品id作為key,商品庫存作為value,存到redis中,同時將商品id作為key,false作為value,放到第三步的map中,表示該商品沒有售完。最后將ISINREDIS的值設(shè)置為true,表示已經(jīng)將所有參加秒殺的商品加到redis中了。

5、預(yù)扣庫存:

利用redis的decr對商品進(jìn)行自減,然后對自減后的結(jié)果進(jìn)行判斷。如果自減后結(jié)果小于0,表示商品已經(jīng)賣完了,那么就將map中對應(yīng)的商品id的值設(shè)置為true,并且返回“來遲了,商品已秒殺完”的提示。

6、判斷是否重復(fù)秒殺:

如果用戶秒殺成功,在秒殺訂單入庫后,會將用戶id和商品id作為key,true作為value存入redis中,表示該用戶已經(jīng)秒殺過該商品了。所以在這里就根據(jù)用戶id和商品id去redis中判斷是否重復(fù)秒殺,如果是,就返回“請勿重復(fù)秒殺”的提示。

7、異步處理:

如果以上校驗都通過了,那么就可以處理秒殺了。但是,如果處理每個秒殺請求我們都在數(shù)據(jù)庫進(jìn)行扣庫存、創(chuàng)建訂單的操作,也是非常慢的,還有可能壓垮數(shù)據(jù)庫。所以我們可以異步處理,即通過了以上校驗,就將用戶id和商品id作為message發(fā)送到MQ中,然后立即給用戶返回“排隊中”的提示。然后在MQ的消費(fèi)者端對消息進(jìn)行消費(fèi),拿到用戶id和商品id,可以根據(jù)商品id查詢庫存,再次確保庫存充足;然后也可以再次判斷是否重復(fù)秒殺。通過了判斷后,就操作數(shù)據(jù)庫,扣減庫存,創(chuàng)建秒殺訂單。注意扣減庫存和創(chuàng)建秒殺訂單需要在同一個事務(wù)中。

8、超賣問題:

超賣問題就是商品庫存出現(xiàn)負(fù)數(shù)的情況。比如庫存剩余1了,然后10個用戶同時秒殺,在判斷庫存的時候都是1,所以10個人都能下單成功,最后庫存為-9。如何解決?其實本系統(tǒng)中根本就不會出現(xiàn)這樣的問題,因為一開始用redis進(jìn)行了庫存預(yù)減,而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;
 
 // 用來標(biāo)記商品是否已經(jīng)加入到redis中的key
 private static final String ISINREDIS = "isInRedis";
 
 // 用goodsId作為key,標(biāo)記該商品是否已經(jīng)賣完
 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秒鐘內(nèi)沒有拿到令牌,就返回“人太多了,擠不進(jìn)去”的提示
  if (!rateLimiter.tryAcquire(1, TimeUnit.SECONDS)) {
   return new JsonResult<>(SeckillGoodsEnum.TRY_AGAIN.getCode(), SeckillGoodsEnum.TRY_AGAIN.getMessage());
  }
  
  // 2. 檢查用戶是否登錄(用戶登錄后,訪問每個接口都應(yīng)該在請求頭帶上token,根據(jù)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. 如果商品已經(jīng)秒殺完了,就不執(zhí)行下面的邏輯,直接返回商品已秒殺完的提示
  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. 先自減,預(yù)扣庫存,判斷預(yù)扣后庫存是否小于0,如果是,表示秒殺完了
  Long stock = RedisUtil.decr(String.valueOf(sgId));
  if (stock < 0) {
   // 標(biāo)記該商品已經(jīng)秒殺完
   seckillOver.put(sgId, true);
   return new JsonResult<>(SeckillGoodsEnum.SECKILL_OVER.getCode(), SeckillGoodsEnum.SECKILL_OVER.getMessage());
  }
  
  // 6. 判斷是否重復(fù)秒殺(成功秒殺并創(chuàng)建訂單后,會將userId和goodsId作為key放到redis中)
  if (RedisUtil.isExist(userId + sgId)) {
   return new JsonResult<>(SeckillGoodsEnum.REPEAT_SECKILL.getCode(), SeckillGoodsEnum.REPEAT_SECKILL.getMessage());
  }
  
  // 7. 以上校驗都通過了,就將當(dāng)前請求加入到MQ中,然后返回“排隊中”的提示
  String msg = userId + "," + sgId;
  mqSender.send(msg);
  return new JsonResult<>(SeckillGoodsEnum.LINE_UP.getCode(), SeckillGoodsEnum.LINE_UP.getMessage());
 }

}

三、壓測

用jmeter模擬并發(fā)請求,測試高并發(fā)情況下系統(tǒng)能否扛得住。由于只有一個id為1的商品,所以商品id固定寫死1。但是每個用戶都要先請求登錄接口獲取到token才能進(jìn)行秒殺請求,有點(diǎn)兒麻煩,所以可以先把jwt模塊注釋掉,把userId當(dāng)成參數(shù)傳進(jìn)去。jmeter配置如下圖:

jmeter壓測配置
jmeter壓測配置

四、總結(jié)

秒殺系統(tǒng)的核心就是限流,防止高流量沖垮系統(tǒng);redis預(yù)減庫存,解決超賣問題;然后是異步下單,及時返回提示給用戶,提升用戶體驗,同時也減輕數(shù)據(jù)庫的壓力。

在本公眾號后臺發(fā)送"秒殺" 獲取項目源碼。

-java開發(fā)那些事-

    轉(zhuǎn)藏 分享 獻(xiàn)花(0

    0條評論

    發(fā)表

    請遵守用戶 評論公約

    類似文章 更多