分布式锁实现原理

187 阅读7分钟

分布式锁

为什么需要这样的锁?

我们先用两句话说一下jdk中的synchronized锁和juc的lock锁

  • synchronized锁的实现是基于jvm创建出来的一个管程对象objectMonitor(也就是常说的对象锁),对象头中的Mark Word会记录objectMonitor地址。
  • lock锁跟是基于AQS的一个实现。

这里不细说这两个锁的原理,它们都有一个共同点:作用域范围都是在单台JVM实例中。

当我的服务集群的情况下这些锁都只在自己的单机JVM上存储并共享,并没有在整个集群中共享,我们使用锁肯定是想在全局上锁住我们的共享资源,这个时候我们就需要想办法把锁对象存储在可以让整个集群共享的一个地方,可以让集群共享的锁就是分布式锁。

分布式锁能够保证同一时刻只有一个节点可以获取到共享资源的独占访问资格,而单机锁无法做到。

实现分布式锁的方式有多种,常见的包括:

  • 基于数据库:将锁信息存储在数据库表中,并使用数据库事务来实现互斥性和一致性。
  • 基于缓存:利用分布式缓存(如 Redis)来实现分布式锁。
  • 基于 ZooKeeper:ZooKeeper 是一个分布式协调服务,可以用来实现分布式锁,它保证了互斥性和高可用性。

我们使用Redis来一步步实现出一把分布式锁。

利用setnx的特性实现分布式锁。

加锁解锁方法

加锁操作其实就是添加一个setnx,解锁其实就是删除这个setnx

锁必须设置过期时间,以避免死锁的发生:例如一个服务获取到了锁后,还未释放锁就挂了,锁如果没有过期时间就会导致其他服务的一直获取不到锁。

private boolean tryLock(String key) {
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    return BooleanUtil.isTrue(flag);
}
​
private void unlock(String key) {
    stringRedisTemplate.delete(key);
}

加锁操作只需要简单调用tryLock即可

而解锁操作为了避免误删的情况:A线程业务执行时间超出了过期时间导致锁被删除,此时B线程获取锁,而A线程在B线程获取锁之后业务执行完毕准备解锁,此时就把B线程获取的锁给删了。

为了避免这样的情况我们需要判断一下当前锁的持有线程是否为自己,是自己才删除锁,如果不是则不做任何处理,我们利用Lua脚本的原子性来实现解锁的操作,以上的unLock需要改造。

unLock.lua

-- 比较持有锁的线程是否为自己
if(redis.call('get', KEYS[1]) ==  ARGV[1]) then
    -- 释放锁 del key
    return redis.call('del', KEYS[1])
end
return 0

一开始就加载lua文件,仅一次

    private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
    static {
        SECKILL_SCRIPT = new DefaultRedisScript<>();
        SECKILL_SCRIPT.setLocation(new ClassPathResource("unLock.lua"));
        SECKILL_SCRIPT.setResultType(Long.class);
    }
​
private void unLock(String key){
    // 直接执行解锁脚本,传入参数
         Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.singletonList(key),
                value
        );
}

此时可能会有人疑惑,线程业务还没有执行完毕就因为过期而导致锁被释放,这样不就会有安全问题吗?这里先不做过多解释,后续会使用后台线程重置超时时间解决该问题。

以上的锁在失败之后就直接返回结果了,并没有重试机制,我们改造成自旋获取锁,设置间隔50毫秒

private boolean tryLock(String key) {
    while(!stringRedisTemplate.opsForValue().setIfAbsent(key, "线程唯一标识", 10, TimeUnit.SECONDS)){
        Thread.sleep(50);
    }
    return true;
}

演示卖货完整代码

锁对象

package com.example.distributedlock.utils;
​
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
​
import java.util.Collections;
import java.util.concurrent.TimeUnit;
​
public class RedisLock {
​
    private StringRedisTemplate stringRedisTemplate;
    // 仅加载一次lua脚本
    private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
​
    static {
        SECKILL_SCRIPT = new DefaultRedisScript<>();
        SECKILL_SCRIPT.setLocation(new ClassPathResource("unLock.lua"));
        SECKILL_SCRIPT.setResultType(Long.class);
    }
    public RedisLock(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }
​
    public boolean tryLock(String key) throws InterruptedException {
        String value = Thread.currentThread().getId()+"";
        // 加锁失败则间隔50ms自旋
        while(!stringRedisTemplate.opsForValue().setIfAbsent(key, value, 10, TimeUnit.SECONDS)){
            Thread.sleep(50);
        }
        System.out.println("线程加锁:"+value);
        return true;
    }
​
    public void unlock(String key) {
        String value = Thread.currentThread().getId()+"";
        // 直接执行解锁lua脚本,传入参数
        Long a = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.singletonList(key),
                value
        );
        System.out.println("线程解锁是否成功:"+a);
    }
​
}
​

解锁lua脚本

​
-- 比较线程标示与锁中的标示是否一致
if(redis.call('get', KEYS[1]) ==  ARGV[1]) then
    -- 释放锁 del key
    return redis.call('del', KEYS[1])
end
return 0

接口

package com.example.distributedlock.controller;
​
import com.example.distributedlock.utils.RedisLock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/lock")
public class TestLockController {
    @Autowired
    StringRedisTemplate stringRedisTemplate;
    /**
     * 模拟卖货
     * @return
     * @throws InterruptedException
     */
    @GetMapping
    public String test() throws InterruptedException {
        // 创建锁对象
        RedisLock redisLock = new RedisLock(stringRedisTemplate);
        // 自旋加锁直到成功
        redisLock.tryLock("testLock");
        try {
            //业务代码
            // 库存减1
            // 睡眠演示出不会误删的效果
            try{
                Thread.sleep(1000*11);
            }catch (Exception e){e.printStackTrace();}
            Long stock = stringRedisTemplate.opsForValue().increment("stock", -1);
            System.out.println("库存数:"+stock);
            return "购买成功";
        }catch (Exception e){
            e.printStackTrace();
            return "购买失败";
        }finally {
            redisLock.unlock("testLock");
        }
    }
}

注意事项

使用while自旋方式获取锁,而非递归,防止栈溢出。

给锁设置过期时间,避免死锁。

删锁前判断锁是否是自己的,避免锁过期导致的误删锁。

利用lua脚本实现判断锁跟删除锁的原子性,避免误删。

如果没有过期时间,服务集群情况下,其中一台还没释放锁就挂了,又没过期时间,就会导致整个服务模块出现死锁。

问题
  • 无法可重入

    • 因为setnx的特性:键不存在的时候设置键值,在需要重入时,无法修改setnx
  • 超时释放锁导致的数据安全问题

    • 业务没有执行完毕,锁因为超时而被释放。
  • 单点故障

    • 单机redis挂了,锁都直接没了
  • 集群一致性问题

    • 异步的复制,当主机存入锁状态时,直接返回获取锁成功,异步的复制给从机时,主机挂了,从机没有锁的状态,导致其他线程此时获取锁发现从机没有锁,就导致了一把锁两个线程都获取到了。

使用hash结构解决可重入

可重入不能使用setnx,还得保证键值对存储,所以使用hash结构。

实现lock接口规范实现可重入,为了保证判断锁是否被占用跟获取锁的原子性,使用lua脚本实现获取锁跟释放锁。

锁对象代码

public class MyReentrantLock implements Lock {
    private long time = 30L;
    private StringRedisTemplate stringRedisTemplate;
    private String key; // 锁的键名
    private String val; // 占用锁的线程唯一标识
    public MyReentrantLock(StringRedisTemplate stringRedisTemplate ,String key ,String val) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.key = key;
        this.val = MyThreadLocal.get()+"-"+val;
    }
    @Override
    public void lock() {
        tryLock();
    }
    @Override
    public boolean tryLock() {
        try {
            return tryLock(time, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
            return false;
        }
    }
​
    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        String script = "-- 锁是否存在\n" +
                "if(redis.call('exists', KEYS[1]) ==  1) then\n" +
                "    -- 存在,判断当前占用锁的线程是否为自己\n" +
                "    if(redis.call('hexists', KEYS[1],ARGV[1]) ==  1) then\n" +
                "        -- 是自己\n" +
                "        -- 重入数+1\n" +
                "        redis.call('hincrby', KEYS[1],ARGV[1],1)\n" +
                "        return 1\n" +
                "    else\n" +
                "        -- 不是自己,自己返回0表示false\n" +
                "        return 0\n" +
                "    end\n" +
                "else\n" +
                "    -- 不存在,获取锁\n" +
                "    redis.call('hset', KEYS[1],ARGV[1],1)\n" +
                "    redis.call('expire',KEYS[1],ARGV[2])\n" +
                "return 1\n" +
                "end";
        // 自旋获取锁
        while(!stringRedisTemplate.execute(new DefaultRedisScript<>(script,Boolean.class),
                Collections.singletonList(key),val,time+"")){
            Thread.sleep(50);
        }
        return true;
    }
​
    @Override
    public void unlock() {
        String script = "-- 判断当前锁是否是自己的\n" +
                "if(redis.call('hexists', KEYS[1],ARGV[1]) ==  0) then\n" +
                "    -- 不是自己的\n" +
                "    return nil\n" +
                "    -- 是自己的,判断减重入次数后是否为0\n" +
                "elseif(redis.call('hincrby', KEYS[1],ARGV[1],-1) ==  0) then\n" +
                "    -- 为0,可释放锁\n" +
                "    redis.call('del', KEYS[1])\n" +
                "    return 1\n" +
                "else\n" +
                "    -- 锁是自己的,但是值不为0,表示锁还在,还没完全被释放。\n" +
                "    return 0\n" +
                "end";
        Long res = stringRedisTemplate.execute(new DefaultRedisScript<>(script,Long.class),
                Collections.singletonList(key),val,time+"");
        if(res == null){
            throw new RuntimeException("你没有持有锁");
        }
    }
​
    @Override
    public Condition newCondition() {
        return null;
    }
​
    @Override
    public void lockInterruptibly() throws InterruptedException {
​
    }
}

使用,模拟可重入

@GetMapping
    public String test() {
        // 创建锁对象
//        RedisLock redisLock = new RedisLock(stringRedisTemplate);
        // 将uuid存入线程本地变量,供线程内访问
        String uuid = UUID.randomUUID().toString(true);
        MyThreadLocal.set(uuid);
        // 使用自定义可重入锁
        Lock myReentrantLock = new MyReentrantLock(stringRedisTemplate,"testMyReentrantLock",
                Thread.currentThread().getId()+"");
        // 自旋加锁直到成功
        myReentrantLock.lock();
        System.out.println("第一次加锁成功:"+uuid+Thread.currentThread().getId());
        try {
            //业务代码
            // 库存减1
            try{
                Thread.sleep(10);
            }catch (Exception e){e.printStackTrace();}
            Long stock = stringRedisTemplate.opsForValue().increment("stock", -1);
            System.out.println("库存数:"+stock);
            // 演示重入
            reentrant();
            return "购买成功";
        }catch (Exception e){
            e.printStackTrace();
            return "购买失败";
        }finally {
            myReentrantLock.unlock();
            System.out.println("第一次解锁成功:"+uuid+Thread.currentThread().getId());
        }
    }
​
    private void reentrant() {
        Lock myReentrantLock = new MyReentrantLock(stringRedisTemplate,"testMyReentrantLock",
                Thread.currentThread().getId()+"");
        myReentrantLock.lock();
        System.out.println("第二次加锁成功:"+MyThreadLocal.get()+Thread.currentThread().getId());
        try {
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            myReentrantLock.unlock();
            System.out.println("第二次解锁成功:"+MyThreadLocal.get()+Thread.currentThread().getId());
        }
    }

到此,可重入也已解决,但还有三个问题没解决,接下来解决超时释放问题

还有问题
  • 超时释放锁导致的数据安全问题

    • 业务没有执行完毕,锁因为超时而被释放。
  • 单点故障

    • 单机redis挂了,锁都直接没了
  • 集群一致性问题

    • 异步的复制,当主机存入锁状态时,直接返回获取锁成功,异步的复制给从机时,主机挂了,从机没有锁的状态,导致其他线程此时获取锁发现从机没有锁,就导致了一把锁两个线程都获取到了。

后台定时任务解决超时释放锁问题

在加锁成功时,开启一个定时任务,每隔3分之一的超时时间判断一下当前线程是否还持有锁执行业务。

如果还持有,则执行一次重置过期时间操作,并且继续定时任务,如果没有持有,则定时任务结束。

使用jdk的timer类实现定时任务。

在上述代码的基础增加该方法

MyReentrantLock中加,并在tryLock成功后调用该方法,定时续期。

private void renewExpire() {
    String script = "if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 1 then " +
            "return redis.call('expire',KEYS[1],ARGV[2]) " +
            "else " +
            "return 0 " +
            "end";
​
    new Timer().schedule(new TimerTask()
    {
        @Override
        public void run()
        {
            if (stringRedisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class),
                    Collections.singletonList(key),val,String.valueOf(time))) {
                renewExpire();
            }
        }
    },(this.time * 1000)/3);
}

到此超时释放问题就解决了,就只剩下两个问题了

还有问题
  • 单点故障

    • 单机redis挂了,锁都直接没了
  • 集群一致性问题

    • 异步的复制,当A线程请求锁时主机存入A获取的锁状态时,直接返回获取锁成功,异步的复制给从机时,主机挂了,从机没有锁的状态,导致B线程此时获取锁发现从机没有锁就直接也存入了锁状态,就导致了一把锁两个线程都获取到了。

解决单点故障无非就是搞集群,集群面临主从的复制,Redis异步的主从复制会导致上述的不一致问题。

我们只需要想办法解决集群不一致问题就可以了。

Redis为了解决这个问题,引入了红锁的概念。

红锁

红锁(RedLock)是一种分布式锁算法,旨在解决在 Redis 集群中实现分布式锁时可能遇到的问题,它是一个基于多实例的 Redis 集群的锁实现方案。

红锁的原理如下:

  1. 多实例 Redis :红锁需要包含多个Redis主机实例,并且这些实例各自独立,互不影响。
  2. 获取当前时间,以毫秒为单位;
  3. 获取锁:当一个客户端需要获取锁时,它会尝试在 N 个 Redis 实例上获取锁,当从大多数(N/2+1,N是节点数量)的 Redis 节点都取到锁,并且获取锁使用的时间小于锁失效时间时,锁才算获取成功;。
  4. 判断是否获取锁:如果客户端成功在至少 N/2+1 个 Redis 实例上获取了锁(即至少 3 个实例),则表示成功获取锁。这是因为红锁的思想是使用大多数原则,确保锁的多数实例都被成功获取。
  5. 释放锁:客户端在释放锁时,需要在所有实例上执行 DEL 命令,将锁删除。

红锁的原理依赖于 Redis的多实例,并且要求大多数实例都能成功获取锁,以确保在分区和故障情况下锁仍然是有效的。

使用了红锁,即使挂了一台也无所谓,半数+1的实例中获取到了锁就算成功。

Redisson实现分布式锁

Redisson是java的redis客户端之一,提供了方便高效的API,该客户端就实现了红锁。

替换掉之前自定义的分布式锁使用Redisson实现分布式锁

依赖

<!--redisson-->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.4</version>
</dependency>

配置类

@Configuration
public class RedisConfig {
    
    //单Redis节点模式
    @Bean
    public Redisson redisson(){
        Config config = new Config();
        config.useSingleServer().setAddress("redis://ip:端口").setDatabase(使用几号数据库).setPassword("密码");
        return (Redisson) Redisson.create(config);
    }
}

使用Redisson后的代码

​
    @GetMapping
    public String test() {
        String uuid = UUID.randomUUID().toString(true);
        MyThreadLocal.set(uuid);
        // 创建锁对象
//        RedisLock redisLock = new RedisLock(stringRedisTemplate);
        // 使用自定义可重入锁
//        Lock myReentrantLock = new MyReentrantLock(stringRedisTemplate,"testMyReentrantLock",
//                Thread.currentThread().getId()+"");
//        // 自旋加锁直到成功
//        myReentrantLock.lock();
        // 使用Redisson实现分布式锁。
        String key = "testRedissonLock";
        RLock redissonLock = redisson.getLock(key);
        redissonLock.lock();
        System.out.println("第一次加锁成功:"+uuid+Thread.currentThread().getId());
        try {
            //业务代码
            // 库存减1
//
//            try{
//                Thread.sleep(10);
//            }catch (Exception e){e.printStackTrace();}
            Long stock = stringRedisTemplate.opsForValue().increment("stock", -1);
            System.out.println("库存数:"+stock);
            // 演示重入
            reentrant();
            return "购买成功";
        }catch (Exception e){
            e.printStackTrace();
            return "购买失败";
        }finally {
            if(redissonLock.isLocked() && redissonLock.isHeldByCurrentThread()){
                redissonLock.unlock();
            }
            System.out.println("第一次解锁成功:"+uuid+Thread.currentThread().getId());
        }
    }
​
​
    private void reentrant() {
//        Lock myReentrantLock = new MyReentrantLock(stringRedisTemplate,"testMyReentrantLock",
//                Thread.currentThread().getId()+"");
        String key = "testRedissonLock";
        RLock redissonLock = redisson.getLock(key);
        redissonLock.lock();
        System.out.println("第二次加锁成功:"+MyThreadLocal.get()+Thread.currentThread().getId());
        try {
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            if(redissonLock.isLocked() && redissonLock.isHeldByCurrentThread()){
                redissonLock.unlock();
            }
            System.out.println("第二次解锁成功:"+MyThreadLocal.get()+Thread.currentThread().getId());
        }
    }
}

RedissonMultiLock

联锁就是红锁的一个实现

配置类,配置多台redis实例

@Bean
public Redisson redisson1(){
    Config config = new Config();
    config.useSingleServer().setAddress("redis://ip:端口").setDatabase(使用几号数据库).setPassword("密码");
    return (Redisson) Redisson.create(config);
}
@Bean
public Redisson redisson2(){
    Config config = new Config();
    config.useSingleServer().setAddress("redis://ip:端口").setDatabase(使用几号数据库).setPassword("密码");
    return (Redisson) Redisson.create(config);
}
@Bean
public Redisson redisson3(){
    Config config = new Config();
   config.useSingleServer().setAddress("redis://ip:端口").setDatabase(使用几号数据库).setPassword("密码");
    return (Redisson) Redisson.create(config);
}

使用

    @Autowired
    private Redisson redisson1;
    @Autowired
    private Redisson redisson2;
    @Autowired
    private Redisson redisson3;
    /**
     * 模拟卖货
     * @return
     */
    @GetMapping
    public String test() {
        String uuid = UUID.randomUUID().toString(true);
        MyThreadLocal.set(uuid);
        // 创建锁对象
//        RedisLock redisLock = new RedisLock(stringRedisTemplate);
        // 使用自定义可重入锁
//        Lock myReentrantLock = new MyReentrantLock(stringRedisTemplate,"testMyReentrantLock",
//                Thread.currentThread().getId()+"");
//        // 自旋加锁直到成功
//        myReentrantLock.lock();
        // 使用Redisson实现分布式锁。
        String key = "testRedissonLock";
        RLock lock1 = redisson1.getLock(key);
        RLock lock2 = redisson2.getLock(key);
        RLock lock3 = redisson3.getLock(key);
        RedissonMultiLock redLock = new RedissonMultiLock(lock1, lock2, lock3);
        redLock.lock();
        System.out.println("第一次加锁成功:"+uuid+Thread.currentThread().getId());
        try {
            //业务代码
            // 库存减1
//
            try{
                Thread.sleep(50*1000);
            }catch (Exception e){e.printStackTrace();}
            Long stock = stringRedisTemplate.opsForValue().increment("stock", -1);
            System.out.println("库存数:"+stock);
            // 演示重入
            reentrant();
            return "购买成功";
        }catch (Exception e){
            e.printStackTrace();
            return "购买失败";
        }finally {
            if(redLock.isLocked() && redLock.isHeldByCurrentThread()){
                redLock.unlock();
            }
            System.out.println("第一次解锁成功:"+uuid+Thread.currentThread().getId());
        }
    }
​
​
    private void reentrant() {
//        Lock myReentrantLock = new MyReentrantLock(stringRedisTemplate,"testMyReentrantLock",
//                Thread.currentThread().getId()+"");
        String key = "testRedissonLock";
        RLock lock1 = redisson1.getLock(key);
        RLock lock2 = redisson2.getLock(key);
        RLock lock3 = redisson3.getLock(key);
        RedissonMultiLock redLock = new RedissonMultiLock(lock1, lock2, lock3);
        redLock.lock();
        System.out.println("第二次加锁成功:"+MyThreadLocal.get()+Thread.currentThread().getId());
        try {
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            if(redLock.isLocked() && redLock.isHeldByCurrentThread()){
                redLock.unlock();
            }
            System.out.println("第二次解锁成功:"+MyThreadLocal.get()+Thread.currentThread().getId());
        }
    }

使用联锁,则可以避免之前说过的所有问题。