【180414】分布式鎖(redis/mysql)

單臺(tái)機(jī)器所能承載的量是有限的,用戶的量級(jí)上萬(wàn),基本上服務(wù)都會(huì)做分布式集群部署。很多時(shí)候,會(huì)遇到對(duì)同一資源的方法。這時(shí)候就需要鎖,如果是單機(jī)版的,可以利用java等語(yǔ)言自帶的并發(fā)同步處理。如果是多臺(tái)機(jī)器部署就得要有個(gè)中間代理人來(lái)做分布式鎖了。

常用的分布式鎖的實(shí)現(xiàn)有三種方式。

  • 基于redis實(shí)現(xiàn)(利用redis的原子性操作setnx來(lái)實(shí)現(xiàn))
  • 基于mysql實(shí)現(xiàn)(利用mysql的innodb的行鎖來(lái)實(shí)現(xiàn),有兩種方式, 悲觀鎖與樂(lè)觀鎖)
  • 基于Zookeeper實(shí)現(xiàn)(利用zk的臨時(shí)順序節(jié)點(diǎn)來(lái)實(shí)現(xiàn))

目前,我已經(jīng)是用了redis和mysql實(shí)現(xiàn)了鎖,并且根據(jù)應(yīng)用場(chǎng)景應(yīng)用在不同的線上環(huán)境中。zk實(shí)現(xiàn)比較復(fù)雜,又無(wú)應(yīng)用場(chǎng)景,有興趣的可以參考他山之石中的《Zookeeper實(shí)現(xiàn)分布式鎖》。

說(shuō)說(shuō)心得和體會(huì)。

沒(méi)有什么完美的技術(shù)、沒(méi)有萬(wàn)能鑰匙、不同方式不同應(yīng)用場(chǎng)景
CAP原理:一致性(consistency)、可用性(availability)、分區(qū)可容忍性(partition-tolerance)三者取其二。

他山之石

基于redis緩存實(shí)現(xiàn)分布式鎖

基于redis的鎖實(shí)現(xiàn)比較簡(jiǎn)單,由于redis的執(zhí)行是單線程執(zhí)行,天然的具備原子性操作,我們可以利用命令setnx和expire來(lái)實(shí)現(xiàn),java版代碼參考如下:

/**
 * User: Rudy Tan
 * Date: 2017/11/20
 *
 * redis 相關(guān)操作
 */
public class RedisUtil {

    /**
     * 獲取分布式鎖
     *
     * @param key        string 緩存key
     * @param expireTime int 過(guò)期時(shí)間,單位秒
     * @return boolean true-搶到鎖,false-沒(méi)有搶到鎖
     */
    public static boolean getDistributedLockSetTime(String key, Integer expireTime) {
        try {
            // 移除已經(jīng)失效的鎖
            String temp = JedisProxy.getMasterInstance().get(key);
            Long currentTime = (new Date()).getTime();
            if (null != temp && Long.valueOf(temp) < currentTime) {
                JedisProxy.getMasterInstance().del(key);
            }

            // 鎖競(jìng)爭(zhēng)
            Long nextTime = currentTime + Long.valueOf(expireTime) * 1000;
            Long result = JedisProxy.getMasterInstance().setnx(key, String.valueOf(nextTime));
            if (result == 1) {
                JedisProxy.getMasterInstance().expire(key, expireTime);
                return true;
            }
        } catch (Exception ignored) {
        }
        return false;
    }
}

包名和獲取redis操作對(duì)象換成自己的就好了。

基本步驟是

  1. 每次進(jìn)來(lái)先檢測(cè)一下這個(gè)key是否實(shí)現(xiàn)。如果失效了移除失效鎖
  2. 使用setnx原子命令爭(zhēng)搶鎖。
  3. 搶到鎖的設(shè)置過(guò)期時(shí)間。

步驟2為最核心的東西,
為啥設(shè)置步驟3?可能應(yīng)為獲取到鎖的線程出現(xiàn)什么移除請(qǐng)求,而無(wú)法釋放鎖,因此設(shè)置一個(gè)最長(zhǎng)鎖時(shí)間,避免死鎖。
為啥設(shè)置步驟1?redis可能在設(shè)置expire的時(shí)候掛掉。設(shè)置過(guò)期時(shí)間不成功,而出現(xiàn)鎖永久生效。

線上環(huán)境,步驟1、3的問(wèn)題都出現(xiàn)過(guò)。所以要做保底攔截。

redis集群部署

redis集群部署.png

通常redis都是以master-slave解決單點(diǎn)問(wèn)題,多個(gè)master-slave組成大集群,然后通過(guò)一致性哈希算法將不同的key路由到不同master-slave節(jié)點(diǎn)上。

redis鎖的優(yōu)缺點(diǎn):

優(yōu)點(diǎn):redis本身是內(nèi)存操作、并且通常是多片部署,因此有這較高的并發(fā)控制,可以抗住大量的請(qǐng)求。
缺點(diǎn):redis本身是緩存,有一定概率出現(xiàn)數(shù)據(jù)不一致請(qǐng)求。

在線上,之前,利用redis做庫(kù)存計(jì)數(shù)器,獎(jiǎng)品發(fā)放理論上只發(fā)放10個(gè)的,最后發(fā)放了14個(gè)。出現(xiàn)了數(shù)據(jù)的一致性問(wèn)題。

因此在這之后,引入了mysql數(shù)據(jù)庫(kù)分布式鎖。

基于mysql實(shí)現(xiàn)的分布式鎖。

實(shí)現(xiàn)第一版

在此之前,在網(wǎng)上搜索了大量的文章,基本上都是 插入、刪除發(fā)的方式或是直接通過(guò)"select for update"這種形式獲取鎖、計(jì)數(shù)器。具體可以參考他山之石中的《分布式鎖的幾種實(shí)現(xiàn)方式~》關(guān)于數(shù)據(jù)庫(kù)鎖章節(jié)。

一開(kāi)始,我的實(shí)現(xiàn)方式偽代碼如下:

public boolean getLock(String key){
     select for update
     if (記錄存在){
           update
     }else {
           insert 
   }
}

這樣實(shí)現(xiàn)出現(xiàn)了很嚴(yán)重的死鎖問(wèn)題,具體原因可以可以參考他山之石中的《select for update引發(fā)死鎖分析》
這個(gè)版本中存在如下幾個(gè)比較嚴(yán)重的問(wèn)題:

1.通常線上數(shù)據(jù)是不允許做物理刪除的
2.通過(guò)唯一鍵重復(fù)報(bào)錯(cuò),處理錯(cuò)誤形式是不太合理的。
3.如果appclient在處理中還沒(méi)釋放鎖之前就掛掉了,會(huì)出現(xiàn)鎖一直存在,出現(xiàn)死鎖。
4.如果以這種方式,實(shí)現(xiàn)redis中的計(jì)數(shù)器(incr decr),當(dāng)記錄不存在的時(shí)候,會(huì)出現(xiàn)大量死鎖的情況。

因此考慮引入,記錄狀態(tài)字段、中央鎖概念。

實(shí)現(xiàn)第二版

在第二版中完善了數(shù)據(jù)庫(kù)表設(shè)計(jì),參考如下:

-- 鎖表,單庫(kù)單表
CREATE TABLE IF NOT EXISTS test_db.t_lock (

    -- 記錄index
    Findex INT NOT NULL AUTO_INCREMENT COMMENT '自增索引id',

    -- 鎖信息(key、計(jì)數(shù)器、過(guò)期時(shí)間、記錄描述)
    Flock_name VARCHAR(128) DEFAULT '' NOT NULL COMMENT '鎖名key值',
    Fcount INT NOT NULL DEFAULT 0 COMMENT '計(jì)數(shù)器',
    Fdeadline DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '鎖過(guò)期時(shí)間',
    Fdesc VARCHAR(255) DEFAULT '' NOT NULL COMMENT '值/描述',
    
    -- 記錄狀態(tài)及相關(guān)事件
    Fcreate_time DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '創(chuàng)建時(shí)間',
    Fmodify_time DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '修改時(shí)間',
    Fstatus TINYINT NOT NULL DEFAULT 1 COMMENT '記錄狀態(tài),0:無(wú)效,1:有效',

    -- 主鍵(PS:總索引數(shù)不能超過(guò)5)
    PRIMARY KEY (Findex),
    -- 唯一約束
    UNIQUE KEY uniq_Flock_name(Flock_name),
    -- 普通索引
    KEY idx_Fmodify_time(Fmodify_time)

)ENGINE=INNODB DEFAULT CHARSET=UTF8 COMMENT '鎖與計(jì)數(shù)器表';

在這個(gè)版本中,考慮到再條鎖并發(fā)插入存在死鎖(間隙鎖爭(zhēng)搶)情況,引入中央鎖概念。

基本方式是:

  1. 根據(jù)sql創(chuàng)建好數(shù)據(jù)庫(kù)
  2. 創(chuàng)建一條記錄Flock_name="center_lock"的記錄。
  3. 在對(duì)其他鎖(如Flock_name="sale_invite_lock")進(jìn)行操作的時(shí)候,先對(duì)"center_lock"記錄select for update
  4. "sale_invite_lock"記錄自己的增刪改查。

考慮到不同公司引入的數(shù)據(jù)庫(kù)操作包不同,因此提供偽代碼,以便于理解
偽代碼

// 開(kāi)啟事務(wù)
@Transactional
public boolean getLock(String key){
      // 獲取中央鎖
      select * from tbl where Flock_name="center_lock"    
    
     // 查詢key相關(guān)記錄
     select for update
     if (記錄存在){
           update
     }else {
           insert 
   }
}

到此,該方案,能夠滿足我的分布式鎖的需求。

但是該方案,有一個(gè)比較致命的問(wèn)題,就是所有記錄共享一個(gè)鎖,并發(fā)并不高。

經(jīng)過(guò)測(cè)試,開(kāi)啟50*100個(gè)線程并發(fā)修改,5次耗時(shí)平均為8秒。

實(shí)現(xiàn)第三版

由于方案二,存在共享同一把中央鎖,并發(fā)不高的請(qǐng)求。參考concurrentHashMap實(shí)現(xiàn)原理,引入分段鎖概念,降低鎖粒度。


concurrentHashMap分段鎖概念

基本方式是:

  1. 根據(jù)sql創(chuàng)建好數(shù)據(jù)庫(kù)
  2. 創(chuàng)建100條記錄Flock_name="center_lock_xx"的記錄(xx為00-99)。
  3. 在對(duì)其他鎖(如Flock_name="sale_invite_lock")進(jìn)行操作的時(shí)候,根據(jù)crc32算法找到對(duì)應(yīng)的center_lock_02,先對(duì)"center_lock_02"記錄select for update
  4. "sale_invite_lock"記錄自己的增刪改查。

偽代碼如下:

// 開(kāi)啟事務(wù)
@Transactional
public boolean getLock(String key){
     // 根據(jù)key計(jì)算哈希值
      centerKey = "center_lock_xx";
      // 獲取中央鎖
      select * from tbl where Flock_name="center_lock_xx"    
    
     // 查詢key相關(guān)記錄
     select for update
     if (記錄存在){
           update
     }else {
           insert 
   }
}

經(jīng)過(guò)測(cè)試,開(kāi)啟50*100個(gè)線程并發(fā)修改,5次耗時(shí)平均為5秒。相較于版本二幾乎有一倍的提升。

至此,完成redis/mysql分布式鎖、計(jì)數(shù)器的實(shí)現(xiàn)與應(yīng)用。

最后

根據(jù)不同應(yīng)用場(chǎng)景,做出如下選擇:

  1. 高并發(fā)、不保證數(shù)據(jù)一致性:redis鎖/計(jì)數(shù)器
  2. 低并發(fā)、保證數(shù)據(jù)一致性:mysql鎖/計(jì)數(shù)器
  3. 低并發(fā)、不保證數(shù)據(jù)一致性:你隨意
  4. 高并發(fā)。保證數(shù)據(jù)一致性:redis鎖/計(jì)數(shù)器 + mysql鎖/計(jì)數(shù)器。
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容