1. 程式人生 > >萬字長文!不為人所知的分散式鎖實現全都在這裡了

萬字長文!不為人所知的分散式鎖實現全都在這裡了

1、引入業務場景

2、分散式鎖家族成員介紹

3、分散式鎖成員實現原理剖析

4、最後的總結

2019 已經過去!

2020 已經到站!


1、引入業務場景

首先來由一個場景引入:

最近老闆接了一個大單子,允許在某終端裝置安裝我們的APP,終端裝置廠商日活起碼得幾十萬到百萬級別,這個APP也是近期產品根據市場競品分析設計出來的,幾個小碼農通宵達旦開發出來的,主要功能是線上購物一站式服務,後臺可以給各個商家分配許可權,來維護需要售賣的商品資訊。

老闆大O:談下來不容易,接下來就是考慮如何吸引終端裝置上更多的使用者註冊上來,如何引導使用者購買,這塊就交給小P去負責了,需求儘快做,我明天出差!

產品小P:嘿嘿~,眼珠一轉兒,很容易就想到了,心裡想:“這還不簡單,起碼在首頁搞個活動頁... ”。

技術小T:很快了解了產品的需求,目前小J主要負責這塊,找了前端和後端同學一起將活動頁搞的快差不多了。

業務場景一出現:

因為小T剛接手專案,正在吭哧吭哧對熟悉著程式碼、部署架構。在看程式碼過程中發現,下單這塊程式碼可能會出現問題,這可是分散式部署的,如果多個使用者同時購買同一個商品,就可能導致商品出現 庫存超賣 (資料不一致) 現象,對於這種情況程式碼中並沒有做任何控制。

原來一問才知道,以前他們都是售賣的虛擬商品,沒啥庫存一說,所以當時沒有考慮那麼多...

這次不一樣啊,這次是售賣的實體商品,那就有庫存這麼一說了,起碼要保證不能超過庫存設定的數量吧。

小T大眼對著螢幕,屏住呼吸,還好提前發現了這個問題,趕緊想辦法修復,不賺錢還賠錢,老闆不得瘋了,還想不想幹了~

業務場景二出現:

小T下面的一位兄弟正在壓測,發現個小問題,因為在終端裝置上跟鵝廠有緊密合作,呼叫他們的介面時需要獲取到access_token,但是這個access_token過期時間是2小時,過期後需要重新獲取。

壓測時發現當到達過期時間時,日誌看刷出來好幾個不一樣的access_token,因為這個服務也是分散式部署的,多個節點同時發起了第三方介面請求導致。

雖然以最後一次獲取的access_token為準,也沒什麼不良副作用,但是會導致多次不必要的對第三方介面的呼叫,也會短時間內造成access_token的 重複無效獲取(重複工作)

業務場景三出現:

下單完成後,還要通知倉儲物流,待使用者支付完成,支付回撥有可能會將多條訂單訊息傳送到MQ,倉儲服務會從MQ消費訂單訊息,此時就要 保證冪等性

,對訂單訊息做 去重 處理。

以上便於大家理解為什麼要用分散式鎖才能解決,勾勒出的幾個業務場景。

上面的問題無一例外,都是針對共享資源要求序列化處理,才能保證安全且合理的操作。

用一張圖來體驗一下:

此時,使用Java提供的Synchronized、ReentrantLock、ReentrantReadWriteLock...,僅能在單個JVM程序內對多執行緒對共享資源保證執行緒安全,在分散式系統環境下統統都不好使,心情是不是拔涼呀。

這個問題得請教 分散式鎖 家族來支援一下,聽說他們家族內有很多成員,每個成員都有這個分散式鎖功能,接下來就開始探索一下。


2、分散式鎖家族成員介紹

為什麼需要分散式鎖才能解決?

聽聽 Martin 大佬們給出的說法:

Martin kleppmann 是英國劍橋大學的分散式系統的研究員,曾經跟 Redis 之父 Antirez 進行過關於 RedLock (Redis裡分散式鎖的實現演算法)是否安全的激烈討論。

他們討論了啥,整急眼了?
都能單獨寫篇文章了

請你自己看 Maritin 部落格文章:

https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html

效率:

使用分散式鎖可以避免多個客戶端重複相同的工作,這些工作會浪費資源。比如使用者支付完成後,可能會收到多次簡訊或郵件提醒。

比如業務場景二,重複獲取access_token。

對共享資源的操作是冪等性操作,無論你操作多少次都不會出現不同結果。
本質上就是為了避免對共享資源重複操作,從而提高效率。

正確性:

使用分散式鎖同樣可以避免鎖失效的發生,一旦發生會引起正確性的破壞,可能會導致資料不一致,資料缺失或者其他嚴重的問題。

比如業務場景一,商品庫存超賣問題。

對共享資源的操作是非冪等性操作,多個客戶端操作共享資源會導致資料不一致。

分散式鎖有哪些特點呢?

以下是分散式鎖的一些特點,分散式鎖家族成員並不一定都滿足這個要求,實現機制不大一樣。

互斥性: 分散式鎖要保證在多個客戶端之間的互斥。

可重入性:同一客戶端的相同執行緒,允許重複多次加鎖。

鎖超時:和本地鎖一樣支援鎖超時,防止死鎖。

非阻塞: 能與 ReentrantLock 一樣支援 trylock() 非阻塞方式獲得鎖。

支援公平鎖和非公平鎖:公平鎖是指按照請求加鎖的順序獲得鎖,非公平鎖真好相反請求加鎖是無序的。

分散式鎖家族實現者介紹

分散式鎖家族實現者一覽:

思維導圖做了一個簡單分類,不一定特別準確,幾乎包含了分散式鎖各個元件實現者。

下面讓他們分別來做下自我介紹:

1、資料庫

排它鎖(悲觀鎖):基於 select * from table where xx=yy for update SQL語句來實現,有很多缺陷,一般不推薦使用,後文介紹。

樂觀鎖:表中新增一個時間戳或者版本號的欄位來實現,update xx set version = new... where id = y and version = old 當更新不成功,客戶端重試,重新讀取最新的版本號或時間戳,再次嘗試更新,類似 CAS 機制,推薦使用。

2、Redis

特點:CAP模型屬於AP | 無一致性演算法 | 效能好

開發常用,如果你的專案中正好使用了redis,不想引入額外的分散式鎖元件,推薦使用。

業界也提供了多個現成好用的框架予以支援分散式鎖,比如Redisson、spring-integration-redis、redis自帶的setnx命令,推薦直接使用。

另外,可基於redis命令和redis lua支援的原子特性,自行實現分散式鎖。

3、Zookeeper

特點:CAP模型屬於CP | ZAB一致性演算法實現 | 穩定性好

開發常用,如果你的專案中正好使用了zk叢集,推薦使用。

業界有Apache Curator框架提供了現成的分散式鎖功能,現成的,推薦直接使用。

另外,可基於Zookeeper自身的特性和原生Zookeeper API自行實現分散式鎖。

4、其他

Chubby,Google開發的粗粒度分佈鎖的服務,但是並沒有開源,開放出了論文和一些相關文件可以進一步瞭解,出門百度一下獲取文件,不做過多討論。

Tair,是阿里開源的一個分散式KV儲存方案,沒有用過,不做過多討論。

Etcd,CAP模型中屬於CP,Raft一致性演算法實現,沒有用過,不做過多討論。

Hazelcast,是基於記憶體的資料網格開源專案,提供彈性可擴充套件的分散式記憶體計算,並且被公認是提高應用程式效能和擴充套件性最好的方案,聽上去很牛逼,但是沒用過,不做過多討論。

當然了,上面推薦的常用分散式鎖Zookeeper和Redis,使用時還需要根據具體的業務場景,做下權衡,實現功能上都能達到你要的效果,原理上有很大的不同。

畫外音: 你對哪個熟悉,原理也都瞭解,hold住,你就用哪個。

3、分散式鎖成員實現原理剖析

資料庫悲觀鎖實現

以「悲觀的心態」操作資源,無法獲得鎖成功,就一直阻塞著等待。

1、有一張資源鎖表

CREATE TABLE `resource_lock` (
  `id` int(4) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
  `resource_name` varchar(64) NOT NULL DEFAULT '' COMMENT '鎖定的資源名',
  `owner` varchar(64) NOT NULL DEFAULT '' COMMENT '鎖擁有者',
  `desc` varchar(1024) NOT NULL DEFAULT '備註資訊',
  `update_time` timestamp NOT NULL DEFAULT '' COMMENT '儲存資料時間,自動生成',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_resource_name` (`resource_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='鎖定中的資源';

resource_name 鎖資源名稱必須有唯一索引。

2、使用姿勢

必須新增事務,查詢和更新操作保證原子性,在一個事務裡完成。

虛擬碼實現:

@Transaction
public void lock(String name) {
   ResourceLock rlock = exeSql("select * from resource_lock where resource_name = name for update");
     if (rlock == null) {
           exeSql("insert into resource_lock(reosurce_name,owner,count) values (name, 'ip',0)");
     } 
}

使用 for update 鎖定的資源。
如果執行成功,會立即返回,執行插入資料庫,後續再執行一些其他業務邏輯,直到事務提交,執行結束;
如果執行失敗,就會一直阻塞著。

你也可以在資料庫客戶端工具上測試出來這個效果,當在一個終端執行了 for update,不提交事務。
在另外的終端上執行相同條件的 for update,會一直卡著,轉圈圈...

雖然也能實現分散式鎖的效果,但是會存在效能瓶頸。

3、悲觀鎖優缺點

優點:簡單易用,好理解,保障資料強一致性。

缺點一大堆,羅列一下:

1)在 RR 事務級別,select 的 for update 操作是基於間隙鎖(gap lock) 實現的,是一種悲觀鎖的實現方式,所以存在阻塞問題

2)高併發情況下,大量請求進來,會導致大部分請求進行排隊,影響資料庫穩定性,也會耗費服務的CPU等資源

當獲得鎖的客戶端等待時間過長時,會提示:

[40001][1205] Lock wait timeout exceeded; try restarting transaction

高併發情況下,也會造成佔用過多的應用執行緒,導致業務無法正常響應。

3)如果優先獲得鎖的執行緒因為某些原因,一直沒有釋放掉鎖,可能會導致死鎖的發生。

4)鎖的長時間不釋放,會一直佔用資料庫連線,可能會將資料庫連線池撐爆,影響其他服務。

5) MySql資料庫會做查詢優化,即便使用了索引,優化時發現全表掃效率更高,則可能會將行鎖升級為表鎖,此時可能就更悲劇了。

6)不支援可重入特性,並且超時等待時間是全域性的,不能隨便改動。

資料庫樂觀鎖實現

樂觀鎖,以「樂觀的心態」來操作共享資源,無法獲得鎖成功,沒關係過一會重試一下看看唄,再不行就直接退出,嘗試一定次數還是不行?也可以以後再說,不用一直阻塞等著。

1、有一張資源表

為表新增一個欄位,版本號或者時間戳都可以。通過版本號或者時間戳,來保證多執行緒同時間操作共享資源的有序性和正確性。

CREATE TABLE `resource` (
  `id` int(4) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
  `resource_name` varchar(64) NOT NULL DEFAULT '' COMMENT '資源名',
  `share` varchar(64) NOT NULL DEFAULT '' COMMENT '狀態',
    `version` int(4) NOT NULL DEFAULT '' COMMENT '版本號',
  `desc` varchar(1024) NOT NULL DEFAULT '備註資訊',
  `update_time` timestamp NOT NULL DEFAULT '' COMMENT '儲存資料時間,自動生成',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_resource_name` (`resource_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='資源';

2、使用姿勢

虛擬碼實現:

Resrouce resource = exeSql("select * from resource where resource_name = xxx");
boolean succ = exeSql("update resource set version= 'newVersion' ... where resource_name = xxx and version = 'oldVersion'");

if (!succ) {
    // 發起重試
}

實際程式碼中可以寫個while迴圈不斷重試,版本號不一致,更新失敗,重新獲取新的版本號,直到更新成功。

3、樂觀鎖優缺點

優點:簡單易用,保障資料一致性。

缺點:

1)加行鎖的效能上有一定的開銷

2)高併發場景下,執行緒內的自旋操作 會耗費一定的CPU資源。

另外,比如在更新資料狀態的一些場景下,不考慮冪等性的情況下,可以直接利用 行鎖 來保證資料一致性,示例:update table set state = 1 where id = xxx and state = 0;

樂觀鎖就類似 CAS Compare And Swap 更新機制,推薦閱讀 <<一文徹底搞懂CAS>>


基於Redis分散式鎖實現

基於SetNX實現分散式鎖

基於Redis實現的分散式鎖,效能上是最好的,實現上也是最複雜的。

前文中提到的 RedLock 是 Redis 之父 Antirez 提出來的分散式鎖的一種 「健壯」 的實現演算法,但爭議也較多,一般不推薦使用。

Redis 2.6.12 之前的版本中採用 setnx + expire 方式實現分散式鎖,示例程式碼如下所示:

public static boolean lock(Jedis jedis, String lockKey, String requestId, int expireTime) {
        Long result = jedis.setnx(lockKey, requestId);
        //設定鎖
        if (result == 1) {
            //獲取鎖成功
            //若在這裡程式突然崩潰,則無法設定過期時間,將發生死鎖
            //通過過期時間刪除鎖
            jedis.expire(lockKey, expireTime);
            return true;
        }
        return false;
    }

如果 lockKey 存在,則返回失敗,否則返回成功。設定成功之後,為了能在完成同步程式碼之後成功釋放鎖,方法中使用 expire() 方法給 lockKey 設定一個過期時間,確認 key 值刪除,避免出現鎖無法釋放,導致下一個執行緒無法獲取到鎖,即死鎖問題。

但是 setnx + expire 兩個命令放在程式裡執行,不是原子操作,容易出事。

如果程式設定鎖之後,此時,在設定過期時間之前,程式崩潰了,如果 lockKey 沒有設定上過期時間,將會出現死鎖問題

解決以上問題 ,有兩個辦法:

1)方式一:lua指令碼

我們也可以通過 Lua 指令碼來實現鎖的設定和過期時間的原子性,再通過 jedis.eval() 方法執行該指令碼:

// 加鎖指令碼,KEYS[1] 要加鎖的key,ARGV[1]是UUID隨機值,ARGV[2]是過期時間
private static final String SCRIPT_LOCK = "if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then redis.call('pexpire', KEYS[1], ARGV[2]) return 1 else return 0 end";

// 解鎖指令碼,KEYS[1]要解鎖的key,ARGV[1]是UUID隨機值
private static final String SCRIPT_UNLOCK = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";

2)方式二:set原生命令

在 Redis 2.6.12 版本後 SETNX 增加了過期時間引數:

SET lockKey anystring NX PX max-lock-time

程式實現程式碼如下:

public static boolean lock(Jedis jedis, String lockKey, String requestId, int expireTime) {
        String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
        if ("OK".equals(result)) {
            return true;
        }
        return false;
    }

雖然 SETNX 方式能夠保證設定鎖和過期時間的原子性,但是如果我們設定的過期時間比較短,而執行業務時間比較長,就會存在鎖程式碼塊失效的問題,失效後其他客戶端也能獲取到同樣的鎖,執行同樣的業務,此時可能就會出現一些問題。

我們需要將過期時間設定得足夠長,來保證以上問題不會出現,但是設定多長時間合理,也需要依具體業務來權衡。如果其他客戶端必須要阻塞拿到鎖,需要設計迴圈超時等待機制等問題,感覺還挺麻煩的是吧。

Spring企業整合模式實現分散式鎖

除了使用Jedis客戶端之外,完全可以直接用Spring官方提供的企業整合模式框架,裡面提供了很多分散式鎖的方式,Spring提供了一個統一的分散式鎖抽象,具體實現目前支援:

  • Gemfire
  • Jdbc
  • Zookeeper
  • Redis

早期,分散式鎖的相關程式碼存在於Spring Cloud的子專案Spring Cloud Cluster中,後來被遷到Spring Integration中。

Spring Integration 專案地址 :https://github.com/spring-projects/spring-integration

Spring強大之處在於此,對Lock分散式鎖做了全域性抽象。

抽象結構如下所示:

LockRegistry 作為頂層抽象介面:

/**
 * Strategy for maintaining a registry of shared locks
 *
 * @author Oleg Zhurakousky
 * @author Gary Russell
 * @since 2.1.1
 */
@FunctionalInterface
public interface LockRegistry {

    /**
     * Obtains the lock associated with the parameter object.
     * @param lockKey The object with which the lock is associated.
     * @return The associated lock.
     */
    Lock obtain(Object lockKey);

}

定義的 obtain() 方法獲得具體的 Lock 實現類,分別在對應的 XxxLockRegitry 實現類來建立。

RedisLockRegistry 裡obtain()方法實現類為 RedisLock,RedisLock內部,在Springboot2.x(Spring5)版本中是通過SET + PEXIPRE 命令結合lua指令碼實現的,在Springboot1.x(Spring4)版本中,是通過SETNX命令實現的。

ZookeeperLockRegistry 裡obtain()方法實現類為 ZkLock,ZkLock內部基於 Apache Curator 框架實現的。

JdbcLockRegistry 裡obtain()方法實現類為 JdbcLock,JdbcLock內部基於一張INT_LOCK資料庫鎖表實現的,通過JdbcTemplate來操作。

客戶端使用方法:

private final String registryKey = "sb2";
RedisLockRegistry lockRegistry = new RedisLockRegistry(getConnectionFactory(), this.registryKey);
Lock lock = lockRegistry.obtain("foo");
lock.lock();
try {
    // doSth...
}
finally {
    lock.unlock();
}
}

下面以目前最新版本的實現,說明加鎖和解鎖的具體過程。

RedisLockRegistry$RedisLock類lock()加鎖流程:

加鎖步驟:

1)lockKey為registryKey:path,本例中為sb2:foo,客戶端C1優先申請加鎖。

2)執行lua指令碼,get lockKey不存在,則set lockKey成功,值為clientid(UUID),過期時間預設60秒。

3)客戶端C1同一個執行緒重複加鎖,pexpire lockKey,重置過期時間為60秒。

4)客戶端C2申請加鎖,執行lua指令碼,get lockKey已存在,並且跟已加鎖的clientid不同,加鎖失敗

5)客戶端C2掛起,每隔100ms再次嘗試加鎖。

RedisLock#lock()加鎖原始碼實現:

大家可以對照上面的流程圖配合你理解。

@Override
public void lock() {
    this.localLock.lock();
    while (true) {
        try {
            while (!obtainLock()) {
                Thread.sleep(100); //NOSONAR
            }
            break;
        }
        catch (InterruptedException e) {
            /*
             * This method must be uninterruptible so catch and ignore
             * interrupts and only break out of the while loop when
             * we get the lock.
             */
        }
        catch (Exception e) {
            this.localLock.unlock();
            rethrowAsLockException(e);
        }
    }
}

// 基於Spring封裝的RedisTemplate來操作的
private boolean obtainLock() {
    Boolean success =
            RedisLockRegistry.this.redisTemplate.execute(RedisLockRegistry.this.obtainLockScript,
                    Collections.singletonList(this.lockKey), RedisLockRegistry.this.clientId,
                    String.valueOf(RedisLockRegistry.this.expireAfter));

    boolean result = Boolean.TRUE.equals(success);

    if (result) {
        this.lockedAt = System.currentTimeMillis();
    }
    return result;
}

執行的lua指令碼程式碼:

private static final String OBTAIN_LOCK_SCRIPT =
    "local lockClientId = redis.call('GET', KEYS[1])\n" +
            "if lockClientId == ARGV[1] then\n" +
            "  redis.call('PEXPIRE', KEYS[1], ARGV[2])\n" +
            "  return true\n" +
            "elseif not lockClientId then\n" +
            "  redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])\n" +
            "  return true\n" +
            "end\n" +
            "return false";

RedisLockRegistry$RedisLock類unlock()解鎖流程:

RedisLock#unlock()原始碼實現:

@Override
public void unlock() {
    if (!this.localLock.isHeldByCurrentThread()) {
        throw new IllegalStateException("You do not own lock at " + this.lockKey);
    }
    if (this.localLock.getHoldCount() > 1) {
        this.localLock.unlock();
        return;
    }
    try {
        if (!isAcquiredInThisProcess()) {
            throw new IllegalStateException("Lock was released in the store due to expiration. " +
                    "The integrity of data protected by this lock may have been compromised.");
        }

        if (Thread.currentThread().isInterrupted()) {
            RedisLockRegistry.this.executor.execute(this::removeLockKey);
        }
        else {
            removeLockKey();
        }

        if (LOGGER.isDebugEnabled()) {
            LOGGER.debug("Released lock; " + this);
        }
    }
    catch (Exception e) {
        ReflectionUtils.rethrowRuntimeException(e);
    }
    finally {
        this.localLock.unlock();
    }
}
        
// 刪除快取Key
private void removeLockKey() {
    if (this.unlinkAvailable) {
        try {
            RedisLockRegistry.this.redisTemplate.unlink(this.lockKey);
        }
        catch (Exception ex) {
            LOGGER.warn("The UNLINK command has failed (not supported on the Redis server?); " +
                    "falling back to the regular DELETE command", ex);
            this.unlinkAvailable = false;
            RedisLockRegistry.this.redisTemplate.delete(this.lockKey);
        }
    }
    else {
        RedisLockRegistry.this.redisTemplate.delete(this.lockKey);
    }
}

unlock()解鎖方法裡發現,並不是直接就呼叫Redis的DEL命令刪除Key,這也是在Springboot2.x版本中做的一個優化,Redis4.0版本以上提供了UNLINK命令。

換句話說,最新版本分散式鎖實現,要求是Redis4.0以上版本才能使用。

看下Redis官網給出的一段解釋:

This command is very similar to DEL: it removes the specified keys.
Just like DEL a key is ignored if it does not exist. However the
command performs the actual memory reclaiming in a different thread,
so it is not blocking, while DEL is. This is where the command name
comes from: the command just unlinks the keys from the keyspace. The
actual removal will happen later asynchronously.

DEL始終在阻止模式下釋放值部分。但如果該值太大,如對於大型LIST或HASH的分配太多,它會長時間阻止Redis,為了解決這個問題,Redis實現了UNLINK命令,即「非阻塞」刪除。如果值很小,則DEL一般與UNLINK效率上差不多。

本質上,這種加鎖方式還是使用的SETNX實現的,而且Spring只是做了一層薄薄的封裝,支援可重入加鎖,超時等待,可中斷加鎖。

但是有個問題,鎖的過期時間不能靈活設定,客戶端初始化時,建立RedisLockRegistry時允許設定,但是是全域性的。

/**
     * Constructs a lock registry with the supplied lock expiration.
     * @param connectionFactory The connection factory.
     * @param registryKey The key prefix for locks.
     * @param expireAfter The expiration in milliseconds.
     */
public RedisLockRegistry(RedisConnectionFactory connectionFactory, String registryKey, long expireAfter) {
    Assert.notNull(connectionFactory, "'connectionFactory' cannot be null");
    Assert.notNull(registryKey, "'registryKey' cannot be null");
    this.redisTemplate = new StringRedisTemplate(connectionFactory);
    this.obtainLockScript = new DefaultRedisScript<>(OBTAIN_LOCK_SCRIPT, Boolean.class);
    this.registryKey = registryKey;
    this.expireAfter = expireAfter;
}

expireAfter引數是全域性的,同樣會存在問題,可能是鎖過期時間到了,但是業務還沒有處理完,這把鎖又被另外的客戶端獲得,進而會導致一些其他問題。

經過對原始碼的分析,其實我們也可以借鑑RedisLockRegistry實現的基礎上,自行封裝實現分散式鎖,比如:

1、允許支援按照不同的Key設定過期時間,而不是全域性的?

2、當業務沒有處理完成,當前客戶端啟動個定時任務探測,自動延長過期時間?

自己實現?嫌麻煩?別急別急!業界已經有現成的實現方案了,那就是 Redisson 框架!

站在Redis叢集角度看問題

從Redis主從架構上來考慮,依然存在問題。因為 Redis 叢集資料同步到各個節點時是非同步的,如果在 Master 節點獲取到鎖後,在沒有同步到其它節點時,Master 節點崩潰了,此時新的 Master 節點依然可以獲取鎖,所以多個應用服務可以同時獲取到鎖。

基於以上的考慮,Redis之父Antirez提出了一個RedLock演算法

RedLock演算法實現過程分析:

假設Redis部署模式是Redis Cluster,總共有5個master節點,通過以下步驟獲取一把鎖:

1)獲取當前時間戳,單位是毫秒

2)輪流嘗試在每個master節點上建立鎖,過期時間設定較短,一般就幾十毫秒

3)嘗試在大多數節點上建立一個鎖,比如5個節點就要求是3個節點(n / 2 +1)

4)客戶端計算建立好鎖的時間,如果建立鎖的時間小於超時時間,就算建立成功了

5)要是鎖建立失敗了,那麼就依次刪除這個鎖

6)只要有客戶端建立成功了分散式鎖,其他客戶端就得不斷輪詢去嘗試獲取鎖

以上過程前文也提到了,進一步分析RedLock演算法的實現依然可能存在問題,也是Martain和Antirez兩位大佬爭論的焦點。

問題1:節點崩潰重啟

節點崩潰重啟,會出現多個客戶端持有鎖。

假設一共有5個Redis節點:A、B、 C、 D、 E。設想發生瞭如下的事件序列:

1)客戶端C1成功對Redis叢集中A、B、C三個節點加鎖成功(但D和E沒有鎖住)。

2)節點C Duang的一下,崩潰重啟了,但客戶端C1在節點C加鎖未持久化完,丟了。

3)節點C重啟後,客戶端C2成功對Redis叢集中C、D、 E嘗試加鎖成功了。

這樣,悲劇了吧!客戶端C1和C2同時獲得了同一把分散式鎖。

為了應對節點重啟引發的鎖失效問題,Antirez提出了延遲重啟的概念,即一個節點崩潰後,先不立即重啟它,而是等待一段時間再重啟,等待的時間大於鎖的有效時間。

採用這種方式,這個節點在重啟前所參與的鎖都會過期,它在重啟後就不會對現有的鎖造成影響。

這其實也是通過人為補償措施,降低不一致發生的概率。

問題2:時鐘跳躍

假設一共有5個Redis節點:A、B、 C、 D、 E。設想發生瞭如下的事件序列:

1)客戶端C1成功對Redis叢集中A、B、 C三個節點成功加鎖。但因網路問題,與D和E通訊失敗。

2)節點C上的時鐘發生了向前跳躍,導致它上面維護的鎖快速過期。

3)客戶端C2對Redis叢集中節點C、 D、 E成功加了同一把鎖。

此時,又悲劇了吧!客戶端C1和C2同時都持有著同一把分散式鎖。

為了應對時鐘跳躍引發的鎖失效問題,Antirez提出了應該禁止人為修改系統時間,使用一個不會進行「跳躍式」調整系統時鐘的ntpd程式。這也是通過人為補償措施,降低不一致發生的概率。

但是...,RedLock演算法並沒有解決,操作共享資源超時,導致鎖失效的問題。

存在這麼大爭議的演算法實現,還是不推薦使用的。

一般情況下,本文鎖介紹的框架提供的分散式鎖實現已經能滿足大部分需求了。

小結:

上述,我們對spring-integration-redis實現原理進行了深入分析,還對RedLock存在爭議的問題做了分析。

除此以外,我們還提到了spring-integration中集成了 Jdbc、Zookeeper、Gemfire實現的分散式鎖,Gemfire和Jdbc大家感興趣可以自行去看下。

為啥還要提供個Jdbc分散式鎖實現?

猜測一下,當你的應用併發量也不高,比如是個後臺業務,而且還沒依賴Zookeeper、Redis等額外的元件,只依賴了資料庫。

但你還想用分散式鎖搞點事兒,那好辦,直接用spring-integration-jdbc即可,內部也是基於資料庫行鎖來實現的,需要你提前建好鎖表,建立表的SQL長這樣:

CREATE TABLE INT_LOCK  (
    LOCK_KEY CHAR(36) NOT NULL,
    REGION VARCHAR(100) NOT NULL,
    CLIENT_ID CHAR(36),
    CREATED_DATE DATETIME(6) NOT NULL,
    constraint INT_LOCK_PK primary key (LOCK_KEY, REGION)
) ENGINE=InnoDB;

具體實現邏輯也非常簡單,大家自己去看吧。

整合的Zookeeper實現的分散式鎖,因為是基於Curator框架實現的,不在本節展開,後續會有分析。

基於Redisson實現分散式鎖

Redisson 是 Redis 的 Java 實現的客戶端,其 API 提供了比較全面的 Redis 命令的支援。

Jedis 簡單使用阻塞的 I/O 和 Redis 互動,Redission 通過 Netty 支援非阻塞 I/O。

Redisson 封裝了鎖的實現,讓我們像操作我們的本地 Lock 一樣去使用,除此之外還有對集合、物件、常用快取框架等做了友好的封裝,易於使用。

截止目前,Github上 Star 數量為 11.8k,說明該開源專案值得關注和使用。

Redisson分散式鎖Github:

https://github.com/redisson/redisson/wiki/8.-Distributed-locks-and-synchronizers

Redisson 可以便捷的支援多種Redis部署架構:

1) Redis 單機

2) Master-Slave + Sentinel 哨兵

3) Redis-Cluster叢集

// Master-Slave配置
Config config = new Config();
MasterSlaveServersConfig serverConfig = config.useMasterSlaveServers()
            .setMasterAddress("")
            .addSlaveAddress("")
            .setReadMode(ReadMode.SLAVE)
            .setMasterConnectionPoolSize(maxActiveSize)
            .setMasterConnectionMinimumIdleSize(maxIdleSize)
            .setSlaveConnectionPoolSize(maxActiveSize)
            .setSlaveConnectionMinimumIdleSize(maxIdleSize)
            .setConnectTimeout(CONNECTION_TIMEOUT_MS) // 預設10秒
            .setTimeout(socketTimeout)
            ;
            
RedissonClient redisson = Redisson.create(config);
RLock lock = redisson.getLock("myLock");

// 獲得鎖
lock.lock();

// 等待10秒未獲得鎖,自動釋放
lock.lock(10, TimeUnit.SECONDS);

// 等待鎖定時間不超過100秒
// 10秒後自動釋放鎖
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
if (res) {
   try {
     ...
   } finally {
       lock.unlock();
   }
}

使用上非常簡單,RedissonClient客戶端提供了眾多的介面實現,支援可重入鎖、、公平鎖、讀寫鎖、鎖超時、RedLock等都提供了完整實現。

lock()加鎖流程:

為了相容老的版本,Redisson裡都是通過lua指令碼執行Redis命令的,同時保證了原子性操作。

加鎖執行的lua指令碼:

Redis裡的Hash雜湊結構儲存的。

引數解釋:

KEY[1]:要加鎖的Key名稱,比如示例中的myLock。

ARGV[1]:針對加鎖的Key設定的過期時間

ARGV[2]:Hash結構中Key名稱,lockName為UUID:執行緒ID

protected String getLockName(long threadId) {
       return id + ":" + threadId;
}

1)客戶端C1申請加鎖,key為myLock。

2)如果key不存在,通過hset設定值,通過pexpire設定過期時間。同時開啟Watchdog任務,預設每隔10秒中判斷一下,如果key還在,重置過期時間到30秒。

開啟WatchDog原始碼:

3)客戶端C1相同執行緒再次加鎖,如果key存在,判斷Redis裡Hash中的lockName跟當前執行緒lockName相同,則將Hash中的lockName的值加1,代表支援可重入加鎖。

4)客戶單C2申請加鎖,如果key存在,判斷Redis裡Hash中的lockName跟當前執行緒lockName不同,則執行pttl返回剩餘過期時間。

5)客戶端C2執行緒內不斷嘗試pttl時間,此處是基於Semaphore訊號量實現的,有許可立即返回,否則等到pttl時間還是沒有得到許可,繼續重試。

重試原始碼:

Redisson這樣的實現就解決了,當業務處理時間比過期時間長的問題。

同時,Redisson 還自己擴充套件 Lock 介面,叫做 RLock 介面,擴充套件了很多的鎖介面,比如給 Key 設定過期時間,非阻塞+超時時間等。

void lock(long leaseTime, TimeUnit unit);

boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;

redisson裡的WatchDog(看門狗)邏輯保證了沒有死鎖發生。

如果客戶端宕機了,WatchDog任務也就跟著停掉了。此時,不會對Key重置過期時間了,等掛掉的客戶端持有的Key過期時間到了,鎖自動釋放,其他客戶端嘗試獲得這把鎖。

可以進一步看官網的關於WatchDog描述:

If Redisson instance which acquired lock crashes then such lock could hang forever in acquired state. To avoid this Redisson maintains lock watchdog, it prolongs lock expiration while lock holder Redisson instance is alive. By default lock watchdog timeout is 30 seconds and can be changed through Config.lockWatchdogTimeout setting.

unlock()解鎖過程也是同樣的,通過lua指令碼執行一大坨指令的。

解鎖lua指令碼:

根據剛剛對加鎖過程的分析,大家可以自行看下指令碼分析下。

基於Zookeeper實現分散式鎖

Zookeeper 是一種提供「分散式服務協調」的中心化服務,是以 Paxos 演算法為基礎實現的。Zookeeper資料節點和檔案目錄類似,同時具有Watch機制,基於這兩個特性,得以實現分散式鎖功能。

資料節點:

順序臨時節點:Zookeeper 提供一個多層級的節點名稱空間(節點稱為 Znode),每個節點都用一個以斜槓(/)分隔的路徑來表示,而且每個節點都有父節點(根節點除外),非常類似於檔案系統。

節點型別可以分為持久節點(PERSISTENT )、臨時節點(EPHEMERAL),每個節點還能被標記為有序性(SEQUENTIAL),一旦節點被標記為有序性,那麼整個節點就具有順序自增的特點。

一般我們可以組合這幾類節點來建立我們所需要的節點,例如,建立一個持久節點作為父節點,在父節點下面建立臨時節點,並標記該臨時節點為有序性。

Watch 機制:

Zookeeper 還提供了另外一個重要的特性,Watcher(事件監聽器)。

ZooKeeper 允許使用者在指定節點上註冊一些 Watcher,並且在一些特定事件觸發的時候,ZooKeeper 服務端會將事件通知給使用者。

圖解Zookeeper實現分散式鎖:

首先,我們需要建立一個父節點,節點型別為持久節點(PERSISTENT)如圖中的 /locks/lock_name1 節點 ,每當需要訪問共享資源時,就會在父節點下建立相應的順序子節點,節點型別為臨時節點(EPHEMERAL),且標記為有序性(SEQUENTIAL),並且以臨時節點名稱 + 父節點名稱 + 順序號組成特定的名字,如圖中的 /0000000001 /0000000002 /0000000003 作為臨時有序節點。

在建立子節點後,對父節點下面的所有以臨時節點名稱 name 開頭的子節點進行排序,判斷剛剛建立的子節點順序號是否是最小的節點,如果是最小節點,則獲得鎖。

如果不是最小節點,則阻塞等待鎖,並且獲得該節點的上一順序節點,為其註冊監聽事件,等待節點對應的操作獲得鎖。當呼叫完共享資源後,刪除該節點,關閉 zk,進而可以觸發監聽事件,釋放該鎖。

// 加鎖
InterProcessMutex lock = new InterProcessMutex(client, lockPath);
if ( lock.acquire(maxWait, waitUnit) ) 
{
    try 
    {
        // do some work inside of the critical section here
    }
    finally
    {
        lock.release();
    }
}

public void acquire() throws Exception
    {
            if ( !internalLock(-1, null) )
            {
                    throw new IOException("Lost connection while trying to acquire lock: " + basePath);
            }
    }

private boolean internalLock(long time, TimeUnit unit) throws Exception
    {
            /*
                 Note on concurrency: a given lockData instance
                 can be only acted on by a single thread so locking isn't necessary
            */

            Thread currentThread = Thread.currentThread();

            LockData lockData = threadData.get(currentThread);
            if ( lockData != null )
            {
                    // re-entering
                    lockData.lockCount.incrementAndGet();
                    return true;
            }

            String lockPath = internals.attemptLock(time, unit, getLockNodeBytes());
            if ( lockPath != null )
            {
                    LockData newLockData = new LockData(currentThread, lockPath);
                    threadData.put(currentThread, newLockData);
                    return true;
            }

            return false;
    }
// ... 其他程式碼略

InterProcessMutex 是 Curator 實現的可重入鎖,可重入鎖原始碼過程分析:

加鎖流程:

1)可重入鎖記錄在 ConcurrentMap<Thread, LockData> threadData 這個 Map 裡面。

2)如果 threadData.get(currentThread) 是有值的那麼就證明是可重入鎖,然後記錄就會加 1。

3)資源目錄下建立一個節點:比如這裡建立一個 /0000000002 這個節點,這個節點需要設定為 EPHEMERAL_SEQUENTIAL 也就是臨時節點並且有序。

4)獲取當前目錄下所有子節點,判斷自己的節點是否是最小的節點。

5)如果是最小的節點,則獲取到鎖。如果不是最小的節點,則證明前面已經有人獲取到鎖了,那麼需要獲取自己節點的前一個節點。

6)節點 /0000000002 的前一個節點是 /0000000001,我們獲取到這個節點之後,再上面註冊 Watcher,Watcher 呼叫的是 object.notifyAll(),用來解除阻塞。

7)object.wait(timeout) 或 object.wait() 進行阻塞等待

解鎖流程:

1)如果可重入鎖次數減1後,加鎖次數不為 0 直接返回,減1後加鎖次數為0,繼續。

2)刪除當前節點。

3)刪除 threadDataMap 裡面的可重入鎖的資料。

最後的總結

上面介紹的諸如Apache Curator、Redisson、Spring框架整合的分散式鎖,既然是框架實現,會考慮使用者需求,儘量設計和實現通用的分散式鎖介面。

基本都涵蓋了如下的方式實現:

當然,Redisson和Curator都是自己定義的分散式鎖介面實現的,易於擴充套件。

Curator裡自定義了InterProcessLock介面,Redisson裡自定義RLock介面,繼承了 java.util.concurrent.locks.Lock介面。

對於Redis實現的分散式鎖:

大部分需求下,不會遇到「極端複雜場景」,基於Redis實現分散式鎖很常用,效能也高。

它獲取鎖的方式簡單粗暴,獲取不到鎖直接不斷嘗試獲取鎖,比較消耗效能。

另外來說的話,redis的設計定位決定了它的資料並不是強一致性的,沒有一致性演算法,在某些極端情況下,可能會出現問題,鎖的模型不夠健壯。

即便有了Redlock演算法的實現,但存在爭議,某些複雜場景下,也無法保證其實現完全沒有問題,並且也是比較消耗效能的。

對於Zookeeper實現的分散式鎖:

Zookeeper優點:

天生設計定位是分散式協調,強一致性。鎖的模型健壯、簡單易用、適合做分散式鎖。

如果獲取不到鎖,只需要新增一個監聽器就可以了,不用一直輪詢,效能消耗較小。

如果客戶端宕機,也沒關係,臨時節點會自動刪除,觸發監聽器通知下一個節點。

Zookeeper缺點:

若有大量的客戶端頻繁的申請加鎖、釋放鎖,對於ZK叢集的壓力會比較大。

另外,本文對spring-integration整合redis做了詳細分析,推薦可以直接使用,更推薦直接使用 Redisson,實現了非常多的分散式鎖各種機制,有單獨開放Springboot整合的jar包,使用上也是非常方便的。

文章開頭部分提到的幾個業務場景,經過對分散式鎖家族的介紹和原理分析,可以自行選擇技術方案了。

以上,一定有一款能滿足你的需求,希望大家有所收穫!

碼字不易,文章不妥之處,歡迎留言斧正。

歡迎關注我的公眾號,掃二維碼關注獲得更多精彩文章,與你一同成長~