分散式鎖的實現方案

opentest-oper@360.cn發表於2021-03-11

分散式鎖的實現方案

什麼是分散式鎖

當多個程序在同一個系統中,用分散式鎖控制多個程序對資源的訪問

分散式鎖應用場景

  1. 傳統的單體應用單機部署情況下,可以使用 java 併發處理相關的 API 進行互斥控制。
  2. 分散式系統後由於多執行緒,多程序分佈在不同機器上,使單機部署情況下的併發控制鎖策略失效,為了解決跨 JVM 互斥機制來控制共享資源的訪問,這就是分散式鎖的來源;分散式鎖應用場景大都是高併發、大流量場景。

分散式鎖實現

1、基於 redis 的分散式鎖

redis 分散式鎖的實現

  1. 加鎖機制:根據 hash 節點選擇一個客戶端執行 lua 指令碼

  2. 鎖互斥機制:再來一個客戶端執行同樣的 lua 指令碼會提示已經存在鎖,然後進入迴圈一直嘗試加鎖

  3. 可重入機制

  4. watch dog 自動延期機制

  5. 釋放鎖機制

測試用例

單機

private RedissonClient getClient(){
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");//.setPassword("");//.setConnectionMinimumIdleSize(10).setConnectionPoolSize(10);//.setConnectionPoolSize();//172.16.10.164
        RedissonClient redissonClient = Redisson.create(config);
        return redissonClient;
    }
    private ExecutorService executorService = Executors.newCachedThreadPool();
    @Test
    public void test() throws Exception {
        int[] count = {0};
        for (int i = 0; i < 10; i++) {
            RedissonClient client = getClient();
            final RedisLock redisLock = new RedisLock(client,"lock_key");
            executorService.submit(() -> {
                try {
                    redisLock.lock();
                    count[0]++;
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    try {
                        redisLock.unlock();
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            });
        }
        executorService.shutdown();
        executorService.awaitTermination(1, TimeUnit.HOURS);
        System.out.println(count[0]);
    }

RedLock

public static RLock create (String url, String key){
       Config config = new Config();
       config.useSingleServer().setAddress(url);
       RedissonClient redissonClient = Redisson.create(config);
       return redissonClient.getLock(key);
   }

   RedissonRedLock redissonRedLock = new RedissonRedLock(
           create("redis://redis://127.0.0.1:6379","lock_key1"),
           create("redis://redis://127.0.0.1:6380","lock_key2"),
           create("redis://redis://127.0.0.1:6381","lock_key3"));
   RedisRedLock redLock = new RedisRedLock(redissonRedLock);

   private ExecutorService executorService = Executors.newCachedThreadPool();

  @Test
   public void test() throws Exception {
       int[] count = {0};
       for (int i = 0; i < 2; i++) {
           executorService.submit(() -> {
               try {
                   redLock.lock();
                   count[0]++;
               } catch (Exception e) {
                   e.printStackTrace();
               } finally {
                   try {
                       redLock.unlock();
                   } catch (Exception e) {
                       e.printStackTrace();
                   }
               }
           });
       }
       executorService.shutdown();
       executorService.awaitTermination(1, TimeUnit.HOURS);
       System.out.println(count[0]);
   }

2、基於 ETCD 實現分散式鎖分析

ETCD 分散式鎖的實現

  1. Lease 機制:租約機制(TTL,Time To Live),Etcd 可以為儲存的 key-value 對設定租約,
    當租約到期,key-value 將失效刪除;同時也支援續約,透過客戶端可以在租約到期之前續約,
    以避免 key-value 對過期失效。Lease 機制可以保證分散式鎖的安全性,為鎖對應的 key 配置租約,
    即使鎖的持有者因故障而不能主動釋放鎖,鎖也會因租約到期而自動釋放。

  2. Revision 機制:每個 key 帶有一個 Revision 號,每進行一次事務加一,它是全域性唯一的,
    透過 Revision 的大小就可以知道進行寫操作的順序。在實現分散式鎖時,多個客戶端同時搶鎖,
    根據 Revision 號大小依次獲得鎖,可以避免 “羊群效應” ,實現公平鎖。

  3. Prefix 機制:即字首機制。例如,一個名為 /etcdlock 的鎖,兩個爭搶它的客戶端進行寫操作,
    實際寫入的 key 分別為:key1="/etcdlock/UUID1",key2="/etcdlock/UUID2",
    其中,UUID 表示全域性唯一的 ID,確保兩個 key 的唯一性。寫操作都會成功,但返回的 Revision 不一樣,
    那麼,如何判斷誰獲得了鎖呢?透過字首 /etcdlock 查詢,返回包含兩個 key-value 對的的 KeyValue 列表,
    同時也包含它們的 Revision,透過 Revision 大小,客戶端可以判斷自己是否獲得鎖。

  4. Watch 機制:即監聽機制,Watch 機制支援 Watch 某個固定的 key,也支援 Watch 一個範圍(字首機制),
    當被 Watch 的 key 或範圍發生變化,客戶端將收到通知;在實現分散式鎖時,如果搶鎖失敗,
    可透過 Prefix 機制返回的 KeyValue 列表獲得 Revision 比自己小且相差最小的 key(稱為 pre-key),
    對 pre-key 進行監聽,因為只有它釋放鎖,自己才能獲得鎖,如果 Watch 到 pre-key 的 DELETE 事件,
    則說明 pre-key 已經釋放,自己已經持有鎖。

基於 ETCD 分散式鎖

步驟 1、建立連線

客戶端連線 Etcd,以 /etcd/lock 為字首建立全域性唯一的 key,
假設第一個客戶端對應的 key="/etcd/lock/UUID1",第二個為 key="/etcd/lock/UUID2";
客戶端分別為自己的 key 建立租約 - Lease,租約的長度根據業務耗時確定;

步驟 2、建立定時任務作為租約的 “心跳”

當一個客戶端持有鎖期間,其它客戶端只能等待,為了避免等待期間租約失效,客戶端需建立一個定時任務作為 “心跳” 進行續約。此外,如果持有鎖期間客戶端崩潰,心跳停止,key 將因租約到期而被刪除,從而鎖釋放,避免死鎖。

步驟 3、客戶端將自己全域性唯一的 key 寫入 Etcd

執行 put 操作,將步驟 1 中建立的 key 繫結租約寫入 Etcd,根據 Etcd 的 Revision 機制,假設兩個客戶端 put 操作返回的 Revision 分別為 1、2,客戶端需記錄 Revision 用以接下來判斷自己是否獲得鎖

步驟 4、客戶端判斷是否獲得鎖

客戶端以字首 /etcd/lock/ 讀取 keyValue 列表,判斷自己 key 的 Revision 是否為當前列表中最小的,如果是則認為獲得鎖;否則監聽列表中前一個 Revision 比自己小的 key 的刪除事件,一旦監聽到刪除事件或者因租約失效而刪除的事件,則自己獲得鎖。

步驟 5、執行業務

獲得鎖後,操作共享資源,執行業務程式碼

步驟 6、釋放鎖

完成業務流程後,刪除對應的 key 釋放鎖

測試用例

public class EtcdDistributeLock extends AbstractLock{

    private Client client;
    private Lock lockClient;
    private Lease leaseClient;
    private String lockKey;
    private String lockPath;
    /** 鎖的次數 */
    private AtomicInteger lockCount;
    /** 租約有效期,防止客戶端崩潰,可在租約到期後自動釋放鎖;另一方面,正常執行過程中,會自動進行續租,單位 ns */
    private Long leaseTTL;
    /** 續約鎖租期的定時任務,初次啟動延遲,單位預設為 s,預設為1s,可根據業務定製設定*/
    private Long initialDelay = 0L;
    /** 定時任務執行緒池類 */
    ScheduledExecutorService service = null;
    /** 儲存執行緒與鎖物件的對映,鎖物件包含重入次數,重入次數的最大限制為Int的最大值 */
    private final ConcurrentMap<Thread, LockData> threadData = Maps.newConcurrentMap();

    public EtcdDistributeLock(){}

    public EtcdDistributeLock(Client client, String lockKey, long leaseTTL,TimeUnit unit){
        this.client = client;
        lockClient = client.getLockClient();
        leaseClient = client.getLeaseClient();
        this.lockKey = lockKey;
        // 轉納秒
        this.leaseTTL = unit.toNanos(leaseTTL);
        service = Executors.newSingleThreadScheduledExecutor();
    }


    @Override
    public void lock() {
        // 檢查重入性
        Thread currentThread = Thread.currentThread();
        LockData oldLockData = threadData.get(currentThread);
        if (oldLockData != null && oldLockData.isLockSuccess()) {
            // re-entering
            int lockCount = oldLockData.lockCount.incrementAndGet();
            if(lockCount < 0 ){
                throw new Error("超出可重入次數限制");
            }
            return;
        }

        // 記錄租約 ID
        Long leaseId = 0L;
        try{
            leaseId = leaseClient.grant(TimeUnit.NANOSECONDS.toSeconds(leaseTTL)).get().getID();
            // 續租心跳週期
            long period = leaseTTL - leaseTTL / 5;
            // 啟動定時任務續約
            service.scheduleAtFixedRate(new EtcdDistributeLock.KeepAliveRunnable(leaseClient, leaseId),
                    initialDelay,period,TimeUnit.NANOSECONDS);
            LockResponse lockResponse = lockClient.lock(ByteSequence.from(lockKey.getBytes()), leaseId).get();
            if(lockResponse != null){
                lockPath = lockResponse.getKey().toString(Charset.forName("utf-8"));
                log.info("獲取鎖成功,鎖路徑:{},執行緒:{}",lockPath,currentThread.getName());
            }
        }catch (InterruptedException | ExecutionException e){
            log.error("獲取鎖失敗",e);
            return;
        }
        // 獲取鎖成功,鎖物件設定
        LockData newLockData = new LockData(currentThread, lockKey);
        newLockData.setLeaseId(leaseId);
        newLockData.setService(service);
        threadData.put(currentThread, newLockData);
        newLockData.setLockSuccess(true);
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {
        super.lockInterruptibly();
    }

    @Override
    public boolean tryLock() {
        return super.tryLock();
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return super.tryLock(time,unit);
    }


    @Override
    public void unlock() {
        Thread currentThread = Thread.currentThread();
        LockData lockData = threadData.get(currentThread);
        if (lockData == null){
            throw new IllegalMonitorStateException("You do not own the lock: " + lockKey);
        }
        int newLockCount = lockData.lockCount.decrementAndGet();
        if ( newLockCount > 0 ) {
            return;
        }
        if ( newLockCount < 0 ) {
            throw new IllegalMonitorStateException("Lock count has gone negative for lock: " + lockKey);
        }
        try {
            // 釋放鎖
            if(lockPath != null){
                lockClient.unlock(ByteSequence.from(lockPath.getBytes())).get();
            }
            if(lockData != null){
                // 關閉定時任務
                lockData.getService().shutdown();
                // 刪除租約
                if (lockData.getLeaseId() != 0L) {
                    leaseClient.revoke(lockData.getLeaseId());
                }
            }
        } catch (InterruptedException | ExecutionException e) {
            log.error("解鎖失敗",e);
        }finally {
            // 移除當前執行緒資源
            threadData.remove(currentThread);
        }
    }


    @Override
    public Condition newCondition() {
        return super.newCondition();
    }

    /**
     * 心跳續約執行緒類
     */
    public static class KeepAliveRunnable implements Runnable {
        private Lease leaseClient;
        private long leaseId;

        public KeepAliveRunnable(Lease leaseClient, long leaseId) {
            this.leaseClient = leaseClient;
            this.leaseId = leaseId;
        }

        @Override
        public void run() {
            // 對該leaseid進行一次續約
            leaseClient.keepAliveOnce(leaseId);
        }
    }

public class EtcdLockTest {
    private Client client;
    private String key = "/etcd/lock";
    private static final String server = "http://xxxx:xxxx";
    private ExecutorService executorService = Executors.newFixedThreadPool(10000);

    @Before
    public void before() throws Exception {
        initEtcdClient();
    }

    private void initEtcdClient(){
       client = Client.builder().endpoints(server).build();
    }

    @Test
    public void testEtcdDistributeLock() throws InterruptedException {
        int[] count = {0};
        for (int i = 0; i < 100; i++) {
            executorService.submit(() -> {
                final EtcdDistributeLock lock = new EtcdDistributeLock(client, key,20,TimeUnit.SECONDS);
                try {
                    lock.lock();
                    count[0]++;
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    try {
                        lock.unlock();
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            });
        }
        executorService.shutdown();
        executorService.awaitTermination(1, TimeUnit.HOURS);
        System.err.println("執行結果: " + count[0]);
    }
}

3、基於 Zookeeper 分散式鎖

實現原理

  1. 啟動客戶端,確認連結到了伺服器
  2. 多個客戶端併發的在特定路徑下建立臨時性順序節點
  3. 客戶端判斷自己的建立的順序節點是否是最小的,如果是最小的,則獲取鎖成功
  4. 第三步若判定失敗,則採用 zk 的 watch 機制監聽自己的前一個順序節點,等待前一個節點的刪除(放鎖)事件,再開始第三步判定。

zookeeper 作為高效能分散式協調框架,可以把其看做一個檔案系統,其中有節點的概念,並且分為 4 種:1.永續性節點 2.永續性順序節點 3.臨時性節點 4.臨時性順序節點。
分散式鎖的實現主要思路就是:監控其他客戶端的狀態,來判斷自己是否可以獲得鎖。
採用臨時性順序節點的原因:

  1. zk 伺服器維護了客戶端的會話有效性,當會話失效的時候,其會話所建立的臨時性節點都會被刪除,透過這一特點,可以透過 watch 臨時節點來監控其他客戶端的情況,方便自己做出相應動作。
  2. 因為 zk 對寫操作是順序性的,所以併發建立的順序節點會有一個唯一確定的序號,當前鎖是公平鎖的一種實現,所以依靠這種順序性可以很好的解釋—節點序列小的獲取到鎖並且可以採用 watch 自己的前一個節點來避免驚群現象(這樣 watch 事件的傳播是線性的)。

測試用例

public class ZKLock extends AbstractLock {

    /**
     *     1.Connect to zk
     */
    private CuratorFramework client;

    private InterProcessLock lock ;


    public  ZKLock(String zkAddress,String lockPath) {
        // 1.Connect to zk
        client = CuratorFrameworkFactory.newClient(
                zkAddress,
                new RetryNTimes(5, 5000)
        );
        client.start();
        if(client.getState() == CuratorFrameworkState.STARTED){
            log.info("zk client start successfully!");
            log.info("zkAddress:{},lockPath:{}",zkAddress,lockPath);
        }else{
            throw new RuntimeException("客戶端啟動失敗。。。");
        }
        this.lock = defaultLock(lockPath);
    }

    private InterProcessLock defaultLock(String lockPath ){
       return  new InterProcessMutex(client, lockPath);
    }
    @Override
    public void lock() {
        try {
            this.lock.acquire();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public boolean tryLock() {
        boolean flag ;
        try {
            flag=this.lock.acquire(0,TimeUnit.SECONDS);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        return flag;
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        boolean flag ;
        try {
            flag=this.lock.acquire(time,unit);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        return flag;
    }

    @Override
    public void unlock() {
        try {
            this.lock.release();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

}
private ExecutorService executorService = Executors.newCachedThreadPool();


   @Test
   public void testLock() throws Exception{
       ZKLock zkLock = new ZKLock("xxxx:xxxx","/lockPath");
       int[] num = {0};
       long start = System.currentTimeMillis();
       for(int i=0;i<200;i++){
           executorService.submit(()->{
               try {
                   zkLock.lock();
                   num[0]++;
               } catch (Exception e){
                   throw new RuntimeException(e);
               } finally {
                   zkLock.unlock();
               }
           });

       }
       executorService.shutdown();
       executorService.awaitTermination(1, TimeUnit.HOURS);
       log.info("耗時:{}",System.currentTimeMillis()-start);
       System.out.println(num[0]);
   }

總結

  1. redis 的分散式鎖中 redisson 一般為單例項,當單例項不可用時,會阻塞業務流程。主從方式、主從資料非同步,會存在鎖失效的問題。RedLock 一般要求至少 3 臺以上的 redis 主從例項,維護成本相對來說比較高。
  2. ZK 鎖具備高可用、可重入、阻塞鎖特性,可解決失效死鎖問題。但是因為需要頻繁的建立和刪除節點,效能上不如 Redis 方式。
  3. ETCD 分散式鎖的實現原理與 zk 鎖類似,但是 ETCD 分散式鎖更加可靠強大。其 Lease 功能保證分散式鎖的安全性;watch 功能支援監聽某個固定的 key,也支援 watch 一個範圍的 key(字首機制);revision 功能可透過 Revision 的大小就可以知道進行寫操作的順序。可以避免 “羊群效應”(也稱 “驚群效應”),實現公平鎖。字首機制與 watch 功能配合使用解決了死鎖問題。總之 ETCD 的靈感來源於 Zookeeper,但實現的時候做了很多的改進,如:高負載下的穩定讀寫、資料模型的多版本併發控制、穩定的 watch 功能,通知訂閱者監聽值得變化、可以容忍腦裂現場的發生、客戶端的協議使用 gRPC 協議,支援 go、c++、java 等。
如果覺得我的文章對您有用,請隨意打賞。您的支援將鼓勵我繼續創作!
打賞支援
暫無回覆。

相關文章