分散式鎖(二)——基於資料庫實現分散式鎖
上一篇部落格中簡單說了說什麼是分散式鎖,搭建了基本的環境(非常簡單)這篇部落格就需要開始正式體驗分散式鎖 了,由於是在單機上開發,沒有做叢集,但是程式碼方法的具體實現與叢集方面沒有二異,只能通過JMeter模擬多執行緒達到高併發的效果。
模擬業務場景
1、模擬資料庫中商品庫存銷售的SQL
<!--更新庫存--> <update id="updateStock" parameterType="com.learn.lockmodel.entity.ProductLock"> update product_lock set stock = stock - #{stock,jdbcType=INTEGER} where id = #{id,jdbcType=INTEGER} </update>
2、商品銷售的實體
其中的BindingResult就是校驗結果,之前介紹過,傳送門:spring boot中的引數校驗 其中的BaseResponse是封裝的統一訊息處理物件,具體程式碼如下:
@Data public class BaseResponse<T> { private Integer code; private String msg; private T data; public BaseResponse(StatusCode statusCode) { this.code = statusCode.getCode(); this.msg = statusCode.getMsg(); } public BaseResponse(StatusCode statusCode,T data) { this.code = statusCode.getCode(); this.msg = statusCode.getMsg(); this.data = data; } public BaseResponse(Integer code,String msg) { this.code = code; this.msg = msg; } public BaseResponse(Integer code,String msg,T data) { this.code = code; this.msg = msg; this.data = data; } }
5、postman進行驗證
商品id:10010,商品庫存:1000
利用postman構建如下請求
請求傳送成功之後會看到庫存正常減少
JMeter模擬高併發
JMeter在我看來能模擬我們開發中用到的所有場景,功能似乎比postman強大的多,Apache JMeter 介紹 下載地址:JMeter的各個版本下載地址
安裝
安裝就是一個Easy到爆的東西,解壓完成之後,進入到bin目錄下,點選jmeter.bat批處理指令碼(windows環境下,linux環境下點選jmeter.sh),就可以啟動jmeter
加入HTTP資訊頭管理器
滑鼠反鍵->新增->配置元件->HTTP資訊頭管理器。
之後需要在HTTP頭中增加HTTP的資料型別
這一點與postman不同,新增更加方便。
加入HTTP請求頭
滑鼠右鍵->新增->取樣器->HTTP請求
加入HTTP請求之後,需要配置url,埠等引數。
這裡的請求引數,我們採用的動態配置,JMeter可以通過動態配置變數,自動獲取指定檔案中的資料,這個就需要新增相應的資料檔案了。
加入測試資料檔案
滑鼠右鍵->新增->配置元件
新增檔案之後,我們需要指定CSV檔案,並指定變數名稱:
這也就是為什麼在HTTP請求中指定${stock}變數的原因。這裡的資料CSV檔案中我們就指定了兩個資料——2,4。
設定執行緒組
忘了提一下,在開啟JMeter的時候,預設就會有一個測試計劃,在給測試計劃中添加了執行緒組之後,才可以新增HTTP資訊管理頭,HTTP請求頭和測試資料檔案。
設定相關執行緒組:
發起請求
發起請求,1000個執行緒處理完成之後,我們會發現如下情況
庫存變成負數,這是不能忍的(雖然一定程度下這隻能算是多執行緒的一種簡單模擬,但是如果這些程式碼放在多個伺服器上,多個JVM中執行,還是會出現庫存負數的情況)。
樂觀鎖與悲觀鎖
關於樂觀鎖和悲觀鎖的介紹這裡也不再贅述,樂觀鎖引入了一個版本號的概念,如果版本號不一致則表示已經有其他的操作對其進行了修改,資料就變成了髒資料。悲觀鎖無非就是讓所有的請求進行排隊。這些基本概念已經有大牛總結的非常到位了,百度一搜一大堆,這裡就直接附上一個連結即可。樂觀鎖與悲觀鎖簡介
基於資料庫的實現
資料庫是所用應用程式的資料來源,在資料庫階段完成資料的訪問限制,自然能實現分散式鎖的操作。
樂觀鎖的實現
引入版本號的欄位之後,在更新是匹配版本號與本執行緒是否一致,如果一致則更新資料,如果不一致則放棄更新。在基礎的程式碼基礎上,我們只需要修改SQL就可以實現。
<!--更新庫存-樂觀鎖v1-->
<update id="updateStockV1" parameterType="com.learn.lockmodel.entity.ProductLock">
update product_lock set stock = stock - #{stock,jdbcType=INTEGER},version=version+1
where id = #{id,jdbcType=INTEGER} and version = #{version,jdbcType=INTEGER} and stock > 0
</update>
where 條件中加入了version的判斷,在更新的時候同時讓版本號+1。
業務程式碼無需大的更改,只需要呼叫指定的資料訪問程式碼即可。
@RequestMapping(value=prefix+"/db/update/optimistic",method = RequestMethod.POST,consumes = MediaType.APPLICATION_JSON_UTF8_VALUE)
public BaseResponse dataBaseOptimisticLock(@RequestBody @Validated ProductLockDto productLockDto,BindingResult bindingResult){
if(bindingResult.hasErrors()){
return new BaseResponse(StatusCode.InvalidParam);
}
BaseResponse result = new BaseResponse(StatusCode.Ok);
try{
log.debug("當前請求資料:{}",productLockDto);
int res = dataLockService.updateStockWithOptimisticLock(productLockDto);
if(res<=0){//如果資料庫層面更新失敗,則直接購買失敗
return new BaseResponse(StatusCode.Fail);
}
}catch (Exception e){
log.error("更新商品庫存失敗,異常資訊為:{}",e.fillInStackTrace());
result = new BaseResponse(StatusCode.Fail);
}
return result;
}
測試
為了便於計算,我們將所有的購買個數變成1,然後發起多個執行緒,進行壓力測試。
併發2000個執行緒,初始化庫存50000,啟動JMeter測試,如果版本號+庫存=50000則表示資料正常(只有在購買數都為1的時候才可以用這個公式進行驗證,任何時候公式都成立才表示資料正確)。
測試結果如下圖
悲觀鎖的實現
我們回本溯源一下,出現數據不符合邏輯的情況,其實就是資料出現了髒讀讀取情況,如果用悲觀鎖實現,就需要在讀取資料的時候,加上X 鎖,關於資料庫的X鎖和S鎖可以參見這篇部落格——MySql(三)——事務和鎖
加入X鎖
利用for update語句,給資料庫讀取的時候增加上X鎖,這樣就能避免資料出現髒讀的情況。
<!--根據主鍵查詢for update 悲觀鎖-->
<select id="selectByPKForNegative" resultType="com.learn.lockmodel.entity.ProductLock">
SELECT <include refid="Base_Column_List"/> FROM product_lock
WHERE id=#{id} FOR UPDATE
</select>
加入for update語句,用於加入X鎖。
業務程式碼層面需要做的變更依舊很少,只是需要變更資料查詢的邏輯就可以了。如下所示:
/*
* 悲觀鎖的更新操作
* @param dto
* @return
*/
@Transactional(rollbackFor = Exception.class)
public int updateStockNegativeLock(ProductLockDto dto){
int res = 0;
//獲取庫存資料的時候,加上X鎖
ProductLock negativeLockEntity = lockMapper.selectByPKForNegative(dto.getId());
if(negativeLockEntity!=null && negativeLockEntity.getStock().compareTo(dto.getStock())>=0){
negativeLockEntity.setStock(dto.getStock());
res = lockMapper.updateStockForNegative(negativeLockEntity);
if(res>0){//搶購成功
log.info("下單搶購商品成功,stock{}",negativeLockEntity.getStock());
}else{
log.error("搶購失敗");
}
return res;
}
return res;
}
controller的例項:
/*
* 悲觀鎖更新資料庫
* @param productLockDto
* @param bindingResult
* @return
*/
@RequestMapping(value=prefix+"/db/update/negative",consumes = MediaType.APPLICATION_JSON_UTF8_VALUE)
public BaseResponse dataBaseNegativeLock(@RequestBody @Validated ProductLockDto productLockDto,productLockDto);
int res = dataLockService.updateStockNegativeLock(productLockDto);
if(res<=0){//如果資料庫層面更新失敗,則直接購買失敗
return new BaseResponse(StatusCode.Fail);
}
}catch (Exception e){
log.error("更新商品庫存失敗,異常資訊為:{}",e.fillInStackTrace());
result = new BaseResponse(StatusCode.Fail);
}
return result;
}
**邏輯上這種鎖是直接載入MySQL層面,每一個請求無法更新資料的時候會等待,知道資料更新成功,因此只要資料庫的連線數是充足的,則並不會像樂觀鎖那樣出現很多更新失敗的情況。**測試結果如下所示:
總結
本篇部落格從例項出發,介紹了資料庫層面的樂觀鎖和悲觀鎖。