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 三個對象,和我們這個業務場景對標一下,如下

py5DyO

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 ==&& 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 ==&& 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