Redis篇:事务和Lua 脚本的使用

时间:2022-04-23 22:16:04

Redis篇:事务和Lua 脚本的使用

现在多数秒杀,抽奖,抢红包等大并发高流量的功能一般都是基于 redis 实现,然而在选择 redis 的时候,我们也要了解 redis 如何保证服务正确运行的原理

前言

  • redis 如何实现高性能和高并发
  • reids 事务的 ACID 原理
  • WATCH、EXEC 命令实现 redis 事务
  • lua 实现 redis事务
  • 抢红包方案

redis 如何实现高性能和高并发

  • redis 是一个内存数据库,读写非常高效。除了开启 AOF,RDB 异步线程去持久化数据,基本没有磁盘I/O消耗,性能方面是比 mysql,oracle 快很多
  • redis 自己实现一套简单高效的基础数据结构:动态字符串(SDS),链表,字典,跳跃链表,整数集合和压缩列表。然后在这个基础上去实现用户能操作的对象:字符串,列表,哈希,集合,有序集合等对象
  • reactor 模式的网络事件处理器。它使用了 I/O 多路复用去同时监控多个套接字,这是一种高效的I/O模型。reactor 相关知识可以看下这篇文章框架篇:见识一下linux高性能网络IO+Reactor模型
  • 事件处理器是单线执行的,这大大减少CPU的上下文切换,和对资源锁的竞争问题,极大提高redis服务处理速度(至于为啥使用单线程,因为CPU够用了,它的性能瓶颈在内存而不是CPU)
  • Redis直接自己构建了VM 机制 ,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求

reids 事务的 ACID 原理

redis 的事务需要先划分出三个阶段

  • 事务开启,使用 MULTI 可以标志着执行该命令的客户端从非事务状态切换至事务状态redis> MULTI
  • 命令入队,MULTI开启事务之后,非 WATCH、EXEC、DISCARD、MULTI 等特殊命令;客户端的命令不会被立即执行,而是放入一个事务队列
  • 执行事务或者丢弃。如果收到 EXEC 的命令,事务队列里的命令将会被执行。如果是 DISCARD 则事务被丢弃

命令入队过程如果出错(如使用了不存在的命令),则事务队列会被拒接执行

执行事务期间出现了异常(如命令和操作的数据类型不匹配),事务队列的里的命令还是继续执行下去,直到全部命令执行完。不会回滚

WATCH 可用于监控 redis 变量值,在命令 EXEC 之前;redis 里的数据是有机会被其他客户端的命令修改的。使用 WATCH,监控的变量被修改后,执行 EXEC 时则会返回执行失败的 nil 回复

  1. redis> WATCH "name"
  2. OK
  3. redis> MULTI ### 此时name已被其他客户端的命令修改
  4. OK
  5. redis> SET "name" "lwl"
  6. QUEUED
  7. redis> EXEC
  8. (nil)

从严格意义上来说,redis 是没有事务的。因为事务必须具备四个特点:原子性(Atomicity),一致性(Consistency),隔离性(Isolation),持久性(Durability)。然后 redis 是做不到这四点,只是具备其中一些特征,redis的事务是个伪事务,而且不支持回滚。下面将为各位同学一一道来

原子性

从上面可以,事务的异常会发生在EXEC命令执行前、后

EXEC命令执行前:在命令入队时就报错,(如内存不足,命令名称错误),redis 就会报错并且记录下这个错误。此时,客户还能继续提交命令操作;等到执行EXEC时,redis 就会拒绝执行所有提交的命令操作,返回事务失败的结果 nil

EXEC命令执行后:命令和操作的数据类型不匹配,但 redis 实例没有检查出错误。在执行完 EXEC 命令以后,redis 实际执行这些指令,就会报错。此时事务是不会回滚的,但事务队列的命令还是继续被执行。事务的原子性无法保证

EXEC执行时,发生故障:如果 redis 开启了 AOF 日志,那么,只会有部分的事务操作被记录到 AOF 日志中。需要使用 redis-check-aof 工具检查 AOF 日志文件,这个工具可以把未完成的事务操作从 AOF 文件中去除。事务的原子性得到保证

一致性

EXEC命令执行前:入队报错事务会被放弃执行,具有一致性

EXEC命令执行后:实际执行时报错,错误的执行不会执行,正确的指令可以正常执行,一致性可以保证

EXEC执行时,发生故障:RDB 模式,RDB 快照不会在事务执行时执行,事务结果不会保存在RDB;AOF 模式,可以使用 redis-check-aof 工具检查 AOF 日志文件,把未完成的事务操作从 AOF 文件中去除。可以保证一致性

隔离性

EXEC 命令前执行,隔离性需要通过 WATCH 机制保证。因为 EXEC 命令执行前,其他客户端命令可以被执行,相关变量会被修改;但可以使用 WATCH 机制监控相关变量。一旦相关变量被修改,则 EXEC 后则事务失败返回;具有隔离性

EXEC 命令之后,隔离性可以保证。因为 redis 是单线程执行,事务队列里的命令和其他客户端的命令只能二选一被顺序执行,因此具有隔离性

持久性

如果 redis 没有使用 RDB 或 AOF,事务的持久化是不存在的

使用 RDB 模式,那么在一个事务执行后,而下一次的 RDB 快照还未执行前,如果发生了实例宕机,数据丢失,这种情况下,事务修改的数据也是不能保证持久化

AOF 模式,因为 AOF 模式的三种配置选项 no、everysec 和 always 都会存在数据丢失的情况。所以,事务的持久性属性也还是得不到保证

总结

redis 的事务机制可以保证一致性和隔离性;但是无法保证持久性;具备了一定的原子性,但不支持回滚

WATCH、EXEC 命令实现 redis 事务

  1. redis> WATCH "map"
  2. OK
  3. redis> MULTI
  4. OK
  5. redis> HSET map "csc" "lwl"
  6. QUEUED
  7. redis> HGET map "csc"
  8. QUEUED
  9. redis> EXEC
  10. 1) OK
  11. 2) "lwl"

lua 实现 redis 事务

除了 MULTI、WATCH、EXEC 命令,还有其他的方式可做到 redis 原子性和隔离性吗?有的,lua 脚本;redis 内置了lua的执行环境,并自带了一些 lua 函数库。redis 执行 lua 时,会启动一个伪客户端去执行脚本里的 redis 命令

一致性,原子性,持久性 和 MULTI,EXEC 过程相似:如果 lua 存在错误的命令名称,事务会执行失败。如果在执行 redis 命令过程出现异常,之前正常执行的命令也不会回滚

lua 脚本被当做一命令集合一起被执行,且 redis 是单线处理机制,因此不需要 WATCH 保证隔离性,天然具备隔离性

Lua调用Redis指令: redis.call("命令名称",参数1,参数2)

优点

减少网络开销:可以将多个请求通过脚本的形式一次发送,减少网络时延

原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其他请求插入。在脚本运行过程中无需担心会出现竞态条件

可重复使用:客户端发送的脚本会永久存在 redis 中,这样其他客户端可以复用这一脚本,而不需要使用代码完成相同的逻辑

抢红包方案

问题关键点

  • 一:用户是否参与过活动,不可重复参与
  • 二:红包数量有限;而且一个可抢的红包,保证不能让多个人同时抢到
  • 三:持久化存储红包与用户的关系
  • 四:如何保证 步骤一到步骤三的原子性和隔离性

关键点一

redis 的集合对象 set 是无序且唯一的。set 集合由整数集合或字典实现的,添加,删除,查找的复杂度基本视为 O(1),存放的最大对象个数是2^32 - 1 (4294967295)

使用 set 集合保存参加过的用户,每次用户参与活动时先判断是否在 set 里。不在则可以抢红包

如果是用户可以重复参与多次的场景,则使用哈希对象,key存用户对象,value 存放参与次数。使用 INCR 原子操作增加 value,如果返回数值 > 上限,说明抢的次数用完

关键点二

使用 list 或者 set 存放事先创建好的有限个红包;因为 redis 是单线程操作,同一时间,多人抢红包,只会有一个人成功。而红包是事先生成的,消费用完即止,不存在超发的可能

使用 list 列表存放红包

  • 因为红包金额大小不一,为增加抢到红包大小的随机性,需要先shuffle一次,再 LPUSH 入队列
  • RPOP 出队列一个红包,如果返回不为nil,则代表获取成功,继续下一步,反之则说明已抢完,返回

set 集合中有两个指令非常适合在抢红包、抽奖的场景使用

  • SPOP key [count] 移除并返回集合中的一个随机元素
  • SRANDMEMBER key [count] 返回集合中一个或多个随机数;需要再调 SREM 移除一遍
  • 将所有的红包通过 SADD 添加到 set 中,然后通过随机命令获取对应的红包即可

如果有谢谢惠顾之类的落空选项,生成对应的无效红包、奖品放入 set 或 list 即可

抢红包一般是有时效性,正好可以配合 redis 的 key 的失效时间使用。使得抢红包功能很完美的解决

关键点三

使用额外的 list 列表保存用户与红包的关系,用户抢到红包后,将对应的关系 LPUSH 入队列,然后服务去消费拉取数据批量保存到数据库即可

关键点四

使用 lua 脚本实现即可

  1. -- 参数:KEYS[1]-红包list,KEYS[2]-用户和红包的消费list,KEYS[3]-去重的哈希对象,KEYS[4]-用户ID
  2. -- 函数:尝试获得红包,如果成功,则返回json字符串,如果不成功,则返回nil
  3. -- 返回值:nil 或者 json字符串,{"userId":"用户ID","id":"红包ID"}
  4. -- 如果用户已抢过红包,则返回nil
  5.  
  6. -- 步骤一,拦截重复参与
  7. if redis.call('hexists', KEYS[3], KEYS[4]) == 1 then
  8. return nil
  9. else
  10. -- 步骤二,先取出一个红包
  11. local lunkMoney = redis.call('rpop', KEYS[1]);
  12. if luckMoney then
  13. local data = cjson.decode(luckMoney);
  14. data['userId'] = KEYS[4]; -- 加入用户ID信息
  15. local re = cjson.encode(data);
  16. -- 把用户ID放到去重的哈希,value设置为 1
  17. redis.call('hset', KEYS[3], KEYS[4], 1);
  18. -- 步骤三: 用户和红包放到已消费队列里
  19. redis.call('lpush', KEYS[2], re);
  20. return re;
  21. end
  22. end
  23. return nil

欢迎指正文中错误

参考文章

redis事务一致性问题?

Redis的ACID属性

抢红包设计

腾讯二面:Redis 事务支持 ACID 么?

原文链接:https://mp.weixin.qq.com/s/bDFxylvE7l1BKwM1ix86eg