悲觀鎖 樂觀鎖
在沒有加鎖的秒殺場景下 每秒打進來的請求是巨大的 高并發場景下 我們發現不僅異常率高的可怕 庫存竟然還變成了負數 這產生的結果肯定是很大損失的 那為什么會出現超賣問題呢
我們假設有下面兩個線程
線程1查詢庫存,發現庫存充足,創建訂單,然后準備對庫存進行扣減,但此時線程2和線程3也進行查詢,同樣發現庫存充足,然后線程1執行完扣減操作后,庫存變為了0,線程2和線程3同樣完成了庫存扣減操作,最終導致庫存變成了負數!這就是超賣問題的完整流程
因此超賣產生了 那我們應該如何解決呢 很簡單 直接加鎖不就好了 加個互斥鎖 一個一個來 那么會出現所有人都在堵塞 秒殺變成小時殺了 肯定不行 所以我先介紹兩種鎖的機制
- 悲觀鎖,認為線程安全問題一定會發生,因此操作數據庫之前都需要先獲取鎖,確保線程串行執行。常見的悲觀鎖有:synchronized、lock
- 樂觀鎖,認為線程安全問題不一定發生,因此不加鎖,只會在更新數據庫的時候去判斷有沒有其它線程對數據進行修改,如果沒有修改則認為是安全的,直接更新數據庫中的數據即可,如果修改了則說明不安全,直接拋異常或者等待重試。常見的實現方式有:版本號法、CAS操作、樂觀鎖算法
接下來我們詳細分析樂觀鎖的實現方式:
版本號機制
版本號機制是樂觀鎖最常見的實現方式。每條數據都有一個版本號,每次更新數據時版本號加1。當線程A要更新數據時,先檢查當前版本號是否與自己獲取時的版本號一致,如果一致則更新,否則說明數據已被其他線程修改,更新失敗。
例如我們可以在商品表中增加一個version字段:
UPDATE product SET stock = stock - 1, version = version + 1
WHERE id = #{id} AND version = #{version}
CAS (Compare And Swap)
CAS是樂觀鎖的另一種實現方式,它包含三個操作數:內存位置、預期原值和新值。執行CAS操作時,將內存位置的值與預期原值比較,如果相匹配,則將內存位置的值更新為新值。否則,不做任何操作。
public boolean decreaseStock(Long productId, Integer version) {// 查詢商品當前庫存和版本號Product product = productMapper.selectById(productId);if (product.getStock() <= 0) {return false; // 庫存不足}// 使用CAS更新庫存和版本號int result = productMapper.decreaseStockWithVersion(productId, product.getVersion(), product.getVersion() + 1);return result > 0;
}
兩種鎖的適用場景
悲觀鎖適用于:
- 并發寫入多、臨界資源爭搶激烈的場景
- 讀少寫多的場景
- 要求數據強一致性的場景
樂觀鎖適用于:
- 并發寫入少、沖突較少的場景
- 讀多寫少的場景
- 允許短時間數據不一致的場景
樂觀鎖解決超賣問題
首先我們要為 tb_seckill_voucher 表新增一個版本號字段 version ,線程1查詢完庫存,在進行庫存扣減操作的同時將版本號+1,線程2在查詢庫存時,同時查詢出當前的版本號,發現庫存充足,也準備執行庫存扣減操作,但是需要判斷當前的版本號是否是之前查詢時的版本號,結果發現版本號發生了改變,這就說明數據庫中的數據已經發生了修改,需要進行重試(或者直接拋異常中斷)
**boolean flag = seckillVoucherService.update(new LambdaUpdateWrapper<SeckillVoucher>().eq(SeckillVoucher::getVoucherId, voucherId).gt(SeckillVoucher::getStock, 0).setSql("stock = stock -1"));**
注意到這里**.gt(SeckillVoucher::getStock, 0)而不是eq(SeckillVoucher::getStock,voucher.getStock())**
其實是因為樂觀鎖的弊端 可能鎖住正常訂單 例如大家一起獲取庫存100 一個線程執行成功 其他線程扣減時 發現庫存為99 直接不干了 多個線程直接斷了 因此我們直接寫成第一種即可
一人一單超賣
很容易發現 在判斷訂單前加上邏輯即可
int count = this.count(new LambdaQueryWrapper<VoucherOrder>().eq(VoucherOrder::getUserId, ThreadLocalUtls.getUser().getId()));if (count >= 1) {// 當前用戶不是第一單return Result.fail("用戶已購買");}
通過測試,發現并沒有達到我們想象中的目標,一個人只能購買一次,但是發現一個用戶居然能夠購買8次。這說明還是存在超賣問題
問題原因:出現這個問題的原因和前面庫存為負數數的情況是一樣的,線程1查詢當前用戶是否有訂單,當前用戶沒有訂單準備下單,此時線程2也查詢當前用戶是否有訂單,由于線程1還沒有完成下單操作,線程2同樣發現當前用戶未下單,也準備下單,這樣明明一個用戶只能下一單,結果下了兩單,也就出現了超賣問題
解決方案:一般這種超賣問題可以使用下面兩種常見的解決方案
- 悲觀鎖
- 樂觀鎖
悲觀鎖解決超賣問題
/*** 搶購秒殺券** @param voucherId* @return*/@Transactional@Overridepublic Result seckillVoucher(Long voucherId) {// 1、查詢秒殺券SeckillVoucher voucher = seckillVoucherService.getById(voucherId);// 2、判斷秒殺券是否合法if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {// 秒殺券的開始時間在當前時間之后return Result.fail("秒殺尚未開始");}if (voucher.getEndTime().isBefore(LocalDateTime.now())) {// 秒殺券的結束時間在當前時間之前return Result.fail("秒殺已結束");}if (voucher.getStock() < 1) {return Result.fail("秒殺券已搶空");}// 3、創建訂單Long userId = ThreadLocalUtls.getUser().getId();synchronized (userId.toString().intern()) {// 創建代理對象,使用代理對象調用第三方事務方法, 防止事務失效IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();return proxy.createVoucherOrder(userId, voucherId);}}/*** 創建訂單** @param userId* @param voucherId* @return*/@Transactionalpublic Result createVoucherOrder(Long userId, Long voucherId) {
// synchronized (userId.toString().intern()) {// 1、判斷當前用戶是否是第一單int count = this.count(new LambdaQueryWrapper<VoucherOrder>().eq(VoucherOrder::getUserId, userId));if (count >= 1) {// 當前用戶不是第一單return Result.fail("用戶已購買");}// 2、用戶是第一單,可以下單,秒殺券庫存數量減一boolean flag = seckillVoucherService.update(new LambdaUpdateWrapper<SeckillVoucher>().eq(SeckillVoucher::getVoucherId, voucherId).gt(SeckillVoucher::getStock, 0).setSql("stock = stock -1"));if (!flag) {throw new RuntimeException("秒殺券扣減失敗");}// 3、創建對應的訂單,并保存到數據庫VoucherOrder voucherOrder = new VoucherOrder();long orderId = redisIdWorker.nextId(SECKILL_VOUCHER_ORDER);voucherOrder.setId(orderId);voucherOrder.setUserId(ThreadLocalUtls.getUser().getId());voucherOrder.setVoucherId(voucherOrder.getId());flag = this.save(voucherOrder);if (!flag) {throw new RuntimeException("創建秒殺券訂單失敗");}// 4、返回訂單idreturn Result.ok(orderId);}}
這里有很多值得注意的小問題
- 鎖的范圍盡量小。synchronized盡量鎖代碼塊,而不是方法,鎖的范圍越大性能越低
- 鎖的對象一定要是一個不變的值。我們不能直接鎖 Long 類型的 userId,每請求一次都會創建一個新的 userId 對象,synchronized 要鎖不變的值,所以我們要將 Long 類型的 userId 通過 toString()方法轉成 String 類型的 userId,toString()方法底層(可以點擊去看源碼)是直接 new 一個新的String對象,顯然還是在變,所以我們要使用 intern() 方法從常量池中尋找與當前 字符串值一致的字符串對象,這就能夠保障一個用戶 發送多次請求,每次請求的 userId 都是不變的,從而能夠完成鎖的效果(并行變串行)
- 我們要鎖住整個事務,而不是鎖住事務內部的代碼。如果我們鎖住事務內部的代碼會導致其它線程能夠進入事務,當我們事務還未提交,鎖一旦釋放,仍然會存在超賣問題
- Spring的@Transactional注解要想事務生效,必須使用動態代理。Service中一個方法中調用另一個方法,另一個方法使用了事務,此時會導致@Transactional失效,所以我們需要創建一個代理對象,使用代理對象來調用方法。
讓代理對象生效的步驟:
①引入AOP依賴,動態代理是AOP的常見實現之一
<dependency><groupId>org.aspectj</groupId><artifactId>aspectjweaver</artifactId></dependency>
②暴露動態代理對象,默認是關閉的
@EnableAspectJAutoProxy(exposeProxy = true)
樂觀鎖解決
ALTER TABLE tb_voucher_order
ADD CONSTRAINT UNIQUE (user_id)
樂觀鎖解決方案其實更加優雅。我們可以通過在數據庫表中添加唯一約束來防止一人多單的問題。通過在user_id字段上添加唯一約束,當多個線程嘗試為同一用戶創建訂單時,數據庫會自動拒絕重復記錄,只有第一個提交的事務能夠成功。這種方式比使用悲觀鎖性能更好,因為它不需要額外的加鎖操作,而是利用了數據庫自身的特性來保證數據一致性。
要記得捕獲異常返回前端
try {save(order); // 可能會拋出 DuplicateKeyException} catch (DuplicateKeyException e) {return Result.fail("你已經搶過了");}
這樣即可