Redis设计与实现-事务

214 阅读8分钟

事务

Redis 是通过 MULTI、EXEC、WATCH等命令来实现事务功能的。事务提供了一种将多个命令请求打包,然后一次性、按顺序的执行多个命令的机制,并且在事务执行期间,服务器不会中断事务而该去执行其他客户端的命令请求,它会将事务中 的所有命令执行完毕,才去处理其他客户端的命令请求。

事务的实现

事务开始到结束三个阶段:

  1. 事务开始

  2. 命令入队

  3. 事务执行

事务开始

MULTI 命令的执行标志着事务的开始,这个命令可以将执行该命令的客户端从非事务状态切换至事务状态(通过在客户端状态的flags 属性中打开 REDIS_MULTI 标识来完成),这个命令的伪代码如下:

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

命令入队

当一个客户端切换到事务状态之后,服务器会根据这个客户端发来的不同命令执行不同的操作:

  • 如果客户端发送的命令为 EXEC、DISCARD、WATCH、MULTI四个命令中的一个,那么服务器立即执行这个命令;

  • 否则,服务器将这个命令放入一个事务队列里面,然后向客户端返回 QUEUE 回复。

image.png

事务队列

每个Redis客户端都有自己的事务状态,这个事务状态保存在客户端状态的master属性里面:

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 命令将立即被服务器执行。服务器会遍历这个客户端的事务队列,执行队列中保存的所有命令,最后将执行命令所得的结果全部返回客户端。

EXEC 命令的实现伪代码:

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

使用 WATCH 命令监视数据库键

每个Redis数据库都保存着一个 watched_keys 字典,键是某个被监视的数据库键,值是链表(记录额所有监视相应数据库键的客户端):

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

通过这个字典,服务器可以清楚地知道哪个数据库键正在被监视,以及哪些客户端正在监视这些数据库键。

通过执行 WATCH 命令,客户端可以在 watched_keys 字典中与被监视的键进行关联。

监视机制的触发

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

touchWatchKey 函数的定义伪代码:

def toutchWatchKey(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 标识来决定是否执行事务:

  • 如果打开,则服务器会拒绝执行客户端提供的事务

  • 没有打开,执行事务

image.png

事务的ACID性质

用于检验事务功能的可靠性和安全性。

  1. 原子性

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

    与传统的关系型数据库事务支持的原子性不同,redis不支持 事务回滚机制,即使事务队列中的某个命令在执行期间出错,整个事务也会继续执行下去,直到事务队列中所有的命令都执行完毕

  2. 一致性

    一致性指的是,如果数据库在执行事务之前是一致的,那么在事务执行之后,无论事务是否执行成功,数据库也应该仍然是一致的。

    一致指的是数据符合数据库本身的定义和要求,没有包含非法或者无效的错误数据。

下面是Redis事务可能出错的地方:

  • 入队错误

    事务入队时,出现了命令不存在或者命令格式不正确等情况,Redis将拒绝执行这个事务。因为服务器会拒绝执行入队过程中出现错误的事务,所以Redis事务的一致性不会被带有入队错误的事务影响。

  • 执行错误

  • 服务器停机

  1. 隔离性

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

  2. 耐久性

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

    当服务器在无持久化的内存模式下,事务不具有持久性,一旦停机就丢失了.

    如果在RDB 持久化模式下,只有在特定的条件满足下才会进行存盘,因此也不具有耐久性

    当服务器在 AOF 持久化模式下,并且 appendfsync 值为 always 时,每一条数据都会执行同步函数,进行存盘,所以有持久性

    配置选项no-appendfsync-on-rewrite如果打开,即使AOF设置成always,事务也不具备耐久性【因为该选项打开过程中,BGSAVE或者BGREWRITEAOF执行时,服务器会暂停对AOF文件的同步,以减少I/O阻塞】

    当 appendfsync 为 everysec 时,程序会每秒同步一次命令数据到硬盘,所以可能会造成丢失,不具有持久性.

    当 appendfsync 为 no 时,只有当主动发出 SAVE 命令时,才会进行存盘,所以也不具有持久性.

    书中给出了一个方法,在每次事务完成后进行手动的命令存盘, SAVE 但是效率太低,但是我们可以在AOF 持久化模式下,对关键的事务进行主动存盘.

总结

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

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

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

  • watch:1)每个redis数据库都保存这一个watched_keys字典,这个字典的key是某个被watch命令监视的数据库key。2)所有对数据库进行修改的命令,比如:set、lpush、sadd等,在执行之后都会调用multi.c/touchWatchKey函数对watched_keys字典进行检测,看是否有客户端正在监视刚刚被修改过的数据库key,如果有的话,那么touchWatchKey函数会将监视被修改key的客户端的REDIS_DIRTY_CAS标识打开,表示该客户端的事务安全已经被破坏。

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

  • 上面提到的在RedisWATCH命令的实现是基于乐观锁,不是通常数据库实现乐观锁的一般方法:检测版本号,而是在执行完一个写命令后,会进行检查,检查是否是被WATCH监视的键