Zookeeper 使用 Java 实现分布式锁对共享资源的并发访问控制

我们如果只有一个程序,在运行时需要控制共享资源的并发访问时,只需要在程序中采用 synchronized 或者 Lock 的方式,解决多线程之间的代码同步问题即可,这时多线程都是在同一个 JVM 中运行的,不存在任何问题。但是如果我们是在分布式集群环境下运行相同的程序访问共享资源时,这就需要在多个 JVM 环境下控制并发访问,跨 JVM 之间已经无法通过多线程的锁来解决同步问题,只能通过分布式锁来解决问题了。

Zookeeper 和 Redis 都能实现分布式锁的功能,两者各有优势,相比而言,Redis 性能最好,但是 Zookeeper 安全稳定性最好。本篇博客主要通过 Java 代码操作 Zookeeper 实现分布式锁控制共享资源的并发访问,在博客的最后会提供源代码下载。


一、Zookeeper 分布式锁实现原理

Zookeeper 实现分布式锁的主要核心思想就是:多个客户端在相同的节点下,创建临时有序节点,然后通过判断自己创建的节点序号是否是最小,从而确定是否获取到了锁,在获取到锁并操作完共享资源后,删掉自己创建的临时节点,从而释放锁。具体实现细节如下:

  1. 多个客户端连接到 Zookeeper ,在同一个节点下(比如 /jobs/lock 节点下)创建临时有序节点。

  2. 客户端获取 /jobs/lock 下的所有子节点,判断自己创建的子节点序号是否最小,如果序号最小,就认为该客户端获取到了锁。

  3. 如果客户端发现自己创建的子节点序号不是最小的,说明自己还没有获取到锁,此时客户端需要找到比自己小的那个节点,对其注册删除事件的监听器。

  4. 如果客户端发现比自己小的那个节点被删除,则该客户端会受到通知,此时再次判断自己创建的子节点是否是 /jobs/lock 下所有子节点中序号最小的,如果是则获取到了锁,如果不是则重复以上步骤继续获取到比自己小的一个节点并注册删除事件的监听器。

虽然 Zookeeper 分布式锁的实现原理比较复杂,但是这些都已经被封装到第三方组件 Curator 的 API 方法中了,使用起来非常容易,甚至无法感知。因此我们只需要了解有关原理即可,重点要放在对代码实现的掌握即可。


二、搭建工程

新建一个 maven 项目,导入相关 jar 包,内容如下:

有关具体的 jar 包地址,可以在 https://mvnrepository.com 上进行查询。

<dependencies>

    <!--导入 Spring 相关的 jar 包-->
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-context</artifactId>
        <version>5.3.18</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-jdbc</artifactId>
        <version>5.3.18</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-test</artifactId>
        <version>5.3.18</version>
        <scope>test</scope>
    </dependency>

    <!--导入 junit 的 jar 包-->
    <dependency>
        <groupId>junit</groupId>
        <artifactId>junit</artifactId>
        <version>4.13.2</version>
        <scope>test</scope>
    </dependency>

    <!--导入 mysql 和 druid 连接池相关的 jar 包-->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.28</version>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid</artifactId>
        <version>1.2.8</version>
    </dependency>

    <!--导入 curator 的 jar 包-->
    <dependency>
        <groupId>org.apache.curator</groupId>
        <artifactId>curator-framework</artifactId>
        <version>4.0.0</version>
    </dependency>
    <dependency>
        <groupId>org.apache.curator</groupId>
        <artifactId>curator-recipes</artifactId>
        <version>4.0.0</version>
    </dependency>

    <!--导入日志相关的 jar 包-->
    <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-api</artifactId>
        <version>1.7.21</version>
    </dependency>
    <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-log4j12</artifactId>
        <version>1.7.21</version>
    </dependency>
</dependencies>

搭建后的最终工程如下图所示,非常简单:

image

本篇博客的 Demo 中准备了两个测试类:zkLockTest1 和 zkLockTest2,各自只有一个测试方法,都是实现对数据库中相同的一张表的数据进行修改。通过在短时间内先后运行这两个测试类的方法,来演示采用 zookeeper 分布式锁控制并发访问。为了简化代码结构和减少代码量,本篇采用了 JdbcTemplate 操作 mysql 数据库。在 Demo 运行结束后,可以在 IDEA 控制台和数据库中查看和验证运行效果。


三、配置相关

首先在 mysql 中运行以下脚本,创建表和数据:

CREATE DATABASE IF NOT EXISTS `testdb` /*!40100 DEFAULT CHARACTER SET utf8 */;
USE `testdb`;

CREATE TABLE IF NOT EXISTS `data_test` (
  `data_id` int(11) NOT NULL,              //主键
  `data_version` varchar(50) DEFAULT NULL, //修改内容
  `thread1_update` int(11) DEFAULT NULL,   //是否是【线程1】修改
  `thread2_update` int(11) DEFAULT NULL,   //是否是【线程2】修改
  `update_time` datetime DEFAULT NULL,     //膝盖时间
  PRIMARY KEY (`data_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

INSERT INTO `data_test`
(`data_id`, `data_version`, `thread1_update`, `thread2_update`, `update_time`)
VALUES (1, NULL, NULL, NULL, NULL),(2, NULL, NULL, NULL, NULL),
(3, NULL, NULL, NULL, NULL),(4, NULL, NULL, NULL, NULL),
(5, NULL, NULL, NULL, NULL),(6, NULL, NULL, NULL, NULL),
(7, NULL, NULL, NULL, NULL),(8, NULL, NULL, NULL, NULL),
(9, NULL, NULL, NULL, NULL),(10, NULL, NULL, NULL, NULL),
.....
(3000, NULL, NULL, NULL, NULL);

在 jdbc.properties 文件中配置数据库连接相关的参数:

mysql.driver=com.mysql.cj.jdbc.Driver
mysql.url=jdbc:mysql://localhost:3306/testdb?useSSL=false
mysql.username=root
mysql.password=123456

# 初始化连接的数量
druid.initialSize=3
# 最大连接的数量
druid.maxActive=20
# 获取连接的最大等待时间(毫秒)
druid.maxWait=3000

对应的 jdbcConfig 类实现对数据库连接参数的读取,以及获取 JdbcTemplate 对象:

package com.jobs.config;

import com.alibaba.druid.pool.DruidDataSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.PropertySource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;

import javax.sql.DataSource;

//通过 @PropertySource 注解,加载 jdbc.properties 文件的配置信息
@PropertySource("classpath:jdbc.properties")
public class jdbcConfig {

    //通过 @Value 注解,获取 jdbc.properties 文件中相关 key 的配置值
    @Value("${mysql.driver}")
    private String driver;
    @Value("${mysql.url}")
    private String url;
    @Value("${mysql.username}")
    private String userName;
    @Value("${mysql.password}")
    private String password;

    @Value("${druid.initialSize}")
    private Integer initialSize;
    @Value("${druid.maxActive}")
    private Integer maxActive;
    @Value("${druid.maxWait}")
    private Long maxWait;

    //让 Spring 装载 druid 具有数据库连接池的数据源
    @Bean
    public DataSource getDataSource() {
        DruidDataSource ds = new DruidDataSource();
        ds.setDriverClassName(driver);
        ds.setUrl(url);
        ds.setUsername(userName);
        ds.setPassword(password);
        ds.setInitialSize(initialSize);
        ds.setMaxActive(maxActive);
        ds.setMaxWait(maxWait);
        return ds;
    }

    //让 Spring 装载 NamedParameterJdbcTemplate 对象
    //NamedParameterJdbcTemplate 可以忽略 sql 语句的参数顺序,可以使用参数名称对应参数值,很方便
    @Bean
    public NamedParameterJdbcTemplate getJdbcTemplate(@Autowired DataSource dataSource) {
        return new NamedParameterJdbcTemplate(dataSource);
    }
}

在 zookeeper.properties 文件中配置 Zookeeper 连接相关的参数:

# zookeeper的连接字符串
# 如果是操作 zookeeper 集群,可以配置多个 zookeeper 地址
# 多个地址之间用英文逗号分隔,如 ip1:port1,ip2:port2,ip3:port3
zk.connectString=127.0.0.1:2181

# zookeeper的会话超时时间
# 单位:毫秒,默认是 60 秒
zk.sessionTimeoutMs=60000

# zookeeper的连接超时时间
# 单位:毫秒,默认是 15 秒
zk.connectionTimeoutMs=15000

# zookeeper默认操作的根节点
# 所有的增删改查操作,默认在该节点下进行
zk.namespace=jobs

对应的 zookeeperConfig 类实现对 Zookeeper 连接参数的读取,以及获取 Curator 客户端连接对象:

package com.jobs.config;

import org.apache.curator.RetryPolicy;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.PropertySource;

//加载 zookeeper.properties 文件内容
@PropertySource("classpath:zookeeper.properties")
public class zookeeperConfig {

    @Value("${zk.connectString}")
    private String connectString;

    @Value("${zk.sessionTimeoutMs}")
    private Integer sessionTimeoutMs;

    @Value("${zk.connectionTimeoutMs}")
    private Integer connectionTimeoutMs;

    @Value("${zk.namespace}")
    private String namespace;

    //获取 Curator 的客户端连接
    @Bean
    public CuratorFramework getCuratorFramework(){
        //重试策略,如果没有连接失败,最多重试 1 次
        RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 1);
        CuratorFramework client =
                CuratorFrameworkFactory.builder()
                        .connectString(connectString)
                        .sessionTimeoutMs(sessionTimeoutMs)
                        .connectionTimeoutMs(connectionTimeoutMs)
                        .namespace(namespace)
                        .retryPolicy(retryPolicy)
                        .build();
        client.start();
        return client;
    }
}

由于 Curator 组件需要用到 Log4j 日志,因此需要在添加 log4j.properties 配置文件,具体内容如下:

log4j.rootLogger=WARN, stdout

# 如果你既要控制台打印日志,也要文件记录日志的话,可以使用下面这行配置
# log4j.rootLogger=WARN, stdout, logfile

log4j.appender.stdout=org.apache.log4j.ConsoleAppender
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
log4j.appender.stdout.layout.ConversionPattern=%d %p [%c] - %m%n

log4j.appender.logfile=org.apache.log4j.FileAppender
log4j.appender.logfile.File=target/spring.log
log4j.appender.logfile.layout=org.apache.log4j.PatternLayout
log4j.appender.logfile.layout.ConversionPattern=%d %p [%c] - %m%n

最后我们将 jdbcConfig 类和 zookeeperConfig 类导入到 springConfig 启动类中即可完成配置:

package com.jobs.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;

//导入 jdbcConfig 和 zookeeperConfig 配置类
@Configuration
@Import({jdbcConfig.class, zookeeperConfig.class})
public class springConfig { }

四、测试代码实现

分别创建 zkLockTest1 和 zkLockTest2 两个测试类,两个类的实现细节绝大部分相同,差异很小,具体如下:

package com.jobs.test;

import com.jobs.config.springConfig;
import org.apache.curator.framework.CuratorFramework;

import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

import java.text.SimpleDateFormat;
import java.util.*;
import java.util.concurrent.TimeUnit;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = springConfig.class)
public class zkLockTest1 {

    //--------------------------
    //注意:在运行程序前,请先运行以下 sql 语句,初始化数据:
    // update data_test set data_version=null,
    // thread1_update=null,thread2_update=null,update_time = null;
    //--------------------------

    //注意:由于 namespace 配置的是 jobs
    //因此对 zookeeper 的操作,都是默认在 /jobs 节点下进行操作
    @Autowired
    private CuratorFramework client;

    @Autowired
    private NamedParameterJdbcTemplate jdbcTemplate;

    @Test
    public void lockTest1() throws Exception {

        //该方法运行时,作为【线程1】去修改数据库 testdb 中 data_test 表中的数据

        //【线程1】生成的版本号
        String version = UUID.randomUUID().toString();

        //InterProcessMutex 是 Curator 基于 Zookeeper 提供的分布式可重入排它锁
        //InterProcessMutex 是最常使用的分布式锁,使用起来很简单。
        //使用 curator 的客户端连接对象,在 /jobs/lock 下创建临时有序节点,实现分布式锁控制
        InterProcessMutex lock = new InterProcessMutex(client, "/lock");

        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

        while (true) {
            try {
                //获取分布式锁
                lock.acquire(2, TimeUnit.SECONDS);

                //获取数据库中 data_id 最小,且没有被修改过的数据的 data_id
                String sql1 = "select data_id from data_test " +
                        "where update_time is null order by data_id limit 1";
                List<Map<String, Object>> maps = jdbcTemplate.queryForList(sql1, new HashMap<>());

                if (maps.size() > 0) {

                    Integer dataID = (Integer) maps.get(0).get("data_id");
                    //SQL语句中参数采用的格式为【:参数名称】
                    String sql2 = "update data_test set data_version=:version," +
                            "thread1_update=1,update_time=:time where data_id=:id";

                    //使用 NamedParameterJdbcTemplate 操作数据库,需要提供 sql 语句中对应的参数和值,顺序无所谓
                    //注意:参数名称不需要加冒号
                    Map<String, Object> paramMap = new HashMap<>();
                    paramMap.put("version", version);
                    paramMap.put("time", sdf.format(new Date()));
                    paramMap.put("id", dataID);

                    //返回受影响的行数
                    int update = jdbcTemplate.update(sql2, paramMap);
                    if (update > 0) {
                        System.out.println(dataID + " 被【线程1】修改");
                    }
                } else {
                    break;
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                //最终要释放锁
                if (lock.isAcquiredInThisProcess() || lock.isOwnedByCurrentThread()) {
                    lock.release();
                }
            }
        }
    }
}
package com.jobs.test;

import com.jobs.config.springConfig;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

import java.text.SimpleDateFormat;
import java.util.*;
import java.util.concurrent.TimeUnit;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = springConfig.class)
public class zkLockTest2 {

    //--------------------------
    //注意:在运行程序前,请先运行以下 sql 语句,初始化数据:
    // update data_test set data_version=null,
    // thread1_update=null,thread2_update=null,update_time = null;
    //--------------------------

    //注意:由于 namespace 配置的是 jobs
    //因此对 zookeeper 的操作,都是默认在 /jobs 节点下进行操作
    @Autowired
    private CuratorFramework client;

    @Autowired
    private NamedParameterJdbcTemplate jdbcTemplate;

    @Test
    public void loclTest2() throws Exception {

        //该方法运行时,作为【线程2】去修改数据库 testdb 中 data_test 表中的数据

        //【线程2】生成的版本号
        String version = UUID.randomUUID().toString();

        //InterProcessMutex 是 Curator 基于 Zookeeper 提供的分布式可重入排它锁
        //InterProcessMutex 是最常使用的分布式锁,使用起来很简单。
        //使用 curator 的客户端连接对象,在 /jobs/lock 下创建临时有序节点,实现分布式锁控制
        InterProcessMutex lock = new InterProcessMutex(client, "/lock");

        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

        while (true) {
            try {
                //获取分布式锁
                lock.acquire(2, TimeUnit.SECONDS);

                //获取数据库中 data_id 最小,且没有被修改过的数据的 data_id
                String sql1 = "select data_id from data_test " +
                        "where update_time is null order by data_id limit 1";
                List<Map<String, Object>> maps = jdbcTemplate.queryForList(sql1, new HashMap<>());

                if (maps.size() > 0) {

                    Integer dataID = (Integer) maps.get(0).get("data_id");
                    //SQL语句中参数采用的格式为【:参数名称】
                    String sql2 = "update data_test set data_version=:version," +
                            "thread2_update=1,update_time=:time where data_id=:id";

                    //使用 NamedParameterJdbcTemplate 操作数据库,需要提供 sql 语句中对应的参数和值,顺序无所谓
                    //注意:参数名称不需要加冒号
                    Map<String, Object> paramMap = new HashMap<>();
                    paramMap.put("version", version);
                    paramMap.put("time", sdf.format(new Date()));
                    paramMap.put("id", dataID);

                    //返回受影响的行数
                    int update = jdbcTemplate.update(sql2, paramMap);
                    if (update > 0) {
                        System.out.println(dataID + " 被【线程2】修改");
                    }
                } else {
                    break;
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                //最终要释放锁
                if (lock.isAcquiredInThisProcess() || lock.isOwnedByCurrentThread()) {
                    lock.release();
                }
            }
        }
    }
}

以上代码主要在于 InterProcessMutex 的使用,该类是 Curator 提供的基于 Zookeeper 实现的分布式可重入排他锁,也是最常用的分布式锁。使用起来很简单,只需要传入 Curator 连接对象,以及要要创建子节点的根节点即可。需要确保使用分布式锁的多个客户端,创建子节点使用的是相同的根节点。

为了查看和验证效果,在短时间内,先后运行两个测试类的方法即可,模拟两个不同的线程,通过分布式锁并发修改同一张数据库表的数据,可以通过查看 IDEA 和数据库数据来验证运行效果。

如果想多次运行 Demo ,请在运行之前,执行以下 sql 语句初始化数据:

update data_test set data_version=null,thread1_update=null,thread2_update=null,update_time = null;


到此为止,有关使用 Java 操作 Zookeeper 实现分布式锁控制并发资源访问的技术,已经介绍完毕。

本篇博客 Demo 的源代码下载地址为:https://files.cnblogs.com/files/blogs/699532/zookeeper_lock.zip


posted @ 2022-07-17 22:36  乔京飞  阅读(10055)  评论(0编辑  收藏  举报