事务
虽然 Redis 的 LPUSH
命令和 RPUSH
命令允许用户一次向列表推入多个元素,但是列表的弹出命令 LPOP
和 RPOP
每次却只能弹出一个元素:
- redis> RPUSH lst 1 2 3 4 5 6 -- 一次推入五个元素
- (integer) 6
- redis> LPOP lst -- 弹出一个元素
- "1"
- redis> LPOP lst
- "2"
- redis> LPOP lst
- "3"
因为 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
mlpop()
函数通过将多条 LPOP
命令发送至服务器来达到弹出多个元素的目的。遗憾的是,这个函数并不能保证它发送的所有 LPOP
命令都会被服务器执行:如果服务器在执行多个 LPOP
命令的过程中下线了,那么 mlpop()
发送的这些 LPOP
命令将只有一部分会被执行。
举个例子,如果我们执行调用 mlpop(client, "lst", 3)
,尝试从 "lst"
列表中弹出三个元素,那么 mlpop()
将向服务器连续发送三个 LPOP
命令,但如果服务器在顺利执行前两个 LPOP
命令之后因为故障下线了,那么 "lst"
列表将只有两个元素会被弹出。
需要注意的是,即使我们使用上一节介绍的流水线特性,把多条 LPOP
命令打包在一起发送,也不能保证所有命令都会被服务器执行:这是因为流水线只能保证多条命令会一起被发送至服务器,但它并不保证这些命令都会被服务器执行。
为了实现一个正确且安全的 mlpop()
函数,我们需要一种能够让服务器将多个命令打包起来一并执行的技术,而这正是本节将要介绍的事务特性:
事务可以将多个命令打包成一个命令来执行,当事务成功执行时,事务中包含的所有命令都会被执行;
相反地,如果事务没有成功执行,那么它包含的所有命令都不会被执行。
通过使用事务,用户可以保证自己想要执行的多个命令要么全部都被执行,要么就一个都不执行。以 mlpop()
函数为例,通过使用事务,我们可以保证被调用的多个 LPOP
命令要么全部都执行,要么就一个都不执行,从而杜绝了只有其中一部分 LPOP
命令被执行的情况出现。
本节接下来的内容将会介绍 Redis 事务特性的使用方法以及相关事项,至于事务版本 mlpop()
函数的具体实现则会留到下一节再行介绍。
MULTI:开启事务
用户可以通过执行 MULTI
命令来开启一个新的事务,这个命令在成功执行之后将返回 OK
:
- MULTI
在一般情况下,除了少数阻塞命令之外,用户键入到客户端里面的数据操作命令总是会立即执行:
- redis> SET title "Hand in Hand"
- OK
- redis> SADD fruits "apple" "banana" "cherry"
- (integer) 3
- redis> RPUSH numbers 123 456 789
- (integer) 3
但是当一个客户端执行 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
正如代码所示,服务器在把客户端发送的命令放入到事务队列之后,会向客户端返回一个 QUEUED
作为结果。
其他信息
属性 | 值 |
---|---|
复杂度 | O(1) |
版本要求 | MULTI 命令从 Redis 1.2.0 版本开始可用。 |
EXEC:执行事务
在使用 MULTI
命令开启事务并将任意多个命令放入到事务队列之后,用户就可以通过执行 EXEC
命令来执行事务了:
- EXEC
当事务成功执行时,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 命令的执行结果
其他信息
属性 | 值 |
---|---|
复杂度 | 事务包含的所有命令的复杂度之和。 |
版本要求 | EXEC 命令从 Redis 1.2.0 版本开始可用。 |
DISCARD:放弃事务
如果用户在开启事务之后,不想要执行事务而是想要放弃事务,那么只需要执行以下命令即可:
- DISCARD
DISCARD
命令会清空事务队列中已有的所有命令,并让客户端退出事务模式,最后返回 OK
表示事务已被取消。
以下代码展示了一个使用 DISCARD
命令放弃事务的例子:
- redis> MULTI
- OK
- redis> SET page_counter 10086
- QUEUED
- redis> SET download_counter 12345
- QUEUED
- redis> DISCARD
- OK
其他信息
属性 | 值 |
---|---|
复杂度 | 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):当事务执行完毕时,它的结果将被储存在硬盘里面,即使服务器在此之后停机,事务对数据库所做的修改也不会丢失。
稍后的《持久化》一章将对事务的耐久性做补充说明。
事务对服务器的影响
因为事务在执行时会独占服务器,所以用户应该避免在事务里面执行过多命令,更不要将一些需要大量计算的命令放入到事务里面,以免造成服务器阻塞。
流水线与事务
正如前面所言,流水线与事务虽然在概念上有些相似,但是在作用上却并不相同:流水线的作用是将多个命令打包然后一并发送至服务器,而事务的作用则是将多个命令打包然后让服务器一并执行它们。
因为 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]
在执行 transaction.execute()
调用时,redis-py 将通过流水线向服务器发送以下命令:
- MULTI
- SET title "Hand in Hand"
- SADD fruits "apple" "banana" "cherry"
- RPUSH numbers "123" "456" "789"
- EXEC
这样的话,无论事务包含了多少个命令,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]
在执行 pipe.execute()
调用时,redis-py 将通过流水线向服务器发送以下命令:
- SET download_counter 10086
- GET download_counter
- HSET user::123::profile "name" "peter"
因为这三个命令并没有被事务包裹,所以客户端只保证它们会一并被发送至服务器,至于这些命令在何时会以何种方式执行则由服务器本身决定。