在任何数据库系统中,事务都是比较难以处理的。我们不仅需要理解存储的是什么数据,还要知道数据是何时存储的。理想情况下,多层次的抽象可以帮助我们隔离复杂性。但是如果我们深入了解一下 Redis 的事务就会发现,Redis 的做法和其他系统是不一样的,这也导致许多人认为 Redis 根本就没有事务。例如,Redis 有一点比较特别的是对回滚的处理方法。下面我们来详细分析一下。
从更高层面上来看, Redis 有几个需要特别注意的地方:
Redis 是单线程的(也有不少例外),这就意味着,Redis 同时只能做一件事;当然,对于 Redis,“做什么”最好是以毫秒或者纳秒来衡量的;
Redis 的持久化级别是可调的,一些选项可提供很好的持久性,另外一些则完全没有持久化可言;后面我们会注意到,持久化级别对事务也是有影响的;
Redis 的事务没有回滚,但如果一个 key 的值在事务开始之前改变了,那么事务会失败。这种对事务控制的方法和通常的做法是相反的,这么做可以让我们把数据拉回到客户端,从逻辑上进行求值,确保在事务开始之前数据不发生变化。
对大多数人来说,最大的一个陷阱是,事务中的单条命令会发生错误。这就会导致在某些情况下,事务中的每个命令都执行了,但是某个或多个命令发生了错误。认识到这一点有助于我们理解和控制这些异常情况。
首先,让我们看下 Redis 的语法错误和语义错误的区别。语法错误只是命令的语法有问题,不会发生数据访存动作。例如,发送一条不存在的命令或者命令参数违反了 key/value 的顺序。语法错误将导致事务永远不会开始。
举个例子:
Redis 知道 STE 不是一个合法指令,所以抛了个异常,没有对底层的数据进行求值,拒绝执行整个 MULTI/EXEC 指令块。其他可以捕获的错误包括参数个数和参数模式匹配的问题。即使抛出了异常,事务也是非常安全的。使用 MULTI,所有的指令序列都排着队等待运行,一旦 EXEC 调用了,就开始执行。任何触发了 EXECABORT 的事务都不能执行。
语义错误是另外一类出错代价更高的错误。它和语法错误在行为上有所不同,属于 Redis 不能静态捕获的错误,通常需要 Redis 对底层的数据进行求值。
一个经典的例子是这个:
现实世界中一个合格的开发者绝不会故意对字符串“hello world”执行递增操作。但设想一下,我们有一个端到端的接受用户输入的应用程序,期望输入的是一个数字,但验证后却发现不是一个数字的情况。另外一个问题是,第 2 个 SET 指令被执行了,但是 INCR 没有执行。这不是大多数开发者在使用事务时期望得到的结果。好消息是,如果我们使用 WATCH 指令,这种类错误就可以得到控制。WATCH 可以用来观察 key 的变化,如果 key 发生了变化,他马上就会向客户端发送一个错误消息。这是一个非常强大的命令,使得我们可以把数据发送到客户端进行求值验证。在这种情况下,如果 foo 可以递增(亦即是一个整数),那么我们就可以对其求值。
我们看下这个伪代码:
现在,如果在第 3 行到第 9 行(但不在第 9 行执行过程中,这一行允许事务本身改变被监视的数据)命令执行的过程中,foo 被任何连上的客户端改变了,这个过程将会跳回到第 2 行。在第 4 行之后,将查看 foo 的内容,并决定是否可以进行递增操作。因为如果发生了变化,他就会开始重试,这就保证了不会因为 foo 的内容是错误的,从而导致整个事务发生错误。
这一点同 Redis 如何处理整数和浮点数也有关系。可以参考:
https://redislabs.com/blog/set-command-strange-beast/
基本上,如果数据是一个浮点数,就要使用 INCRBYFLOAT,而不是 INCR 或者 INCRBY。如果这个值正好等于一个整数,我们可以用 INCR 或者 INCRBY。很重要的一点是,使用 INCRBYFLOAT 命令,我们能用非浮点数对非浮点数进行递增操作,而不会产生什么影响。
事实上,INCR 命令族,绝对产生不了一个“1.0”的值。在执行 MULTI/EXEC 命令时,使用 INCRBYFLOAT 而不是 INCR 或者 INCRBY 是比较安全的,因为这不会产生错误。此时,使用 INCR 或者 INCRBY 的唯一理由是,如果我们确保这个数是一个浮点数,那么可以保证抛出一个错误。但不管怎样,我们还是必须使用 WATCH 命令。
以这种方式对数据求值,需要注意的是,这是有代价的。如果我们把数据拉到客户端,求值代价是极小的。例如,我们有一个值未知的 key,需要对其求值,这个值不是像组成一个数字的所需要的那么长的 5~7 个字节,而是 500M 字节的二进制块。把他传到客户端再求值那就要多等一会儿了。当然这是个比较极端的例子。
WATCH/MULTI/EXEC 模式,在用来防止命令/类型不匹配(WRONGTYPE)问题时很有用,这个错误会出现在事务的结果中。在 INCR 问题中,我们不得不 WATCH 一个字符串,使用在客户端对数据求值的方法,来决定是否可以在上面执行 INCR 或者 INCRBYFLOAT 命令。也可以直接使用 TYPE 命令对数据的类型进行求值,而不是对数据本身进行求值(这可以很好的避免对 500M 字节的数据直接进行求值的开销)。
让我们看下这个模式是怎么使用的:
在这个例子里,我们知道 HSET 和 HLEN 命令是可以执行的,因为事先我们已经验证了他们的类型,而且在执行 EXEC 的时候,类型也没有发生变化。对于 HINCRBY/HINCRBYFLOAT 命令来说,需要结合之前的技巧,使用 HGET 而不是 GET 来检查是否可以在上面执行 INCR 命令。
有意思的是,BITFIELD 命令对数值越界的处理是有控制的,基于我们在命令中定义的类型,既可以封装也可以填充这些值。或者可以使用 FAIL 选项,奇怪的是,这并不会产生一个错误,而是忽略 INCR 命令,值保持和以前一样。然而,BITFIELD 还有另外一个陷阱,虽然这个命令很复杂,但 Redis 只做一些基本的语法检查,所以如果有语法错误,当这个命令加入到事务中时,命令是不会求值的。但当命令在事务中执行时,将产生一个语法错误,事务并不会取消,而是随着返回值返回一个错误。防范这类错误的唯一方法,就是确保这条命令的语法是正确的,确保命令在放入事务之前,在客户端层面来看语法是正确的。
总的来说,Redis 对数据是没有初始化这个步骤的。一些人会感到有点吃惊,但对大多数人来说,这是可接受的。如果一个 key 是空的,我们向其中加入数据,新创建的数据结构的类型是通过向其中加入数据的命令来定义的。但是这个情况随着模块 (module) 的出现慢慢开始变化了,使得这个惯例被打破了。比如,RedisGraph 要求,在查询 graph 之前,需要添加一些节点和关系。
我们看下这个例子:
事实上,在 Redis Streams 中也能看到这个行为,例如:
值得庆幸的是,使用前面提到的 WATCH,TYPE, MULTI/EXEC 的模式,这些问题解决起来还是比较简单的。类似在上面这些例子,你只需检查类型是否匹配,例如是否是 graphdata 或 stream 类型。
使用 MULTI 和 EXEC 的 Redis 事务其实也没那么复杂。但我们确实需要注意一些陷阱来确保事务的行为是符合预期的。最后给大家在使用 Redis 事务上的一点建议,不要对类型、内容或者引用的 key 的存在性做任何假设,这样我们的 Redis 事务就是“刀枪不入”的了。
本文转载自公众号中间件小哥(ID:huawei_kevin)。
原文链接:
https://mp.weixin.qq.com/s/QShkLdQYcR3LqBSAPs1w3g
评论