輕量級分散式鎖的設計原理分析與實現

房東的小黑黑發表於2020-04-05

微信公眾號:房東的小黑黑
路途隨遙遠,將來更美好
學海無涯,大家一起加油!

為什麼要設計分散式鎖

在簡單的單機系統中,當存在多個執行緒同時要修改某個共享變數時,為了資料的操作安全,往往需要通過加鎖的方法,在同一時刻同一程式碼塊只能有一個程式執行操作,存在很多加鎖的方式,比如在java中有synchronize或Lock子類等。
但是在分散式中,會存在多個主機,即會存在多個jvm, 在jvm之間資料是不能共享的,上面的方法只能在一個jvm中執行有效,在多個jvm中同一變數可能會有不同的值。所以我們要設計一種跨jvm的共享互斥機制來控制共享變數資源的訪問,這也是提出分散式鎖的初衷。

需要解決的問題

為了將分散式鎖實現較好的效能,我們需要解決下面幾個重要的問題:

  • 一個方法或程式碼片段在同一時刻只能被一個程式所執行。
  • 高可用的獲取鎖與釋放鎖功能。
  • 避免死鎖
  • 鎖只能被持有該鎖的客戶端刪除或者釋放。
  • 容錯,在伺服器當機時,鎖依然能得到釋放或者其他伺服器可以進行加鎖。

下面分別利用redis和zookeeper來實現加鎖和解鎖機制。

基於Redis的加鎖第一版

本版本通過變數sign設定鎖的唯一標識,確保只有擁有該鎖的客戶端才能刪除它,其他客戶端不能刪除。
利用阻塞鎖的思想, 通過while(System.currentTimeMillis() < endTime)Thread.sleep()相結合,在設定的規定時間內進行多次嘗試。
但是setnx操作和expire分割開了,不具有原子性,可能會出現問題。
比如說,在執行到jedis.expire時,可能系統發生了崩潰,導致鎖沒有設定過期時間,導致發生死鎖。

 public String addLockVersion1(String key, int blockTime, int expireTime) {
        if (blockTime <=0 || expireTime <= 0)
            return null;
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
            String sign = UUID.randomUUID().toString();
            String token = null;
            //設定阻塞嘗試時間
            long endTime = System.currentTimeMillis() + blockTime;
            while (System.currentTimeMillis() < endTime) {
                if (jedis.setnx(key, sign) == 1) {
                    // 新增成功,設定鎖的過期時間,防止死鎖
                    jedis.expire(key, expireTime);
                    // 在釋放鎖時用於驗證
                    token = sign;
                    return token;
                }
                //加鎖失敗,休眠一段時間,再進行嘗試。
                try {
                    Thread.sleep(DEFAULT_SLEEP_TIME);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        } catch (JedisException e) {
            e.printStackTrace();
        } finally {
            if (jedis != null)
                jedis.close();
        }
        return null;
    }
複製程式碼

基於Redis的加鎖第二版

通過設定key對應的value值為鎖的過期時間,當遇到系統崩潰,致使利用expire設定鎖過期時間失敗時,通過獲取value值,來判斷當前鎖是否過期,如果該鎖已經過期了,則進行重新獲取。

但是它也存在一些問題。當鎖過期時,如果多個程式同時執行jedis.getSet方法,雖然只有一個程式可以獲得該鎖,但是這個程式的鎖的過期時間可能被其他程式的鎖所覆蓋。
該鎖沒有設定唯一標識,也會被其他客戶端鎖釋放,不滿足只能被鎖的擁有者鎖釋放的條件。

public boolean addLockVersion2(String key, int blockTime, int expireTime) {
        if (blockTime <=0 || expireTime <= 0)
            return false;
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
            long endTime = System.currentTimeMillis() + blockTime;
            while (System.currentTimeMillis() < endTime) {
                long redisExpierTime = System.currentTimeMillis() + expireTime;
                if (jedis.setnx(key, redisExpierTime + "") == 1) {
                    jedis.expire(key, expireTime);
                    return true;
                } else {
                    String oldRedisExpierTime = jedis.get(key);
                    // 當鎖設定成功,但是沒有通過expire成功設定過期時間,但是根據存的值判斷出它實際上已經過期了
                    if (oldRedisExpierTime != null && Long.parseLong(oldRedisExpierTime) < System.currentTimeMillis()) {
                        String lastRedisExpierTime = jedis.getSet(key, System.currentTimeMillis() + blockTime + "");
                        //獲取到該鎖,沒有被其他執行緒所修改
                        if (lastRedisExpierTime.equals(oldRedisExpierTime)) {
                            jedis.expire(key, expireTime);
                            return true;
                        }
                    }
                }
                //加鎖失敗,休眠一段時間,再進行嘗試。
                try {
                    Thread.sleep(DEFAULT_SLEEP_TIME);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        } catch (JedisException e) {
            e.printStackTrace();
        } finally {
            if (jedis != null) {
                jedis.close();
            }
        }
        return false;
    }
複製程式碼

基於Redis的加鎖第三版

具體通過set方法來實現setnxexpire的相加功能,實現了原子操作。
如果key不存在時,就進行加鎖操作,並對鎖設定一個有效期,同時uniqueId表示加鎖的客戶端;如果key存在,不做任何操作。

public boolean addLockVersion3(String key, String uniqueId, int blockTime, int expireTime) {
        Jedis jedis = null;
        try {
            long endTime = System.currentTimeMillis() + blockTime;
            while (System.currentTimeMillis() < endTime) {
                jedis = jedisPool.getResource();
                String result = jedis.set(key, uniqueId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
                if (LOCK_STATE.equals(result))
                    return true;
                try {
                    Thread.sleep(DEFAULT_SLEEP_TIME);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
            return false;
        } catch (JedisException e) {
            e.printStackTrace();
        } finally {
            if (jedis != null)
                jedis.close();
        }
        return false;
    }
複製程式碼

基於Redis的加鎖第四版

為了使對同一個物件新增多次鎖,並且不發生阻塞,即實現類似可重入鎖,我們借鑑了ReetrantLock的思想,新增了變數states來控制。

public boolean addLockVersion4(String key, String uniqueId, int expireTime) {
        int state = states.get();
        if (state > 1) {
            states.set(state+1);
            return true;
        }
        return doLock(key, uniqueId, expireTime);
    }

private boolean doLock(String key, String uniqueId, int expireTime) {
        Jedis jedis = null;
        if (expireTime <= 0)
            return false;
        try {
            jedis = jedisPool.getResource();
            String result = jedis.set(key, uniqueId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
            if (LOCK_STATE.equals(result))
                states.set(states.get() + 1);
                return true;
        } catch (JedisException e) {
            e.printStackTrace();
        } finally {
            if (jedis != null)
                jedis.close();
        }
        return false;
    }
複製程式碼

基於Redis的加鎖第五版

從上面可知,利用setnxexpire實現加鎖機制時因為不是原子操作,會產生一些問題,我們可用lua指令碼來實現。

public boolean addLockVersion5(String key, String uniqueId, int expireTime) {
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
            String luaScript = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then" +
                    "redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";
            List<String> keys = new ArrayList<>();
            List<String> values = new ArrayList<>();
            keys.add(key);
            values.add(uniqueId);
            values.add(String.valueOf(expireTime));
            Object result = jedis.eval(luaScript, keys, values);
            if ((Long)result == 1L)
                return true;
        } catch (JedisException e) {
            e.printStackTrace();
        } finally {
            if (jedis != null) {
                jedis.close();
            }
        }
        return false;
    }
複製程式碼

基於Redis的釋放鎖第一版

在解鎖時首先判斷加速與解鎖是否是同一個客戶端,然後利用del方法進行刪除。
但是會出現一些問題。
當方法執行到判斷內部時,即將要執行del方法時,該鎖已經過期了,並被其他的客戶端所請求應有,此時執行del會造成鎖的誤刪。

public boolean releaseLockVersion1(String key, String uniqueId) {
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
            //加鎖與解鎖是否是同一個客戶端
            String lockId = jedis.get(key);
            if (lockId != null && lockId.equals(uniqueId)) {
                jedis.del(key);
                return true;
            }
        } catch (JedisException e) {
            e.printStackTrace();
        } finally {
            if (jedis != null)
                jedis.close();
        }
        return false;
    }
複製程式碼

基於Redis的釋放鎖第二版

從上面的分析來看,我們要確保刪除的原子性,利用lua指令碼可以保證一點。
在指令碼語言裡,KEYS[1]和ARGV[1]分別表示傳入的key名和唯一識別符號。

public boolean releaseLockVersion2(String key, String uniqueId) {
        String luaScript = "if  redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Jedis jedis = null;
        Object result = null;
        try{
            jedis = jedisPool.getResource();
            result = jedis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(uniqueId));
            if ((Long)result == 1)
                return true;
        } catch (JedisException e) {
            e.printStackTrace();
        } finally {
            if (jedis != null)
                jedis.close();
        }
        return false;
    }
複製程式碼

基於Redis的釋放鎖第三版

在利用可重入鎖思想時,只有當states=1時才能被釋放,大於0時,只能進行減1操作。

public boolean releaseLockVersion3(String key, String uniqueId) {
        int state = states.get();
        if (state > 1) {
            states.set(states.get() - 1);
            return false;
        }
        return this.doRelease(key, uniqueId);

    }
    private boolean doRelease(String key, String uniqueId) {
        String luaScript = "if  redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Jedis jedis = null;
        Object result = null;
        try{
            jedis = jedisPool.getResource();
            result = jedis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(uniqueId));
            if ((Long)result == 1)
                return true;
        } catch (JedisException e) {
            e.printStackTrace();
        } finally {
            states.set(0);
            if (jedis != null)
                jedis.close();
        }
        return false;
    }
複製程式碼

利用Zookeeper實現分散式鎖

Zookeeper提供一個多層次的節點名稱空間,每個節點都用一個以斜槓(/)分割的路徑表示,
而且每個節點都有父節點(根節點除外),非常類似於檔案系統。

基本思想流程

  • 在某父節點下新增建立一個節點,
  • 獲取該父節點下的所有子節點,並進行排序,獲得有個有序序列
  • 如果當前新增的節點是序列中序號最小的節點,表示獲取鎖成功
  • 如果不是最小的節點,則對在有序列表中的它的前一個節點進行監聽,當被監聽的節點被刪除後,會通知該節點獲取鎖。
  • 解鎖的時候刪除當前節點。

實現程式碼

public class zklock {
    private ZkClient zkClient;
    private String name;
    private String currentLockPath;
    private CountDownLatch countDownLatch;
    private static final String PATENT_LOCK_PATH = "distribute_lock";
    private static final int MAX_RETEY_TIMES = 3;
    private static final int DEFAULT_WAIT_TIME = 3;
    public zklock(ZkClient zkClient, String name) {
        this.zkClient = zkClient;
        this.name = name;
    }
    public void addLock() {
        if (!zkClient.exists(PATENT_LOCK_PATH)) {
            zkClient.createPersistent(PATENT_LOCK_PATH);
        }
        int count = 0;
        boolean iscompleted = false;
        while (!iscompleted) {
            iscompleted = true;
            try {
                //建立當前目錄下的臨時有序節點
                currentLockPath = zkClient.createEphemeralSequential(PATENT_LOCK_PATH + "/", System.currentTimeMillis());
            } catch (Exception e) {
                if (count++ < MAX_RETEY_TIMES) {
                    iscompleted = false;
                } else
                    throw  e;
            }
        }
    }
    public void releaseLock() {
        zkClient.delete(currentLockPath);
    }
    //檢查是否是最小的節點
    private boolean checkMinNode(String localPath) {
        List<String> children = zkClient.getChildren(PATENT_LOCK_PATH);
        Collections.sort(children);
        int index = children.indexOf(localPath.substring(PATENT_LOCK_PATH.length()+1));

        if (index == 0) {
            if (countDownLatch != null) {
                countDownLatch.countDown();
            }
            return true;
        } else {
            String waitPath = PATENT_LOCK_PATH + "/" + children.get(index-1);
            waitForLock(waitPath, false);
            return false;
        }

    }
    //監聽有序序列中的前一個節點
    private void waitForLock(String waitPath, boolean useTime) {
        countDownLatch = new CountDownLatch(1);
        zkClient.subscribeDataChanges(waitPath, new IZkDataListener() {
            @Override
            public void handleDataChange(String s, Object o) throws Exception {

            }

            @Override
            public void handleDataDeleted(String s) throws Exception {
                 checkMinNode(currentLockPath);
            }
        });
        if (!zkClient.exists(waitPath)) {
            return;
        }
        try {
            if (useTime == true)
                countDownLatch.await(DEFAULT_WAIT_TIME, TimeUnit.SECONDS);
            else
                countDownLatch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        countDownLatch = null;
    }

}
複製程式碼

基於Redis和Zookeeper的分散式鎖的優劣

  • Redis是nosql資料庫,主要特點是快取;
  • Zookeeper是分散式協調工具,主要用於分散式解決方案。

加鎖機制

  • Redis: 通過set方法建立key, 因為Redis的key是唯一的,誰先建立成功,誰能夠先獲得鎖。
  • Zookeeper: 會在Zookeeper上建立一個臨時節點,因為Zookeeper節點命名路徑保證唯一,只要誰先建立成功,誰能夠獲取到鎖。

釋放鎖

  • Redis: 為了確保鎖的一致性問題,在刪除的redis的key時,需要判斷是否是之前擁有該鎖的客戶端;通過設定有效期解決死鎖。
  • Zookeeper: 直接關閉臨時節點session會話連線,因為臨時節點生命週期與session會話繫結在一塊,如果session會話連線關閉的話,該臨時節點也會被刪除。

效能

redis分散式鎖,其實需要自己不斷去嘗試獲取鎖,比較消耗效能。
zk分散式鎖,獲取不到鎖,註冊個監聽器即可,不需要不斷主動嘗試獲取鎖,效能開銷較小。
另外一點就是,如果是redis獲取鎖的那個客戶端bug了或者掛了,那麼只能等待超時時間之後才能釋放鎖;而zk的話,因為建立的是臨時znode,只要客戶端掛了,znode就沒了,此時就自動釋放鎖。

輕量級分散式鎖的設計原理分析與實現

相關文章