原文出自:公眾號 sowhat1412
原文鏈接:
https://mp.weixin.qq.com/s/rbXrhzIJG2NtYt_61OmzTA
1 秒殺場景
秒殺場景
- 登陸12306進行火車票搶座
- 1599元購入飛天茅臺
- 周董演唱會的門票
- 雙十一秒殺活動
秒殺場景關注點
- 嚴格防止超賣:庫存1000件賣了1020件,要殺個碼農祭天了!防止超賣是秒殺系統設計最核心的部分。
- 防止黑產:防止不懷好意的羊毛黨薅羊毛。
- 保證用戶體驗:高并發下,給用戶提供友善的購物體驗,盡可能支持比較高的QPS等等。
接下來就讓我們按照關注點,不斷細化秒殺場景。
2 第1版-裸奔
裸奔秒殺
不加思考,上來直接按照 SpringBoot + MyBatis 模式進行秒殺系統的設計,流程如下:
- Controller層獲得用戶秒殺請求后調用Service層。
- Service層獲得請求后要要檢查已售數據跟庫存總量是否一致,一致說明商品賣沒了,不一致說明還有庫存,那就調用DAO層對已售數量進行加1。
- DAO層獲得請求后直接通過MyBatis操作數據庫實現已售數量加1跟訂單創建。
如果你用Postman去測試會發現是OK的,但如果你用專業的并發測試工具JMeter模式多用戶并發請求會發現訂單創建數量 > 庫存量 - 已售量。原因解釋下,比如用戶A、B并發進行秒殺請求,此時庫存=100,已售=64。
- A用戶進行描述請求,此時調用到了Service層,發現已售不等于庫存,此時拿到庫存數是64,A將庫存更新為63,然后創建訂單。
- B用戶進行描述請求,此時調用到了Service層,發現已售不等于庫存,此時拿到庫存數是64,B將庫存更新為63,然后創建訂單。
- 此時庫存減少了1個但是訂單創建多個,賣超了!
無鎖并發請求,賣超了
3 第2版-悲觀鎖
syn悲觀鎖
遇見 并發問題 很容易想到以前學過并發編程嘛,既然Controller默認是單例模式,那我用 synchronized 將Controller層調用Service層的代碼進行加鎖同步即可。
這樣就可以解決賣超問題了,但是須知,既然是悲觀鎖,如果有1000個并發請求,那只有1個拿到鎖了。有999個會去競爭這個鎖的。
@Transactional
@Service
@Transactional
@Slf4j
public class OrderServiceImpl implements OrderService
{
//校驗庫存
Stock stock = checkStock(id);
//更新庫存
updateSale(stock);
//創建訂單
return createOrder(stock);
}
當然了你也可以用Spring自帶的事務注解來實現悲觀鎖的操作,因為用了@Transactional就可以實現通過事務來控制,要么全部成功,要么全部失敗,用事務時有兩點需注意:
- 盡可能將MySQL執行語句往方法體后面靠,因為MySQL事務的commit語句是在第一次執行MySQL相關語句開始,一直到方法的結束。
- 設置事務的超時時間,如果不設置默認是-1是無限長。并且事務中設置的耗時timeout = 最后一個MySQL語句耗時 + 以及最后一個MySQL之前的所有耗時。
需注意:悲觀鎖狀態下會保證商品賣出去,如果沒拿到鎖的線程會阻塞的等待拿鎖。但是他的阻塞也會給用戶帶來非常不良好的體驗。
4 第3版-樂觀鎖
MySQL版本號
我們為每個數量的已售數據配備個版本號,在Service層調用時獲得用戶的已售數跟對應版本號,然后更新時將已售數跟版本號同時更新。因為 MySQL在更新時會自帶樂觀加速機制,如果更新成功則表示搶購成功,更新失敗則表示搶購失敗,此時你會發現不是手速越快就一定能搶到的哦,但起碼保證了不會超賣,
update 庫存表 set
已售數=已售數+1,版本號=版本號+1
where 秒殺id =#{id} and 版本號 = #{version}
需注意:樂觀鎖狀態下,由于是隨機性的秒殺失敗,所以可能活動結束后還會有幾個沒售出去的!
5 第4版-限流
最核心的超賣問題已經解決了,接下來就是各種優化手段了。在高并發請求中如果不對接口限流會對后臺服務器造成極大壓力,所以一般秒殺系統為了不影響其他業務會單獨部署到個某個服務器上,同時還會設置好限流。
常用的限流方法有我們在 redis 中曾經說過,主要有漏桶算法、令牌桶算法。而google開源項目Guava中RateLimiter使用的就是令牌桶控制算法。在開發高并發系統時有三把利器用來保護系統:緩存、降級、限流
- 緩存:緩存的目的是提升系統訪問速度和增大系統處理容量。
- 降級:降級是當服務器壓力劇增的情況下,根據當前業務情況及流量對一些服務和頁面有策略的降級,以此釋放服務器資源以保證核心任務的正常運行。
- 限流:限流的目的是通過對并發訪問/請求進行限速,或者對一個時間窗口內的請求進行限速來保護系統,一旦達到限制速率則可以拒絕服務、排隊或等待、降級等處理。
5.1 漏桶算法
漏桶算法思路:把水比作是請求,漏桶比作是系統處理能力極限,水先進入到漏桶里,漏桶里的水按一定速率流出,當流出的速率小于流入的速率時,由于漏桶容量有限,后續進入的水直接溢出(拒絕請求),以此實現限流。
5.2 令牌桶算法
令牌桶算法原理:可以理解成醫院的掛號看病,只有拿到號以后才可以進行診病。
流程大致:
- 所有的請求在處理之前都需要拿到一個可用的令牌才會被處理。
- 根據限流大小,設置按照一定的速率往桶里添加令牌。
- 設置桶最大可容納值,當桶滿時新添加的令牌就被丟棄或者拒絕。
- 請求達到后首先要獲取令牌桶中的令牌,拿著令牌才可以進行其他的業務邏輯,處理完業務邏輯之后,將令牌直接刪除。
- 如果用戶無法獲得令牌可以選擇一直阻塞等待,也可以選擇設置好timeout機制。
- 令牌桶有最低限額,當桶中的令牌達到最低限額的時候,請求處理完之后將不會刪除令牌,以此保證足夠的限流。
工程中一般用令牌桶算法為多,一般用Google的Guava 中 RateLimiter 即可。
//創建令牌桶實例
private RateLimiter rateLimiter = RateLimiter.create(20);
// 阻塞式獲得令牌才繼續往下執行
rateLimiter.acquire();
// 就等3秒看是否可以獲得令牌,返回Boolean值。
rateLimiter.tryAcquire(3, TimeUnit.SECONDS)
6 第5版- 細節優化
有了樂觀鎖跟限流,接下來再思考寫細節問題。
- 秒殺要有時間范圍限制的,不能再任意時刻都可以接受秒殺請求,要實行限時搶購。
- 如果有懂IT人員通過抓包獲取了秒殺接口地址,在秒殺開始時,不通過按鈕,直接通過腳本秒殺咋辦?要實行秒殺接口隱藏。
- 每個用戶單位時間內訪問次數要做頻率限制。
6.1 限時搶購
很簡單,將秒殺商品放入Redis并設置超時,比如我們以kill + 商品id作為key,以商品id作為value,設置180秒超時。
127.0.0.1:6379> set kill1 1 EX 180
OK
加入時間校驗:
public Integer createOrder(Integer id) {
//redis校驗搶購時間
if(!
stringRedisTemplate.hasKey("kill" + id)){
throw new RuntimeException("秒殺超時,活動已經結束啦!!!");
}
//校驗庫存
Stock stock = checkStock(id);
//扣庫存
updateSale(stock);
//下訂單
return createOrder(stock);
}
6.2 秒殺接口隱藏
接口隱藏
- 用戶秒殺前先通過getMd5方法獲得一個請求秒殺URL的MD5值。
- 請求getMd5算法,Key = 商品id + 用戶id,value = 商品id + 用戶id + 鹽 。將KV存入redis并且設置過期時間,最終返回value作為md5值。
- 用戶請求秒殺URL的時候需攜帶MD5值,然后Service層會根據商品id + 用戶id從redis中獲取下對應的value,看這個value跟MD5值是否一致,絕對下一步操作。
// 根據商品id 跟 用戶id生成個md5。
@Override
public String getMd5(Integer id, Integer userid) {
//檢驗用戶的合法性
User user = userDAO.findById(userid);
if(user==null)throw new RuntimeException("用戶信息不存在!");
//檢驗商品的合法行
Stock stock = stockDAO.checkStock(id);
if(stock==null) throw new RuntimeException("商品信息不合法!");
String hashKey = "KEY_" + userid + "_" + id;
//生成md5,此處的 !AW# 是一個鹽,可以跟找個Random隨機生成。
String key =
DigestUtils.md5DigestAsHex((userid + id + "!AW#").getBytes());
stringRedisTemplate.opsForValue().set(hashKey, key, 3600, TimeUnit.SECONDS);
return key;
}
此時如果用戶直接請求秒殺接口就會被限制了,但如果黑客技術升級,將請求MD5跟請求秒殺接口寫到一起,還是無法防止被薅羊毛!咋辦呢?再限制下用戶訪問頻率。
6.3 訪問頻率限制
- 通過前面請求后根據用戶id生成個redis中的key,value為訪問次數,默認為0,并且設置好該KV的過期時間。
- 用戶在驗證是否通過秒殺隱藏接口驗證前,先看下他的單位時間內訪問次數是多少,如果超過閾值則直接拒絕,沒超過再進行隱藏接口的驗證。
- 這里只是舉例為用戶訪問次數限制,IP訪問次數限制類似。
訪問頻率限制
7 第6版-眾多細節優化
- CDN加速:為何京東物流快,因為人在全國各地配置了多個倉庫。同理,我們可以將前端的一些靜態東西配置在全國各個不同的地方,用戶請求時,直接請求距離自己最近的前端資源即可。
- 前端按鈕灰色化:如果參與過秒殺活動會發現,沒到秒殺時間時秒殺按鈕是灰色狀態的,只有時間到了才是可點擊狀態。并且秒殺開始咯也不是一直可以點的,可能只允許1秒內點10次那種的。
- Nginx負載均衡:一個Tomcat的QPS一般在200~1000左右,如果淘寶或京東性質的秒殺,就需要搞個Nginx負載均衡來支持幾萬級別的并發了。
- 信息存儲Redis化:單獨的MySQL是無法支撐上萬的QPS的,既然Redis號稱可支持10W級的QPS,我們把數據信息存到Redis中就好咯嘛!有人可能會說MySQL有樂觀鎖跟事務性啊,Redis不是沒有事務性么,其實我們可以通過 Lua 腳本來實現并發情況下Redis的事務性操作。
- 消息中間件-流量削峰:秒殺成功后,如果秒殺的成功量過大,全部訂單直接寫入MySQL也是不太恰當的,可以把秒殺成功的用戶信息寫入消息中間件。比如RabbitMQ、Kafka,給用戶返回搶購成功信息,然后專門代碼消費中間件信息(生成訂單,數據持久化),因為是異步消費,為防止用戶秒殺成功后無法看到訂單信息,在訂單生成前給用戶提示訂單提交排隊中,啥時候訂單異步消費成功了再告知用戶成功。
- 輔助手段:秒殺前做個預演練是必須的吧,系統上線后QPS監控、CPU監控、IO監控、緩存監控也是必須要搞的。同時一旦服務真的扛不住了熔斷跟限流也要考慮進去。
- 短URL:有時你別人發給你個超短的URL你打開后就直接跳轉為日常看到的購物頁面了,這就涉及到短URL映射了,大致思路就是做個鏈接映射,在此基礎上也可以玩出各種花樣,反正挺有趣的。






