在微服務架構中實施分散式事務鎖的幾個方案比較 - Prasanth Gullapalli

banq發表於2020-12-04

眾所周知,鎖通常用於監視和控制多個執行緒同時訪問共享資源。它們基本上保護併發應用程式中的資料完整性和原子性,即,一次只能有一個執行緒可以獲取共享資源上的鎖,否則將無法訪問該鎖。但是在分散式環境中的鎖定不僅僅是在多執行緒應用程式中的互斥鎖。由於必須立即跨叢集或網路中的任何節點出現故障的所有節點獲取鎖定,因此情況變得更加複雜。
這是我們看看這樣案例:該應用以使用者首選的格式獲取資料,並將其轉換為可上傳至政府門戶的標準化格式(如PDF)。該應用程式有兩種不同的微服務可以執行以下操作:Transformer和Rules Engine。我們已經使用Cassandra進行持久化,並使用Kafka作為訊息佇列。另外,請注意,一旦接受使用者請求,則立即返回。PDF生成後,將非同步通知使用者。這是透過以下步驟實現的:
  • 使用者請求被放入訊息佇列。
  • 一旦Transformer服務接收到使用者請求,它將把使用者上傳的檔案轉換為Rule Engine可以理解的格式。
  • 現在,資料透過規則引擎獲取,規則引擎更新資料點
  • 最後,將資料轉換為PDF並通知使用者。


首先,讓我們嘗試理解為什麼在分散式環境中根本需要獲取鎖。以下是我們使用分散式鎖的用例:
  1. 效率:這是為了確保同一昂貴的計算不會多次發生。例如:假設使用者已上傳檔案進行處理。由於請求數量增加或當前檔案太大而無法處理,因此係統上的負擔很重,因此可能需要一段時間才能生成PDF。現在,如果使用者變得焦躁不安,等待通知,他可以再次上傳檔案進行處理(此後不必要地增加了系統的負擔)。透過在處理檔案之前對檔案的校驗和進行鎖定可以避免這種情況。
  2. 正確性:這是為了避免應用程式中的資料損壞。當使用鎖時,系統中的兩個併發/並行程式不會弄亂基礎資料。如果兩個程式同時對基礎資料集進行操作而沒有獲取鎖定,則很有可能損壞資料。例如:假設我們已經從使用者那裡獲得了銷售交易和訂單項資料。交易級別的稅額是根據交易級別已經徵收的稅額與行級別存在的任何其他稅額之和計算得出的。現在,如果在兩個不同的節點中並行執行同一事務的規則,則很有可能該行專案的稅額增加兩次。如果我們鎖定事務級別,則可以避免這種情況。

請注意,鎖通常不是一個好主意。阻塞操作透過限制系統的計算能力,增加了對那裡基礎資源的爭用。此外,由於以下原因,嘗試鎖定分散式環境會更加困難和危險:
  • 當獲取它的節點在沒有釋放的情況下崩潰時,該鎖會發生什麼情況?
  • 我們如何處理網路分割槽的情況?
  • 這些將把共識的其他方面帶入畫面。我們將在一段時間內討論分散式共識的想法。

因此,出於上述所有原因,如果存在任何其他解決方案,我們應儘量避免使用這些鎖定。這是可以在應用程式中使用的兩種可能的方法:
  1. 樂觀鎖定:  在這種情況下,資源實際上並未鎖定。在提交事務之前,我們檢查資源是否由其他人更新。如果資料是陳舊的,則事務將回滾,並向使用者丟擲錯誤指示該錯誤。與此相反,悲觀鎖定是指您採用排他鎖定時,其他任何人都無法修改資源。例如:資料庫中的選擇更新鎖,Java鎖。Hibernate提供了樂觀鎖定的支援。您可以在此處瞭解更多資訊。
  2. Kafka中分割槽的用法:如前所述,在處理使用者請求之前,我們始終將其保留在Kafka中。因為可用性是應用程式的核心體系結構原理之一,所以它是透過這種方式完成的。我們不希望當某些高峰使用期間負載增加多倍時應用程式崩潰。Kafka將針對某個主題釋出的訊息儲存在內部的多個分割槽中。而且,它可以確保始終將給定分割槽中的訊息以與釋出時相同的順序提供給使用者。利用此資訊,我們將不想並行處理的所有請求釋出到了同一分割槽(因此使用了鎖)。這可以透過在將訊息釋出到Kafka時指定分割槽鍵來完成。具有相同金鑰的訊息將釋出到同一分割槽。現在,隨著訊息從分割槽中順序接收。

分散式鎖定
在某些情況下,我們更喜歡採用分散式鎖定,它不屬於上述情況。當我們談論分散式鎖時,就會出現分散式共識。共識可以定義為使叢集中的所有節點根據其投票就某個特定值達成共識的過程。所有節點都必須同意相同的值,並且該值必須是至少一個節點提交的值。現在,當說某個特定節點獲取叢集中的分散式鎖時,叢集中的其餘節點必須同意該鎖已被其使用。有多種共識演算法,例如Paxos,Raft,ZAB,Pacifica等。我在部落格末尾提供了一些連結,供那些對此感興趣的人解釋這些演算法。

  • 對稱/無領導者:在這裡,所有參與共識的伺服器都具有相同的角色。因此,在這種情況下,客戶端可以連線到任何伺服器。例如:Paxos
  • 基於非對稱/基於領導者:在任何給定時間,一臺伺服器都充當參與共識的伺服器的領導者。其餘伺服器接受領導者的決定。在這裡,客戶只能與領導者交流。示例:Raft,ZAB

幾十年來,Paxos已成為共識的代名詞。但是現在,如上所述有共識的不同實現。Raft實際上克服了傳統Paxos的一些缺點。對於上述每種演算法,都有不同的實現方式。對於Ex:Cassandra為輕量級交易實現了Paxos。卡夫卡內部使用Pacifica,而Zookeeper和Hazelcast分別使用ZAB和Raft。這是我們的應用程式中分散式鎖的通用介面:

package common.concurrent.lock;
 
import java.util.concurrent.TimeUnit;
 
/**
 * Provides interface for the distributed lock implementations based on Zookeeper and Hazelcast.
 * @author pgullapalli
 */
public interface DistributedLock {
    /**
     * Acquires the lock. If the lock is not available, the current thread until the lock has been acquired.
     * The distributed lock acquired by a thread has to be released by same thread only.
     **/
    void lock();
 
    /**
     * This is a non-blocking version of lock() method; it attempts to acquire the lock immediately, return true if locking succeeds.
     * The distributed lock acquired by a thread has to be released by same thread only.
     **/
    boolean tryLock();
 
    /**
     * Acquires the lock. Blocks until the lock is available or timeout is expired.
     * The distributed lock acquired by a thread has to be released by same thread only.
     **/
    boolean tryLock(long timeout, TimeUnit unit);
 
    /**
     * Checks if current thread has already acquire the lock.
     * @return
     */
    boolean isLocked();
 
    /**
     * Releases the lock. This method has to be called by same thread as which has acquired the lock.
     */
    void release();
}
 
public interface DistributedLocker {
 
    /**
     * This method only fetches the lock object but does not explicitly lock. Lock has to be acquired and released.
     * specifically
     * @param key Fetch the lock object based on the key provided.
     * @return Implementation of DistributedLock object
     */
    DistributedLock getLock(String key);
 
}

 
對於我們的應用程式,以下是我們為實現分散式鎖而探索的選項:

a)Zookeeper的InterProcessSemaphoreMutex:由Netflix開源的Curator,它是建立在Zookeeper之上的高階API,提供了許多配方,並處理了管理連線以及對基礎ZooKeeper集合進行重試操作的複雜性。InterProcessSemaphoreMutex是Curator Framework的配方,是可重入的互斥量,可在JVM之間使用。它使用Zookeeper來保持鎖。跨JVM使用相同鎖定路徑的所有程式都將達到程式間關鍵部分。此外,該互斥鎖是“公平的” –每個使用者將按照請求的順序獲得互斥鎖(從Zookeeper的角度來看)。

package common.concurrent.lock.impl;
 
import common.concurrent.lock.DistributedLock;
import common.concurrent.lock.DistributedLocker;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.framework.recipes.locks.InterProcessLock;
import org.apache.curator.framework.recipes.locks.InterProcessSemaphoreMutex;
import org.apache.curator.retry.ExponentialBackoffRetry;
 
import java.util.concurrent.TimeUnit;
 
public class ZKBasedDistributedLocker implements DistributedLocker {
    private final CuratorFramework curatorClient;
    private final String basePath;
 
    public ZKBasedDistributedLocker(){
        curatorClient = CuratorFrameworkFactory.newClient("localhost:2181",
                new ExponentialBackoffRetry(1000, 3));
        basePath = new StringBuilder("/config/sample-app/distributed-locks/").toString();
    }
 
    @Override
    public DistributedLock getLock(String key) {
        String lock = new StringBuilder(basePath).append(key).toString();
        return new ZKLock(new InterProcessSemaphoreMutex(curatorClient, lock));
    }
 
    private class ZKLock implements DistributedLock {
        private final InterProcessLock lock;
 
        public ZKLock(InterProcessLock lock){
            this.lock = lock;
        }
 
        @Override
        public void lock() {
            try {
                lock.acquire();
            } catch (Exception e) {
                throw new RuntimeException("Error while acquiring lock", e);
            }
        }
 
        @Override
        public boolean tryLock() {
            return tryLock(10, TimeUnit.MILLISECONDS);
        }
 
        @Override
        public boolean tryLock(long timeout, TimeUnit unit) {
            try {
                return lock.acquire(timeout, unit);
            } catch (Exception e) {
                throw new RuntimeException("Error while acquiring lock", e);
            }
        }
 
        @Override
        public boolean isLocked() {
            return lock.isAcquiredInThisProcess();
        }
 
        @Override
        public void release() {
            try {
                lock.release();
            } catch (Exception e) {
                throw new RuntimeException("Error while releasing lock", e);
            }
        }
    }
}

由於Zookeeper通常在許多分散式系統中使用,因此使用此選項不需要任何其他鎖定框架。但是有一個觀察到,隨著鎖數量的增加,效能會下降。這是由於所有鎖實際上都在內部建立為znode。隨著znode數量的增加,在列出/刪除Zookeeper中的locks資料夾時,我們甚至開始遇到問題。因此,對於需要較少數量鎖的情況,Zookeeper非常適合。由於應用程式的許多服務可能都依賴Zookeeper,因此Zookeeper的任何問題也可能影響它們。很少有這樣的用例,例如微服務向服務發現註冊自己,使用Kafka的服務,而Kafka則取決於Zookeeper進行領導者選舉。
 

b)來自Cassandra的輕量級事務:在基於主控的分散式系統中很容易實現強一致性。但是,這也意味著如果主伺服器當機,則會影響系統的可用性。Cassandra是無主控系統,並且在可用性與一致性之間進行權衡。它屬於CAP定理的AP類別,因此高度可用,並且預設情況下最終保持一致。最終一致表示某個值的寫入後讀取可能不會產生寫入的最新值。但是我們可以透過將查詢的一致性級別指定為QUORUM來實現Cassandra中的強一致性。仲裁意味著寫入事務只有在將其寫入大多數伺服器後才能成功。我們可以在Cassandra中實現鎖定,如下所示:

  1. 建立表lock_requests(resource_id文字,lock_status文字,created_on時間戳,主鍵(resource_id));
  2. 試圖獲取鎖的執行緒將檢查鎖表中是否存在具有指定鍵的條目:select * from lock_requests其中resource_id ='ABC';
  3. 如果不存在鎖,現在我們說在鎖中插入一個條目後就獲得了該鎖:插入lock_requests(resource_id,lock_status,created_on)values('ABC','Locked',toTimestamp(now()))

但是請注意,如果我們將這些作為與應用程式分開的步驟來進行,則步驟2和3之間的執行緒之間總是存在競爭狀態的可能性。但是,如果資料庫本身可以在插入之前檢查行是否存在,則可以避免競爭條件。這就是所謂的線性化一致性(即ACID術語中的序列隔離級別)。輕量級交易完全一樣。因此,可以將上述步驟2和3結合起來:

insert into lock_requests(resource_id,lock_status,created_on) values('ABC', 'Locked', toTimestamp(now())) if not exists;


如果存在鎖,則上述寫入失敗,因此無法獲取鎖。現在,下一個問題是,如果獲取鎖的服務未釋放它,將會發生什麼情況。伺服器可能崩潰了,或者程式碼可能引發了異常。鎖將永遠不會被釋放。對於這種情況,我們可以為該行定義生存時間(TTL)。這意味著鎖定行將在規定的秒數後自動過期。這是我們透過為該行的每個記錄定義TTL來實現的方法。

create table lock_requests(resource_id text,lock_status text, created_on timestamp, primary key(resource_id)) with gc_grace_seconds=86400 and default_time_to_live=600;


現在,鎖定將在10分鐘後自動失效。透過為所有列定義TTL,可以為每一行覆蓋此設定。如果我們無法粗略估計一次計算(被鎖包圍)所花費的時間,則TTL可能無濟於事。

package common.concurrent.lock.impl;
 
import com.datastax.oss.driver.api.core.CqlSession;
import com.datastax.oss.driver.api.core.cql.BoundStatement;
import com.datastax.oss.driver.api.core.cql.PreparedStatement;
import com.datastax.oss.driver.api.core.cql.ResultSet;
import com.datastax.oss.driver.api.core.cql.Row;
import common.concurrent.lock.DistributedLock;
import common.concurrent.lock.DistributedLocker;
import org.apache.commons.lang3.time.StopWatch;
 
import java.net.InetSocketAddress;
import java.time.Instant;
import java.util.concurrent.TimeUnit;
 
public class CassandraDistributedLocker implements DistributedLocker {
    private final CqlSession session;
    private final PreparedStatement selectStatement, insertStatement, deleteStatement;
 
    public CassandraDistributedLocker(){
        session = CqlSession.builder()
                .addContactPoint(new InetSocketAddress("127.0.0.1", 9042))
                .withKeyspace("sample").build();
        selectStatement = session.prepare(
                "select * from lock_requests where resource_id=?");
        insertStatement = session.prepare(
                "insert into lock_requests(resource_id,lock_status,created_on) values(?,?,?) if not exists");
        deleteStatement = session.prepare(
                "delete from lock_requests where resource_id=? if exists");
    }
 
    @Override
    public DistributedLock getLock(String key) {
        return new CassandraLock(key);
    }
 
    private class CassandraLock implements DistributedLock{
        private final String key;
 
        public CassandraLock(String key) {
            this.key = key;
        }
 
        @Override
        public void lock() {
            insertLock();
        }
 
        private boolean insertLock() {
            BoundStatement boundStatement = insertStatement.bind()
                    .setString(0, key)
                    .setString(1, "LOCKED")
                    .setInstant(2, Instant.now());
            ResultSet resultSet = session.execute(boundStatement);
            return resultSet.wasApplied();// this is equivalent to row.getBool("applied")
        }
 
        @Override
        public boolean tryLock() {
            return tryLock(10, TimeUnit.MILLISECONDS);
        }
 
        @Override
        public boolean tryLock(long timeout, TimeUnit unit) {
            try {
                boolean locked = false;
                StopWatch stopWatch = StopWatch.createStarted();
                while(stopWatch.getTime(TimeUnit.SECONDS) < timeout) {
                    if(insertLock()) {
                        locked = true;
                        break;
                    }
                }
                return locked;
            } catch (Exception e) {
                throw new RuntimeException("Error while acquiring lock", e);
            }
        }
 
        @Override
        public boolean isLocked() {
            BoundStatement boundStatement = selectStatement.bind().setString(0, key);
            ResultSet resultSet = session.execute(boundStatement);
            Row row = resultSet.one();
            return row != null ? "LOCKED".equals(row.getString("lock_status")) : false;
        }
 
        @Override
        public void release() {
            try {
                BoundStatement boundStatement = deleteStatement.bind().setString(0, key);
                session.execute(boundStatement);
            } catch (Exception e){
                throw new RuntimeException("Error while releasing lock", e);
            }
        }
    }
}

Cassandra內部使用Paxos的修改版本來實現輕量級交易。它進行了4次額外的往返行程以實現此線性化。如果您的應用程式很少需要將每個操作線性化的應用程式,那麼這聽起來像是很高的成本–也許太高了。但是對於大多數應用程式而言,只有極少數操作需要線性化,這是一個很好的工具,可以增強到目前為止我們提供的強大/最終的一致性。有關更多資訊,請參考此連結
當然,僅當應用程式已使用Cassandra進行持久化時,此解決方案才可行。我們還看到輕型卡車在重負荷下會超時。因此,最好謹慎使用這些鎖。這些鎖的優點之一是,不存在必須由獲得該鎖的人釋放該鎖的約束。如果我們遇到這樣的場景,其中一個微服務最初會獲得一個鎖,而另一個服務會在工作流非同步完成後釋放它,這可能會派上用場。
 

c)使用Hazelcast的分散式鎖: Hazelcast IMDG提供了基本Java集合和同步器的分散式版本。Hazelcast API的優點在於,在實現Java API本身時,它們很容易理解。例如: com.hazelcast.map.IMap擴充套件了java.util.Map。因此,這裡的學習曲線較少。分散式MAP實現具有一種鎖定特定金鑰的方法。如果該鎖不可用,則當前執行緒將被阻塞,直到釋放該鎖為止。即使MAP上沒有鑰匙,我們也可以將其鎖定。如果MAP中不存在該金鑰,則嘗試將鎖定的金鑰放入MAP中時,除鎖所有者之外的任何執行緒都將被阻塞。

package common.concurrent.lock.impl;
 
import com.hazelcast.core.Hazelcast;
import com.hazelcast.core.HazelcastInstance;
import com.hazelcast.core.IMap;
import common.concurrent.lock.DistributedLock;
import common.concurrent.lock.DistributedLocker;
 
import java.util.concurrent.TimeUnit;
 
public class HzMapBasedDistributedLocker implements DistributedLocker {
    private IMap txLockMap;
 
    public HzMapBasedDistributedLocker(){
        HazelcastInstance hazelcastInstance = Hazelcast.newHazelcastInstance();
        txLockMap = hazelcastInstance.getMap("txLockMap");
    }
 
    @Override
    public DistributedLock getLock(String lockKey) {
        return new HzMapBasedLock(lockKey);
    }
 
    private class HzMapBasedLock implements DistributedLock{
        private final String key;
 
        public HzMapBasedLock(String key) {
            this.key = key;
        }
 
        @Override
        public void lock() {
            txLockMap.lock(key);
        }
 
        @Override
        public boolean tryLock() {
            return txLockMap.tryLock(key);
        }
 
        @Override
        public boolean tryLock(long timeout, TimeUnit unit) {
            try {
                return txLockMap.tryLock(key, timeout, unit);
            } catch (Exception e) {
                throw new RuntimeException("Error while acquiring lock", e);
            }
        }
 
        @Override
        public boolean isLocked() {
            return txLockMap.isLocked(key);
        }
 
        @Override
        public void release() {
            try {
                txLockMap.unlock(key);
            } catch (Exception e){
                throw new RuntimeException("Error while releasing lock", e);
            }
        }
    }
}

請注意,Hazelcast IMDG實施也屬於CAP系統的AP類別。但是,強一致性(即使在失敗/異常情況下)也是需要分散式協調的所有任務的基本要求。因此,在某些情況下,基於Map實現的現有鎖將失敗。為了解決這些問題,Hazelcast後來提出了CPSubsystem實現。CP子系統在Raft共識之上獲得了新的分散式鎖實現。CPSubsystem與Hazelcast IMDG群集的AP資料結構並存。CPSubsystem在所有情況下均保持線性化,包括客戶端和伺服器故障,網路分割槽,並防止出現裂腦情況。實際上,Hazelcast聲稱它們是提供線性化和分散式鎖實現的唯一且唯一的解決方案。

package common.concurrent.lock.impl;
 
import com.hazelcast.config.Config;
import com.hazelcast.core.Hazelcast;
import com.hazelcast.core.HazelcastInstance;
import com.hazelcast.cp.lock.FencedLock;
import common.concurrent.lock.DistributedLock;
import common.concurrent.lock.DistributedLocker;
 
import java.util.concurrent.TimeUnit;
 
public class HzLockBasedDistributedLocker implements DistributedLocker {
    private HazelcastInstance hazelcastInstance;
 
    public HzLockBasedDistributedLocker(int cpMemberCount){
        Config config = new Config();
        config.getCPSubsystemConfig().setCPMemberCount(3);
        config.getCPSubsystemConfig().setGroupSize(3);
        hazelcastInstance = Hazelcast.newHazelcastInstance(config);
    }
 
    @Override
    public DistributedLock getLock(String key) {
        return wrapHzLock(key);
    }
 
    private DistributedLock wrapHzLock(String key){
        return new HzLock(key);
    }
 
    private class HzLock implements DistributedLock {
        private final FencedLock lock;
 
        public HzLock(String key) {
            this.lock = hazelcastInstance.getCPSubsystem().getLock(key);
        }
 
        @Override
        public void lock() {
            lock.lock();
        }
 
        @Override
        public boolean tryLock() {
            return lock.tryLock();
        }
 
        @Override
        public boolean tryLock(long timeout, TimeUnit unit) {
            try {
                return lock.tryLock(timeout, unit);
            } catch (Exception e) {
                throw new RuntimeException("Error while acquiring lock", e);
            }
        }
 
        @Override
        public boolean isLocked() {
            return lock.isLocked();
        }
 
        @Override
        public void release() {
            try {
                lock.unlock();
                //((DistributedObject) lock).destroy();
            } catch (Exception e){
                throw new RuntimeException("Error while releasing lock", e);
            }
        }
    }
}

上面的程式碼看起來很乾淨和簡單。但是問題在於,除非明確銷燬這些鎖,否則它們在Hazelcast中永遠不會自行失效。如果未銷燬且建立頻率更高,那麼一段時間後我們可能會遇到記憶體不足的異常。Hazelcast文件中的以下內容對此進行了澄清:

鎖不會自動移除。如果鎖不再使用,Hazelcast不會自動在鎖中執行垃圾回收。這可能導致OutOfMemoryError。如果您動態建立鎖,請確保它們已被銷燬。
儘管此修復程式看起來很簡單,即取消註釋上面程式碼中的destroy行,但這裡的問題是,一旦銷燬了鎖,除非重新啟動,否則無法在同一CP組中重新建立。因此,如果您需要重新使用一旦釋放的鎖,那麼我們將無法銷燬它們。在這種情況下,最好使用基於地圖的實現本身。根據特定的用例,可以使用兩種實現之一。Hazelcast可能會在近期功能中解決該問題。請參閱ticket。如果您還在尋找票,您也可以對其進行優先投票。
 
還有其他類似Redis的框架,它提供了分散式鎖的解決方案,我在這裡沒有對其進行解釋。我已經在資源部分列出了它們。請透過他們。最後要記住的一點是,謹慎使用這些鎖總是更好的選擇。如果存在不需要鎖的任何替代解決方案,則最好使用該解決方案。

其他資源

  1. 使用Paxos實施複製的日誌
  2. Raft:用於複製日誌的共識演算法
  3. Zab vs Paxos
  4. Cassandra 2.0中的輕量級交易
  5. ZAB的體系結構-ZooKeeper原子廣播協議
  6. 使用Redis的分散式鎖
  7. 分散式鎖已失效;分散式鎖萬歲!

相關文章