关于分布式锁:写给小白看的分布式锁教程一-基本概念与使用

91次阅读

共计 6895 个字符,预计需要花费 18 分钟才能阅读完成。

分布式锁在刚毕业的时候就碰到,然而我过后的兴致倒不是很大,起因在于锁后面被分布式所润饰,一下子就变的高端起来了。到当初的话,我也仅仅是停留在回调办法,没有粗疏的梳理一下相干概念。这次就来疏解一下这概念。本篇要求有 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 来实现分布式锁,这两个中间件都有开源的分布式锁框架,咱们间接集成进入我的项目即可。

参考资料

  • 《Hello,分布式锁》https://juejin.cn/book/701839…
  • Redisson https://github.com/redisson/r…

正文完
 0