背景
在電商系統(tǒng)中買商品過(guò)程楞陷,先加入購(gòu)物車怔鳖,然后選中商品,點(diǎn)擊結(jié)算固蛾,即會(huì)進(jìn)入待支付狀態(tài)结执,后續(xù)支付度陆。
過(guò)程需要檢驗(yàn)庫(kù)存是否足夠,保證庫(kù)存不被超賣献幔。
場(chǎng)景一:買家需要購(gòu)買數(shù)量可以多件
場(chǎng)景二:秒殺活動(dòng)懂傀,到時(shí)間點(diǎn)只能購(gòu)買一件
目的
- 防止相同用戶重復(fù)下單
- 檢查庫(kù)存準(zhǔn)確數(shù)量
- 防止扣錯(cuò)庫(kù)存數(shù)量
- 扣庫(kù)存時(shí)性能效率提升、不阻塞用戶
主要解決手段
- 利用redis的incr斜姥、decr的原子性做操作
- redis的lpush鸿竖、rpop的原子性做操作,但是這個(gè)只能一個(gè)一個(gè)的扣铸敏,但不能原子地同時(shí)扣多個(gè)
- sql樂(lè)觀鎖
交互流程
主要環(huán)節(jié):購(gòu)物車->結(jié)清->支付
本文講述結(jié)清時(shí)缚忧,扣庫(kù)存環(huán)節(jié),分布式系統(tǒng)產(chǎn)生訂單環(huán)節(jié)后續(xù)文章再詳細(xì)分析杈笔。
備注:挺推薦使用https://www.processon.com/在線來(lái)做流程圖的
一闪水、防止重復(fù)
利用redis分布式鎖
用分布式鎖,是為了防刷蒙具、防止同一個(gè)用戶同一秒里面把購(gòu)物車?yán)锏纳唐愤M(jìn)行多次結(jié)算球榆,防止前端代碼出問(wèn)題觸發(fā)兩次。
利用Jedis客戶端編寫分布式鎖
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
lockKey是redis的Key禁筏,為用戶id+商品id+商品數(shù)量組成持钉,這樣同一秒中只能有一次處理邏輯。
requestId是redis的value篱昔,實(shí)際是當(dāng)前線程id每强,表示有一條線程占用。
大家要注意這種分布式鎖寫法州刽,是同時(shí)設(shè)定超時(shí)時(shí)間的空执。有些分布式鎖的文章可能是比較舊版的redis不支持同時(shí)設(shè)置超時(shí)時(shí)間,他就一條語(yǔ)句先設(shè)置key value穗椅,另一條語(yǔ)句后設(shè)置超時(shí)時(shí)間辨绊。所以大家留意一下。
二匹表、扣減庫(kù)存
安全扣減庫(kù)存方案有很多說(shuō)法门坷,列一下幾個(gè)方案和我推薦的方案。
方案一:分布式鎖
有的文章會(huì)用redis分布式鎖來(lái)做保證扣庫(kù)存數(shù)量準(zhǔn)確的環(huán)節(jié)桑孩,讓點(diǎn)擊結(jié)算時(shí)拜鹤,后端邏輯會(huì)查詢庫(kù)存和扣庫(kù)存的update語(yǔ)句同時(shí)只有一條線程能夠執(zhí)行,以商品id為分布式鎖的key流椒,鎖一個(gè)商品敏簿。但是這樣,其他購(gòu)買相同商品的用戶將會(huì)進(jìn)行等待。
- 優(yōu)點(diǎn):這樣做雖然安全
- 缺點(diǎn):但是失去的是性能問(wèn)題惯裕。
方案二:分布式鎖+分段緩存
也有文章會(huì)說(shuō)借鑒ConcurrenthashMap温数,分段鎖的機(jī)制,把100個(gè)商品蜻势,分在3個(gè)段上撑刺,key為分段名字,value為庫(kù)存數(shù)量握玛。用戶下單時(shí)對(duì)用戶id進(jìn)行%3計(jì)算够傍,看落在哪個(gè)redis的key上,就去取哪個(gè)挠铲。
如key1=product-01,value1=33;key2=product-02,value2=33;key3=product-03,value3=33;
其實(shí)會(huì)有幾個(gè)問(wèn)題:
- 一個(gè)是用戶想買34件的時(shí)候冕屯,要去兩個(gè)片查
- 一個(gè)片上賣完了為0,又要去另外一個(gè)片查
- 取余方式計(jì)算每一片數(shù)量拂苹,除不盡時(shí)安聘,讓最后一片補(bǔ),如100/3=33.33瓢棒。
缺點(diǎn):
- 方案復(fù)雜
- 有遺留問(wèn)題
方案三: redis的lpush rpop
redis隊(duì)列的lpush浴韭、rpop都是只能每次進(jìn)出一個(gè),對(duì)于購(gòu)買多個(gè)數(shù)量的情況下不適用脯宿,只適用于秒殺情況購(gòu)買一個(gè)的場(chǎng)景念颈、或者搶紅包的場(chǎng)景,所以覺(jué)得不是很通用连霉。
備注:這個(gè)搶紅包場(chǎng)景以后再分享舍肠。
方案四:推薦使用redis原子操作+sql樂(lè)觀鎖
利用Redis increment 的原子操作,保證庫(kù)存數(shù)安全
先查詢r(jià)edis中是否有庫(kù)存信息窘面,如果沒(méi)有就去數(shù)據(jù)庫(kù)查,這樣就可以減少訪問(wèn)數(shù)據(jù)庫(kù)的次數(shù)叽躯。
獲取到后把數(shù)值填入redis财边,以商品id為key,數(shù)量為value点骑。
注意要設(shè)置序列化方式為StringRedisSerializer酣难,不然不能把value做加減操作。
還需要設(shè)置redis對(duì)應(yīng)這個(gè)key的超時(shí)時(shí)間黑滴,以防所有商品庫(kù)存數(shù)據(jù)都在redis中憨募。比較下單數(shù)量的大小,如果夠就做后續(xù)邏輯袁辈。
執(zhí)行redis客戶端的increment菜谣,參數(shù)為負(fù)數(shù),則做減法。因?yàn)閞edis是單線程處理尾膊,并且因?yàn)?strong>increment讓key對(duì)應(yīng)的value 減少后返回的是修改后的值媳危。
有的人會(huì)不做第一步查詢直接減,其實(shí)這樣不太好冈敛,因?yàn)楫?dāng)庫(kù)存為1時(shí)待笑,很多做減3,或者減30情況抓谴,其實(shí)都是不夠暮蹂,這樣就白減。扣減數(shù)據(jù)庫(kù)的庫(kù)存癌压,這個(gè)時(shí)候就不需要再select查詢仰泻,直接樂(lè)觀鎖update,把庫(kù)存字段值減1 措拇。
做完扣庫(kù)存就在訂單系統(tǒng)做下單我纪。
樣例場(chǎng)景:
- 假設(shè)兩個(gè)用戶在第一步查詢得到庫(kù)存等于10,A用戶走到第二步扣10件丐吓,同時(shí)一秒內(nèi)B用戶走到第二部扣3件浅悉。
- 因?yàn)閞edis單線程處理,若A用戶線程先執(zhí)行redis語(yǔ)句券犁,那么現(xiàn)在庫(kù)存等于0术健,B就只能失敗,就不會(huì)出更新數(shù)據(jù)庫(kù)了粘衬。
public void order(OrderReq req) {
String key = "product:" + req.getProductId();
// 第一步:先檢查 庫(kù)存是否充足
Integer num = (Integer) redisTemplate.get(key);
if (num == null){
// 去查數(shù)據(jù)庫(kù)的數(shù)據(jù)
// 并且把數(shù)據(jù)庫(kù)的庫(kù)存set進(jìn)redis荞估,注意使用NX參數(shù)表示只有當(dāng)沒(méi)有redis中沒(méi)有這個(gè)key的時(shí)候才set庫(kù)存數(shù)量到redis
//注意要設(shè)置序列化方式為StringRedisSerializer,不然不能把value做加減操作
// 同時(shí)設(shè)置超時(shí)時(shí)間稚新,因?yàn)椴荒茏宺edis存著所有商品的庫(kù)存數(shù)勘伺,以免占用內(nèi)存。
if (count >=0) {
//設(shè)置有效期十分鐘
redisTemplate.expire(key, 60*10+隨機(jī)數(shù)防止雪崩, TimeUnit.SECONDS);
}
// 減少經(jīng)常訪問(wèn)數(shù)據(jù)庫(kù)褂删,因?yàn)榇疟P比內(nèi)存訪問(wèn)速度要慢
}
if (num < req.getNum()) {
logger.info("庫(kù)存不足");
}
// 第二步:減少庫(kù)存
long value = redisTemplate.increment(key, -req.getNum().longValue());
// 庫(kù)存充足
if (value >= 0) {
logger.info("成功購(gòu)買");
// update 數(shù)據(jù)庫(kù)中商品庫(kù)存和訂單系統(tǒng)下單飞醉,單的狀態(tài)未待支付
// 分開(kāi)兩個(gè)系統(tǒng)處理時(shí),可以用LCN做分布式事務(wù)屯阀,但是也是有概率會(huì)訂單系統(tǒng)的網(wǎng)絡(luò)超時(shí)
// 也可以使用最終一致性的方式缅帘,更新庫(kù)存成功后,發(fā)送mq难衰,等待訂單創(chuàng)建生成回調(diào)钦无。
boolean res= updateProduct(req);
if (res)
createOrder(req);
} else {
// 減了后小小于0 ,如兩個(gè)人同時(shí)買這個(gè)商品盖袭,導(dǎo)致A人第一步時(shí)看到還有10個(gè)庫(kù)存失暂,但是B人買9個(gè)先處理完邏輯彼宠,
// 導(dǎo)致B人的線程10-9=1, A人的線程1-10=-9,則現(xiàn)在需要增加剛剛減去的庫(kù)存趣席,讓別人可以買1個(gè)
redisTemplate.increment(key, req.getNum().longValue());
logger.info("恢復(fù)redis庫(kù)存");
}
}
update使用樂(lè)觀鎖
updateProduct方法中執(zhí)行的sql如下:
update Product set count = count - #{購(gòu)買數(shù)量} where id = #{id} and count - #{購(gòu)買數(shù)量} >= 0;
雖然redis已經(jīng)防止了超賣兵志,但是數(shù)據(jù)庫(kù)層面,為了也要防止超賣宣肚,以防redis崩潰時(shí)無(wú)法使用或者不需要redis處理時(shí)想罕,則用樂(lè)觀鎖,因?yàn)椴灰欢ㄈ可唐范加胷edis霉涨。
利用sql每條單條語(yǔ)句都是有事務(wù)的按价,所以兩條sql同時(shí)執(zhí)行,也就只會(huì)有其中一條sql先執(zhí)行成功笙瑟,另外一條后執(zhí)行楼镐,也如上文提及到的場(chǎng)景一樣。
簡(jiǎn)單說(shuō)一下分布式事務(wù):
分開(kāi)兩個(gè)系統(tǒng)處理庫(kù)存和訂單時(shí)往枷,這個(gè)時(shí)候可以用LCN框架做分布式事務(wù)框产,但是因?yàn)槭莌ttp請(qǐng)求的,也是有概率會(huì)訂單系統(tǒng)的網(wǎng)絡(luò)超時(shí)错洁,導(dǎo)致未返回結(jié)果秉宿。
其實(shí)也可以使用最終一致性的方式,數(shù)據(jù)表記錄一條交互流水記錄屯碴,更新庫(kù)存成功后描睦,更新這個(gè)交互流水記錄的庫(kù)存操作字段為已處理,訂單處理字段為處理中导而,然后發(fā)送mq忱叭,等待訂單創(chuàng)建生成回調(diào)。也要做定時(shí)任務(wù)做主動(dòng)查詢訂單系統(tǒng)的結(jié)果今艺,以防沒(méi)有結(jié)果回來(lái)韵丑。
方案優(yōu)勢(shì)
- 不需要頻繁訪問(wèn)數(shù)據(jù)庫(kù)商品庫(kù)存還有多少
- 不阻塞其他用戶
- 安全扣減庫(kù)存量
- 內(nèi)存訪問(wèn)庫(kù)存數(shù)量,減少數(shù)據(jù)庫(kù)交互
高并發(fā)額外優(yōu)化
- 用戶訪問(wèn)下單是虚缎,前端ui可以讓用戶觸發(fā)結(jié)算后埂息,把按鈕置灰色,防止重復(fù)觸發(fā)遥巴。
- 可以按照庫(kù)存數(shù)量來(lái)選定是否要用redis,因?yàn)槿绻麕?kù)存數(shù)量少享幽,或者說(shuō)最近下單次數(shù)少的商品铲掐,就不用放redis,因?yàn)樯偃丝春唾I的情況下值桩,不必放redis導(dǎo)致占用內(nèi)存摆霉。
- 如果到時(shí)間點(diǎn)搶購(gòu)時(shí),可以使用mq隊(duì)列形式,用戶觸發(fā)購(gòu)買商品后携栋,進(jìn)入隊(duì)列搭盾,讓用戶的頁(yè)面一直在轉(zhuǎn)圈圈,等輪到他買的時(shí)候再進(jìn)入結(jié)算頁(yè)面婉支,結(jié)算頁(yè)面的后續(xù)流程和本文一致鸯隅。
歡迎關(guān)注
我的公眾號(hào) :地藏思維
掘金:地藏Kelvin
簡(jiǎn)書:地藏Kelvin
我的Gitee: 地藏Kelvin https://gitee.com/kelvin-cai