硬核講解秒殺設計

1 秒殺場景

  1. 登陸 12306 進行火車票搶座

  2. 1599 元購入飛天茅臺

  3. 周董演唱會的門票

  4. 雙十一秒殺活動

秒殺場景關注點

  1. 嚴格防止超賣:庫存 1000 件賣了 1020 件,要殺個碼農祭天了!防止超賣是秒殺系統設計最核心的部分。

  2. 防止黑產:防止不懷好意的羊毛黨薅羊毛。

  3. 保證用戶體驗:高併發下,給用戶提供友善的購物體驗,儘可能支持比較高的 QPS 等等。

2 第 1 版 - 裸奔

裸奔秒殺
不加思考,上來直接按照 SpringBoot + MyBatis 模式進行秒殺系統的設計,流程如下:

  1. Controller層獲得用戶秒殺請求後調用Service層。

  2. Service層獲得請求後要要檢查已售數據跟庫存總量是否一致,一致說明商品賣沒了,不一致說明還有庫存,那就調用DAO層對已售數量進行加 1。

  3. DAO層獲得請求後直接通過MyBatis操作數據庫實現已售數量加 1 跟訂單創建。

如果你用Postman去測試會發現是 OK 的,但如果你用專業的併發測試工具JMeter模式多用戶併發請求會發現訂單創建數量 > 庫存量 - 已售量。原因解釋下,比如用戶 A、B 併發進行秒殺請求,此時庫存 = 100,已售 = 64。

  1. A 用戶進行描述請求,此時調用到了Service層,發現已售不等於庫存,此時拿到庫存數是 64,A 將庫存更新爲 63,然後創建訂單。

  2. B 用戶進行描述請求,此時調用到了Service層,發現已售不等於庫存,此時拿到庫存數是 64,B 將庫存更新爲 63,然後創建訂單。

  3. 此時庫存減少了 1 個但是訂單創建多個,賣超了!

    無鎖併發請求,賣超了

3 第 2 版 - 悲觀鎖

syn 悲觀鎖
遇見 併發問題 很容易想到以前學過併發編程嘛,既然Controller默認是單例模式,那我用 synchronizedController層調用Service層的代碼進行加鎖同步即可。

這樣就可以解決賣超問題了,但是須知,既然是悲觀鎖,如果有 1000 個併發請求,那只有 1 個拿到鎖了。有 999 個會去競爭這個鎖的。

 1@Transactional
 2@Service
 3@Transactional
 4@Slf4j
 5public class OrderServiceImpl implements OrderService
 6{
 7    //校驗庫存
 8    Stock stock = checkStock(id);
 9    //更新庫存
10    updateSale(stock);
11    //創建訂單
12    return createOrder(stock);
13}
14
15

當然了你也可以用 Spring 自帶的事務註解來實現悲觀鎖的操作,因爲用了@Transactional就可以實現通過事務來控制,要麼全部成功,要麼全部失敗,用事務時有兩點需注意:

  1. 儘可能將 MySQL 執行語句往方法體後面靠,因爲 MySQL 事務的 commit 語句是在第一次執行 MySQL 相關語句開始,一直到方法的結束。

  2. 設置事務的超時時間,如果不設置默認是 - 1 是無限長。並且事務中設置的耗時 timeout = 最後一個 MySQL 語句耗時 + 以及最後一個 MySQL 之前的所有耗時。

需注意:悲觀鎖狀態下會保證商品賣出去,如果沒拿到鎖的線程會阻塞的等待拿鎖。但是他的阻塞也會給用戶帶來非常不良好的體驗。

4 第 3 版 - 樂觀鎖

MySQL 版本號
我們爲每個數量的已售數據配備個版本號,在Service層調用時獲得用戶的已售數跟對應版本號,然後更新時將已售數跟版本號同時更新。因爲 MySQL 在更新時會自帶樂觀加速機制,如果更新成功則表示搶購成功,更新失敗則表示搶購失敗,此時你會發現不是手速越快就一定能搶到的哦,但起碼保證了不會超賣,

1update 庫存表 set
2   已售數=已售數+1,版本號=版本號+1
3where 秒殺id =#{id} and 版本號 = #{version}
4
5

需注意:樂觀鎖狀態下,由於是隨機性的秒殺失敗,所以可能活動結束後還會有幾個沒售出去的!

5 第 4 版 - 限流

最核心的超賣問題已經解決了,接下來就是各種優化手段了。在高併發請求中如果不對接口限流會對後臺服務器造成極大壓力,所以一般秒殺系統爲了不影響其他業務會單獨部署到個某個服務器上,同時還會設置好限流。

常用的限流方法有我們在 Redis 中曾經說過,主要有漏桶算法令牌桶算法。而Google開源項目GuavaRateLimiter使用的就是令牌桶控制算法。在開發高併發系統時有三把利器用來保護系統:緩存降級限流

  1. 緩存:緩存的目的是提升系統訪問速度和增大系統處理容量。

  2. 降級:降級是當服務器壓力劇增的情況下,根據當前業務情況及流量對一些服務和頁面有策略的降級,以此釋放服務器資源以保證核心任務的正常運行。

  3. 限流:限流的目的是通過對併發訪問 / 請求進行限速,或者對一個時間窗口內的請求進行限速來保護系統,一旦達到限制速率則可以拒絕服務、排隊或等待、降級等處理。

5.1 漏桶算法

漏桶算法思路:把水比作是請求,漏桶比作是系統處理能力極限,水先進入到漏桶裏,漏桶裏的水按一定速率流出,當流出的速率小於流入的速率時,由於漏桶容量有限,後續進入的水直接溢出(拒絕請求),以此實現限流。

5.2 令牌桶算法

令牌桶算法原理:可以理解成醫院的掛號看病,只有拿到號以後纔可以進行診病。


流程大致

  1. 所有的請求在處理之前都需要拿到一個可用的令牌纔會被處理。

  2. 根據限流大小,設置按照一定的速率往桶裏添加令牌。

  3. 設置桶最大可容納值,當桶滿時新添加的令牌就被丟棄或者拒絕。

  4. 請求達到後首先要獲取令牌桶中的令牌,拿着令牌纔可以進行其他的業務邏輯,處理完業務邏輯之後,將令牌直接刪除。

  5. 如果用戶無法獲得令牌可以選擇一直阻塞等待,也可以選擇設置好 timeout 機制。

  6. 令牌桶有最低限額,當桶中的令牌達到最低限額的時候,請求處理完之後將不會刪除令牌,以此保證足夠的限流。

工程中一般用令牌桶算法爲多,一般用GoogleGuavaRateLimiter 即可。

1//創建令牌桶實例
2private RateLimiter rateLimiter = RateLimiter.create(20);
3// 阻塞式獲得令牌才繼續往下執行
4rateLimiter.acquire();
5// 就等3秒看是否可以獲得令牌,返回Boolean值。
6rateLimiter.tryAcquire(3, TimeUnit.SECONDS) 
7
8

6 第 5 版 - 細節優化

有了樂觀鎖跟限流,接下來再思考寫細節問題。

  1. 秒殺要有時間範圍限制的,不能再任意時刻都可以接受秒殺請求,要實行限時搶購

  2. 如果有懂 IT 人員通過抓包獲取了秒殺接口地址,在秒殺開始時,不通過按鈕,直接通過腳本秒殺咋辦?要實行秒殺接口隱藏

  3. 每個用戶單位時間內訪問次數要做頻率限制

6.1 限時搶購

很簡單,將秒殺商品放入 Redis 並設置超時,比如我們以 kill + 商品 id 作爲 key,以商品 id 作爲 value,設置 180 秒超時。

1127.0.0.1:6379> set kill1 1 EX 180
2OK
3
4

加入時間校驗:

 1public Integer createOrder(Integer id) {
 2    //redis校驗搶購時間
 3    if(!stringRedisTemplate.hasKey("kill" + id)){
 4        throw new RuntimeException("秒殺超時,活動已經結束啦!!!");
 5    }
 6    //校驗庫存
 7    Stock stock = checkStock(id);
 8    //扣庫存
 9    updateSale(stock);
10    //下訂單
11    return createOrder(stock);
12}
13
14
6.2 秒殺接口隱藏

接口隱藏

  1. 用戶秒殺前先通過 getMd5 方法獲得一個請求秒殺 URL 的 MD5 值。

  2. 請求 getMd5 算法,Key = 商品 id + 用戶 id,value = 商品 id + 用戶 id + 鹽 。將 KV 存入 redis 並且設置過期時間,最終返回 value 作爲 md5 值。

  3. 用戶請求秒殺 URL 的時候需攜帶 MD5 值,然後 Service 層會根據商品 id + 用戶 id 從 redis 中獲取下對應的 value,看這個 value 跟 MD5 值是否一致,絕對下一步操作。

 1// 根據商品id 跟 用戶id生成個md5。
 2@Override
 3public String getMd5(Integer id, Integer userid) {
 4  //檢驗用戶的合法性
 5  User user = userDAO.findById(userid);
 6  if(user==null)throw new RuntimeException("用戶信息不存在!");
 7
 8  //檢驗商品的合法行
 9  Stock stock = stockDAO.checkStock(id);
10  if(stock==null) throw new RuntimeException("商品信息不合法!");
11
12  String hashKey = "KEY_" + userid + "_" + id;
13  //生成md5,此處的 !AW# 是一個鹽,可以跟找個Random隨機生成。
14  String key = DigestUtils.md5DigestAsHex((userid + id + "!AW#").getBytes());
15  stringRedisTemplate.opsForValue().set(hashKey, key, 3600, TimeUnit.SECONDS);
16  return key;
17}
18
19

此時如果用戶直接請求秒殺接口就會被限制了,但如果黑客技術升級,將請求 MD5 跟請求秒殺接口寫到一起,還是無法防止被薅羊毛!咋辦呢?再限制下用戶訪問頻率

6.3 訪問頻率限制
  1. 通過前面請求後根據用戶 id 生成個 redis 中的 key,value 爲訪問次數,默認爲 0,並且設置好該 KV 的過期時間。

  2. 用戶在驗證是否通過秒殺隱藏接口驗證前,先看下他的單位時間內訪問次數是多少,如果超過閾值則直接拒絕,沒超過再進行隱藏接口的驗證。

  3. 這裏只是舉例爲用戶訪問次數限制,IP 訪問次數限制類似。

  4. 秒殺源碼公衆號回覆秒殺獲取。

訪問頻率限制

7 第 6 版 - 衆多細節優化

  1. CDN加速:爲何京東物流快,因爲人在全國各地配置了多個倉庫。同理,我們可以將前端的一些靜態東西配置在全國各個不同的地方,用戶請求時,直接請求距離自己最近的前端資源即可。

  2. 前端按鈕灰色化:如果參與過秒殺活動會發現,沒到秒殺時間時秒殺按鈕是灰色狀態的,只有時間到了纔是可點擊狀態。並且秒殺開始咯也不是一直可以點的,可能只允許 1 秒內點 10 次那種的。

  3. Nginx負載均衡:一個 tomcat 的 QPS 一般在 200~1000 左右,如果淘寶或京東性質的秒殺,就需要搞個 Nginx 負載均衡來支持幾萬級別的併發了。

  4. 信息存儲Redis化:單獨的 MySQL 是無法支撐上萬的 QPS 的,既然 Redis 號稱可支持 10W 級的 QPS,我們把數據信息存到 Redis 中就好咯嘛!有人可能會說 MySQL 有樂觀鎖跟事務性啊,Redis 不是沒有事務性麼,其實我們可以通過 Lua 腳本來實現併發情況下 Redis 的事務性操作。

  5. 消息中間件-流量削峯:秒殺成功後,如果秒殺的成功量過大,全部訂單直接寫入 MySQL 也是不太恰當的,可以把秒殺成功的用戶信息寫入消息中間件。比如 RabbitMQKafka,給用戶返回搶購成功信息,然後專門代碼消費中間件信息 (生成訂單,數據持久化),因爲是異步消費,爲防止用戶秒殺成功後無法看到訂單信息,在訂單生成前給用戶提示訂單提交排隊中,啥時候訂單異步消費成功了再告知用戶成功。

  6. 輔助手段:秒殺前做個預演練是必須的吧,系統上線後 QPS 監控、CPU 監控、IO 監控、緩存監控也是必須要搞的。同時一旦服務真的扛不住了熔斷跟限流也要考慮進去。

  7. 短URL:有時你別人發給你個超短的 URL 你打開後就直接跳轉爲日常看到的購物頁面了,這就涉及到短 URL 映射了,大致思路就是做個鏈接映射,在此基礎上也可以玩出各種花樣,反正挺有趣的 (有興趣可以水一篇)。

    秒殺大致流程圖

  8. 工業化秒殺:真正工業化的秒殺絕對不止我前面說的那麼簡單哦,起碼你會接觸到 MQSpringBootRedisDubboZKMavenlua等知識點,我也從同性交友網站 GitHub 找到了份爆讚的工業化秒殺項目,公衆號回覆**秒殺**就可以獲取啦。

8 參考

  1. B 站:https://b23.tv/IsifGk

  2. github:https://github.com/qiurunze123/miaosha

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/slk2sgweI2Bxa6R1JC5qiA