Redis的事务和锁机制(乐观锁和悲观锁)

Redis学习笔记(四)

  • 1,Redis事务的定义
  • 2,Redis事务操作的三个基本命令
  • 3,解决Redis中的事务冲突(乐观锁和悲观锁)
    • 3.1,悲观锁
    • 3.2,乐观锁
    • 3.3,Redis中使用乐观锁
    • 3.4,Redis事务的三特性
  • 4,秒杀案例感受Redis中的事务和锁机制
    • 4.1,秒杀的基本实现
    • 4.2,代码存在的一些问题
    • 4.3,优化一:通过连接池解决连接超时问题
    • 4.4,优化二:通过Redis的事务和锁机制解决超卖问题(重点)
    • *4.5,优化三:通过LUA脚本解决库存遗留问题

1,Redis事务的定义

Redis事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。

Redis事务的主要作用就是串联多个命令防止别的命令插队。

2,Redis事务操作的三个基本命令

multi、exec、discard为Redis事务操作的三个基本命令

  • 从输入multi命令开始,输入的命令都会依次进入命令队列中,但不会执行。
  • 直到输入exec命令后,Redis会将之前的命令队列中的命令依次执行。
  • 输入multi命令进入组队阶段后,可以使用discard命令放弃组队。

事务执行成功的情况演示如下:

Redis的事务和锁机制(乐观锁和悲观锁)_第1张图片
Redis的事务和锁机制(乐观锁和悲观锁)_第2张图片
Redis的事务和锁机制(乐观锁和悲观锁)_第3张图片


事务执行失败的情况演示如下:

情形一: 组队中某个命令出现了报告错误,执行时整个的所有队列都会被取消。如下图:
Redis的事务和锁机制(乐观锁和悲观锁)_第4张图片
Redis的事务和锁机制(乐观锁和悲观锁)_第5张图片

情形二: 执行阶段某个命令报出了错误,则只有报错的命令不会被执行,而其他的命令都会执行,不会回滚。如下图:
Redis的事务和锁机制(乐观锁和悲观锁)_第6张图片
Redis的事务和锁机制(乐观锁和悲观锁)_第7张图片

注意:上图中,虽然存在执行失败的命令。但不出错的命令,最终依然可以执行成功!!! 因为Redis数据库不保证原子性。


3,解决Redis中的事务冲突(乐观锁和悲观锁)

引例:

想象一个场景: 你的银行卡里有10000元,共有三个人知道你的银行卡账户和密码:分别是你的前女友、现女友和你自己。三个人同时使用此账户在双十一当天进行消费。(银行卡不可以透支)

  • 前女友存在报复心理,准备花8000元给自己买一台iphone13 pro
  • 现女友想花5000元给自己买一台iphone12
  • 你想花1000元修一修自己使用多年的诺基亚手机

Redis的事务和锁机制(乐观锁和悲观锁)_第8张图片
三个人同时消费的时候,如果同时通过了if语句判断。最终卡余额就会变为 -4000。明显存在冲突问题。

以上即为事务冲突问题。

如何处理事务冲突问题?
Redis针对此类问题提供了乐观锁悲观锁的机制进行解决。

3.1,悲观锁

悲观锁: 顾名思义,就是很悲观。每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人就不能拿到这个数据直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等都是在做操作之前先上锁。

Redis的事务和锁机制(乐观锁和悲观锁)_第9张图片
悲观锁。前女友操作10000的时候,首先会给余额上锁。上完锁进入阻塞状态,别人就不能操作了,除非锁打开。前女友消费后余额减少8000,剩2000。然后解锁,解锁之后现女友才可以得到这2000,现女友再拿2000进行操作,依然是先上锁。判断2000不能减5000,因此不能操作(当场分手)…----此即为悲观锁机制

悲观锁存在效率低的缺点,因为很多人操作的时候只能一个一个来,不可以同时多人进行操作。

3.2,乐观锁

乐观锁: 顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型(很多人读数据但只要少部分人修改),这样可以提高吞吐量。Redis默认就是利用这种机制实现事务的。

Redis的事务和锁机制(乐观锁和悲观锁)_第10张图片
乐观锁。对数据操作的时候可以给数据加上一个版本号的字段。一开始所有人都可以得到版本数据,如前女友和现女友都可以得到版本为1.0的数据,此数据均为10000。如果前女友手速较快,就会先消费8000,余额修变为2000,同时版本号也同步更新为1.1。这时如果现女友再进行操作,就需要检查一下当前数据的版本号和数据库中的版本号是否一致。 发现1.0!=1.1,版本号不一致则不能再进行操作,无法消费(当场分手)…—此即为乐观锁机制

乐观锁应用广泛,典型的乐观锁应用场景:12306抢票。当系统中只有一张票的时候所有人都可以参与抢票,但最终只能有一个人支付成功。


3.3,Redis中使用乐观锁

watch key [key …]:在执行multi之前,先执行watch key1 [key2],可以监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。

①连接Redis服务器后在xshell中打开两个终端(分别代表前女友和现女友)
Redis的事务和锁机制(乐观锁和悲观锁)_第11张图片
②在终端1中加入数据
在这里插入图片描述
③两个终端都监视balance
在这里插入图片描述
④两个终端都通过multi开启事务
在这里插入图片描述

⑤终端一(前女友)中执行事务
Redis的事务和锁机制(乐观锁和悲观锁)_第12张图片
⑥终端二(现女友)中执行事务
Redis的事务和锁机制(乐观锁和悲观锁)_第13张图片


以上两个终端(前女友和现女友)都得到balannce(余额)这个数据,对其进行监视(watch),前女友这边先执行就会把balance的版本号进行修改;然后现女友那边经过判断发现版本号发生改变就不能再进行修改。(当场分手)


3.4,Redis事务的三特性

  • 单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
  • 没有隔离级别的概念:队列中的命令没有提交之前都不会实际被执行,因为事务提交前任何指令都不会被实际执行
  • 不保证原子性!!!事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚

4,秒杀案例感受Redis中的事务和锁机制

秒杀是日常中典型的商家活动,接下来我们对其进行模拟。

如果有10个商品需要进行秒杀,100个人参与秒杀。其中一个人抢到了商品,商品库存减一,抢到商品的用户加入到秒杀成功者清单中去。以此类推…

Redis的事务和锁机制(乐观锁和悲观锁)_第14张图片
秒杀的大致思路如下:

  • 判断商品id和用户id是否为空,空则return false
  • 连接redis
  • 拼接库存key和秒杀成功用户key(用于区分)
  • 判断库存是否为null。null则说明秒杀未开始
  • 判断用户是否重复秒杀(已经秒杀成功的不可以重复秒杀)
  • 判断商品库存数量是否小于1,大于等于1才可以秒杀
  • 执行秒杀。库存减一,秒杀成功用户的信息添加至清单

4.1,秒杀的基本实现

代码实现如下:

public class SecKill_redis {

	public static void main(String[] args) {
		Jedis jedis =new Jedis("47.116.4.200",6379); //此处第一个参数为你的服务器ip地址,第二个参数为你的服务器上redis的端口号
		jedis.auth("******");  //如果Redis设置了密码此处需要授权

		System.out.println(jedis.ping()); //测试Redis是否连接成功
		jedis.close();
	}

	//秒杀过程
	public static boolean doSecKill(String uid,String prodid) throws IOException {

		//1 uid和prodid非空判断(这两个如果有空值,直接不执行即可)
		if(uid == null || prodid == null) {
			return false;
		}

		//2 连接redis(通过Jedis进行连接)
		Jedis jedis = new Jedis("47.116.4.200",6379);
		jedis.auth("******");  //如果Redis设置了密码此处需要授权

		//3 拼接相关key(:便于分组;和prodid拼接可以区分不同商品的秒杀过程)
		// 3.1 库存key
		String kcKey = "sk:"+prodid+":qt";
		// 3.2 秒杀成功用户key
		String userKey = "sk:"+prodid+":user";


		//4 获取库存,如果库存null,表示秒杀还没有开始
		String kc = jedis.get(kcKey); //库存最终存入了Redis中
		if(kc == null) {  //表示秒杀未开始
			System.out.println("秒杀还没有开始,请等待");
			jedis.close();

			return false;  //直接返回,不再执行其他操作
		}

		// 5 判断用户是否重复秒杀操作(保证一个用户只能秒杀一次)
		//注意秒杀成功清单中的value值存储秒杀成功者的Id。使用的是set数据类型防止重复。所以此处使用相应的sismember方法取数据
		if(jedis.sismember(userKey, uid)) {   //sismember方法判断set中是否存在此value值。第一个参数是key,第二个参数为value。
			System.out.println("已经秒杀成功了,不能重复秒杀");
			jedis.close();

			return false;
		}

		//6 判断如果商品数量,库存数量小于1,秒杀已经结束
		if(Integer.parseInt(kc)<1) {   //kc为string类型,需要转换才可判断
			System.out.println("秒杀已经结束了");
			jedis.close();

			return false;
		}

		//7 执行秒杀(库存-1,秒杀成功用户添加清单)
		//7.1 库存-1
		jedis.decr(kcKey);
		//7.2 把秒杀成功用户添加清单里面(set集合添加对应sadd方法)
		jedis.sadd(userKey,uid);

		System.out.println("秒杀成功了..");
		jedis.close();
		
		return true;
	}
}

以上代码如果是单个用户进行操作没有问题,但秒杀功能必定涉及多个用户。所以以上代码存在一些并发问题待解决,需要继续进行优化。

4.2,代码存在的一些问题

以上代码如果多个用户的并发操作情况下会出现超卖问题超时问题库存遗留问题

  • 超时:每个操作都要连接Redis,如果有大量请求,Redis不能同时处理,有的请求就需要等待。等待时间过长就会出现连接超时问题
  • 超卖:商品已经秒杀结束了,但还可以秒杀到,导致最终商品数量变为负数。如下图:
    Redis的事务和锁机制(乐观锁和悲观锁)_第15张图片
  • 库存遗留问题:秒杀结束,但还有库存,此即为库存遗留问题

4.3,优化一:通过连接池解决连接超时问题

连接超时问题可以使用连接池解决。

Jedis为了防止使用连接对象jedis时频繁的创建和销毁,造成资源的浪费,提供了Jedis连接池,可以从连接池中获取Jedis对象,使用完毕后归还这个连接对象。

编写Jedis连接池工具类:(模板如下)

public class JedisPoolUtil {
	private static volatile JedisPool jedisPool = null;

	private JedisPoolUtil() {
	}

	public static JedisPool getJedisPoolInstance() {   //获取Jedis连接对象
		if (null == jedisPool) {
			synchronized (JedisPoolUtil.class) {
				if (null == jedisPool) {
					JedisPoolConfig poolConfig = new JedisPoolConfig();
					poolConfig.setMaxTotal(200);  //最大连接数
					poolConfig.setMaxIdle(32);   
					poolConfig.setMaxWaitMillis(100*1000);
					poolConfig.setBlockWhenExhausted(true);
					poolConfig.setTestOnBorrow(true);  // ping  PONG
				 
					jedisPool = new JedisPool(poolConfig, "你的服务器ip", 6379, 60000,"******");  //第二个参数为redis端口号;第三个参数为超时时间;第四个参数为你的redis的密码(设置了Redis密码的话才需要此参数)
				}
			}
		}
		return jedisPool;
	}

	public static void release(JedisPool jedisPool, Jedis jedis) {
		if (null != jedis) {
			jedisPool.returnResource(jedis);
		}
	}

}

有了连接池就可以获取连接,这样就不需要在New的方式创建连接。因此可以将源代码中的new Jedis连接方式进行替换。如下图:
Redis的事务和锁机制(乐观锁和悲观锁)_第16张图片
代码替换之后便可以解决连接超时问题。


4.4,优化二:通过Redis的事务和锁机制解决超卖问题(重点)

可以使用乐观锁方式解决超卖问题
Redis的事务和锁机制(乐观锁和悲观锁)_第17张图片
乐观锁优化代码体现:

Redis的事务和锁机制(乐观锁和悲观锁)_第18张图片


Redis的事务和锁机制(乐观锁和悲观锁)_第19张图片

至此完整代码为:

public class SecKill_redis {

	public static void main(String[] args) {
		Jedis jedis =new Jedis("47.116.4.200",6379);
		jedis.auth("******");

		System.out.println(jedis.ping());
		jedis.close();
	}


	public static boolean doSecKill(String uid,String prodid) throws IOException {

		//1 uid和prodid非空判断(这两个如果有空值,直接不执行即可)
		if(uid == null || prodid == null) {
			return false;
		}

		//2 连接redis(通过Jedis进行连接)
		//通过连接池得到jedis对象
		JedisPool jedisPoolInstance = JedisPoolUtil.getJedisPoolInstance();
		Jedis jedis = jedisPoolInstance.getResource();

		//3 拼接相关key
		String kcKey = "sk:"+prodid+":qt";
		String userKey = "sk:"+prodid+":user";
		
		//监视库存
		jedis.watch(kcKey);

		//4 获取库存,如果库存为null,表示秒杀还没有开始
		String kc = jedis.get(kcKey); 
		if(kc == null) {  
			System.out.println("秒杀还没有开始,请等待");
			jedis.close();

			return false;  //直接返回,不再执行其他操作
		}

		// 5 判断用户是否重复秒杀操作(保证一个用户只能秒杀一次)
		if(jedis.sismember(userKey, uid)) {   
			System.out.println("已经秒杀成功了,不能重复秒杀");
			jedis.close();

			return false;
		}

		//6 判断如果商品数量,库存数量小于1,秒杀结已经结束
		if(Integer.parseInt(kc)<1) {   
			System.out.println("秒杀已经结束了");
			jedis.close();

			return false;
		}

		//7 执行秒杀(库存-1,秒杀成功用户添加清单)
		//使用事务
		Transaction multi = jedis.multi();   //multi方法开启事务

		//组队操作
		multi.decr(kcKey);   //把 decr对库存进行减一的操作 放到命令队列
		multi.sadd(userKey,uid);   //把 用户信息值加到成功秒杀用户的清单中去的操作 放到命令队列

		//执行
		List<Object> results = multi.exec();   //exec方法顺序执行命令队列里的命令。返回的list集合即为最终结果

		if(results == null || results.size()==0) {
			System.out.println("秒杀失败了....");
			jedis.close();
			return false;
		}

		System.out.println("秒杀成功了..");
		jedis.close();

		return true;
	}
}

*4.5,优化三:通过LUA脚本解决库存遗留问题

使用乐观锁可能会造成库存遗留问题:

  • 库存遗留问题:秒杀结束,但还有库存,此即为库存遗留问题

假设有一批用户抢到了商品,其中有一个用户购买成功并修改版本号,版本号修改之后,尽管商品依然存在,但因为版本号不一致了,其他抢到商品的用户就不能继续进行操作了,因此造成库存遗留问题。

Lua 是一个小巧的脚本语言,Lua脚本可以很容易的被C/C++ 代码调用,也可以反过来调用C/C++的函数,Lua并没有提供强大的库,一个完整的Lua解释器不过200k,所以Lua不适合作为开发独立应用程序的语言,而是作为嵌入式脚本语言。

很多应用程序、游戏使用LUA作为自己的嵌入式脚本语言,以此来实现可配置性、可扩展性。因此很多游戏外挂上常见这样的脚本语言。
Redis的事务和锁机制(乐观锁和悲观锁)_第20张图片

LUA脚本在Redis中的优势:

  • 将复杂的或者多步的redis操作,写为一个脚本,一次提交给redis执行,减少反复连接redis的次数。提升性能。
  • LUA脚本是类似redis事务,有一定的原子性,不会被其他命令插队,可以完成一些redis事务性的操作。

注意:redis的lua脚本功能,只有在Redis 2.6以上的版本才可以使用。

redis 2.6版本以后,通过lua脚本解决争抢问题,实际上是redis 利用其单线程的特性,用任务队列的方式解决多任务并发问题。

LUA脚本如下(了解):

local userid=KEYS[1];  此处定义变量
local prodid=KEYS[2];  
local qtkey="sk:"..prodid..":qt";     相当于拼接key
local usersKey="sk:"..prodid.":usr'; 
local userExists=redis.call("sismember",usersKey,userid);  调用redis中的sismember命令
if tonumber(userExists)==1 then 
  return 2;  约定2代表秒杀过,不能再进行秒杀了
end
local num= redis.call("get" ,qtkey);   调用get方法
if tonumber(num)<=0 then 
  return 0;  秒杀结束
else 
  redis.call("decr",qtkey); 库存减一
  redis.call("sadd",usersKey,userid);  添加用户至清单
end
return 1;

把此脚本加入Java代码中:

public class SecKill_redisByScript {
	
	private static final  org.slf4j.Logger logger =LoggerFactory.getLogger(SecKill_redisByScript.class) ;

	public static void main(String[] args) {
		JedisPool jedispool =  JedisPoolUtil.getJedisPoolInstance();
 
		Jedis jedis=jedispool.getResource();
		System.out.println(jedis.ping());
		
		Set<HostAndPort> set=new HashSet<HostAndPort>();

	//	doSecKill("201","sk:0101");
	}
	
	static String secKillScript ="local userid=KEYS[1];\r\n" +          //lua脚本
			"local prodid=KEYS[2];\r\n" + 
			"local qtkey='sk:'..prodid..\":qt\";\r\n" + 
			"local usersKey='sk:'..prodid..\":usr\";\r\n" + 
			"local userExists=redis.call(\"sismember\",usersKey,userid);\r\n" + 
			"if tonumber(userExists)==1 then \r\n" + 
			"   return 2;\r\n" + 
			"end\r\n" + 
			"local num= redis.call(\"get\" ,qtkey);\r\n" + 
			"if tonumber(num)<=0 then \r\n" + 
			"   return 0;\r\n" + 
			"else \r\n" + 
			"   redis.call(\"decr\",qtkey);\r\n" + 
			"   redis.call(\"sadd\",usersKey,userid);\r\n" + 
			"end\r\n" + 
			"return 1" ;
			 
	static String secKillScript2 = 
			"local userExists=redis.call(\"sismember\",\"{sk}:0101:usr\",userid);\r\n" +
			" return 1";

	public static boolean doSecKill(String uid,String prodid) throws IOException {

		//通过连接池获取Jedis连接
		JedisPool jedispool =  JedisPoolUtil.getJedisPoolInstance();
		Jedis jedis=jedispool.getResource();

		 //String sha1=  .secKillScript;
		String sha1=  jedis.scriptLoad(secKillScript);    //加载脚本
		Object result= jedis.evalsha(sha1, 2, uid,prodid);

		  String reString=String.valueOf(result);
		if ("0".equals( reString )  ) {
			System.err.println("已抢空!!");
		}else if("1".equals( reString )  )  {
			System.out.println("抢购成功!!!!");
		}else if("2".equals( reString )  )  {
			System.err.println("该用户已抢过!!");
		}else{
			System.err.println("抢购异常!!");
		}
		jedis.close();
		return true;
	}
}

至此优化完成,程序可以很好地支持并发操作。

你可能感兴趣的:(Redis,redis,缓存,数据库)