ZooKeeper 实现分布式锁

1. 前言

在我们的应用中,经常会碰见多个请求去访问同一个资源的情况。如果请求 A 拿到这个资源数据,想要对它进行修改,但是还没有进行事务提交,此时请求 B 访问这个资源就会拿到修改前的数据,很显然请求 B 拿到的是历史数据,是不正确的。

在单个服务器的应用中,我们可以使用系统的线程来对这个资源进行加锁。那么在分布式环境中我们有什么方案来解决这个问题呢?答案就是使用分布式锁。那么什么是分布式锁?分布式锁又是如何实现的呢?本节我们就来讲解如何使用 Zookeeper 实现分布式锁,以及它的实现原理。

2. 分布式锁

在讲解 Zookeeper 实现的分布式锁之前,我们先来了解什么是分布式锁,分布式锁的实现技术,以及分布式锁常用的类型。

2.1 分布式锁的特点

顾名思义,分布式锁就是实现在分布式网络环境中的锁。也就是说,在锁的基础上加上分布式的特性,我们来分析一下分布式锁实现的必要条件:

  1. 在分布式环境中,多个进程对资源的访问必须具有顺序性;
  2. 获取锁和释放锁的过程需要高可用和高性能;
  3. 具有锁失效的机制,避免死锁;
  4. 非阻塞的锁,没有获取到锁直接返回获取锁失败。

介绍了分布式锁的特点,那么有哪些技术能够实现分布式锁呢?

2.2 分布式锁的实现技术

  1. Memcached: 使用 add 命令来添加 keykey 添加成功说明当前无人使用此 key,也就是说无人使用此资源,相当于获取锁。再次使用 add 命令来添加相同的 key 时,此时 key 已存在就会添加失败,说明有人已经使用了这个 key,也就是说此资源被人占用,相当于获取锁失败;
  2. Redis: 使用 setnx 命令来添加 keykey 添加成功说明当前无人使用此 key,也就是说无人使用此资源,相当于获取锁。再次使用 setnx 命令来添加相同的 key 时,此时 key 已存在就会添加失败,说明有人已经使用了这个 key,也就是说此资源被人占用,相当于获取锁失败;
  3. Chubby: Google 使用 Paxos 一致性算法实现的粗粒度分布式锁;
  4. Zookeeper: 使用 Zookeeper 临时顺序节点的特性,实现分布式锁和锁的等待队列。

介绍了分布式锁的实现技术,接下来我们来介绍分布式锁常用的类型。

2.3 分布式锁常用的类型

分布式锁常用的类型有两种:一种是排他锁,一种是共享锁。接下来我们分别介绍这两锁的特点。

  • 排他锁

排他锁也叫独占锁,顾名思义,也就是对资源进行独占。排他锁只允许获取了该锁的线程,对具有排他锁的资源进行访问,无论是写操作还是读操作,直到该线程主动释放掉排他锁。

  • 共享锁

共享锁也就是把资源进行共享,当然共享的只有读操作。共享锁只对写操作进行加锁,其它线程的读操作不做加锁操作,这样的共享机制提高了对资源访问的性能。

介绍完分布式锁的常用类型,接下来我们开始学习如何使用 Zookeeper 实现分布式锁。

3. Zookeeper 实现分布式锁

上面我们提到,Zookeeper 是根据它的临时顺序节点来实现的分布式锁,这里我们来回顾一下临时顺序节点的特性。

3.1 临时顺序节点

临时顺序节点:

  • 节点具有临时性,创建该节点的 Zookeeper 客户端与 Zookeeper 服务端断开连接时,该节点会自动被 Zookeeper 服务端删除;
  • 节点具有顺序性,创建该节点时,Zookeeper 服务端会根据创建时间的顺序在该节点名称后面加上顺序编号。

回顾了临时顺序节点的特性,接下来我们就使用 Zookeeper 的 Java 客户端 Curator 来创建临时顺序节点,我们可以使用在 Zookeeper Curator 一节创建的 Spring Boot 测试项目来进行测试。

我们可以在测试类 CuratorDemoApplicationTests 中编写测试用例:

@SpringBootTest
class CuratorDemoApplicationTests {

    @Autowired
    private CuratorService curatorService;

    @Test
    void contextLoads() throws Exception {
        // 获取客户端
        CuratorFramework client = curatorService.getCuratorClient();
        // 开启会话
        client.start();
        
        // 第一次创建临时顺序节点
        String s1 = client.create()
            // 如果有父节点会一起创建
            .creatingParentsIfNeeded()
            // 节点类型:临时顺序节点
            .withMode(CreateMode.EPHEMERAL_SEQUENTIAL)
            // 节点路径 /wiki
            .forPath("/wiki-");
        // 输出
        System.out.println(s1);
        
        // 第二次创建临时顺序节点
        String s2 = client.create()
            // 如果有父节点会一起创建
            .creatingParentsIfNeeded()
            // 节点类型:临时顺序节点
            .withMode(CreateMode.EPHEMERAL_SEQUENTIAL)
            // 节点路径 /wiki
            .forPath("/wiki-");
        // 输出
        System.out.println(s2);
        
        // 关闭客户端
        client.close();
    }
}

执行测试方法,控制台输出:

/wiki-0000000000
/wiki-0000000001

我们可以发现,控制台一共输出了两个 /wiki 节点,而且每个 /wiki 节点后面都增加了编号,此时我们去 zkCli 命令行客户端查看所有节点,发现并没有 /wiki 节点。因为在我们的测试程序中,我们关闭了客户端,所以临时节点会被移除。

Tips: 如果这里创建失败,请同学们注意父节点是否存在 ACL 访问控制。

回顾了临时顺序节点,那么如何使用 Zookeeper 的临时顺序节点来实现分布式锁呢?接下来我们就开始介绍如何使用 Zookeeper 的临时顺序节点来控制它们的访问顺序。

3.2 分布式锁实现

本节我们来介绍分布式锁实现的具体步骤:

  1. 创建临时顺序节点: 每一次获取资源的请求,我们都需要使用 Zookeeper 客户端创建一个临时顺序节点,用这个临时顺序节点在 Zookeeper 服务端中获取锁。

  2. 获取锁: 这里的锁并不具体指代什么,而是根据 Zookeeper 的临时顺序节点的顺序来决定是否获取了锁。如果该节点的顺序编号是最小的,则说明该节点是排在最前面的,在它之前无人占领资源,也就可以说该节点获取了锁,具有访问资源的权限。

图片描述

  1. 监听锁: 如果获取锁这一步发现 Zookeeper 客户端创建的临时顺序节点的顺序编号不是最小的,也就是在这个临时顺序节点之前存在其它临时顺序节点,那么就可以说这个节点获取锁失败了,它会进入等待队列。我们可以监听它的前一个节点,只要它的前一个临时顺序节点的删除事件触发,我们就可以获取临时顺序节点的列表来重新确认这个节点的顺序。

图片描述

  1. 释放锁: 当一个请求对资源的操作结束后,我们可以使用 Zookeeper 客户端的节点删除 API 来删除这个请求创建的临时顺序节点。除了使用 API 来主动释放锁之外,根据临时顺序节点的特性,当创建这个临时顺序节点的 Zookeeper 客户端与 Zookeeper 服务端断开连接时,这个临时顺序节点会被 Zookeeper 服务端移除。这两种方式都会触发临时节点的删除事件,让下一个临时顺序节点来确认自身的顺序。

4. 总结

本节内容中,我们学习了什么是分布式锁,以及它的特点和类型,还学习了使用 Zookeeper 实现分布式锁的主要步骤。以下是本节内容的总结:

  1. 分布式锁的特点和常用类型。
  2. 临时顺序节点的特性。
  3. 使用 Zookeeper 实现分布式锁的主要步骤。