谈Redis的事务和ACID的关系

Redis Transaction with ACID

Posted by LiuShuo on December 2, 2018

Redis通过MULTI、DISCARD、EXEC和WATCH四个命令来实现事务功能。但是它提供的事务和MySQL提供的事务是完全不一样的,前者仅支持CI并不支持AD。

事务执行过程

事务提供了一种「将多个命令打包, 然后一次性、按顺序地执行」的机制, 并且事务在执行的期间不会主动中断 —— 服务器在执行完事务中的所有命令之后, 才会继续处理其他客户端的其他命令。 一个事务从开始到执行会经历以下三个阶段:

  • 开始事务,通过MULTI命令打开客户端的事务支持,Redis服务器返回OK
  • 命令入队,所有的命令都是「单独发送」给Redis服务器,并返回Queued表示该命令已经入队。内部采用一个数组进行维护索引,内部的元素包括cmd(命令名字)、argv (数组)、argc(argv 元素的个数),其中key和具体的value都包含在argv中,argc记录了argv中的元素的个数。
  • 执行事务,当处于事务开启状态的客户端发送EXEC命令时,会将其内部保存的数据的元素按照FIFO 的方式出队,逐个执行。然后「清空客户端的事务状态」、「清空对应的数组」并将每个执行的结果放入一个队列中统一方会给客户端。 DISCARD命令用于取消一个事务,它清空客户端的整个事务队列,然后将客户端从事务状态调整回非事务状态,最后返回字符串OK给客户端,说明事务已被取消。

带WATCH的事务

WATCH命令用于在「事务开始之前」监视任意数量的键:当调用EXEC命令执行事务时,如果任意一个被监视的键已经被其他客户端修改了,那么整个事务不再执行,直接返回失败。 Redis内部通过维护一个watched_keys字典来记录所有被WATCH的key,value对应的一个是单链表,链表的值为客户端的信息。我们可以通过遍历单链表来获取某个在被WATCH字典里的key的所有关注者。 这些关注者一旦记录了自己关注的key之后,在该key被写命令修改后都会被通知到(内部通过判断该key是否在watched_keys字典中,如果在则逐个将链表中的客户端的设置中REDIS_DIRTY_CAS打开)。

当客户端发送EXEC命令、触发事务执行时,服务器会对客户端的状态进行检查:

  • 如果客户端的REDIS_DIRTY_CAS 选项已经被打开,那么说明被客户端监视的键「至少有一个」已经被修改了,事务的安全性已经被破坏。服务器会放弃执行这个事务,直接向客户端返回空回复,表示事务执行失败。
  • 如果REDIS_DIRTY_CAS选项没有被打开,那么说明所有监视键都安全,服务器正式执行事务。

一定要注意,因为命令是一个一个发到Redis服务器的,所以Redis记录这些操作到队列里和watched_keys字典中是在发送EXEC命令之前的,也就是说,虽然Redis 是串行执行事务内容,并且执行的时候不会被其他操作打断,但是由于执行和声明WATCH是有时间差的,导致整个时间差内会有被其他客户端修改被WATCH的key的可能的。

事务和非事务的执行方式的异同

执行方式

非事务状态下的命令以单个命令为单位执行,前一个命令和后一个命令的客户端不一定是同一个; 而事务状态则是以一个事务为单位,执行事务队列中的所有命令:除非当前事务执行完毕,否则服务器不会中断事务,也不会执行其他客户端的其他命令。

结果返回方式

在非事务状态下,执行命令所得的结果会立即被返回给客户端; 而事务则是将所有命令的结果集合到回复队列,再作为EXEC命令的结果返回给客户端。

事务状态下的非法命令

MULTI

Redis的事务是不可嵌套的,当客户端已经处于事务状态,而客户端又再向服务器发送MULTI时,服务器只是简单地向客户端发送一个错误,然后继续等待其他命令的入队。MULTI 命令的发送不会造成整个事务失败,也不会修改事务队列中已有的数据。

WATCH

WATCH只能在客户端进入事务状态之前执行,在事务状态下发送WATCH命令会引发一个错误,但它不会造成整个事务失败,也不会修改事务队列中已有的数据(和前面处理MULTI的情况一样)。

事务的ACID性质

Atomicity

虽然Redis的单个命令是原子的,并且也保证事务内的操作是顺序执行、不会被干扰,但是它并没有在事务上增加原子的特性。也就是说,如果如发生Redis进程被kill 或者宿主机器宕机等情况,已经执行完的操作是不会被回滚或者重试的,即Redis没有自动的机制通过启动后校验来完成脏数据的处理。

Consistency

入队错误

类似于MySQL中的「语法错误」,一定不会执行成功,事务也会失败。 在命令入队的过程中,如果客户端向服务器发送了错误的命令,比如命令的参数数量不对,等等,那么服务器将向客户端返回一个出错信息(服务器可以做相应的语法校验),并且将客户端的事务状态设为REDIS_DIRTY_EXEC。 当客户端执行EXEC命令时,Redis会拒绝执行状态为REDIS_DIRTY_EXEC的事务,并返回失败信息。 因此,带有不正确语法的命令入队的事务是不会被执行的,所以不会影响数据库的一致性。

执行错误

这跟MySQL的事务是不一样的,在MySQL的事务中,如果Constraint不正确(如外检、唯一索引冲突等)会直接回滚整个事务,而Redis会继续其他命令。

注意这并不是说Redis没有相应的Constraint校验,比如在使用了WATCH命令的事务时就是「Constraint 校验」的,只不过它的机制并不是在执行事务过程中触发的,而是服务器提前打开了客户端的REDIS_DIRTY_CAS属性,导致客户端提交EXEC命令时直接报错。

如果命令在事务执行的过程中发生错误,比如说,「对一个不同类型的key执行了错误的操作」,e.g. hset stringkey1 value1,Redis 只会将错误包含在事务的结果中,这不会引起事务中断或整个失败(服务器不会在命令入队时判断key的类型是否可以做对应的Op操作 ),不会影响已执行事务命令的结果,也不会影响后面要执行的事务中的命令,所以它对事务的一致性也没有影响。

Redis进程被终结

如果Redis服务器进程在执行事务的过程中被其他进程终结,或者被管理员强制kill,那么根据Redis所使用的持久化模式,可能有以下情况出现:

  • 内存模式:如果Redis没有采取任何持久化机制,那么重启之后的数据库总是空白的,所以数据总是一致的。
  • RDB模式:在执行事务时,「Redis不会中断事务去执行保存RDB的工作」,只有在事务执行之后,保存RDB的工作才有可能开始。所以当RDB模式下的Redis 服务器进程在事务中途被杀死时,事务内执行的命令,不管成功了多少,都「不会」被保存到RDB文件里。恢复数据库需要使用现有的RDB文件,而这个RDB 文件的数据保存的是最近一次的数据库快照(snapshot),所以它的数据「可能不是最新的」,但只要RDB文件本身没有因为其他问题而出错,那么还原后的数据库就是一致的。
  • AOF模式:因为保存AOF文件的工作在「后台线程」进行,所以即使是在事务执行的中途,保存AOF文件的工作也可以继续进行,因此,根据事务语句是否被写入并保存到AOF文件,有以下两种情况发生:
    • 1)如果事务语句未写入到AOF文件,或AOF未被SYNC调用保存到磁盘,那么当进程被杀死之后,Redis可以根据最近一次成功保存到磁盘的AOF文件来还原数据库,只要AOF文件本身没有因为其他问题而出错,那么还原后的数据库总是一致的,但其中的数据不一定是最新的。
    • 2)如果事务的部分语句被写入到AOF文件,并且AOF文件被成功保存,那么不完整的事务执行信息就会遗留在AOF文件里,当重启Redis时,程序会检测到AOF 文件并「不完整」,Redis会退出,并报告错误。需要使用redis-check-aof 工具将部分成功的事务命令移除之后,才能再次启动服务器。还原之后的数据总是一致的,而且数据也是最新的(直到事务执行之前为止)。

Isolation

Redis是单进程程序,并且它保证在执行事务时,不会对事务的执行进行中断处理去执行其他事务,所以事务可以运行直到执行完事务队列中的所有命令为止。因此,Redis的事务是总是带有隔离性的。

Durability

因为事务不过是用队列包裹起了一组Redis命令,并没有提供任何额外的持久性功能,所以事务的持久性由Redis所使用的持久化模式决定:

  • 在单纯的内存模式下,事务肯定是不持久的。
  • RDB模式下,服务器可能在事务执行之后、RDB文件更新之前的这段时间失败(宕机等),所以RDB模式下的Redis事务也是不持久的。
  • AOF的“Always SYNC”模式下,事务的每条命令在执行成功之后,都会立即调用fsyncfdatasync将事务数据写入到AOF 文件。但是,这种保存是由后台线程进行的,主线程不会阻塞直到保存成功,所以从命令执行成功到数据保存到硬盘之间,还是有一段非常小的间隔,所以这种模式下的事务也是不持久的。
  • 其他AOF模式也和“Always SYNC”模式类似,所以它们都是不持久的。

因为Redis的操作是在内存中的,所以无论怎样,都不会是持久化的,因为持久化都需要在磁盘这种永久的存储介质中完成,而Redis中的「持久化」操作是异步的,故并不能保证ACID 中的Durability特性。

Pipeline

管道,指的是客户端允许将多个请求一次性发给服务器,过程中不需要等待请求的回复,最后再一并读取结果即可。

  • 客户端首先将执行的命令写入到本地缓冲区,最后再一次性发送Redis。但缓冲区的大小是有限制的,超过了,则flush缓冲区,发送到Redis,但不立即处理Redis的响应, 最后才处理Redis的应答。
  • 服务端需要能够处理一个客户端通过同一个TCP连接发来的多个命令。如果响应填满了接收缓冲区,那么客户端会通过ACK来控制服务端不能再发送数据。所以需要注意控制Pipeline的大小。 Pipeline减少了RTT(Round Trip Time),也减少了IO调用次数; 需要控制Pipeline的大小,否则会消耗双端的内存:
  • 从客户端角度角度,Pipeline数据不能过大,客户端需要缓存命令和响应结果。
  • 从服务端角度,因为redis必须在处理完所有命令前先缓存起所有命令的处理结果。所以打包的命令越多,缓存消耗内存也越多,特别是同时处理多个客户端发来的Pipeline请求。

Pipeline无法提供原子性/事务保障:Redis只能保证所有pipeline中的命令是串行执行的,并且中间可以穿插其他客户端发送过来的命令,所以也就无法保证原子性/事务特征。

本文首次发布于 LiuShuo’s Blog, 转载请保留原文链接.