事务

虽然Redis的LPUSH命令和RPUSH命令允许用户一次向列表推入多个元素,但是列表的弹出命令LPOP和RPOP每次却只能弹出一个元素:

redis> RPUSH lst 1 2 3 4 5 6 -- 一次推入5个元素
(integer) 6
redis> LPOP lst -- 弹出一个元素
"1"
redis> LPOP lst
"2"
redis> LPOP lst
"3"
bash

因为Redis并没有提供能够一次弹出多个列表元素的命令,所以为了方便地执行这一任务,用户可能会写出代码清单13-3所示的代码。

代码清单13-3 不安全的 mlpop() 实现:/pipeline-and-transaction/unsafe_mlpop.py
def mlpop(client, list_key, number):
    # 用于储存被弹出元素的结果列表
    items = []
    for i in range(number):
        # 执行 LPOP 命令,弹出一个元素
        poped_item = client.lpop(list_key)
        # 将被弹出的元素追加到结果列表末尾
        items.append(poped_item)
    # 返回结果列表
    return items
python

mlpop()函数通过将多条LPOP命令发送至服务器来达到弹出多个元素的目的。遗憾的是,这个函数并不能保证它发送的所有LPOP命令都会被服务器执行:如果服务器在执行多个LPOP命令的过程中下线了,那么mlpop()发送的这些LPOP命令将只有一部分会被执行。

举个例子,如果我们调用mlpop(client,"lst",3),尝试从"lst"列表中弹出3个元素,那么mlpop()将向服务器连续发送3个 LPOP命令,但如果服务器在顺利执行前两个LPOP命令之后因为故障下线了,那么"lst"列表将只有2个元素会被弹出。

需要注意的是,即使我们使用流水线特性,把多条LPOP命令打包在一起发送,也不能保证所有命令都会被服务器执行:这是因为流水线只能保证多条命令会一起被发送至服务器,但它并不保证这些命令都会被服务器执行。

为了实现一个正确且安全的mlpop()函数,我们需要一种能够让服务器将多个命令打包起来一并执行的技术,而这正是本节将要介绍的事务特性:

  • 事务可以将多个命令打包成一个命令来执行,当事务成功执行时,事务中包含的所有命令都会被执行。

  • 相反,如果事务没有成功执行,那么它包含的所有命令都不会被执行。

通过使用事务,用户可以保证自己想要执行的多个命令要么全部被执行,要么一个都不执行。以mlpop()函数为例,通过使用事务,我们可以保证被调用的多个LPOP命令要么全部执行,要么一个也不执行,从而杜绝只有其中一部分LPOP命令被执行的情况出现。

接下来将会介绍Redis事务特性的使用方法以及相关事项,至于事务版本mlpop()函数的具体实现则会留到“实现mlpop()函数”示例中再行介绍。

MULTI:开启事务

用户可以通过执行MULTI命令来开启一个新的事务,这个命令在成功执行之后将返回OK:

MULTI
bash

在一般情况下,除了少数阻塞命令之外,用户输入客户端中的数据操作命令总是会立即执行:

redis> SET title "Hand in Hand"
OK
redis> SADD fruits "apple" "banana" "cherry"
(integer) 3
redis> RPUSH numbers 123 456 789
(integer) 3
bash

但是当一个客户端执行MULTI命令之后,它就进入了事务模式,这时用户输入的所有数据操作命令都不会立即执行,而是会按顺序放入一个事务队列中,等待事务执行时再统一执行。

比如,以下代码就展示了在MULTI命令执行之后,将SET命令、SADD命令和RPUSH命令放入事务队列中的例子:

redis> MULTI
OK
redis> SET title "Hand in Hand"
QUEUED
redis> SADD fruits "apple" "banana" "cherry"
QUEUED
redis> RPUSH numbers 123 456 789
QUEUED
bash

正如代码所示,服务器在把客户端发送的命令放入事务队列之后,会向客户端返回一个QUEUED作为结果。

其他信息

  • 复杂度:O(1)。

  • 版本要求:MULTI 命令从 Redis 1.2.0 版本开始可用。

EXEC:执行事务

在使用MULTI命令开启事务并将任意多个命令放入事务队列之后,用户就可以通过执行EXEC命令来执行事务了:

EXEC
bash

当事务成功执行时,EXEC命令将返回一个列表作为结果,这个列表会按照命令的入队顺序依次包含各个命令的执行结果。

作为例子,以下代码展示了一个事务从开始到执行的整个过程:

redis> MULTI -- 1) 开启事务
OK
redis> SET title "Hand in Hand" -- 2) 命令入队
QUEUED
redis> SADD fruits "apple" "banana" "cherry"
QUEUED
redis> RPUSH numbers 123 456 789
QUEUED
redis> EXEC -- 3)执行事务
1) OK -- SET命令的执行结果
2) (integer) 3 -- SADD命令的执行结果
3) (integer) 3 -- RPUSH命令的执行结果
bash

其他信息

  • 复杂度:事务包含的所有命令的复杂度之和。

  • 版本要求:EXEC 命令从 Redis 1.2.0 版本开始可用。

DISCARD:放弃事务

如果用户在开启事务之后,不想执行事务而是想放弃事务,那么只需要执行以下命令即可:

DISCARD
bash

DISCARD 命令会清空事务队列中已有的所有命令,并让客户端退出事务模式,最后返回OK表示事务已被取消。

以下代码展示了一个使用 DISCARD 命令放弃事务的例子:

redis> MULTI
OK
redis> SET page_counter 10086
QUEUED
redis> SET download_counter 12345
QUEUED
redis> DISCARD
OK
bash

其他信息

  • 复杂度:O(N),其中N为事务队列包含的命令数量。

  • 版本要求:DISCARD命令从Redis 2.0.0版本开始可用。

事务的安全性

在对数据库的事务特性进行介绍时,人们一般都会根据数据库对ACID 性质的支持程度去判断数据库的事务是否安全。

具体来说,Redis的事务总是具有ACID性质中的A、C、I性质:

  • 原子性(Atomic):如果事务成功执行,那么事务中包含的所有命令都会被执行;相反,如果事务执行失败,那么事务中包含的所有命令都不会被执行。

  • 一致性(Consistent):Redis服务器会对事务及其包含的命令进行检查,确保无论事务是否执行成功,事务本身都不会对数据库造成破坏。

  • 隔离性(Isolate):每个Redis客户端都拥有自己独立的事务队列,并且每个Redis事务都是独立执行的,不同事务之间不会互相干扰。

除此之外,当Redis服务器运行在特定的持久化模式之下时,Redis的 事务也具有ACID性质中的D性质:

  • 耐久性(Durable):当事务执行完毕时,它的结果将被存储在硬盘中,即使服务器在此之后停机,事务对数据库所做的修改也不会丢失。

第 15 章中将对事务的耐久性做补充说明。

事务对服务器的影响

因为事务在执行时会独占服务器,所以用户应该避免在事务中执行过多命令,更不要将一些需要进行大量计算的命令放入事务中,以免造成服务器阻塞。

流水线与事务

正如前面所言,流水线与事务虽然在概念上有些相似,但是在作用上却并不相同:流水线的作用是将多个命令打包,然后一并发送至服务器,而事务的作用则是将多个命令打包,然后让服务器一并执行它们。

因为Redis的事务在EXEC命令执行之前并不会产生实际效果,所以很多Redis客户端都会使用流水线去包裹事务命令,并将入队的命令缓存在本地,等到用户输入EXEC命令之后,再将所有事务命令通过流水线一并发送至服务器,这样客户端在执行事务时就可以达到“打包发送,打包执行”的最优效果。

本书使用的redis-py客户端就是这样处理事务命令的客户端之一,当我们使用pipeline()方法开启一个事务时,redis-py默认将使用流水线包裹事务队列中的所有命令。

举个例子,对于以下代码来说:

>>> from redis import Redis
>>> client = Redis(decode_responses=True)
>>> transaction = client.pipeline() # 开启事务
>>> transaction.set("title", "Hand in Hand") # 将命令放入事务队列
Pipeline<ConnectionPool<Connection<host=localhost,port=6379,db=0>>>
>>> transaction.sadd("fruits", "apple", "banana", "cherry")
Pipeline<ConnectionPool<Connection<host=localhost,port=6379,db=0>>>
>>> transaction.rpush("numbers", "123", "456", "789")
Pipeline<ConnectionPool<Connection<host=localhost,port=6379,db=0>>>
>>> transaction.execute() # 执行事务
[True, 3, 3L]
bash

在执行transaction.execute()调用时,redis-py将通过流水线向服务器发送以下命令:

MULTI
SET title "Hand in Hand"
SADD fruits "apple" "banana" "cherry"
RPUSH numbers "123" "456" "789"
EXEC
bash

这样,无论事务包含了多少个命令,redis-py也只需要与服务器进行一次网络通信。

如果用户只需要用到流水线特性而不是事务特性,那么可以在调用 pipeline()方法时通过transaction=False参数显式地关闭事务特性,就像这样:

>>> pipe = client.pipeline(transaction=False) # 开启流水线
>>> pipe.set("download_counter", 10086) # 将命令放入流水线队列
Pipeline<ConnectionPool<Connection<host=localhost,port=6379,db=0>>>
>>> pipe.get("download_counter")
Pipeline<ConnectionPool<Connection<host=localhost,port=6379,db=0>>>
>>> pipe.hset("user::123::profile", "name", "peter")
Pipeline<ConnectionPool<Connection<host=localhost,port=6379,db=0>>>
>>> pipe.execute() # 将流水线队列中的命令打包发送至服务器
[True, '10086', 1L]
bash

在执行pipe.execute()调用时,redis-py将通过流水线向服务器发送以下命令:

SET download_counter 10086
GET download_counter
HSET user::123::profile "name" "peter"
bash

因为这3个命令并没有被事务包裹,所以客户端只保证它们会一并被发送至服务器,至于这些命令会在何时以何种方式执行,则由服务器本身决定。

示例:实现mlpop()函数

在了解了事务的使用方法之后,现在是时候用它来重新实现一个安全且正确的mlpop()函数了,为此,我们需要使用事务包裹被执行的所有LPOP命令,就像代码清单13-4所示的那样。

代码清单13-4 事务版本的 mlpop() 函数:/pipeline-and-transaction/mlpop.py
def mlpop(client, list_key, number):
    # 开启事务
    transaction = client.pipeline()
    # 将多个 LPOP 命令放入事务队列
    for i in range(number):
        transaction.lpop(list_key)
    # 执行事务
    return transaction.execute()
python

新版的 mlpop()函数通过事务确保自己发送的多个LPOP命令要么全部执行,要么全部不执行,以此来避免只有一部分LPOP命令被执行的情况出现。

举个例子,如果我们执行函数调用:

mlpop(client, "lst", 3)
bash

那么mlpop()函数将向服务器发送以下命令序列:

MULTI
LPOP "lst"
LPOP "lst"
LPOP "lst"
EXEC
bash

如果这个事务能够成功执行,那么它包含的3个LPOP命令也将成功执行;相反,如果这个事务执行失败,那么它包含的3个LPOP命令也不会被执行。

以下是新版mlpop()函数的实际运行示例:

>>> from redis import Redis
>>> from mlpop import mlpop
>>> client = Redis(decode_responses=True)
>>> client.rpush("lst", "123", "456", "789") # 向列表右端推入3个元素
3L
>>> mlpop(client, "lst", 3) # 从列表左端弹出3个元素
['123', '456', '789']
bash