Redis事务的实现
程序员文章站
2022-05-28 12:09:53
...
Redis 事务是通过 MULTI、EXEC、WATCH 等命令来实现的。本节接下来会对这些命令的实现细节进行一一展开。
一个 Redis 事务从开始到结束通常会经历以下三个阶段:
1)事务开始。MULTI 命令的执行标志着事务的开始。执行该命令的客户端会从非事务状态切换至事务状态,这一切换是通过打开客户端状态的 flags 属性中的 REDIS_MULTI 标识来完成的。
2)命令入队。当一个客户端切换到事务状态后,如果客户端发送的命令为 EXEC、DISCARD、WATCH 和 MULTI 四个命令中的其中一个,那么服务器会立即执行这个命令;否则,服务器将之放入一个事务队列里面,然后向客户端返回 QUEUED 回复。
3)事务执行。当一个处于事务状态的客户端向服务器发送 EXEC 命令时,服务器就会执行该客户端事务队列中的所有命令,最后再将执行结果按顺序全部返回给客户端。此外,它还会清除客户端的 REDIS_MULTI 标识,让其回到非事务状态,同时清空事务队列数据。
事务队列
每个 Redis 客户端都将自己的事务状态保存在 redisClient.mstate 属性里面:
事务队列中的每个 multiCmd 结构都保存了一个已入队命令的相关信息,包括指向命令实现函数的指针、命令的参数,以及参数的数量。
WATCH 命令的实现
WATCH 命令是一个乐观锁(optimistic locking),它可以在事务执行前监视任意数量的数据库键,并在事务执行时检查被监视的键是否至少有一个已经被修改过了,如果是的话,服务器将拒绝执行事务,并向客户端返回事务执行失败的空回复,如下代码片段所示:
每个 Redis 数据库都保存着一个 watched_keys 字典,它的键是被 WATCH 命令监视的数据库键,而值则是一个记录了所有监视该数据库键的客户端链表。通过这个字典,服务器就可以清楚地知道哪些数据库键正在被监视,以及对应的监视客户端。
所有对数据库进行修改的命令(比如 SET、LPUSH 等),在执行后都会检查 watched_keys 字典,如果发现有客户端正在监视刚刚修改过的数据库键,则会打开该客户端的 REDIS_DIRTY_CAS 标识,表示该客户端的事务安全性已经被破坏。当服务器收到客户端发来的 EXEC 命令时,它就会根据该客户端是否打开了 REDIS_DIRTY_CAS 标识来决定是否执行事务。
Redis 事务的 ACID 性质
在传统的关系型数据库中,常常用 ACID 性质来检验事务功能的可靠性和安全性。在 Redis 中,事务总是具有原子性(Atomicity)、一致性(Consistency)和隔离性(Isolation),并且当 Redis 运行在某种特定的持久化模式下时,事务也具有耐久性(Durability)。
Redis 的事务和传统的关系型数据库事务的最大区别在于,Redis 不支持事务回滚机制,即使事务队列中的某个命令在执行期间出现了错误,整个事务也会继续执行下去,直到将其中的所有命令都执行完毕为止。
事务具有一致性指的是,如果数据库在执行事务之前是一致的,那么在事务执行之后,无论是否执行成功,数据库也应该仍然是一致的。这里的“一致”的意思是数据符合数据库本身的定义和要求,没有包含非法或者无效的错误数据。
Redis 通过谨慎的错误检测和简单的设计来保证事务的一致性。Redis 事务中可能出错的地方主要有以下三处:
1)入队错误。如果一个事务在入队命令的过程中,出现了命令不存在,或者命令的格式不正确等情况,则最终 Redis 将拒绝执行这个事务(这种情况下,Redis 2.6.5 以前的版本依然会执行事务队列中那些正确的命令)。
2)执行错误。事务在执行过程中发生的错误都是一些不能在入队时发现的错误(比如将一种类型的键当成另一种类型的键来操作),这些错误只能在命令实际执行时触发。不过如上所述,即使事务在执行过程中发生了错误,服务器也不会中断事务的执行,而是会继续执行余下的其他命令。
3)服务器停机。如果 Redis 服务器在执行事务的过程中停机,那么不管服务器是否使用了持久化模式,都不会影响数据库的一致性。因为如果服务器运行在无持久化的内存模式下,则重启后的数据库将是空白的,而空白数据库总是一致的。反之,如果服务器运行在 RDB 或者 AOF 模式下,则服务器重启时可以根据现有的 RDB 文件或者 AOF 文件来恢复数据,从而将数据库还原到一个一致的状态。如果找不到可供使用的 RDB 文件或者 AOF 文件,重启后的数据库就会是空白的,所以依然是一致的。
事务的隔离性指的是,数据库中即使有多个事务并发执行,相互之间也不会影响,并且在并发状态下执行的事务和串行执行的事务产生的结果完全相同。因为 Redis 使用单线程的方式来执行事务,并且服务器保证,在事务执行期间不会被中断。因此,Redis 的事务总是以串行的方式运行的,自然总是具有隔离性。
关于事务的耐久性,则是指当一个事务执行完毕时,所得的结果已经被保存到永久性存储介质(如硬盘)里面了,即使服务器在事务执行完毕后停机,执行事务所得的结果也不会丢失。因为 Redis 的事务只是简单地用队列包裹了一组 Redis 命令,并没有为事务提供任何额外的持久化功能,所以 Redis 事务的耐久性由 Redis 所使用的持久化模式决定。不过即使使用了持久化模式,也只有服务器运行在 AOF 持久化模式,并且 appendfsync 选项的值为 always 时,Redis 事务才具有耐久性。因为这种情况下,程序总会在执行命令后调用同步(sync)函数,将命令数据真正地保存到硬盘中(然而当打开了 no-appendfsync-on-rewrite 配置选项时,在执行 BGSAVE 或者 BGREWRITEAOF 命令期间,服务器会暂时停止同步 AOF 文件,以尽可能地减少 I/O 阻塞。这样一来,事务结果也就可能丢失,从而也就不具有耐久性了)。而当服务器运行在 RDB 持久化模式,或者运行在 AOF 持久化模式,但 appendfsync 选项的值不为 always 时,服务器都需要在特定的保存条件满足后才会执行 BGSAVE 命令或者执行同步,这些都不能保证事务数据在第一时间保存到硬盘里面,所以可能会造成事务数据丢失,因而都不具有耐久性。不过不论在什么模式下运行,在一个事务的最后(即执行 EXEC 命令之前)加上 SAVE 命令都总可以保证事务的耐久性,但因为这种做法效率太低,所以不太具有实用性。
参考书籍:
1、《Redis设计与实现》第 19 章——事务。
一个 Redis 事务从开始到结束通常会经历以下三个阶段:
1)事务开始。MULTI 命令的执行标志着事务的开始。执行该命令的客户端会从非事务状态切换至事务状态,这一切换是通过打开客户端状态的 flags 属性中的 REDIS_MULTI 标识来完成的。
2)命令入队。当一个客户端切换到事务状态后,如果客户端发送的命令为 EXEC、DISCARD、WATCH 和 MULTI 四个命令中的其中一个,那么服务器会立即执行这个命令;否则,服务器将之放入一个事务队列里面,然后向客户端返回 QUEUED 回复。
3)事务执行。当一个处于事务状态的客户端向服务器发送 EXEC 命令时,服务器就会执行该客户端事务队列中的所有命令,最后再将执行结果按顺序全部返回给客户端。此外,它还会清除客户端的 REDIS_MULTI 标识,让其回到非事务状态,同时清空事务队列数据。
事务队列
每个 Redis 客户端都将自己的事务状态保存在 redisClient.mstate 属性里面:
typedef struct redisClient{ multiState mstate; // MULTI/EXEC state /* other fields ...*/ } redisClient; typedef struct multiState{ multiCmd *commands; // 事务队列,FIFO 顺序 int count; // 已入队命令计数 } multiState; typedef struct multiCmd{ robj **argv; // 参数 int argc; // 参数数量,包含命令名 struct redisCommand *cmd; // 命令指针 } multiCmd;
事务队列中的每个 multiCmd 结构都保存了一个已入队命令的相关信息,包括指向命令实现函数的指针、命令的参数,以及参数的数量。
WATCH 命令的实现
WATCH 命令是一个乐观锁(optimistic locking),它可以在事务执行前监视任意数量的数据库键,并在事务执行时检查被监视的键是否至少有一个已经被修改过了,如果是的话,服务器将拒绝执行事务,并向客户端返回事务执行失败的空回复,如下代码片段所示:
redis> WATCH "name" OK redis> SET "name" "john" # 模拟其他客户端修改了这个 name 键 OK redis> MULTI OK redis> SET "name" "peter" QUEUED redis> EXEC (nil) redis> GET "name" "john"
每个 Redis 数据库都保存着一个 watched_keys 字典,它的键是被 WATCH 命令监视的数据库键,而值则是一个记录了所有监视该数据库键的客户端链表。通过这个字典,服务器就可以清楚地知道哪些数据库键正在被监视,以及对应的监视客户端。
typedef struct redisDb{ dict *watched_keys; // 正在被 WATCH 命令监视的键 /* other fields ...*/ } redisDb;
所有对数据库进行修改的命令(比如 SET、LPUSH 等),在执行后都会检查 watched_keys 字典,如果发现有客户端正在监视刚刚修改过的数据库键,则会打开该客户端的 REDIS_DIRTY_CAS 标识,表示该客户端的事务安全性已经被破坏。当服务器收到客户端发来的 EXEC 命令时,它就会根据该客户端是否打开了 REDIS_DIRTY_CAS 标识来决定是否执行事务。
Redis 事务的 ACID 性质
在传统的关系型数据库中,常常用 ACID 性质来检验事务功能的可靠性和安全性。在 Redis 中,事务总是具有原子性(Atomicity)、一致性(Consistency)和隔离性(Isolation),并且当 Redis 运行在某种特定的持久化模式下时,事务也具有耐久性(Durability)。
Redis 的事务和传统的关系型数据库事务的最大区别在于,Redis 不支持事务回滚机制,即使事务队列中的某个命令在执行期间出现了错误,整个事务也会继续执行下去,直到将其中的所有命令都执行完毕为止。
事务具有一致性指的是,如果数据库在执行事务之前是一致的,那么在事务执行之后,无论是否执行成功,数据库也应该仍然是一致的。这里的“一致”的意思是数据符合数据库本身的定义和要求,没有包含非法或者无效的错误数据。
Redis 通过谨慎的错误检测和简单的设计来保证事务的一致性。Redis 事务中可能出错的地方主要有以下三处:
1)入队错误。如果一个事务在入队命令的过程中,出现了命令不存在,或者命令的格式不正确等情况,则最终 Redis 将拒绝执行这个事务(这种情况下,Redis 2.6.5 以前的版本依然会执行事务队列中那些正确的命令)。
2)执行错误。事务在执行过程中发生的错误都是一些不能在入队时发现的错误(比如将一种类型的键当成另一种类型的键来操作),这些错误只能在命令实际执行时触发。不过如上所述,即使事务在执行过程中发生了错误,服务器也不会中断事务的执行,而是会继续执行余下的其他命令。
3)服务器停机。如果 Redis 服务器在执行事务的过程中停机,那么不管服务器是否使用了持久化模式,都不会影响数据库的一致性。因为如果服务器运行在无持久化的内存模式下,则重启后的数据库将是空白的,而空白数据库总是一致的。反之,如果服务器运行在 RDB 或者 AOF 模式下,则服务器重启时可以根据现有的 RDB 文件或者 AOF 文件来恢复数据,从而将数据库还原到一个一致的状态。如果找不到可供使用的 RDB 文件或者 AOF 文件,重启后的数据库就会是空白的,所以依然是一致的。
事务的隔离性指的是,数据库中即使有多个事务并发执行,相互之间也不会影响,并且在并发状态下执行的事务和串行执行的事务产生的结果完全相同。因为 Redis 使用单线程的方式来执行事务,并且服务器保证,在事务执行期间不会被中断。因此,Redis 的事务总是以串行的方式运行的,自然总是具有隔离性。
关于事务的耐久性,则是指当一个事务执行完毕时,所得的结果已经被保存到永久性存储介质(如硬盘)里面了,即使服务器在事务执行完毕后停机,执行事务所得的结果也不会丢失。因为 Redis 的事务只是简单地用队列包裹了一组 Redis 命令,并没有为事务提供任何额外的持久化功能,所以 Redis 事务的耐久性由 Redis 所使用的持久化模式决定。不过即使使用了持久化模式,也只有服务器运行在 AOF 持久化模式,并且 appendfsync 选项的值为 always 时,Redis 事务才具有耐久性。因为这种情况下,程序总会在执行命令后调用同步(sync)函数,将命令数据真正地保存到硬盘中(然而当打开了 no-appendfsync-on-rewrite 配置选项时,在执行 BGSAVE 或者 BGREWRITEAOF 命令期间,服务器会暂时停止同步 AOF 文件,以尽可能地减少 I/O 阻塞。这样一来,事务结果也就可能丢失,从而也就不具有耐久性了)。而当服务器运行在 RDB 持久化模式,或者运行在 AOF 持久化模式,但 appendfsync 选项的值不为 always 时,服务器都需要在特定的保存条件满足后才会执行 BGSAVE 命令或者执行同步,这些都不能保证事务数据在第一时间保存到硬盘里面,所以可能会造成事务数据丢失,因而都不具有耐久性。不过不论在什么模式下运行,在一个事务的最后(即执行 EXEC 命令之前)加上 SAVE 命令都总可以保证事务的耐久性,但因为这种做法效率太低,所以不太具有实用性。
参考书籍:
1、《Redis设计与实现》第 19 章——事务。
推荐阅读