zoukankan      html  css  js  c++  java
  • 【5】Redis从入门到放弃---秒杀案例(Redis的事务+锁机制+lua脚本)

    (1)Redis的事务

    1.1 Redis事务的定义:

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

    1.2 Multi、Exec、discard命令

    组队阶段:从输入multi命令开始,后面输入的任务命令都会依次放入到队列中,但不会执行;

    执行阶段:及就是从输入exec开始,Redis会将之前的命令队列中的命令依次执行;

    取消事务:只能在组队的过程中可以通过discard命令来放弃组队。

    1.3 实操如下:

     场景一:组队成功,提交也成功

     场景二:组队阶段报错,提交失败

     场景三:组队成功,提交有成功有失败情况

     1.4 Redis事务三特性

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

    没有隔离级别的概念:队列中的命令没有提交之前都不会实际被执行,因为事务提交前任何指令都不会被实际执行

    不保证原子性 :事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚

    (2)Redis的事务锁机制

     2.1 Redis的锁机制

    在实际业务中,有一些场景例如:秒杀、抢车票等等,同一时间多个请求进来,那可能就会存在超卖现象,针对这种情况我们可以使用事务和redis的锁机制来解决这种问题。

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

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

    2.2 watch和unwatch的命令

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

    unwatch 取消 WATCH 命令对所有 key 的监视。 如果在执行 WATCH 命令之后,EXEC 命令或DISCARD 命令先被执行了的话,那么就不需要再执行UNWATCH 了。 

    http://doc.redisfans.com/transaction/exec.html

    (3)秒杀案例

    1. 使用Redis解决计数器和人员记录的事务操作
    2. 模拟:单个请求到并发秒杀(使用工具JMeter模拟测试)
    3. 超卖问题:利用事务和乐观锁淘汰用户,解决超卖问题
    4. 模拟:加大库存,会存在秒杀结束却还有库存
    5. 使用LUA脚本解决库存剩余问题

    1.使用Redis解决计数器和人员记录的事务操作

    写个秒杀测试类如下:

    /**
     * 秒杀案例,一个用户只能秒杀成功一次
     */
    @RestController
    @RequestMapping("/testRedisSeckill")
    public class TestRedisSeckillController {
    
        @Autowired
        private RedisTemplate redisTemplate;
    
    
        @GetMapping("/doSeckill")
        public boolean doSeckill() throws IOException {
            String usrId = new Random().nextInt(50000) + "";
            return doSeckillFun(usrId, "20210731");
        }
    }
    /**
         * 秒杀过程1(高并发下会超卖)
         *
         * @param usrId 用户id
         * @param atcId 活动id
         * @return
         * @throws IOException
         */
        private boolean doSeckillFun(String usrId, String atcId) throws IOException {
            //1.参数校验
            if (usrId == null || atcId == null) return false;
    
            //2.设置Redis值(库存key== atcId:stock, 秒杀成功用户key== atcId:userId)
            String stockKey = atcId + ":stock";
            String userIdKey = atcId + ":userId";
    
            //3.获取库存,如果库存是空,秒杀还没开始
            Object stock = redisTemplate.opsForValue().get(stockKey);//获取库存
            if (stock == null) {
                System.out.println("别着急,秒杀还没开始呢!!");
                return false;
            }
    
            //4.判断用户是否重复秒杀操作(Set类型操作)
            if (redisTemplate.opsForSet().isMember(userIdKey, usrId)) {
                System.out.println("你已经秒杀成功了,不能重复秒杀");
                return false;
            }
    
            //5.判断库存数量,小于1,秒杀结束
            int stock1 = (int) stock;
            if (stock1 < 1) {
                System.out.println("秒杀结束了。。。");
                return false;
            }
    
            //6.秒杀过程(库存减1,把秒杀成功用户添加到用户清单)
            redisTemplate.opsForSet().add(userIdKey, usrId);
            redisTemplate.opsForValue().decrement(stockKey);//库存-1
            System.out.println("恭喜你!秒杀成功了!");
            return true;
        }

    1.1 模拟场景1:单个请求

    先设置库存10个

     Jmeter模拟单个请求

     

     查看redis剩余库存和用户清单:

     1.2 模拟高并发500个请求

    看着没什么毛病对吧,那如果我把并发加大到500,会出现什么情况呢?

    在执行之前先清空redis的数据,点击jmeter执行

     控制台输出:

     查看redis数据

     

     发现库存 -190,出现超卖了,所以我们的场景1的代码在高并发的情况下会出现超卖的问题,那么针对这个问题我们需要使用乐观锁来解决

    2.使用乐观锁来解决

    代码如下:

    /**
         * 秒杀过程2(乐观锁解决超卖问题)
         *
         * @param usrId 用户id
         * @param atcId 活动id
         * @return
         * @throws IOException
         */
        private boolean doSeckillFun(String usrId, String atcId) throws IOException {
            //1.参数校验
            if (usrId == null || atcId == null) return false;
    
            //2.设置Redis值(库存key== atcId:stock, 秒杀成功用户key== atcId:userId)
            String stockKey = atcId + ":stock";
            String userIdKey = atcId + ":userId";
    
            //通过 SessionCallback,保证所有的操作都在同一个 Session 中完成
            //更常见的写法仍是采用 RedisTemplate 的默认配置,即不开启事务支持。
            // 但是,我们可以通过使用 SessionCallback,该接口保证其内部所有操作都是在同一个Session中
            SessionCallback<Object> callback = new SessionCallback<Object>() {
    
                @Override
                public Object execute(RedisOperations operations) throws DataAccessException {
                    //3. 打开事务支持
                    //redisTemplate.setEnableTransactionSupport(true);
    
                    //4.增加乐观锁进行对库存的监视
                    operations.watch(stockKey);
    
                    //5.获取库存,如果库存是空,秒杀还没开始
                    Object stock = operations.opsForValue().get(stockKey);//获取库存
                    if (stock == null) {
                        System.out.println("别着急,秒杀还没开始呢!!");
                        return false;
                    }
    
                    //6.判断用户是否重复秒杀操作(Set类型操作)
                    if (operations.opsForSet().isMember(userIdKey, usrId)) {
                        System.out.println("你已经秒杀成功了,不能重复秒杀");
                        return false;
                    }
    
                    //7.判断库存数量,小于1,秒杀结束
                    int stock1 = (int) stock;
                    if (stock1 < 1) {
                        System.out.println("秒杀结束了。。。");
                        return false;
                    }
    
                    //8. 增加事务
                    operations.multi();
    
                    //9.秒杀过程
                    operations.opsForValue().decrement(stockKey);//库存-1
                    operations.opsForSet().add(userIdKey, usrId);//把秒杀成功用户添加到用户清单
    
                    //10.执行事务
                    List<Object> list = operations.exec();
    
                    //11.判断事务提交是否失败
                    if (list == null || list.size() == 0) {
                        System.out.println("秒杀失败");
                        return false;
                    }
    
                    System.out.println("恭喜你!秒杀成功了!");
                    return true;
                }
            };
    
            return (boolean) redisTemplate.execute(callback);
        }

     2.1 设置10个库存,继续模拟500个并发请求,结果如下:

     

     终于解决超卖问题了,嘻嘻

     2.2 那我把库存加大到300个,继续模拟500个并发请求,会出现什么情况呢?

     执行Jmeter模拟500个并发,结果如下:

     虽然没有超卖问题了,但是有500个请求却还剩余102个库存,那么就有下边的lua解决库存遗留问题。

    3. LUA脚本在Redis中的优势 :将复杂的或者多步的redis操作,写为一个脚本,一次提交给redis执行,减少反复连接redis的次数。提升性能。 LUA脚本是类似redis事务,有一定的原子性,不会被其他命令插队,可以完成一些redis事务性的操作。 但是注意redis的lua脚本功能,只有在Redis 2.6 以上的版本才可以使用。 利用lua脚本淘汰用户,解决超卖问题。 redis 2.6版本以后,通过lua脚本解决争抢问题,实际上是redis 利用其单线程的特性,用任务队列的方式解决多任务并发问题。

    使用Lua脚本解决库存遗留的问题,代码如下:

     /**
         * 秒杀过程3(LUA解决库存剩余问题)
         *
         * @param usrId 用户id
         * @param atcId 活动id
         * @return
         * @throws IOException
         */
        private boolean doSeckillFun(String usrId, String atcId) throws IOException {
    
            String luaScript = "local userId=KEYS[1];
    " +
                    "local stockKey=KEYS[2];
    " +
                    "local userIdKey=KEYS[3];
    " +
                    "local userExists=redis.call("sismember",userIdKey,userId); 
    " +
                    "if tonumber(userExists)==1 
    " +
                    "then 
    " +
                    "  return 2;
    " +
                    "end 
    " +
                    "local num= redis.call("get" ,stockKey);
    " +
                    "if tonumber(num)<=0 then   return 0;
    " +
                    "else 
     " +
                    " redis.call("decr",stockKey);
    " +
                    "redis.call("sadd",userIdKey,userId);
    " +
                    "end 
    " +
                    "return 1;";
    
            // 指定 lua 脚本,并且指定返回值类型
            // (为什么返回值不用 Integer 接收而是用 Long。这里是因为 spring-boot-starter-data-redis 提供的返回类型里面不支持 Integer。)
            DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(luaScript, Long.class);
            List<String> keys = new ArrayList<>();
            keys.add(usrId);
            keys.add(atcId + ":stock");
            keys.add(atcId + ":userId");
            // 参数一:redisScript,参数二:key列表,参数三:arg(可多个)
            Long result = (Long) redisTemplate.execute(redisScript, keys);
            if (0 == result) {
                System.out.println("秒杀结束了。。。");
            } else if (1 == result) {
                System.out.println("恭喜你!秒杀成功了!");
                return true;
            } else if (2 == result) {
                System.out.println("你已经秒杀成功了,不能重复秒杀");
            } else {
                System.out.println("秒杀异常啦~");
            }
    
            return false;
        }

    lua脚本:

    local userId=KEYS[1];
    local stockKey=KEYS[2];
    local userIdKey=KEYS[3];
    local userExists=redis.call("sismember",userIdKey,userId); 
    if tonumber(userExists)==1
    then
      return 2;
    end
    local num= redis.call("get" ,stockKey);
    if tonumber(num)<=0 then   return 0;
    else
     redis.call("decr",stockKey);
    redis.call("sadd",userIdKey,userId);
    end
    return 1;

    这样一个完美的秒杀案例就完成了。嘻嘻嘻~~~~

  • 相关阅读:
    Windbg使用
    C#与C++之间类型的对应
    Hook CreateProcess
    基于EasyHook实现监控explorer资源管理器文件复制、删除、剪切等操作
    GUID和UUID、CLSID、IID 区别及联系
    hook C++
    EasyHook Creating a remote file monitor
    hook工具
    EasyHook
    Hook exe 和 file
  • 原文地址:https://www.cnblogs.com/yaoyaoling/p/15090409.html
Copyright © 2011-2022 走看看