新公司網(wǎng)站建設(shè)百度平臺(tái)營銷收費(fèi)標(biāo)準(zhǔn)
一、MySQL 排它鎖和共享鎖
在進(jìn)行實(shí)驗(yàn)前,先來了解下MySQL
的排它鎖和共享鎖,在 MySQL
中的鎖分為表鎖和行鎖,在行鎖中鎖又分成了排它鎖和共享鎖兩種類型。
1. 排它鎖
排他鎖又稱為寫鎖,簡稱X
鎖,是一種悲觀鎖,具有悲觀鎖的特征,如一個(gè)事務(wù)獲取了一個(gè)數(shù)據(jù)行的X
鎖,其他事務(wù)嘗試獲取鎖時(shí)就會(huì)等待另一個(gè)事務(wù)的釋放。其中在 InnoDB
引擎下做寫操作時(shí) (UPDATE、DELETE、INSERT
)都會(huì)自動(dòng)給涉及到的數(shù)據(jù)加上 X
鎖,因此當(dāng)多線程情況下對(duì)同一條數(shù)據(jù)進(jìn)行更新,在MySQL
中不會(huì)出現(xiàn)線程安全問題。
其中 SELECT
語句默認(rèn)不會(huì)加鎖,如果查詢的數(shù)據(jù)已經(jīng)存在 X
鎖,則會(huì)返回其最近提交的數(shù)據(jù),如果希望每次獲取的數(shù)據(jù)都是更新后最新的數(shù)據(jù),當(dāng)存在有更新時(shí),則等待更新完成后獲取新的值,這種情況下就需要對(duì) SELECT
語句也要存在 X
鎖,其中 SELECT
語句加 X
鎖的話需要使用 FOR UPDATE
語句。
比如:當(dāng)前有一張表結(jié)構(gòu)如下:
CREATE TABLE `lock` (`id` int NOT NULL AUTO_INCREMENT,`name` varchar(255) DEFAULT NULL,PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
寫入一條測(cè)試數(shù)據(jù):
INSERT INTO `testdb`.`lock`(`id`, `name`) VALUES (1, 'lock1');
下面,我使用 Navicat
開啟了兩個(gè)對(duì)話框,我在第一個(gè)對(duì)話框中,使用手動(dòng)提交事務(wù)的方式執(zhí)行更新語句,并且既不提交也不回滾事務(wù):
BEGIN;
UPDATE `lock` SET `name` = 'lock2' WHERE id = 1;
下面在另一個(gè)對(duì)話框中,查詢 id = 1
的數(shù)據(jù):
SELECT * FROM `lock` where id = 1
可以看到,并沒有拿到最新的內(nèi)容,因?yàn)榇藭r(shí) X
鎖還沒有釋放,那此時(shí)對(duì)查詢語句進(jìn)行調(diào)整下,加上 FOR UPDATE
語句:
SELECT * FROM `lock` where id = 1 FOR UPDATE
此時(shí)會(huì)發(fā)現(xiàn),查詢語句一直在等待,因?yàn)檫@個(gè)查詢語句在等待 X
鎖的釋放,下面對(duì)第一個(gè)對(duì)話框中,執(zhí)行提交事務(wù):
COMMIT;
在回到第二個(gè)對(duì)話框中查看:
已經(jīng)拿到最新的值。這里需要注意下,你的是不是出現(xiàn)了超時(shí)報(bào)錯(cuò),這是因?yàn)?Innodb
引擎對(duì)等待鎖有個(gè)等待超時(shí)時(shí)間,默認(rèn)情況下是 50s
,可以通過下面指令查看:
SHOW VARIABLES LIKE "Innodb_lock_wait_timeout"
如果感覺太小,可以通過下面指令調(diào)整:
SET innodb_lock_wait_timeout = 100
上面的操作已經(jīng)感覺出來 X
鎖的效果,那當(dāng)兩個(gè) SELECT
語句都加上 FOR UPDATE
呢,比如在第一個(gè)回話框中,使用手動(dòng)事務(wù)執(zhí)行 SELECT
語句,同樣不提交事務(wù):
BEGIN;
SELECT * FROM `lock` where id = 1 FOR UPDATE;
在第二個(gè)對(duì)話框同樣執(zhí)行相同的代碼,可以發(fā)現(xiàn)被阻塞掉了。
當(dāng)?shù)谝粋€(gè)提交事務(wù)后,第二個(gè)緊接著也查出了信息,這也正符合排他鎖的特征。
2. 共享鎖
共享鎖可以理解為讀鎖,簡稱S
鎖,可以對(duì)多個(gè)事務(wù)SELECT
情況下讀取同一數(shù)據(jù)時(shí)不會(huì)阻塞,但是如果存在寫操作時(shí) (UPDATE、DELETE、INSERT
),SELECT
語句也會(huì)被阻塞,在MySQL
中使用 S
鎖需要使用 LOCK IN SHARE MODE
。
例如還是開啟兩個(gè)對(duì)話框,在第兩個(gè)對(duì)話框中,都查詢 id = 1
的數(shù)據(jù),并加上 S
鎖,最后同樣不提交事務(wù):
BEGIN;
SELECT * FROM `lock` where id = 1 LOCK IN SHARE MODE;
可以發(fā)現(xiàn)兩個(gè)都拿到了數(shù)據(jù),對(duì)兩個(gè)都提交事務(wù)后,假如第一個(gè)對(duì)話框中是更新操作,最后同樣不提交事務(wù):
BEGIN;
UPDATE `lock` SET `name` = 'lock3' WHERE id = 1 ;
在第二個(gè)對(duì)話框中還是加上 S
鎖的查詢操作:
BEGIN;
SELECT * FROM `lock` where id = 1 LOCK IN SHARE MODE;
可以看到查詢被阻塞了,當(dāng)?shù)谝粋€(gè)對(duì)話框中提交了事務(wù),這里才會(huì)返回結(jié)果:
讀到這里相信大家已經(jīng)對(duì) MySQL
的排它鎖和共享鎖有了一定的了解,下面我們基于 排它鎖
實(shí)現(xiàn)分布式鎖的場(chǎng)景。
二、基于 MySQL 排它鎖實(shí)現(xiàn)分布式可重入鎖
根據(jù)上面的實(shí)例可以看到排它鎖具有阻塞等待的效果,和我們 JVM
中普通的鎖的效果是一致的,但普通的鎖通常只能在單個(gè) JVM
中,但現(xiàn)在的服務(wù),動(dòng)則都要多臺(tái)集群部署,對(duì)于不同的 JVM
普通的鎖實(shí)在心有余而力不足,此時(shí)就要考慮使用分布式鎖,目前分布式鎖的解決方案也比較多,例如基于 Redis
的 setNx
實(shí)現(xiàn)的分布式鎖,相關(guān)框架有 Redissson
,還有基于 Zookeeper
的臨時(shí)節(jié)點(diǎn)實(shí)現(xiàn)的分布式鎖,相關(guān)框架有 Curator
等等,而且這些都有方案實(shí)現(xiàn)鎖的可重入性。
本文我們?cè)俳榻B一種基于 MySQL
的方案,畢竟現(xiàn)在再小的項(xiàng)目基本都會(huì)引入數(shù)據(jù)庫,我們?cè)诖嘶A(chǔ)上延伸也少了其他框架的學(xué)習(xí)。
實(shí)現(xiàn)的思路:
- 數(shù)據(jù)庫中創(chuàng)建一個(gè)
lock
表 ,里面根據(jù)場(chǎng)景添加數(shù)據(jù),一行就代表一個(gè)分布式鎖的句柄。 - 在項(xiàng)目中在需要鎖的方法中首先開啟事務(wù),保證下面的操作在事務(wù)中,事務(wù)可借助
Spring
的@Transactional
注解。 - 在獲取鎖時(shí),使用
SELECT * FROM lock WHERE id = #{id} FOR UPDATE
排它鎖語句執(zhí)行。 - 如果正常查詢到則獲取鎖成功,此時(shí)如果其他事務(wù)也在獲取鎖,則因?yàn)榕潘i的原因會(huì)阻塞等待。
- 此時(shí)如果還要獲取鎖,也就是對(duì)于鎖的可重入性設(shè)計(jì),可以利用同一個(gè)事務(wù)中對(duì)于同一條數(shù)據(jù)
FOR UPDATE
不會(huì)阻塞的特征,只需在同一個(gè)事務(wù)中再次獲取鎖的操作即可實(shí)現(xiàn) 。 - 方法執(zhí)行完,如果是手動(dòng)事務(wù)一定要提交或回滾事務(wù),即表示釋放鎖,如果是
Spring
的@Transactional
注解,則會(huì)自動(dòng)提交或回滾。
開始實(shí)施:
首先新建一個(gè) SpringBoot
項(xiàng)目,在 pom 中引入 mybatis-plus
依賴:
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>3.3.2</version>
</dependency><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId>
</dependency><dependency><groupId>com.alibaba</groupId><artifactId>druid</artifactId><version>1.1.6</version>
</dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId>
</dependency>
下面在配置文件中增加 MySQL
的配置:
server:port: 8081spring:datasource:url: jdbc:mysql://127.0.0.1:3306/testdb?useUnicode=true&characterEncoding=utf8&serverTimezone=UTCusername: rootpassword: roottype: com.alibaba.druid.pool.DruidDataSource
下面獲取鎖的邏輯其實(shí)就是一個(gè) Mapper
中的 Select
操作:
@Mapper
public interface LockMapper {/*** 嘗試獲取鎖*/@Select("SELECT id FROM `lock` where id = #{id} FOR UPDATE;")Long tryLock(@Param("id") Long id);
}
下面編寫一個(gè)線程安全的例子,使用 10
個(gè)線程,去對(duì)一個(gè)全局 int
變量做 +1
操作,這里為了方便測(cè)試,直接聲明成 Controller
@RestController
public class LockService {private volatile int count = 0;@GetMapping("/test")public void test() {for (int i = 0; i < 10; i++) {new Thread(() -> {testLock();}).start();}}public void testLock() {count++;System.out.print(count+" , ");}
}
運(yùn)行后,訪問測(cè)試接口,查看控制臺(tái)打印的效果:
可以看到已經(jīng)出現(xiàn)線程安全問題了,下面我們改造成使用 MySQL
的排他鎖進(jìn)行協(xié)調(diào),這里需要注意下,這里事務(wù)使用的是 Spring
的 @Transactional
注解,是基于 AOP
實(shí)現(xiàn)的,因此 LockService
需要從 Spring
容器中獲取 ,另外對(duì)于鎖的超時(shí)可以捕獲 CannotAcquireLockException
異常。
@RestController
public class LockService {@ResourceLockService lockService;@ResourceLockMapper lockMapper;private final Long LOCK_ID = 1L;private volatile int count = 0;@GetMapping("/test")public void test() {for (int i = 0; i < 10; i++) {new Thread(() -> {lockService.testLock();}).start();}}@Transactional(rollbackFor = Exception.class)public void testLock() {try {//獲取鎖,如果獲取不到則阻塞if (Objects.nonNull(lockMapper.tryLock(LOCK_ID))){count++;System.out.print(count + " , ");}} catch (CannotAcquireLockException e) {System.out.println("獲取鎖超時(shí)!");}}
}
執(zhí)行后,查看日志:
細(xì)心地話可以明顯感覺執(zhí)行速度比之前慢了,因?yàn)槌霈F(xiàn)了阻塞情況,通過數(shù)據(jù)可以看到已經(jīng)解決了線程安全問題,但是鎖的可重入性呢,我們?cè)讷@取到鎖后,再次獲取鎖看看是否正常,注意可重入鎖表示鎖中鎖,鎖的對(duì)象一定要是一致的,也就是這里的鎖的 ID
要是一致的:
@RestController
public class LockService {@ResourceLockService lockService;@ResourceLockMapper lockMapper;private final Long LOCK_ID = 1L;private volatile int count = 0;@GetMapping("/test")public void test() {for (int i = 0; i < 10; i++) {new Thread(() -> {lockService.testLock();}).start();}}@Transactional(rollbackFor = Exception.class)public void testLock() {try {//獲取鎖,如果獲取不到則阻塞if (Objects.nonNull(lockMapper.tryLock(LOCK_ID))){// 重入鎖if (Objects.nonNull(lockMapper.tryLock(LOCK_ID))){count++;System.out.print(count + " , ");}}} catch (CannotAcquireLockException e) {System.out.println("獲取鎖超時(shí)!");}}
}
運(yùn)行后,查看日志:
可以看到可重入鎖場(chǎng)景下也是可以正常獲取到鎖。
三、總結(jié)
本文基于 MySQL
實(shí)現(xiàn)的一種分布式可重入鎖的效果,由于鎖是使用的 MySQL
的排他鎖,因此在多個(gè) JVM
中也是可以實(shí)現(xiàn)鎖的效果。這里主要講解了實(shí)現(xiàn)思路,對(duì)于模塊的封裝沒有做過多的設(shè)計(jì),如果有想法的小伙伴也可以發(fā)動(dòng)想法封裝一下。另外由于是使用了 MySQL
如果是大量并發(fā)的情況下,可能會(huì)對(duì) MySQL
造成一些壓力。另外可能由于某些原因造成一端持有鎖的時(shí)間過長,其余等待鎖發(fā)生超時(shí)現(xiàn)象,超時(shí)情況這里未做處理,后續(xù)可以根據(jù)實(shí)際情況進(jìn)行重試或錯(cuò)誤處理。