• [06] Redis 事务


    定义

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

    事务可以一次执行多个命令,本质是一组命令的集合。一个事务中的所有命令都会序列化,按顺序地串行化执行而不会被其他命令插入,不许加塞。

    在一个队列中,一次性、顺序性、排他性地执行一系列命令。Redis 事务的主要作用就是串联多个命令防止别的命令插队

    事务相关指令

    • 从输入 multi 命令开始,输入的命令都会依次进入命令队列中,但不会执行,至到输入 exec 后,Redis 会将之前的命令队列中的命令依次执行。
    • 组队的过程中可以通过 discard 来放弃组队。
    • 示例

    事务的错误处理

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


    如果执行阶段某个命令报出了错误(类比运行时异常),则只有报错的命令不会被执行,而其他的命令都会执行,不会回滚。

    事务冲突的问题

    场景

    有 3 个人有你的账户,同时去参加双十一抢购

    悲观锁&乐观锁

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

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

    watch|unwatch

    watch

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

    unwatch

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

    Redis 事务三特性

    • 单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断
    • 没有隔离级别的概念:队列中的命令没有提交之前都不会实际的被执行,因为事务提交前任何指令都不会被实际执行,也就不存在“事务内的查询要看到事务里的更新,在事务外查询不能看到”这个让人万分头痛的问题
    • 不保证原子性:Redis 同一个事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚

    秒杀案例

    使用 ab 工具

    安装

    • CentOS 6 默认安装
    • CentOS 7 需要手动安装
      • 联网:yum install httpd-tools
      • 无网络

    使用

    ab -n 请求数 -c 最大并发数 -p 请求的数据文件 -T "application/x-www-form-urlencoded" 测试的请求
    

    测试

    页面核心代码:

    <form id="msform" action="${pageContext.request.contextPath}/doseckill"
            enctype="application/x-www-form-urlencoded">
        <input type="hidden" id="prodid" name="prodid" value="1101">
        <input type="button" id="miaosha_btn" name="seckill_btn" value="秒杀点我"/>
    </form>
    
    <script  type="text/javascript">
    $(function(){
        $("#miaosha_btn").click(function(){
            var url = $("#msform").attr("action");
            $.post(url, $("#msform").serialize(), function(data) {
                if(data=="false"){
                    alert("抢光了!");
                    $("#miaosha_btn").attr("disabled", true);
                }
            });
        })
    })
    </script>
    

    doseckill → SecKillServlet:

    public class SecKillServlet extends HttpServlet {
        protected void doPost(HttpServletRequest request
                , HttpServletResponse response) throws ServletException, IOException {
            String userid = new Random().nextInt(50000) + "" ;
            String prodid = request.getParameter("prodid");
            boolean if_success = SecKill_redis.doSecKill(userid, prodid);
            response.getWriter().print(if_success);
        }
    }
    

    SecKill_redis:

    public class SecKill_redis {
        public static boolean doSecKill(String uid, String prodid) throws IOException {
            // 拼接 key
            String kcKey = "Seckill:" + prodid + ":kc";
            String userKey = "Seckill:" + prodid + ":user";
            Jedis jedis = new Jedis("192.168.33.128", 6379);
    
            // 获取库存
            String kc = jedis.get(kcKey);
    
            // 1. 秒杀还没开始:库存为 NULL
            if(kc == null) {
                System.out.println("秒杀还没开始!");
                jedis.close();
                return false;
            }
    
            // 2. 已经秒杀成功:存储秒杀成功的用户 set 中已经有该用户id
            if(jedis.sismember(userKey, uid)) {
                System.out.println("已经秒杀成功,不能重复秒杀!");
                jedis.close();
                return false;
            }
    
            // 3. 判断库存
            // 3.1 若 <= 0,秒杀结束
            if(Integer.parseInt(kc) <= 0) {
                System.out.println("秒杀已结束!");
                jedis.close();
                return false;
            }
    
            // 3.2 若 > 0,则减库存加人
            jedis.decr(kcKey);
            jedis.sadd(userKey, uid);
            System.out.println("秒杀成功!");
            jedis.close();
            return true;
        }
    }
    

    超卖问题

    加入事务,监视库存。

    public static boolean doSecKill(String uid, String prodid) throws IOException {
        // 拼接 key
        String kcKey = "Seckill:" + prodid + ":kc";
        String userKey = "Seckill:" + prodid + ":user";
        Jedis jedis = new Jedis("192.168.33.128", 6379);
    
        // 监视库存
        jedis.watch(kcKey);
        // 获取库存
        String kc = jedis.get(kcKey);
    
        // 1. 秒杀还没开始:库存为 NULL
        if(kc == null) {
            System.out.println("秒杀还没开始!");
            jedis.close();
            return false;
        }
    
        // 2. 已经秒杀成功:存储秒杀成功的用户 set 中已经有该用户id
        if(jedis.sismember(userKey, uid)) {
            System.out.println("已经秒杀成功,不能重复秒杀!");
            jedis.close();
            return false;
        }
    
        // 3. 判断库存
        // 3.1 若 <= 0,秒杀结束
        if(Integer.parseInt(kc) <= 0) {
            System.out.println("秒杀已结束!");
            jedis.close();
            return false;
        }
    
        // 3.2 若 > 0,则减库存加人
        Transaction transaction = jedis.multi();
        transaction.decr(kcKey);
        transaction.sadd(userKey, uid);
        List<Object> execList = transaction.exec();
    
        if(execList == null || execList.size() == 0) {
            System.out.println("秒杀失败!");
            jedis.close();
            return false;
        }
    
        System.out.println("秒杀成功!");
        jedis.close();
        return true;
    }
    

    请求超时问题

    • 使用连接池,节省每次连接 Redis 服务带来的消耗,把连接好的实例反复利用。
    • 通过参数管理连接的行为:
      • MaxTotal:控制一个 pool 可分配多少个 Jedis 实例,通过 pool.getResource() 来获取;如果赋值为 -1,则表示不限制;如果 pool 已经分配了 MaxTotal 个 Jedis 实例,则此时 pool 的状态为 exhausted
      • maxIdle:控制一个 pool 最多有多少个状态为 idle(空闲) 的 Jedis 实例
      • MaxWaitMillis:表示当 borrow 一个 Jedis 实例时,最大的等待毫秒数,如果超过等待时间,则直接抛出 JedisConnectionException
      • testOnBorrow:获得一个 Jedis 实例的时候是否检查连接可用性 ping();如果为 true,则得到的 Jedis 实例均是可用的
    • 代码演示
      JedisPool jedisPool = JedisPoolUtil.getJedisPoolInstance();
      Jedis jedis = jedisPool.getResource();
      System.out.println(jedis.ping());
      

    库存遗留问题

    问题展示

    Lua 脚本

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

    很多应用程序、游戏使用 Lua 作为自己的嵌入式脚本语言,以此来实现可配置性、可扩展性。这其中包括众多游戏插件或外挂。


    Lua 脚本在 Redis 中的优势:

    • 将复杂的或者多步的 Redis 操作,写为一个脚本,一次提交给 Redis 执行,减少反复连接 Redis 的次数。提升性能。
    • Lua 脚本是类似 Redis 事务,但脚本整体执行有一定的原子性,一次性执行完一整个脚本,不会被其他命令插队,故可以完成一些 Redis 事务性的操作
    • 但是注意 Redis 的 Lua 脚本功能,只有在 2.6 以上的版本才可以使用。

    解决问题

    Redis 为单线程模型,Lua 脚本执行具有原子性。

    Redis 使用单个 Lua 解释器去运行所有脚本,并且,Redis 也保证脚本会以原子性(atomic) 的方式执行:当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行。这和使用 MULTI / EXEC 包围的事务很类似。在其他别的客户端看来,脚本的效果(effect) 要么是不可见的(not visible),要么就是已完成的(already completed)。

    在高并发下,很多看似不大可能是问题的,都成了实际产生的问题了。要解决“超抢/超卖”的问题,核心在于保证检查库存时的操作是依次执行的,再形象的说就是把“多线程”转成“单线程”。即使有很多用户同时到达,也是一个个检查并给与抢购资格,一旦库存抢尽,后面的用户就无法继续了。

    我们需要使用 Redis 的单线程和 Lua 脚本的原子性来实现这个功能。假设有 10 件库存,就往 Redis 中 set Seckill:prodid:kc 10,这个数没有实际意义,仅仅只是代表一件库存。抢购开始后,每到来一个用户,就执行一遍 Lua 脚本(也就是 decr 库存),表示用户抢购成功。当库存为 0 时,表示已经被抢光了。因为 Redis 的单线程,所以即使有很多用户同时到达,也是依次执行的。


    SecKillServlet

    boolean if_success = SecKill_redisByScript.doSecKill(userid, prodid);
    

    SecKill_redisByScript

    public class SecKill_redisByScript {
        static String secKillScript ="local userid=KEYS[1];
    " + 
            "local prodid=KEYS[2];
    " + 
            "local qtkey='Seckill:'..prodid..":kc";
    " + 
            "local usersKey='Seckill:'..prodid..":user";
    " + 
            "local userExists=redis.call("sismember",usersKey,userid);
    " + 
            "if tonumber(userExists)==1 then 
    " + 
            "   return 2;
    " + 
            "end
    " + 
            "local num= redis.call("get" ,qtkey);
    " + 
            "if tonumber(num)<=0 then 
    " + 
            "   return 0;
    " + 
            "else 
    " + 
            "   redis.call("decr",qtkey);
    " + 
            "   redis.call("sadd",usersKey,userid);
    " + 
            "end
    " + 
            "return 1" ;
    
        static String secKillScript2 =
            "local userExists=redis.call("sismember","{sk}:0101:usr",userid);
    " +
            " return 1";
    
        public static boolean doSecKill(String uid,String prodid) throws IOException {
            JedisPool jedisPool = JedisPoolUtil.getJedisPoolInstance();
            Jedis jedis = jedisPool.getResource();
            String sha1 =  jedis.scriptLoad(secKillScript);
            // [1] 加载脚本之后的结果
            // [2] 参数个数
            // [3 ..] 参数..
            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;
        }
    }
    

  • 相关阅读:
    设计模式:组合模式
    对技术的认识及思考
    设计模式:策略模式
    java集合:常用集合的数据结构
    设计模式:代理模式
    java反射
    Spring事务管理
    在Spring使用junit注解进行单元测试
    tomcat限制ip访问
    获取openid回调两次
  • 原文地址:https://www.cnblogs.com/liujiaqi1101/p/13613210.html
Copyright © 2020-2023  润新知