当前位置: 首页 > 科技观察

面试官:Redis事务是原子的吗?

时间:2023-03-19 21:24:09 科技观察

一提到数据库事务,估计很多同学的第一反应就是ACID,而ACID中排在第一位的A原子性要求一个事务中的所有操作都必须完成或不完成。熟悉redis的同学一定知道redis是有事务的,那么它的事务是不是也满足原子性呢?下面就来一探究竟吧。什么是Redis事务?与数据库事务类似,redis事务也用于一次执行多个命令。使用起来也非常简单。可以使用MULTI来启动一个事务,然后将多个命令入队到事务队列中,最后通过EXEC命令触发事务,执行事务中的所有命令。看一个简单的事务执行例子:127.0.0.1:6379>multiOK127.0.0.1:6379>setnameHydraQUEUED127.0.0.1:6379>setage18QUEUED127.0.0.1:6379>incrageQUEUED127.0.0.1:6379>exec1)OK2)OK3)(integer)19可以看出,在指令和操作数的数据类型正常的情况下,输入EXEC后所有指令执行成功。Redis事务是否满足原子性?如果要验证redis事务是否满足原子性,需要在redis事务执行异常时进行。接下来,我们将测试两种不同类型的错误。语法错误首先测试命令是否有语法错误。在这种情况下,命令的参数个数不正确或命令本身错误。接下来我们在事务中输入格式错误的命令,打开事务依次输入以下命令:127.0.0.1:6379>multiOK127.0.0.1:6379>setnameHydraQUEUED127.0.0.1:6379>incr(error)ERRwrongnumberofargumentsfor'incr'command127.0.0.1:6379>setage18QUEUED输入命令incr后面没有加参数,是语法错误,命令格式不对。此时,命令进入队列时会立即检测到错误,并提示错误。使用exec执行事务,查看结果输出:127.0.0.1:6379>exec(error)EXECABORTTransactiondiscardedbecauseofpreviouserrors。这样的话,只要事务中的某个命令有语法错误,执行exec后就会直接返回错误,包括语法正确的命令在内的所有命令都不会执行。要验证这一点,请查看事务中其他指令的执行情况,并检查set命令的执行结果。都是空的,说明指令还没有执行。127.0.0.1:6379>getname(nil)127.0.0.1:6379>getage(nil)另外,如果命令本身有拼写错误,或者输入了不存在的命令,也是语法错误错误。执行事务时会直接报错。运行错误运行错误是指输入的命令格式正确,但在命令执行过程中出现错误。典型的场景是当输入参数的数据类型不符合命令的参数要求时,会出现运行错误。例如下面的例子,当对字符串类型的值进行列表操作时,报错如下:127.0.0.1:6379>setkey1value1OK127.0.0.1:6379>lpushkey1value2(error)WRONGTYPEOperationagainstakeyholdingthewrongkindofvalue这种error在redis真正执行命令之前是不可能被发现的,只有真正执行的时候才能发现,所以这样的命令是可以被事务队列接收到的,不会像上面的语法错误那样立刻报错。具体的,当事务中出现运算错误时,在如下事务中,尝试对字符串类型数据进行incr自增操作:127.0.0.1:6379>multiOK127.0.0.1:6379>setnameHydraQUEUED127.0.0。1:6379>setageeighteenQUEUED127.0.0.1:6379>incrageQUEUED127.0.0.1:6379>delnameQUEUEDredis到这里一直没有提示错误,执行exec看结果输出:127.0.0.1:6379>exec1)OK2)OK3)(error)ERRvalueisnotanintegeroroutofrange4)(integer)1运行结果可以看出,incrage命令虽然有错误,但是前后的命令都正常执行。看这几个key对应的值,确实证明其余命令执行成功:127.0.0.1:6379>getname(nil)127.0.0.1:6379>getage"十八"阶段性结论分析上述事务的运行结果:在语法错误的情况下,所有命令都不会被执行。在这种情况下,除了执行错误的命令外,其他命令都可以正常执行。通过分析我们知道redis中的事务是不满足原子性的。在出现错误的情况下,它不提供类似于数据库中的回滚。功能。那么redis为什么不支持回滚呢?官方文档给出了解释,大致思路是这样的:Rediscommandfailure只有在语法错误或者数据类型错误的情况下才会出现。这个结果是编程过程中的错误造成的。应该在开发环境而不是生产环境中检测到这种情况。不使用回滚,可以让redis的内部设计更简单,速度更快。回滚无法避免编程逻辑上的错误。如果要将键的值增加2,但只添加了1。这种情况下,即使提供回滚也无济于事。基于以上原因,redis官方选择了更简单快捷的方式,不支持错误回滚。这种情况下,如果我们的业务场景需要保证原子性,那么就需要开发者通过其他手段来保证所有命令的成功或失败,比如在执行命令前验证参数类型,或者在事务执行出错时进行业务补偿及时。说到其他的方法,相信很多小伙伴都听说过用Lua脚本来保证操作的原子性。例如,Lua脚本通常用于分布式锁。那么,神奇的Lua脚本真的能保证原子性吗?Lua脚本简单入门在验证lua脚本的原子性之前,我们需要对其做一个简单的了解。Redis从2.6版本开始支持lua脚本的执行。它的功能与交易非常相似。lua脚本作为单个命令执行。这样就可以将多个redis命令写入到lua中,达到类似的事务执行结果。下面我们就来看看常用的命令吧。最常用的EVAL命令用于执行脚本。它的命令格式如下:EVALscriptnumkeyskey[key...]arg[arg...]简单说明一下参数:script是一个lua脚本程序numkeys指定后面的参数有几个key,如果没有key就0key[key...]表示脚本中使用的redis中的key,而在lua脚本中,arg是以KEYS[i]的形式获取的[arg...]表示附加参数,在lua中获取一个简单的通过脚本中的ARGV[i]示例:127.0.0.1:6379>eval"return{KEYS[1],KEYS[2],ARGV[1],ARGV[2]}"2key1key2value1vauel21)"key1"2)"key2"3)"value1"4)"vauel2"上面命令中,lua脚本在双引号内,后面的2表示有两个key,分别是key1和key2,后面的参数是附加参数value1和价值2。如果想用lua脚本执行set命令,可以这样写:127.0.0.1:6379>EVAL"redis.call('SET',KEYS[1],ARGV[1]);"1nameHydra(nil)这里使用redis内置的lua函数redis.call完成set命令。这里打印的执行结果是nil,因为没有返回值。如果你不习惯,我们其实可以加一个return0的return语句;在脚本中。SCRIPTLOAD和EVALSHA命令组合在一起,因为它们通常成对使用。首先看SCRIPTLOAD,它用于将脚本加载到缓存中并返回SHA1校验和。此时只是缓存了命令,并没有立即执行命令。看个例子:127.0.0.1:6379>SCRIPTLOAD"returnredis.call('GET',KEYS[1]);""228d85f44a89b14a5cdb768a29c4c4d907133f56"这里返回了一个SHA1的校试和,接下攀登就可以使用SHA1:127.0.0.1:6379>EVALSHA"228d85f44a89b14a5cdb768a29c4c4d907133f56"1name"Hydra"这里使用这个SHA1值相当于导入上面缓存的命令,然后拼接numkeys,key,arg等参数,就可以执行命令了通常情况下。其他命令使用SCRIPTEXISTS命令判断脚本是否缓存:127.0.0.1:6379>SCRIPTEXISTS228d85f44a89b14a5cdb768a29c4c4d907133f561)(integer)1使用SCRIPTFLUSH命令清除redis中的lua脚本缓存:127.0.0.1.6379>79RIPT079>SCRIPT7.FLUSHOK1SCRIPTEXISTS228d85f44a89b14a5cdb768a29c4c4d907133f561)(integer)0执行SCRIPTFLUSH后,再次查看SHA1值可以看到脚本已经不存在了。最后,SCRIPTKILL命令也可用于终止当前正在运行的lua脚本,但前提是该脚本未执行写操作。从这些操作来看,lua脚本有以下优点:可以在一个请求中完成多个网络请求,减少网络开销和网络延迟客户端发送的脚本会存储在redis中,其他客户端可以复用这个脚本,不需要重复执行编码来完成相同的逻辑。在Java代码中使用lua脚本。在Java代码中,可以使用Jedis封装的API来执行lua脚本。下面是使用Jedis执行lua脚本的例子:publicstaticvoidmain(String[]args){Jedisjedis=newJedis("127.0.0.1",6379);Stringscript="redis.call('SET',KEYS[1],ARGV[1]);"+"returnredis.call('GET',KEYS[1]);";Listkeys=Arrays.asList("age");Listvalues=Arrays.asList("eighteen");Objectresult=jedis.eval(script,keys,values);System.out.println(result);}执行上面的代码,控制台打印get命令返回的结果:eighteen经过简单的准备就是完成了,我们来看看lua脚本能不能实现回滚级别的原子性。修改上面的代码,插入一条错误的命令:publicstaticvoidmain(String[]args){Jedisjedis=newJedis("127.0.0.1",6379);Stringscript="redis.call('SET',KEYS[1],ARGV[1]);"+"redis.call('INCR',KEYS[1]);"+"returnredis.call('GET',KEYS[1]);";Listkeys=Arrays.asList("age");Listvalues=Arrays.asList("eighteen");Objectresult=jedis.eval(script,keys,values);System.out.println(result);}查看执行结果:然后进入客户端执行get命令:127.0.0.1:6379>getage"eighteen"也就是说,虽然程序抛出异常,但是异常之前的命令依然正常执行,没有回滚。尝试在redis客户端直接运行这条命令:127.0.0.1:6379>flushallOK127.0.0.1:6379>eval"redis.call('SET',KEYS[1],ARGV[1]);redis.call('INCR',KEYS[1]);returnredis.call('GET',KEYS[1])"1ageeight(error)ERRErrorrunningscript(calltof_c2ea9d5c8f60735ecbedb47efd42c834554b9b3b):@user_script:1:ERRvalueisnotanintegeroroutofrange127:60。“八”同样,错误前的指令仍然没有回滚,那么我们之前经常听到的Lua脚本的原子操作有什么保障呢?其实在redis中使用了同一个lua解释器来执行所有的命令,也保证了在执行一个lua脚本的时候,不会同时执行其他的脚本或者redis命令,保证操作不会被插入或者被打扰通过其他指令,只有这个级别的原子操作才能实现。但遗憾的是,如果lua脚本运行出错中途结束,后续操作不会执行,但之前发生的写操作不会撤销,所以即使使用lua脚本,也无法实现类似数据库回滚的原子性实现了。本文基于redis5.0.3进行测试。官方文档相关说明:https://redis.io/topics/transactions大规模分布式系统架构设计五款开源游戏化工具2021年数字化转型的七大热门趋势和三大降温趋势Windows11新预览版22449推送:发生了什么开机动画改了吗?游戏玩家大规模退居Windows7:Windows10暴跌