Redis 事务学习笔记

Redis 为了支持事务,提供了 5 个相关的命令,他们分别是 MULTI,EXEC, WATCH,UNWATCH 和 DISCARD。我们先介绍 MULTI 和 EXEC 的用法,MULTI 和 EXEC 支持了 Redis 的基本事务的用法。接下来介绍 WATCH,UNWATCH 和 DISCARD,这3个命令则支持更高级的 Redis 事务的用法。

事务允许一次执行多个命令,并且带有以下两个重要的保证:

  • 事务是一个单独的隔离的操作:事务中的所有命令会按顺序执行,事务在执行过程中,不会被其他客户端发来的命令请求所打断
  • 事务是一个原子的操作:事务中的命令要么全部被执行,要么全部都不执行

Redis 的基本事务

Redis 的基本事务需要用到 MULTI 和 EXEC 命令,这种事务可以让一个客户端在不被其他客户端打断的情况下执行多个命令。被 MULTI 和 EXEC 命令包裹的所有命令会一个接一个执行,直到所有命令都执行完毕,当一个事务执行完毕之后,Redis 才会处理其他客户端的命令。

MULTI 命令用于开启一个事务,它总是返回 OK。
MULTI 执行之后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个队列中,当 EXEC 命令被调用时,队列中的所有命令才会被执行。
以下是一个基本事务的例子,它原子地增加了 foo 和 bar 两个健的值:

redis 127.0.0.1:6379> MULTI
OK
redis 127.0.0.1:6379> INCR foo
QUEUED
redis 127.0.0.1:6379> INCR bar
QUEUED
redis 127.0.0.1:6379> EXEC

  1. (integer) 1
  2. (integer) 1

EXEC 命令的返回是一个数组,数组中的每个元素都是执行事务中命令所产生的回复,且返回回复的先后顺序与命令发送的先后顺序一致。
当客户端处于事务状态时,所有传入的命令都会返回一个内容为 QUEUED 的状态回复(status reply),这些被入队的命令将在 EXEC 命令被调用时执行。

上面的情况是,客户端的每个命令都会发送到 Redis,Redis 对于每个命令都返回结果给客户端。为了减少 Redis 与客户端之间的通信往返次数,提升执行多个命令时的性能,也可以在客户端先存储起事务包含的多个命令,然后在事务执行时一次性地将所有命令都发送给 Redis。Redis 的 Python 客户端 redis-py 正是这样实现的。

Redis-py 实现事务

我们以 redis-py 为例,说明如何实现 Redis 事务。
Redis-py 提供了Pipeline对象来实现事务,对 Redis 客户端对象(StrictRedis或者Redis对象)调用pipeline()方法将返回事务型流水线对象PipelinePipeline对象会自动地使用 MULTI 和 EXEC 包裹起用户输入的多个命令,在事务执行时一次性将所有命令发送给 Redis 服务端执行。
Pipeline对象还提供非事务型流水线的功能,只需要transaction参数设置为False即可。
根据 redis-py 官方文档的说明,Pipeline对象不是线程安全的,不应该在多线程环境中使用同一个Pipeline对象。
Pipeline的使用比较简单,以下是一个简单的例子,更详细的说明可以参考 redis-py 的官方文档。

1
2
3
4
5
6
7
8
9
10
11
12
13
import redis

r = redis.Redis(host='127.0.0.1', port=6379)
r.set('lee', 'leo')
# 使用 pipeline() 方法创建一个事务型流水线对象 Pipeline
pipe = r.pipeline()
# 以下的 SET 和 GET 命令会被在客户端缓存起来
pipe.set('foo', 'bar')
pipe.get('lee')
# execute() 会将上面缓存起来的所有命令被发送到服务端,
# 并返回所有命令回复的列表
res = pipe.execute()
print(res)

输出为上述两个命令回复的列表:

[True, ‘leo’]

更高级的 Redis 事务操作

在执行 EXEC 命令之前,Redis 不会执行任何实际的操作,所以用户没办法根据读取到的数据来做决定。Redis 提供了 WATCH 和 UNWATCH 命令来实现更高级的 Redis 事务操作。

使用 WATCH 实现乐观锁

WATCH 命令可以为 Redis 事务提供 check-and-set (CAS)行为。被 WATCH 的键会被监视,并且 Redis 会发觉这些键是否被改动过。在用户使用 WATCH 命令对键进行监视之后,直到用户执行 EXEC 命令的这段时间里面,如果有其他客户端抢先对任何被监视的键进行了替换,更新或者删除操作,那么当用户尝试执行 EXEC 命令的时候,事务将失败并返回一个错误(之后用户可以选择重试事务或者放弃事务)。
通过使用 WATCH、MULTI/EXEC、UNWATCH/DISCARD 等命令,程序可以在执行某些重要操作的时候,通过确保自己正在使用的数据没有发生变化来避免数据出错。
为了说明 WATCH 的作用,假设我们不使用 Redis 的 INCR 命令而去实现原子性地为某个键的值增加 1 的操作。我们最先想到的方案可能如下所示:

1
2
3
4
5
6
7
8
9
import redis

r = redis.Redis(host='127.0.0.1', port=6379)
val = r.get('mykey')
if val:
val = int(val) + 1
else:
val = 1
r.set('mykey', val)

首先读出一个键的值,然后对这个值加 1,如果键不存在,则使用默认值 1。上面的实现在只有一个客户端的情况下可以执行得很好,但是,当多个客户端同时对同一个键进行这样的操作时,就会产生竞争条件。例如,如果客户端 A 和 B 都读取了键原来的值,比如 1,那个两个客户端都会将键设置为 2,但正确的结果应该是 3 才对。

接下来我们想到的方案是使用 MULTI 和 EXEC 封装 Redis 来实现对键增加 1 的原子性操作。但是由于增加 1 的操作依赖于前面读取键值命令的结果,所以单纯使用 MULTI 和 EXEC 也没法实现我们想要的结果。

有了 WATCH,我们就可以轻松地解决这类问题了,我们编写的 Python 的代码如下:

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
import redis

r = redis.Redis(host='127.0.0.1', port=6379)
with r.pipeline() as pipe:
while True:
try:
# 监视我们需要修改的键
res = pipe.watch('mykey')
# 由于调用了 WATCH 命令,接下来的 pipe 对象的命令都会立马
# 发送到 Redis 服务端执行,故我们可以正常拿到执行的结果
val = pipe.get('mykey')
print('val: %s' % val)
if val:
val = int(val) + 1
else:
val = 1
# 现在,我们重新将 pipe 对象设置为将命令包裹起来执行的形式
pipe.multi()
pipe.set('mykey', val)
# 最后,将包裹起来的命令一起发送到 Redis 服务端以事务形式执行
pipe.execute()
# 如果没抛 WatchError 异常,说明事务被成功执行
break
except redis.WatchError:
# 如果其他客户端在我们 WATCH 和 事务执行期间,则重试
continue

我们继续使用 redis-py 中的Pipeline对象来向 Redis 服务端发送命令。
读上面的代码大家可能有个疑问:Pipeline对象不是执行execute()时才一次性将所有命令发送到 Redis 服务端执行么,怎么在代码的中间就可以拿到命令执行的结果了?
说实话,一开始我也对上面的代码不理解。为了弄清楚个究竟,我们将上面的代码在 PyCharm 执行,利用 PyCharm 的断点功能,可以看到代码的详细执行流程。

我们来分析 redis-py 内部的实现代码。
pipe.watch('mykey')代码如下所示:

1
2
3
4
def watch(self, *names):
"Watches the values at keys ``names``"
...
return self.execute_command('WATCH', *names)

由于执行的是 WATCH 命令,接下来的execute_command()方法会调用immediate_execute_command()方法。进入immediate_execute_command()方法查看其代码,可以看到其对于每次调用的命令都会立马发送给 Redis 服务端执行,其获取返回结果。也就是说,通过pipe.watch()的调用,程序可以顺利拿到执行 Redis 命令中间结果的值。

1
2
3
4
5
def execute_command(self, *args, **kwargs):
if (self.watching or args[0] == 'WATCH') and \
not self.explicit_transaction:
return self.immediate_execute_command(*args, **kwargs)
return self.pipeline_execute_command(*args, **kwargs)

接下来调用pipe.multi()设置self.explicit_transaction为真:

1
2
3
4
5
6
7
def multi(self):
"""
Start a transactional block of the pipeline after WATCH commands
are issued. End the transactional block with `execute`.
"""
...
self.explicit_transaction = True

这样 redis-py 会重新使用 MULTI 和 EXEC 来包裹起接下来需要执行的命令,以达到命令以事务形式执行的目的。有兴趣的同学可以通过断点运行的方式来查看程序的接下来的执行过程,也可以结合 redis-py 对于事务型流水线对象Pipeline的介绍来理解,链接:官方文档:)
再回到原来前面我们所说的,如果键mykey在 EXEC 执行之前被修改了,那么整个事务就会被取消。如果事务被取消了,接着继续重试的过程,直到事务被正常执行。

取消监视 UNWATCH

UNWATCH 命令用来取消对所有键的监视。如果在执行 WATCH 命令之后,EXEC 命令已经执行了的话(无论事务是否成功执行),那么就不需要再执行 UNWATCH 了。这是因为当 EXEC 被调用时,Redis 对所有键的监视都会被取消。另外,由于 DISCARD 命令在取消事务的同时也会取消对所有对键的监视,所以 DISCARD 命令执行以后,也没必要执行 UNWATCH 了。

放弃事务 DISCARD

当执行 DISCARD 命令时,事务会被放弃,事务队列会被清空。例如:

redis 127.0.0.1:6379> SET foo 1
OK
redis 127.0.0.1:6379> MULTI
OK
redis 127.0.0.1:6379> INCR foo
QUEUED
redis 127.0.0.1:6379> DISCARD
OK
redis 127.0.0.1:6379> get foo
“1”

上面的例子中,DISCARD 命令执行后,事务被取消,前面的 INCR 并没有生效,’foo’键的值仍然为1。

参考资料

  1. http://redisdoc.com/topic/transaction.html
  2. https://www.tutorialspoint.com/redis/redis_transactions.htm
  3. Redis 实战,Josiah L. Carlson 著,黄健宏译,人民邮电出版社,2015年
  4. Redis-py的源码,https://github.com/andymccurdy/redis-py