什么是分布式锁

我们在开发应用的时候,如果需要对某一个共享数据进行多线程同步访问(增加、减少等)的时候,可以使用锁进行处理,防止在多线程环境下数据表现不一致。如果该应用部署在多个节点上面,我们就不能运用只是在单机上面表现的锁。解决分布式应用下数据一致性问题的锁称为分布式锁。
分布式锁 - 图1
对变量A的操作锁定就需要用到分布式锁

分布式锁特点

用一些中间件进行数据标识来实现分布式锁是常见的一种做法,对于分布式锁在应用中一般都会具备以下几个特点

  • 排他性:任意时刻,只能有一个客户端能获取到锁。
  • 容错性:只要分布式锁服务集群节点大部分存活,客户端就可以进行加锁解锁操作。
  • 避免死锁:分布式锁一定能得到释放,即使客户端在释放之前崩溃或者网络不可达。

    常见的实现方案

  1. 基于数据库实现分布式锁基于数据库实现分布式锁我们可以有两种方案
    • 乐观锁:通过数据的版本号进行判断是否给与操作
    • 悲观锁:可以用for update语句数据库自带的排它锁,也可以自行设计逻辑标识,例如创建一个表,给一个字段上唯一约束,加锁就是向该表插入数据,该字段就是锁的标识,同一时间多个请求提交到数据库由于有唯一约束也只会有一个请求数据插入成功,也就代表加锁成功,解锁就是删除数据。
  2. 基于Redis实现分布式锁
    基于Redis实现的锁机制,主要是依赖redis自身的原子操作。可以通过向Redis里面存入一个值来代表加锁,值如果存入成功表示加锁成功,存入的时候如果值已经存在就不能存入,代表加锁失败,解锁就是删除改值。可以给值设置过期时间来防止死锁。
  3. Zookeeper分布式锁
    ZooKeeper是一个为分布式应用提供一致性服务的开源组件,它内部是一个分层的文件系统目录树结构,规定同一个目录下只能有一个唯一文件名。
    分布式锁 - 图2
    当某客户端要进行逻辑的加锁时,就在zookeeper上的某个指定节点的目录(locker目录)下生成一个唯一的临时有序节点(locker/node_N),然后判断自己是否是这些有序节点中序号最小的一个,如果是,则算是获取了锁。如果不是,则说明没有获取到锁,那么就需要在序列中找到比自己小的那个节点,并对其调用exist()方法,对其注册事件监听,当监听到这个节点被删除了,那就再去判断一次自己当初创建的节点是否变成了序列中最小的。如果是,则获取锁,如果不是,则重复上述步骤。
    当释放锁的时候,只需将这个临时节点删除即可。

    三种方案的对比

  • 数据库
    实现简单,数据库性能存在瓶颈,不适合高并发场景,锁的失效时间难以控制,删除锁失败容易导致死锁。即这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。一般在分布式系统中使用这种机制实现分布式锁时,需要业务侧增加控制锁超时和重试的流程。
  • Redis
    性能好,实现起来较为方便。单点问题。这里的单点指的是单master,就算是个集群,如果加锁成功后,锁从master复制到slave的时候挂了,也是会出现同一资源被多个client加锁的。redis的设计定位决定了它的数据并不是强一致性的,在某些极端情况下,可能会出现问题,不够健壮。即便使用redlock算法来实现,在某些复杂场景下,也无法保证其实现100%没有问题。
  • Zookeeper
    能有效的解决单点问题,非阻塞问题以及锁无法释放的问题。实现起来较为简单。性能上不如使用缓存实现分布式锁,如果有较多的客户端频繁的申请加锁、释放锁,对于zk集群的压力会比较大。

如何选择?
如果系统不想引入过多网元,可以采用数据库锁实现,好处就是比较容易理解,但是这种方案业务层控制逻辑多且复杂,需要对业务有足够了解,易于理解但是实现复杂度最高。
如果追求高性能,Redis是最佳选择,但是redis是有可能存在隐患的,可能会导致数据不对的情况,可靠性不如ZK。
如果系统已经存在ZK集群,优先选用ZK实现,实现最简单,且可以提供高可靠性,性能稍逊Redis缓存方案。

Redis实现分布式锁的原理以及流程

如何通过Redis来进行加锁?我们其实可以通过向Redis里面存一个值(Key-Value)来表示一把锁,key就是锁的标识(一般可以通过业务中的数据ID跟上前缀或则后缀的方式),Value可以是一个唯一值(可以用UUID),用来标识加锁方是谁。
具体加锁步骤可以如下:

  1. 准备向Redis里面存入一个值
  2. 判断如果该值存在就说明已经有其他线程加锁成功,现在不能存入只能等待或者加锁失败
  3. 如果该值不存在则当前线程存入成功(加锁成功)
  4. 对该值设置过期时间(锁可以在一定时间之后自动释放)

我们需要注意的是以上步骤的执行必须具备原子性,不然加锁逻辑自身就会导致冲突,如何保证原子性,我们可以使用set命令参数如下:

  1. SET KEY VALUE EX 20 NX

如果是在SpringBoot里面我们可以调用方法:

stringRedisTemplate.opsForValue().setIfAbsent(key, value, 20, TimeUnit.SECONDS)

Redis加锁思路其实就是存入代表锁的值,谁存入了谁就拥有这把锁。解锁就是删除该值。

实现加锁跟解锁方法

我们知道了在Redis里面加锁与解锁的流程,现在通过代码来实现加锁跟解锁的方法

@Component
public class RedisLock {
    @Autowired
    private StringRedisTemplate srt;
    //锁的超时时间    
    private Integer lockTimeOut = 20;
    //获取锁超时时间
    private Integer timeOut = 20;
    /**
     * 加锁
     */
    public boolean lock(String key) throws RedisLockException {
        //生成存入redis锁的value值
        String uuid = UUID.randomUUID().toString();
        boolean b = false; 
        Long time = System.currentTimeMillis();
        while(!(b = srt.opsForValue().setIfAbsent(key, uuid, lockTimeOut, TimeUnit.SECONDS))) {
            //判定获取锁是否超时
            if(System.currentTimeMillis() - time >= timeOut * 1000) {
                throw new RedisLockException("加锁超时");
            }
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        return b;
    }
    /**
     * 解锁
     */
    public void unLock(String key) {
        if(StringUtils.isNotBlank(key)) {
            srt.delete(key);
        }
    }
}

Redisson跟Jedis、Lettuce的区别

Redis推荐的三大客户端:Jedis、Lettuce、Redisson。在之前我们使用过了Jedis客户端,在SpringBoot集成操作的时候介绍了Lettuce,
今天我们来看一下Redisson,首先看下三个框架的各自特点:

  • Jedis:
    提供了比较全面的Redis命令的支持, 比较全面的提供了Redis的操作特性,使用阻塞的I/O,且其方法调用都是同步的,程序流需要等到sockets处理完I/O才能执行,不支持异步。Jedis客户端实例不是线程安全的,所以需要通过连接池来使用Jedis。
  • Lettuce:
    高级Redis客户端,用于线程安全同步,异步和响应使用,支持集群,Sentinel,管道和编码器。主要在一些分布式缓存框架上使用比较多。基于Netty框架的事件驱动的通信层,其方法调用是异步的。Lettuce的API是线程安全的,所以可以操作单个Lettuce连接来完成各种操作。
  • Redisson:
    实现了分布式和可扩展的Java数据结构。促使使用者对Redis的关注分离,提供很多分布式相关操作服务,例如,分布式锁,分布式集合,可通过Redis支持延迟队列。基于Netty框架的事件驱动的通信层,其方法调用是异步的。Redisson的API是线程安全的,所以可以操作单个Redisson连接来完成各种操作。

从上面我们可以看出,Redisson提供了各种分布式对象的操作,其中就包括分布式锁。

SpringBoot集成Redisson

  1. 首先需要导入集成包
    <dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.14.0</version>
    </dependency>
    <!--  
    有了Redisson的集成包之后,SpringBoot默认的调用就是Redisson客户端,所以这个包可以去掉
    <dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    -->
    
    2.由于是SpringBoot的集成,所以对Redis的常规命令操作没有任何变换,还是SpringBoot提供的模板对象
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    3.如果我们要使用Redisson特有的功能,例如分布式锁,我们需要用到Redisson提供的客户端对象
    @Autowired
    private RedissonClient redissonClient;
    
    4 使用Redisson在购买商品减少库存的时候进行加锁
    @Service
    public class OrderService {
     @Autowired
     private RedissonClient rc;
     @Autowired
     private GoodsMapper goodsMapper;
     /**
     * 减少库存
     */
     public Integer reduceCont(OrderParameter op) {
         /*
         *通过Redisson客户端获取可重入锁对象
         *通过商品前缀与当前商品id值构成锁的key
         */
         RLock lock = rc.getLock("order:goods:id:"+op.getGoodsId());
         try { 
             //进行加锁,最多等待20秒,上锁后20秒自动解锁
             boolean res = lock.tryLock(20, 20, TimeUnit.SECONDS);
             //加锁成功执行库存减少业务
             if(res) {
                 Goods goods = goodsMapper.selectById(op.getGoodsId());
                 if(goods.getCont() >= op.getCont()) {
                     goods.setCont(goods.getCont() - op.getCont());
                     return goodsMapper.updateById(goods) > 0 ? op.getCont() : -1;
                 }
             }
         } catch (InterruptedException e) {
             e.printStackTrace();
         } finally {
             //解锁
             if(lock != null) lock.unlock();
         }
         return -1;
     }
    }