现在多数秒杀,抽奖,抢红包等大并发高流量的功能一般都是基于 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 则事务被丢弃
- 事务开启,使用 MULTI 可以标志着执行该命令的客户端从非事务状态切换至事务状态
- 命令入队过程如果出错(如使用了不存在的命令),则事务队列会被拒接执行
- 执行事务期间出现了异常(如命令和操作的数据类型不匹配),事务队列的里的命令还是继续执行下去,直到全部命令执行完。不会回滚
- 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