Loading

[06] Redis 事务&管道

1. 事务定义

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

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

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

2. 事务相关指令

从输入 multi 命令开始,输入的命令都会依次进入命令队列中,但不会执行,至到输入 exec 后,Redis 会将之前的命令队列中的命令依次执行。

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

示例:

3. 事务的错误处理

组队中某个命令出现了报告错误(类比编译时异常),执行时整个队列的命令都会被取消

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

4. 事务冲突的问题

4.1 场景

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

4.2 悲观锁&乐观锁

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

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

4.3 watch | unwatch

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

watch 就相当于获取版本号的过程。如果在 exec 之前,key 被改动了,就相当于手握的版本号已经不是最新的了,就会事务执行失败。此时,就需要 unwatch,可理解为“重新获取版本号”。


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

5. Redis 事务三特性

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

6. 秒杀案例

6.1 ab 工具

CentOS 6 默认安装,CentOS 7 需要手动安装:yum install httpd-tools

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

6.2 测试代码

页面核心代码:

<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;
    }
}

6.3 超卖问题

加入事务,监视库存。

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;
}

6.4 请求超时问题

  • 使用连接池,节省每次连接 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());
    

6.5 库存遗留问题

a. 问题展示

b. Lua 脚本

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

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

Lua 脚本在 Redis 中的优势:

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

c. 解决问题

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];\r\n" + 
        "local prodid=KEYS[2];\r\n" + 
        "local qtkey='Seckill:'..prodid..\":kc\";\r\n" + 
        "local usersKey='Seckill:'..prodid..\":user\";\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 {
        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;
    }
}

7. 管道 Pipeline

7.1 问题引入

如何优化频繁命令往返造成的性能瓶颈?

Redis 是一种基于客户端-服务端模型以及请求/响应协议的 TCP 服务。一个请求会遵循以下步骤:

  1. 客户端发起一个请求,并监听 socket 返回,通常情况都是阻塞模式等待 Redis 服务器的响应;
  2. 服务端处理命令,并且返回处理结果给客户端;
  3. 客户端接收到服务的返回结果,程序从阻塞代码处返回。

Redis 客户端和服务端之间通过网络连接进行数据传输,这个连接可以很快(loopback 接口)或很慢(建立了一个多次跳转的网络连接)。无论网络延如何延时,数据包总是能从客户端到达服务器,并从服务器返回数据回复客户端,这个时间被称之为 RTT(Round Trip Time,数据包往返于两端的时间)。我们可以很容易就意识到,Redis 在连续请求服务端时,即使 Redis 每秒能处理 100k 请求,但也会因为网络传输花费大量时间,导致整体性能的下降。

如果同时需要执行大量的命令,那么就要等待上一条命令应答后再执行,这中间不仅仅多了 RTT,还频繁调用系统 IO,发送网络请求,同时需要 Redis 调用多次 read/write 系统调用,系统调用会将涉及用户态到内核态的转换,这样就会对进程上下文有比较大的影响了。

7.2 深入 Pipeline

  1. 客户端调用 write 将数据写入操作系统内核(kernel)为 socket 连接分配的发送缓冲区(send buffer);
  2. 客户端操作系统内核将发送缓冲区(send buffer)的数据发送到网卡(NIC);
  3. 网卡(NIC)将数据通过路由(route)将数据送到 Redis 服务器机器网卡(NIC);
  4. 服务器操作系统内核(kernel)将网卡(NIC)接收的数据,写入 kernel 为 socket 分配的接收缓冲区(recv buffer);
  5. 服务器进程从接收缓冲区(recv buffer)调用 read 读取数据,并进行数据逻辑处理;
  6. 数据处理完成之后,服务器进程调用 write 将响应数据写入 kernel 为 socket 分配的发送缓冲区(send buffer);
  7. kernel 将发送缓冲区(send buffer)的数据发送到服务器网卡(NIC);
  8. 服务器网卡(NIC)将响应数据通过路由(route)发送到客户端网卡(NIC);
  9. 客户端网卡(NIC)接收响应数据;
  10. 客户端操作系统内核(kernel)读取网卡(NIC)接收到的服务器响应数据,并写入 kernel 为 socket 连接分配的接收缓冲区(recv buffer);
  11. 客户端进程调用 read 从接收缓冲区(recv buffer)中读取服务器响应数据。

至此,一次完整网络请求来回过程结束。

对于 Pipeline 技术而言,就是将 n * 11 个步骤合并成 1 * 11,这样服务请求响应的总体时间将会大大的减少。

7.3 测试用例

7.4 注意点

(1)在上述网络请求来回中,可能出现我们经常说到的 IO 阻塞~

  • 当 write 操作发生,并且发送缓冲区(send buffer)满时,就会导致 write 操作阻塞;
  • 当 read 操作发生,并且接收缓冲区(recv buffer)满时,就会导致 read 操作阻塞。

上述的这两个阻塞如果出现,将会导致整个请求时间变长,因此我们操作大批量指令的时候,比如 10k 个指令,我们可以合理的对指令分多次批量发送,这样可以减少出现阻塞的情况,也可以避免服务器响应一个过大的答复包,导致客户端内存负载过重。

(2)Pipeline 缓冲的指令只是会依次执行,不保证原子性,如果执行中指令发生异常,将会继续执行后续的指令;

应用 Pipeline 可以提服务器的吞吐能力,并提高 Redis 处理查询请求的能力。但是这里存在一个问题,当通过 Pipeline 提交的查询命令数据较少,可以被内核缓冲区所容纳时,Redis 可以保证这些命令执行的原子性。然而一旦数据量过大,超过了内核缓冲区的接收大小,那么命令的执行将会被打断,原子性也就无法得到保证。因此 Pipeline 只是一种提升服务器吞吐能力的机制,如果想要命令以事务的方式原子性的被执行,还是需要〈事务机制〉,或者使用更高级的〈脚本功能〉以及模块功能;

(3)Pipeline 和事务的区别

  • Pipeline 是〈客户端〉的行为,对于服务器来说是透明的,可以认为服务器无法区分客户端发送来的查询命令是以普通命令的形式还是以 Pipeline 的形式发送到服务器的;
  • 事务则是实现在〈服务器端〉的行为,用户执行 MULTI 命令时,服务器会将对应这个用户的客户端对象设置为一个特殊的状态,在这个状态下后续用户执行的查询命令不会被真的执行,而是被服务器缓存起来,直到用户执行 EXEC 命令为止,服务器会将这个用户对应的客户端对象中缓存的命令按照提交的顺序依次执行;
  • 事务具有原子性,管道不具有原子性
  • 可以将事务和 Pipeline 结合起来使用,减少事务的命令在网络上的传输时间,将多次网络 IO 缩减为一次网络 IO。
posted @ 2020-09-04 12:53  tree6x7  阅读(148)  评论(0编辑  收藏  举报