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

一、超賣問題復現

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

直接維護一張鎖表:

@Autowired
private MethodlockMapper methodlockMapper;

@Override
public boolean tryLock() {
    try {
        //插入一條數據   insert into
        methodlockMapper.insert(new Methodlock("lock"));
    }catch (Exception e){
        //插入失敗
        return false;
    }
    return true;
}

@Override
public void waitLock() {
    try {
        Thread.sleep(10);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

@Override
public 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

封裝一個鎖對象:

@Slf4j
public 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 的觀察器只可以監控一次,數據發生變化之後可以發送給客戶端,之後需要再次設置監控。exists、create、getChildren三個方法都可以添加watcher ,也就是在調用方法的時候傳遞true就是添加監聽。注意這裏Lock 實現了Watcher和AutoCloseable:

當前線程創建的節點是第一個節點就獲得鎖,否則就監聽自己的前一個節點的事件:

/**
 * 自己本身就是一個 watcher,可以得到通知
 * AutoCloseable 實現自動關閉,資源不使用的時候
 */
@Slf4j
public 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依賴中,不要引用錯了。

@Autowired
private CuratorFramework client;

@Test
public 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項目引入

https://redisson.org/

引入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="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:redisson="http://redisson.org/schema/redisson"
       xsi:schemaLocation="
       http://www.springframework.org/schema/beans
       http://www.springframework.org/schema/beans/spring-beans.xsd
       http://www.springframework.org/schema/context
       http://www.springframework.org/schema/context/spring-context.xsd
       http://redisson.org/schema/redisson
       http://redisson.org/schema/redisson/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即可。

https://github.com/redisson/redisson/tree/master/redisson-spring-boot-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 設置配置類

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

3.6.4 使用

@Test
public 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,此處不再贅述:https://time.geekbang.org/column/article/350285

四、常見分佈式鎖的原理

4.1 Redisson

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

//lua腳本命令執行方式:redis-cli --eval /tmp/test.lua , 10
jedis.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主從架構的鎖失效

https://redis.io/docs/manual/patterns/distributed-locks/

查看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

https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html

Antirez:Is Redlock safe?

http://antirez.com/news/101

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實現的分佈式鎖。

作者 | 秦澤濤

原文鏈接

本文爲阿里雲原創內容,未經允許不得轉載。

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