掌握 Redis 事务,提升数据处理效率的必备秘籍
在实际的软件开发项目中,我们经常会遇到需要对数据进行一系列连续操作的情况,而且这些操作必须作为一个整体要么全部成功,要么全部失败,以保证数据的一致性。比如在电商系统中,下单、扣库存、记录订单信息等操作需要作为一个不可分割的整体来执行。
Redis作为一款常用的数据库,其事务功能就为解决这类问题提供了有力的支持。那么,如何在项目中正确、高效地使用Redis事务呢?
一、redis事务的基本概念
1. redis事务的基本概念redis的事务是一个单独隔离的操作,它会将一系列指令按需排队并顺序执行,期间不会被其他客户端的指令插队,所以redis事务是保证组合命令的原子性。
redis的事务指令有3个关键字,分别是:
multi:开启事务exec:执行事务discard:取消事务通过multi,当前客户端就会开启事务,后续用户键入的都指令都会保证到队列中暂不执行,当用户键入exec后,这些指令都会按顺序执行。 需要注意的是,若开启multi后输入若干指令,客户端输入discard,则之前的指令通通取消执行。
如上所示,事务本质就是开启、入队、提交,接下来我们就来简单演示一下,打开客户端首先开启事务:
然后将需要执行的操作提交:
完成后,我们就可以通过exec指令提交并执行:
最后查看执行验证一下结果:
二、详解redis事务中的原子性
1. 组队时错误redis事务中的错误分别以下两种:
组队时错误执行命令时错误我们先来说说组队时错误的指令,上文我们已经说过,redis事务开启后提交的指令都会存到队列中,这也就意味着在指令提交阶段redis是可以感知到语法上的错误,所以在组队时错误,redis一旦感知到错误,这些指令都不会执行:
这一点我们也可以从源码的角度分析,redis会为每一个redis客户端分配一个结构体维护其内部信息,这其中flag字段就代表着客户端各种状态标识,这其中低3位就表示客户端是否开启事务标识,如果1就代表开启,反之代表未开启:
我们都知道redis开启事务需要multi指令,客户端键入该指令之后,redis首先就会通过按位与判断这个二进制为是否被标识为1,如果是则说明已经开启事务,直接抛出嵌套事务异常告知客户端不可重复调用multi指令,反之通过或运算将其设置为1:
对应的我们给出multi指令的源码实现multiCommand,逻辑和笔者说明的一致解:
后续用户的指令提交处理都会走到公用处理函数processCommand,一旦感知到某条指令处理异常,redis就会将客户端标识flag标记为脏事务REDIS_DIRTY_EXEC,后续指令提交时如果发现这个标志位为1:
对应我们给出所有指令提交前的通用逻辑函数processCommand,可以看到如果服务端感知到指令的指令参数不一致等异常就会调用flagTransaction将事务标记为脏:
有了上述的基础,我们执行的exec就会通过判断flags查看是否被标记为REDIS_DIRTY_EXEC ,如果是则调用discardTransaction也就是discard清除队列中的指令不执行:
来小结一下,redis组队时异常回滚的底层实现:
multi开启事务提交指令,如果发现指令异常则将当前客户端事务标记为脏事务调用exec时判断客户端标识,如果包含脏标记则清除事务队列中的指令不执行2. 执行时错误有了上述基础我们就很好理解执行时错误了,执行时错误比较特殊,他在按序处理所有指令,即时遇到错误就按正常流程处理继续执行下去,如下示例所示,可以看到我们将k1对应的value是字符串类型,第二条指令执行错误后,k2还是正常设置进去了:
三、详解redis事务中的乐观锁
1. 为什么redis需要事务通过redis事务解决需要高性能且需要保证原子性的符合指令操作,最经典的就是秒杀场景,如下图,假设一个秒杀活动中有3个用户,同时通过get指令发现库存剩下1,全部通过原子扣减指令进行扣减,导致超卖:
常见的解决方案有悲观锁和乐观锁,悲观锁(Pessimistic Lock)的原理是认为自己操作的数据很可能会被他人修改,所以对临界资源操作都持有悲观的态度,每次进行操作前都会对数据上锁保证互斥,常见的关系型数据库MySQL的行锁、表锁等都是基于这种锁机制:
我们再来说说乐观锁(Optimistic Lock),该锁的总是乐观的认为自己操作的数据不会被他人修改,进行修改操作时不会针对临界资源上锁,而是修改的时候判断一下当前去数据版本号和修改的数据是否一致,通过比对版本号是否一致判断是否被人修改,只要版本号一致当前线程修改操作就会生效,redis中的watch关键字和jdk下的JUC包下的原子类就是采用这种工作机制:
这里我们就演示一下redis乐观锁的实现,原理比较简单,通过watch指令监听事务操作要操作的一个或者多个key值,当用户提交修改事务时,watch指令没有检测到key发生变化,则提交成功。
为方便演示,我们假设需要用事务操作名称为key的数据,我们首先初始化一下这个键值对:
然后开始watch指令监听这个key:
此时我们就可以开启事务提交要执行的操作:
同理我们在这时候起一个客户端2同样执行watch和multi操作:
此时我们回到客户端1执行修改操作,可以看到因为watch到key没有发生改变,修改操作成功:
此时我们回到客户端2提交指令并提交,可以看到提交结果失败了,返回nil:
这里我们也从源码的角度解释一下redis对于watch乐观锁的实现,如上操作,当我们客户端键入watch指令时监控key时,redis就会将当前客户端的信息挂到一个watched_keys的字典中,用key作为键,客户端信息作为value追加到这个key的链表中。
我们客户端1提交时,因为之前没有客户端进行修改,所以成功提交修改操作,并将watched_keys中监听key的所有客户端的flags标识为已被CAS修改即枚举变量REDIS_DIRTY_CAS数值为1<<5。 然后客户端2进行修改操作时,看到自己的flags被修改为REDIS_DIRTY_CAS就知道了当前key被人修改了,所以乐观修改操作失败:
对应源码如下,当客户端1执行exec时发现监听的key没有被人修改,执行incr操作之后,就会走到下面这个方法touchWatchedKey将watched_keys中监听key的客户端标识标记为REDIS_DIRTY_CAS,告知当前这个key已被我们修改:
所以当客户端2的执行exec时,调用来到了execCommand,当他发现自己的标识即flags字段被客户端1标记为REDIS_DIRTY_CAS,就知道当前key被人修改了,于是就执行discard取消执行当前指令:
四、详解redis事务的一些常见问题
1. 为什么redis不支持事务回滚redis实际上是支持事务回滚的,只不过这种回滚是仅仅支持组队时的异常,只有组队时感知到指令错误,redis服务端才会标记异常,后续执行exec时就会将提交队列的指令清除且不执行,由此原子性,对应的我们也有在上面的源码给出解释说明。
2. 如何理解redis的事务与ACID(1) 原子性: redis设计者认为他们是支持原子性的,因为原子性的概念是:所有指令要么全部执行,要么全部不执行,只要客户端提交的指令能够在组队阶段被感知,它就能做到指令操作的原子性。
(2) 一致性: 针对数据的一致性,我们从3种情况进行讨论:
组队阶段:如果在事务组队阶段感知到异常,redis会主动事务中的指令且不执行,可以保证一致性。执行时异常:在事务执行阶段出现异常,redis还是会顺序执行后续的指令,一致性就会被破坏事务提交前redis宕机:如果开启了rdb或者aof持久化机制,可以在服务重启时重新加载提交到队列中的数据,保证一致性。(3) 隔离性: 隔离性要求避免所有的客户端事务操作并发交叉执行时导致数据不一致问题,如上乐观锁的说明,我们可以通过watch关键字监听key的变化保证事务提交时感知到其他客户端的修改,如果发生修改就不提交事务,由此避免隔离性遭到破坏。
(4) 持久性: 持久性的定义为事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。),考虑到性能问题,redis无论rdb还是aof都是异步持久化,所以并不能保证持久性。
3. Redis事务的其他实现方式了解过嘛?基于lua脚本可以保证redis指令一次性执按顺序执行完成,并且不会被其他客户端打断,但是这种方式却无法实现事务回滚,所以我们可以需要在lua脚本的实现上进行响应的处理。
4. Redis事务三特性是什么?单独的隔离操作:事务中的命令都会序列化并且按序执行,执行过程中不会被其他客户端的指令打断。没有隔离级别的概念:事务提交前所有指令都不会被执行。无原子性:上文示例已经演示过,执行时出错某段指令,事务过程中的指令仍然会生效。5. 如何使用 Redis 事务?Redis 可以通过 MULTI,EXEC,DISCARD 和 WATCH 等命令来实现事务(transaction)功能。
6. 如何解决 Redis 事务的缺陷?从上文我们看出基于redis事务进行秒杀方面的需求时会出现库存遗留问题,这就是redis事务乐观锁机制的缺陷。 为了保证所有事务都能一次性的执行,我们可以使用lua脚本更快(lua脚本可以轻易调用C语言库函数以及被C语言直接调用)、更有效(基于lua脚本可以保证指令一次性被执行不会被其他线程打断),但是这种方案不支持回滚。