前言
本文將從零開始搭建一個秒殺的后臺系統,整體思路如下圖所示

前置準備
- 整體后端框架采用的是 SpringBoot + mybatis plus
- 運用到 redis ,rabbitmq 等中間件
- 性能測驗用到了 jmeter
正文
秒殺在生活中的應用場景還是挺多的,比如雙十一搶限購商品,12306搶座,大學搶課,搶門票等等,
這些場景下,就有可能帶出以下問題
- 高并發
- 極短的時間內,用戶請求量大
- 超賣
- 庫存 100 件,最終下單了 120 件
- 惡意請求
- 一些不壞好意的黑客,或者黃牛,通過腳本來模擬請求,如果是用來搶商品的,機器的請求肯定比人快,那頂多算欺負老實人;要是惡意偽造請求,造成快取穿透,處理不好整個服務都掛了,
- 資料庫
- 上萬甚至上百萬的 qps 打到資料庫,如果沒有做降級,限流,熔斷等處理,可能影響的就不是秒殺這一個業務了,
所以在我們設計的時候,就需要根據這些問題,對癥下藥,
1 普通下單
建立一個簡單的場景,資料庫中存有一個商品,庫存為 100,用戶通過下單介面來下單,不做任何限制,

public int createWrongOrder(int sid) throws Exception {
//校驗庫存
Stock stock = checkStock(sid);
//扣庫存
saleStock(stock);
//創建訂單
int id = createOrder(stock);
return id;
}
通過 jmeter 進行性能測驗,設定執行緒數1000,模擬 1000 位用戶進行請求,觀察結果,
可以看到 http 請求全部正常回傳,銷量只賣出了 27 單,但是訂單表里添加了 1000 條記錄



這就是之前提出的超賣問題,
2 下單加鎖(樂觀鎖)
解決上述問題,我采用上鎖的方式,選擇的是樂觀鎖
樂觀鎖:總是假設最好的情況,每次去拿資料的時候都認為別人不會修改,所以不會上鎖,只在更新的時候會判斷一下在此期間別人有沒有去更新這個資料,
public int createOptimisticOrder(int sid) {
//校驗庫存
Stock stock = checkStock(sid);
//樂觀鎖更新庫存
boolean success = saleStockOptimistic(stock);
if (!success) {
throw new RuntimeException("過期庫存值,更新失敗");
}
//創建訂單
int id = createOrder(stock);
return stock.getCount() - stock.getSale();
}
代碼層面,就修改了一下更新庫存的 sql
public int updateStockByOptimistic(Stock stock) {
UpdateWrapper<Stock> wrapper = new UpdateWrapper<>();
wrapper.lambda().eq(Stock::getId, stock.getId()).eq(Stock::getVersion, stock.getVersion());
stock.setSale(stock.getSale() + 1);
stock.setVersion(stock.getVersion() + 1);
return mapper.update(stock, wrapper);
}
翻譯成 sql 陳述句就是
UPDATE stock
SET sale = sale + 1,
version = version + 1
WHERE
id = 1
AND version = 0
繼續用 jmeter 進行測驗,日志中可以看到,存在大量購買失敗,銷售量為 47,但是訂單量也為 47,說明不存在超賣的情況,



3 下單介面限流
解決了超賣的問題,接下來需要解決高并發下帶來的壓力,
因此,我們需要選擇更優雅的方式來處理大量請求,
首先是前端,
- 頁面靜態化
- 可以對頁面進行靜態化處理,因為前端作為秒殺活動的入口,如果把入口限制住,就能很好的達到限流的效果,到了秒殺時間點,并且用戶主動點了秒殺按鈕,才會訪問服務端,
- CDN 快取
- 到了秒殺時間點,再更新秒殺按鈕,
而作為一名后端開發,本文的重點更多的在于后端的限流,
- 單獨部署
- 一種最常見的方式,就是單獨部署,以免秒殺業務崩潰而影響其他業務系統,
- 快取
- 添加快取可以避免請求直接打到資料庫,具體程序如下:根據商品id,先從快取中查詢商品,如果商品存在,則參與秒殺,如果不存在,則需要從資料庫中查詢商品,如果存在,則將商品資訊放入快取,然后參與秒殺,如果商品不存在,則直接提示失敗,
- 這也會引發其他問題
- 快取擊穿
- 比如某一時刻快取失效,大量請求還是會直接打到資料庫,此時可以根據實際情況,將快取的有效期設定為不失效,并在秒殺活動開始前,對快取進行預熱,同時對資料庫查詢加鎖

- 快取穿透
- 商品id可能非法,也會導致直接訪問資料庫的情況,加鎖可以較好的緩解這一情況,同時,我們可以使用布隆過濾器,也能很好的解決這個問題,
- 比如某一時刻快取失效,大量請求還是會直接打到資料庫,此時可以根據實際情況,將快取的有效期設定為不失效,并在秒殺活動開始前,對快取進行預熱,同時對資料庫查詢加鎖
- 快取擊穿
- 介面限流
- 這邊以令牌桶限流演算法為例

-

- 代碼層面,使用Guava的RateLimiter實作令牌桶限流介面
-
// 每秒放行10個請求 private RateLimiter rateLimiter = RateLimiter.create(10); @GetMapping("/createOptimisticOrder/{sid}") public String createOptimisticOrder(@PathVariable int sid) { // 1. 阻塞式獲取令牌 log.info("等待時間" + rateLimiter.acquire()); // 2. 非阻塞式獲取令牌 // if (!rateLimiter.tryAcquire(1000, TimeUnit.MILLISECONDS)) { // log.warn("你被限流了,真不幸,直接回傳失敗"); // return "你被限流了,真不幸,直接回傳失敗"; // } int id; try { id = stockOrderService.createOptimisticOrder(sid); log.info("購買成功,剩余庫存為: [{}]", id); } catch (Exception e) { log.error("購買失敗:[{}]", e.getMessage()); return "購買失敗,庫存不足"; } return String.format("購買成功,剩余庫存為:%d", id); } - 兩種方式獲取令牌
- 非阻塞式獲取令牌:請求進來后,若令牌桶里沒有足夠的令牌,會嘗試等待設定好的時間(這里寫了1000ms),其會自動判斷在1000ms后,這個請求能不能拿到令牌,如果不能拿到,直接回傳搶購失敗,如果timeout設定為0,則等于阻塞時獲取令牌,
- 阻塞式獲取令牌:請求進來后,若令牌桶里沒有足夠的令牌,就在這里阻塞住,等待令牌的發放,
jmeter 測驗,采用阻塞式獲取令牌,可以看到吞吐量為 10

- 這邊以令牌桶限流演算法為例
再看訂單情況,售出了 100 個,訂單也有 100 條


4 下單介面加鹽
我們的介面可以通過抓包輕易獲取到,這會給一些不法分子可乘之機,
一個簡單的做法就是給我們的介面地址加鹽,即動態的生成下單地址,
獲取鹽值介面
@GetMapping(value = "/getVerifyHash")
public String getVerifyHash(@RequestParam(value = "sid") Integer sid,
@RequestParam(value = "userId") Integer userId) {
String hash;
try {
hash = userService.getVerifyHash(sid, userId);
} catch (Exception e) {
log.error("獲取驗證hash失敗,原因:[{}]", e.getMessage());
return "獲取驗證hash失敗";
}
return String.format("請求搶購驗證hash值為:%s", hash);
}
加鹽下單介面
@GetMapping(value = "/createOrderWithVerifiedUrl")
public String createOrderWithVerifiedUrl(@RequestParam(value = "sid") Integer sid,
@RequestParam(value = "userId") Integer userId,
@RequestParam(value = "verifyHash") String verifyHash) {
int stockLeft;
try {
stockLeft = stockOrderService.createVerifiedOrder(sid, userId, verifyHash);
log.info("購買成功,剩余庫存為: [{}]", stockLeft);
} catch (Exception e) {
log.error("購買失敗:[{}]", e.getMessage());
return e.getMessage();
}
return String.format("購買成功,剩余庫存為:%d", stockLeft);
}
另外,可以限制用戶下單的頻率,
@GetMapping(value = "/createOrderWithVerifiedUrl")
public String createOrderWithVerifiedUrl(@RequestParam(value = "sid") Integer sid,
@RequestParam(value = "userId") Integer userId,
@RequestParam(value = "verifyHash") String verifyHash) {
int stockLeft;
try {
int count = userService.addUserCount(userId);
log.info("用戶截至該次的訪問次數為: [{}]", count);
boolean isBanned = userService.getUserIsBanned(userId);
if (isBanned) {
return "購買失敗,超過頻率限制";
}
stockLeft = stockOrderService.createVerifiedOrder(sid, userId, verifyHash);
log.info("購買成功,剩余庫存為: [{}]", stockLeft);
} catch (Exception e) {
log.error("購買失敗:[{}]", e.getMessage());
return e.getMessage();
}
return String.format("購買成功,剩余庫存為:%d", stockLeft);
}
用戶訪問頻率可以放在快取 redis 或者 memcached 中,限制了單個用戶最多搶5單,(注意是搶5單,而不是限購5單),最終發現搶到了2單(這邊搶到的單數屬于隨機事件)



5 保證 Redis 和 資料庫 資料的一致性
對于訪問量很大的“熱點”資料,尤其是一些讀取量遠大于寫入量的資料,更應該被快取,而不應該讓請求打到資料庫上,
快取的優點
- 能夠縮短服務的回應時間,給用戶帶來更好的體驗,
- 能夠增大系統的吞吐量,依然能夠提升用戶體驗,
- 減輕資料庫的壓力,防止高峰期資料庫被壓垮,導致整個線上服務掛掉,
快取的問題
- 快取有多種選型,你是否都熟悉,如果不熟悉,無疑增加了維護的難度,
- 快取系統也要考慮分布式,無疑增加了系統的復雜性,
- 如果對快取的準確性有非常高的要求,就必須考慮「快取和資料庫的一致性問題」,
接下來重點討論快取和資料庫一致性的問題
5.1 不使用更新快取而是洗掉快取
大部分觀點認為,做快取不應該是去更新快取,而是應該洗掉快取,然后由下個請求去去快取,發現不存在后再讀取資料庫,寫入快取,
其實如果業務非常簡單,只是去資料庫拿一個值,寫入快取,那么更新快取也是可以的,但是,淘汰快取操作簡單,并且帶來的副作用只是增加了一次cache miss,建議作為通用的處理方式,
5.2 先洗掉快取,還是先操作資料庫?
方案一 先刪快取,再更新資料庫
該方案會導致請求資料不一致
同時有一個請求A進行更新操作,另一個請求B進行查詢操作,那么會出現如下情形:
(1)請求A進行寫操作,洗掉快取
(2)請求B查詢發現快取不存在
(3)請求B去資料庫查詢得到舊值
(4)請求B將舊值寫入快取
(5)請求A將新值寫入資料庫
上述情況就會導致不一致的情形出現,而且,如果不采用給快取設定過期時間策略,該資料永遠都是臟資料,
方案二 先更新資料庫,再刪快取
假設這會有兩個請求,一個請求A做查詢操作,一個請求B做更新操作,那么會有如下情形產生
(1)快取剛好失效
(2)請求A查詢資料庫,得一個舊值
(3)請求B將新值寫入資料庫
(4)請求B洗掉快取
(5)請求A將查到的舊值寫入快取
如果發生上述情況,確實是會發生臟資料,
發生上述情況有一個先天性條件,就是步驟(3)的寫資料庫操作比步驟(2)的讀資料庫操作耗時更短,才有可能使得步驟(4)先于步驟(5),可是,大家想想,「資料庫的讀操作的速度遠快于寫操作的(不然做讀寫分離干嘛,做讀寫分離的意義就是因為讀操作比較快,耗資源少),因此步驟(3)耗時比步驟(2)更短,這一情形很難出現,
所以,如果你想實作基礎的快取資料庫雙寫一致的邏輯,那么在大多數情況下,在不想做過多設計,增加太大作業量的情況下,請 先更新資料庫,再刪快取!
方案三 先刪快取,再更新資料庫,過一段時間,再同步/異步 刪一次快取
(1)先淘汰快取
(2)再寫資料庫(這兩步和原來一樣)
(3)休眠1秒,再次淘汰緩存
這么做,可以將1秒內所造成的快取臟資料,再次洗掉,
6 下單異步處理
實際秒殺程序可以分為 秒殺 - 下單 - 支付 三個步驟,大部分的流量壓力是在秒殺這一步,之后的步驟完全可以異步完成,
這時候就可以用到 rabbitmq 的流量削峰的功能了,
代碼層面也簡單,新增一個 controller 介面
/**
* 下單介面:異步處理訂單
*/
@GetMapping(value = "/createUserOrderWithMq")
public String createUserOrderWithMq(@RequestParam(value = "sid") Integer sid,
@RequestParam(value = "userId") Integer userId) {
try {
// 檢查快取中該用戶是否已經下單過
Boolean hasOrder = stockOrderService.checkUserOrderInfoInCache(sid, userId);
if (hasOrder != null && hasOrder) {
log.info("該用戶已經搶購過");
return "你已經搶購過了,不要太貪心.....";
}
// 沒有下單過,檢查快取中商品是否還有庫存
log.info("沒有搶購過,檢查快取中商品是否還有庫存");
Integer count = stockService.getStockCount(sid);
if (count == 0) {
return "秒殺請求失敗,庫存不足.....";
}
// 有庫存,則將用戶id和商品id封裝為訊息體傳給訊息佇列處理
// 注意這里的有庫存和已經下單都是快取中的結論,存在不可靠性,在訊息佇列中會查表再次驗證
log.info("有庫存:[{}]", count);
RabbitOrderDTO dto = new RabbitOrderDTO();
dto.setSid(sid);
dto.setUserId(userId);
sendToOrderQueue(JSONObject.toJSONString(dto));
return "秒殺請求提交成功";
} catch (Exception e) {
log.error("下單介面:異步處理訂單例外:", e);
return "秒殺請求失敗,服務器正忙.....";
}
}
再配置一個消費者
@Component
@RabbitListener(queues = "orderQueue")
@Slf4j
public class OrderMqListener {
@Autowired
private StockOrderService orderService;
@RabbitHandler
public void process(String message) {
log.info("OrderMqReceiver收到訊息開始用戶下單流程: " + message);
try {
RabbitOrderDTO dto = JSONObject.parseObject(message, RabbitOrderDTO.class);
orderService.createOrderByMq(dto.getSid(), dto.getUserId());
} catch (Exception e) {
log.error("訊息處理例外:", e);
}
}
}
異步與非異步性能對比
非異步下單(添加樂觀鎖,不限流,不限購)下單成功 54 單,吞吐量大約為 145



異步下單 100單全部賣出,吞吐量達 448



由此可以明顯感受到異步下單的優越性的
總結
至此,我們從超賣,高并發,快取,限流,超鏈接加鹽等多個角度簡單設計了一個秒殺系統,但是實際生產運用程序中,要考慮的東西遠不止這些,像快取擊穿,快取穿透,分布式鎖的設計,mq佇列訊息丟失,或者重復消費的問題,都是需要根據實際情況具體問題具體分析的,實踐出真知,希望有朝一日能真正運用到生產中吧,
原始碼地址
https://github.com/kid626/seckill
參考
【秒殺系統】從零打造秒殺系統(一):防止超賣
面試必考:秒殺系統如何設計? - 云+社區 - 騰訊云
《進大廠系列》系列-秒殺系統設計 - 知乎
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/379433.html
標籤:其他
上一篇:大資料之Kafka看這一篇就夠了
下一篇:【hadoop】mysql安裝
