在实际的软件开发项目中,我们经常会遇到需要对数据进行一系列连续操作的情况,而且这些操作必须作为一个整体要么全部成功,要么全部失败,以保证数据的一致性。比如在电商系统中,下单、扣库存、记录订单信息等操作需要作为一个不可分割的整体来执行。
Redis作为一款常用的数据库,其事务功能就为解决这类问题提供了有力的支持。那么,如何在项目中正确、高效地使用Redis事务呢?
一、redis事务的基本概念
1. redis事务的基本概念
redis的事务是一个单独隔离的操作,它会将一系列指令按需排队并顺序执行,期间不会被其他客户端的指令插队,所以redis事务是保证组合命令的原子性。
redis的事务指令有3个关键字,分别是:
- multi:开启事务
- exec:执行事务
- discard:取消事务
通过multi,当前客户端就会开启事务,后续用户键入的都指令都会保证到队列中暂不执行,当用户键入exec后,这些指令都会按顺序执行。 需要注意的是,若开启multi后输入若干指令,客户端输入discard,则之前的指令通通取消执行。
2. 事务基础操作示例
如上所示,事务本质就是开启、入队、提交,接下来我们就来简单演示一下,打开客户端首先开启事务:
# 开启事务
127.0.0.1:6379> MULTI
OK
然后将需要执行的操作提交:
# 将两个指令组队
127.0.0.1:6379(TX)> set k1 v1
QUEUED
127.0.0.1:6379(TX)> set k2 v2
QUEUED
完成后,我们就可以通过exec指令提交并执行:
# 执行两个指令
127.0.0.1:6379(TX)> EXEC
1) OK
2) OK
最后查看执行验证一下结果:
# 查看执行结果
127.0.0.1:6379> keys *
1) "k1"
2) "k2"
二、详解redis事务中的原子性
1. 组队时错误
redis事务中的错误分别以下两种:
- 组队时错误
- 执行命令时错误
我们先来说说组队时错误的指令,上文我们已经说过,redis事务开启后提交的指令都会存到队列中,这也就意味着在指令提交阶段redis是可以感知到语法上的错误,所以在组队时错误,redis一旦感知到错误,这些指令都不会执行:
# 开启事务
127.0.0.1:6379> MULTI
OK
# 指令入队
127.0.0.1:6379(TX)> set k1 v1
QUEUED
127.0.0.1:6379(TX)> set k2 v2
QUEUED
127.0.0.1:6379(TX)> set k33
(error) ERR wrong number of arguments for 'set' command
127.0.0.1:6379(TX)> set k4 v4
QUEUED
# 执行指令
127.0.0.1:6379(TX)> exec
(error) EXECABORT Transaction discarded because of previous errors.
# 指令并没有被执行
127.0.0.1:6379> keys *
(empty array)
127.0.0.1:6379>
这一点我们也可以从源码的角度分析,redis会为每一个redis客户端分配一个结构体维护其内部信息,这其中flag字段就代表着客户端各种状态标识,这其中低3位就表示客户端是否开启事务标识,如果1就代表开启,反之代表未开启:
我们都知道redis开启事务需要multi指令,客户端键入该指令之后,redis首先就会通过按位与判断这个二进制为是否被标识为1,如果是则说明已经开启事务,直接抛出嵌套事务异常告知客户端不可重复调用multi指令,反之通过或运算将其设置为1:
对应的我们给出multi指令的源码实现multiCommand,逻辑和笔者说明的一致解:
void multiCommand(redisClient *c) {
//REDIS_MULTI值为1<<3 如果按位与发现当前客户端已经被标识为开启事务,则直接跑错事务不可嵌套的异常
if (c->flags & REDIS_MULTI) {
addReplyError(c,"MULTI calls can not be nested");
return;
}
//REDIS_MULTI值为1<<3 通过 | 符号将低3位标识为1,意为开启事务
c->flags |= REDIS_MULTI;
addReply(c,shared.ok);
}
后续用户的指令提交处理都会走到公用处理函数processCommand,一旦感知到某条指令处理异常,redis就会将客户端标识flag标记为脏事务REDIS_DIRTY_EXEC,后续指令提交时如果发现这个标志位为1:
对应我们给出所有指令提交前的通用逻辑函数processCommand,可以看到如果服务端感知到指令的指令参数不一致等异常就会调用flagTransaction将事务标记为脏:
int processCommand(redisClient *c) {
//.......
c->cmd = c->lastcmd = lookupCommand(c->argv[0]->ptr);
if (!c->cmd) {
//......
} else if ((c->cmd->arity > 0 && c->cmd->arity != c->argc) ||
(c->argc < -c->cmd->arity)) {//检查参数数和命令表配置是否一致
//如果发现不一致则将客户端flags标识标记上REDIS_DIRTY_EXEC标识当前事务是脏事务
flagTransaction(c);
addReplyErrorFormat(c,"wrong number of arguments for '%s' command",
c->cmd->name);
return REDIS_OK;
}
//......
}
void flagTransaction(redisClient *c) {
//如果开启事务则将flags标记上REDIS_DIRTY_EXEC,标识当前事务已脏
if (c->flags & REDIS_MULTI)
c->flags |= REDIS_DIRTY_EXEC;
}
有了上述的基础,我们执行的exec就会通过判断flags查看是否被标记为REDIS_DIRTY_EXEC ,如果是则调用discardTransaction也就是discard清除队列中的指令不执行:
void execCommand(redisClient *c) {
//......
//如果发现标识标记为REDIS_DIRTY_EXEC,则调用 discardTransaction释放掉事务队列的指令不执行
if (c->flags & (REDIS_DIRTY_CAS|REDIS_DIRTY_EXEC)) {
addReply(c, c->flags & REDIS_DIRTY_EXEC ? shared.execaborterr :
shared.nullmultibulk);
discardTransaction(c);
goto handle_monitor;
}
//......
}
来小结一下,redis组队时异常回滚的底层实现:
- multi开启事务
- 提交指令,如果发现指令异常则将当前客户端事务标记为脏事务
- 调用exec时判断客户端标识,如果包含脏标记则清除事务队列中的指令不执行
2. 执行时错误
有了上述基础我们就很好理解执行时错误了,执行时错误比较特殊,他在按序处理所有指令,即时遇到错误就按正常流程处理继续执行下去,如下示例所示,可以看到我们将k1对应的value是字符串类型,第二条指令执行错误后,k2还是正常设置进去了:
# 开启事务
127.0.0.1:6379> MULTI
OK
# 设置字符串k1 v1
127.0.0.1:6379(TX)> set k1 v1
QUEUED
# 设置v1进行自增,此时redis无法感知到这个异常
127.0.0.1:6379(TX)> INCR k1
QUEUED
# 正常键值对设置
127.0.0.1:6379(TX)> set k2 v2
QUEUED
# 提交执行,1、3指令执行成功
127.0.0.1:6379(TX)> EXEC
1) OK
2) (error) ERR value is not an integer or out of range
3) OK
# 即使指令2失败,指令3还是正常提交
127.0.0.1:6379> keys *
1) "k1"
2) "k2"
127.0.0.1:6379>
三、详解redis事务中的乐观锁
1. 为什么redis需要事务
通过redis事务解决需要高性能且需要保证原子性的符合指令操作,最经典的就是秒杀场景,如下图,假设一个秒杀活动中有3个用户,同时通过get指令发现库存剩下1,全部通过原子扣减指令进行扣减,导致超卖:
常见的解决方案有悲观锁和乐观锁,悲观锁(Pessimistic Lock)的原理是认为自己操作的数据很可能会被他人修改,所以对临界资源操作都持有悲观的态度,每次进行操作前都会对数据上锁保证互斥,常见的关系型数据库MySQL的行锁、表锁等都是基于这种锁机制:
我们再来说说乐观锁(Optimistic Lock),该锁的总是乐观的认为自己操作的数据不会被他人修改,进行修改操作时不会针对临界资源上锁,而是修改的时候判断一下当前去数据版本号和修改的数据是否一致,通过比对版本号是否一致判断是否被人修改,只要版本号一致当前线程修改操作就会生效,redis中的watch关键字和jdk下的JUC包下的原子类就是采用这种工作机制:
2. redis事务乐观锁使用示例
这里我们就演示一下redis乐观锁的实现,原理比较简单,通过watch指令监听事务操作要操作的一个或者多个key值,当用户提交修改事务时,watch指令没有检测到key发生变化,则提交成功。
为方便演示,我们假设需要用事务操作名称为key的数据,我们首先初始化一下这个键值对:
# 设置key值
127.0.0.1:6379> set key 10
OK
然后开始watch指令监听这个key:
# 监听key
127.0.0.1:6379> WATCH key
OK
此时我们就可以开启事务提交要执行的操作:
# 开启事务
127.0.0.1:6379> MULTI
OK
同理我们在这时候起一个客户端2同样执行watch和multi操作:
# 监听key
127.0.0.1:6379> WATCH key
OK
# 开启事务
127.0.0.1:6379> MULTI
OK
此时我们回到客户端1执行修改操作,可以看到因为watch到key没有发生改变,修改操作成功:
# 指令加入队列
127.0.0.1:6379(TX)> INCR key
QUEUED
# 执行指令,可以看到执行成功,修改了一条数据,值被更新为11
127.0.0.1:6379(TX)> EXEC
1) (integer) 11
此时我们回到客户端2提交指令并提交,可以看到提交结果失败了,返回nil:
127.0.0.1:6379(TX)> INCR key
QUEUED
127.0.0.1:6379(TX)> exec
(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已被我们修改:
void touchWatchedKey(redisDb *db, robj *key) {
//......
//从watched_keys找到监听当前key的所有客户端
clients = dictFetchValue(db->watched_keys, key);
//......
//遍历订阅这个key的所有客户端
listRewind(clients,&li);
while((ln = listNext(&li))) {
redisClient *c = listNodeValue(ln);
//标识为REDIS_DIRTY_CAS
c->flags |= REDIS_DIRTY_CAS;
}
}
所以当客户端2的执行exec时,调用来到了execCommand,当他发现自己的标识即flags字段被客户端1标记为REDIS_DIRTY_CAS,就知道当前key被人修改了,于是就执行discard取消执行当前指令:
void execCommand(redisClient *c) {
//......
//如果发现标识标记为REDIS_DIRTY_EXEC或REDIS_DIRTY_CAS(当前watch的key被人修改),则调用 discardTransaction释放掉事务队列的指令不执行
if (c->flags & (REDIS_DIRTY_CAS|REDIS_DIRTY_EXEC)) {
addReply(c, c->flags & REDIS_DIRTY_EXEC ? shared.execaborterr :
//执行discard操作清除当前客户端提交的执行,且不执行 shared.nullmultibulk);
discardTransaction(c);
goto handle_monitor;
}
//......
四、详解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脚本可以保证指令一次性被执行不会被其他线程打断),但是这种方案不支持回滚。