【Redis实现系列】事务功能实现

事务

实现原理

一个事务从开始到结束通常会经历以下三个阶段:

  • 事务开始。

  • 命令入队。

  • 事务执行。

事务开始

  • MULTI命令的执行标志着事务的开始:

    redis> MULTI
    OK
    
  • MULTI命令可以将执行该命令的客户端从非事务状态切换至事务状态,这一切换是通过在客户端状态的flags属性中打开REDIS_MULTI标识来完成的,MULTI命令的实现可以用以下伪代码来表示:

    def MULTI():
      # 打开事务标识
      client.flags |= REDIS_MULTI
      # 返回OK回复
      replyOK()
    

命令入队

  • 当一个客户端处于非事务状态时,这个客户端发送的命令会立即被服务器执行:

    redis> SET "name" "Practical Common Lisp"
    OK
    redis> GET "name"
    "Practical Common Lisp"
    redis> SET "author" "Peter Seibel"
    OK
    redis> GET "author"
    "Peter Seibel"
    
  • 与此不同的是,当一个客户端切换到事务状态之后,服务器会根据这个客户端发来的不同命令执行不同的操作:

    • 如果客户端发送的命令为EXEC、DISCARD、WATCH、MULTI四个命令的其中一个,那么服务器立即执行这个命令。
    • 与此相反,如果客户端发送的命令是EXEC、DISCARD、WATCH、MULTI四个命令以外的其他命令,那么服务器并不立即执行这个命令,而是将这个命令放入一个事务队列里面,然后向客户端返回QUEUED回复。
    redis> MULTI
    OK
    redis> SET "name" "Practical Common Lisp"
    QUEUED
    redis> GET "name"
    QUEUED
    redis> SET "author" "Peter Seibel"
    QUEUED
    redis> GET "author"
    QUEUED
    

    在这里插入图片描述

  • 事务队列

    • 每个Redis客户端都有自己的事务状态,这个事务状态保存在客户端状态的mstate属性里面:
    typedef struct redisClient {
      // ...
      // 事务状态
      multiState mstate;    /* MULTI/EXEC state */
      // ...
    } redisClient;
    

    事务状态包含一个事务队列,以及一个已入队命令的计数器(也可以说是事务队列的长度):

    typedef struct multiState {
      // 事务队列(数组),FIFO顺序
      multiCmd *commands;
      // 已入队命令计数
      int count; 
    } multiState;
    

    事务队列是一个multiCmd类型的数组,较先入队的命令会被放到数组的前面,而较后入队的命令则会被放到数组的后面。

    数组中的每个multiCmd结构都保存了一个已入队命令的相关信息,包括指向命令实现函数的指针、命令的参数,以及参数的数量:

    typedef struct multiCmd { 
      // 参数
      robj **argv;
      // 参数数量
      int argc;
      // 命令指针
      struct redisCommand *cmd;
    } multiCmd;
    

    在这里插入图片描述

执行事务

  • 当一个处于事务状态的客户端向服务器发送EXEC命令时,这个EXEC命令将立即被服务器执行。服务器会遍历这个客户端的事务队列,执行队列中保存的所有命令,最后将执行命令所得的结果全部返回给客户端。

    redis> EXEC
    1) OK
    2) "Practical Common Lisp"
    3) OK
    4) "Peter Seibel"
    
  • 伪代码描述:

    def EXEC():
        # 创建空白的回复队列
        reply_queue = []
        # 遍历事务队列中的每个项
    	# 读取命令的参数,参数的个数,以及要执行的命令
        for argv, argc, cmd in client.mstate.commands:
          # 执行命令,并取得命令的返回值
          reply = execute_command(cmd, argv, argc)
          # 将返回值追加到回复队列末尾
          reply_queue.append(reply)
        # 移除REDIS_MULTI标识,让客户端回到非事务状态
        client.flags & = ~REDIS_MULTI
        # 清空客户端的事务状态,包括:
        #1 )清零入队命令计数器
        #2 )释放事务队列
        client.mstate.count = 0
        release_transaction_queue(client.mstate.commands)
        # 将事务的执行结果返回给客户端
        send_reply_to_client(client, reply_queue)
    

WATCH事务原理

WATCH命令是一个乐观锁(optimistic locking),它可以在EXEC命令执行之前,监视任意数量的数据库键,并在EXEC命令执行时,检查被监视的键是否至少有一个已经被修改过了,如果是的话,服务器将拒绝执行事务,并向客户端返回代表事务执行失败的空回复。

  • 示例

    在这里插入图片描述

    clientA> WATCH "name" 
    OK
    clientA> MULTI
    OK
    clientA> SET "name" "peter"
    QUEUED
    clientA> EXEC
    (nil)
    

WATCH监视数据库键

  • 每个Redis数据库都保存着一个watched_keys字典

    typedef struct redisDb {
      // ...
      // 正在被WATCH命令监视的键
      dict *watched_keys;
      // ...
    } redisDb;
    

    watched_keys

    • 键是某个被WATCH命令监视的数据库键,
    • 值是一个链表,链表中记录了所有监视相应数据库键的客户端
  • 通过watched_keys字典,服务器可以清楚地知道哪些数据库键正在被监视,以及哪些客户端正在监视这些数据库键。

监视机制的触发

  • 所有对数据库进行修改的命令,比如SET、LPUSH、SADD、ZREM、DEL、FLUSHDB等等,在执行之后都会调用multi.c/touchWatchKey函数对watched_keys字典进行检查,查看是否有客户端正在监视刚刚被命令修改过的数据库键,如果有的话,那么touchWatchKey函数会将监视被修改键的客户端的REDIS_DIRTY_CAS标识打开,表示该客户端的事务安全性已经被破坏。

  • touchWatchKey函数伪代码描述:

    def touchWatchKey(db, key):
      # 如果键key存在于数据库的watched_keys字典中
      # 那么说明至少有一个客户端在监视这个key
      if key in db.watched_keys:
        # 遍历所有监视键key的客户端
        for client in db.watched_keys[key]:
          # 打开标识
          client.flags |= REDIS_DIRTY_CAS
    

判断事务是否安全

  • 当服务器接收到一个客户端发来的EXEC命令时,服务器会根据这个客户端是否打开了REDIS_DIRTY_CAS标识来决定是否执行事务:

    • 如果客户端的REDIS_DIRTY_CAS标识已经被打开,那么说明客户端所监视的键当中,至少有一个键已经被修改过了,在这种情况下,客户端提交的事务已经不再安全,所以服务器会拒绝执行客户端提交的事务。
    • 如果客户端的REDIS_DIRTY_CAS标识没有被打开,那么说明客户端监视的所有键都没有被修改过(或者客户端没有监视任何键),事务仍然是安全的,服务器将执行客户端提交的这个事务。

在这里插入图片描述

Redis的ACID性质

原子性

  • 事务具有原子性指的是,数据库将事务中的多个操作当作一个整体来执行,服务器要么就执行事务中的所有操作,要么就一个操作也不执行。

  • 对于Redis的事务功能来说,事务队列中的命令要么就全部都执行,要么就一个都不执行,因此,Redis的事务是具有原子性的。

    • 都是先保存到命令队列,等提交后再一起执行
    • 同时如果一个事务在入队命令的过程中,出现了命令不存在,或者命令的格式不正确等情况,那么Redis将拒绝执行整个事务。
    • Redis的事务和传统的关系型数据库事务的最大区别在于,Redis不支持事务回滚机制(rollback),即并非传统关系数据库使用 锁+mvcc,在执行到中途可以回滚,以此来保证原子性。
  • 补充说明:事务执行过程中出错,不管关系型数据库还是redis,都是如果事务已经提交,那么执行的 sql 即使有错误,仍然会继续执行整个事务,并不会回滚。

隔离性

  • 事务的隔离性指的是,即使数据库中有多个事务并发地执行,各个事务之间也不会互相影响,并且在并发状态下执行的事务和串行执行的事务产生的结果完全相同。

  • 因为Redis使用单线程的方式来执行事务(以及事务队列中的命令),并且服务器保证,在执行事务期间不会对事务进行中断,因此,Redis的事务总是以串行的方式运行的,并且事务也总是具有隔离性的。

耐久性

  • 事务的耐久性指的是,当一个事务执行完毕时,执行这个事务所得的结果已经被保存到永久性存储介质(比如硬盘)里面了,即使服务器在事务执行完毕之后停机,执行事务所得的结果也不会丢失。

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

    • 当服务器在无持久化的内存模式下运作时,事务不具有耐久性:一旦服务器停机,包括事务数据在内的所有服务器数据都将丢失。

    • 当服务器在RDB持久化模式下运作时,服务器只会在特定的保存条件被满足时,才会执行BGSAVE命令,对数据库进行保存操作,并且异步执行的BGSAVE不能保证事务数据被第一时间保存到硬盘里面,因此RDB持久化模式下的事务也不具有耐久性。

    • 当服务器运行在AOF持久化模式下,并且appendfsync选项的值为always时,程序总会在执行命令之后调用同步(sync)函数,将命令数据真正地保存到硬盘里面,因此这种配置下的事务是具有耐久性的。

    • 当服务器运行在AOF持久化模式下,并且appendfsync选项的值为everysec时,程序会每秒同步一次命令数据到硬盘。因为停机可能会恰好发生在等待同步的那一秒钟之内,这可能会造成事务数据丢失,所以这种配置下的事务不具有耐久性。

    • 当服务器运行在AOF持久化模式下,并且appendfsync选项的值为no时,程序会交由操作系统来决定何时将命令数据同步到硬盘。因为事务数据可能在等待同步的过程中丢失,所以这种配置下的事务不具有耐久性。

一致性

  • 事务具有一致性指的是,如果数据库在执行事务之前是一致的,那么在事务执行之后,无论事务是否执行成功,数据库也应该仍然是一致的。
    • ACID里的AID都是数据库的特征,也就是依赖数据库的具体实现。而唯独这个C,实际上它依赖于应用层,也就是依赖于开发者。
    • 这里的一致性是指系统从一个正确的状态,迁移到另一个正确的状态。正确的状态就是当前的状态满足预定的约束就叫做正确的状态
    • 应用层面的一致性,就是通过前面说的原子性、隔离性和持久性来保证的。

小结

事务提供了一种将多个命令打包,然后一次性、有序地执行的机制。

  • 多个命令会被入队到事务队列中,然后按先进先出(FIFO)的顺序执行。

  • 事务在执行过程中不会被中断,当事务队列中的所有命令都被执行完毕之后,事务才会结束。

带有WATCH命令的事务会将客户端和被监视的键在数据库的watched_keys字典中进行关联,当键被修改时,程序会将所有监视被修改键的客户端的REDIS_DIRTY_CAS标志打开。

  • 只有在客户端的REDIS_DIRTY_CAS标志未被打开时,服务器才会执行客户端提交的事务,否则的话,服务器将拒绝执行客户端提交的事务。

Redis的事务总是具有ACID中的原子性、一致性和隔离性,当服务器运行在AOF持久化模式下,并且appendfsync选项的值为always时,事务也具有耐久性。

已标记关键词 清除标记
相关推荐
©️2020 CSDN 皮肤主题: 撸撸猫 设计师:C马雯娟 返回首页