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 回复
redis> WATCH "name"
OK
redis> MULTI   ### 此时name已被其他客户端的命令修改
OK
redis> SET "name" "lwl"
QUEUED
redis> EXEC
(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 事务

redis> WATCH "map"
OK
redis> MULTI 
OK
redis> HSET map "csc" "lwl"  
QUEUED
redis> HGET map "csc"
QUEUED
redis> EXEC
1) OK
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 脚本实现即可

-- 参数:KEYS[1]-红包list,KEYS[2]-用户和红包的消费list,KEYS[3]-去重的哈希对象,KEYS[4]-用户ID
-- 函数:尝试获得红包,如果成功,则返回json字符串,如果不成功,则返回nil
-- 返回值:nil 或者 json字符串,{"userId":"用户ID","id":"红包ID"}
-- 如果用户已抢过红包,则返回nil

-- 步骤一,拦截重复参与
if redis.call('hexists', KEYS[3], KEYS[4]) == 1 then
  return nil
else
  -- 步骤二,先取出一个红包
  local lunkMoney = redis.call('rpop', KEYS[1]);
  if luckMoney then
    local data = cjson.decode(luckMoney);
    data['userId'] = KEYS[4]; -- 加入用户ID信息
    local re = cjson.encode(data);
    -- 把用户ID放到去重的哈希,value设置为 1
    redis.call('hset', KEYS[3], KEYS[4], 1);
    -- 步骤三: 用户和红包放到已消费队列里
    redis.call('lpush', KEYS[2], re);
    return re;
  end
end
return nil

欢迎指正文中错误

参考文章