1.分布式锁的出现

当我们处于集群分布式,原先加的用户锁,就不能锁住单体用户,如果在不同的jvm中同一用户再次抢卷,仍可能出现一人抢多张卷的情况,如图:
集群时线程的情况

所以需要一个锁监视器来监视全部的锁,下面时分布式锁的工作原理:
分布式锁的工作原理

2.什么是分布式锁

分布式锁:满足分布式系统或者集群模式下多线程可见并且互斥的锁
一般满足一下五项必须特性

  • 多线程可见
  • 互斥
  • 高可用
  • 高性能
  • 安全性

还有些功能性特征,比如:是否满足可重入性,获取锁是阻塞的还是非阻塞的等等

3.分布式锁的实现

分布式锁的核心是多线程之间的互斥,而满足这一点的方法有很多,常见有三种:
常见三种分布式锁

基于redis实现分布式锁
基于redis的分布式锁的实现原理
分布式锁的实现原理

3.1实现redis分布式锁版本1

package com.hmdp.utils;

public interface ILock {

    /**
    尝试获取锁
     */
    boolean tryLock(long timeoutSec);

    /**
     * 释放锁
     */
    void unLock();
}

package com.hmdp.utils;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.concurrent.TimeUnit;

@RequiredArgsConstructor
public class SimpleRedisLock implements ILock{

    private final StringRedisTemplate stringRedisTemplate;
    private final String name;

    private final String KEY_PREFIX = "lock:";//锁的key的统一前缀



    @Override
    public boolean tryLock(long timeoutSec) {
        //获取当前线程的标识
        long id = Thread.currentThread().getId();
        //获取锁
        return Boolean.TRUE.equals(stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, String.valueOf(id), timeoutSec, TimeUnit.SECONDS));
    }

    @Override
    public void unLock() {
        //释放锁
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

//加悲观锁
        Long userId = UserHolder.getUser().getId();

        //创建锁对象
        SimpleRedisLock redisLock = new SimpleRedisLock(stringRedisTemplate, "order:" + userId);
        //获取锁  ,正常不需要设置这么长时间,这里是为了方便调试
        boolean b = redisLock.tryLock(12000);
        //判断获取锁成功
        if (!b) {
            //获取锁失败,返回失败
            return Result.fail("请勿重复下单");
        }

        try {
            //拿到当前对象的代理对象
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            //7.返回订单ID 利用代理对象调用createVoucherOrder方法,事务才能正常回滚
            return proxy.createVoucherOrder(id);
        } finally {
        //释放锁
            redisLock.unLock();
        }

3.2分布式锁误删问题

当线程1业务阻塞时,线程1的锁因超时自动释放了,这时线程2进来获取锁,获取锁后,线程1业务完成释放锁,直接把线程2的锁给释放了,如果这时来了个线程3获取锁也能获取成功,这是就会出现两把锁,产生线程安全问题,发生了分布式锁的误删,这就是实现redis分布式锁版本1的缺点,过程如图:
误删问题的发生

由于这个原因,我们需要给锁加一个标识,保证每一个线程释放的锁是自己创建的锁,如图:
给锁加标识的分布式锁方案

分布式锁的改进方案,如图:
分布式锁的改进方案

只需要修改锁的实现类即可

private final StringRedisTemplate stringRedisTemplate;
    private final String name;

    private final String KEY_PREFIX = "lock:";//锁的key的统一前缀
    private final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";//利用uuid生成线程标识,参数true表示删除uuid中的-


    @Override
    public boolean tryLock(long timeoutSec) {
        //获取当前线程的标识
        String id = ID_PREFIX + Thread.currentThread().getId();
        //获取锁
        return Boolean.TRUE.equals(stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, id, timeoutSec, TimeUnit.SECONDS));
    }

    @Override
    public void unLock() {
        //获取线程标识
        String id = ID_PREFIX + Thread.currentThread().getId();
        //判断标识是否一致
        String redisId = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        if (id.equals(redisId)) {
            //一致,释放锁
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }

3.3分布式锁的原子性问题

当业务在判断锁是否唯一后,发生了阻塞 (阻塞原因是jvm垃圾回收) 虽然判断动作结束后就是释放锁,但是中间仍可能因为垃圾回收引起线程阻塞,导致超市释放锁,又有一个线程2在阻塞过程中,超时释放锁结束后获取锁,然后线程1把线程2的锁释放了,又再次发生线程安全问题,出现这种情况的原因,就是锁标识和释放锁是两个动作,不能保证判断锁和释放锁成为一个原子性的动作。
原子性问题发生实列
redis的事务只能保证原子性,但是不能保证一致性,可以利用redis事务结合乐观锁的忘却机制实现,但是很复杂
在redis中提供Lua脚本功能,在一个脚本中编写多条redis命令,确保原子性。Lua是一种编程语言,基本语法参考网站https://www.runoob.com/lua/lua-basic-syntax.html

在resources资源目录下创建unlock.lua资源文件

--这里的KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标识
--获取锁中的标识,判断是否与当前线程标识一致
if(redis.call('GET',KEYS[1]==ARGV[1])) then
	--一致,则删除锁
	return redis.call('DEL',KEYS[1])
end
--不一致,则直接返回
return 0

由上述分析可知,出现问题的原因只和释放锁有关,所以我们是改动释放锁的方法

//加载lua脚本静态资源
private static final DefaultRedisScript< Long> UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }
    
 //不再是自己释放锁,而是通过lua脚本释放锁
    @Override
    public void unLock() {
        //调用lua脚本
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name),
                ID_PREFIX + Thread.currentThread().getId());
    }

4.redisson

上述redis分布式锁可以解决大多数问题,但是基于setnx实现的分布式锁仍存在下面情况:
setnx实现的分布式锁仍存在下面情况
于是就出现了redisson,redisson是一个在redis基础上实现java驻内存数据网络。他不仅提供了一系列分布式的java常用对象,还提供了许多分布式服务,其中包含各种锁的实现:
各种锁的实现

4.1redisson的快速入门

引入xml依赖

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

redisson也可以在yaml文件中配置,但是配置会覆盖原来的redis配置,所以这里单独配置

@Configuration
@ConfigurationProperties(prefix = "spring.redis")
public class RedisConfig {
    @Bean
    public RedissonClient redisClient() {
        //配置
        Config config=new Config();
        String address = "redis://" + host + ":" + port;
        config.useSingleServer()
                .setAddress(address)
                .setPassword(password)
                .setDatabase(database);
        //创建redisson对象
        return Redisson.create(config);
    }
}

其实这里也可以读取yaml文件中的redis配置属性,这样也能做到更好的解耦,
属性多的话,可以在类上加注解@ConfigurationProperties(prefix = “spring.redis”)少的话可以在属性上加注解,如@Value(“${spring.redis.host}”),同时记得需要在启动类或配置类上添加 @EnableConfigurationProperties 来启用配置属性功能。

@Component
@ConfigurationProperties(prefix = "spring.redis")
public class RedisProperties {
    private String host;
    private int port;
    private String password;
    private int database;
    
    // getters and setters
}

使用redisson分布式锁
分布式锁的使用

4.2可重入锁的原理

将原来redis存的key:String变成key:hash类型,保证一次能存入线程标识和获取锁的次数,每次获取锁,hash.value就+1(同时重置有效期),每次释放锁就-1,当=0时就删除锁,整体过程,如图:
可重入锁的原理
原理的过程很多,为了保证事务的原子性,肯定不能在依靠java代码了,我们需要借用lua脚本实现,如图:

  • 获取锁
    lua脚本获取锁的流程
  • 释放锁
    lua脚本释放锁
    redisson分布式锁的原理
    watchDog看门狗机制,只要转入参数leaseTime=-1时才会执行,这是一个重置锁过期时间的方法,自动续约方案
    redisson分布式锁的原理

4.3redisson解决主从一致性导致锁失效问题

一般redis都是以集群的方式部署,主节点负责写修改功能,从节点负责读的功能,主节点修改后,会向从节点进行同步(中间虽然很快,但如果主节点修改完,还没向从节点同步就宕机的话,会从从节点服务中重新挑选一个主节点,重选后却不带原来的锁,就会出现主从一致导致锁丢失问题),效果图如图:
产生过程
在redisson中,我们将全部的从节点都变成主节点,同时从所有节点中获取锁,如果有一个获取失败了,就不能获取锁,同时确保安全,我们也可以在全部节点后都添加从节点,这样就算其中一个主节点失效了,也可以从节点顶替成主节点,就算顶替的从节点中没有锁,我们需要全部都获取锁才能成功获取锁,所有也对整个业务没有影响,只是让java应用重新获取锁而已。

这种行为在redisson中叫做连锁。

5.总结

总结

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐