面試官:MySQL如何實(shí)現(xiàn)分布式讀寫鎖?
1、先看個(gè)業(yè)務(wù)場景
對 X 資源,可以執(zhí)行 2 種操作:W 操作、R 操作,2 種操作需要滿足下面條件
(1)、執(zhí)行操作的機(jī)器分布式在不同的節(jié)點(diǎn)中,也就是分布式的
(2)、W 操作是獨(dú)享的,也就是說同一時(shí)刻只允許有一個(gè)操作者對 X 執(zhí)行 W 操作
(3)、R 操作是共享的,也就是說同時(shí)可以有多個(gè)執(zhí)行者對 X 資源執(zhí)行 R 操作
(4)、W 操作和 R 操作是互斥的,什么意思呢?也就是說 W 操作和 R 操作不能同時(shí)存在
通俗點(diǎn)說:
如果當(dāng)前 W 操作正在執(zhí)行,此時(shí)有 R 操作請求過來,那么這個(gè) R 請求只能等待或者執(zhí)行失敗
如果前有 R 操作正在執(zhí)行,此時(shí)有 W 操作請求過來,那么這個(gè) W 請求只能等待或者執(zhí)行失敗。
這種業(yè)務(wù)場景如果是單臺(tái)虛擬機(jī),在 java 中可以使用 ReadWriteLock 讀寫鎖就可以實(shí)現(xiàn)了,但是今天我們要討論的是操作者不在同一個(gè) jvm 中,而是分布式在不同的節(jié)點(diǎn),服務(wù)中。
大家可能在思考,哪里有這樣的業(yè)務(wù)場景?
我之前做過 p2p,這里給大家舉個(gè) p2p 中的例子。
可能大家對 p2p 不了解,這里先介紹一下 p2p 的業(yè)務(wù)。
比如小明需要 10 萬買車,但是手頭上沒錢,此時(shí)可以在 p2p 平臺(tái)上申請一個(gè) 10 萬的借款,然后 p2p 平臺(tái)會(huì)發(fā)布一個(gè)借款項(xiàng)目,開始募集資金。
其他網(wǎng)民可以去投資這個(gè)項(xiàng)目,每個(gè)月借款人會(huì)進(jìn)行還款,投資人會(huì)拿到收益。
當(dāng)投資人每次投資的時(shí)候,會(huì)產(chǎn)生一份債權(quán),可以把債權(quán)理解為借款人欠你錢的一個(gè)憑證。
如果投資人急著用錢,但是此時(shí)投資還未到期,此時(shí)你可以發(fā)起債權(quán)轉(zhuǎn)讓,將你的債權(quán)賣給給其他人,這樣你就可以及時(shí)拿到本金了。
這里面涉及到 2 個(gè)關(guān)鍵的業(yè)務(wù):借款人執(zhí)行還款、投資人發(fā)起債權(quán)轉(zhuǎn)讓。
借款人還款:借款人執(zhí)行還款的時(shí)候,會(huì)將資金發(fā)到投資人賬戶中,涉及到投資人賬戶資金的變動(dòng),還有債權(quán)信息的變化等,整個(gè)還款過程涉及到調(diào)用銀行系統(tǒng),過程比較復(fù)雜,耗時(shí)相對比較長。
債權(quán)轉(zhuǎn)讓:投資人發(fā)起債權(quán)轉(zhuǎn)讓,也涉及到債權(quán)的編號和投資人賬戶的資金的變動(dòng)。
由于這 2 個(gè)業(yè)務(wù)都會(huì)操作債權(quán)記錄和投資人賬戶資金,為了保證資金的正確性,降低系統(tǒng)的復(fù)雜度,我們是這么做的,讓這 2 種業(yè)務(wù)互斥
某筆借款執(zhí)行還款的過程中,那么這筆借款關(guān)聯(lián)的所有債權(quán)記錄不允許發(fā)起轉(zhuǎn)讓 如果某筆借款記錄當(dāng)前沒有在還款處理中,那么這筆借款記錄關(guān)聯(lián)的債權(quán)都可以同時(shí)發(fā)起債權(quán)轉(zhuǎn)讓
開頭提到的 X、W、R 三個(gè)對象,和我們這個(gè)業(yè)務(wù)場景對標(biāo)一下,如下
| X 表示資源 | W 操作 | R 操作 |
|---|---|---|
| 標(biāo)的 id | 還款操作 | 債權(quán)轉(zhuǎn)讓 |
2、解決問題的思路
mysql 大家都用過,mysql 中同時(shí)對一筆記錄發(fā)起 update 操作的時(shí)候,mysql 會(huì)確保整個(gè)操作會(huì)排隊(duì)執(zhí)行,內(nèi)部是互斥鎖實(shí)現(xiàn)的,從而可以確保在并發(fā)修改數(shù)據(jù)的時(shí)候,數(shù)據(jù)的正確性,執(zhí)行 update 的時(shí)候,會(huì)返回被更新的行數(shù),這里我們就利用 mysql 這個(gè)特性來實(shí)現(xiàn)讀寫鎖的功能。
2.1、創(chuàng)建讀寫鎖表
在業(yè)務(wù)庫創(chuàng)建一個(gè)鎖表,如下:
create table t_read_write_lock(
resource_id varchar(50) primary key not null comment '互斥資源id',
w_count int not null default 0 comment '目前執(zhí)行中的W操作數(shù)量' ,
r_count int not null default 0 comment '目前執(zhí)行中的R操作數(shù)量',
version bigint not null default 0 comment '版本號,每次執(zhí)行update的時(shí)候+1'
);
這里主要關(guān)注 3 個(gè)字段:
1、resource_id:互斥資源 id,比如上面的借款記錄 id
2、w_count:當(dāng)前執(zhí)行 W 操作的數(shù)量
3、r_count:當(dāng)前執(zhí)行 R 操作的數(shù)量
下面來看 W 操作和 R 操作的實(shí)現(xiàn)。
2.2、W 操作過程
1、通過resource_id去t_read_write_lock查詢,如果不存在,則插入一條記錄,這里由于resource_id是主鍵,所以對于同一個(gè)resource_id只會(huì)有一個(gè)插入成功,這里用 $lock_record表示t_read_write_lock記錄
2、判斷l(xiāng)ock_record.w_count ==0 && lock_record.r_count==0,如果為true繼續(xù)向下,否則返回false,業(yè)務(wù)終止
3、獲取鎖,過程如下
{
3.1、開啟事務(wù)
3.2、int count = (update t_read_write_lock set w_count=1 where r_count = 0)
3.3、提交事務(wù);
}
4、如果3.2的count==1,繼續(xù)向下執(zhí)行,否則終止業(yè)務(wù)
5、執(zhí)行業(yè)務(wù)操作
6、釋放鎖,過程如下
{
6.1、開啟事務(wù)
6.2、update t_read_write_lock set w_count=0 where w_count = 1
6.3、提交事務(wù)
}
整個(gè)過程有個(gè)問題,不知道大家發(fā)現(xiàn)沒有,如果執(zhí)行到 5 之后,系統(tǒng)掛了,會(huì)出現(xiàn)什么情況?
業(yè)務(wù)執(zhí)行完畢了,但是 w 鎖卻沒有釋放,這種后果就是死鎖了,以后 r 操作就沒法執(zhí)行了。
我們來看看,如何改進(jìn)?
需要添加一下上鎖日志表,每次上鎖成功,則記錄一條日志,表結(jié)構(gòu)如下
create table t_lock_log(
id bigint primary key auto_increment comment '主鍵,自動(dòng)增長'
resource_id varchar(50) primary key not null comment '互斥資源id',
lock_type smallint default 0 comment '鎖類型,0:W鎖,1:R鎖',
status smallint default 0 comment '狀態(tài),0:獲取鎖成功,1:業(yè)務(wù)執(zhí)行完畢,2:鎖被釋放',
create_time bigint default 0 comment '記錄創(chuàng)建時(shí)間',
version bigint not null default 0 comment '版本號,每次執(zhí)行update的時(shí)候+1'
);
如何使用呢?
下面看 W 過程的改進(jìn)
1、通過resource_id去t_read_write_lock查詢,如果不存在,則插入一條記錄,這里由于resource_id是主鍵,所以對于同一個(gè)resource_id只會(huì)有一個(gè)插入成功,這里用 $lock_record表示t_read_write_lock記錄
2、判斷l(xiāng)ock_record.w_count ==0 && lock_record.r_count==0,如果為true繼續(xù)向下,否則返回false,業(yè)務(wù)終止
3、獲取鎖,過程如下
{
3.1、開啟事務(wù)
3.2、int count = (update t_read_write_lock set w_count=1 where r_count = 0)
3.3、如果count==1,則插入一條上鎖日志,鎖類型是0,狀態(tài)是0:insert t_lock_log (resource_id,lock_type,status,create_time) values (#{resource_id},0,0,'當(dāng)前時(shí)間');
3.4、提交事務(wù);
}
4、如果3.2的count==1,繼續(xù)向下執(zhí)行,否則終止業(yè)務(wù)
5、執(zhí)行業(yè)務(wù)操作,業(yè)務(wù)操作過程如下
{
5.1、業(yè)務(wù)庫開啟事務(wù)
5.2、執(zhí)行業(yè)務(wù)
5.3、更新鎖日志記錄的狀態(tài)為1,條件中必須帶上status=0:int updateLogCount = (update t_lock_log set status=1 where id=#{日志記錄id} and status = 0)
5.4、if(updateLogCount==1){
5.5、提交事務(wù)
}else{
5.6、回滾事務(wù)【走到這里說明更新鎖日志記錄失敗了,說明t_lock_log的status被其他地方改掉了,被防止死鎖的job修改了】
}
}
6、釋放鎖,過程如下
{
6.1、開啟事務(wù)
6.2、釋放鎖:update t_read_write_lock set w_count=0 where w_count = 1 and resource_id = #{resource_id}
6.3、更新鎖日志狀態(tài)為2:update t_lock_log set status=2 where id = #{日志記錄id}
6.4、提交事務(wù)
}
2.3、死鎖的處理
上面這個(gè)是正常流程,如果第 3 步執(zhí)行完了,也就是上鎖 W 鎖成功,但是執(zhí)行到第 6 步之前,系統(tǒng)掛了,此時(shí) W 鎖沒有釋放,會(huì)出現(xiàn)死鎖。
此時(shí)我們需要一個(gè) job,通過這個(gè) job 來釋放長時(shí)間還未釋放的鎖,比如過了 10 分鐘,鎖還未被釋放的,job 的邏輯如下
1、獲取10分鐘之前鎖未釋放的鎖日志列表:select * from t_lock_log where status in (0,1) and create_time+10分鐘<=當(dāng)前時(shí)
間的;
2、輪詢獲取的日志列表,釋放鎖,操作如下
{
2.1、開啟事務(wù)
2.2、if(t_lock_log.lock_type==0){
//lock_type為0表示是W鎖,下面準(zhǔn)備釋放W鎖
//先將日志狀態(tài)更新為2,注意條件中帶上version作為條件,這里使用到了樂觀鎖,可以確保并發(fā)修改時(shí)只有一個(gè)count的值為1
int count = (update t_lock_log set status=2 where id = #{日志記錄id} and version = #{日志記錄.version})
if(count==1){
//將w_count置為0
update t_read_write_lock set w_count=0 where w_count = 1 and resource_id = #{resource_id}
}
}else{
//準(zhǔn)備釋放R鎖
//先將日志狀態(tài)置為2
int count = (update t_lock_log set status=2 where id = #{日志記錄id} and version = #{日志記錄.version})
if(count==1){
//將r_count置為r_count-1,注意條件中帶上r_count - 1>=0
update t_read_write_lock set r_count=r_count-1 where r_count - 1>=0 and resource_id = #{resource_id}
}
}
2.3、提交事務(wù)
}
2.4、R 鎖的過程
1、通過resource_id去t_read_write_lock查詢,如果不存在,則插入一條記錄,這里由于resource_id是主鍵,所以對于同一個(gè)resource_id只會(huì)有一個(gè)插入成功,這里用 $lock_record表示t_read_write_lock記錄
2、判斷l(xiāng)ock_record.w_count ==0,如果為true繼續(xù)向下,否則返回false,業(yè)務(wù)終止
3、獲取鎖,過程如下
{
3.1、開啟事務(wù)
3.2、int count = (update t_read_write_lock set r_count=r_count+1 where w_count = 0)
3.3、如果count==1,則插入一條上鎖日志,鎖類型是1【表示R鎖】,狀態(tài)是0:insert t_lock_log (resource_id,lock_type,status,create_time) values (#{resource_id},1,0,'當(dāng)前時(shí)間');
3.4、提交事務(wù);
}
4、如果3.2的count==1,繼續(xù)向下執(zhí)行,否則終止業(yè)務(wù)
5、執(zhí)行業(yè)務(wù)操作,業(yè)務(wù)操作過程如下
{
5.1、業(yè)務(wù)庫開啟事務(wù)
5.2、執(zhí)行業(yè)務(wù)
5.3、更新鎖日志記錄的狀態(tài)為1,條件中必須帶上status=0:int updateLogCount = (update t_lock_log set status=1 where id=#{日志記錄id} and status = 0)
5.4、if(updateLogCount==1){
5.5、提交事務(wù)
}else{
5.6、回滾事務(wù)【走到這里說明更新鎖日志記錄失敗了,說明t_lock_log的status被其他地方改掉了,被防止死鎖的job修改了】
}
}
6、釋放鎖,過程如下
{
6.1、開啟事務(wù)
6.2、釋放鎖:update t_read_write_lock set r_count=r_count-1 where r_count - 1 >= 0 and resource_id = #{resource_id}
6.3、更新鎖日志狀態(tài)為2:update t_lock_log set status=2 where id = #{日志記錄id}
6.4、提交事務(wù)
}
3、總結(jié)
本文主要介紹了如何使用 mysql 來實(shí)現(xiàn)讀寫鎖,如何防止死鎖,重點(diǎn)就是 2 張表,鎖表和日志表,2 個(gè)表配合一個(gè) job,就把問題解決了。
大家可以將上面代碼轉(zhuǎn)換為程序,結(jié)合 spring 的 aop 可以實(shí)現(xiàn)一個(gè)通用的 db 讀寫鎖,有興趣的可以試試,有問題歡加我微信 itsoku 交流。
4、Spring 系列
Spring 系列第 1 篇:為何要學(xué) spring? Spring 系列第 2 篇:控制反轉(zhuǎn)(IoC)與依賴注入(DI) Spring 系列第 3 篇:Spring 容器基本使用及原理 Spring 系列第 4 篇:xml 中 bean 定義詳解(-) Spring 系列第 5 篇:創(chuàng)建 bean 實(shí)例這些方式你們都知道? Spring 系列第 6 篇:玩轉(zhuǎn) bean scope,避免跳坑里! Spring 系列第 7 篇:依賴注入之手動(dòng)注入 Spring 系列第 8 篇:自動(dòng)注入(autowire)詳解,高手在于堅(jiān)持 Spring 系列第 9 篇:depend-on 到底是干什么的? Spring 系列第 10 篇:primary 可以解決什么問題? Spring 系列第 11 篇:bean 中的 autowire-candidate 又是干什么的? Spring 系列第 12 篇:lazy-init:bean 延遲初始化 Spring 系列第 13 篇:使用繼承簡化 bean 配置(abstract & parent) Spring 系列第 14 篇:lookup-method 和 replaced-method 比較陌生,怎么玩的? Spring 系列第 15 篇:代理詳解(Java 動(dòng)態(tài)代理&cglib 代理)? Spring 系列第 16 篇:深入理解 java 注解及 spring 對注解的增強(qiáng)(預(yù)備知識(shí)) Spring 系列第 17 篇:@Configration 和@Bean 注解詳解(bean 批量注冊) Spring 系列第 18 篇:@ComponentScan、@ComponentScans 詳解(bean 批量注冊) Spring 系列第 18 篇:@import 詳解(bean 批量注冊) Spring 系列第 20 篇:@Conditional 通過條件來控制 bean 的注冊 Spring 系列第 21 篇:注解實(shí)現(xiàn)依賴注入(@Autowired、@Resource、@Primary、@Qulifier) Spring 系列第 22 篇:@Scope、@DependsOn、@ImportResource、@Lazy 詳解 Spring 系列第 23 篇:Bean 生命周期詳解 Spring 系列第 24 篇:父子容器詳解 Spring 系列第 25 篇:@Value【用法、數(shù)據(jù)來源、動(dòng)態(tài)刷新】 Spring 系列第 26 篇:國際化詳解 Spring 系列第 27 篇:spring 事件機(jī)制詳解 Spring 系列第 28 篇:Bean 循環(huán)依賴詳解 Spring 系列第 29 篇:BeanFactory 擴(kuò)展(BeanFactoryPostProcessor、BeanDefinitionRegistryPostProcessor) Spring 系列第 30 篇:jdk 動(dòng)態(tài)代理和 cglib 代理 Spring 系列第 31 篇:aop 概念詳解 Spring 系列第 32 篇:AOP 核心源碼、原理詳解 Spring 系列第 33 篇:ProxyFactoryBean 創(chuàng)建 AOP 代理 Spring 系列第 34 篇:@Aspect 中@Pointcut 12 種用法 Spring 系列第 35 篇:@Aspect 中 5 中通知詳解 Spring 系列第 36 篇:@EnableAspectJAutoProxy、@Aspect 中通知順序詳解 Spring 系列第 37 篇:@EnableAsync & @Async 實(shí)現(xiàn)方法異步調(diào)用 Spring 系列第 38 篇:@Scheduled & @EnableScheduling 定時(shí)器詳解 Spring 系列第 39 篇:強(qiáng)大的 Spel 表達(dá)式 Spring 系列第 40 篇:緩存使用(@EnableCaching、@Cacheable、@CachePut、@CacheEvict、@Caching、@CacheConfig) Spring 系列第 41 篇:@EnableCaching 集成 redis 緩存 Spring 系列第 42 篇:玩轉(zhuǎn) JdbcTemplate Spring 系列第 43 篇:spring 中編程式事務(wù)怎么用的? Spring 系列第 44 篇:詳解 spring 聲明式事務(wù)(@Transactional) Spring 系列第 45 篇:帶你吃透 Spring 事務(wù) 7 種傳播行為 Spring 系列第 46 篇:Spring 如何管理多數(shù)據(jù)源事務(wù)? Spring 系列第 47 篇:spring 編程式事務(wù)源碼解析 Spring 系列第 48 篇:@Transaction 事務(wù)源碼解析 Spring 系列第 49 篇:通過 Spring 事務(wù)實(shí)現(xiàn) MQ 中的事務(wù)消息 Spring 系列第 50 篇:spring 事務(wù)攔截器順序如何控制? Spring 系列第 51 篇:導(dǎo)致 Spring 事務(wù)失效常見的幾種情況 Spring 系列第 52 篇:Spring 實(shí)現(xiàn)數(shù)據(jù)庫讀寫分離 Spring 系列第 53 篇:Spring 集成 MyBatis Spring 系列第 54 篇:集成 junit Spring 系列第 55 篇:spring 上下文生命周期 Spring 系列第 56 篇:面試官:循環(huán)依賴不用三級緩存可以么?
5、更多好文章
Java 高并發(fā)系列(共 34 篇) MySql 高手系列(共 27 篇) Maven 高手系列(共 10 篇) Mybatis 系列(共 12 篇) 聊聊 db 和緩存一致性常見的實(shí)現(xiàn)方式 接口冪等性這么重要,它是什么?怎么實(shí)現(xiàn)? 泛型,有點(diǎn)難度,會(huì)讓很多人懵逼,那是因?yàn)槟銢]有看這篇文章!
