由秒殺引發的一個問題
- 秒殺最大的一個問題就是解決超賣的問題。其中一種解決超賣如下方式:
update goods set num = num - 1 WHERE id = 1001 and num > 0
我們假設現在商品只剩下一件了,此時數據庫中 num = 1;
但有100個線程同時讀取到了這個 num = 1,所以100個線程都開始減庫存了。
但你會最終會發覺,其實只有一個線程減庫存成功,其他99個線程全部失敗。
為何?
這就是MySQL中的排他鎖起了作用。
排他鎖又稱為寫鎖,簡稱X鎖,顧名思義,排他鎖就是不能與其他所並存,如一個事務獲取了一個數據行的排他鎖,其他事務就不能再獲取該行的其他鎖,包括共享鎖和排他鎖,但是獲取排他鎖的事務是可以對數據就行讀取和修改。
就是類似於我在執行update
操作的時候,這一行是一個事務(默認加了排他鎖)。這一行不能被任何其他線程修改和讀寫
- 第二種解決超賣的方式如下
select version from goods WHERE id= 1001;
update goods set num = num - 1, version = version + 1 WHERE id= 1001 AND num > 0 AND version = @version(上面查到的version);
update goods set num = num - 1, version = version + 1 WHERE id= 1001 AND version = @version(上面查到的version);
而且還應該在執行該sql語句前增加一個num數目是否大於0的業務邏輯判斷。
在mysql中,這里實際上還是會加排它鎖的,但是采用版本號也是解決超賣的一種方式,只不過用version
的方式代替掉了數據庫中num>0
這語句的作用,將num>0
的判斷放置在了業務邏輯中進行。
實際上,這兩種方式解決超賣的方式也有細微的一點區別。考慮兩個線程,當庫存數量為2時,如果是第一種方式,那么兩個線程都能成功執行。如果為第二種方式,如果在第一個線程提交事務之前,第二個線程也執行了相同的sql
拿到了version
值(也就是線程1和線程2拿到了相同的version
值),那么這兩個線程之間將只有一個線程能夠讓庫存數目減一成功執行。最終庫存數目不為0
,而為1
。
這種方式采用了版本號的方式,其實也就是CAS的原理。
假設此時version = 100
, num = 1
; 100個線程進入到了這里,同時他們select
出來版本號都是version
= 100。
然后直接update
的時候,只有其中一個先update
了,同時更新了版本號。
那么其他99個在更新的時候,會發覺version
並不等於上次select
的version
,就說明version
被其他線程修改過了。那么我就放棄這次update
- 第三種解決超賣的方式如下
利用redis
的單線程預減庫存。
比如商品有100件。那么我在redis存儲一個k,v。例如 <gs1001, 100>
每一個用戶線程進來,key值就減1,等減到0的時候,全部拒絕剩下的請求。
那么也就是只有100個線程會進入到后續操作。所以一定不會出現超賣的現象。
SpringBoot + redis解決商品秒殺庫存超賣,看這篇文章就夠了 - 知乎 (zhihu.com)
在眾多搶購活動中,在有限的商品數量的限制下如何保證搶購到商品的用戶數不能大於商品數量,也就是不能出現超賣的問題;還有就是搶購時會出現大量用戶的訪問,如何提高用戶體驗效果也是一個問題,也就是要解決秒殺系統的性能問題。
本文主要介紹基於redis 實現商品秒殺功能。先來跟大家講下大概思路。總體思路就是要減少對數據庫的訪問,盡可能將數據緩存到Redis緩存中,從緩存中獲取數據。
- 在系統初始化時,將商品的庫存數量加載到Redis緩存中,並不是需要先請求一次才能緩存
- 接收到秒殺請求時,在Redis中進行預減庫存,當Redis中的庫存不足時,直接返回秒殺失敗,減少對數據庫的訪問。否則繼續進行第3步;
- 將請求放入異步隊列(RabbitMQ)中,立即給前端返回一個值,表示正在排隊中。
- 服務端異步隊列將請求出隊,出隊成功的請求可以然后進行秒殺邏輯,減庫存–>下訂單–>寫入秒殺訂單,成功了就返回成功。
- 當后台訂單創建成功之后可以通過
websocket
向用戶發送一個秒殺成功通知。前端以此來判斷是否秒殺成功,秒殺成功則進入秒殺訂單詳情,否則秒殺失敗。
- 系統初始化的時候將秒殺商品庫存放入
redis
緩存
//首先我們需要實現InitializingBean接口,InitializingBean接口為bean提供了初始化方法的方式,它就包括afterPropertiesSet方法,凡是繼承該接口的類,在初始化bean的時候會執行該方法。
@Component
public class WebListener implements InitializingBean{
@Autowired
private RedisTemplate redistemplate;
@Override
public void afterPropertiesSet() throws Exception{
List<GoodsVo> goodsList = goodsService.listGoodsVo();
if(goodsList == null) {
return;
}
for(GoodsVo goods : goodsList) {
redistemplate.set(GoodsKey.getMiaoshaGoodsStock, ""+goods.getId(), goods.getStockCount());
localOverMap.put(goods.getId(), false);//先初始化 每個商品都是false 就是還有庫存
}
}
}
//這就實現了我們系統啟動就把所有緩存加載完畢,然后我們通過操作redis來實現預減庫存
- 預減庫存 請求放到異步隊列
//然后當我們的並發量夠大,redis的壓力頁很大,然后我們可以通過map集合標記緩存,減少redis服務器的壓力
// 1、生成一個map,並在初始化的時候,將所有商品的id為鍵,標記false 存入map中。
// 2、在預減庫存之前,從map中取標記,若標記為false,說明庫存,還有,
// 3、預減庫存,當遇到庫存不足的時候,將該商品的標記置為true,表示該商品的庫存不足。
這樣,下面的所有請求,將被攔截,無需訪問redis進行預減庫存。
//系統啟動時會對其初始化,將所有秒殺商品id存入map,庫存為0是為true
private Map<Long,Boolean> localOverMap = new HashMap<Long,Boolean>();
//====================================================================================
@RequestMapping(value="/{path}/do_miaosha", method=RequestMethod.POST)
@ResponseBody
public Result<Integer> miaosha(HttpServletRequest request, HttpServletResponse response,
Model model,MiaoshaUser user,
@RequestParam("goodsId")long goodsId,
@PathVariable("path") String path) {
model.addAttribute("user", user);
//如果用戶為空,則返回至登錄頁面
if(user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
//驗證path
boolean check = miaoshaService.checkPath(user, goodsId, path);
if(!check){
return Result.error(CodeMsg.REQUEST_ILLEGAL);
}
//內存標記,從map取值判斷,減少redis訪問
boolean over = localOverMap.get(goodsId);
if(over) {
return Result.error(CodeMsg.MIAO_SHA_OVER);
}
//預減庫存 這里的減庫存是原子性的操作
long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, ""+goodsId);//10
if(stock < 0) {
localOverMap.put(goodsId, true);
return Result.error(CodeMsg.MIAO_SHA_OVER);
}
//判斷是否已經秒殺到了
MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
if(order != null) {
return Result.error(CodeMsg.REPEATE_MIAOSHA);
}
//入隊
MiaoshaMessage mm = new MiaoshaMessage();
mm.setUser(user);
mm.setGoodsId(goodsId);
sender.sendMiaoshaMessage(mm);
//返回0代表排隊中
return Result.success(0);
}
// redis給數據庫減輕壓力,利用map標記庫存給redis減輕壓力
還有一種寫法:
這里使用到了redis api
中的decrement
操作,預減用戶搶購的數量,同時判斷redis中的庫存是否大於用戶搶購數量,如果小於0,直接提示用戶秒殺失敗,否則秒殺成功,進入redis消息隊列執行數據庫建庫存操作。
- 由於是通過異步隊列寫入數據庫中,可能存在數據不一致。
[閑談秒殺系統(二)解決一致性問題 - 知乎 (zhihu.com)](