mysql:悲觀鎖與樂觀鎖

爲什麼會使用到數據庫級別的鎖?

你可能會有這麼一個疑問:現在的程序已經提供了很完善的鎖機制來保證多線程的安全問題,還需要用到數據庫級別的鎖嗎?我覺得還是需要的,爲什麼呢?理由很簡單,我們再編程中使用的大部分鎖都是單機,尤其是現在分佈式集羣的流行,這種單機的鎖機制就保證不了線程安全了,這個時候,你可能又會想到使用redis的setNX分佈式鎖或者zookeeper的強一致性來保證線程安全,但是這裏我們需要考慮到一個問題,那就是成本問題,有的時候使用redis分佈式鎖以及zookeeper會增加維護的成本,結合實際出發,再說沒有百分百安全的程序,所以再數據庫層加鎖,也能將安全再提升一級,所以還是有必要的。

什麼是悲觀鎖

悲觀鎖,正如其名,具有強烈的獨佔和排他特性。它指的是對數據被外界(包括本系統當前的其他事務,以及來自外部系統的事務處理)修改持保守態度,因此,在整個數據處理過程中,將數據處於鎖定狀態。悲觀鎖的實現,往往依靠數據庫提供的鎖機制(也只有數據庫層提供的鎖機制才能真正保證數據訪問的排他性,否則,即使在本系統中實現了加鎖機制,也無法保證外部系統不會修改數據)。

通俗的講:開啓一個事務之後開啓悲觀鎖,這時候數據庫將會鎖着你需要查詢的某條數據或者某張表,其他事務中的查詢將會處於阻塞狀態,開啓悲觀鎖事務裏面操作不會被阻塞,這點有點類似java中的互斥鎖(其中的可重入鎖),那什麼時候鎖記錄?什麼時候鎖整張表呢?接着往下看。

mysql悲觀鎖如何使用?

1.在查詢後加:for update

2.需要先開啓事務,否者悲觀鎖無效

3.執行完查詢之後一定要接上update語句,否者其他事物會一直處於阻塞狀態,直到第一個事務拋出異常爲止。

我們看一個例子,假如用戶現在有100塊錢,買充電器需要100,買耳機也需要100,這時候用戶同時買下這兩款商品,會發生什麼事情呢?

我們分別說一下正常情況和加了悲觀鎖的情況,這裏暫時不討論程序鎖的問題,如果想了解程序中的鎖,請參考:java併發編程之synchronized、java併發編程之ReentrantReadWriteLock讀寫鎖等等。

我在數據庫新建了一張表:

 表比較簡單,我們只需要關注用戶id和用戶餘額,我們等會會用到,我們現在就來模擬一下同時扣款100元,會發生什麼情況,直接上代碼

單元測試代碼:

 @Resource
    private IUserWalletService userWalletService;

@Test
    void deductMoney() throws InterruptedException {
        //需要扣除的金額
        BigDecimal meney = BigDecimal.valueOf(100l);

        //新建第一個線程t1
        Thread t1 = new Thread(() ->{
            //線程1:讓用戶1扣除100元
            userWalletService.deductMoney(1, meney);
        });
        //新建第一個線程t2
        Thread t2 = new Thread(() ->{
            //線程2:讓用戶1扣除100元
            userWalletService.deductMoney(1, meney);
        });
        //啓動線程1
        t1.start();
        //啓動線程1
        t2.start();
        //讓線程同步
        t1.join();
        t2.join();
        System.out.println("執行完畢");

    }

service代碼:

private UserWalletMapper userWalletMapper;


    UserWalletServiceImpl(UserWalletMapper userWalletMapper){
        this.userWalletMapper = userWalletMapper;
    }

    @Override
    @Transactional
    public void deductMoney(int userId, BigDecimal money) {
        //獲取線程名
        String threadName = Thread.currentThread().getName();
        //查詢當前用戶錢包信息
        UserWallet userWallet = userWalletMapper.getWalletByUserId(userId);
        log.info("線程:{},用戶id:{},錢包餘額:{}",threadName,userId,userWallet.getBalance());
        //判斷當前用戶是否存在
        if(null == userWallet){
            log.info("線程:{},用戶id:{},不存在",threadName,userId);
            return ;
        }
        //判斷用戶的金額是否足夠扣除
        if(userWallet.getBalance().subtract(money).compareTo(BigDecimal.ZERO) < 0){
            log.info("線程:{},用戶id:{},餘額不足",threadName,userId);
            return ;
        }
        //修改餘額
        userWallet.setBalance(userWallet.getBalance().subtract(money));
        //扣錢,修改數據庫
        userWalletMapper.update(userWallet,new UpdateWrapper<UserWallet>().lambda().eq(UserWallet::getUId,userId));
        //獲取用戶扣款之後的餘額
        UserWallet wallet = userWalletMapper.selectOne(new QueryWrapper<UserWallet>().lambda().eq(UserWallet::getUId,userId));
        log.info("線程:{},用戶id:{},扣錢之後餘額:{}",threadName,userId,wallet.getBalance());
    }

mapper代碼:

/**
     * 通過用戶id查詢用戶錢包新消息
     * @param userId
     * @return
     */
    UserWallet getWalletByUserId(int userId);

與mapper對應的xml代碼:

<!-- 通用查詢映射結果 -->
    <resultMap id="BaseResultMap" type="com.aspect.entity.UserWallet">
        <id column="id" property="id" />
        <result column="balance" property="balance" />
        <result column="u_id" property="uId" />
        <result column="version" property="version" />
    </resultMap>

    <!-- 通用查詢結果列 -->
    <sql id="Base_Column_List">
        id, balance, u_id, version
    </sql>


    <select id="getWalletByUserId" resultType="com.aspect.entity.UserWallet">
        select <include refid="Base_Column_List" /> from user_wallet  where u_id =#{userId}

    </select>

實體類:

package com.aspect.entity;

import java.math.BigDecimal;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.baomidou.mybatisplus.annotation.TableId;

import java.io.Serializable;

import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;

/**
 * <p>
 *
 * </p>
 *
 * @author yaomaoyang
 * @since 2020-01-10
 */
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
public class UserWallet extends Model<UserWallet> {

    private static final long serialVersionUID = 1L;

    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    private BigDecimal balance;

    private Long uId;

    private Long version;


    @Override
    protected Serializable pkVal() {
        return this.id;
    }

}

userWalletMapper.update、userWalletMapper.selectOne屬於mybatis-plus語法。

好了,正常的寫法應該是這樣的吧,那到底會不會出現問題呢?我們先再數據庫給用戶id爲1的一個初始化數據:100元

好了,準備工作已經做完,我們運行deductMoney()

預期結果:第一次扣錢成功,第二次提示餘額不足

實際結果:

結果卻不是我們想要的,如果這要是出現在我們的生產環境,那是要背大鍋的,那如何解決呢?肯定有人想到了:互斥鎖,這個肯定能解決,那如果有兩個服務呢?其實也能解決,感興趣的可以自行研究,文章開頭已經說了解決方向,還有什麼方式能解決呢?我們的mysql悲觀鎖就應該要登場了。

使用悲觀鎖優化代碼:

之前的代碼不動,只需要修改一處代碼即可,那就是查詢用戶錢包信息的sql語句:

<select id="getWalletByUserId" resultType="com.aspect.entity.UserWallet">
        select <include refid="Base_Column_List" /> 
        
        from user_wallet  where u_id =#{userId} 

        for update

    </select>

我們將用戶的餘額重新修改爲100,然後再運行單元測試代碼:

 發現沒有,線程2與線程3居然串行化了,並且也變成了我們預期的結果,雖然悲觀鎖可以實現線程的安全,但是弊端也很明顯,那就是效率會很慢,有時候用的不好,會導致系統崩潰。

我們再來說說悲觀鎖鎖住的到底是什麼?

一共有兩種
1.鎖定指定行:查詢對象爲主鍵、字段有索引
2.鎖定整張表:其他

查詢對象爲主鍵這個我就不演示了,這裏我來展示一下另外一種情況,需求如下:
同時扣除用戶id:1與用戶id:2 的賬戶100元。

在數據庫新增一條用戶id等於2的數據,初始金額100

u_id字段我們暫時還沒有加索引,所以是一個普通字段,爲了讓你們看的清楚,我們再改造一下代碼:

單元測試代碼

 @Test
    void deductMoney2() throws InterruptedException {
        //需要扣除的金額
        BigDecimal meney = BigDecimal.valueOf(100l);

        //新建第一個線程t1
        Thread t1 = new Thread(() ->{
            //線程1:讓用戶1扣除100元
            userWalletService.deductMoney(1, meney);
        });
        //新建第一個線程t2
        Thread t2 = new Thread(() ->{
            //線程2:讓用戶1扣除100元
            userWalletService.deductMoney(2, meney);
        });
        //啓動線程1
        t1.start();
        //啓動線程1
        t2.start();
        //讓線程同步
        t1.join();
        t2.join();
        System.out.println("執行完畢");

    }

與第一個單元測試的差別僅僅只是修改了一個線程的用戶id,接着再service中讓程序休眠1秒,這樣我們可以更直觀的看結果

@Override
    @Transactional
    public void deductMoney(int userId, BigDecimal money) {
        //獲取線程名
        String threadName = Thread.currentThread().getName();
        //查詢當前用戶錢包信息
        UserWallet userWallet = userWalletMapper.getWalletByUserId(userId);
        log.info("線程:{},用戶id:{},錢包信息:{}", threadName, userId, userWallet);
         try {
            //休眠一秒
            log.info("線程:{},用戶id:{},休眠開始", threadName, userId);
            TimeUnit.SECONDS.sleep(1);
            log.info("線程:{},用戶id:{},休眠結束", threadName, userId);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //判斷當前用戶是否存在
        if (null == userWallet) {
            log.info("線程:{},用戶id:{},不存在", threadName, userId);
            return;
        }
        //判斷用戶的金額是否足夠扣除
        if (userWallet.getBalance().subtract(money).compareTo(BigDecimal.ZERO) < 0) {
            log.info("線程:{},用戶id:{},餘額不足", threadName, userId);
            return;
        }
        //修改餘額
        userWallet.setBalance(userWallet.getBalance().subtract(money));
        //扣錢,修改數據庫

       
        userWalletMapper.update(userWallet,new UpdateWrapper<UserWallet>().lambda().eq(UserWallet::getUId,userId));
        //獲取用戶扣款之後的餘額
        UserWallet wallet = userWalletMapper.selectOne(new QueryWrapper<UserWallet>().lambda().eq(UserWallet::getUId, userId));
        log.info("線程:{},用戶id:{},扣錢之後餘額:{}", threadName, userId, wallet.getBalance());
    }

運行單元測試

發現沒有,程序先扣除了用戶id等於2的金額,然後再扣除用戶id等於1的金額,兩個完全不相干的用戶居然阻塞了?這就是我們剛剛說的普通字段悲觀鎖 鎖定的整張表 ,我們希望的是同個用戶的操作互斥,不同用戶的操作並行,該如何實現呢?加入索引即可

我們再執行一次剛剛的單元測試:

此時,不同的用戶扣錢操作就變成了並行,提高了一點點效率,主鍵也能保證並行,這裏就不做演示了,你需要注意一點那就是mysql的悲觀鎖需要配合事務一起使用,否則無效

那在數據庫層面有沒有比讀寫鎖更快的一種鎖呢?答案肯定是有的,就是接下來需要說到的樂觀鎖。

樂觀鎖

樂觀鎖機制採取了更加寬鬆的加鎖機制。悲觀鎖大多數情況下依靠數據庫的鎖機制實現,以保證操作最大程度的獨佔性。但隨之而來的就是數據庫 性能的大量開銷,特別是對長事務而言,這樣的開銷往往無法承受。相對悲觀鎖而言,樂觀鎖更傾向於開發運用。

簡單來說,就是在修改時做一個版本判斷,符合要求則修改,否則不修改,修改的同時改變版本號。

這種方式嚴格來說不算鎖,java程序中也有這樣類似的操作,有一個專業術語:CAS(Compare and Swap),比如:Atomic的原子類操作都是無鎖,實現機制就和樂觀鎖很相像,比較和賦值,這裏就不多說了,感興趣的可以自行百度。

那應該如何改造之前的代碼?

第一步:刪除sql中的 for update 悲觀鎖

 <select id="getWalletByUserId" resultType="com.aspect.entity.UserWallet">
        select <include refid="Base_Column_List" />
        from user_wallet  where u_id =#{userId}
    </select>

第二步:修改更新數據庫金額的語句:

 @Override
    @Transactional
    public void deductMoney(int userId, BigDecimal money) {
        //獲取線程名
        String threadName = Thread.currentThread().getName();
        //查詢當前用戶錢包信息
        UserWallet userWallet = userWalletMapper.getWalletByUserId(userId);
        log.info("線程:{},用戶id:{},錢包餘額:{}", threadName, userId, userWallet.getBalance());
        //判斷當前用戶是否存在
        if (null == userWallet) {
            log.info("線程:{},用戶id:{},不存在", threadName, userId);
            return;
        }
        //判斷用戶的金額是否足夠扣除
        if (userWallet.getBalance().subtract(money).compareTo(BigDecimal.ZERO) < 0) {
            log.info("線程:{},用戶id:{},餘額不足", threadName, userId);
            return;
        }
        //修改餘額
        userWallet.setBalance(userWallet.getBalance().subtract(money));
        //扣錢,修改數據庫
        Integer update = userWalletMapper.updateByVersion(userWallet);
        if(update == 0){
            log.info("線程:{},用戶id:{},修改金額失敗", threadName, userId);
            return ;
        }
        
        //userWalletMapper.update(userWallet,new UpdateWrapper<UserWallet>().lambda().eq(UserWallet::getUId,userId));
        //獲取用戶扣款之後的餘額
        UserWallet wallet = userWalletMapper.selectOne(new QueryWrapper<UserWallet>().lambda().eq(UserWallet::getUId, userId));
        log.info("線程:{},用戶id:{},扣錢之後餘額:{}", threadName, userId, wallet.getBalance());
    }

第三步:修改mapper代碼:

/**
     * 扣錢
     * @param userWallet
     */
    Integer updateByVersion(UserWallet userWallet);

第四步:修改與mapper對應的xml:

<update id="updateByVersion">

        update user_wallet  balance = #{balance},version = version+1 where u_id = #{uId} and version = #{version}
    </update>

記住,修改的同時一定要改變version的值,我這裏做了+1處理(最好做累加處理,防止出現ABA問題)。

第四步:單元測試還是修改同一個用戶的金額,部分代碼如下:

//新建第一個線程t1
        Thread t1 = new Thread(() ->{
            //線程1:讓用戶1扣除100元
            userWalletService.deductMoney(1, meney);
        });
        //新建第一個線程t2
        Thread t2 = new Thread(() ->{
            //線程2:讓用戶1扣除100元
            userWalletService.deductMoney(1, meney);
        });

運行結果:

線程2扣錢成功,金額變成了0,線程3扣除失敗,符合預期要求。

總結

悲觀鎖強調互斥,與java的鎖很類似,樂觀鎖強調對比,與java的原子類操作很相似,悲觀鎖會降低系統的可用性(阻塞超時等等),樂觀鎖會降低系統的強一致性(很多無效請求),我們在選擇悲觀鎖與樂觀鎖的時候需要結合自己的實際項目,因爲他們都不是完美的,看系統願意捨棄哪一種。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章