redis分布式锁

分布式锁简介

在单机应用中对某一个共享变量进行多线程同步访问的时候,加锁就可以解决线程问题。
但在集群中,为了实现高并发,高可用,往往会部署多台服务器,而线程锁只在同一个服务器(jvm)中起到效果。
但在不同的机器(jvm)中,如果同时有两台机器对数据库中同一个值进行操作,此时线程锁将完全没有用,就只能用分布式锁了,他提供了一个全局的第三方的获取锁的一个东西,每个机器获取释放锁都需要经过这个东西。
这把锁一般由redis,数据库,zk等中间件实现。
一般来说该锁至少拥有四个规则:
1.锁的互斥性:在分布式集群应用中,共享资源的锁在同一时间只能被一个对象获取。
2.可重入:为了避免死锁,这把锁是可以重入的,并且可以设置超时。
3.高效的加锁和解锁:能够高效的加锁和解锁,获取锁和释放锁的性能也好。
4.阻塞、公平:可以根据业务的需要,考虑是使用阻塞、还是非阻塞,公平还是非公平的锁。

数据库实现

数据库中一般包含悲观锁和乐观锁
其中悲观锁依赖于数据库自身的锁机制实现,乐观锁cas则是通过版本号比较实现。

悲观锁使用

select ... where ... for update

在sql中加入for update,可以给数据行加上排它锁。

connection.commit(); 

解锁
但是悲观锁是重锁,所以一般不能适用高并发

乐观锁使用

数据库的乐观锁,在数据需要更新的时候才去对一下版本号(在表中加一个版本号字段),没有变化则说明期间没人更新数据,可以提交。
请添加图片描述
上图图2中 a在版本号为1的时候修改变量,未提交。b也想修改该变量,此时a未完成修改,版本号依然为1,b操作比较简单抢先a一步将改变量修改完成,在提交前查看版本号还是1,未改变可提交,此时b就将修改提交,并将版本号加1变成2。在b操作完成后不久,a也完成了,提交前查看了下版本号变成2了,与原来读入的1不相等,这就表明在a操作的过程中有其他操作执行,此时a的行为回滚,操作失败。这就是简单的乐观锁。

乐观锁实现分布式锁

可建立一张专门做分布式的表
里面有 ID,方法名字,方法描述等

CREATE TABLE `LOCK` ( 
`ID` int PRIMARY KEY NOT NULL AUTO_INCREMENT,  
`METHODNAME` varchar(64) NOT NULL DEFAULT '',
`DESCRIPTION` varchar(1024) NOT NULL DEFAULT '',  
`TIME` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,  
UNIQUE KEY `UNIQUEMETHODNAME` (`METHODNAME`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8;

METHODNAME 这个属性是 UNIQUE KEY
当要获取锁时

insert into LOCK(METHODNAME,DESCRIPTION) values (‘getLock’,‘获取锁’) ;

因为 METHODNAME 是 UNIQUE KEY,如果执行失败 说明已经有getLock这条记录,锁已经被其他线程持有,放弃此次操作。如果执行成功就说明,成功获取到锁,操作完毕后,释放锁。

delete from LOCK where METHODNAME='getLock';

可重入,可以增加一个次数字段,如果是当前线程 次数加1,释放一次减1
失效时间,在时间戳字段尚存一个逾期时间,每次和当前时间比较,如果超过自动释放锁
实现公平锁,竞争的线程都会按照时间存储于这个中间表,当要某个线程尝试获取某个方法的锁的时候,检查中间表中是否已经存在等待的队列。

但是 数据库实现分布式锁一般不太采用

redis实现

redis事务

在redis中,事务的作用就是在一个队列中一次性、顺序性、排他性的执行一系列的命令。

事务的生命周期:

  1. 事务的创建:使用MULTI开启一个事务

  2. 加入队列:在开启事务的时候,每次操作的命令将会被插入到一个队列中,同时这个命令并不会被真的执行

  3. EXEC命令进行提交事务

常用的关于事务的命令有:

  1. MULTI:使用该命令,标记一个事务块的开始,通常在执行之后会回复OK,(但不一定真的OK),这个时候用户可以输入多个操作来代替逐条操作,redis会将这些操作放入队列中。

  2. EXEC:执行这个事务内的所有命令

  3. DISCARD:放弃事务,即该事务内的所有命令都将取消

  4. WATCH:监控一个或者多个key,如果这些key在提交事务(EXEC)之前被其他用户修改过,那么事务将执行失败,需要重新获取最新数据重头操作(类似于乐观锁)。

  5. UNWATCH:取消WATCH命令对多有key的监控,所有监控锁将会被取消。
    redis事务的“原子性”

MULTI
中间的命令如果在执行前出问题(语法有问题这类的,输入时就已经报错)EXEC  就会失败
中间的命令如果在执行中出问题(输入时就没报错)EXEC  就会成功,只有那个执行有问题的语句才会报错,其他正常执行
EXEC

原因是在redis中,对于一个存在问题的命令,如果在入队的时候就已经出错,整个事务内的命令将都不会被执行(其后续的命令依然可以入队),如果这个错误命令在入队的时候并没有报错,而是在执行的时候出错了,那么redis默认跳过这个命令执行后续命令。也就是说,redis只实现了部分事务。

最后通过上述的实验,我们总结redis事务的三条性质:

  1. 单独的隔离操作:事务中的所有命令会被序列化、按顺序执行,在执行的过程中不会被其他客户端发送来的命令打断
  2. 没有隔离级别的概念:队列中的命令在事务没有被提交之前不会被实际执行
  3. 不保证原子性:redis中的一个事务中如果存在命令执行失败,那么其他命令依然会被执行,没有回滚机制

细节

redis实现分布式锁

setnx(key,value)
如果key存在则啥都不操作返回0key不存在操作成功返回1。得到所
expire()
设置key的过期时间
del()删除key,释放锁
getset:
将key设置为给定的value值,并返回原来的旧value值,若是key不存在就会返回返回null

参考
1)初始化客户端线程池

public static void intitClients() {
 ExecutorService threadPool= Executors.newCachedThreadPool();
 for (int i = 0; i < 5000; i++) {
  threadPool.execute(new Client(i));
 }
 threadPool.shutdown();
 
 while(true){ 
         if(threadPool.isTerminated()){  
             break;  
         }  
     }  
}

2)初始化商品数量

public static void initPrductNum() {
  Jedis jedis = RedisUtil.getInstance().getJedis();
  jedisUtils.set("produce", "1000");// 初始化商品库存数
  RedisUtil.returnResource(jedis);// 返还数据库连接
 }
}

3)处理


/**
 * 顾客线程
 * 
 *
 */
class client implements Runnable {
 Jedis jedis = null;
 String key = "produce"; // 商品数量的主键
 String name;
 
 public ClientThread(int num) {
  name= "编号=" + num;
 }
 
 public void run() {
 
  while (true) {
   jedis = RedisUtil.getInstance().getJedis();
   try {
    jedis.watch(key);
    int num= Integer.parseInt(jedis.get(key));// 当前商品个数
    if (num> 0) {
     Transaction ts= jedis.multi(); // 开始事务
     ts.set(key, String.valueOf(num - 1)); // 库存扣减
     List<Object> result = ts.exec(); // 执行事务
     if (result == null || result.isEmpty()) {
      System.out.println("抱歉,您抢购失败,请再次重试");
     } else {
      System.out.println("恭喜您,抢购成功");
      break;
     }
    } else {
     System.out.println("抱歉,商品已经卖完");
     break;
    }
   } catch (Exception e) {
    e.printStackTrace();
   } finally {
    jedis.unwatch(); // 解除被监视的key
    RedisUtil.returnResource(jedis);
   }
  }
 }
}

在代码的实现中有一个重要的点就是「商品的数据量被watch了」,当前的客户端只要发现数量被改变就会抢购失败,然后不断的自旋进行抢购。

这个是基于Redis事务实现的简单的秒杀系统,Redis事务中的watch命令有点类似乐观锁的机制,只要发现商品数量被修改,就执行失败。

第二种


public void redis(Produce produce) {
        long timeout= 10000L; // 超时时间
        Long result= RedisUtil.setnx(produce.getId(), String.valueOf(System.currentTimeMillis() + timeout));
        if (result!= null && result.intValue() == 1) { // 返回1表示成功获取到锁
         RedisUtil.expire(produce.getId(), 10);//有效期为5秒,防止死锁
         //执行业务操作
         ......
         //执行完业务后,释放锁
         RedisUtil.del(produce.getId());
        } else {
           System.println.out("没有获取到锁")
        }
    }

1.先setnx如果成功获取到锁,(如果没有获取到,要检查上个业务时间是否过期,在进行处理)
2.先设定expire的过期时间(防止死锁),
3.执行业务
4.业务结束释放锁del

public void redis(Produce produce) {
        long timeout= 10000L; // 超时时间
        Long result= RedisUtil.setnx(produce.getId(), String.valueOf(System.currentTimeMillis() + timeout));
        if (result!= null && result.intValue() == 1) { // 返回1表示成功获取到锁
         RedisUtil.expire(produce.getId(), 10);//有效期为10秒,防止死锁
         //执行业务操作
         ......
         //执行完业务后,释放锁
         RedisUtil.del(produce.getId());
        } else {
            String value= RedisUtil.get(produce.getId());
            // 存在该key,并且已经超时
            if (value!= null && System.currentTimeMillis() > Long.parseLong(value)) {
                String result = RedisUtil.getSet(produce.getId(), String.valueOf(System.currentTimeMillis() + timeout)); 
                if (result == null || (result != null && StringUtils.equals(value, result))) {
                     RedisUtil.expire(produce.getId(), 10);//有效期为10秒,防止死锁
           //执行业务操作
           ......
           //执行完业务后,释放锁
           RedisUtil.del(produce.getId());
                } else {
                    System.println("没有获取到锁")
                }
            } else {
                System.println("没有获取到锁")
            }
        }
    }

但如果1,2之间redis宕机 则凉凉

第三种Redisson
请添加图片描述
并且它还支持「Redis单实例、Redis哨兵、redis cluster、redis master-slave」等各种部署架构,都给你完美的实现,不用自己再次拧螺丝。

但是,crud的同时还是要学习一下它的底层的实现原理,下面我们来了解下一下,对于一个分布式的锁的框架主要的学习分为下面的5个点:

加锁机制
解锁机制
生存时间延长机制
可重入加锁机制
锁释放机制
只要掌握一个框架的这五个大点,基本这个框架的核心思想就已经掌握了,若是要你去实现一个锁机制框架,就会有大体的一个思路。

Redisson中的加锁机制是通过lua脚本进行实现,Redisson首先会通过「hash算法」,选择redis cluster集群中的一个节点,接着会把一个lua脚本发送到Redis中。

它底层实现的lua脚本如下:
lua脚本


returncommandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
 "if (redis.call('exists', KEYS[1]) == 0) then " +
       "redis.call('hset', KEYS[1], ARGV[2], 1); " +
       "redis.call('pexpire', KEYS[1], ARGV[1]); " +
       "return nil; " +
   "end; " +
   "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
       "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
       "redis.call('pexpire', KEYS[1], ARGV[1]); " +
       "return nil; " +
   "end; " +
   "return redis.call('pttl', KEYS[1]);",
     Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));

「redis.call()的第一个参数表示要执行的命令,KEYS[1]表示要加锁的key值,ARGV[1]表示key的生存时间,默认时30秒,ARGV[2]表示加锁的客户端的ID。」

比如第一行中redis.call(‘exists’, KEYS[1]) == 0) 表示执行exists命令判断Redis中是否含有KEYS[1],这个还是比较好理解的。

lua脚本中封装了要执行的业务逻辑代码,它能够保证执行业务代码的原子性,它通过hset lockName命令完成加锁。

若是第一个客户端已经通过hset命令成功加锁,当第二个客户端继续执行lua脚本时,会发现锁已经被占用,就会通过pttl myLock返回第一个客户端的持锁生存时间。

若是还有生存时间,表示第一个客户端会继续持有锁,那么第二个客户端就会不停的自旋尝试去获取锁。

假如第一个客户端持有锁的时间快到期了,想继续持有锁,可以给它启动一个watch dog看门狗,他是一个后台线程会每隔10秒检查一次,可以不断的延长持有锁的时间。

Redisson中可重入锁的实现是通过incrby lockName来实现,「重入一个计数就会+1,释放一次锁计数就会-1」。

最后,使用完锁后执行del lockName就可以直接「释放锁」,这样其它的客户端就可以争抢到该锁了。

这就是分布式锁的开源Redisson框架底层锁机制的实现原理,我们可以在生产中实现该框架实现分布式锁的高效使用。

public class SellTicket implements Runnable {
    private int ticketNum = 1000;
    RLock lock = getLock();
    // 获取锁 
    private RLock getLock() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379");
        Redisson redisson = (Redisson) Redisson.create(config);
        RLock lock = redisson.getLock("keyName");
        return lock;
    }
 
    @Override
    public void run() {
        while (ticketNum>0) {
            // 获取锁,并设置超时时间
            lock.lock(1, TimeUnit.MINUTES);
            try {
                if (ticketNum> 0) {
                    System.out.println(Thread.currentThread().getName() + "出售第 " + ticketNum-- + " 张票");
                }
            } finally {
                lock.unlock(); // 释放锁
            }
        }
    }
}

测试代码


public class Test {
    public static void main(String[] args) {
        SellTicket sellTick= new SellTicket();
        // 开启5五条线程,模拟5个窗口
        for (int i=1; i<=5; i++) {
            new Thread(sellTick, "窗口" + i).start();
        }
    }
}

是不是感觉很简单,因为多线程竞争共享资源的复杂的过程它在底层都帮你实现了,屏蔽了这些复杂的过程,而你也就成为了优秀的API调用者。
摘抄添加链接描述

ZK实现

。。。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值