Redis事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
Redis事务的主要作用就是串联多个命令防止别的命令插队。
multi、exec、discard为Redis事务操作的三个基本命令
输入的命令都会依次进入命令队列中,但不会执行。
命令队列中的命令依次执行。
discard命令放弃组队。
事务执行成功的情况演示如下:
事务执行失败的情况演示如下:
情形一: 组队中某个命令出现了报告错误,
执行时整个的所有队列都会被取消。如下图:
情形二: 执行阶段某个命令报出了错误,
则只有报错的命令不会被执行,而其他的命令都会执行,不会回滚。如下图:
注意:上图中,虽然存在执行失败的命令。但不出错的命令,最终依然可以执行成功!!! 因为Redis数据库不保证原子性。
引例:
想象一个场景: 你的银行卡里有10000元,共有三个人知道你的银行卡账户和密码:分别是你的前女友、现女友和你自己。三个人同时使用此账户在双十一当天进行消费。(银行卡不可以透支)
- 前女友存在报复心理,准备花8000元给自己买一台iphone13 pro
- 现女友想花5000元给自己买一台iphone12
- 你想花1000元修一修自己使用多年的诺基亚手机
三个人同时消费的时候,如果同时通过了if语句判断。最终卡余额就会变为 -4000。明显存在冲突问题。
以上即为事务冲突问题。
如何处理事务冲突问题?
Redis针对此类问题提供了乐观锁和悲观锁的机制进行解决。
悲观锁: 顾名思义,就是很悲观。每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人就不能拿到这个数据直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
悲观锁。前女友操作10000的时候,首先会给余额上锁。上完锁进入阻塞状态,别人就不能操作了,除非锁打开。前女友消费后余额减少8000,剩2000。然后解锁,解锁之后现女友才可以得到这2000,现女友再拿2000进行操作,依然是先上锁。判断2000不能减5000,因此不能操作(当场分手)…----此即为悲观锁机制
悲观锁存在效率低的缺点,因为很多人操作的时候只能一个一个来,不可以同时多人进行操作。
乐观锁: 顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型(很多人读数据但只要少部分人修改),这样可以提高吞吐量。Redis默认就是利用这种机制实现事务的。
乐观锁。对数据操作的时候可以给数据加上一个版本号的字段。一开始所有人都可以得到版本数据,如前女友和现女友都可以得到版本为1.0的数据,此数据均为10000。如果前女友手速较快,就会先消费8000,余额修变为2000,同时版本号也同步更新为1.1。这时如果现女友再进行操作,就需要检查一下当前数据的版本号和数据库中的版本号是否一致。 发现1.0!=1.1,版本号不一致则不能再进行操作,无法消费(当场分手)…—此即为乐观锁机制
乐观锁应用广泛,典型的乐观锁应用场景:12306抢票。当系统中只有一张票的时候所有人都可以参与抢票,但最终只能有一个人支付成功。
watch key [key …]:在执行multi之前,先执行watch key1 [key2],可以监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。
①连接Redis服务器后在xshell中打开两个终端(分别代表前女友和现女友)
②在终端1中加入数据
③两个终端都监视balance
④两个终端都通过multi开启事务
以上两个终端(前女友和现女友)都得到balannce(余额)这个数据,对其进行监视(watch),前女友这边先执行就会把balance的版本号进行修改;然后现女友那边经过判断发现版本号发生改变就不能再进行修改。(当场分手)
单独的隔离操作
:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。没有隔离级别的概念
:队列中的命令没有提交之前都不会实际被执行,因为事务提交前任何指令都不会被实际执行不保证原子性!!!
:事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚秒杀是日常中典型的商家活动,接下来我们对其进行模拟。
如果有10个商品需要进行秒杀,100个人参与秒杀。其中一个人抢到了商品,商品库存减一,抢到商品的用户加入到秒杀成功者清单中去。以此类推…
代码实现如下:
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;
}
}
以上代码如果是单个用户进行操作没有问题,但秒杀功能必定涉及多个用户。所以以上代码存在一些并发问题待解决,需要继续进行优化。
以上代码如果多个用户的并发操作情况下会出现超卖问题、超时问题、库存遗留问题
连接超时问题可以使用连接池解决。
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连接方式进行替换。如下图:
代码替换之后便可以解决连接超时问题。
至此完整代码为:
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;
}
}
使用乐观锁可能会造成库存遗留问题:
假设有一批用户抢到了商品,其中有一个用户购买成功并修改版本号,版本号修改之后,尽管商品依然存在,但因为版本号不一致了,其他抢到商品的用户就不能继续进行操作了,因此造成库存遗留问题。
Lua 是一个小巧的脚本语言,Lua脚本可以很容易的被C/C++ 代码调用,也可以反过来调用C/C++的函数,Lua并没有提供强大的库,一个完整的Lua解释器不过200k,所以Lua不适合作为开发独立应用程序的语言,而是作为嵌入式脚本语言。
很多应用程序、游戏使用LUA作为自己的嵌入式脚本语言,以此来实现可配置性、可扩展性。因此很多游戏外挂上常见这样的脚本语言。
LUA脚本在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;
}
}
至此优化完成,程序可以很好地支持并发操作。