赞
踩
博主在工作中经常使用分布式缓存,公司的缓存中间件是自研的,但设计上是参考Redis的,研究透了Redis也就能搞懂公司自研的缓存中间件,《Redis设计与实现》是学习Redis的宝典,这本书博主学生时代看过,近期重新翻看,感触良多,感觉有必要把核心要点总结一下。
Redis的单线程,主要是指Redis的网络IO和键值对读写是由一个线程来完成的,这也是Redis对外提供键值存储服务的主要流程。但Redis的其他功能比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。
PS:Redis 6.0有一个非常受关注的关键新特性——面向网络处理的多IO线程,随着网络硬件的性能提升,Redis的性能瓶颈有时会出现在网络IO的处理上,也就是说,单个主线程处理网络请求的速度跟不上底层网络硬件的速度,Redis 6.0采用多个IO线程来处理网络请求,提高网络请求处理的并行度。
Redis持久化方案分为AOF(append only file)日志与RDB(内存快照)两种。
AOF日志是写后日志,记录的是Redis的命令语句,使用AOF还是会有两个风险:
以上两个风险都与AOF写回磁盘的时机有关,Redis提供三种写回策略:
如果不在意数据的丢失,只在意系统性能,可以选择No;
如果不在意数据的少量丢失,可以选择Everysec;
如果不能容忍数据的丢失,不在意系统性能,可以选择Always;
RDB内存快照就是某一个时刻的内存数据“拍”下来,生成一个rdb文件,其中rdb是指Redis DataBase。这时就算内存宕机了,rdb文件也不会丢失。rbd文件存储的是某一时刻的全量数据,所以只要将快照文件再读入内存,数据就会很快的恢复。
Redis为了保证数据的可靠性,默认使用的是全量快照。因为“拍”的是内存的全部数据, 生成的rdb文件很大,写入到磁盘中的时间就会比较长。
Redis默认使用bgsave命令创建一个子进程,专门用于写入rdb文件,避免了主线程的阻塞,bgsave子进程是由主线程fork生成的,可以共享主线程的所有内存数据。bgsave子进程运行后,开始读取主线程的内存数据,并把它们写入rdb文件。
在生成rdb文件期间,即快照期间不能修改数据怎么办?通过操作系统的写时复制技术。
Redis 4.0后就提出了同时使用内存快照和AOF文件进行数据备份的混合持久化机制,即在T0时刻进行快照,在下一次快照时间T1到达之前都使用AOF文件记录命令语句,到了T1时刻再次生成了快照,这时由于最新的全量快照已经有了,所以记录了T0到T1这段时间所有命令语句的AOF日志就不需要了,可以清空AOF日志,用来记录T1-T2期间的命令,如此循环往复。
这种方法避免了频繁的fork子进程对主线程的影响,同时AOF日志记录的是两次快照之间的,不需要记录全部日志,就不会出现日志文件过大,不会重写AOF日志,也避免了重复开销。这种方法,即体验了RDB快速恢复数据的好处,也体验了AOF日志只简单记录命令的优势。
默认的过期策略:惰性删除 + 定期删除
主动删除机制:定期删除
Redis内部维护一个定时任务,默认每隔100毫秒(每秒10次)会从过期字典中随机取出20个Key,删除其中过期的Key。如果过期Key的比例超过了25%,则继续获取20个Key,删除过期的Key,循环往复,直到过期Key的比例下降到 25% 或者这次任务的执行耗时超过了25毫秒,才会退出循环
注意,Redis的主动过期定时任务也是在Redis主线程(主线程为单线程模型)中执行的,也就是说如果在执行主动过期的过程中,出现了需要大量删除过期Key的情况, 那么在业务访问时,必须等这个过期任务执行结束,才可以处理业务请求。此时就会出现,业务访问延时增大的问题,最大延迟为25毫秒。而且这个访问延迟的情况,不会记录在慢日志里(因为它不是执行命令的真正耗时)。因为Redis中过期的Key是由主线程删除的,为了不阻塞用户的请求,所以删除过期Key的策略是少量多次。
主动删除机制带来的问题
Redis中如果有一批key同时过期,会导致其它key的读写效率降低。
惰性删除机制:get时再检查并删除
Redis不会在键值对过期的时候就立刻删除,这样做非常消耗CPU资源,而是在有外部指令get这个键的时候再判断是否过期,过期了就执行删除操作,然后再返回空。
为什么不用定时删除策略
定时删除虽然内存及时释放,但是十分消耗CPU资源。在大并发请求下,CPU要将时间应用在处理请求,而不是删除key,因此没有采用这一策略。
主从+哨兵(sentinel)部署模式可以解决Redis高可用问题,即当master故障后可以自动将slave提升为master,从而可以保证redis服务的正常使用,但是无法解决Redis单机读写并发量不够大且无法容纳海量数据的问题,单机Redis的性能受限于单内存大小、网卡速率等因素。 如果采用「纵向扩展」的思路去升级Redis服务器硬件能力,「超大内存」会导致Redis持久化时的“成本”巨大(RDB持久化是全量的,fork子进程时有可能由于使用内存过大,导致主线程阻塞时间过长),由于「纵向扩展」行不通,就只能「横向扩展」——用多个Redis实例来组成一个集群,按照一定的规则把数据「分发」到不同的Redis实例上,这就是“分片集群”。
Redis Cluster模式(也叫切片集群)是Redis提供的分布式数据存储方案,可以解决单机数据量瓶颈,并且不再需要配置单独的哨兵(sentinel),集群通过数据分片sharding来进行数据的共享,同时提供复制和故障转移的功能。
一个切片集群共有16384个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的key通过CRC16运算后与16384取模,将key分布到固定槽位中(0-16383),每台Redis分别保管不同的槽位,在创建集群时可自动把这些槽平均分布在集群实例上,也可手动分配或按比重分配。另外集群模式中的各个节点也是主从模式,并且通常是一主多从,例如3个节点的集群,其中单个节点是1主2从,全体集群总计3主6从。
Redis集群中并没有sentinel哨兵节点,如何实现高可用呢?哨兵模式的自动故障转移能力为其提供高可用保障,同样的,为了提供集群的可用性,Redis Cluster提供了自动故障检测及故障转移能力,两者在设计思想上有很大的相似之处。
Redis Cluster作为无中心的分布式系统,集群容错机制依靠各个节点共同协作通过心跳机制维护集群状态。在集群中,每两个节点之间通过PING和PONG两种类型的消息保持心跳,心跳动作周期性触发,心跳消息在集群节点两两之间以“我知道的给你,你知道的给我”这样“瘟疫传播”的方式传播、交换信息,可以保证在短时间内节点状态达成一致。
如果节点A向节点B发送ping消息,节点B没有在规定的时间内响应ping,那么节点A会标记节点B为pfail疑似下线状态,同时把B的状态通过消息的形式发送给其他节点,如果超过半数以上的节点都标记B为pfail状态,B就会被标记为fail下线状态,此时将会发生故障转移,优先从复制数据较多的从节点选择一个成为主节点,并且接管下线节点的slot,整个过程和哨兵非常类似,都是基于Raft协议做选举。
首先,Redis实例会把自己的哈希槽信息发给集群中和它相连的其它实例,来完成哈希槽分配信息的扩散。客户端和Redis集群建立连接后,Redis实例就会把哈希槽的分配信息发给客户端,客户端将哈希槽信息缓存在本地,当客户端请求键值对时,会先计算key所对应的哈希槽,然后就可以给相应的实例发送请求了。
当集群删除或者新增Redis实例时,如何感知最新的哈希槽与实例的关系呢?当集群扩缩容时,就会有某Redis实例所负责的哈希槽关系会发生变化,发生变化的信息会通过消息发送至整个集群中,所有的Redis实例都会知道该变化,然后更新自己所保存的映射关系,但这时候,客户端其实是不感知的,当客户端请求时某Key时,还是会请求到「原来」的Redis实例上。而原来的Redis实例会返回「moved」命令,告诉客户端应该要去新的Redis实例上去请求。
那就是扩缩容,我们使用哈希槽时,增加减少Redis节点就会很方便,如果我们想要新添加个节点D, 我们只需要从之前的节点分部分哈希槽到节点D上。 如果我想移除某个节点,只需要将该节点中的哈希槽移到另外两个节点上,然后将该节点从集群中移除即可.。
集群的扩容、缩容都是以「哈希槽」作为基本单位进行操作,总的来说就是实现更加简洁、高效。另外,由于数据存储在哪个槽位是固定的,所以,数据恢复的逻辑比起一致性哈希,要直接明了。
一致性哈希的节点分布基于圆环,无法很好的手动设置数据分布,比如有些节点的硬件差,希望少存一点数据,这种很难操作。而哈希槽可以很灵活的配置每个节点占用哈希槽的数量。
一个成熟的数据库通常都会有事务支持,Redis也不例外。Redis 的事务使用非常简单,不同于关系数据库,我们无须理解那么多复杂的事务模型,就可以直接使用。不过也正是因为这种简单性,它的事务模型很不严格,这要求我们不能像使用关系数据库的事务一样来使用Redis的事务。
每个事务的操作都有begin、commit和rollback,begin指示事务的开始,commit指示事务的提交,rollback指示事务的回滚。
Redis在形式上看起来也差不多,分别是multi/exec/discard。multi指示事务的开始,exec指示事务的执行,discard指示事务的丢弃。
> multi
OK
> incr books
QUEUED
> incr books
QUEUED
> exec
(integer) 1
(integer) 2
上面的指令演示了一个完整的事务过程,所有的指令在exec之前不执行,而是缓存在服务器的一个事务队列中,服务器一旦收到exec指令,才开执行整个事务队列,执行完毕后一次性返回所有指令的运行结果。因为 Redis的单线程特性,它不用担心自己在执行队列的时候被其它指令打搅,可以保证他们能得到的「原子性」执行。上图显示了以上事务过程完整的交互效果。QUEUED是一个简单字符串,同OK是一个形式,它表示指令已经被服务器缓存到队列里了。
事务的原子性是指要么事务全部成功,要么全部失败,那么Redis 事务执行是原子的么?来看一个特别的例子。
> multi OK > set books iamastring QUEUED > incr books QUEUED > set poorman iamdesperate QUEUED > exec 1) OK 2) (error) ERR value is not an integer or out of range 3) OK > get books "iamastring" > get poorman "iamdesperate
上面的例子是事务执行到中间遇到失败了,因为我们不能对一个字符串进行数学运算,事务在遇到指令执行失败后,后面的指令还继续执行,所以poorman的值能继续得到设置。到这里,你应该明白Redis的事务根本不能算「原子性」,而仅仅满足了exec指令执行的串行化——当前执行的事务有着不被其它事务打断的权利。
Redis也没有提供回滚机制,虽然Redis提供了DISCARD命令,但是,这个命令只能用来主动放弃事务执行,把暂存的命令队列清空,起不到回滚的效果。
> get books
(nil)
> multi
OK
> incr books
QUEUED
> incr books
QUEUED
> discard
OK
> get books
(nil)
我们可以看到discard之后,队列中的所有指令都没执行,就好像multi和discard中间的所有指令从未发生过一样。
Redis是内存数据库,所以数据是否持久化保存完全取决于Redis的持久化配置模式。
如果Redis没有使用RDB或AOF,那么事务的持久化属性肯定得不到保证。
如果Redis使用了RDB模式,那么,在一个事务执行后,而下一次的RDB快照还未执行前,如果发生了实例宕机,这种情况下,事务修改的数据也是不能保证持久化的。
如果Redis采用了AOF模式,因为AOF模式的三种配置选项no、everysec和always都会存在数据丢失的情况,所以,事务的持久性属性也还是得不到保证。
隔离性事务的隔离性保证,会受到和事务一起执行的并发操作的影响。而事务执行又可以分成命令入队(EXEC 命令执行前)和命令实际执行(EXEC 命令执行后)两个阶段,我们就针对这两个阶段,分成两种情况来分析:并发操作在 EXEC命令前执行,此时在不引入WATCH机制前提下隔离性无法保证;并发操作在 EXEC命令后执行,此时隔离性可以保证。
考虑到一个业务场景,Redis存储了我们的账户余额数据,它是一个整数。现在有两个并发的客户端要对账户余额进行修改操作,这个修改不是一个简单的incrby指令,而是要对余额乘以一个倍数。Redis可没有提供 multiplyby这样的原子指令。我们需要先取出余额然后在内存里乘以倍数,再将结果写回Redis,这就会出现并发问题,因为有多个客户端会并发进行操作。我们可以通过Redis的分布式锁来避免冲突,这是一个很好的解决方案。分布式锁是一种悲观锁,那是不是可以使用乐观锁的方式来解决冲突呢?
Redis的watch就是一种乐观锁。有了watch我们又多了一种解决并发修改的方法,watch的使用方式如下。
while True:
do_watch()
commands()
multi()
send_commands()
try:
exec()
break
except WatchError:
continue
watch命令会在事务开始之前盯住1个或多个关键变量,当事务执行时,也就是服务器收到了exec指令要顺序执行缓存的事务队列时,Redis会检查关键变量自watch之后,是否被修改了 (包括当前事务所在的客户端)。如果关键变量被人动过了,exec指令就会返回null回复告知客户端事务执行失败,这个时候客户端一般会选择重试。
> watch books
OK
> incr books # 被修改了
(integer) 1
> multi
OK
> incr books
QUEUED
> exec # 事务执行失败
(nil)
事务应确保数据库的状态从一个一致状态转变为另一个一致状态。一致状态的含义是数据库中的数据应满足完整性约束,写入的数据必须完全符合所有的预设规则。
事务的一致性保证会受到错误命令、实例故障的影响,分三种情况来看。
所以,总结来说,在命令执行错误或Redis发生故障的情况下,Redis事务机制对一致性属性是有保证的。
Redis事务与传统的关系型数据库事务有些许不同,Redis不支持事务回滚机制。事务的持久性取决于Redis自身选择的持久化方式,生产上我们基本不会选择效率低下的appendfsync=Always模式,因此,一般不具备持久性。
Redis作者在其文章中也提到,Redis Lua脚本似乎正在取代Redis事务,毕竟作为后起之秀的Redis Lua脚本远比Redis事务应用广泛,大势所趋,也许不久的将来Redis事务相关代码将会被下掉。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。