# /

# 事务(Transactions)

MULTI、EXEC、DISCARD和WATCH是Redis中事务的基础。它们允许在一个步骤中执行一组命令,有两个重要保证:

  • 事务中的所有命令都是按顺序序列化和执行的。在执行Redis事务的过程中,不可能提供由另一个客户端发出的请求。这保证了命令作为单个独立操作执行。
  • 要么处理所有命令,要么不处理任何命令,因此Redis事务也是原子事务。EXEC命令触发事务中所有命令的执行,因此,如果客户端在调用EXEC命令之前在事务上下文中失去了与服务器的连接,则不会执行任何操作,相反,如果调用了EXEC命令,则会执行所有操作。当使用仅追加文件时,Redis确保使用单个write(2)系统调用将事务写入磁盘。但是,如果Redis服务器崩溃或被系统管理员以某种艰难的方式终止,则可能只注册了部分操作。Redis将在重新启动时检测到这种情况,并将以错误退出。使用redis check aof工具,可以修复将删除部分事务的仅追加文件,以便服务器可以重新启动。

从版本2.2开始,Redis允许以乐观锁定的形式为上述两种操作提供额外的保证,其方式与check-and-set (CAS) 操作非常相似。本页稍后将对此进行说明。

# 事务使用

Redis事务是使用MULTI命令输入的。命令总是以“确定”作为回复。此时,用户可以发出多个命令。Redis将不执行这些命令,而是对它们进行排队。调用EXEC后,将执行所有命令。
调用DISCARD将刷新事务队列并退出事务。
以下示例以原子方式递增键foo和bar。

> MULTI
OK
> INCR foo
QUEUED
> INCR bar
QUEUED
> EXEC
1) (integer) 1
2) (integer) 1
1
2
3
4
5
6
7
8
9

从上面的会话中可以看出,EXEC返回一个回复数组,其中每个元素都是事务中单个命令的回复,其顺序与发出命令的顺序相同。
当Redis连接处于MULTI请求的上下文中时,所有命令都将使用字符串QUEUED(从Redis协议的角度来看,作为状态回复发送)进行回复。排队的命令只是在调用EXEC时安排执行。

# 事务内部的错误

在事务处理过程中,可能会遇到两种命令错误:

  • 命令可能无法排队,因此在调用EXEC之前可能会出现错误。例如,该命令可能语法错误(参数数量错误、命令名称错误…),或者可能存在一些关键情况,如内存不足(如果使用maxmemory指令将服务器配置为具有内存限制)。
  • 调用EXEC后,命令可能会失败,例如,因为我们对具有错误值的键执行了操作(如对字符串值调用列表操作)。

客户端用于通过检查排队命令的返回值来检测EXEC调用之前发生的第一种错误:如果命令以queued进行回复,则它已正确排队,否则Redis将返回错误。如果在对命令进行排队时出现错误,大多数客户端将中止事务并丢弃它。
然而,从Redis 2.6.5开始,服务器会记住在累积命令的过程中发生了错误,并将拒绝执行事务,在EXEC过程中也会返回错误,并自动丢弃事务。
在Redis 2.6.5之前,行为是只使用排队成功的命令子集执行事务,以防客户端调用EXEC而不管以前的错误。新的行为使将事务与流水线混合使用变得更加简单,这样整个事务就可以一次发送,稍后一次读取所有回复。
相反,EXEC之后发生的错误不会以特殊方式处理:即使某些命令在事务过程中失败,也会执行所有其他命令。
这在协议层面上更为明显。在以下示例中,即使语法正确,一个命令在执行时也会失败:

Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
MULTI
+OK
SET a abc
+QUEUED
LPOP a
+QUEUED
EXEC
*2
+OK
-ERR Operation against a key holding the wrong kind of value
1
2
3
4
5
6
7
8
9
10
11
12
13

EXEC返回了两个元素的Bulk字符串回复,其中一个是OK代码,另一个是-ERR回复。这取决于客户端库找到一种合理的方式来向用户提供错误。

需要注意的是,即使一个命令失败,队列中的所有其他命令都会被处理——Redis不会停止对命令的处理。

另一个例子,再次使用telnet的有线协议,显示了如何尽快报告语法错误:

MULTI
+OK
INCR a b c
-ERR wrong number of arguments for 'incr' command
1
2
3
4

这一次,由于语法错误,错误的INCR命令根本没有排队。

# 为什么Redis不支持回滚?

如果你有关系数据库背景,Redis命令可能会在事务过程中失败,但Redis仍然会执行事务的其余部分,而不是回滚,这对你来说可能很奇怪。
然而,对于这种行为有一些好的意见:

  • Redis命令只有在使用错误的语法调用时(在命令排队过程中无法检测到问题),或者针对持有错误数据类型的键调用时才会失败:这意味着在实际操作中,失败的命令是编程错误的结果,而且这种错误很可能在开发过程中检测到,而不是在生产过程中。
  • Redis内部简化且速度更快,因为它不需要回滚功能。

反对Redis观点的一个论点是错误会发生,但应该注意的是,通常情况下,回滚并不能使您免于编程错误。例如,如果查询将键增加2而不是1,或者增加了错误的键,则回滚机制无法提供帮助。考虑到没有人能将程序员从错误中拯救出来,而且Redis命令失败所需的错误不太可能进入生产,我们选择了一种更简单、更快的方法,即不支持错误回滚。

# DISCARD(放弃命令队列)

DISCARD可用于中止事务。在这种情况下,不执行任何命令,并且连接状态恢复到正常。

> SET foo 1
OK
> MULTI
OK
> INCR foo
QUEUED
> DISCARD
OK
> GET foo
"1"
1
2
3
4
5
6
7
8
9
10
# WATCH(使用CAS优化锁定)

WATCH用于为Redis事务提供一种check-and-set (CAS)行为。
监视WATCHed钥匙,以检测钥匙的变化。如果在执行EXEC命令之前至少修改了一个被监视的密钥,则整个事务将中止,EXEC将返回一个Null回复以通知事务失败。
例如,假设我们需要将键的值原子递增1(假设Redis没有INCR)。
第一次尝试可能如下:

val = GET mykey
val = val + 1
SET mykey $val
1
2
3

只有当我们有一个客户端在给定的时间内执行操作时,这才能可靠地工作。如果多个客户端试图在大约相同的时间增加密钥,则会出现竞争条件。例如,客户端A和B将读取旧值,例如10。该值将由两个客户端递增到11,最后设置为密钥的值。因此,最终值将是11,而不是12。
多亏了WATCH,我们能够很好地模拟问题:

WATCH mykey
val = GET mykey
val = val + 1
MULTI
SET mykey $val
EXEC
1
2
3
4
5
6

使用上面的代码,如果存在竞争条件,并且另一个客户端在我们调用WATCH和调用EXEC之间的时间内修改了val的结果,则事务将失败。
我们只需要重复操作,希望这次不会有新的比赛。这种形式的锁定被称为乐观锁定,是一种非常强大的锁定形式。在许多用例中,多个客户端将访问不同的密钥,因此不太可能发生冲突——通常不需要重复操作。

# WATCH解释

那么WATCH到底是关于什么的呢?这是一个将EXEC设为条件的命令:我们要求Redis仅在没有修改WATCHed密钥的情况下执行事务。这包括客户端所做的修改,如写入命令,以及Redis本身所做的更改,如过期或驱逐。如果密钥在WATCHed和接收到EXEC之间被修改,则整个事务将被中止。

注意:在6.0.9之前的Redis版本中,过期的密钥不会导致事务中止。更多信息事务中的命令不会触发WATCH条件,因为它们只在发送EXEC之前排队。

WATCH可以调用多次。简单地说,从调用开始直到调用EXEC,所有WATCH调用都将具有监视更改的效果。您还可以向单个WATCH呼叫发送任意数量的密钥。
调用EXEC时,无论事务是否中止,所有密钥都将被UNWATCH。此外,当客户端连接关闭时,所有内容都将被取消监视。
也可以使用UNWATCH命令(不带参数)来刷新所有关注的密钥。有时这很有用,因为我们乐观地锁定了一些密钥,因为我们可能需要执行事务来更改这些密钥,但在读取了密钥的当前内容后,我们不想继续。当这种情况发生时,我们只需调用UNWATCH,这样连接就可以自由用于新的事务。

# 使用WATCH实现ZPOP

一个很好的例子来说明如何使用WATCH来创建Redis不支持的新原子操作,那就是实现ZPOP(ZPOPMIN、ZPOPMAX及其阻塞变体仅在版本5.0中添加),这是一个以原子方式从排序集弹出得分较低的元素的命令。这是最简单的实现:

WATCH zset
element = ZRANGE zset 0 0
MULTI
ZREM zset element
EXEC
1
2
3
4
5

如果EXEC失败(即返回Null回复),我们只需重复该操作。

# Redis脚本和事务

Redis脚本根据定义是事务性的,所以你可以用Redis事务做的一切,你也可以用脚本做,通常脚本会更简单更快。
这种重复是由于Redis 2.6中引入了脚本,而事务早就存在了。然而,我们不太可能在短期内取消对事务的支持,因为即使不使用Redis脚本,仍然可以避免竞争条件,这在语义上似乎是合适的,特别是因为Redis事务的实现复杂性最小。
然而,在不久的将来,我们将看到整个用户群都在使用脚本,这并非不可能。如果发生这种情况,我们可能会弃用并最终删除事务。

# Redis事务小结

1、Redis事务是一个命令队列。
2、Redis事务执行提交命令队列EXEC后,部分命令出现语法错误,已经执行的命令不会撤销。
3、Redis事务不支持回滚,仅支持撤销事务。
4、Redis事务提供WATCH命令应对并发问题,发生并发写操作时,后者自动撤销事务。
5、Redis事务可以做的,Redis脚本都可以做。

# 管道(Pipelining)

# 请求/响应协议和RTT

Redis是一个使用客户端-服务器模型和所谓的请求/响应协议的TCP服务器。
这意味着通常通过以下步骤来完成请求:

  • 客户端向服务器发送一个查询,并从套接字中读取服务器响应,通常是以阻塞的方式。
  • 服务器处理命令并将响应发送回客户端。

例如,四个命令序列是这样的:

Client: INCR X
Server: 1
Client: INCR X
Server: 2
Client: INCR X
Server: 3
Client: INCR X
Server: 4
1
2
3
4
5
6
7
8

客户端和服务器通过网络链路连接。这样的链路可以非常快(环回接口),也可以非常慢(通过互联网建立的连接,在两个主机之间有许多跳)。无论网络延迟是多少,数据包从客户端传输到服务器,再从服务器返回到客户端以携带回复都需要时间。
这个时间被称为RTT(往返时间)。很容易看出,当客户端需要在一行中执行许多请求时(例如,向同一列表中添加许多元素,或用许多键填充数据库),这会如何影响性能。例如,如果RTT时间为250毫秒(在互联网上的链接非常慢的情况下),即使服务器每秒能够处理10万个请求,我们也将能够每秒最多处理四个请求。
如果使用的接口是环回接口,RTT会短得多(例如,我的主机报告为0044毫秒ping 127.0.0.1),但如果您需要在一行中执行多次写入,RTT仍然很大。
幸运的是,有一种方法可以改进这个用例。

# 管道(Pipelining)

可以实现请求/响应服务器,这样即使客户端还没有读取旧的响应,它也能够处理新的请求。通过这种方式,可以向服务器发送多个命令,而无需等待回复,并最终在一个步骤中读取回复。
这被称为流水线pipelining,是一种广泛使用了几十年的技术。例如,许多POP3协议实现已经支持此功能,大大加快了从服务器下载新电子邮件的过程。
Redis从很早的时候就支持流水线,所以无论你运行什么版本,你都可以在Redis中使用流水线。这是一个使用原始netcat实用程序的示例:

$ (printf "PING\r\nPING\r\nPING\r\n"; sleep 1) | nc localhost 6379
+PONG
+PONG
+PONG
1
2
3
4

这一次,我们不是为每次调用支付RTT的费用,而是为三个命令只支付一次。
非常明确地说,对于流水线,我们第一个例子的操作顺序如下:

Client: INCR X
Client: INCR X
Client: INCR X
Client: INCR X
Server: 1
Server: 2
Server: 3
Server: 4
1
2
3
4
5
6
7
8

重要提示:当客户端使用流水线发送命令时,服务器将被迫使用内存对回复进行排队。因此,如果你需要通过流水线发送大量命令,最好分批发送,每个命令都包含合理的数量,例如10k个命令,读取回复,然后再次发送10k个指令,以此类推。速度将几乎相同,但所使用的额外内存将达到将这些10k命令的回复排队所需的最大数量。

# 这不仅仅是RTT的问题

流水线不仅是减少与往返时间相关的延迟成本的一种方式,它实际上大大提高了在给定Redis服务器中每秒可以执行的操作数量。这是因为,在不使用流水线的情况下,从访问数据结构和产生回复的角度来看,为每个命令提供服务是非常便宜的,但从进行套接字I/O的角度来看是非常昂贵的。这涉及到调用read()和write()系统调用,这意味着从用户端到内核端。上下文切换是一个巨大的速度损失。
当使用流水线时,许多命令通常通过一个read()系统调用来读取,而多个回复则通过一个write()的系统调用来传递。因此,每秒执行的总查询数最初几乎随着管道的延长而线性增加,最终达到不使用管道的基线的10倍,如下图所示:
pipeline_iops.png

# 代码示例

在下面的基准测试中,我们将使用支持流水线的Redis-Ruby客户端来测试流水线带来的速度提高:

require 'rubygems'
require 'redis'

def bench(descr)
  start = Time.now
  yield
  puts "#{descr} #{Time.now - start} seconds"
end

def without_pipelining
  r = Redis.new
  10_000.times do
    r.ping
  end
end

def with_pipelining
  r = Redis.new
  r.pipelined do
    10_000.times do
      r.ping
    end
  end
end

bench('without pipelining') do
  without_pipelining
end
bench('with pipelining') do
  with_pipelining
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

在我的Mac OS X系统上运行上面的简单脚本,在环回接口上运行,会产生以下图形,其中管道化将提供最小的改进,因为RTT已经很低了:

without pipelining 1.185238 seconds
with pipelining 0.250783 seconds
1
2

正如您所看到的,使用流水线,我们将传输提高了五倍。

# 流水线与脚本

使用Redis脚本(在Redis 2.6或更高版本中可用),可以使用在服务器端执行大量所需工作的脚本更有效地解决许多流水线使用情况。脚本的一大优势是,它能够以最小的延迟读取和写入数据,使读取、计算、写入等操作非常快速(在这种情况下,流水线操作没有帮助,因为客户端在调用写入命令之前需要读取命令的回复)。
有时,应用程序可能还希望在管道中发送EVAL或EVALSHA命令。这是完全可能的,Redis通过SCRIPT LOAD命令明确支持它(它保证可以在没有失败风险的情况下调用EVALSHA)。

# 附录:为什么即使在环回接口loopback interface上繁忙的循环busy loops也很慢?

即使本页涵盖了所有背景知识,您可能仍然会想,为什么像下面这样的Redis基准测试(伪代码)即使在环回接口中执行,当服务器和客户端在同一物理机器中运行时,速度也很慢:

FOR-ONE-SECOND:
    Redis.SET("foo","bar")
END
1
2
3

毕竟,如果Redis进程和基准测试都在同一个盒子里运行,难道这不只是将内存中的消息从一个地方复制到另一个地方吗?
原因是系统中的进程并不总是在运行,实际上是内核调度程序让进程运行。因此,例如,当允许基准测试运行时,它会从Redis服务器读取回复(与执行的最后一个命令有关),并写入一个新命令。该命令现在在环回接口缓冲区中,但为了被服务器读取,内核应该安排服务器进程(当前在系统调用中被阻止)运行,以此类推。因此,实际上,由于内核调度器的工作方式,环回接口仍然涉及类似网络的延迟。
基本上,在测量网络服务器中的性能时,繁忙循环基准测试是最愚蠢的事情。明智的做法是避免以这种方式进行基准测试。
注意:回环接口Loopback Interface指允许运行在同一台主机上的服务器和客户端通过TCP/IP进行通讯。

# 发布/订阅(Pub/Sub)

SUBSCRIBE、UNSUBSCRIBE和PUBLISH实现了发布/订阅消息传递范式,其中(引用维基百科)发送者(出版商)没有被编程为将其消息发送给特定接收者(订阅者)。相反,已发布的消息被划分为多个频道,而不知道可能有哪些(如果有的话)订阅者。订阅者对一个或多个频道表示兴趣,并且只接收感兴趣的消息,而不了解可能有哪些发布者。发布者和订阅者的这种解耦可以实现更大的可扩展性和更动态的网络拓扑。
例如,为了订阅频道foo和bar,客户端发出subscribe,提供频道名称:

> SUBSCRIBE foo bar
1) "subscribe"
2) "foo"
3) (integer) 1
1) "subscribe"
2) "bar"
3) (integer) 2
1
2
3
4
5
6
7

其他客户端发送到这些通道的消息将由Redis推送到所有订阅的客户端。
订阅了一个或多个频道的客户端不应发出命令,尽管它可以订阅和取消订阅其他频道。对订阅和取消订阅操作的回复以消息的形式发送,这样客户端就可以读取连贯的消息流,其中第一个元素指示消息的类型。在已订阅客户端的上下文中允许的命令有SUBSCRIBE、PSUBSCRIBE,UNSUBSCRIBE,PUNSUBSCRIBE,PING和QUIT。

请注意,reds-cli在订阅模式下不会接受任何命令,只能使用Ctrl-C退出该模式。

# 推送消息的格式

消息是一个包含三个元素的数组回复。
第一个元素是消息的类型:

  • subscribe:表示我们成功订阅了回复中作为第二个元素给出的频道。第三个参数表示我们当前订阅的频道数。
  • unsubscribe取消订阅:意味着我们成功地取消订阅了回复中作为第二个元素给出的频道。第三个参数表示我们当前订阅的频道数量。当最后一个参数为零时,我们不再订阅任何频道,并且客户端可以发出任何类型的Redis命令,因为我们处于Pub/Sub状态之外。
  • message:这是另一个客户端发出PUBLISH命令后收到的消息。第二个元素是始发信道的名称,第三个参数是实际的消息有效载荷。
# 数据库和范围界定

Pub/Sub与密钥空间没有关系。它是为了不在任何层面上干扰它,包括数据库编号。
在数据库10上发布,将由数据库1上的订户听到。

注意:如果您需要某种范围,请在通道前面加上环境名称(测试、暂存、生产…)。

# 有线协议示例
> SUBSCRIBE first second
1) "subscribe"
2) "first"
3) (integer) 1
1) "subscribe"
2) "second"
3) (integer) 2
1
2
3
4
5
6
7
> SUBSCRIBE first second
*3
  $9 subscribe
  $5 first
  :1
*3
  $9 subscribe
  $6 second
  :2
1
2
3
4
5
6
7
8
9

此时,我们从另一个客户端对名为second的通道发出PUBLISH操作:

> PUBLISH second Hello
1

这是第一个客户端收到的内容:

1) "message"
2) "second"
3) "Hello"
1
2
3
*3
  $7 message
  $6 second
  $5 Hello
1
2
3
4

现在,客户端使用UNSUBSCRIBE命令取消订阅所有通道,无需其他参数:

> UNSUBSCRIBE
1) "unsubscribe"
2) (nil)
3) (integer) 0
1
2
3
4
> UNSUBSCRIBE
*3
  $11 unsubscribe
  $6 second
  :1
*3
  $11 unsubscribe
  $5 first
  :0
1
2
3
4
5
6
7
8
9
# 模式匹配订阅

Redis Pub/Sub实现支持模式匹配。客户端可以订阅glob样式的模式,以便接收发送到与给定模式匹配的频道名称的所有消息。
例如:

PSUBSCRIBE news.*
1

将接收发送到频道news.art.figurative、news.music.jazz等的所有消息。所有glob样式的模式都是有效的,因此支持多个通配符。

PUNSUBSCRIBE news.*
1

然后将取消订阅该模式的客户端。没有其他订阅将受到此调用的影响。
由于模式匹配而接收到的消息以不同的格式发送:

  • 消息类型为pmessage:这是另一个客户端发出PUBLISH命令后收到的消息,与模式匹配订阅相匹配。第二个元素是匹配的原始模式,第三个元素是始发信道的名称,最后一个元素是实际的消息有效载荷。

与SUBSCRIBE和UNSUBSCRIBE类似,系统使用与订阅和取消订阅消息格式相同的格式发送类型为PSUBSCRIBE和PUNSUBSCRIBE的消息来确认PSUBSCRIBE和PUNSUBSCRIBE命令。

# 同时匹配模式和频道订阅的消息

如果客户端订阅了与已发布消息匹配的多个模式,或者订阅了与该消息匹配的模式和通道,则客户端可以多次接收单个消息。如以下示例所示:

SUBSCRIBE foo
PSUBSCRIBE f*
1
2

在上面的例子中,如果一条消息被发送到通道foo,客户端将收到两条消息:一条消息类型为message,另一条消息为pmessage。