由淺入深詳解四種分散式鎖

2023-04-19 12:02:22

在多執行緒環境下,為了保證資料的執行緒安全,鎖保證同一時刻,只有一個可以存取和更新共用資料。在單機系統我們可以使用synchronized鎖或者Lock鎖保證執行緒安全。synchronized鎖是Java提供的一種內建鎖,在單個JVM程序中提供執行緒之間的鎖定機制,控制多執行緒並行。只適用於單機環境下的並行控制:

但是如果想要鎖定多個節點服務,synchronized就不適用於了:

想要在多個節點中提供鎖定,在分散式系統並行控制共用資源,確保同一時刻只有一個存取可以呼叫,避免多個呼叫者競爭呼叫和資料不一致問題,保證資料的一致性

分散式鎖就是控制分散式系統不同程序存取共用資源的一種鎖的機制。不同程序之間呼叫需要保持互斥性,任意時刻,只有一個使用者端能持有鎖。

從單體鎖到分散式鎖,只不過是將鎖的物件從一個程序的多個執行緒,轉成多個程序。

共用資源包含:

  • 資料庫
  • 檔案硬碟
  • 共用記憶體

實現思路

分散式鎖的加鎖和解鎖是使用不同的數值來表示不同的狀態,比如0表示空閒狀態。

  • 加鎖

    • 加鎖時,判斷鎖是否空閒,如果空閒,修改狀態為1表示已加鎖,返回成功。
    • 如果不為空閒狀態0,則返回失敗,表示沒有獲取到鎖。
  • 解鎖

    • 將鎖狀態修改為空閒狀態0

以上的加鎖和解鎖操作,都要保證是一個原子操作

分散式鎖特性

1. 互斥性

分散式鎖最基本的特性,同一時刻只能一個節點服務擁有該鎖,當有節點獲取鎖之後,其他節點無法獲取鎖,不同節點之間具有互斥性。

2. 超時機制

不考慮異常,正常情況下,請求獲取鎖之後,處理任務,處理完成之後釋放鎖。但是如果在處理任務發生服務異常,或者網路異常時,導致鎖無法釋放。其他請求都無法獲取鎖,變成死鎖。

為了防止鎖變成死鎖,需要設定鎖的超時時間。過了超時時間後,鎖自動釋放,其他請求能正常獲取鎖。

3. 自動續期

鎖設定了超時機制後,如果持有鎖的節點處理任務的時候過長超過了超時時間,就會發生執行緒未處理完任務鎖就被釋放了,其他執行緒就能獲取到該鎖,導致多個節點同時存取共用資源。對此,就需要延長超時時間。

開啟一個監聽執行緒,定時監聽任務,監聽任務執行緒還存活就延長超時時間。當任務完成、或者任務發生異常就不繼續延長超時時間。

分散式實現

分散式主要有三種實現:

  • 資料庫
  • Zookeeper
  • Redis

通過模擬客戶下單操作。

  • 先判斷庫存是否充足
    • 如果充足,先扣庫存,再新增訂單。
    • 如果不足就提示庫存不夠。

先建立訂單表和商品庫存表:

--庫存表-- 
create table t_product(
    `id` bigint(20) not null auto_increment,
    `name` varchar(64) not null comment "商品名",
    `store` int default 0 comment "庫存",
    primary key(`id`)
)

insert into `t_product` values (1, '紅米手機', 100);

-- 訂單表 --
create table t_order(
  `id` bigint(20) not null auto_increment,
  `sn` varchar(64) not null comment '訂單號',
  `num` int default null comment '數量',
  `price` int default null comment '單價',
  `product_id` bigint default null comment '商品id',
  `create_time` timestamp not null default CURRENT_TIMESTAMP comment '建立時間',
  primary key(`id`)
)

為了查詢時防止幻讀,我們還需要保證查詢和插入是在同一個事務中。下單先判斷是否有庫存,有庫存就減庫存1,再新增訂單。主要程式碼如下:

@Transactional
public void addOrder(Order order) throws Exception {
    Product product = productDao.selectById(order.getProductId());
    int store = product.getStore() - 1;
    if (store >= 0) {
        // 扣庫存   
        product.setStore(store);
        productDao.updateByPrimaryKey(product);
        // 新增訂單
        orderDao.insert(order);
    } else {
        throw new Exception("哎呦喂,庫存不足");
    }
}

其中查詢庫存方法productDao.selectByIdSQL語句是:

select id,name,store from t_product where id = xxx

使用壓測工具apache ab開啟多個執行緒,請求50次:

ab -n 10 -c 2 http://127.0.0.1:8080/xxxx

壓測結果:

庫存剩餘:72,訂單數量:50

**新增了 50 條訂單,庫存只扣了 28 **。

這是因為在並行環境下,多個執行緒下單操作,前面的執行緒還未更新庫存,後面的執行緒已經請求進來,並獲取到了未更新的庫存,後續扣減庫存都不是扣減最近的庫存。執行緒越多,扣減的庫存越少。這就是在高並行場景下發生的超賣問題

1. 資料庫實現分散式鎖

Mysql資料庫可以使用select xxx for update來實現分散式鎖。

for update是一種行級鎖,也叫排它鎖。如果一條select語句後面加上for update,其他事務可以讀取,但不能進進行更新操作。

將上面查詢庫存productDao.selectById方法的SQL語句後面加上for update:

select id,name,store from t_product where id = xxx for update

再使用apache ab開啟多個執行緒,請求50次:

ab -n 10 -c 2 http://127.0.0.1:8080/xxxx

壓測結果:

庫存剩餘:50,訂單數量:50

資料庫成功實現分散式鎖

使用for update行級鎖可以實現分散式鎖,通過行級鎖鎖住庫存,where後條件一定要走索引,不然會觸發表鎖,會降低MySQL的效能。

不過基於MySQL實現的分散式鎖,存在效能瓶頸,在Repeatable read隔離級別下select for update操作是基於間隙鎖鎖實現,這是一種悲觀鎖,會存線上程阻塞問題。

當有大量的執行緒請求的情況下,大部分請求會被阻塞等待,後續的請求只能等前面的請求結束後,才能排隊進來處理。

Zookeeper 實現分散式鎖

資料庫實現分散式鎖存在效能瓶頸,無法支撐高並行的請求。可以使用Zookeeper實現分散式鎖,Zookeeper提供一種分散式服務協調的中心化服務,而分散式鎖的實現是基於Zookeeper的兩個特性。

順序臨時節點:

Zookeeper 資料模型znode是以多層節點命名的空間,每個節點都用斜槓/分開的路徑來表示,類似檔案系統的目錄。

節點型別分成持久節點臨時節點,每個節點還可以標記有序性。一旦節點被標記為有序性,那整個節點就有自動遞增的特點。利用以上的特性,建立一個持久節點作為父節點,在父節點下面建立一個臨時節點,並標記該臨時節點為有序性

Watch 機制:

Zookeeper 還提供了另一個重要的特性:Watch(事件監聽器),在指定節點的上註冊監聽事件。當事件觸發時,會將事件通知給對應的客戶。

瞭解了Zookeeper的兩個特性之後,那如何使用這兩種特性來實現分散式鎖呢?

首先,建立一個持久型別的父節點,當用戶請求時,就在父節點建立臨時型別的子節點,並標記臨時節點為有序性。

建立子節點之後,對父節點下面所有臨時節點進行排序,判斷剛建立的臨時節點是否是最小的節點,如果是最小的節點,就獲取鎖。如果不最小的節點,則等待鎖,並且獲取該節點上一個順序節點,併為其註冊監聽事件,等待觸發事件並獲得鎖。

當請求完畢後,刪除該節點,並觸發監聽事件,下一個順序節點獲得鎖,流程如下所示:

curator將上面實現分散式鎖的思路封裝好了,直接呼叫即可。

引入curator依賴:

<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-recipes</artifactId>
    <version>4.3.0</version>
</dependency>

使用InterProcessMutex分散式可重入排它鎖,一般流程如下:

InterProcessMutex lock = new InterProcessMutex(client, lockPath);
// 加鎖
interProcessMutex.acquire();
// 執行程式碼xxxxxxx
// 解鎖
interProcessMutex.release();

為了避免每次請求都要建立InterProcessMutex範例,建立InterProcessMutexbean:

private String address = "xxxxx";

@Bean
public InterProcessMutex interProcessMutex() {
    CuratorFramework zkClient = getZkClient();
    String lockPath = "/lock";
    InterProcessMutex lock = new InterProcessMutex(zkClient,lockPath);
    return lock;
}

private CuratorFramework getZkClient() {
    ExponentialBackoffRetry retry = new ExponentialBackoffRetry(1000,3,5000);
    CuratorFramework zkClient = CuratorFrameworkFactory.builder()
            .connectString(address)
            .sessionTimeoutMs(5000)
            .connectionTimeoutMs(5000)
            .retryPolicy(retry).build();
    zkClient.start();
    return zkClient;
}

在高並行場景下,多個使用者請求系統,並獲取臨時節點順序:

使用interProcessMutex獲取鎖和釋放鎖:

  • 獲取鎖 interProcessMutex.acquire()
  • 釋放鎖 interProcessMutex.release()

請求介面如下:

@RestController
public class Controller {
    @Autowired
    private InterProcessMutex interProcessMutex;
    
    @GetMapping("/sec-kill")
    public String secKill() throws Exception {
        // 獲取鎖
        interProcessMutex.acquire();
        // 扣減庫存,建立訂單等操作....
        interProcessMutex.release();
        return "ok";
    }
}

如果獲取鎖之後,系統發生異常,系統就一直持有鎖,後續請求也無法獲取鎖,導致死鎖。需要設定鎖超時機制,interProcessMutex.acquire新增超時時間:

interProcessMutex.acquire(watiTime,TimeUnit);

超時時間設定要根據業務執行時間來設定,不能太長,也不能太短。

Zookeeper一些特點

  • Zookeeper實現的分散式鎖,相對資料庫,效能有很大的提高。
  • Zookeeper設定叢集,發生單點故障時、或者系統掛掉時,臨時節點會因為 session 連線斷開而自動刪除。
  • 頻繁的建立和刪除節點,並且每個節點都有watch事件,對Zookeeper服務來說壓力大。相對Redis的效能,還存在差距。

3. Redis 實現分散式鎖

Redis 實現分散式鎖,是最複雜的,但是也是效能最高的。

  • 加鎖SETNX key value 如果鍵不存在時,對鍵設值,返回1。如果鍵存在,不做任何操作,返回0setnx全稱是set if not exist
  • 解鎖DEL key,通過刪除key釋放鎖,刪除鍵之後,其他執行緒可以爭奪鎖。

Redis也需要考慮超時問題,一般都是用SETNX + EXPIRE組合來實現超時設定,虛擬碼如下:

pubic boolean lock(Jedis jedis,String key,String value,long expireTime) {
     long flag = jedis.setnx(key,value);
     // 成功獲取鎖
     if(flag) {
         // 如果這裡突然崩潰,無法設定過期時間,將發生死鎖 
         jedis.expirt(key,expireTime);
         return true;
     }
     return false;
} 

通過setnx方法獲取鎖,如果key存在,就返回失敗。如果不存在,就設值成功,設值成功之後,再通過expirt設定超時時間。

如果在設定超時時間和設定鎖之間出現系統崩潰,此時沒有給鎖設定過期時間,將會出現死鎖問題。

Redis 2.6.12版本後SETNX增加了過期時間引數:

pubic boolean lock(Jedis jedis,String key,String value,long expireTime) {
     long flag = jedis.setnx(key,value,expireTime);
     // 成功獲取鎖
     if(flag) {
         return true;
     }
     return false;
} 

解鎖,需要刪除鍵值即可,其他執行緒就能競爭鎖了:

pubic void lock(Jedis jedis,String key) {
    jedis.del(key);
}

一般請求controlle如下:

@RestController
public class Controller {
    @Autowired
    private InterProcessMutex interProcessMutex;
    
    @GetMapping("/sec-kill")
    public String secKill() throws Exception {
        // 獲取鎖
        lock();
        // 扣減庫存,建立訂單等操作....
        unLock();
        return "ok";
    }
}

Redis設定了超時時間後,就解決死鎖的問題,但也會引發其他問題。

如果設定的超時時間比較短,而業務執行的時間比較長。比如超時時間設定5s,而業務執行需要10s,此時業務還未執行完,其他請求就會獲取到鎖,兩個請求同時請求業務資料,不滿足分散式鎖的互斥性,無法保證執行緒的安全,如下流程所示:

超時解鎖導致並行:

使用者A先獲取鎖,還未執行完業務程式碼,此時已經過了超時時間,鎖被釋放。使用者B獲取到鎖,此時使用者A使用者B並行執行業務資料,

鎖誤刪除:

使用者A執行完業務程式碼後,執行釋放鎖操作,而此時使用者A已經被超時釋放,鎖被使用者B持有,此時釋放鎖,就把使用者B的鎖誤刪了。

解決方案:

首先要將超時時間設定的長一些,滿足業務執行的時間。如果系統對吞吐量要求比較嚴格,根據具體的業務的執行時間來設定超時時間,超時時間比業務執行時間長一些,超時時間不能設定太長也不能設定太短

針對鎖誤刪除的問題。每個執行緒在獲取鎖時,設定一個的執行緒標識,比如UUID,作為唯一的標識,設定value值,在解鎖時,先判斷是是否是自己執行緒的標識,如果不是,就不做刪除:

pubic void lock(Jedis jedis,String key,String value) { 
    if (jedis.get(key).equals(value)) {
        jedis.del(key);
    }    
}

除了設定合理超時時間外,可能還有偶爾幾個執行緒執行業務程式碼,因為網路環境執行時間變長。這時候就需要再加一個執行緒,定時執行,自動續期鎖。

4.Redission 分散式鎖

Redis雖然作為分散式鎖來說,效能是最好的。但是也是最複雜的,上面總結Redis主要有下面幾個問題:

  • 死鎖
  • 設定超時後
    • 鎖誤刪
    • 業務還繼續執行,導致多個執行緒並行執行

線上都是用Redission實現分散式鎖Redisson是一個在Redis的基礎上實現的Java駐記憶體資料網格(In-Memory Data Grid)。它不僅提供了一系列的分散式的Java常用物件,還提供了許多分散式服務。Redisson是基於netty通訊框架實現的,所以支援非阻塞通訊,效能優於Jedis

Redisson分散式鎖四層保護:

  • 防死鎖
  • 防誤刪
  • 可重入
  • 自動續期

Redisson實現Redis分散式鎖,支援單機和叢集模式,

引入maven依賴:

<dependency>
      <groupId>org.redisson</groupId>
      <artifactId>redisson</artifactId>
      <version>3.8.2</version>
</dependency>

新增Redission設定:

@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        // 單機模式
        config.useSingleServer()
                .setAddress("redis://127.0.0.1:6379")
                .setPassword("xxxx");
        // 叢集模式
        /*config.useClusterServers()
        .setScanInterval(2000) // 叢集狀態掃描間隔時間,單位是毫秒
        //可以用"rediss://"來啟用SSL連線
        .addNodeAddress("redis://127.0.0.1:7000", "redis://127.0.0.1:7001")
       .addNodeAddress("redis://127.0.0.1:7002");*/       
        return Redisson.create(config);
    }

}

使用Redission分散式鎖,分成三個步驟:

  • 獲取鎖 redissonClient.getLock("lock")
  • 加鎖 rLock.lock()
  • 解鎖 rLock.unlock()

請求controlle範例如下:

@RestController
public class Controller {
    @Autowired
    private RedissonClient redissonClient;
    
    @GetMapping("/sec-kill")
    public String secKill() throws Exception {
        // 獲取鎖
        RLock rLock = redissonClient.getLock("lock");
        // 加鎖
        rLock.lock();

        // 執行業務資料

        // 解鎖
        rLock.unlock();
        return "ok";
    }
}

Redission實現的分散式鎖,直接呼叫,不需要鎖異常、超時並行、鎖刪除等問題,它把處理上面的問題的程式碼都封裝好了,直接呼叫即可。

Redlock 演演算法

在單機模式下,Redis發生單機故障,Redis master宕機了該怎麼辦?是否將鎖轉移到slave呢?答案是不行的,因為Redis複製是非同步的,無法滿足鎖互斥性。

Redlock演演算法可以解決上面的問題,在叢集模式下,Redission 使用Redlock演演算法,使用單機範例的方式順序獲取叢集下的鎖。如果請求超時,則認定該節點不可用。當獲取鎖的範例數超過半數時,則獲取鎖成功。如果獲取鎖失敗,即沒有獲取超過半數的範例,那麼久釋放所有節點的鎖。

Watch dog 看門狗機制

Redission通過看門狗的實現自動續期的功能,當分散式鎖獲取到鎖後,對應的Redis宕機了,會出現死鎖的狀態,為避免出現這種狀態,鎖一般會設定一個過期時間。預設是30s,超過30s後,會自動釋放鎖。

Redission範例被關閉之前,不斷的延長鎖的有效期,拿到執行緒的鎖如果沒有完成業務操作,那麼看門狗會一直延長鎖的超時時間。預設情況下,每10s延長一次超時時間,續期時間是30s,可以通過Config.lockWatchdogTimeout來設定。

Redisson還提供了可以指定leaseTime引數的加鎖方法來指定加鎖的時間。超過這個時間後鎖便自動解開了,不會延長鎖的有效期。

總結

  • 分散式鎖是由於單機鎖無法滿足分散式系統鎖,在分散式環境下,需要分散式鎖來控制共用內容,保證執行緒的安全。
  • 分散式滿足幾個特性
    • 互斥性
    • 超時釋放鎖
    • 自動續期
  • 分散式鎖實現方式
    • Mysql使用排它鎖,select xxxx for update。實現比較簡單,但是資料庫無法支撐大量請求存取,效能較差。
    • Zookeeper先建立一個持久型別的節點,當多個執行緒請求時,在持久型別節點建立順序臨時節點,先判斷自己是否是最小節點,如果是持有鎖,執行後續邏輯,如果不是就找到上一個順序節點,並新增watch監聽事件。執行緒處理結束後。觸發監聽事件,通知下一個節點獲取鎖。Zookeeper效能優於資料庫,但是頻繁的建立、刪除節點並且建立watch監聽,對伺服器的壓力也大。
    • 使用Redis實現分散式鎖效能是最優,也是最複雜的。SETNX key value 獲取鎖,如果key存在,則成功獲取鎖,否則獲取鎖失敗。使用del刪除節點釋放鎖。複雜在於需要平衡超時時間鎖續期。不設定超時時間,會發生死鎖。設定了超時時間就可能出現業務處理時間大於超時時間,出現多個鎖同時存取共用資料,以及鎖誤刪的情況。解決方案是根據具體的業務時間設定合理的超時時間,鎖誤刪的話要給每個執行緒設定一個唯一的id。此外,如果業務時間大於超時時間,開啟執行緒定時續約時間。
    • 針對Redis實現分散式鎖存在的問題Redisson提供瞭解決方案,Redisson是一個在Redis的基礎上實現的Java駐記憶體資料網格(In-Memory Data Grid),Redisson是基於netty通訊框架實現的,所以支援非阻塞通訊,效能也比較高。Redisson有四種特性防死鎖防誤刪可重入自動續期。支援單機和叢集模式。自動續期是使用watch dog看門狗機制,在範例關閉前,不斷地延長鎖的有效期。預設10s延遲一次,延長時間為30s

參考