首先说的是这篇文章的知识点很初级,大神请绕路~之所以要这样一篇比较入门级的文章,是因为今天有个学弟问了个关于 Redis 的问题,让我感触颇深,突然感觉到,风水轮流转,哈哈。谁还没有被一些很二的问题卡住过。
Redis 事务里面有一步出错了,怎么才能回滚其他的操作啊!
如果有老鸟看到这里估计也笑了。不知道你们有没有,反正本人当年也遇到过相同的问题。
思考
肯定有很多人会说,这么入门的问题,随便看看文档就避免了。但是从这个问题中可以看出人们的一些惯性思维很有可能在开发中造成一些问题。正如这个问题一样,随便一个接触过数据库的人肯定都知道“事务”,虽然各种数据库的事务多多少少都有一些区别,但是大体的概念都是相同的,那么当你在其他的应用中再次听到“事务”的时候,很容易就将你已经了解的概念套用进去了,那么这时候很有可能就会造成问题了,比如上面我那个小学弟的问题,很容易把 Redis 事务理解成和 ACID 事务一样,所以我们应该养成一个多看文档的好习惯。
导读
本文包含 Redis 事务的使用、注意事项和原理这三个方面,大家可以跳过已经熟悉的部分,选择感兴趣的部分阅读。
Redis 事务,究竟怎么用 —— Redis 事务使用方法
Redis 事务,有啥不一样 —— Redis 事务注意事项
Redis 事务,到底是个啥 —— Redis 事务原理
Redis 事务,究竟怎么用
和其他数据库的事务类似,Redis 的事务执行也是分为三个步骤,第一步是启动事务,第二步是将要执行的命令加入队列(注意,这里是将命令加入到队列,并不是立即执行命令),第三步执行事务。下面展示的就是一个 Redis 事务的执行过程:
> MULTI // 启动事务OK> SET a 1 // 加入命令QUEUED> SET b 2QUEUED> EXEC // 执行事务1) OK2) OK
在这个事务中,通过 MULTI 命令启动事务,然后加入两个 set 命令到队列中,然后通过 EXEC 命令执行事务,我们可以得到两个 OK 表示队列中的两个命令的执行结果。
这是一个最简单的事务的执行过程,Redis 中共提供了五个和事务相关的操作命令,除了这个例子中我们使用到的 MULTI 和 EXEC,还有 DISCARD 取消事务,WATCH 监控key,UNWATCH 取消监控。那么这些命令分别在什么场景下使用呢?
我们先来说一说 WATCH 和 UNWATCH 命令,它的作用为监控一个或者多个 key 的值是否变化,如果在监控之间发生变化,那么事务则无法执行。UNWATCH 则为取消对所有 key 的监控。下面举例看下 WATCH 的使用:
> WATCH aOK> MULTIOK> SET a 1QUEUED> ............................. set a 2 // 在另外的线程中操作> ............................. OK > EXECnil
在这个例子中,在开启事务之前 WATCH 了 a 的值,然后在开启事务后,在另一个线程中设置 a 的值,然后返回事务后执行事务,结果为 nil,说明事务没有被执行,因为 a 的值在 WATCH 之后发生了变化,所以事务被取消了。这里要提一点,这里和开启事务的时间点没有关系,只要是在 WATCH 之后发生了变化,无论事务是否已经开启,执行事务的时候都会取消。而且执行 EXEC 和 DISCARD命令,都会默认执行 UNWATCH。
DISCARD 命令作用为取消事务,即将事务中已经入队列的命令移除,将 Redis 连接状态恢复为开启事务之前。同样通过一个例子来演示:
> MULTIOK> SET a 1QUEUED> SET b 2QUEUED> DISCARDOK
在这个例子中,开启事务后,入队两个 SET 命令,然后执行 DISCARD 取消事务,此时,a 和 b 的值都未改变。
Redis 事务,有啥不一样
看了上一节的内容,新接触 Redis 事务的同学肯定就会想,这不是和关系数据库的事务差不多吗?那么这一节就主要说说 Redis 事务的特(另)点(类)。
首先,先把 Redis 文档中介绍事务的两个特点贴出来:
- 事务中的所有命令会按照顺序执行,而且在执行过程中,不会有其他的命令插入,保证事务中的命令都是单独的操作。
- 事务中的所有命令要么全部执行,要么都不做处理。
看起来好像就是 ACID 事务中原子性,如果真的这么想你就上当了。那么 Redis 事务满足事务的原子性吗?看上面的两个特点,要么全部执行、要么全不执行,而且在执行中不会被其他命令插入,看起来好像是满足的!然而事实并非如此。我们先看一个例子:
> MULTIOK> SET a 1QUEUED> SET b aaaQUEUED> INCR bQUEUED> EXEC1) OK2) OK3) (error) ERR value is not an integer or out of range
我们直接看这个事务的执行结果,三条命令有两条执行成功返回 OK,第三条命令由于我们给一个非数值的对象做增加操作,所以报出了一个错误。那么这时候我们再执行 GET a 或者 GET b,发现这两个值已经被成功设置了。看到这里,大家可能就要问了,说好的原子性呢?不是说要么全部执行,要么全不执行吗?如果我告诉你人家确实都全部执行了,只不过有一条命令执行报错了而已!哈哈哈。
回到本文开头的问题,“Redis 事务里面有一步出错了,怎么才能回滚其他的操作啊!”,很遗憾,Redis 的事务没有回滚操作。但是这里我们谈一谈在特殊情况下类似回滚的操作。
上面事务中报错的例子,错误是在 EXEC 阶段产生的,这种类型的错误会导致报错的命令返回错误,而其他的命令正常执行。在 Redis 事务中还有一种错误,就是在 EXEC 之前产生的错误,比如命令名称错误,命令参数错误等等,这些错误都可以在 EXEC 之前检查出来,所以在发生这些错误的时候,事务会被取消,事务中的所有命令都不会被执行,这样看起来是不是就有点像回滚了。
另一种情况,在事务中执行 DISCARD 命令,也可以取消所有命令的执行。比如在检查业务逻辑的时候发现需要回滚,如果此时还没有执行 EXEC,那么执行 DISCARD 则会取消所有操作,又有点像回滚了。
另外,Redis 是支持 LUA 脚本的,而在执行脚本的时候也是事务性的,所以大家如果真的需要更为完善的事务操作,推荐使用 LUA 脚本去实现。
至于为什么 Redis 的事务不支持回滚操作呢?下面是 Redis 官方的说法(本人英语比较渣,翻译比较飞,介意的可以移步官网查看文档):
Redis 事务中只有在命令语法出现错误,或者执行的操作和数据类型不一致的时候才会导致错误,而这些错误都是在编程中人为产生的,是可以避免的。而且 Redis 简单和高效也导致 Redis 事务不适合支持回滚操作。
说到事务,还有个不得不提的概念就是事务隔离,在关系数据库中这个概念大家应该都了解,那么在 Redis 事务中,是否有事务隔离的概念呢?答案是,没有。那么,在使用 Redis 事务的时候会有事务隔离的需求吗?当然也会有,只不过不需要和关系数据库同样的复杂的事务隔离等级区分,因为 Redis 在执行命令时是单线程的(即使 Redis 6.0 增加多线程特性,大部分的数据操作的命令还是单线程的),而且事务中的命令都是在提交的时候一次性执行的,所以并不需要考虑在多线程并发的情况下事务隔离的情况。
那么我们就可以使用已有的命令来实现简单的类似事务隔离特性的功能,主要思路就是使用 WATCH 监控事务中需要操作的值,以保证事务操作前后所监控的值不发生变化,或者发生变化以后中断事务操作。
Redis 事务,到底是个啥
前面已经将 Redis 事务的使用和特性介绍完了,但是为了显得本文不是那么水,我决定拿出我价值5毛钱的C语言功底,来从源码的角度分析下 Redis 事务的原理。
Redis 的事务是由 MULTI 命令开始的,所以先来看看 MULTI 命令做了那些事情。
void multiCommand(client *c) { if (c->flags & CLIENT_MULTI) { addReplyError(c,"MULTI calls can not be nested"); return; } c->flags |= CLIENT_MULTI; addReply(c,shared.ok);}
在执行命令的时候,首先检查客户端连接的状态,如果事务已经启动,则提示错误,否则就将客户端连接状态置为 CLIENT_MULTI,表示事务已启动。
上面在介绍事务的使用的时候提到过,在开启事务以后执行数据操作命令的时候,该命令并不会立即执行,而是会添加到一个队列中,这里我们从代码中看看这部分的实现:
int processCommand(client *c) { ....省略代码.... if (c->flags & CLIENT_MULTI && c->cmd->proc != execCommand && c->cmd->proc != discardCommand && c->cmd->proc != multiCommand && c->cmd->proc != watchCommand) { queueMultiCommand(c); addReply(c,shared.queued); } else { call(c,CMD_CALL_FULL); c->woff = server.master_repl_offset; if (listLength(server.ready_keys)) handleClientsBlockedOnKeys(); } return C_OK;}
在执行命令的时候对客户端连接状态进行了检查,如果状态为 CLIENT_MULTI (事务已启动),并且执行的命令不是 EXEC, DISCARD, MULTI, WATCH 的时候,Redis 会将这条命令添加到事务队列中,然后返回 QUEUED 信息。
在添加完命令后,下一步则为 EXEC 执行事务,或者 DISCARD 取消事务,我们看下对应命令的代码:
void discardCommand(client *c) { if (!(c->flags & CLIENT_MULTI)) { addReplyError(c,"DISCARD without MULTI"); return; } discardTransaction(c); addReply(c,shared.ok);}void discardTransaction(client *c) { freeClientMultiState(c); initClientMultiState(c); c->flags &= ~(CLIENT_MULTI|CLIENT_DIRTY_CAS|CLIENT_DIRTY_EXEC); unwatchAllKeys(c);}
DISCARD 的实现很简单,在执行 DISCARD 的时候,首先检查客户端连接状态,如果处于 CLIENT_MULTI,则执行取消事务的操作,包括释放事务状态,重置事务状态,然后重置客户端连接状态,最后解除对所有 key 的监控。
至于 EXEC 命令的代码比较多,这里就不贴出来了,大家可以去 GITHUB 看源码 execCommand 方法,我们这里只对 execCommand 方法具体做了那些事情做一个简述。
在执行 EXEC 命令的时候,首先检查客户端连接状态是否为 CLIENT_MULTI,如果客户端没有进入 CLIENT_MULTI 状态,则返回错误信息;然后检查客户端连接是否处于需要取消事务的状态,这里我们简单回顾下上面我们曾经提到的两种需要取消事务的情况,这里当然不包括我们主动调用 DISCARD 命令取消事务。第一种为在开启事务后输入命令,在向事务队列中加入命令时出错,包括命令名称或者参数错误,这时客户端连接状态会被置为 CLIENT_DIRTY_EXEC;第二种情况为在事务启动前通过 WATCH 命令监控某些 key 的值,当在事务执行是发现监控的值发生了变化,这时客户端连接状态会被置 CLIENT_DIRTY_CAS。如果在执行 EXEC 时客户端连接处于 CLIENT_DIRTY_EXEC 或者 CLIENT_DIRTY_CAS 这两种状态,则执行取消事务操作。
接下来会检查事务中是否有写操作和当前客户端连接的节点是否可写,这里主要针对的是如果当前连接到一个只读的从库,那么如果包含写操作,则事务无法执行,需要取消。
然后就是按顺序执行事务队列中的命令。当然,在执行事务队列之前,Redis 会执行 UNWATCH 命令取消对所有 key 的监控,事务队列中命令执行完成之后,会执行取消事务的相关操作,这里的取消事务其实也就是关闭事务的意思,因为事务中的命令已经执行完成了。
最后就是同步主从节点数据等相关操作了。至此 EXEC 命令执行完毕。从源码分析一个事务的执行过程也就结束了。当然这里我们并没有提到 WATCH 和 UNWATCH 命令的源码,当然大家有兴趣可以直接去看源码,说实话 Redis 源码还是很良心的,注释写的很详细,很方便阅读。
小结
Redis 事务很早的版本就已经加入了,最开始只有 MULTI 和 EXEC,后续又逐渐加入了 DISCARD,WATCH,UNWATCH,当然这也都是 2.x 版本的事了,在后续的版本中 Redis 事务并没有发生大的改变,而且随着 Redis 对于脚本的支持越来越好,很多操作都推荐使用脚本进行,而对于事务而言,脚本也可以实现较为复杂的事务逻辑,所以在未来某个版本中,Redis 事务说不定就会推出舞台了。
至此,本文对 Redis 事务的介绍也就结束了。虽然都是很基础的操作,但是还是写出来希望能够让刚接触的朋友避开一些误区,毕竟,我本人当年也在误区中被困住过。
欢迎大家能够交流自己的一些问题和经验,持续学习,共同成长!