Loading

Redis事务和锁机制(四)

一、Redis的事务定义

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

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

Redis中的事务和MySQL中的事务不同。

Redis的事务没有MySQL中的完善,只保证了一致性和隔离性,不满足原子性和持久性。

原子性,redis会将事务中的所有命令执行一遍,哪怕是中间有执行失败也不会回滚。kill信号、宿主机宕机等导致事务执行失败,redis也不会进行重试或者回滚。

提问:redis是单线程为什么还要事务?

虽然redis是单线程,但是可以同时有多个客户端访问,每个客户端相当于一个线程。客户端访问之间存在竞争。当多个客户端并发操作同一Key值时,就会产生类似于多线程操作的现象。

二、Multi、Exec、discard

从输入 Multi 命令开始,输入的命令会依次进入命令队列中,但不会执行。

直到输入 Exec 后,Redis会将之前的命令队列中的命令依次执行。

组队的过程中可以通过 discard 来放弃组队。

image-20220218231000874

image-20220218234951571

三、事务的错误处理

组队中某个命令出现了报告错误,执行时整个队列都会被取消。

image-20220218235050253

image-20220218235506048

如果执行阶段某个命令报出了错误,则只有报错的命令不会被执行,而其他的命令都会执行,不会回滚。

image-20220218235724595

image-20220218235838610

四、为什么要做成事务

想象一个场景:有很多人有你的账户,同时去参加双十一抢购。

image-20220219000558964

4.1 悲观锁

image-20220219000808651

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

4.2 乐观锁

image-20220219001139012

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

Redis 就是利用这种 check-and-set机制实现事务的。

4.3 WATCH key [key ...]

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

image-20220219131858840

4.4 Redis事务三特性

  • 单独的隔离操作

    事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的请求所打断。

  • 没有隔离级别的概念

    队列中的命令没有提交之前都不会实际被执行,因为事务提交前任何指令都不会被执行。

  • 不保证原子性

    事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚。

五、Redis事务秒杀案例

5.1 解决计数器和人员记录的事务操作

image-20220219132557698

//秒杀过程(未添加事务)
public static boolean deSecKill(String uid, String prodid) throws IOException {
    //1.uid和prodid非空判断
    if (uid == null || prodid == null) {
        return false;
    }
    //2.连接redis
    Jedis jedis = new Jedis("192.168.121.138", 6379);
    //3.拼接key
    //3.1 库存key
    String kcKey = "sk:" + prodid + ":qt";
    //3.2 秒杀成功用户key
    String userKey = "sk:" + prodid + ":user";
    //4.获取库存,如果库存为null,秒杀还没开始
    String exists = jedis.get(kcKey);
    if (exists == null) {
        System.out.println("秒杀还没开始,请等待");
        jedis.close();
        return false;
    }
    //5.判断用户是否重复秒杀
    boolean sismember = jedis.sismember(userKey, uid);
    if (sismember) {
        System.out.println("你已经秒杀成功了,不能重复秒杀");
        jedis.close();
        return false;
    }
    //6.判断商品数量,库存量小于1,秒杀结束
    if (Integer.parseInt(exists) <= 0) {
        System.out.println("秒杀已经结束");
        jedis.close();
        return false;
    }
    //7.秒杀过程
    //7.1 库存-1
    jedis.decr(kcKey);
    //7.2 成功用户添加
    jedis.sadd(userKey, uid);
    
}

5.2 Redis事务-并发模拟

上面的代码能够完成单线程的秒杀,但是实际业务中肯定是高并发的,类似多线程。

模拟高并发

使用工具 ab 模拟测试(Linux中的一个工具)

联网:yum install httpd-tools

image-20220219135649293

# -n:请求数量	-c:并发数量	  [-p:参数	   -T:Content-type]
ab -n 1000 -c 100 -p ./postfile -T application/x-www-form-urlencoded http://192.168.0.105:8093/redisTest/deSecKill/

image-20220219154956067

image-20220219155023445

image-20220219155152102

5.2 加上watch

@GetMapping("/deSecKill/{prodid}")
public String deSecKill(@PathVariable String prodid) {
    StringBuilder userId = new StringBuilder();
    Random random = new Random();
    for (int i = 0; i < 4; i++) {
        userId.append(random.nextInt(10));
    }
    String uid = userId.toString();

    //2.连接redis
    Jedis jedis = new Jedis("192.168.121.138", 6379);
    //3.拼接key
    //3.1 库存key
    String kcKey = "sk:" + prodid + ":qt";
    //3.2 秒杀成功用户key
    String userKey = "sk:" + prodid + ":user";

    //在获取库存之前先监视库存
    jedis.watch(kcKey);

    //4.获取库存,如果库存为null,秒杀还没开始
    String exists = jedis.get(kcKey);
    if (exists == null) {
        System.out.println("秒杀还没开始,请等待");
        jedis.close();
        return "秒杀还没开始,请等待";
    }
    //5.判断用户是否重复秒杀
    boolean sismember = jedis.sismember(userKey, uid);
    if (sismember) {
        System.out.println("你已经秒杀成功了,不能重复秒杀");
        jedis.close();
        return "你已经秒杀成功了,不能重复秒杀";
    }
    //6.判断商品数量,库存量小于1,秒杀结束
    if (Integer.parseInt(exists) <= 0) {
        System.out.println("秒杀已经结束");
        jedis.close();
        return "秒杀已经结束";
    }
    //7.秒杀过程

    //添加事务
    Transaction multi = jedis.multi();

    //组队
    multi.decr(kcKey);
    multi.sadd(userKey, uid);

    //执行
    List<Object> results = multi.exec();

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

    System.out.println("秒杀成功...");
    return "秒杀成功";
}

image-20220219160522496

image-20220219160550321

5.3 连接池

节省每次连接redis服务带来的消耗,把连接好的实例反复利用(单例模式)。

通过参数管理连接的行为

具体实现,上百度。

5.4 库存遗留问题

这次将库存改为 500 个秒杀库存,2000 个请求,300 个并发。

image-20220219161307932

image-20220219161341064

为什么会出现这个问题?

2000个请求同时发起,有1个先秒杀成功了,就会修改版本号,那么这时另外的1999个获得的版本号就不对了,导致失败。

5.5 解决库存遗留问题

5.5.1 LUA脚本

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

很多应用程序、游戏使用 LUA 作为自己的嵌入式脚本语言,以此来实现可配置性、可扩展性。

这其中包括魔兽争霸地图、魔兽世界、博德之门、愤怒的小鸟等众多游戏插件或外挂。

5.5.2 LUA脚本在Redis中的优势

将复杂的或者多步的Redis操作,写成一个脚本,一次提交给Redis执行,减少反复连接Redis的次数。提升性能。

LUA脚本是类似于Redis事务,有一定的原子性,不会被其他命令插队,可以完成一些Redis事务的操作。

但是注意Redis的LUA脚本功能,只有在Redis2.6以上版本可以使用。

利用LUA脚本淘汰用户,解决超卖问题。

用LUA封装一个事务,并给Redis提供原子性,类似于悲观锁串行执行。

参考资源:

posted @ 2022-02-28 20:47  KledKled  阅读(286)  评论(0编辑  收藏  举报