头图

分布式锁在刚毕业的时候就碰到,但是我当时的兴致倒不是很大,原因在于锁前面被分布式所修饰,一下子就变的高端起来了。到现在的话,我也仅仅是停留在回调方法,没有细致的梳理一下相关概念。这次就来疏解一下这概念。本篇要求有Zookeeper、Redis的基础。如果不会可以我掘金的文章:

  • 《Zookeeper学习笔记(一)基本概念和简单使用》 这篇公众号里面有
  • 《Redis学习笔记(一) 初遇篇》 这篇还未迁移到公众号

当我们说起锁时,是在说什么?

写本篇的时候我首先想的是现实世界的锁,像下面这样:

锁

现实世界的锁为了保护资源设计,持有钥匙的人被视为资源的主人,可以获取被锁保护的资源。在现实世界中的锁大都基于这种设计而生,像门锁防止门里面的资源被盗窃,手机上的指纹锁保护手机的资源。那软件世界的锁是一种什么样的概念呢?也是为了实现对资源的保护而生吗? 某种程度上可以这么理解,以多线程下卖票为例,如果不加上锁,那么就会由可能实现两个线程共同卖一张票的情况。所以我们对获取票,并对总票数进行减去的这个操作加上了synchronized。

public class TicketSell implements Runnable {
    // 总共一百张票
    private int total = 2000;
    @Override
    public void run() {
        while (total > 0) {
            // total-- 这个操作并非是原子操作, 可能会被打断。
            // A线程可能还没拉的及完成减减操作,时间片耗尽。B线程被进来也读取到了total的值就会出现
            // 两个线程出现卖一张票的情况
            System.out.println(Thread.currentThread().getName() + "正在售卖:" + total--);
        }
    }
}
 public static void main(String[] args) {
        TicketSell ticketSell = new TicketSell();
        Thread a =  new Thread(ticketSell, "a");
        Thread b = new Thread(ticketSell, "b");
        a.start();
        b.start();
}

避免这样的情况,我们可以用悲观锁synchronzed、ReentrantLock锁住代码块来实现避免超卖或者重复买的原因在于我们开启的两个线程都属于一个JVM进程,total也位于JVM进程内,JVM可以用锁来保护这个变量。那如果我们将这个票数移动到数据库内表里,我们上的锁还管用吗? 肯定是不管用了,原因在于在JVM进程内的归JVM进程管,数据库属于另一个进程,JVM的锁无法锁另一个进程的变量。 下面我们将这个总票数移动到数据库里,重写一下这个卖票的程序, 首先我们准备一张表,这里为了图省事,就用我手头里面的Student的最大number来充当总票 , 建表语句如下:

CREATE TABLE `student`  (
  `id` int(11) NOT NULL,
  `name` varchar(255) CHARACTER SET latin1 COLLATE latin1_swedish_ci NULL DEFAULT NULL,
  `number` int(255) NULL DEFAULT NULL COMMENT '学号',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

我们表里面目前最大的number是5,id是5。我们的模拟卖票变量如下:

@RestController
public class TicketSellController {

    @Autowired
    private StudentInfoDao studentInfoDao;

    @GetMapping("sell")
    public String sellOne() throws Exception{
        Student student = studentInfoDao.selectById(5);
        Integer number = student.getNumber();
        // 线程模拟五秒,模拟做业务操作
        TimeUnit.SECONDS.sleep(5);
        student.setNumber(--number);
        studentInfoDao.updateById(student);
        return "更新成功";
    }

}

在postman里启动两个请求,会发现我们发出了两个请求事实上数据库的票只少了一张。这个TickSellController也可以多机部署。这里我们分析一下synchronized可以保证TickSell不会出现多卖这样的情况:

  • synchronized 具备互斥性 线程在进入被synchronized修饰的代码块,未执行完。其他线程进入会陷入阻塞。

那如何在线程访问数据库数据的时候实现类似synchronized的效果呢?我们当前的目标就是加强版的synchronized,有人想到了SELECT ... FOR UPDATE,但如果你去尝试的话,会发现这并不是一个可行的操作。原因在于这个锁定时间受制于MySQL内的最长事务锁定时间,数据库也未提供对应方法让我们查询对应的是否有锁,如果两个事务都去执行select for update。的确是只有一个事务会执行成功,但另一个事务会一直等待另一个事务执行完成再去执行。我们期待的方法是获取锁的时候先看看上面是否有锁,如果有锁,这个时候参照着synchronized的锁升级,我们可以有两种策略,第一种就是不断的再次重新获取锁,第二种就是获取锁失败就陷入阻塞,等待锁持有者唤醒。

那select for update 行不通,数据库还有唯一索引,所以我们可以建一张表,表里面的唯一索引是商品数量,所以在卖票的时候首先根据商品表的数量和id去数据库插入一条记录,如果失败了,就代表抢锁失败。但这个表该怎么设计? 为每张需要做控制的表建一张表,这不通用。那一张通用的表应该有哪几个字段呢?首先是id,在现代高级语言中都是以方法为单位的,所以需要一个方法字段,这其实对于单体应用是足够的,但是如果我们将应用进行分拆,也不一定是微服务,那么就在不同的应用中就可能会出现不同的项目名,相同的方法名存在。所以还需要有个项目名字段。但是不同项目的相同方法名,操纵的可能是不同的资源,所以这里还需要一个资源ID。但如果某应用出现了集群部署呢,所以这里我们还需要一个机器ip。

其实到这里还有一个场景,同学们可能没想到,我们的锁还要支持重入, 即我们设计的synchronized还要支持可重入,即锁的持有者再次申请获得锁应当能重新获取到,实际的场景如下:

// 这里只是为了说明锁重入的必要性,这个没设置递归结束条件
public void distributedLock(){
    Lock lock = new ReentrantLock();
    distributedLock();  
}

所以这里我们还需要记录持有锁的线程和重入次数,所以最终的建表脚本如下所示:

DROP TABLE IF EXISTS `distributed_Lock`;
CREATE TABLE `distributed_Lock` (
  `id` int NOT NULL,
  `lock_key` varchar(100) NOT NULL,
  `thread_id` int NOT NULL,
  `entry_count` int NOT NULL,
  `host_ip` varchar(30) NOT NULL
  PRIMARY KEY (`id`),
  UNIQUE KEY `lock_key` (`lock_key`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

lock_key 由当前项目名-方法名-资源名构成。所以我们现在获取锁的逻辑如下,首先判断有没有锁,有锁然后判断是否是自己的锁,不是自己的锁就进行重试。无锁就尝试进行加锁,加锁失败也进入重试。可能有同学看到这里会问,不是已经判断无锁了,怎么还会加锁失败啊。我们的操作如下:

# 如果查不到代表没锁
select * from distributed_Lock where lockKey = '' # 语句一
# 然后进行加锁
insert into distributed_Lock # 语句二

可能两个线程相差很短都执行了语句一,都得出无锁,然后唯一索引就只会有一个插入记录成功。那重试该怎么重试呢? 其实这里也可以做一个重试器,有两种重试策略:

  • 重试失败,再次重试。直到达到最大重试次数。
  • 重试失败,等待一段时间,再重试。

获取锁,重试我们这里大致设计完毕,那怎么释放锁呢? 我们在加锁,做完操作之后,直接释放? 那如果刚加锁成功 , 然后应用宕机了,所以为了追求我们系统的高可用,我们需要准备一个定时任务来做释放锁的操作,引入一个新的工具去解决问题,其实这个工具还会带来新的问题,比如我们用定时任务去避免这种情况下的死锁,但如果怎么判定是否发生了死锁呢? 所以我们的表里面还要存一个锁持有时间? 然后快到达时间的时候进行锁续期。如果我们要用数据库去做资源控制,那么对应编程语言层面工具类的构成就有以下几个组成:

  • 加锁
  • 重试
  • 锁续期

分布式锁

其实上面我们讨论的就是基于数据库实现的分布式锁,上面我们用唯一索引来实现对资源的保护,那什么是分布式锁:

分布式锁,是控制分布式系统之间同步访问共享资源的一种方式。在分布式系统中,常常需要协调他们的动作。如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰来保证一致性,在这种情况下,便需要使用到分布式锁。

上面我们基于数据库实现分布式锁,步骤是比较多的,花费的成本也高。这是分布式锁的一种使用场景: 秒杀减库存等类似业务防止超卖的情况。我们也用分布式锁做:

  • 防止缓存击穿
比如缓存中的某个key过期了,为了避免访问这个key的访问量都打到数据库上,我们可以用分布式锁做控制,保证一个访问打到数据库上,其他的访问失败。等到数据库加载到缓存完毕,再释放锁。
  • 保证接口幂等性
表单的重复提交,分布式锁也可以解决这种场景,接口添加分布式锁,第二次访问发现有锁提示已经提交过了。
  • 任务调度
上面我们讨论用定时任务防止死锁,但定时任务所在的应用也有可能挂掉,所以我们为了追求高可用可以多部署几个,但是只能让一个跑。

我们实际上常用Redis和Zookeeper来实现分布式锁,原因在于基于内存性能比较高,丰富的特性,不用让我们花太多手脚就能构建起一个分布式锁。

用Redis实现分布式锁

上面我们在讨论用数据库实现分布式锁的时候,提到了为了防止加锁成功,还未成功释放锁,应用宕机,造成死锁。为了追求高可用,我们引入了定时任务去扫描这些异常的锁占用,去释放锁。Redis 里面刚好有设置key缓存时间的命令,还是原子的。因此我们就不必担心死锁了。但是设置多长时间这又是个问题,我们的希望是刚刚好,所以这里我们引入锁续期,即已经超过锁缓存时间的三分之一还没有释放锁,那么为这个锁重新续上加锁时间。现在我们面临的另一个问题就是如何部署Redis:

  • 单机
缺点很明显,这台Redis不小心宕机,整个分布式锁不可用。
  • 哨兵
那为了追求高可用, 我多部署了几台Redis,在主节点不可用的时候,会自动的选取从节点提拔为主节点。但还是有问题,如果我刚写到主节点,还没来得及同步完成,主节点就宕机了,这不是又不行了吗?
  • RedLock
大名鼎鼎的红锁,一个主节点不行,那就多来几个主节点,挨个加锁,只要加锁超过一半以上就代表加锁成功,释放的时候也是逐台释放。这样的话,就算一个主节点挂了,还有其他备胎。看起来已经完美的解决了问题,其实还是有漏洞,我们没有可能在一篇文章中讲清楚这个漏洞,会放在后面讲。这里我们只是做简单了解。

我们要从零开始实现一个基于Redis实现的分布式锁吗? 当然不用,基本上主流高级语言都有封装完好的实现,这里我们选择介绍Java领域的RedisSession。

标准实现-java

第一步仍然是引入maven依赖:

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

简单示例:

 Config config = new Config();
 config.useClusterServers()
                // 可以添加多个ip
                .addNodeAddress("redis://127.0.0.1:7181");
 RedissonClient redisson = Redisson.create(config);
        // 获取非公平锁
 RLock lock = redisson.getLock("myLock");
        // 读写锁
 RReadWriteLock readWriteLock = redisson.getReadWriteLock("myLock");
        //公平锁
redisson.getFairLock("myLock");
        // 自旋锁
 redisson.getSpinLock("myLock");

boolean lockResult = lock.tryLock();

lock.unlock();// 释放锁

用Zookeeper实现分布式锁

我们在《Zookeeper学习笔记(一)基本概念和简单使用》已经介绍了用Zookeeper的临时顺序节点来实现分布式锁,不用担心加锁之后客户端宕机引发的死锁问题,原因在于客户端宕机之后,临时节点随之消失。Zookeeper的节点是通过session来续期的,Zookeeper有个心跳链接的概念,如果Zookeeper服务器长时间没收到Session的心跳,就会认为这个客户端失活,就会把对应的节点删除。

所以用Zookeeper实现分布式锁相对简单很多,客户端请求Zookeeper创建节点,然后判断自己的节点值是否是最小的,如果最小的代表抢锁成功,其他节点开通监听器监听上一个节点,上一个节点消失就代表可以获取锁。这是公平锁的实现。我们当然也不会从零实现这个分布式锁。Zookeeper有了很强大的客户端来支持分布式锁,它的名字叫Curator 。下面我们来简单介绍它的基本使用:

  <dependency>
            <groupId>org.apache.curator</groupId>
            <artifactId>curator-recipes</artifactId>
            <version>5.3.0</version>
        </dependency>
 <dependency>
            <groupId>org.apache.curator</groupId>
            <artifactId>curator-client</artifactId>
            <version>5.1.0</version>
 </dependency>
 // 连接不上,执行重试,重试四次。两次重试间隔400ms
RetryNTimes retryNTimes = new RetryNTimes(4,400);
CuratorFramework client = CuratorFrameworkFactory.newClient("", retryNTimes);
client.start();
InterProcessMutex lock = new InterProcessMutex(client, ""); // 可重入公平锁
InterProcessSemaphoreMutex interProcessSemaphoreMutex = new InterProcessSemaphoreMutex(client,"");// 不可重入非公平锁
InterProcessReadWriteLock interProcessReadWriteLock = new InterProcessReadWriteLock(client,"");//可冲入读写锁
lock.acquire(); // 加锁
lock.release();

总结一下

在分布式系统中,访问共享资源,常常需要协调他们的动作,每个应用在访问共享资源的时候,抢中的对共享资源进行操作,未抢中的进入等待或其他状态,这也就是分布式锁,独占式的访问共享资源。分布式锁是一种思想,一个设计良好的分布式锁应当具备以下特点:

  • 高可用
  • 可重入
  • 不会出现死锁
  • 互斥

主流的有三种实现:

  • 基于数据库实现分布式锁
  • 基于Redis实现分布式锁
  • 基于Zookeeper分布式锁

我们在实际开发中一般使用Redis和Zookeeper来实现分布式锁,这两个中间件都有开源的分布式锁框架,我们直接集成进入项目即可。

参考资料


北冥有只鱼
147 声望35 粉丝