細說分佈式鎖

一、使用場景

目前幾乎很多大型網站及應用都是分佈式部署的,分佈式場景中的數據一致性問題一直是一個比較重要的話題。分佈式的CAP理論告訴我們“任何一個分佈式系統都無法同時滿足一致性(Consistency)、可用性(Availability)和分區容錯性(Partition tolerance),最多隻能同時滿足兩項。”所以,很多系統在設計之初就要對這三者做出取捨。在互聯網領域的絕大多數的場景中,都需要犧牲強一致性來換取系統的高可用性,系統往往只需要保證“最終一致性”,只要這個最終時間是在用戶可以接受的範圍內即可。

在很多場景中,我們爲了保證數據的最終一致性,需要很多的技術方案來支持,比如分佈式事務、分佈式鎖等。有的時候,我們需要保證一個方法在同一時間內只能被同一個線程執行。在單機環境中,Java中其實提供了很多併發處理相關的API,但是這些API在分佈式場景中就無能爲力了。也就是說單純的Java Api並不能提供分佈式鎖的能力。

分佈式鎖有以下幾個特點:

  1. 互斥性:和我們本地鎖一樣互斥性是最基本的,但是分佈式鎖需要保證在不用節點的不同線程的互斥。
  2. 可重入性:同一個節點上的同一個線程如果獲取鎖之後那麼也可以再次獲取這個鎖。
  3. 鎖超時:和本地鎖一樣支持鎖超時,防止死鎖。
  4. 高效,高可用:加鎖和解鎖需要高效,同時也需要保證高可用防止分佈式鎖失效,可以增加降級。
  5. 支持阻塞和非阻塞:和 ReentrantLock 一樣支持 lock 和 trylock 以及 tryLock(long timeOut)。
  6. 支持公平鎖和非公平鎖(可選):公平鎖的意思是按照請求加鎖的順序獲得鎖,非公平鎖就相反是無序的。這個一般來說實現的比較少。

常見的分佈式鎖:
我們一般實現分佈式鎖有以下幾個方式:
1.MySQL
2.ZK
3.Redis
4.自研分佈式鎖,如谷歌的Chubby

二、mysql數據庫的實現

2.1利用mysql的隔離性:唯一索引

創建一張鎖表

CREATE TABLE `methodLock` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
  `method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '鎖定的方法名',
  `desc` varchar(1024) NOT NULL DEFAULT '備註信息',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存數據時間,自動生成',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='鎖定中的方法';

當我們想要獲取鎖的時候,執行以下sql:

insert into methodLock(method_name,desc) values (‘method_name’,desc)

因爲我們對method_name字段做了唯一性約束,所以如果有多個插入操作,數據庫只會保證一個成功其他的拋出異常,我們可以認爲操作成功的那個線程獲得鎖,可以執行方法體的內容。
當我們想釋放鎖的時候,需要執行以下sql:

delete from methodLock where method_name ='method_name'

2.2基於數據庫排他鎖

還用剛剛創建的那張表,可以通過數據庫的排他鎖來實現分佈式鎖。

public boolean lock(){
    connection.setAutoCommit(false)
    while(true){
        try{
            result = select * from methodLock where method_name=xxx for update;
            if(result==null){
                return true;
            }
        }catch(Exception e){

        }
        sleep(1000);
    }
    return false;
}

在查詢語句後面加for update,數據庫會在查詢過程中給數據庫加排他鎖(InnoDB引擎在加鎖的時候,只有通過索引進行檢索的時候纔會使用行級鎖,否則會使用表級鎖)。當某條記錄被加上排他鎖之後,其他線程無法再在該行記錄上增加排他鎖。
獲得排他鎖的線程即可獲得分佈式鎖,再通過以下方法解鎖。

public void unlock(){
    connection.commit();
}

2.3version樂觀鎖

樂觀鎖與前面最大的區別在於基於CAS思想,是不具有互斥性,不會產生鎖等待而消耗資源,操作過程中認爲不存在併發衝突,只有update version失敗後才能覺察到。一般搶購、秒殺就是用了這種實現以防止超賣。
通過增加遞增的版本號字段實現樂觀鎖
select …,version
update table set vesion+1 where version=XX

優點:
直接藉助數據庫,實現簡單。
缺點:
數據庫是單點,不夠可靠。
鎖沒有失效時間
影響數據庫性能
使用數據庫的行級鎖不一定靠譜,尤其當鎖表並不大的時候。

三、Redis的實現

Redis可以利用命令Setnx()來實現分佈式鎖,性能是最好的,但是可靠性沒有zookeeper好,而且通過超時時間來控制鎖的失效時間並不可靠。
還可以通過Lua腳本來釋放鎖,這種分佈式鎖在redis sentinel集羣情況下並不靠譜。
具體可以參考文章https://www.cnblogs.com/demingblog/p/9542124.html#%E9%94%81%E8%B6%85%E6%97%B6

package cn.sp.lock;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.core.RedisOperations;
import org.springframework.data.redis.core.SessionCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

/**
 * Created by 2YSP on 2019/1/26.
 */
@Component
public class RedisLock {

    @Autowired
    private StringRedisTemplate redisTemplate;


    /**
     * 獲取鎖
     * @param lockName
     * @param requireTimeOut
     * @return
     */
    public String requireLock(String lockName,long requireTimeOut){
        String key = "lock:"+lockName;
        String identifier = UUID.randomUUID().toString();
        long end = System.currentTimeMillis() + requireTimeOut;
        while (System.currentTimeMillis() < end){
            Boolean success = redisTemplate.opsForValue().setIfAbsent(key, identifier);
            if (success){
                return identifier;
            }

            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        return null;
    }

    /**
     * 釋放鎖
     * @param lockName
     * @param identifier
     * @return
     */
    public boolean releaseLock(String lockName,String identifier){
        String key = "lock:"+lockName;
        while (true){
            redisTemplate.watch(key);
            if (identifier.equals(redisTemplate.opsForValue().get(key))){
                //檢查是否還未釋放
                SessionCallback<Object> sessionCallback = new SessionCallback<Object>() {
                    @Nullable
                    @Override
                    public  Object execute(RedisOperations operations) throws DataAccessException {
                        operations.multi();
                        operations.delete(key);
                        List obj = operations.exec();
                        return obj;
                    }
                };
                Object object = redisTemplate.execute(sessionCallback);
                if (object != null) {
                    return true;
                }
                continue;
            }
            redisTemplate.unwatch();
            break;
        }
        return false;
    }

    /**
     *
     * @param lockName
     * @param requireTimeOut
     * @return
     */
    public String requireLockWithTimeOut(String lockName,long requireTimeOut,long lockTimeOut){
        String key = "lock:"+lockName;
        String identifier = UUID.randomUUID().toString();
        long end = System.currentTimeMillis() + requireTimeOut;
        int lockExpire = (int) (lockTimeOut/1000);
        while (System.currentTimeMillis() < end){
            Boolean success = redisTemplate.opsForValue().setIfAbsent(key, identifier);
            if (success){
                //設置過期時間
                redisTemplate.expire(key,lockExpire, TimeUnit.SECONDS);
                return identifier;
            }

            if (redisTemplate.getExpire(key,TimeUnit.SECONDS) == -1){
                redisTemplate.expire(key,lockExpire, TimeUnit.SECONDS);
            }
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        return null;
    }
}

四、zookeeper的實現

zookeeper實現分佈式鎖的原理主要是利用順序臨時節點的特性。
獲取鎖
所有客戶端都試圖創建同一個臨時節點A,zookeeper會保證所有客戶端中只有一個能創建成功,那麼就可以認爲該客戶端獲得了鎖,其他客戶端就要到臨時節點A上註冊一個子節點變更的Watcher監聽。
釋放鎖
以下兩種情況,都可能釋放鎖:

  1. 當前獲取鎖的客戶端機器發生宕機,那麼zookeeper上的這個臨時節點就會被刪除。
  2. 正常業務邏輯執行完後,客戶端會主動將自己創建的臨時節點刪除。

無論什麼情況移除了節點A,ZooKeeper都會通知所有在該節點上註冊了子節點變更Watcher的客戶端,這些客戶端在接收到通知後,再次重新發起分佈式鎖獲取。

我是使用的開源客戶端Curator實現的
pom.xml

<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>

		<!-- https://mvnrepository.com/artifact/org.apache.curator/curator-recipes -->
		<dependency>
			<groupId>org.apache.curator</groupId>
			<artifactId>curator-recipes</artifactId>
			<version>2.11.0</version>
		</dependency>
	</dependencies>

HelloController.java

package cn.sp.controller;

import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * Created by 2YSP on 2019/1/27.
 */
@RestController
@RequestMapping("/zoo")
public class HelloController {

    public final Logger log = LoggerFactory.getLogger(this.getClass());

    @Autowired
    CuratorFramework client;

    @RequestMapping("/hello")
    public String hello(){
        final InterProcessMutex lock = new InterProcessMutex(client, "/lock");
        try {
            lock.acquire();
        } catch (Exception e) {
            e.printStackTrace();
            return "error";
        }
        log.info("{} 獲取鎖成功",Thread.currentThread().getName());
        System.out.println("執行業務邏輯。。。。");
        try {
            lock.release();
        } catch (Exception e) {
            e.printStackTrace();
        }
        log.info("{} 釋放鎖成功",Thread.currentThread().getName());
        return "OK";
    }
}

ZookeeperLockApplication.java

@SpringBootApplication
public class ZookeeperLockApplication {

	public static void main(String[] args) {
		SpringApplication.run(ZookeeperLockApplication.class, args);
	}

	@Bean
	public CuratorFramework client(){
		CuratorFramework client = CuratorFrameworkFactory.builder()
				.connectString("198.13.40.234:2181,198.13.40.234:2182,198.13.40.234:2183")
				.retryPolicy(new ExponentialBackoffRetry(1000, 3)).build();
		client.start();
		return client;
	}

}

然後使用ab測試訪問即可。

注意:
Curator的版本要跟ZooKeeper的版本對應好,不然會報錯。

Curator 2.x.x - compatible with both ZooKeeper 3.4.x and ZooKeeper 3.5.x

Curator 3.x.x - compatible only with ZooKeeper 3.5.x and includes support for new features such as dynamic reconfiguration, etc.

zookeeper有較好的性能和可靠性,但是性能不如Redis,主要原因是寫操作(獲取鎖釋放鎖)都需要在Leader上進行,然後同步至follower。

五、總結

相關代碼已上傳至github,點擊這裏訪問

從理解的難易程度角度(從低到高)數據庫 > 緩存 > Zookeeper
從實現的複雜性角度(從低到高)Zookeeper >= 緩存 > 數據庫
從性能角度(從高到低)緩存 > Zookeeper >= 數據庫
從可靠性角度(從高到低)Zookeeper > 緩存 > 數據庫

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章