分散式鎖實現原理與最佳實踐

碼農談IT發表於2023-11-22

分散式鎖實現原理與最佳實踐

來源:阿里雲開發者

阿里妹導讀

在單體的應用開發場景中涉及併發同步時,大家往往採用Synchronized(同步)或同一個JVM內Lock機制來解決多執行緒間的同步問題。而在分散式叢集工作的開發場景中,就需要一種更加高階的鎖機制來處理跨機器的程式之間的資料同步問題,這種跨機器的鎖就是分散式鎖。接下來本文將為大家分享分散式鎖的最佳實踐。

一、超賣問題復現

1.1 現象

存在如下的幾張表:
  • 商品表
分散式鎖實現原理與最佳實踐分散式鎖實現原理與最佳實踐
  • 訂單表
分散式鎖實現原理與最佳實踐
  • 訂單item表
分散式鎖實現原理與最佳實踐
商品的庫存為1,但是併發高的時候有多筆訂單。
錯誤案例一:資料庫update相互覆蓋
直接在記憶體中判斷是否有庫存,計算扣減之後的值更新資料庫,併發的情況下會導致相互覆蓋發生:



























@Transactional(rollbackFor = Exception.class)public Long createOrder() throws Exception {    Product product = productMapper.selectByPrimaryKey(purchaseProductId);    // ... 忽略校驗邏輯
   //商品當前庫存    Integer currentCount = product.getCount();    //校驗庫存    if (purchaseProductNum > currentCount) {        throw new Exception("商品" + purchaseProductId + "僅剩" + currentCount + "件,無法購買");    }    // 計算剩餘庫存    Integer leftCount = currentCount - purchaseProductNum;    // 更新庫存    product.setCount(leftCount);    product.setGmtModified(new Date());    productMapper.updateByPrimaryKeySelective(product);
   Order order = new Order();    // ... 省略 Set    orderMapper.insertSelective(order);
   OrderItem orderItem = new OrderItem();    orderItem.setOrderId(order.getId());    // ... 省略 Set    return order.getId();}
錯誤案例二:扣減序列執行,但是庫存被扣減為負數
在 SQL 中加入運算避免值的相互覆蓋,但是庫存的數量變為負數,因為校驗庫存是否足夠還是在記憶體中執行的,併發情況下都會讀到有庫存:






















@Transactional(rollbackFor = Exception.class)public Long createOrder() throws Exception {    Product product = productMapper.selectByPrimaryKey(purchaseProductId);    // ... 忽略校驗邏輯
   //商品當前庫存    Integer currentCount = product.getCount();    //校驗庫存    if (purchaseProductNum > currentCount) {        throw new Exception("商品" + purchaseProductId + "僅剩" + currentCount + "件,無法購買");    }    // 使用 set count =  count - #{purchaseProductNum,jdbcType=INTEGER}, 更新庫存    productMapper.updateProductCount(purchaseProductNum,new Date(),product.getId());    Order order = new Order();    // ... 省略 Set    orderMapper.insertSelective(order);
   OrderItem orderItem = new OrderItem();    orderItem.setOrderId(order.getId());    // ... 省略 Set    return order.getId();}
錯誤案例三:使用 synchronized 實現記憶體中序列校驗,但是依舊扣減為負數
因為我們使用的是事務的註解,synchronized加在方法上,方法執行結束的時候鎖就會釋放,此時的事務還沒有提交,另一個執行緒拿到這把鎖之後就會有一次扣減,導致負數。






















@Transactional(rollbackFor = Exception.class)public synchronized Long createOrder() throws Exception {    Product product = productMapper.selectByPrimaryKey(purchaseProductId);    // ... 忽略校驗邏輯
   //商品當前庫存    Integer currentCount = product.getCount();    //校驗庫存    if (purchaseProductNum > currentCount) {        throw new Exception("商品" + purchaseProductId + "僅剩" + currentCount + "件,無法購買");    }    // 使用 set count =  count - #{purchaseProductNum,jdbcType=INTEGER}, 更新庫存    productMapper.updateProductCount(purchaseProductNum,new Date(),product.getId());    Order order = new Order();    // ... 省略 Set    orderMapper.insertSelective(order);
   OrderItem orderItem = new OrderItem();    orderItem.setOrderId(order.getId());    // ... 省略 Set    return order.getId();}

1.2 解決辦法

從上面造成問題的原因來看,只要是扣減庫存的動作,不是原子性的。多個執行緒同時操作就會有問題。
  • 單體應用:使用本地鎖 + 資料庫中的行鎖解決

  • 分散式應用:

    • 使用資料庫中的樂觀鎖,加一個 version 欄位,利用CAS來實現,會導致大量的 update 失敗

    • 使用資料庫維護一張鎖的表 + 悲觀鎖 select,使用 select for update 實現

    • 使用Redis 的 setNX實現分散式鎖

    • 使用zookeeper的watcher + 有序臨時節點來實現可阻塞的分散式鎖

    • 使用Redisson框架內的分散式鎖來實現

    • 使用curator 框架內的分散式鎖來實現

二、單體應用解決超賣的問題

正確示例:將事務包含在鎖的控制範圍內

保證在鎖釋放之前,事務已經提交。





























//@Transactional(rollbackFor = Exception.class)public synchronized Long createOrder() throws Exception {    TransactionStatus transaction1 = platformTransactionManager.getTransaction(transactionDefinition);    Product product = productMapper.selectByPrimaryKey(purchaseProductId);    if (product == null) {        platformTransactionManager.rollback(transaction1);        throw new Exception("購買商品:" + purchaseProductId + "不存在");    }        //商品當前庫存    Integer currentCount = product.getCount();    //校驗庫存    if (purchaseProductNum > currentCount) {        platformTransactionManager.rollback(transaction1);        throw new Exception("商品" + purchaseProductId + "僅剩" + currentCount + "件,無法購買");    }
   productMapper.updateProductCount(purchaseProductNum, new Date(), product.getId());
   Order order = new Order();    // ... 省略 Set    orderMapper.insertSelective(order);
   OrderItem orderItem = new OrderItem();    orderItem.setOrderId(order.getId());    // ... 省略 Set    return order.getId();    platformTransactionManager.commit(transaction1);}

正確示例:使用synchronized的程式碼塊





































public Long createOrder() throws Exception {    Product product = null;    //synchronized (this) {    //synchronized (object) {    synchronized (DBOrderService2.class) {        TransactionStatus transaction1 = platformTransactionManager.getTransaction(transactionDefinition);        product = productMapper.selectByPrimaryKey(purchaseProductId);        if (product == null) {            platformTransactionManager.rollback(transaction1);            throw new Exception("購買商品:" + purchaseProductId + "不存在");        }
       //商品當前庫存        Integer currentCount = product.getCount();        System.out.println(Thread.currentThread().getName() + "庫存數:" + currentCount);        //校驗庫存        if (purchaseProductNum > currentCount) {            platformTransactionManager.rollback(transaction1);            throw new Exception("商品" + purchaseProductId + "僅剩" + currentCount + "件,無法購買");        }
       productMapper.updateProductCount(purchaseProductNum, new Date(), product.getId());        platformTransactionManager.commit(transaction1);    }
   TransactionStatus transaction2 = platformTransactionManager.getTransaction(transactionDefinition);
   Order order = new Order();    // ... 省略 Set    orderMapper.insertSelective(order);
   OrderItem orderItem = new OrderItem();    // ... 省略 Set    orderItemMapper.insertSelective(orderItem);    platformTransactionManager.commit(transaction2);    return order.getId();

正確示例:使用Lock











































private Lock lock = new ReentrantLock();
public Long createOrder() throws Exception{      Product product = null;
   lock.lock();
   TransactionStatus transaction1 = platformTransactionManager.getTransaction(transactionDefinition);    try {        product = productMapper.selectByPrimaryKey(purchaseProductId);        if (product==null){            throw new Exception("購買商品:"+purchaseProductId+"不存在");        }
       //商品當前庫存        Integer currentCount = product.getCount();        System.out.println(Thread.currentThread().getName()+"庫存數:"+currentCount);        //校驗庫存        if (purchaseProductNum > currentCount){            throw new Exception("商品"+purchaseProductId+"僅剩"+currentCount+"件,無法購買");        }
       productMapper.updateProductCount(purchaseProductNum,new Date(),product.getId());        platformTransactionManager.commit(transaction1);    } catch (Exception e) {        platformTransactionManager.rollback(transaction1);    } finally {        // 注意拋異常的時候鎖釋放不掉,分散式鎖也一樣,都要在這裡刪掉        lock.unlock();    }
   TransactionStatus transaction = platformTransactionManager.getTransaction(transactionDefinition);    Order order = new Order();    // ... 省略 Set    orderMapper.insertSelective(order);
   OrderItem orderItem = new OrderItem();    // ... 省略 Set    orderItemMapper.insertSelective(orderItem);    platformTransactionManager.commit(transaction);    return order.getId();}

三、常見分散式鎖的使用

上面使用的方法只能解決單體專案,當部署多臺機器的時候就會失效,因為鎖本身就是單機的鎖,所以需要使用分散式鎖來實現。

3.1 資料庫樂觀鎖

資料庫中的樂觀鎖,加一個version欄位,利用CAS來實現,樂觀鎖的方式支援多臺機器併發安全。但是併發量大的時候會導致大量的update失敗

3.2 資料庫分散式鎖

db操作效能較差,並且有鎖表的風險,一般不考慮。
3.2.1 簡單的資料庫鎖
分散式鎖實現原理與最佳實踐
select for update
直接在資料庫新建一張表:
分散式鎖實現原理與最佳實踐
鎖的code預先寫到資料庫中,搶鎖的時候,使用select for update查詢鎖對應的key,也就是這裡的code,阻塞就說明別人在使用鎖。


















// 加上事務就是為了 for update 的鎖可以一直生效到事務執行結束// 預設回滾的是 RunTimeException@Transactional(rollbackFor = Exception.class)public String singleLock() throws Exception {    log.info("我進入了方法!");    DistributeLock distributeLock = distributeLockMapper.        selectDistributeLock("demo");    if (distributeLock==null) {        throw new Exception("分散式鎖找不到");    }    log.info("我進入了鎖!");    try {        Thread.sleep(1000);    } catch (InterruptedException e) {        e.printStackTrace();    }    return "我已經執行完成!";}





<select id="selectDistributeLock" resultType="com.deltaqin.distribute.model.DistributeLock">  select * from distribute_lock  where businessCode = #{businessCode,jdbcType=VARCHAR}  for update</select>


使用唯一鍵作為限制,插入一條資料,其他待執行的SQL就會失敗,當資料刪除之後再去獲取鎖 ,這是利用了唯一索引的排他性。
insert lock
直接維護一張鎖表:






























@Autowiredprivate MethodlockMapper methodlockMapper;
@Overridepublic boolean tryLock() {    try {        //插入一條資料   insert into        methodlockMapper.insert(new Methodlock("lock"));    }catch (Exception e){        //插入失敗        return false;    }    return true;}
@Overridepublic void waitLock() {    try {        Thread.sleep(10);    } catch (InterruptedException e) {        e.printStackTrace();    }}
@Overridepublic void unlock() {    //刪除資料   delete    methodlockMapper.deleteByMethodlock("lock");    System.out.println("-------釋放鎖------");}

3.3 Redis setNx

Redis 原生支援的,保證只有一個會話可以設定成功,因為Redis自己就是單執行緒序列執行的。




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

spring.redis.host=localhost


封裝一個鎖物件:











































































@Slf4jpublic class RedisLock implements AutoCloseable {
   private RedisTemplate redisTemplate;    private String key;    private String value;    //單位:秒    private int expireTime;
   /**     * 沒有傳遞 value,因為直接使用的是隨機值     */    public RedisLock(RedisTemplate redisTemplate,String key,int expireTime){        this.redisTemplate = redisTemplate;        this.key = key;        this.expireTime=expireTime;        this.value = UUID.randomUUID().toString();    }
   /**     * JDK 1.7 之後的自動關閉的功能     */    @Override    public void close() throws Exception {        unLock();    }
   /**     * 獲取分散式鎖     * SET resource_name my_random_value NX PX 30000     * 每一個執行緒對應的隨機值 my_random_value 不一樣,用於釋放鎖的時候校驗     * NX 表示 key 不存在的時候成功,key 存在的時候設定不成功,Redis 自己是單執行緒,序列執行的,第一個執行的才可以設定成功     * PX 表示過期時間,沒有設定的話,忘記刪除,就會永遠不過期     */    public boolean getLock(){        RedisCallback<Boolean> redisCallback = connection -> {            //設定NX            RedisStringCommands.SetOption setOption = RedisStringCommands.SetOption.ifAbsent();            //設定過期時間            Expiration expiration = Expiration.seconds(expireTime);            //序列化key            byte[] redisKey = redisTemplate.getKeySerializer().serialize(key);            //序列化value            byte[] redisValue = redisTemplate.getValueSerializer().serialize(value);            //執行setnx操作            Boolean result = connection.set(redisKey, redisValue, expiration, setOption);            return result;        };
       //獲取分散式鎖        Boolean lock = (Boolean)redisTemplate.execute(redisCallback);        return lock;    }
   /**     * 釋放鎖的時候隨機數相同的時候才可以釋放,避免釋放了別人設定的鎖(自己的已經過期了所以別人才可以設定成功)     * 釋放的時候採用 LUA 指令碼,因為 delete 沒有原生支援刪除的時候校驗值,證明是當前執行緒設定進去的值     * 指令碼是在官方文件裡面有的     */    public boolean unLock() {        // key 是自己才可以釋放,不是就不能釋放別人的鎖        String script = "if redis.call(\"get\",KEYS[1]) == ARGV[1] then\n" +                "    return redis.call(\"del\",KEYS[1])\n" +                "else\n" +                "    return 0\n" +                "end";        RedisScript<Boolean> redisScript = RedisScript.of(script,Boolean.class);        List<String> keys = Arrays.asList(key);
       // 執行指令碼的時候傳遞的 value 就是對應的值        Boolean result = (Boolean)redisTemplate.execute(redisScript, keys, value);        log.info("釋放鎖的結果:"+result);        return result;    }}


每次獲取的時候,自己執行緒需要new對應的RedisLock:















public String redisLock(){    log.info("我進入了方法!");    try (RedisLock redisLock = new RedisLock(redisTemplate,"redisKey",30)){        if (redisLock.getLock()) {            log.info("我進入了鎖!!");            Thread.sleep(15000);        }    } catch (InterruptedException e) {        e.printStackTrace();    } catch (Exception e) {        e.printStackTrace();    }    log.info("方法執行完成");    return "方法執行完成";}

3.4 zookeeper 瞬時znode節點 + watcher監聽機制

臨時節點具備資料自動刪除的功能。當client與ZooKeeper連線和session斷掉時,相應的臨時節點就會被刪除。zk有瞬時和持久節點,瞬時節點不可以有子節點。會話結束之後瞬時節點就會消失,基於zk的瞬時有序節點實現分散式鎖:
  • 多執行緒併發建立瞬時節點的時候,得到有序的序列,序號最小的執行緒可以獲得鎖;

  • 其他的執行緒監聽自己序號的前一個序號。前一個執行緒執行結束之後刪除自己序號的節點;

  • 下一個序號的執行緒得到通知,繼續執行;

  • 以此類推,建立節點的時候,就確認了執行緒執行的順序。
分散式鎖實現原理與最佳實踐











<dependency>  <groupId>org.apache.zookeeper</groupId>  <artifactId>zookeeper</artifactId>  <version>3.4.14</version>  <exclusions>    <exclusion>      <groupId>org.slf4j</groupId>      <artifactId>slf4j-log4j12</artifactId>    </exclusion>  </exclusions></dependency>

zk 的觀察器只可以監控一次,資料發生變化之後可以傳送給客戶端,之後需要再次設定監控。existscreategetChildren三個方法都可以新增watcher ,也就是在呼叫方法的時候傳遞true就是新增監聽。注意這裡Lock 實現了Watcher和AutoCloseable:

當前執行緒建立的節點是第一個節點就獲得鎖,否則就監聽自己的前一個節點的事件:















































































/** * 自己本身就是一個 watcher,可以得到通知 * AutoCloseable 實現自動關閉,資源不使用的時候 */@Slf4jpublic class ZkLock implements AutoCloseable, Watcher {
   private ZooKeeper zooKeeper;
   /**     * 記錄當前鎖的名字     */    private String znode;
   public ZkLock() throws IOException {        this.zooKeeper = new ZooKeeper("localhost:2181",                10000,this);    }
   public boolean getLock(String businessCode) {        try {            //建立業務 根節點            Stat stat = zooKeeper.exists("/" + businessCode, false);            if (stat==null){                zooKeeper.create("/" + businessCode,businessCode.getBytes(),                        ZooDefs.Ids.OPEN_ACL_UNSAFE,                        CreateMode.PERSISTENT);            }
           //建立瞬時有序節點  /order/order_00000001            znode = zooKeeper.create("/" + businessCode + "/" + businessCode + "_", businessCode.getBytes(),                    ZooDefs.Ids.OPEN_ACL_UNSAFE,                    CreateMode.EPHEMERAL_SEQUENTIAL);
           //獲取業務節點下 所有的子節點            List<String> childrenNodes = zooKeeper.getChildren("/" + businessCode, false);            //獲取序號最小的(第一個)子節點            Collections.sort(childrenNodes);            String firstNode = childrenNodes.get(0);            //如果建立的節點是第一個子節點,則獲得鎖            if (znode.endsWith(firstNode)){                return true;            }            //如果不是第一個子節點,則監聽前一個節點            String lastNode = firstNode;            for (String node:childrenNodes){                if (znode.endsWith(node)){                    zooKeeper.exists("/"+businessCode+"/"+lastNode,true);                    break;                }else {                    lastNode = node;                }            }            synchronized (this){                wait();            }            return true;        } catch (Exception e) {            e.printStackTrace();        }        return false;    }
   @Override    public void close() throws Exception {        zooKeeper.delete(znode,-1);        zooKeeper.close();        log.info("我已經釋放了鎖!");    }
   @Override    public void process(WatchedEvent event) {        if (event.getType() == Event.EventType.NodeDeleted){            synchronized (this){                notify();            }        }    }}

3.5 zookeeper curator

在實際的開發中,不建議去自己“重複造輪子”,而建議直接使用Curator客戶端中的各種官方實現的分散式鎖,例如其中的InterProcessMutex可重入鎖。











<dependency>  <groupId>org.apache.curator</groupId>  <artifactId>curator-recipes</artifactId>  <version>4.2.0</version>  <exclusions>    <exclusion>      <artifactId>slf4j-api</artifactId>      <groupId>org.slf4j</groupId>    </exclusion>  </exclusions></dependency>







@Bean(initMethod="start",destroyMethod = "close")public CuratorFramework getCuratorFramework() {    RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);    CuratorFramework client = CuratorFrameworkFactory.        newClient("localhost:2181", retryPolicy);    return client;}

框架已經實現了分散式鎖。zk的Java客戶端升級版。使用的時候直接指定重試的策略就可以。

官網中分散式鎖的實現是在curator-recipes依賴中,不要引用錯了。




















@Autowiredprivate CuratorFramework client;
@Testpublic void testCuratorLock(){    InterProcessMutex lock = new InterProcessMutex(client, "/order");    try {        if ( lock.acquire(30, TimeUnit.SECONDS) ) {            try  {                log.info("我獲得了鎖!!!");            }            finally  {                lock.release();            }        }    } catch (Exception e) {        e.printStackTrace();    }    client.close();}

3.6 Redission

重新實現了Java併發包下處理併發的類,讓其可以跨JVM使用,例如CHM等。
3.6.1 非SpringBoot專案引入
引入Redisson的依賴,然後配置對應的XML即可:











<dependency>  <groupId>org.redisson</groupId>  <artifactId>redisson</artifactId>  <version>3.11.2</version>  <exclusions>    <exclusion>      <artifactId>slf4j-api</artifactId>      <groupId>org.slf4j</groupId>    </exclusion>  </exclusions></dependency>

編寫相應的redisson.xml


















<beans xmlns="       xmlns:xsi="       xmlns:context="       xmlns:redisson="       xsi:schemaLocation="              /spring-beans.xsd              /spring-context.xsd              /redisson.xsd">
   <redisson:client>        <redisson:single-server address="redis://127.0.0.1:6379"/>    </redisson:client></beans>

配置對應@ImportResource("classpath*:redisson.xml")資原始檔。

3.6.2 SpringBoot專案引入
或者直接使用springBoot的starter即可。





<dependency>  <groupId>org.redisson</groupId>  <artifactId>redisson-spring-boot-starter</artifactId>  <version>3.19.1</version></dependency>


修改application.properties即可:#spring.redis.host=
3.6.3 設定配置類






@Beanpublic RedissonClient getRedissonClient() {    Config config = new Config();    config.useSingleServer().setAddress("redis://127.0.0.1:6379");    return Redisson.create(config);}
3.6.4 使用














@Testpublic void testRedissonLock() {    RLock rLock = redisson.getLock("order");    try {        rLock.lock(30, TimeUnit.SECONDS);        log.info("我獲得了鎖!!!");        Thread.sleep(10000);    } catch (InterruptedException e) {        e.printStackTrace();    }finally {        log.info("我釋放了鎖!!");        rLock.unlock();    }}

3.7 Etcd

參考以下文章,普通專案不會為了一把鎖引入etcd,此處不再贅述:

四、常見分散式鎖的原理

4.1 Redisson

Redis 2.6之後才可以執行lua指令碼,比起管道而言,這是原子性的,模擬一個商品減庫存的原子操作:














//lua指令碼命令執行方式:redis-cli --eval /tmp/test.lua , 10jedis.set("product_stock_10016", "15");  //初始化商品10016的庫存String script = " local count = redis.call('get', KEYS[1]) " +        " local a = tonumber(count) " +        " local b = tonumber(ARGV[1]) " +        " if a >= b then " +        "   redis.call('set', KEYS[1], a-b) " +        "   return 1 " +        " end " +        " return 0 ";Object obj = jedis.eval(script, Arrays.asList("product_stock_10016"),                         Arrays.asList("10"));System.out.println(obj);


分散式鎖實現原理與最佳實踐
4.1.1 嘗試加鎖的邏輯
分散式鎖實現原理與最佳實踐
上面的org.redisson.RedissonLock#lock()透過呼叫自己方法內部的lock方法的org.redisson.RedissonLock#tryAcquire方法。之後呼叫 org.redisson.RedissonLock#tryAcquireAsync
分散式鎖實現原理與最佳實踐
首先呼叫內部的org.redisson.RedissonLock#tryLockInnerAsync:設定對應的分散式鎖
分散式鎖實現原理與最佳實踐
到這裡獲取鎖的邏輯就結束了,如果這裡沒有獲取到,在Future的回撥裡面就會直接return,會在外層有一個while true的迴圈,訂閱釋放鎖的訊息準備被喚醒。如果說加鎖成功,就開始執行鎖續命邏輯。
分散式鎖實現原理與最佳實踐
4.1.2 鎖續命邏輯
lua指令碼最後是以毫秒為單位返回key的剩餘過期時間。成功加鎖之後org.redisson.RedissonLock#scheduleExpirationRenewal中將會呼叫org.redisson.RedissonLock#renewExpiration,這個方法內部就有鎖續命的邏輯,是一個定時任務,等10s執行。
執行的時候嘗試執行的續命邏輯使用的是Lua指令碼,當前的鎖有值,就續命,沒有就直接返回0:
分散式鎖實現原理與最佳實踐
返回0之後外層會判斷,延時成功就會再次呼叫自己,否則延時呼叫結束,不再為當前的鎖續命。所以這裡的續命不是一個真正的定時,而是迴圈呼叫自己的延時任務。
分散式鎖實現原理與最佳實踐
4.1.3 迴圈間隔搶鎖機制
如果一開始就加鎖成功就直接返回。
如果一開始加鎖失敗,沒搶到鎖的執行緒就會在while迴圈中嘗試加鎖,加鎖成功就結束迴圈,否則等待當前鎖的超時時間之後再次嘗試加鎖。所以實現邏輯預設是非公平鎖:
分散式鎖實現原理與最佳實踐
裡面有一個subscribe的邏輯,會監聽對應加鎖的key,當鎖釋放之後publish對應的訊息,此時如果沒有到達對應的鎖的超時時間,也會嘗試獲取鎖,避免時間浪費。
4.1.4 釋放鎖和喚醒其他執行緒的邏輯
前面沒有搶到鎖的執行緒會監聽對應的queue,後面搶到鎖的執行緒釋放鎖的時候會傳送一個訊息。
分散式鎖實現原理與最佳實踐分散式鎖實現原理與最佳實踐
訂閱的時候指定收到訊息時候的邏輯:會喚醒阻塞之後執行while迴圈
分散式鎖實現原理與最佳實踐
4.1.5 重入鎖的邏輯
存在對應的鎖,就對對應的hash結構的value直接+1,和Java重入鎖的邏輯是一致的。
分散式鎖實現原理與最佳實踐分散式鎖實現原理與最佳實踐

4.2 RedLock解決非單體專案的Redis主從架構的鎖失效

檢視Redis官方文件,對於單節點的Redis ,使用setnx和lua del刪除分散式鎖是足夠的,但是主從架構的場景下:鎖先加在一個master節點上,預設是非同步同步到從節點,此時master掛了會選擇slave為master,此時又可以加鎖,就會導致超賣。但是如果使用zookeeper來實現的話,由於zk是CP的,所以CP不存在這樣的問題。
Redis文件中給出了RedLock的解決辦法,使用redLock真的可以解決嗎?
4.2.1 RedLock 原理
基於客戶端的實現,是基於多個獨立的Redis Master節點的一種實現(一般為5)。client依次向各個節點申請鎖,若能從多數個節點中申請鎖成功並滿足一些條件限制,那麼client就能獲取鎖成功。它透過獨立的N個Master節點,避免了使用主備非同步複製協議的缺陷,只要多數Redis節點正常就能正常工作,顯著提升了分散式鎖的安全性、可用性。
分散式鎖實現原理與最佳實踐
注意圖中所有的節點都是master節點。加鎖超過半數成功,就認為是成功。具體流程:
  • 獲取鎖

    • 獲取當前時間T1,作為後續的計時依據;

    • 按順序地,依次向5個獨立的節點來嘗試獲取鎖 SET resource_name my_random_value NX PX 30000;

    • 計算獲取鎖總共花了多少時間,判斷獲取鎖成功與否;

    • 時間:T2-T1;

    • 多數節點的鎖(N/2+1);

    • 當獲取鎖成功後的有效時間,要從初始的時間減去第三步算出來的消耗時間;

    • 如果沒能獲取鎖成功,儘快釋放掉鎖。

  • 釋放鎖

    • 向所有節點發起釋放鎖的操作,不管這些節點有沒有成功設定過。





























public String redlock() {    String lockKey = "product_001";    //這裡需要自己例項化不同redis例項的redisson客戶端連線,這裡只是虛擬碼用一個redisson客戶端簡化了    RLock lock1 = redisson.getLock(lockKey);    RLock lock2 = redisson.getLock(lockKey);    RLock lock3 = redisson.getLock(lockKey);
   /**     * 根據多個 RLock 物件構建 RedissonRedLock (最核心的差別就在這裡)     */    RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);    try {        /**         * waitTimeout 嘗試獲取鎖的最大等待時間,超過這個值,則認為獲取鎖失敗         * leaseTime   鎖的持有時間,超過這個時間鎖會自動失效(值應設定為大於業務處理的時間,確保在鎖有效期內業務能處理完)         */        boolean res = redLock.tryLock(10, 30, TimeUnit.SECONDS);        if (res) {            //成功獲得鎖,在這裡處理業務        }    } catch (Exception e) {        throw new RuntimeException("lock fail");    } finally {        //無論如何, 最後都要解鎖        redLock.unlock();    }
   return "end";}

但是,它的實現建立在一個不安全的系統模型上的,它依賴系統時間,當時鍾發生跳躍時,也可能會出現安全性問題。分散式儲存專家Martin對RedLock的分析文章,Redis作者的也專門寫了一篇文章進行了反駁。

Martin Kleppmann:How to do distributed locking

Antirez:Is Redlock safe?

4.2.2 RedLock 問題一:持久化機制導致重複加鎖
如果是上面的架構圖,一般生產都不會配置AOF的每一條命令都落磁碟,一般會設定一些間隔時間,比如1s,如果ABC節點加鎖成功,有一個節點C恰好是在1s內加鎖,還沒有落盤,此時掛了,就會導致其他客戶端透過CDE又會加鎖成功。
4.2.3 RedLock 問題二:主從下重複加鎖
分散式鎖實現原理與最佳實踐
除非多部署一些節點,但是這樣會導致加鎖時間變長,這樣比較下來效果就不如zk了。
4.2.4 RedLock 問題三:時鐘跳躍導致重複加鎖
C節點發生了時鐘跳躍,導致加上的鎖沒有到達實際的超時時間,就被誤以為超時而釋放,此時其他客戶端就可以重複加鎖了。

4.3 Curator

InterProcessMutex 可重入鎖的分析
分散式鎖實現原理與最佳實踐

五、業務中使用分散式鎖的注意點

獲取的鎖要設定有效期,假設我們未設定key自動過期時間,在Set key value NX 後,如果程式crash或者發生網路分割槽後無法與Redis節點通訊,毫無疑問其他 client 將永遠無法獲得鎖,這將導致死鎖,服務出現中斷。
SETNX和EXPIRE命令去設定key和過期時間,這也是不正確的,因為你無法保證SETNX和EXPIRE命令的原子性。
自己使用 setnx 實現Redis鎖的時候,注意併發情況下不要釋放掉別人的鎖(業務邏輯執行時間超過鎖的過期時間),導致惡性迴圈。一般:
1)加鎖的時候需要指定value的內容是當前程式中的當前執行緒的唯一標記,不要使用執行緒ID作為當前執行緒的鎖的標記,因為不同例項上的執行緒ID可能是一樣的。
2)釋放鎖的邏輯會寫在finally ,釋放鎖時候要判斷鎖對應的value,而且要使用lua指令碼實現原子 del 操作。因為if邏輯判斷完之後也可能失效導致刪除別人的鎖。
3)針對扣減庫存這個邏輯,lua指令碼里面實現Redis比較庫存、扣減庫存操作的原子性。透過判斷Redis Decr命令的返回值即可。此命令會返回扣減後的最新庫存,若小於0則表示超賣。

5.1 自己實現分散式鎖的坑

setnx不關心鎖的順序導致刪除別人的鎖
鎖失效之後,別人加鎖成功,自己把別人的鎖刪了。
我們無法預估程式執行需要的鎖的時間。




















public String deductStock() {    String lockKey = "lock:product_101";    Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "deltaqin");    stringRedisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);
   try {        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")        if (stock > 0) {            int realStock = stock - 1;            stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)            System.out.println("扣減成功,剩餘庫存:" + realStock);        } else {            System.out.println("扣減失敗,庫存不足");        }    } finally {        stringRedisTemplate.delete(lockKey);    }
   return "end";}
setnx關心鎖的順序還是刪除了別人的鎖
併發會卡在各種地方,卡住的時候過期了,就會刪掉別人加的鎖:
錯誤的原因還是因為解鎖的邏輯不是原子性的,這裡可以參考Redisson的解鎖邏輯使用lua指令碼實現。
























public String deductStock() {    String lockKey = "lock:product_101";    String clientId = UUID.randomUUID().toString();    Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 30, TimeUnit.SECONDS); //jedis.setnx(k,v)    if (!result) {        return "error_code";    }    try {        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")        if (stock > 0) {            int realStock = stock - 1;            stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)            System.out.println("扣減成功,剩餘庫存:" + realStock);        } else {            System.out.println("扣減失敗,庫存不足");        }    } finally {        if (clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))) {            // 卡在這裡,鎖過期了,其他執行緒又可以加鎖,此時又把其他執行緒新加的鎖刪掉了            stringRedisTemplate.delete(lockKey);        }    }    return "end";}


解決辦法
這種問題解決的辦法就是使用鎖續命,比如使用一個定時任務間隔小於鎖的超時時間,每隔一段時間就給鎖續命,除非執行緒自己主動刪除。這也是Redisson的實現思路。

5.2 鎖最佳化:分段加鎖邏輯

針對一個商品,要開啟秒殺的時候,會將商品的庫存預先載入到Redis快取中,比如有100個庫存,此時可以分為5個key,每一個key有20個庫存。可以把分散式鎖的效能提升5倍。
例如:
  • product_10111_stock = 100

    • product_10111_stock1 = 20

    • product_10111_stock2 = 20

    • product_10111_stock3 = 20

    • product_10111_stock4 = 20

    • product_10111_stock5 = 20
請求來了可以隨機可以輪詢,扣減完之後就標記不要下次再分配到這個庫存。

六、分散式鎖的真相與選擇

6.1 分散式鎖的真相

需要滿足的幾個特性
  • 互斥:不同執行緒、程式互斥。

  • 超時機制:臨界區程式碼耗時導致,網路原因導致。可以使用額外的執行緒續命保證。

  • 完備的鎖介面:阻塞的和非阻塞的介面都要有,lock和tryLock。

  • 可重入性:當前請求的節點+ 執行緒唯一標識。

  • 公平性:鎖喚醒時候,按照順序喚醒。

  • 正確性:程式內的鎖不會因為報錯死鎖,因為崩潰的時候整個程式都會結束。但是多例項部署時死鎖就很容易發生,如果粗暴使用超時機制解決死鎖問題,就預設了下面這個假設:

    • 鎖的超時時間 >> 獲取鎖的時延 + 執行臨界區程式碼的時間 + 各種程式的暫停(比如 GC)

    • 但上述假設其實無法保證的。
將分散式鎖定位為,可以容忍非常小機率互斥語義失效場景下的鎖服務。一般來說,一個分散式鎖服務,它的正確性要求越高,效能可能就會越低。

6.2 分散式鎖的選擇

  • 資料庫:db操作效能較差,並且有鎖表的風險,一般不考慮。

    • 優點:實現簡單、易於理解

    • 缺點:對資料庫壓力大

  • Redis:適用於併發量很大、效能要求很高而可靠性問題可以透過其他方案去彌補的場景。

    • 優點:易於理解

    • 缺點:自己實現、不支援阻塞

    • Redisson:相對於Jedis其實更多用在分散式的場景。

      • 優點:提供鎖的方法,可阻塞

  • Zookeeper:適用於高可靠(高可用),而併發量不是太高的場景。

    • 優點:支援阻塞

    • 缺點:需理解Zookeeper、程式複雜

  • Curator

    • 優點:提供鎖的方法

    • 缺點:Zookeeper,強一致,慢

  • Etcd:安全和可靠性上有保證,但是比較重。
不推薦自己編寫的分散式鎖,推薦使用Redisson和Curator實現的分散式鎖。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024924/viewspace-2996453/,如需轉載,請註明出處,否則將追究法律責任。

相關文章