如何实现一个分布式锁?

为什么需要分布式锁?

在互联网的发展初期,用户体量较小,架构简单,单体应用可以满足用户需求,当用户量增加到一定程度时,不可避免的出现了竞争关系,所以产生了单机锁来协调资源竞争。

但随着业务量和用户量骤增,单体架构已经无法满足现阶段的需求,此时出现了集群,简单的锁机制已经无法满足集群间资源竞争的问题,基于这种场景,出现了分布式锁。

锁应该满足的必要条件

  • 互斥性
  • 可重入
  • 高效的加解锁性能
  • 公平/非公平/阻塞/非阻塞

互斥性

共享资源的锁在同一时间只能被一个对象获取

可重入

为了避免死锁,必须支持可重入,支持超时

性能

锁的增加和释放都必须要保证是高性能的

公平/非公平/阻塞/非阻塞

根据实际业务场景决定锁的性质

  • 作为设计者将所有的坑都考虑到是有必要的
  • 但是如果不考虑付出的开发成本和能得到的利益(实际业务需求),直接去做一个大而全的东西出来,我个人认为是不值得的。

如何实现分布式锁?

基于数据库的实现(这里以MySQL为例)

仔细想象,这种方案太垃圾了,通过维护锁表(排他/锁记录)或者MVCC,除了基于DB简单易懂没有其他的优点了,使用这种方案就是在给自己挖坑。感兴趣的自行百度。

基于Redis实现分布式锁

redis分布式锁可能存在的坑
锁本身存在的问题
原子性问题
  • 如果使用setnx 和 expire,那么这个操作是非原子性的,比如线程A和线程B同时申请锁,此时线程A争抢成功,代码层面开始setnx,此时线程A的节点挂掉了,这个时候就产生了死锁,线程B永远获取不到这把锁。
解决方案

使用redis set命令解决原子性问题。

示例: set(lock_sale_UUID,1,10,NX)

从 Redis 2.6.12 版本开始, SET 命令的行为可以通过一系列参数来修改:

EX second :设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value 。
PX millisecond :设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value 。
NX :只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value 。
XX :只在键已经存在时,才对键进行设置操作。
误删key问题

描述:持有过期锁的客户端误删现有锁
例如:

  • 假如线程A成功得到锁,设置的超时时间为 10 秒。
  • 我们知道程序执行是需要时间的,假如线程A的setnx命令执行了11秒,这个时候线程A会释放锁,但是此时线程A是没有执行完的。
  • 由于线程A释放锁,线程B此时得到锁。 此时线程A执行完毕,开始执行最终的del操作。
  • 但是此时线程B还没有执行完成。
  • 线程A删除的实际上是线程B的锁。
解决方案

在 del 释放锁之前做一个判断,验证当前的锁是不是自己加的锁。

1.在加锁的时候把当前的线程 ID 当做 value,并在删除之前验证 key 对应的 value 是不是自己线程的 ID。
下面是伪代码的实现

String userThreadID = Thread.currentThread().getId();
set(key,threadId ,10,NX)
if(threadId .equals(redisClient.get(key))){
    del(key)
} else {
	throw new customizeException(xxxx);
}

这种方案存在一个缺点,就是加锁和释放锁实际上不是原子性操作,如果做到最完美状态,可以给当前线程增加一个守护线程,用来给当前线程进行“续航”,不断的查询该线程是否执行完毕。

new Thread().setDaemon(true);
可以考虑抽象一个守护线程池,防止随线程执行完毕守护线程不断创建和销毁带来的性能问题。
数据库事物超时

实际业务场景中,我们是存在数据库访问的。那么就会存在超时问题。
一旦你的key长时间获取不到锁,获取锁等待的时间超过数据库事务超时时间,程序就会报异常。

解决方案

将数据库事务改为手动提交、回滚事务。

2.不使用固定的字符串作为键的值,而是设置一个不可猜测(non-guessable)的长随机字符串,作为口令串(token)。
不使用 DEL 命令来释放锁,而是发送一个 Lua 脚本,这个脚本只在客户端传入的值和键的口令串相匹配时,才对键进行删除。
以下为为代码实现

(下面是来自于官方文档的方案)
if redis.call("get",KEYS[1]) == ARGV[1]
then
    return redis.call("del",KEYS[1])
else
    return 0
end
Redis自身存在的问题
Redis的高可用
- 强烈建议不要为了装逼而直接来一套主从哨兵什么之类的。
- 还是那句话,开发是需要成本的,一定要考虑技术上付出和业务上的回报是不是成正比的。
- 假如你只有几百或者千个QPS,设计的那么复杂完全没有必要。

为了保证redis的高可用和访问性能,会设置redis的主从哨兵等模式,主节点负责写操作,从节点负责读,也就意味着,我们所有的锁都要写在主redis服务器中,如果主redis服务器宕机,资源释放(在没有加持久化时候,如果加了持久化会更加复杂),此时redis主节点的数据并没有复制到slave服务器,此时,其他客户端就会趁机获取锁,而之前拥有锁的客户端可能还在对资源进行操作,此时又会出现多客户端对同一资源进行访问和操作的问题.

解决方案

//todo 暂时还没想好怎么解决,后面会单独开一篇博客说这个问题。暂时能想到的方案是增加补偿机制,比如Redis凉了我有数据库锁或者zookeeper锁兜底。

GC导致的锁失效问题(还没验证过是不是会有这种情况出现)

//todo 还没验证。暂时还没想好怎么解决,后面会单独开一篇博客说这个问题。暂时能想到的方案是增加补偿机制,比如Redis凉了我有数据库锁或者zookeeper锁兜底。

测试代码(待优化,大家有好的意见可以提出来一起完成~)
public class RedisDistributedRedLockTest {
    static int n = 5;
   
    public static void main(String[] args) {
        Config config = new Config();
        //支持单机,主从,哨兵,集群等模式
        //此为哨兵模式
        config.useSentinelServers()
                .setMasterName("mymaster")
               .addSentinelAddress("127.0.0.1:26369","127.0.0.1:26379","127.0.0.1:26389")
                .setDatabase(0);
        Runnable runnable = () -> {
            RedisDistributedRedLock redisDistributedRedLock = null;
            RedissonClient redissonClient = null;
            try {
                redissonClient = Redisson.create(config);
                redisDistributedRedLock = new RedisDistributedRedLock(redissonClient, "stock_lock");
                redisDistributedRedLock.acquire();
                secskill();
                System.out.println(Thread.currentThread().getName() + "正在运行");
            } finally {
                if (redisDistributedRedLock != null) {
                    redisDistributedRedLock.release(null);
                }
 
                redissonClient.shutdown();
            }
        };
 
        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(runnable);
            t.start();
        }
    }

基于Zookeeper实现分布式锁

使用zookeeper实现分布式锁的好处

zookeeper对于自身节点的监听者提供事件通知功能,可以不再依赖任何其他组件的情况下完成通知机制而不需要自己实现。

流程图
分布式锁争抢流程

在这里插入图片描述

集群下的分布式锁

在这里插入图片描述

zookeeper为什么可以实现分布式锁

zk中提供了四种类型的节点

- 持久节点(PERSISTENT):节点创建后,就一直存在,直到有删除操作来主动清除这个节点
- 持久顺序节点(PERSISTENT_SEQUENTIAL):保留持久节点的特性,额外的特性是,每个节点会为其第一层子节点维护一个顺序,记录每个子节点创建的先后顺序,ZK会自动为给定节点名加上一个数字后缀(自增的),作为新的节点名。
- 临时节点(EPHEMERAL):和持久节点不同的是,临时节点的生命周期和客户端会话绑定,当然也可以主动删除。
- 临时顺序节点(EPHEMERAL_SEQUENTIAL):保留临时节点的特性,额外的特性如持久顺序节点的额外特性。
避免死锁

一般来说会使用两种临时节点来实现zookeeper分布式锁。

使用持久节点会导致一个问题。假如线程A创建了一把锁在zookeeper,根据zookeeper的实现机制,当线程A挂掉了,此时这把锁因为是持久节点会一直存在,导致锁无法释放。后面的线程无法获取锁导致死锁。
公平/非公平锁
zookeeper可以实现公平锁和非公平锁。公平是在节点删除时只通知相邻的节点来获取的锁(临时顺序节点的顺序性可以保证公平);非公平锁(惊群效应)是节点删除所有等待锁的线程重新竞争。
可能存在的问题思考

假设有两个线程A,B同时连接zookeeper。 假设线程A拿到了锁,然后处理业务。但是在处理过程中,线程A与zookeeper的通信断掉了(但是程序还在执行)。 这个时候线程B被唤醒,发现没有锁,也开始执行业务。这 时候就会出现并发问题。

可以使用事务的思想解决,当通信断掉后,回滚当前线程的所有操作或者记录一下当前线程操作成功的游标并进行持久化,加一个重试机制防止当前线程阻塞掉后面的所有操作。

issue:持久化和增量这部分涉及到的东西很复杂。如果数据不重要或者量级很小,直接回滚。
测试代码
public class ZooKeeperLock implements Watcher {

    private ZooKeeper zk = null;
    private String rootLockNode;            // 锁的根节点
    private String lockName;                // 竞争资源,用来生成子节点名称
    private String currentLock;             // 当前锁
    private String waitLock;                // 等待的锁(前一个锁)
    private CountDownLatch countDownLatch;  // 计数器(用来在加锁失败时阻塞加锁线程)
    private int sessionTimeout = 30000;     // 超时时间
    
    // 1. 构造器中创建ZK链接,创建锁的根节点
    public ZooKeeperLock(String zkAddress, String rootLockNode, String lockName) {
        this.rootLockNode = rootLockNode;
        this.lockName = lockName;
        try {
            // 创建连接,zkAddress格式为:IP:PORT
            zk = new ZooKeeper(zkAddress,this.sessionTimeout,this);
            // 检测锁的根节点是否存在,不存在则创建
            Stat stat = zk.exists(rootLockNode,false);
            if (null == stat) {
                zk.create(rootLockNode, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
            }
        } catch (IOException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (KeeperException e) {
            e.printStackTrace();
        }
    }
    
    // 2. 加锁方法,先尝试加锁,不能加锁则等待上一个锁的释放
    public boolean lock() {
        if (this.tryLock()) {
            System.out.println("线程【" + Thread.currentThread().getName() + "】加锁(" + this.currentLock + ")成功!");
            return true;
        } else {
            return waitOtherLock(this.waitLock, this.sessionTimeout);
        }
    }
    
    public boolean tryLock() {
        // 分隔符
        String split = "_lock_";
        if (this.lockName.contains("_lock_")) {
            throw new RuntimeException("lockName can't contains '_lock_' ");
        }
        try {
            // 创建锁节点(临时有序节点)
            this.currentLock = zk.create(this.rootLockNode + "/" + this.lockName + split, new byte[0],
                    ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
            System.out.println("线程【" + Thread.currentThread().getName() 
                        + "】创建锁节点(" + this.currentLock + ")成功,开始竞争...");
            // 取所有子节点
            List<String> nodes = zk.getChildren(this.rootLockNode, false);
            // 取所有竞争lockName的锁
            List<String> lockNodes = new ArrayList<String>();
            for (String nodeName : nodes) {
                if (nodeName.split(split)[0].equals(this.lockName)) {
                    lockNodes.add(nodeName);
                }
            }
            Collections.sort(lockNodes);
            // 取最小节点与当前锁节点比对加锁
            String currentLockPath = this.rootLockNode + "/" + lockNodes.get(0);
            if (this.currentLock.equals(currentLockPath)) {
                return true;
            }
            // 加锁失败,设置前一节点为等待锁节点
            String currentLockNode = this.currentLock.substring(this.currentLock.lastIndexOf("/") + 1);
            int preNodeIndex = Collections.binarySearch(lockNodes, currentLockNode) - 1;
            this.waitLock = lockNodes.get(preNodeIndex);
        } catch (KeeperException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return false;
    }

    private boolean waitOtherLock(String waitLock, int sessionTimeout) {
        boolean islock = false;
        try {
            // 监听等待锁节点
            String waitLockNode = this.rootLockNode + "/" + waitLock;
            Stat stat = zk.exists(waitLockNode,true);
            if (null != stat) {
                System.out.println("线程【" + Thread.currentThread().getName() 
                            + "】锁(" + this.currentLock + ")加锁失败,等待锁(" + waitLockNode + ")释放...");
                // 设置计数器,使用计数器阻塞线程
                this.countDownLatch = new CountDownLatch(1);
                islock = this.countDownLatch.await(sessionTimeout,TimeUnit.MILLISECONDS);
                this.countDownLatch = null;
                if (islock) {
                    System.out.println("线程【" + Thread.currentThread().getName() + "】锁(" 
                                + this.currentLock + ")加锁成功,锁(" + waitLockNode + ")已经释放");
                } else {
                    System.out.println("线程【" + Thread.currentThread().getName() + "】锁(" 
                                + this.currentLock + ")加锁失败...");
                }
            } else {
                islock = true;
            }
        } catch (KeeperException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return islock;
    }
    
    // 3. 释放锁
    public void unlock() throws InterruptedException {
        try {
            Stat stat = zk.exists(this.currentLock,false);
            if (null != stat) {
                System.out.println("线程【" + Thread.currentThread().getName() + "】释放锁 " + this.currentLock);
                zk.delete(this.currentLock, -1);
                this.currentLock = null;
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (KeeperException e) {
            e.printStackTrace();
        } finally {
            zk.close();
        }
    }
    
    // 4. 监听器回调
    @Override
    public void process(WatchedEvent watchedEvent) {
        if (null != this.countDownLatch && watchedEvent.getType() == Event.EventType.NodeDeleted) {
            // 计数器减一,恢复线程操作
            this.countDownLatch.countDown();
        }
    }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值