分布式锁-悲观锁,乐观锁,Redis分布式锁(临界判断,线程重入)

勿以浮沙筑高台


为什么需要锁

当我们多用户请求的时候,多个线程去拿一个内存地址进行修改,线程还没修改完,另一个线程又进行了读取,读取的值并不是修改后的值而是原始值,这个时候就会出现修改值错误的情况,比如A线程拿值为10,这个时候A线程进行修改为9,但B线程在还没开始修改之前也拿到了值为10,这个时候就会修改为9,并不是我想要的8.

e## 代码模拟

public class SynchronizeTest {
    public int i=20;
    public void get(){
        if (i>0){
            i--;
            System.out.println(i);
        }
    }
}
    public static void main( String[] args )
    {

        SynchronizeTest sy = new SynchronizeTest();
        for (int i = 0; i < 30; i++) {
            new Thread(()->{
                sy.get();
            }).start();
        }
    }

结果
在这里插入图片描述
出现了描述的情况。

什么是锁

为了解决这种多线程情况下的并发问题,我们已堵塞线程为代价换取数据的准确性,阻塞线程的过程就称之为锁。

synchronize锁

在java中我们用synchronize关键字进行锁的管理,即锁定一个资源每次只能拥有一个线程进行访问。

基础语法:

public class SynchronizeTest {
    public int i=20;
    public void get(){
        synchronized(this.getClass()){
            if (i>0){
                i--;
                System.out.println(i);
            }
        }
    }
}

需要主要的是synchronize()括号当中必须在当前JVM中是唯一的,因为如果不是唯一的话,多个字段标记为一处锁的地段,还是能多个线程拿取。所以这里用this.getclass()唯一标记

数据库锁-悲观锁

悲观锁,对一张表中的一条行数据或者表数据进行锁定,当表中的数据修改完成后其他的sql语句才会执行

实现方式:select …for update

select * from books FOR UPDATE;

当查询的where 条件中带有主键时,会进行行锁(row lock)

select * from books where id =3 FOR UPDATE;

当where条件中没有主键时,会进行表锁(table lock)

select * from books where bookname like “%书%” FOR UPDATE;

开启事务后,只要我们的查询没有没有进行最终的commit对于其他的用户是不能对数据进行查询的。有名排它锁。

数据库锁-乐观锁

乐观锁及在数据库字段增加一个version字段,当数据发生更改时version的数据由0变为1。当其他锁进行修改

SELECT version as ver,count as ct FROM BOOKS WHERE ID = 1
update books set version+1 , count -1 WHERE count = ct and VERSION = ver

乐观锁利用的是update操作加了锁的特性,增加一个查询字段代表历史版本当版本更改,其余操作都是无效的特点实现了锁的步骤


总结

上面2种方式都是基于数据库实现的分布式锁,但是基于数据库方式实现锁的效率并不高,我们完全可以基于分布式系统的特点来实现锁的效果。比如在zookeeper中,我们利用节点的创建和监听实现锁的创建,即每个线程创建一个零时节点,当线程操作完或关闭删除节点,触发下一个线程的监听事件,监听启动拿到锁执行业务逻辑操作数据库。具体逻辑见文章:

Zookeeper分布式锁解决羊群效应的方案

Redis分布式实现锁

接下来说Redis锁的实现逻辑
即创建了锁的线程执行业务代码service,执行完后删除节点。
在这里插入图片描述

伪代码如下:

 public boolean lock(Integer goodNo) {
        // 分布式锁的key
        String key = "redis-lock";
        // 根据key获取key的值是否存在
        Object result = redisTemplate.opsForValue().get(key);
        if (result != null) {
            try {
                TimeUnit.MILLISECONDS.sleep(200);
            } catch (Exception e) {
                e.printStackTrace();
            }
            // 锁还没有释放,递归获取锁\
            logger.info("尝试获取锁");
            orderV1(goodNo);
        } else {
            // 说明锁还没有设置
            redisTemplate.opsForValue().set(key,UUID.randomUUID());
            //执行业务代码
            //拿到数据
            StockInfo stockInfo = stockInfoMapper.selectById(id);
            if (stockInfo != null ) {
              //执行业务逻辑
              service.executeService();
              //记录日志
                logger.info("业务逻辑成功");
                // 释放锁,在执行释放锁上面的时候已经出现了异常,意味着锁没有释放,那么其他线程不可能在获取锁,导致死锁
                redisTemplate.delete(key);

                return true;
            }
        }

        return false;
    }

表面上这样看好像没有什么问题,但是仔细想想,倘若主机A拿到数据执行业务的时候宕机了,没有执行到删除节点代码,那么就出现死锁了。


为了避免的上面出现的问题,我们创建的时候给节点创建一个过期时间。

    //新增过期时间30秒
    redisTemplate.opsForValue().set(key,UUID.randomUUID(),Duration.ofSeconds(30));

增加一个报错删除节点

       finally {
       //增加trycatch,当报错的时候要删除锁
                redisTemplate.delete(key);
                logger.info("释放了锁");
                }

继续思考临界值:

  1. 有没有可能线程A报错,走finally进行删除锁,这个时候线程A的锁刚刚过期了,线程B进来创建了锁,执行业务代码,那么这个时候线程A执行finally删除了B的锁的情况,线程C检测到没有lock,进来执行业务代码,造成了并发。
    在这里插入图片描述

这个操作的根本原因就是不是原子操作,分步的一定会有这样的问题。

  1. 线程重入问题,当多核cpu之间发生线程切换,即:线程A执行到一半时,线程A停顿了一下,切换到了另一个内核上运行,这个时候我们还需要去重新设置锁么?在这里插入图片描述

问题解决:

  1. 解决线程重入问题
    伪代码
//新增一个线程副本变量
 private static final ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<>();
 
public boolean lock(Integer goodNo) {
     //在执行写入redis之前进行副本判断
     //从redis中拿到返回值进行判断
     result = redisTemplate.opsForValue().get(key);
     //拿到生成的UUID
     String threadId = THREAD_LOCAL.get();
     //threadId和redisValue不为空,并且相等时
     //线程重入不需要拿锁
    if (StringUtils.hasText(threadId)
                && StringUtils.hasText(redisValue)
                && threadId.equals(redisValue)) {
		   //执行业务代码
		   service.executeService();
		   //删除锁
		   redisTemplate.delete(key);
		   return true;
	   }

   //如果不是则将生成的UUID写入线程副本当中
   THREAD_LOCAL.set(UUID);
   //回到上面的代码lock方法中业务逻辑()
    }
  1. 问题1的本质不是原子操作的,即查询和删除不是一起的。这个2个步骤本来就不是一起的,因此在删除之前再次查询线程中的uuid是否一直。即:
       String uuidnew = redisTemplate.opsForValue().get(key)+"";
       if(uuidnew ==uuid){   //当一样时才删除。
          //删除key
           redisTemplate.delete(key);
       }

继续思考:
如果在删除的过程中发生报错了怎么办,redis内部报错,导致删除失败的情况

使用lua脚本对redis创建报错优化

因为Lua脚本具有原子性,当lua脚本执行发生报错时会回滚数据。

lock.lua

-- Set a lock
--  如果获取锁成功,则返回 1 
local key     = KEYS[1]  --key 
local content = KEYS[2]   --UUID
local ttl     = ARGV[1]   --过期时间
local lockSet = redis.call('setnx', key, content)   --调用setnx方法设置值
if lockSet == 1 then     --如果等于1则表示拿到锁了
  redis.call('pexpire', key, ttl)    --设置过期时间
else
  -- 如果value相同,则认为是同一个线程的请求,则认为重入锁
  local value = redis.call('get', key)   --Key
  if(value == content) then      --如果值是相同的则重入 
    lockSet = 1;
    redis.call('pexpire', key, ttl)    --重新刷新下过期时间,返回1,表示拿到了锁
  end
end
return lockSet
local key     = KEYS[1] --keyname
local content = KEYS[2]   --UUID
local value = redis.call('get', key)   --拿到redis中的UUID
if value == content then   --如果UUID值相等才删除
  return redis.call('del', key);
end
return 0

伪代码

public boolean lock(Integer goodNo) {
     //设置key
     String key = "lock-redis";
     //拿到UUID
     String uuid = UUID.randomUUID().toString();
     //设置超时时间
     Duration duration = Duration.ofSeconds(30);
     //调用lua脚本
     int off = LuaManager.execute("lock.lua",key,uuid , duration );
     if(off=1){//代表创建成功
          try{
            //执行业务代码
          Service.execute();
          }cathch(ex){
          ex.message();
          }finnaly {
        //调用unlock.lua脚本
             int off = LuaManager.execute("unlock.lua",key,uuid);
          }
       }
    }
  • 4
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值