MySQL 如何實現分佈式讀寫鎖?
1、先看個業務場景
對 X 資源,可以執行 2 種操作:W 操作、R 操作,2 種操作需要滿足下麪條件
(1)、執行操作的機器分佈式在不同的節點中,也就是分佈式的
(2)、W 操作是獨享的,也就是說同一時刻只允許有一個操作者對 X 執行 W 操作
(3)、R 操作是共享的,也就是說同時可以有多個執行者對 X 資源執行 R 操作
(4)、W 操作和 R 操作是互斥的,什麼意思呢?也就是說 W 操作和 R 操作不能同時存在
通俗點說:
如果當前 W 操作正在執行,此時有 R 操作請求過來,那麼這個 R 請求只能等待或者執行失敗
如果前有 R 操作正在執行,此時有 W 操作請求過來,那麼這個 W 請求只能等待或者執行失敗。
這種業務場景如果是單臺虛擬機,在 java 中可以使用 ReadWriteLock 讀寫鎖就可以實現了,但是今天我們要討論的是操作者不在同一個 jvm 中,而是分佈式在不同的節點,服務中。
大家可能在思考,哪裏有這樣的業務場景?
我之前做過 p2p,這裏給大家舉個 p2p 中的例子。
可能大家對 p2p 不瞭解,這裏先介紹一下 p2p 的業務。
比如小明需要 10 萬買車,但是手頭上沒錢,此時可以在 p2p 平臺上申請一個 10 萬的借款,然後 p2p 平臺會發佈一個借款項目,開始募集資金。
其他網民可以去投資這個項目,每個月借款人會進行還款,投資人會拿到收益。
當投資人每次投資的時候,會產生一份債權,可以把債權理解爲借款人欠你錢的一個憑證。
如果投資人急着用錢,但是此時投資還未到期,此時你可以發起債權轉讓,將你的債權賣給給其他人,這樣你就可以及時拿到本金了。
這裏面涉及到 2 個關鍵的業務:借款人執行還款、投資人發起債權轉讓。
借款人還款:借款人執行還款的時候,會將資金髮到投資人賬戶中,涉及到投資人賬戶資金的變動,還有債權信息的變化等,整個還款過程涉及到調用銀行系統,過程比較複雜,耗時相對比較長。
債權轉讓:投資人發起債權轉讓,也涉及到債權的編號和投資人賬戶的資金的變動。
由於這 2 個業務都會操作債權記錄和投資人賬戶資金,爲了保證資金的正確性,降低系統的複雜度,我們是這麼做的,讓這 2 種業務互斥
-
某筆借款執行還款的過程中,那麼這筆借款關聯的所有債權記錄不允許發起轉讓
-
如果某筆借款記錄當前沒有在還款處理中,那麼這筆借款記錄關聯的債權都可以同時發起債權轉讓
開頭提到的 X、W、R 三個對象,和我們這個業務場景對標一下,如下
2、解決問題的思路
mysql 大家都用過,mysql 中同時對一筆記錄發起 update 操作的時候,mysql 會確保整個操作會排隊執行,內部是互斥鎖實現的,從而可以確保在併發修改數據的時候,數據的正確性,執行 update 的時候,會返回被更新的行數,這裏我們就利用 mysql 這個特性來實現讀寫鎖的功能。
2.1、創建讀寫鎖表
在業務庫創建一個鎖表,如下:
create table t_read_write_lock(
resource_id varchar(50) primary key not null comment '互斥資源id',
w_count int not null default 0 comment '目前執行中的W操作數量' ,
r_count int not null default 0 comment '目前執行中的R操作數量',
version bigint not null default 0 comment '版本號,每次執行update的時候+1'
);
這裏主要關注 3 個字段:
1、resource_id:互斥資源 id,比如上面的借款記錄 id
2、w_count:當前執行 W 操作的數量
3、r_count:當前執行 R 操作的數量
下面來看 W 操作和 R 操作的實現。
2.2、W 操作過程
1、通過resource_id去t_read_write_lock查詢,如果不存在,則插入一條記錄,這裏由於resource_id是主鍵,所以對於同一個resource_id只會有一個插入成功,這裏用 $lock_record表示t_read_write_lock記錄
2、判斷lock_record.w_count ==0 && lock_record.r_count==0,如果爲true繼續向下,否則返回false,業務終止
3、獲取鎖,過程如下
{
3.1、開啓事務
3.2、int count = (update t_read_write_lock set w_count=1 where r_count = 0)
3.3、提交事務;
}
4、如果3.2的count==1,繼續向下執行,否則終止業務
5、執行業務操作
6、釋放鎖,過程如下
{
6.1、開啓事務
6.2、update t_read_write_lock set w_count=0 where w_count = 1
6.3、提交事務
}
整個過程有個問題,不知道大家發現沒有,如果執行到 5 之後,系統掛了,會出現什麼情況?
業務執行完畢了,但是 w 鎖卻沒有釋放,這種後果就是死鎖了,以後 r 操作就沒法執行了。
我們來看看,如何改進?
需要添加一下上鎖日誌表,每次上鎖成功,則記錄一條日誌,表結構如下
create table t_lock_log(
id bigint primary key auto_increment comment '主鍵,自動增長'
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 '狀態,0:獲取鎖成功,1:業務執行完畢,2:鎖被釋放',
create_time bigint default 0 comment '記錄創建時間',
version bigint not null default 0 comment '版本號,每次執行update的時候+1'
);
如何使用呢?
下面看 W 過程的改進
1、通過resource_id去t_read_write_lock查詢,如果不存在,則插入一條記錄,這裏由於resource_id是主鍵,所以對於同一個resource_id只會有一個插入成功,這裏用 $lock_record表示t_read_write_lock記錄
2、判斷lock_record.w_count ==0 && lock_record.r_count==0,如果爲true繼續向下,否則返回false,業務終止
3、獲取鎖,過程如下
{
3.1、開啓事務
3.2、int count = (update t_read_write_lock set w_count=1 where r_count = 0)
3.3、如果count==1,則插入一條上鎖日誌,鎖類型是0,狀態是0:insert t_lock_log (resource_id,lock_type,status,create_time) values (#{resource_id},0,0,'當前時間');
3.4、提交事務;
}
4、如果3.2的count==1,繼續向下執行,否則終止業務
5、執行業務操作,業務操作過程如下
{
5.1、業務庫開啓事務
5.2、執行業務
5.3、更新鎖日誌記錄的狀態爲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、提交事務
}else{
5.6、回滾事務【走到這裏說明更新鎖日誌記錄失敗了,說明t_lock_log的status被其他地方改掉了,被防止死鎖的job修改了】
}
}
6、釋放鎖,過程如下
{
6.1、開啓事務
6.2、釋放鎖:update t_read_write_lock set w_count=0 where w_count = 1 and resource_id = #{resource_id}
6.3、更新鎖日誌狀態爲2:update t_lock_log set status=2 where id = #{日誌記錄id}
6.4、提交事務
}
2.3、死鎖的處理
上面這個是正常流程,如果第 3 步執行完了,也就是上鎖 W 鎖成功,但是執行到第 6 步之前,系統掛了,此時 W 鎖沒有釋放,會出現死鎖。
此時我們需要一個 job,通過這個 job 來釋放長時間還未釋放的鎖,比如過了 10 分鐘,鎖還未被釋放的,job 的邏輯如下
1、獲取10分鐘之前鎖未釋放的鎖日誌列表:select * from t_lock_log where status in (0,1) and create_time+10分鐘<=當前時
間的;
2、輪詢獲取的日誌列表,釋放鎖,操作如下
{
2.1、開啓事務
2.2、if(t_lock_log.lock_type==0){
//lock_type爲0表示是W鎖,下面準備釋放W鎖
//先將日誌狀態更新爲2,注意條件中帶上version作爲條件,這裏使用到了樂觀鎖,可以確保併發修改時只有一個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{
//準備釋放R鎖
//先將日誌狀態置爲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、提交事務
}
2.4、R 鎖的過程
1、通過resource_id去t_read_write_lock查詢,如果不存在,則插入一條記錄,這裏由於resource_id是主鍵,所以對於同一個resource_id只會有一個插入成功,這裏用 $lock_record表示t_read_write_lock記錄
2、判斷lock_record.w_count ==0,如果爲true繼續向下,否則返回false,業務終止
3、獲取鎖,過程如下
{
3.1、開啓事務
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鎖】,狀態是0:insert t_lock_log (resource_id,lock_type,status,create_time) values (#{resource_id},1,0,'當前時間');
3.4、提交事務;
}
4、如果3.2的count==1,繼續向下執行,否則終止業務
5、執行業務操作,業務操作過程如下
{
5.1、業務庫開啓事務
5.2、執行業務
5.3、更新鎖日誌記錄的狀態爲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、提交事務
}else{
5.6、回滾事務【走到這裏說明更新鎖日誌記錄失敗了,說明t_lock_log的status被其他地方改掉了,被防止死鎖的job修改了】
}
}
6、釋放鎖,過程如下
{
6.1、開啓事務
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、更新鎖日誌狀態爲2:update t_lock_log set status=2 where id = #{日誌記錄id}
6.4、提交事務
}
3、總結
本文主要介紹瞭如何使用 mysql 來實現讀寫鎖,如何防止死鎖,重點就是 2 張表,鎖表和日誌表,2 個表配合一個 job,就把問題解決了。
大家可以將上面代碼轉換爲程序,結合 spring 的 aop 可以實現一個通用的 db 讀寫鎖,有興趣的可以試試
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/swpCwuYvN-8HnfVH7nsHbQ