018 Java | 分布式鎖方案和區(qū)別

分布式鎖的實現(xiàn)

在常見的分布式鎖中有以下三種實現(xiàn):

  1. Redis 實現(xiàn)
  2. Zookeeper 實現(xiàn)
  3. 數(shù)據(jù)庫實現(xiàn)

1. 基于 Redis 的實現(xiàn)

在 Redis 中有 3 個重要命令芋膘,通過這三個命令可以實現(xiàn)分布式鎖

  • setnx key val:當且僅當key不存在時表牢,set一個key為val的字符串嗅回,返回1之众;若key存在,則什么都不做蝇率,返回0迟杂。
  • expire key timeout:為key設置一個超時時間刽沾,單位為second,超過這個時間 key 會自動刪除排拷。
  • delete key:刪除key

1.1 實現(xiàn)原理

  1. 獲取鎖的時候侧漓,使用 setnx 命令設置一個 kv,其中 k 為鎖的名字监氢,v 為一個隨機數(shù)字布蔗,如果成功設置則獲取鎖,如果未設置成功則失敗浪腐。如果設置了嘗試獲取鎖的最大的時間纵揍,則需要在最大時間內,不停的重復該步驟议街,直到獲取鎖或者超過最大時間才能結束泽谨。
  2. 使用 expire 命令為剛才創(chuàng)建的 key 設置超時一個合理的超時時間,防止在無法正確釋放鎖的時候也能通過超時時間進行釋放特漩,這個超時時間需要根據(jù)項目請求情況進行設置吧雹;
  3. 釋放鎖的時候,通過 v 判斷是不是還是原來的鎖涂身,若是該鎖雄卷,則執(zhí)行 delete 進行鎖釋放。

1.2 實現(xiàn)方式

1.2.1 原生代碼

public class DistributedLock implements Lock {

  private static JedisPool JEDIS_POOL = null;
  private static int EXPIRE_SECONDS = 60;

  public static void setJedisPool(JedisPool jedisPool, int expireSecond) {
    JEDIS_POOL = jedisPool;
    EXPIRE_SECONDS = expireSecond;
  }

  private String lockKey;
  private String lockValue;

  private DistributedLock(String lockKey) {
    this.lockKey = lockKey;
  }

  public static DistributedLock newLock(String lockKey) {
    return new DistributedLock(lockKey);
  }

  @Override
  public void lock() {
    if (!tryLock()) {
      throw new IllegalStateException("未獲取到鎖");
    }
  }

  @Override
  public void lockInterruptibly() throws InterruptedException {
  }

  @Override
  public boolean tryLock() {
    return tryLock(0, null);
  }

  @Override
  public boolean tryLock(long time, TimeUnit unit) {
    Jedis conn = null;
    String retIdentifier = null;
    try {
      conn = JEDIS_POOL.getResource();
      lockKey = UUID.randomUUID().toString();

      // 獲取鎖的超時時間蛤售,超過這個時間則放棄獲取鎖
      long end = 0;
      if (time != 0) {
        end = System.currentTimeMillis() + unit.toMillis(time);
      }

      do {
        if (conn.setnx(lockKey, lockValue) == 1) {
          conn.expire(lockKey, EXPIRE_SECONDS);
          return true;
        }

        try {
          Thread.sleep(10);
        } catch (InterruptedException e) {
          Thread.currentThread().interrupt();
        }
      } while (System.currentTimeMillis() < end);
    } catch (JedisException e) {
      if (lockValue.equals(conn.get(lockKey))) {
        conn.del(lockKey);
      }
      e.printStackTrace();
    } finally {
      if (conn != null) {
        conn.close();
      }
    }
    return false;
  }

  @Override
  public void unlock() {
    Jedis conn = null;
    try {
      conn = JEDIS_POOL.getResource();
      if (lockValue.equals(conn.get(lockKey))) {
        conn.del(lockKey);
      }
    } catch (JedisException e) {
      e.printStackTrace();
    } finally {
      if (conn != null) {
        conn.close();
      }
    }
  }

  @Override
  public Condition newCondition() {
    return null;
  }
}

上面的代碼中也有一個問題丁鹉,setnx 和 expire 是分為兩步進行了,雖然在 catch 中處理異常并嘗試將可能出現(xiàn)鎖刪除悴能,但這種方式并不友好揣钦,一個好的方案是通過執(zhí)行 lua 腳本來實現(xiàn)。在 Spring Redis Lock 和 Redission 都是通過 lua 腳本實現(xiàn)的

local lockClientId = redis.call('GET', KEYS[1])
if lockClientId == ARGV[1] then
    redis.call('PEXPIRE', KEYS[1], ARGV[2])
    return true
elseif not lockClientId then
    redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
    return true
end
return false

1.2.2 Spring Redis Lock 實現(xiàn)

1. 引入庫

在 Spring Boot 項目會根據(jù) Spring Boot 依賴管理自動配置版本號

Maven

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

<dependency>
    <groupId>org.springframework.integration</groupId>
    <artifactId>spring-integration-redis</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2. 配置 redis

application-xxx.yml 中配置

spring:
  redis:
    host: 127.0.0.1
    port: 6379
    timeout: 2500
    password: xxxxx

3. 增加配置

RedisLockConfig.java

import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.integration.redis.util.RedisLockRegistry;

@Configuration
public class RedisLockConfig {

  @Bean
  public RedisLockRegistry redisLockRegistry(RedisConnectionFactory redisConnectionFactory) {
    return new RedisLockRegistry(redisConnectionFactory, "redis-lock",
        TimeUnit.MINUTES.toMillis(10));
  }
}

4. 使用


@Autowired
private RedisLockRegistry lockRegistry;

Lock lock = lockRegistry.obtain(key);
boolean locked = false;
try {
  locked = lock.tryLock();
  if (!locked) {
    // 沒有獲取到鎖的邏輯    
  }

  // 獲取鎖的邏輯
} finally {
  // 一定要解鎖
  if (locked) {
    lock.unlock();
  }
}

1.2.3 Redission 實現(xiàn)

Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379").setPassword("xxxxxx").setDatabase(0);
RedissonClient redissonClient = Redisson.create(config);
RLock rLock = redissonClient.getLock("lockKey");
boolean locked = false;
try {
  /* 
   * waitTimeout 嘗試獲取鎖的最大等待時間搜骡,超過這個值,則認為獲取鎖失敗
   * leaseTime   鎖的持有時間,超過這個時間鎖會自動失效
   */
  locked = rLock.tryLock((long) waitTimeout, (long) leaseTime, TimeUnit.SECONDS);
  if (!locked) {
    // 沒有獲取鎖的邏輯
    
    
  }
  
  // 獲取鎖的邏輯
} catch (Exception e) {
  throw new RuntimeException("aquire lock fail");
} finally {
  if(locked)
    rLock.unlock();
}

1.3 優(yōu)缺點

優(yōu)點:redis 本身的性能比較高佑女,即使存在大量的 setnx 命令也不會有所下降

缺點:

  1. 如果 key 設置的超時時間過短可能導致業(yè)務流程還沒處理完鎖就釋放了记靡,導致其他請求也能獲取到鎖
  2. 如果 key 設置的超時時間過大,且未釋放鎖团驱,會導致一些請求長時間在等待鎖
  3. 在鎖不斷嘗試的過程中摸吠,會浪費 CPU 資源

針對第 2 個缺點,在 Redission 通過續(xù)約機制嚎花,每隔一段時間去檢測鎖是否還在進行寸痢,如果還在運行就將對應的 key 增加一定的時間,保證在鎖運行的情況下不會發(fā)生 key 到了過期時間自動刪除的情況

2. 基于 Zookeeper 的實現(xiàn)

2.1 實現(xiàn)原理

基于zookeeper臨時有序節(jié)點可以實現(xiàn)的分布式鎖紊选。

大致步驟:客戶端對某個方法加鎖時啼止,在 zookeeper 上的與該方法對應的指定節(jié)點的目錄下道逗,生成一個唯一的臨時有序節(jié)點。 判斷是否獲取鎖的方式很簡單献烦,只需要判斷有序節(jié)點中序號最小的一個滓窍。 當釋放鎖的時候,只需將這個瞬時節(jié)點刪除即可巩那。同時吏夯,其可以避免服務宕機導致的鎖無法釋放,而產生的死鎖問題即横。

當?shù)谝粋€節(jié)點申請鎖 xxxlock 時如下: 在 xxxlock 持久節(jié)點下噪生,創(chuàng)建一個 lock 的臨時有序節(jié)點,此時因為 lock 為有序節(jié)點中序號最小的一個东囚,則此時獲取到鎖

zklock.png

當?shù)谝粋€節(jié)點還在處理業(yè)務邏輯未釋放鎖時跺嗽,第二節(jié)點申請 xxxlock 鎖,創(chuàng)建一個 lock 的臨時有序節(jié)點舔庶,此時因為 lock 不是有序節(jié)點中序號最小的一個抛蚁,則此時不能獲取到鎖,需要一直等到 lock:1 節(jié)點刪除后才能獲取到鎖惕橙,此時 lock:2 會 watch 它的上一個節(jié)點(即 lock:1)等到 lock:1 刪除后在獲取鎖

zklock2.png

當?shù)谝粋€節(jié)點還在處理業(yè)務邏輯未釋放鎖時瞧甩,第二節(jié)點還在排隊,第三個節(jié)點申請鎖時弥鹦,創(chuàng)建一個 lock 的臨時有序節(jié)點肚逸,此時因為 lock 不是有序節(jié)點中序號最小的一個,則此時不能獲取到鎖彬坏,需要一直等到上面的節(jié)點( lock:1 和 lock:2 )節(jié)點刪除后才能獲取到鎖朦促,此時 lock:3 會 watch 它的上一個節(jié)點(即 lock:2)等到 lock:2 刪除后在獲取鎖

zklock3.png

2.2 使用

2.2.1 使用 spring-integration-zookeeper 實現(xiàn)

Maven.

<dependency>
    <!-- spring integration -->
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-integration</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.integration</groupId>
    <artifactId>spring-integration-zookeeper</artifactId>
</dependency>

Gradle.

compile "org.springframework.integration:spring-integration-zookeeper:5.1.2.RELEASE"

增加配置

@Configuration
public class ZookeeperLockConfig {

  @Value("${zookeeper.host}")
  private String zkUrl;

  @Bean
  public CuratorFrameworkFactoryBean curatorFrameworkFactoryBean() {
    return new CuratorFrameworkFactoryBean(zkUrl);
  }

  @Bean
  public ZookeeperLockRegistry zookeeperLockRegistry(CuratorFramework curatorFramework) {
    return new ZookeeperLockRegistry(curatorFramework, "/lock");
  }
}

使用

@Autowired
private ZookeeperLockRegistry lockRegistry;

Lock lock = lockRegistry.obtain(key);
boolean locked = false;
try {
  locked = lock.tryLock();
  if (!locked) {
    // 沒有獲取到鎖的邏輯    
  }

  // 獲取鎖的邏輯
} finally {
  // 一定要解鎖
  if (locked) {
    lock.unlock();
  }
}

2.2.2 使用 Apache Curator

Maven

<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-framework</artifactId>
    <version>5.1.0</version>
</dependency>

使用

CuratorFramework curatorFramework = CuratorFrameworkFactory.newClient(
                connectString,
                sessionTimeoutMs,
                connectionTimeoutMs,
                new RetryNTimes(retryCount, elapsedTimeMs));

InterProcessMutex mutex = new InterProcessMutex(curatorFramework, "lock name");

mutex.acquire(); // 獲取鎖
mutex.acquire(long time, TimeUnit unit) // 獲取鎖并設置最大等待時間
mutex.release(); // 釋放鎖

2.3 優(yōu)缺點

優(yōu)點:

  1. 解決了單點問題,通過集群部署 zookeeper栓始;
  2. 因為用的臨時節(jié)點务冕,在項目出現(xiàn)意外的情況下可以保證鎖可以釋放,當 session 異常斷開時幻赚,臨時節(jié)點會自動刪除禀忆;
  3. 不用在設置存儲過期時間,避免了 Redis 鎖過期引發(fā)的問題落恼;

缺點:

  1. 性能不如 Redis 實現(xiàn)箩退;

3. 基于數(shù)據(jù)庫的實現(xiàn)

3.1 實現(xiàn)原理

create table distributed_lock (
  id int(11) unsigned NOT NULL auto_increment primary key,
  key_name varchar(30) unique NOT NULL comment '鎖名',
  update_time datetime default current_timestamp on update current_timestamp comment '更新時間'
)ENGINE=InnoDB comment '數(shù)據(jù)庫鎖';

方式一:通過 insert 和 delete 實現(xiàn)

使用數(shù)據(jù)庫唯一索引,當我們想獲取一個鎖的時候佳谦,就 insert 一條數(shù)據(jù)戴涝,如果 insert 成功則獲取到鎖,獲取鎖之后,通過 delete 語句來刪除鎖

這種方式實現(xiàn)啥刻,鎖不會等待奸鸯,如果想設置獲取鎖的最大時間,需要自己實現(xiàn)

方式二:通過for update 實現(xiàn)

以下操作需要在事務中進行

select * from distributed_lock where key_name = 'lock' for update;

在查詢語句后面增加 for update郑什,數(shù)據(jù)庫會在查詢過程中給數(shù)據(jù)庫表增加排他鎖府喳。當某條記錄被加上排他鎖之后,其他線程無法再在該行記錄上增加排他鎖蘑拯。for update 的另一個特性就是會阻塞钝满,這樣也間接實現(xiàn)了一個阻塞隊列,但是 for update 的阻塞時間是由數(shù)據(jù)庫決定的申窘,而不是程序決定的弯蚜。

在 MySQL 8 中,for update 語句可以加上 nowait 來實現(xiàn)非阻塞用法

select * from distributed_lock where key_name = 'lock' for update nowait;

在 InnoDB 引擎在加鎖的時候剃法,只有通過索引查詢時才會使用行級鎖碎捺,否則為表鎖,而且如果查詢不到數(shù)據(jù)的時候也會升級為表鎖贷洲。

這種方式需要在數(shù)據(jù)庫中實現(xiàn)已經存在數(shù)據(jù)的情況下使用收厨。

3.2 優(yōu)缺點

優(yōu)點:

如果項目中已經使用了數(shù)據(jù)庫在不引入其他中間件的情況下,可以直接使用數(shù)據(jù)庫优构,減少依賴
直接借助數(shù)據(jù)庫诵叁,容易理解。

缺點:

  1. 操作數(shù)據(jù)庫需要一定的開銷钦椭,性能問題需要考慮拧额;
  2. 使用數(shù)據(jù)庫的行級鎖并不一定靠譜,尤其是當我們的鎖表并不大的時候彪腔;
  3. 沒有鎖超時機制侥锦,導致必須自己刪除,故障后如何刪除鎖成為一個問題
  4. for update 方式必須在事務內部德挣,如果業(yè)務操作不能在事務里面執(zhí)行又是一個問題
  5. 各種各樣的問題恭垦,在解決問題的過程中會使整個方案變得越來越復雜。

4. 對比

從性能角度(從高到低)緩存 > Zookeeper >= 數(shù)據(jù)庫

從可靠性角度(從高到低)Zookeeper > 緩存 > 數(shù)據(jù)庫

問題格嗅、實現(xiàn) Redis Zookeeper 數(shù)據(jù)庫
性能
可靠性
過期刪除 有番挺,設置過期時間,或者手動刪除 執(zhí)行業(yè)務邏輯后手動刪除 1. for update 事務完成后吗浩,數(shù)據(jù)庫自動釋放 2. insert 方式執(zhí)行業(yè)務邏輯后手動刪除
阻塞隊列 無建芙,需要客戶端自旋解決 通過監(jiān)聽上一個 lock 解決没隘,watch 機制 1. for update 數(shù)據(jù)庫自己解決 2. insert 方式需要客戶端自旋解決
超時時間內業(yè)務未完成問題 需要自己寫續(xù)約機制完成懂扼,Redission 內部自己實現(xiàn)了 無這問題 1. for update 執(zhí)行時間過長,可能導致事務本身超時 2. insert 方式無此問題
項目異常導致鎖未手動刪除的情況 redis 有過期時間,過期時間后自動刪除 session 斷開后阀湿,臨時節(jié)點自動刪除 1. for update 機制數(shù)據(jù)庫會自動清除 2. insert 方式就得自己想解決方案了
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末赶熟,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子陷嘴,更是在濱河造成了極大的恐慌映砖,老刑警劉巖,帶你破解...
    沈念sama閱讀 222,104評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件灾挨,死亡現(xiàn)場離奇詭異邑退,居然都是意外死亡,警方通過查閱死者的電腦和手機劳澄,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,816評論 3 399
  • 文/潘曉璐 我一進店門地技,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人秒拔,你說我怎么就攤上這事莫矗。” “怎么了砂缩?”我有些...
    開封第一講書人閱讀 168,697評論 0 360
  • 文/不壞的土叔 我叫張陵作谚,是天一觀的道長。 經常有香客問我庵芭,道長妹懒,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 59,836評論 1 298
  • 正文 為了忘掉前任喳挑,我火速辦了婚禮彬伦,結果婚禮上,老公的妹妹穿的比我還像新娘伊诵。我一直安慰自己单绑,他們只是感情好,可當我...
    茶點故事閱讀 68,851評論 6 397
  • 文/花漫 我一把揭開白布曹宴。 她就那樣靜靜地躺著搂橙,像睡著了一般。 火紅的嫁衣襯著肌膚如雪笛坦。 梳的紋絲不亂的頭發(fā)上区转,一...
    開封第一講書人閱讀 52,441評論 1 310
  • 那天,我揣著相機與錄音版扩,去河邊找鬼废离。 笑死,一個胖子當著我的面吹牛礁芦,可吹牛的內容都是我干的蜻韭。 我是一名探鬼主播悼尾,決...
    沈念sama閱讀 40,992評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼肖方!你這毒婦竟也來了闺魏?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 39,899評論 0 276
  • 序言:老撾萬榮一對情侶失蹤俯画,失蹤者是張志新(化名)和其女友劉穎析桥,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體艰垂,經...
    沈念sama閱讀 46,457評論 1 318
  • 正文 獨居荒郊野嶺守林人離奇死亡泡仗,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 38,529評論 3 341
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了猜憎。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片沮焕。...
    茶點故事閱讀 40,664評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖拉宗,靈堂內的尸體忽然破棺而出峦树,到底是詐尸還是另有隱情,我是刑警寧澤旦事,帶...
    沈念sama閱讀 36,346評論 5 350
  • 正文 年R本政府宣布魁巩,位于F島的核電站,受9級特大地震影響姐浮,放射性物質發(fā)生泄漏谷遂。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 42,025評論 3 334
  • 文/蒙蒙 一卖鲤、第九天 我趴在偏房一處隱蔽的房頂上張望肾扰。 院中可真熱鬧,春花似錦蛋逾、人聲如沸集晚。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,511評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽偷拔。三九已至,卻和暖如春亏钩,著一層夾襖步出監(jiān)牢的瞬間莲绰,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,611評論 1 272
  • 我被黑心中介騙來泰國打工姑丑, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留蛤签,地道東北人。 一個月前我還...
    沈念sama閱讀 49,081評論 3 377
  • 正文 我出身青樓栅哀,卻偏偏與公主長得像震肮,于是被迫代替她去往敵國和親踏枣。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 45,675評論 2 359

推薦閱讀更多精彩內容