圖解Janusgraph系列-併發安全:鎖機制(本地鎖+分散式鎖)分析

洋仔聊程式設計發表於2020-12-17

圖解Janusgraph系列-併發安全:鎖機制(本地鎖+分散式鎖)分析

大家好,我是洋仔,JanusGraph圖解系列文章,實時更新~

圖資料庫文章總目錄:

原始碼分析相關可檢視github(碼文不易,求個star~)https://github.com/YYDreamer/janusgraph

下述流程高清大圖地址:https://www.processon.com/view/link/5f471b2e7d9c086b9903b629

版本:JanusGraph-0.5.2

轉載文章請保留以下宣告:

作者:洋仔聊程式設計
微信公眾號:匠心Java
原文地址:https://liyangyang.blog.csdn.net/


在分散式系統中,難免涉及到對同一資料的併發操作,如何保證分散式系統中資料的併發安全呢?分散式鎖!

一:分散式鎖

常用的分散式鎖實現方式:

1、基於資料庫實現分散式鎖

​ 針對於資料庫實現的分散式鎖,如mysql使用使用for update共同競爭一個行鎖來實現; 在JanusGraph中,也是基於資料庫實現的分散式鎖,這裡的資料庫指的是我們當前使用的第三方backend storage,具體的實現方式也和mysql有所不同,具體我們會在下文分析

2、基於Redis實現的分散式鎖

​ 基於lua指令碼+setNx實現

3、基於zk實現的分散式鎖

​ 基於znode的有序性和臨時節點+zk的watcher機制實現

4、MVCC多版本併發控制樂觀鎖實現

本文主要介紹Janusgraph的鎖機制,其他的實現機制就不在此做詳解了

下面我們來分析一下JanusGraph鎖機制實現~

二:JanusGraph鎖機制

在JanusGraph中使用的鎖機制是:本地鎖 + 分散式鎖來實現的;

2.1 一致性行為

JanusGraph中主要有三種一致性修飾詞(Consistency Modifier)來表示3種不同的一致性行為,來控制相簿使用過程中的併發問題的控制程度;

public enum ConsistencyModifier {
    DEFAULT,
    LOCK,
    FORK
}

原始碼中ConsistencyModifier列舉類主要作用:用於控制JanusGraph在最終一致或其他非事務性後端系統上的一致性行為!其作用分別為:

  • DEFAULT:預設的一致性行為,不使用分散式鎖進行控制,對配置的儲存後端使用由封閉事務保證的預設一致性模型,一致性行為主要取決於儲存後端的配置以及封閉事務的(可選)配置;無需顯示配置即可使用
  • LOCK:在儲存後端支援鎖的前提下,顯示的獲取分散式鎖以保證一致性!確切的一致性保證取決於所配置的鎖實現;需management.setConsistency(element, ConsistencyModifier.LOCK);語句進行配置
  • FORK:只適用於multi-edgeslist-properties兩種情況下使用;使JanusGraph修改資料時,採用先刪除後新增新的邊/屬性的方式,而不是覆蓋現有的邊/屬性,從而避免潛在的併發寫入衝突;需management.setConsistency(element, ConsistencyModifier.FORK);進行配置

LOCK

在查詢或者插入資料時,是否使用分散式鎖進行併發控制,在圖shcema的建立過程中,如上述可以通過配置schema元素ConsistencyModifier.LOCK方式控制併發,則在使用過程中就會用分散式鎖進行併發控制;

為了提高效率,JanusGraph預設不使用鎖定。 因此,使用者必須為定義一致性約束的每個架構元素決定是否使用鎖定。

使用JanusGraphManagement.setConsistency(element,ConsistencyModifier.LOCK)顯式啟用對架構元素的鎖定

程式碼如下所示:

mgmt = graph.openManagement() 
name = mgmt.makePropertyKey('consistentName').dataType(String.class).make() 
index = mgmt.buildIndex('byConsistentName', Vertex.class).addKey(name).unique().buildCompositeIndex() 
mgmt.setConsistency(name, ConsistencyModifier.LOCK) // Ensures only one name per vertex 
mgmt.setConsistency(index, ConsistencyModifier.LOCK) // Ensures name uniqueness in the graph 
mgmt.commit()

FORK

由於邊緣作為單個記錄儲存在基礎儲存後端中,因此同時修改單個邊緣將導致衝突。

FORK就是為了代替LOCK,可以將邊緣標籤配置為使用ConsistencyModifier.FORK

下面的示例建立一個新的edge label,並將其設定為ConsistencyModifier.FORK

mgmt = graph.openManagement() 
related = mgmt.makeEdgeLabel('related').make() 
mgmt.setConsistency(related, ConsistencyModifier.FORK) 
mgmt.commit()

經過上述配置後,修改標籤配置為FORK的edge時,操作步驟為:

  1. 首先,刪除該邊
  2. 將修改後的邊作為新邊新增

因此,如果兩個併發事務修改了同一邊緣,則提交時將存在邊緣的兩個修改後的副本,可以在查詢遍歷期間根據需要解決這些副本。

注意edge fork僅適用於MULTI edge。 具有多重性約束的邊緣標籤不能使用此策略,因為非MULTI的邊緣標籤定義中內建了一個唯一性約束,該約束需要顯式鎖定或使用基礎儲存後端的衝突解決機制

下面我們具體來看一下janusgrph鎖機制的實現:

2.2 LoackID

在介紹鎖機制之前,先看一下鎖應該鎖什麼東西呢?

我們都知道在janusgraph的底層儲存中,vertexId作為Rowkey,屬性和邊儲存在cell中,由column+value組成

當我們修改節點的屬性和邊+邊的屬性時,很明顯只要鎖住對應的Rowkey + Column即可;

Janusgraph中,這個鎖的標識的基礎部分就是LockID

LockID = RowKey + Column

原始碼如下:

KeyColumn lockID = new KeyColumn(key, column);

2.3 本地鎖

本地鎖是在任何情況下都需要獲取的一個鎖,只有獲取成功後,才會進行下述分散式鎖的獲取!

本地鎖是基於圖例項維度存在的;主要作用是保證當前圖例項下的操作中無衝突!

本地鎖的實現是通過ConcurrentHashMap資料結構來實現的,在圖例項維度下唯一;

基於當前事務+lockId來作為鎖標識

獲取的主要流程:

image-20200810170411991

結合原始碼如下:

上述圖建議依照原始碼一塊分析,原始碼在LocalLockMediator類中的下述方法,下面原始碼分析模組會詳細分析

    public boolean lock(KeyColumn kc, T requester, Instant expires) {
    }

引入本地鎖機制,主要目的: 在圖例項維度來做一層鎖判斷,減少分散式鎖的併發衝突,減少分散式鎖帶來的效能消耗

2.4 分散式鎖

本地鎖獲取成功之後才會去嘗試獲取分散式鎖

分散式鎖的獲取整體分為兩部分流程:

  1. 分散式鎖資訊插入
  2. 分散式鎖資訊狀態判斷

分散式鎖資訊插入

該部分主要是通過lockID來構造要插入的Rowkey和column並將資料插入到hbase中;插入成功即表示這部分處理成功!

具體流程如下:

2

分散式鎖資訊狀態判斷

該部分在上一部分完成之後才會進行,主要是判斷分散式鎖是否獲取成功!

查詢出當前hbase中對應Rowkey的所有column,過濾未過期的column集合,比對集合的第一個column是否等於當前事務插入的column;

等於則獲取成功!不等於則獲取失敗!

具體流程如下:

3

三:原始碼分析 與 整體流程

原始碼分析已經push到github:https://github.com/YYDreamer/janusgraph

1、獲取鎖的入口

    public void acquireLock(StaticBuffer key, StaticBuffer column, StaticBuffer expectedValue, StoreTransaction txh) throws BackendException {
        // locker是一個一致性key鎖物件
        if (locker != null) {
            // 獲取當前事務物件
            ExpectedValueCheckingTransaction tx = (ExpectedValueCheckingTransaction) txh;
            // 判斷:當前的獲取鎖操作是否當前事務的操作中存在增刪改的操作
            if (tx.isMutationStarted())
                throw new PermanentLockingException("Attempted to obtain a lock after mutations had been persisted");
            // 使用key+column組裝為lockID,供下述加鎖使用!!!!!
            KeyColumn lockID = new KeyColumn(key, column);
            log.debug("Attempting to acquireLock on {} ev={}", lockID, expectedValue);
            // 獲取本地當前jvm程式中的寫鎖(看下述的 1:寫鎖獲取分析)
            // (此處的獲取鎖只是將對應的KLV儲存到Hbase中!儲存成功並不代表獲取鎖成功)
            // 1. 獲取成功(等同於儲存成功)則繼續執行
            // 2. 獲取失敗(等同於儲存失敗),會丟擲異常,丟擲到最上層,列印錯誤日誌“Could not commit transaction ["+transactionId+"] due to exception” 並丟擲對應的異常,本次插入資料結束
            locker.writeLock(lockID, tx.getConsistentTx());
            // 執行前提:上述獲取鎖成功!
            // 儲存期望值,此處為了實現當相同的key + value + tx多個加鎖時,只處理第一個
            // 儲存在事務物件中,標識在commit判斷鎖是否獲取成功時,當前事務插入的是哪個鎖資訊
            tx.storeExpectedValue(this, lockID, expectedValue);
        } else {
            // locker為空情況下,直接丟擲一個執行時異常,終止程式
            store.acquireLock(key, column, expectedValue, unwrapTx(txh));
        }
    }

2、執行 locker.writeLock(lockID, tx.getConsistentTx()) 觸發鎖獲取

    public void writeLock(KeyColumn lockID, StoreTransaction tx) throws TemporaryLockingException, PermanentLockingException {

        if (null != tx.getConfiguration().getGroupName()) {
            MetricManager.INSTANCE.getCounter(tx.getConfiguration().getGroupName(), M_LOCKS, M_WRITE, M_CALLS).inc();
        }

        // 判斷當前事務是否在圖例項的維度 已經佔據了lockID的鎖
        // 此處的lockState在一個事務成功獲取本地鎖+分散式鎖後,以事務為key、value為map,其中key為lockID,value為加鎖狀態(開始時間、過期時間等)
        if (lockState.has(tx, lockID)) {
            log.debug("Transaction {} already wrote lock on {}", tx, lockID);
            return;
        }

        // 當前事務沒有佔據lockID對應的鎖
        // 進行(lockLocally(lockID, tx) 本地加鎖鎖定操作,
        if (lockLocally(lockID, tx)) {
            boolean ok = false;
            try {
                // 在本地鎖獲取成功的前提下:
                // 嘗試獲取基於Hbase實現的分散式鎖;
                // 注意!!!(此處的獲取鎖只是將對應的KLV儲存到Hbase中!儲存成功並不代表獲取鎖成功)
                S stat = writeSingleLock(lockID, tx);
                // 獲取鎖分散式鎖成功後(即寫入成功後),更新本地鎖的過期時間為分散式鎖的過期時間
                lockLocally(lockID, stat.getExpirationTimestamp(), tx); // update local lock expiration time
                // 將上述獲取的鎖,儲存在標識當前存在鎖的集合中Map<tx,Map<lockID,S>>,  key為事務、value中的map為當前事務獲取的鎖,key為lockID,value為當前獲取分散式鎖的ConsistentKeyStatus(一致性密匙狀態)物件
                lockState.take(tx, lockID, stat);
                ok = true;
            } catch (TemporaryBackendException tse) {
                // 在獲取分散式鎖失敗後,捕獲該異常,並丟擲該異常
                throw new TemporaryLockingException(tse);
            } catch (AssertionError ae) {
                // Concession to ease testing with mocks & behavior verification
                ok = true;
                throw ae;
            } catch (Throwable t) {
                // 出現底層儲存錯誤! 則直接加鎖失敗!
                throw new PermanentLockingException(t);
            } finally {
                // 判斷是否成功獲取鎖,沒有獲分散式鎖的,則釋放本地鎖
                if (!ok) {
                    // 沒有成功獲取鎖,則釋放本地鎖
                    // lockState.release(tx, lockID); // has no effect
                    unlockLocally(lockID, tx);
                    if (null != tx.getConfiguration().getGroupName()) {
                        MetricManager.INSTANCE.getCounter(tx.getConfiguration().getGroupName(), M_LOCKS, M_WRITE, M_EXCEPTIONS).inc();
                    }
                }
            }
        } else {
            // 如果獲取本地鎖失敗,則直接丟擲異常,不進行重新本地爭用

            // Fail immediately with no retries on local contention
            throw new PermanentLockingException("Local lock contention");
        }
    }

包含兩個部分:

  1. 本地鎖的獲取lockLocally(lockID, tx)
  2. 分散式鎖的獲取writeSingleLock(lockID, tx) 注意此處只是將鎖資訊寫入到Hbase中,並不代表獲取分散式鎖成功,只是做了上述介紹的第一個階段分散式鎖資訊插入

3、本地鎖獲取 lockLocally(lockID, tx)

  public boolean lock(KeyColumn kc, T requester, Instant expires) {
        assert null != kc;
        assert null != requester;

        final StackTraceElement[] acquiredAt = log.isTraceEnabled() ?
                new Throwable("Lock acquisition by " + requester).getStackTrace() : null;

        // map的value,以事務為核心
        final AuditRecord<T> audit = new AuditRecord<>(requester, expires, acquiredAt);
        //  ConcurrentHashMap實現locks, 以lockID為key,事務為核心value
        final AuditRecord<T> inMap = locks.putIfAbsent(kc, audit);

        boolean success = false;

        // 代表當前map中不存在lockID,標識著鎖沒有被佔用,成功獲取鎖
        if (null == inMap) {
            // Uncontended lock succeeded
            if (log.isTraceEnabled()) {
                log.trace("New local lock created: {} namespace={} txn={}",
                    kc, name, requester);
            }
            success = true;
        } else if (inMap.equals(audit)) {
            // 代表當前存在lockID,比對舊value和新value中的事務物件是否是同一個
            // requester has already locked kc; update expiresAt
            // 上述判斷後,事務物件為同一個,標識當前事務已經獲取這個lockID的鎖;
            // 1. 這一步進行cas替換,作用是為了重新整理過期時間
            // 2. 併發處理,如果因為鎖過期被其他事務佔據,則佔用鎖失敗
            success = locks.replace(kc, inMap, audit);
            if (log.isTraceEnabled()) {
                if (success) {
                    log.trace("Updated local lock expiration: {} namespace={} txn={} oldexp={} newexp={}",
                        kc, name, requester, inMap.expires, audit.expires);
                } else {
                    log.trace("Failed to update local lock expiration: {} namespace={} txn={} oldexp={} newexp={}",
                        kc, name, requester, inMap.expires, audit.expires);
                }
            }
        } else if (0 > inMap.expires.compareTo(times.getTime())) {
            // 比較過期時間,如果鎖已經過期,則當前事務可以佔用該鎖

            // the recorded lock has expired; replace it
            // 1. 當前事務佔用鎖
            // 2. 併發處理,如果因為鎖過期被其他事務佔據,則佔用鎖失敗
            success = locks.replace(kc, inMap, audit);
            if (log.isTraceEnabled()) {
                log.trace("Discarding expired lock: {} namespace={} txn={} expired={}",
                    kc, name, inMap.holder, inMap.expires);
            }
        } else {
            // 標識:鎖被其他事務佔用,並且未過期,則佔用鎖失敗
            // we lost to a valid lock
            if (log.isTraceEnabled()) {
                log.trace("Local lock failed: {} namespace={} txn={} (already owned by {})",
                    kc, name, requester, inMap);
                log.trace("Owner stacktrace:\n        {}", Joiner.on("\n        ").join(inMap.acquiredAt));
            }
        }

        return success;
    }

如上述介紹,本地鎖的實現是通過ConcurrentHashMap資料結構來實現的,在圖例項維度下唯一!

4、分散式鎖獲取第一個階段:分散式鎖資訊插入

    protected ConsistentKeyLockStatus writeSingleLock(KeyColumn lockID, StoreTransaction txh) throws Throwable {

        // 組裝插入hbase資料的Rowkey
        final StaticBuffer lockKey = serializer.toLockKey(lockID.getKey(), lockID.getColumn());
        StaticBuffer oldLockCol = null;

        // 進行嘗試插入 ,預設嘗試次數3次
        for (int i = 0; i < lockRetryCount; i++) {
            // 嘗試將資料插入到hbase中;oldLockCol表示要刪除的column代表上一次嘗試插入的資料
            WriteResult wr = tryWriteLockOnce(lockKey, oldLockCol, txh);
            // 如果插入成功
            if (wr.isSuccessful() && wr.getDuration().compareTo(lockWait) <= 0) {
                final Instant writeInstant = wr.getWriteTimestamp(); // 寫入時間
                final Instant expireInstant = writeInstant.plus(lockExpire);// 過期時間
                return new ConsistentKeyLockStatus(writeInstant, expireInstant); // 返回插入物件
            }
            // 賦值當前的嘗試插入的資料,要在下一次嘗試時刪除
            oldLockCol = wr.getLockCol();
            // 判斷插入失敗原因,臨時異常進行嘗試,非臨時異常停止嘗試!
            handleMutationFailure(lockID, lockKey, wr, txh);
        }
        // 處理在嘗試了3次之後還是沒插入成功的情況,刪除最後一次嘗試插入的資料
        tryDeleteLockOnce(lockKey, oldLockCol, txh);
        // TODO log exception or successful too-slow write here
        // 丟擲異常,標識匯入資料失敗
        throw new TemporaryBackendException("Lock write retry count exceeded");
    }

上述只是將鎖資訊插入,插入成功標識該流程結束

5、分散式鎖獲取第一個階段:分散式鎖鎖定是否成功判定

這一步,是在commit階段進行的驗證

    public void commit() throws BackendException {
        // 此方法內呼叫checkSingleLock 檢查分散式鎖的獲取結果
        flushInternal();
        tx.commit();
    }

最終會呼叫checkSingleLock方法,判斷獲取鎖的狀態!

    protected void checkSingleLock(final KeyColumn kc, final ConsistentKeyLockStatus ls,
                                   final StoreTransaction tx) throws BackendException, InterruptedException {

        // 檢查是否被檢查過
        if (ls.isChecked())
            return;

        // Slice the store
        KeySliceQuery ksq = new KeySliceQuery(serializer.toLockKey(kc.getKey(), kc.getColumn()), LOCK_COL_START,
            LOCK_COL_END);
        // 此處從hbase中查詢出鎖定的行的所有列! 預設查詢重試次數3
        List<Entry> claimEntries = getSliceWithRetries(ksq, tx);

        // 從每個返回條目的列中提取timestamp和rid,然後過濾出帶有過期時間戳的timestamp物件
        final Iterable<TimestampRid> iterable = Iterables.transform(claimEntries,
            e -> serializer.fromLockColumn(e.getColumnAs(StaticBuffer.STATIC_FACTORY), times));
        final List<TimestampRid> unexpiredTRs = new ArrayList<>(Iterables.size(iterable));
        for (TimestampRid tr : iterable) { // 過濾獲取未過期的鎖!
            final Instant cutoffTime = now.minus(lockExpire);
            if (tr.getTimestamp().isBefore(cutoffTime)) {
                ...
            }
            // 將還未過期的鎖記錄儲存到一個集合中
            unexpiredTRs.add(tr);
        }
        // 判斷當前tx是否成功持有鎖! 如果我們插入的列是讀取的第一個列,或者前面的列只包含我們自己的rid(因為我們是在第一部分的前提下獲取的鎖,第一部分我們成功獲取了基於當前程式的鎖,所以如果rid相同,代表著我們也成功獲取到了當前的分散式鎖),那麼我們持有鎖。否則,另一個程式持有該鎖,我們無法獲得鎖
        // 如果,獲取鎖失敗,丟擲TemporaryLockingException異常!!!! 丟擲到頂層的mutator.commitStorage()處,最終匯入失敗進行事務回滾等操作
        checkSeniority(kc, ls, unexpiredTRs);
        // 如果上述步驟未丟擲異常,則標識當前的tx已經成功獲取鎖!
        ls.setChecked();
    }

四:整體流程

總流程如下圖:

4

整體流程為:

  1. 獲取本地鎖
  2. 獲取分散式鎖
    1. 插入分散式鎖資訊
    2. commit階段判斷分散式鎖獲取是否成功
  3. 獲取失敗,則重試

五:總結

JanusGraph的鎖機制主要是通過本地鎖+分散式鎖來實現分散式系統下的資料一致性;

分散式鎖的控制維度為:property、vertex、edge、index都可以;

JanusGraph支援在資料匯入時通過前面一致性行為部分所說的LOCK來開關分散式鎖:

  • LOCK:資料匯入時開啟分散式鎖保證分散式一致性
  • DEFAULT、FORK:資料匯入時關閉分散式鎖

是否開啟分散式鎖思考:

在開啟分散式鎖的情況下,資料匯入開銷非常大;如果是資料不是要求很高的一致性,並且資料量比較大,我們可以選擇關閉分散式鎖相關,來提高匯入速度;

然後,針對於小資料量的要求高一致性的資料,單獨開啟分散式鎖來保證資料安全;

另外,我們在不開啟分散式鎖定的情況下,可以通過針對於匯入的資料的充分探查來減少衝突!

針對於圖schema的元素開啟還是關閉分散式鎖,還是根據實際業務情況來決定。

本文有任何問題,可加博主微信或評論指出,感謝!

碼文不易,給個贊和star吧~

本文由部落格群發一文多發等運營工具平臺 OpenWrite 釋出

相關文章