爲什麼會使用到數據庫級別的鎖?
你可能會有這麼一個疑問:現在的程序已經提供了很完善的鎖機制來保證多線程的安全問題,還需要用到數據庫級別的鎖嗎?我覺得還是需要的,爲什麼呢?理由很簡單,我們再編程中使用的大部分鎖都是單機,尤其是現在分佈式集羣的流行,這種單機的鎖機制就保證不了線程安全了,這個時候,你可能又會想到使用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的原子類操作很相似,悲觀鎖會降低系統的可用性(阻塞超時等等),樂觀鎖會降低系統的強一致性(很多無效請求),我們在選擇悲觀鎖與樂觀鎖的時候需要結合自己的實際項目,因爲他們都不是完美的,看系統願意捨棄哪一種。