一篇文章带你彻底了解分布式锁

本文详细介绍了三种常见的分布式锁实现方式:基于数据库、Redis和Zookeeper。针对每种方式,讨论了其实现原理、优缺点,并分析了可能存在的局限性,如可重入性、锁释放时机和高可用问题。对于Redis实现,特别强调了Lua脚本在保证原子性解锁操作中的作用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >


前言:

为啥我们在项目需要用到分布式的锁, synchronized 不可以吗?当我们的开发的环境在多节点部署的时候,由于不在用一个jvm虚拟机上,我们的synchronized锁是不能实现跨虚拟机来加锁的,我们加锁的目的就是为了解决多个线程之间的并发问题,锁分为乐观锁,还有悲观锁,下面我们以悲观锁为例,详细介绍常用的三种实现分布式锁的方式。

1.基于数据库实现分布式锁:

创建表的SQL语句:

CREATE TABLE `distributed_lock` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键id',
  `unique_mutex` varchar(255) NOT NULL COMMENT '业务防重id',
  `holder_id` varchar(255) NOT NULL COMMENT '锁持有者id',
  `create_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `mutex_index` (`unique_mutex`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;

SET FOREIGN_KEY_CHECKS = 1;

id : 自增主键,这里为啥在分布式的环境下要用自增主键,这张表的每一条信息代表每个业务的唯一性,这张表不需要多节点部署,只需要在主库中部署即可(这也暴露了数据库实现分布式锁的缺陷性,万一数据库单节点宕机,会存在服务不可用)
unique_mutex: 业务防重id,一个需要加锁的业务,我们可以自定义一个业务防重id,在执行该业务时,先查询这张表有无该业务防重id,没有的话去添加一条数据加锁然后执行业务代码,具体unique_mutex的值可根据业务进行自定义:

insert into distributed_lock(unique_mutex, holder_id) values (‘unique_mutex’, ‘holder_id’);

然后在执行完业务的时候记得删除锁:

delete from methodLock where unique_mutex=‘unique_mutex’ and holder_id=‘holder_id’;

holder_id :代表竞争到锁的持有者id,可根据用户名,用户手机号组成全局唯一字段等进行自定义。

分析我们的以上的锁是否有局限性?
1.可重入性:
从我们的设计来看,不能满足重入性,我们在查询这张表有无该业务的防重id,没有的话去添加一条数据加锁然后执行业务代码,我们这里需要加再加一条判断条件,就算已经有锁了,我们判断该锁的持有者是否是
当前请求者,如果是可以继续执行当前业务代码,这样可重入性就解决了。
2.是否会产生死锁:
试想一下如果一条进程加锁成功,突然这条进程挂了,或者在代码执行到一半的时候别人直接用kill -9 将你的java进程杀死。那么这条业务永远被挂上了锁而无法解锁。解决的方法: 我们可以给我们的锁设置一个过期时间,在每次这条锁挡住我们之前,我们先判断这个锁是否已经过期了,如果已经过期了,那么不好意思,这条锁没有用了,我们直接将这个锁(对应的是数据库表中的一条记录该业务的信息)删除,然后再加锁。
3. 高可用问题:
目前我们的数据库锁设计方案只能在单节点部署,不存在高可用问题。

2. 基于redis实现分布式锁

redis工具类: 用于加减锁

注意到这里解锁其实是分为2个步骤,涉及到解锁操作的一个原子性操作问题。这也是为什么我们解锁的时候用Lua脚本来实现,因为Lua脚本可以保证操作的原子性。那么这里为什么需要保证这两个步骤的操作是原子操作呢?
设想:假设当前锁的持有者是竞争者1,竞争者1来解锁,成功执行第1步,判断自己就是锁持有者,这是还未执行第2步。这是锁过期了,然后竞争者2对这个key进行了加锁。加锁完成后,竞争者1又来执行第2步,此时错误产生了:竞争者1解锁了不属于自己持有的锁。可能会有人问为什么竞争者1执行完第1步之后突然停止了呢?这个问题其实很好回答,例如竞争者1所在的JVM发生了GC停顿,导致竞争者1的线程停顿。这样的情况发生的概率很低,但是请记住即使只有万分之一的概率,在线上环境中完全可能发生。因此必须保证这两个步骤的操作是原子操作。

/**
 * redis工具类
 */
public class RedisTool {

    private static final String LOCK_SUCCESS = "OK";
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";
    private static final Long RELEASE_SUCCESS = 1L;


    /**
     * 加锁
     * @param jedis Redis客户端
     * @param lockKey 锁的key
     * @param requestId 竞争者id
     * @param expireTime 锁超时时间,超时之后锁自动释放
     * @return 
     */
    public static boolean getDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
        String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
        return LOCK_SUCCESS.equals(result);
    }

    /**
     * 释放分布式锁
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 锁持有者id
     * @return 是否释放成功
     */
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
        return RELEASE_SUCCESS.equals(result);
    }
}

jedis获取工具类:

/**
* jedis获取工具类
*/
@Component
public class RedisPoolUtils {

    private static JedisPool jedisPool = null;

    static {
        Properties properties = new Properties();
        InputStream configStream = RedisPoolUtils.class.getResourceAsStream("/application.properties");
        try {
            properties.load(configStream);
            String ADDRESS = properties.getProperty("spring.redis.host");
            String PORT = properties.getProperty("spring.redis.port");
            String PASSWORD = properties.getProperty("spring.redis.password");
            //可用连接实例的最大数目,默认值为8;
            //如果赋值为-1,则表示不限制;如果pool已经分配了maxActive个jedis实例,则此时pool的状态为exhausted(耗尽)。
            String  MAX_ACTIVE = properties.getProperty("spring.redis.pool.max-active");
            //控制一个pool最多有多少个状态为idle(空闲的)的jedis实例,默认值也是8
            String MAX_IDLE = properties.getProperty("spring.redis.pool.max-idle");
            //等待可用连接的最大时间,单位毫秒,默认值为-1,表示永不超时。如果超过等待时间,则直接抛出JedisConnectionException;
            String MAX_WAIT = properties.getProperty("spring.redis.pool.max-wait");
            JedisPoolConfig config = new JedisPoolConfig();
//            config.setMaxActive(MAX_ACTIVE);
            config.setMaxIdle(Integer.parseInt(MAX_IDLE));
//            config.setMaxWait(MAX_WAIT);
            //在borrow一个jedis实例时,是否提前进行validate操作;如果为true,则得到的jedis实例均是可用的;
            boolean TEST_ON_BORROW = true;
            config.setTestOnBorrow(TEST_ON_BORROW);
            int TIMEOUT = 0;
            jedisPool = new JedisPool(config, ADDRESS, Integer.parseInt(PORT), TIMEOUT);
        } catch (IOException e) {
            e.printStackTrace();
        }

    }

    /**
     * 获取Jedis实例
     *
     * @return
     */
    public synchronized Jedis getJedis() {
        try {

            return jedisPool.getResource();
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }
}

测试类:

@RestController
@RequestMapping("/redis")
public class RedisController {
    @Autowired
    private RedisPoolUtils redisPoolUtils;

    @RequestMapping("/testLock")
    public String testLock() {
        Jedis jedis = Objects.requireNonNull(redisPoolUtils.getJedis());
        boolean distributedLock = RedisTool.getDistributedLock(jedis, "DX", "张三_178061612345", 2000);
        if (distributedLock) {
            System.out.println("开始执行业务逻辑....." + "\n\n");
            try {
                Thread.sleep(1200);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            RedisTool.releaseDistributedLock(jedis, "DX", "张三_178061612345");
        }
        return "OK";
    }

    @RequestMapping("/testConcurrent")
    public String testConcurrent(){
        for (int i = 0; i < 1000; i++) {
            new Thread(this::testLock).start();
        }
       return "OK";
    }
}

分析我们的以上的锁是否有局限性?
1.是否可重入:以上实现的锁是不可重入的,如果需要实现可重入,在SET_IF_NOT_EXIST之后,再判断key对应的value是否为当前竞争者id,如果是返回加锁成功,否则失败。
2.锁释放时机:加锁时我们设置了key的超时,当超时后,如果还未解锁,则自动删除key达到解锁的目的。如果一个竞争者获取锁之后挂了,我们的锁服务最多也就在超时时间的这段时间之内不可用。
3.Redis单点问题:如果需要保证锁服务的高可用,可以对Redis做高可用方案:Redis集群+主从切换。目前都有比较成熟的解决方案

3.基于Zookeeper实现分布式锁

通过创建临时节点来进行排序,通过注册watcher事件来监听比他位置靠前的前一个节点的变化,如果前面的节点不存在了,就成功获得锁,任务执行完毕删除该临时节点。就像一个排队的队列,来一个任务在后面排队,只是监视前面那一个人是否存在,不存在了就轮到自己了,完成任务就将自己删除了。

public class ZookeeperLockTool {

    public static ThreadLocal<CuratorZookeeperClient>  zkClient = null;

    public static String LOCK_NAME = "/lock";

    public static ThreadLocal<String> CURRENT_NODE = new ThreadLocal<>();

      static {
                                                 // zookeeper连接地址,端口号
          zkClient.set(new CuratorZookeeperClient("localhost:7070",2000,2000,null,new RetryOneTime(1)));
      }

    public  static boolean tryLock()  {
        String nodeName = LOCK_NAME + "/zk_";
        try {
            CURRENT_NODE.set(zkClient.get().getZooKeeper().create(nodeName, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL));

            List<String> children = zkClient.get().getZooKeeper().getChildren(nodeName, false);

            Collections.sort(children);

            // 判断是否获得锁
            String minNode = children.get(0);
            if ((LOCK_NAME +"/"+minNode).equals(CURRENT_NODE.get())){
                // 获取到锁
                return true;
            }
            // 没有获取到锁,监听前一个节点是否存在
            // 获取前一个节点的index
            int i = children.indexOf(CURRENT_NODE.get().substring(CURRENT_NODE.get().lastIndexOf("/") + 1));
            // 获取前一个节点
            String preNodeName = children.get(i);
            // 增加countDownLatch 同步线程
            CountDownLatch countDownLatch = new CountDownLatch(1);
            zkClient.get().getZooKeeper().exists(LOCK_NAME + "/" + preNodeName, watchedEvent -> {
                // 监听前一个节点,前一个临时节点消失就是代表当前线程为最小的那个节点,可以获取到锁
                if (Watcher.Event.EventType.NodeDeleted.equals(watchedEvent.getType())){
                    countDownLatch.countDown();
                }
            });
            countDownLatch.await();

        } catch (Exception e) {
            e.printStackTrace();
        }
        return true;
    }


    public static void unlock() {
        try {
            zkClient.get().getZooKeeper().delete(CURRENT_NODE.get(),-1);
            CURRENT_NODE.remove();
        } catch (Exception e) {
            e.printStackTrace();
        }finally{
            zkClient.get().close();
        }
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值