原文出自:公眾號 sowhat1412
原文鏈接:
https://mp.weixin.qq.com/s/rbXrhzIJG2NtYt_61OmzTA
1 秒殺場景
秒殺場景
- 登陸12306進行火車票搶座
- 1599元購入飛天茅臺
- 周董演唱會的門票
- 雙十一秒殺活動
秒殺場景關(guān)注點
- 嚴格防止超賣:庫存1000件賣了1020件,要殺個碼農(nóng)祭天了!防止超賣是秒殺系統(tǒng)設(shè)計最核心的部分。
- 防止黑產(chǎn):防止不懷好意的羊毛黨薅羊毛。
- 保證用戶體驗:高并發(fā)下,給用戶提供友善的購物體驗,盡可能支持比較高的QPS等等。
接下來就讓我們按照關(guān)注點,不斷細化秒殺場景。
2 第1版-裸奔

裸奔秒殺
不加思考,上來直接按照 SpringBoot + MyBatis 模式進行秒殺系統(tǒng)的設(shè)計,流程如下:
- Controller層獲得用戶秒殺請求后調(diào)用Service層。
- Service層獲得請求后要要檢查已售數(shù)據(jù)跟庫存總量是否一致,一致說明商品賣沒了,不一致說明還有庫存,那就調(diào)用DAO層對已售數(shù)量進行加1。
- DAO層獲得請求后直接通過MyBatis操作數(shù)據(jù)庫實現(xiàn)已售數(shù)量加1跟訂單創(chuàng)建。
如果你用Postman去測試會發(fā)現(xiàn)是OK的,但如果你用專業(yè)的并發(fā)測試工具JMeter模式多用戶并發(fā)請求會發(fā)現(xiàn)訂單創(chuàng)建數(shù)量 > 庫存量 - 已售量。原因解釋下,比如用戶A、B并發(fā)進行秒殺請求,此時庫存=100,已售=64。
- A用戶進行描述請求,此時調(diào)用到了Service層,發(fā)現(xiàn)已售不等于庫存,此時拿到庫存數(shù)是64,A將庫存更新為63,然后創(chuàng)建訂單。
- B用戶進行描述請求,此時調(diào)用到了Service層,發(fā)現(xiàn)已售不等于庫存,此時拿到庫存數(shù)是64,B將庫存更新為63,然后創(chuàng)建訂單。
- 此時庫存減少了1個但是訂單創(chuàng)建多個,賣超了!
無鎖并發(fā)請求,賣超了
3 第2版-悲觀鎖

syn悲觀鎖
遇見 并發(fā)問題 很容易想到以前學過并發(fā)編程嘛,既然Controller默認是單例模式,那我用 synchronized 將Controller層調(diào)用Service層的代碼進行加鎖同步即可。
這樣就可以解決賣超問題了,但是須知,既然是悲觀鎖,如果有1000個并發(fā)請求,那只有1個拿到鎖了。有999個會去競爭這個鎖的。
@Transactional
@Service
@Transactional
@Slf4j
public class OrderServiceImpl implements OrderService
{
//校驗庫存
Stock stock = checkStock(id);
//更新庫存
updateSale(stock);
//創(chuàng)建訂單
return createOrder(stock);
}
當然了你也可以用Spring自帶的事務(wù)注解來實現(xiàn)悲觀鎖的操作,因為用了@Transactional就可以實現(xiàn)通過事務(wù)來控制,要么全部成功,要么全部失敗,用事務(wù)時有兩點需注意:
- 盡可能將MySQL執(zhí)行語句往方法體后面靠,因為MySQL事務(wù)的commit語句是在第一次執(zhí)行MySQL相關(guān)語句開始,一直到方法的結(jié)束。
- 設(shè)置事務(wù)的超時時間,如果不設(shè)置默認是-1是無限長。并且事務(wù)中設(shè)置的耗時timeout = 最后一個MySQL語句耗時 + 以及最后一個MySQL之前的所有耗時。
需注意:悲觀鎖狀態(tài)下會保證商品賣出去,如果沒拿到鎖的線程會阻塞的等待拿鎖。但是他的阻塞也會給用戶帶來非常不良好的體驗。
4 第3版-樂觀鎖

MySQL版本號
我們?yōu)槊總€數(shù)量的已售數(shù)據(jù)配備個版本號,在Service層調(diào)用時獲得用戶的已售數(shù)跟對應(yīng)版本號,然后更新時將已售數(shù)跟版本號同時更新。因為 MySQL在更新時會自帶樂觀加速機制,如果更新成功則表示搶購成功,更新失敗則表示搶購失敗,此時你會發(fā)現(xiàn)不是手速越快就一定能搶到的哦,但起碼保證了不會超賣,
update 庫存表 set
已售數(shù)=已售數(shù)+1,版本號=版本號+1
where 秒殺id =#{id} and 版本號 = #{version}
需注意:樂觀鎖狀態(tài)下,由于是隨機性的秒殺失敗,所以可能活動結(jié)束后還會有幾個沒售出去的!
5 第4版-限流
最核心的超賣問題已經(jīng)解決了,接下來就是各種優(yōu)化手段了。在高并發(fā)請求中如果不對接口限流會對后臺服務(wù)器造成極大壓力,所以一般秒殺系統(tǒng)為了不影響其他業(yè)務(wù)會單獨部署到個某個服務(wù)器上,同時還會設(shè)置好限流。
常用的限流方法有我們在 redis 中曾經(jīng)說過,主要有漏桶算法、令牌桶算法。而google開源項目Guava中RateLimiter使用的就是令牌桶控制算法。在開發(fā)高并發(fā)系統(tǒng)時有三把利器用來保護系統(tǒng):緩存、降級、限流
- 緩存:緩存的目的是提升系統(tǒng)訪問速度和增大系統(tǒng)處理容量。
- 降級:降級是當服務(wù)器壓力劇增的情況下,根據(jù)當前業(yè)務(wù)情況及流量對一些服務(wù)和頁面有策略的降級,以此釋放服務(wù)器資源以保證核心任務(wù)的正常運行。
- 限流:限流的目的是通過對并發(fā)訪問/請求進行限速,或者對一個時間窗口內(nèi)的請求進行限速來保護系統(tǒng),一旦達到限制速率則可以拒絕服務(wù)、排隊或等待、降級等處理。
5.1 漏桶算法
漏桶算法思路:把水比作是請求,漏桶比作是系統(tǒng)處理能力極限,水先進入到漏桶里,漏桶里的水按一定速率流出,當流出的速率小于流入的速率時,由于漏桶容量有限,后續(xù)進入的水直接溢出(拒絕請求),以此實現(xiàn)限流。

5.2 令牌桶算法
令牌桶算法原理:可以理解成醫(yī)院的掛號看病,只有拿到號以后才可以進行診病。

流程大致:
- 所有的請求在處理之前都需要拿到一個可用的令牌才會被處理。
- 根據(jù)限流大小,設(shè)置按照一定的速率往桶里添加令牌。
- 設(shè)置桶最大可容納值,當桶滿時新添加的令牌就被丟棄或者拒絕。
- 請求達到后首先要獲取令牌桶中的令牌,拿著令牌才可以進行其他的業(yè)務(wù)邏輯,處理完業(yè)務(wù)邏輯之后,將令牌直接刪除。
- 如果用戶無法獲得令牌可以選擇一直阻塞等待,也可以選擇設(shè)置好timeout機制。
- 令牌桶有最低限額,當桶中的令牌達到最低限額的時候,請求處理完之后將不會刪除令牌,以此保證足夠的限流。
工程中一般用令牌桶算法為多,一般用Google的Guava 中 RateLimiter 即可。
//創(chuàng)建令牌桶實例
private RateLimiter rateLimiter = RateLimiter.create(20);
// 阻塞式獲得令牌才繼續(xù)往下執(zhí)行
rateLimiter.acquire();
// 就等3秒看是否可以獲得令牌,返回Boolean值。
rateLimiter.tryAcquire(3, TimeUnit.SECONDS)
6 第5版- 細節(jié)優(yōu)化
有了樂觀鎖跟限流,接下來再思考寫細節(jié)問題。
- 秒殺要有時間范圍限制的,不能再任意時刻都可以接受秒殺請求,要實行限時搶購。
- 如果有懂IT人員通過抓包獲取了秒殺接口地址,在秒殺開始時,不通過按鈕,直接通過腳本秒殺咋辦?要實行秒殺接口隱藏。
- 每個用戶單位時間內(nèi)訪問次數(shù)要做頻率限制。
6.1 限時搶購
很簡單,將秒殺商品放入Redis并設(shè)置超時,比如我們以kill + 商品id作為key,以商品id作為value,設(shè)置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("秒殺超時,活動已經(jīng)結(jié)束啦!!!");
}
//校驗庫存
Stock stock = checkStock(id);
//扣庫存
updateSale(stock);
//下訂單
return createOrder(stock);
}
6.2 秒殺接口隱藏

接口隱藏
- 用戶秒殺前先通過getMd5方法獲得一個請求秒殺URL的MD5值。
- 請求getMd5算法,Key = 商品id + 用戶id,value = 商品id + 用戶id + 鹽 。將KV存入redis并且設(shè)置過期時間,最終返回value作為md5值。
- 用戶請求秒殺URL的時候需攜帶MD5值,然后Service層會根據(jù)商品id + 用戶id從redis中獲取下對應(yīng)的value,看這個value跟MD5值是否一致,絕對下一步操作。
// 根據(jù)商品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;
}
此時如果用戶直接請求秒殺接口就會被限制了,但如果黑客技術(shù)升級,將請求MD5跟請求秒殺接口寫到一起,還是無法防止被薅羊毛!咋辦呢?再限制下用戶訪問頻率。
6.3 訪問頻率限制
- 通過前面請求后根據(jù)用戶id生成個redis中的key,value為訪問次數(shù),默認為0,并且設(shè)置好該KV的過期時間。
- 用戶在驗證是否通過秒殺隱藏接口驗證前,先看下他的單位時間內(nèi)訪問次數(shù)是多少,如果超過閾值則直接拒絕,沒超過再進行隱藏接口的驗證。
- 這里只是舉例為用戶訪問次數(shù)限制,IP訪問次數(shù)限制類似。

訪問頻率限制
7 第6版-眾多細節(jié)優(yōu)化
- CDN加速:為何京東物流快,因為人在全國各地配置了多個倉庫。同理,我們可以將前端的一些靜態(tài)東西配置在全國各個不同的地方,用戶請求時,直接請求距離自己最近的前端資源即可。
- 前端按鈕灰色化:如果參與過秒殺活動會發(fā)現(xiàn),沒到秒殺時間時秒殺按鈕是灰色狀態(tài)的,只有時間到了才是可點擊狀態(tài)。并且秒殺開始咯也不是一直可以點的,可能只允許1秒內(nèi)點10次那種的。
- Nginx負載均衡:一個Tomcat的QPS一般在200~1000左右,如果淘寶或京東性質(zhì)的秒殺,就需要搞個Nginx負載均衡來支持幾萬級別的并發(fā)了。
- 信息存儲Redis化:單獨的MySQL是無法支撐上萬的QPS的,既然Redis號稱可支持10W級的QPS,我們把數(shù)據(jù)信息存到Redis中就好咯嘛!有人可能會說MySQL有樂觀鎖跟事務(wù)性啊,Redis不是沒有事務(wù)性么,其實我們可以通過 Lua 腳本來實現(xiàn)并發(fā)情況下Redis的事務(wù)性操作。
- 消息中間件-流量削峰:秒殺成功后,如果秒殺的成功量過大,全部訂單直接寫入MySQL也是不太恰當?shù)模梢园衙霘⒊晒Φ挠脩粜畔懭胂⒅虚g件。比如RabbitMQ、Kafka,給用戶返回搶購成功信息,然后專門代碼消費中間件信息(生成訂單,數(shù)據(jù)持久化),因為是異步消費,為防止用戶秒殺成功后無法看到訂單信息,在訂單生成前給用戶提示訂單提交排隊中,啥時候訂單異步消費成功了再告知用戶成功。
- 輔助手段:秒殺前做個預(yù)演練是必須的吧,系統(tǒng)上線后QPS監(jiān)控、CPU監(jiān)控、IO監(jiān)控、緩存監(jiān)控也是必須要搞的。同時一旦服務(wù)真的扛不住了熔斷跟限流也要考慮進去。
- 短URL:有時你別人發(fā)給你個超短的URL你打開后就直接跳轉(zhuǎn)為日常看到的購物頁面了,這就涉及到短URL映射了,大致思路就是做個鏈接映射,在此基礎(chǔ)上也可以玩出各種花樣,反正挺有趣的。