redis-lua 事物

lua 脚本的优势在于,支持在顺序操作中有分支判断的能力



使用lua脚本



  Redis 使用单个 Lua 解释器去运行所有脚本,并且, Redis 也保证脚本会以原子性(atomic)的方式执行:当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行。这和使用 MULTI / EXEC 包围的事务很类似。在其他别的客户端看来,脚本的效果(effect)要么是不可见的(not visible),要么就是已完成的(already completed)。



  另一方面,这也意味着,执行一个运行缓慢的脚本并不是一个好主意。写一个跑得很快很顺溜的脚本并不难,因为脚本的运行开销(overhead)非常少,但是当你不得不使用一些跑得比较慢的脚本时,请小心,因为当这些蜗牛脚本在慢吞吞地运行的时候,其他客户端会因为服务器正忙而无法执行命令。
  2.实现watch机制
  WATCH key [key …]



  监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。
  MULTI



  标记一个事务块的开始。



  事务块内的多条命令会按照先后顺序被放进一个队列当中,最后由 EXEC 命令原子性(atomic)地执行。
  EXEC



  执行所有事务块内的命令。
  假如某个(或某些) key 正处于 WATCH 命令的监视之下,且事务块中有和这个(或这些) key 相关的命令,那么 EXEC 命令只在这个(或这些) key 没有被其 他命令所改动的情况下执行并生效,否则该事务被打断(abort)。
事务,简单理解就是,一组动作,要么全部执行,要么就全部不执行.从而避免出现数据不一致的情况.



redis提供了简单的事务功能,将一组需要的命令放到multi和exec两个命令之间.multi代表事务开始,exec代码事务结束.




Redis操作原子性的原因



Redis的操作之所以是原子性的,是因为Redis是单线程的。
线程,是操作系统最小的执行单元,在单线程程序中,任务一个一个地做,必须做完一个任务后,才会去做另一个任务。



原子性(Atomicity)
原子意味着操作的不可再分,要么执行要么不执行。Redis 本身提供的所有 API 都是原子操作,那么 Redis 事务其实是要保证批量操作的原子性。Redis 实现批量操作的原理是在一个事务上下文中(通过 MULTI命令开启),所有提交的操作请求都先被放入队列中缓存,在 EXEC 命令提交时一次性批量执行。这样保证了批量操作的一次性执行过程,但 Redis 在事务执行过程的错误情况做出了权衡取舍,那就是放弃了回滚。
Redis 官方文档对此给出的解释是:
Redis 操作失败的原因只可能是语法错误或者错误的数据库类型操作,这些都是在开发层面能发现的问题不会进入到生产环境,因此不需要回滚。
Redis 内部设计推崇简单和高性能,因此不需要回滚能力。
据实而说第一条说法感觉有点站不住脚,可以想象得到 Redis 操作失败的原因绝对不止语法层面的错误,特别是一些像依赖操作系统、文件系统的操作。第二条说法更实在,Redis 的应用场景明显不是为了数据存储的高可靠而设计的,而是为了数据访问的高性能而设计,设计者为了简单性和高性能而部分放弃了原子性。
出于以上考虑 Redis 的事务执行有以下特点:
批量操作在发送 EXEC 命令前被放入队列缓存
收到 EXEC 命令后进入事务执行,事务中任意命令执行失败,其余的命令依然被执行
在事务执行过程,其他客户端提交的命令请求不会插入到事务执行命令序列中
一致性(Consistency)
一致性意味着事务结束后系统的数据依然保证一致。在事务开始之前,数据保持有效的状态,事务结束后也如此。显然在前面讨论原子性时,Redis 舍弃了回滚的设计,基本上也就舍弃对数据一致性的有效保证。不过对于一个高效的 key-value store 或 data structure server,数据操作一致性很多时候更多应该依赖应用层面,事实也是我们使用 Redis 时很多时候都是分片和集群的,数据一致性无法依靠任何事务机制。



隔离性(Isolation)
隔离性保证了在事务完成之前,该事务外部不能看到事务里的数据改变。也不能访问一些中间状态,因为假如事务终止的话,这些状态将永远不会发生。Redis 采用单线程设计,在一个事务完成之前,其他客户端提交的各种操作都无法执行因此自然没法看见事务执行的中间状态,隔离性得到保证。



持久性(Durability)
Redis 一般情况下都只进行内存计算和操作,持久性无法保证。但 Redis 也提供了2种数据持久化模式,SNAPSHOT 和 AOF,SNAPSHOT的持久化操作与命令操作是不同步的,无法保证事务的持久性。而AOF模式意味着每条命令的执行都需要进行系统调用操作磁盘写入文件,可以保证持久性,但会大大降低 Redis 的访问性能。



Redis 在2.6版本开始提供脚本(Lua scripting)能力,一种更灵活的批量命令组织方式用于取代目前的事务机制。脚本提供了更强大和灵活的编程能力,但也是一把双刃剑,由于 Redis 需要保证脚本执行的原子性和隔离性,脚本执行期间会阻塞其他命令的执行,因此建议写一些高效的脚本。不过从开发者的角度来说使用Lua脚本的成本(学习成本、开发成本、维护成本)都要更大,特别是一些开发者如果将应用的业务逻辑放入脚本中来执行,是不是让人想起了数据库的存储过程。



可以看到sadd命令一开始返回的结果是QUEUED,代表命令并没有真正执行,只是暂时存在redis中.只有当exec执行了.这组命令才算是完成.
如果事务中的命令出现错误,
错误1. 命令错误
比如说语法错误, set写成了sett,整个的事务将无法执行
错误2.运行时错误
比如说应该用sadd,却误写成了zadd,从语法上讲,是没有毛病的,但是上面的写对的命令,已经执行入库了. 这种情况就需要开发人员自己修复了.
所以说redis不支持事务中的回滚特性.无法实现命令之间的逻辑关系计算.
所以在开发中,还可以采用lua脚本来实现事务的,简单理解:使用lua语言编写脚本传到redis中执行.
Lua
执行有啥好处:
1.lua脚本是作为一个整体执行的.所以中间不会被其他命令插入;
2.可以把多条命令一次性打包,所以可以有效减少网络开销;
3.lua脚本可以常驻在redis内存中,所以在使用的时候,可以直接拿来复用.也减少了代码量.
使用上举个例子
使用
 访问控制 ,10秒内最多访问3次.访问频率在10s内小于等于3次时返回1,否则返回0
local times = redis.call(‘incr’,KEYS[1]) if times == 1 then redis.call(‘expire’,KEYS[1], ARGV[1])end if times > tonumber(ARGV[2]) then return 0endreturn 1redis客户端,测试脚本:  



eval命令和–eval 本质是一样的.客户端如果想要执行lua脚本,首先要在客户端编写好lua脚本代码,然后把脚本作为字符串发送给服务端,服务端把执行结果返回给客户端
–eval 是告诉redis-cli读取并运行后面的脚本, ratelimiting.lua是脚本的位置.后面是脚本的参数. 这里10 是脚本中ARGV[1] 3是 ARGV[2]. 
, 前的rate.limiting:127.0.0.1 是要操作的键,对应的是脚本中KEYS[1]
这个应用场景算是限速.比如说每次登陆,让用户输入手机验证码,从而确定是否是用户本人,但是如果用户疯狂的点,获取验证码,那么短信的这个接口就会一直被调用.那么咱们这边就可以进行限制. 如果不用lua脚本, 用代码也是可以实现的.这里写上伪代码



管道



大家都知道redis是基于TCP连接进行通信的,每一个request/response都需要经历一个RTT往返时间,如果需要执行很多短小的命令,这些往返时间的开销是很大的,在此情形下,redis提出了管道来提高执行效率。管道的思想是:如果client执行一些相互之间无关的命令或者不需要获取命令的返回值,那么redis允许你连续发送多条命令,而不需要等待前面命令执行完毕。比如我们执行3条INCR命令,如果使用管道,理论上只需要一个RTT+3条命令的执行时间即可,如果不适用管道,那么可能需要额外的两个RTT时间。因此,管道相当于批处理脚本,相当于是命令集,例如:



with r.pipeline(transaction=False) as pipe:
pipe.set(‘key1’, ‘value1’)
pipe.set(‘key2’, ‘value2’)
pipe.set(‘key3’, ‘value3’)
pipe.execute()
12345



Pipeline在某些场景下非常有用,比如有多个command需要被“及时的”提交,而且他们对相应结果没有互相依赖,而且对结果响应也无需立即获得,那么pipeline就可以充当这种“批处理”的工具;而且在一定程度上,可以较大的提升性能,性能提升的原因主要是TCP链接中较少了“交互往返”的时间。例如:因为业务需要,我们需要把用户的操作过程记录在日志中以方便以后的统计,每隔3个小时生成一个新的日志文件,那么后台处理线程,将会扫描日志文件并将每条日志输出为“operation”:1,即表示操作次数为1;如果每个operation都发送一个command,事实上性能是很差的,而且是没有必要的;那么我们就可以使用pipeline批量提交即可。



管道和事务是不同的,pipeline只是表达“交互”中操作的传递的方向性,pipeline也可以在事务中运行,也可以不在。无论如何,pipeline中发送的每个command都会被server立即执行,如果执行失败,将会在此后的相应中得到信息;也就是pipeline并不是表达“所有command都一起成功”的语义,管道中前面命令失败,后面命令不会有影响,继续执行。简单来说就是管道中的命令是没有关系的,它们只是像管道一样流水发给server,而不是串行执行,仅此而已;但是如果pipeline的操作被封装在事务中,那么将有事务来确保操作的成功与失败。



使用管道可能在效率上比使用script要好,但是有的情况下只能使用script。因为在执行后面的命令时,无法得到前面命令的结果,就像事务一样,所以如果需要在后面命令中使用前面命令的value等结果,则只能使用script或者事务+watch。



事务



redis中的事务并不像mysql中那么完美,只是简单的保证了原子性。redis中提供了四个命令来实现事务,MULTI:类似于mysql中的BEGIN;EXEC:类似于COMMIT;DISCARD类似于ROLLBACK;WATCH则是用于来实现mysql中类似锁的功能。具体的使用方法非常简单,例如:



127.0.0.1:6379> multi
OK
127.0.0.1:6379> incr count
QUEUED
127.0.0.1:6379> incr count
QUEUED
127.0.0.1:6379> exec
1) (integer) 1
2) (integer) 2
123456789



redis事务的实现原理是把事务中的命令先放入队列中,当client提交了exec命令后,redis会把队列中的每一条命令按序执行一遍。如果在执行exec之前事务中断了,那么所有的命令都不会执行;如果执行了exec命令之后,那么所有的命令都会按序执行。但如果在事务执行期间redis被强制关闭,那么则需要使用redis-check-aof 工具对redis进行修复,删除那些部分执行的命令。下面分几种情况讨论下redis事务中需要注意的地方:



1)入队命令语法错误,此时还没有执行exec命令



虽然redis在碰到exec命令之前不会执行事务中的命令,但是,它会对每个命令进行适当的检查,当发现有某些明显的语法错误时,如参数个数不正确,则会在入队时,返回错误信息,并当看到exec命令调用discard命令进行回滚。例如:



127.0.0.1:6379> get name
“Jeff”
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set name Kate
QUEUED
127.0.0.1:6379> set name
(error) ERR wrong number of arguments for ‘set’ command
127.0.0.1:6379> exec
(error) EXECABORT Transaction discarded because of previous errors.
127.0.0.1:6379> get name
“Jeff”
123456789101112



2) 当exec执行完毕后,执行其它命令时发生错误



当redis在执行命令时,如果出现了错误,那么redis不会终止其它命令的执行。即只要是正确的命令,无论在错误命令之前还是之后,都会顺利执行。例如:



127.0.0.1:6379> lpush visited “name1”
(integer) 1
127.0.0.1:6379> get name
“Kate”
127.0.0.1:6379> get count
“5”
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set name Jeff
QUEUED
127.0.0.1:6379> get visited
QUEUED
127.0.0.1:6379> set count 10
QUEUED
127.0.0.1:6379> exec
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
3) OK
127.0.0.1:6379> get name
“Jeff”
127.0.0.1:6379> get count
“10”
12345678910111213141516171819202122



redis没有实现真正的回滚是因为redis只是一个key-value缓存数据库,如果加上日志回滚,将会影响其效率。



3)事务间的相互影响



事务中最长出现的影响就是同时修改一条记录,而redis中的事务默认没有对此进行处理,如果两个事务同时修改一条记录,首先执行exec的事务的结果将会被覆盖。这里我们可以使用watch命令,该命令用于监控某些具体的key,如果这些key被其它事务修改了,那么本事务再修改时就不会成功,然后返回失败的提示。



T1:
watch name
multi
set name Jeff
exec
T2:
watch name
multi
set name Kate
exec
12345678910



如果T2先提交exec,那么T1提交时则更新失败,此时name依旧是Kate,然后在应用层决定是否需要重新执行该事务。



由于redis事务中的命令在遇到exec命令之前并没有真正的执行,所以我们无法在事务中的命令中使用前面命令的查询结果。我们唯一可以做的就是通过watch保证在我们进行修改时,如果其它事务刚好进行了修改,则我们的修改停止,然后应用层做相应的处理。比如:如果get key 返回的值是true,那么我们set otherkey value,否则什么也不做。这种情况下,虽然可以用事务+watch实现原子操作,但是不免有点太僵硬,很明显这是一个if……else语句。正是因为这个局限,使得lua脚本派上了大的用场。



参考文献:http://redis.io/topics/transactions



lua脚本(2.6.0及以后版本)



原先没有注意lua脚本的用法,上次还是请教了同事才知道redis中lua脚本的强大之处,然后果断在项目中用了一下,感觉非常完美。其使用方法非常简单,例如:



eval “return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}” 2 key1 key2 first second
1



其中eval是lua脚本的解释器;eval的第一个参数是脚本的内容,第二个参数是脚本里面KEYS数组的长度(不包括ARGV参数的个数),这里是两个;紧接着就会有两个参数,用于传递个KEYS数组;后面剩下的参数全部传递给ARGV数组,相当于命令行参数。



如果我们想在lua脚本中调用redis的命令该如何操作?可以在脚本中使用redis.call()或redis.pcall()直接调用,两者用法类似,只是在遇到错误时,返回错误的提示方式不同。例如:



eval “return redis.call(‘set’,KEYS[1],’bar’)” 1 foo
1



redis确保正一条script脚本执行期间,其它任何脚本或者命令都无法执行。正是由于这种原子性,script才可以替代MULTI/EXEC作为事务使用。当然,官方文档也说了,正是由于script执行的原子性,所以我们不要在script中执行过长开销的程序,否则会验证影响其它请求的执行。



另外,redis为了减少每次客户端发送来的数据带宽(如果script太长,则发送来的内容可能非常多),会把每次新出现的脚本的sha1摘要保存下来,这样后续如果script不变的话,只需要调用evalsha命令+script摘要即可,而不需要重复传递过长的脚本内容。例如:



127.0.0.1:6379> set foo bar
OK
127.0.0.1:6379> eval “return redis.call(‘get’,’foo’)” 0
“bar”
127.0.0.1:6379> evalsha 6b1bf486c81ceb7edf3c093f4c48582e38c0e791 0
“bar”
123456



从这里可以看出把key和arg以参数的形式传递而不是直接写在script中的好处,因为这样可以把变量提取出来,使得script的sha1摘要保持不变,提高命中率。在应用程序中,可以先使用evalsha进行调用,如果失败,再使用eval进行操作,这样可以在一定程度上提高效率。



有了上面的知识,我们就可以使用lua脚本来灵活的使用redis的事务,这里举几个简单的例子。



场景1:我们要判断一个IP是不是第一次访问,如果是第一次访问,那么返回状态1,否则插入该ip,并返回状态0.



127.0.0.1:6379> eval “if redis.call(‘get’,KEYS[1]) then return 1 else redis.call(‘set’, KEYS[1], ‘test’) return 0 end” 1 test_127.0.0.1
(integer) 0
127.0.0.1:6379> eval “if redis.call(‘get’,KEYS[1]) then return 1 else redis.call(‘set’, KEYS[1], ‘test’) return 0 end” 1 test_127.0.0.1
(integer) 1
1234



场景2:使用redis限制30分钟内一个IP只允许访问5次



思路:每次想把当前的时间插入到redis的list中,然后判断list长度是否达到5次,如果大于5次,那么取出队首的元素,和当前时间进行判断,如果在30分钟之内,则返回-1,其它情况返回1.



eval “redis.call(‘rpush’, KEYS[1],ARGV[1]);if (redis.call(‘llen’,KEYS[1]) >tonumber(ARGV[2])) then if tonumber(ARGV[1])-redis.call(‘lpop’, KEYS[1])<tonumber(ARGV[3]) then return -1 else return 1 end else return 1 end” 1 ‘test_127.0.0.1’ 1451460590 5 1800
1



通过上面两个场景可以看到,我们仅仅使用了lua的if语句,就可以实现这么方便的操作,如果使用其它的lua语法,肯定更加方便。



官网文档上有这样一段话:



A Redis script is transactional by definition, so everything you can do with a Redis transaction, you can also do with a script, and usually the script will be both simpler and faster.



由此可以看出,官方还是支持大家尽量使用lua script来代替transaction的。


Category storage