Redis由于其高性能、高可用、高拓展的特点,经常被用来搭建缓存服务器,缓解数据库压力。同时其特殊的操作命令,在业务应用方面也有着较为广阔的应用。Redis还可以通过搭建主从服务器和哨兵,或者是服务器集群,以提升服务器的可用性。
1. Redis的高性能
Redis的高性能从存储的位置来说,是因为存储位置位于内存中,但是从本质上来说,是通过高效的数据结构、多线程模型和多路复用机制epoll实现的。
1.1 数据结构
Redis的底层是一个巨大的Hash表,Hash表由于其出众的查询和插入,以及可以集合多种数据结构(如红黑树、链表等)的特点,为Redis的高效存储奠定了扎实的基础。
例如HashMap和CurrentHashMap中,当单个桶中的数据量到达了一定量后,会进行树化。因此,我们可以知道Redis的全局Hash表会根据各种用户的数据需求提供对应的存储结构,并根据存储的数据量进行相应的修改。
1.1.1 数据结构的底层实现
Redis主要的数据结构有String、Hash、List、Set和ZSet。除String类型外,所有的数据结构都可以根据数据量的多少进行切换。
为了减少内存空间的消耗,Hash、List、ZSet等数据结构,在数据量少的时候可以切换成zipList;Set集合在数据量少、且存储的数据为整形时则会切换成intset(整形数组)。
Hash的底层存储是HashTable,存储的是entry,可以高效对entry值进行存取。List的底层实现是双向链表,不需要进行排序,因而可以快速实现首尾的插入和删除操作,基于此特点,它可以模拟诸多数据结构,如栈、队列(消息队列)。Set的底层实现是HashTable,存储的是key值,可以高效对key值进行存取和去重。ZSet的底层实现是SkipList,可以维护排序序列,进行高效的查询删除操作
1.1.2 数据结构的应用
从Redis各种数据结构的应用来看,不同的数据结构适合不同的数据操作,在应用过程中需要注意。
[!NOTE] 为什么说String类型的特点是读写整体数据,而Hash的特点是查找和更新单个数据? 以存储对象类数据而言,如用户信息,使用Hash可以直接更新单个数据,而使用String类型的数据却只能够整体进行读写
| 数据结构 | 特点 | 应用 |
|---|---|---|
| String | 快速读写整体数据 | 缓存,计数器限流,分布式ID,分布式锁 |
| Hash | 快速查找和更新单个字段 | 存储对象类数据,统计类数据,购物车,分布式锁 |
| List | 快速的添加和删除操作 | 文章列表,用户消息时间线,消息队列 |
| Set | 快速的集合运算和去重功能 | 标签,抽奖,点赞,签到,交集并集关注 |
| ZSet | 快速的有序集合运算和排名功能 | 排行榜,按时间播放量点击等 |
1.2 多路复用机制
Redis的多路复用机制epoll,可以避免大量无用的系统调用,提高网络IO效率
同步阻塞式BIO和同步非阻塞式NIO的网络编程相比,后者采用非阻塞式,大大提高了效率,除此之外,后者通过多路复用机制,避免了频繁的系统调用消耗大量时间。
BIO进行系统调用后进入阻塞状态,等待数据到来。- 首先进行的尝试,就是系统调用后不进入阻塞状态,保证在等待数据和连接的过程中程序仍然可以正常运行,以提高效率。但是,在程序代码层面通过轮询查看是否有数据,需要对所有的连接都进行一次查询,且每次对连接的查询都要使用系统调用,严重影响性能。
- 为了避免重复的系统调用,提出多路复用机制
select,避免检查数据到来时,需要对所有的连接都都进行系统调用,select保证只需要进行一次系统调用就可以知道所有连接的是否有数据可读。但是,select在底层依然是依靠轮询的方式查找仍然会存在性能问题,且连接数有限。 - 为了解决连接数的问题,提出了
poll。 - 为了解决系统调用的底层使用轮询的方式查看数据情况,提出了
epoll,epoll在系统底层通过监听的方式检查是否有消息到来,大大提升了性能。但是,在底层查询数据状态时,epoll在调用epoll_ctl方法时,仍然会陷入阻塞。 - 为了解决查询数据状态时陷入阻塞状态的问题,于是就提出了基于信号的
SIGIO,发送信号后直接返回,等到数据准备好后再返回信号。此时已经有了异步的意思了,但是仍然还差一点,因为进程读数据时仍然会陷入阻塞状态。 - 而
AIO通过异步调用的方式,保证全流程不会陷入阻塞状态,等到系统底层将数据全部处理完成后直接通知即可。
1.3 线程模型
Redis的性能瓶颈在于网络IO,为了提高IO效率,提出了多线程模型,即一个主线程,多个IO线程。
2. 高可用
Redis数据存储在内存中,一旦系统发生宕机等突发性异常,会导致所有的数据丢失,需要进行数据持久化。虽然Redis拥有高并发的属性,但在实际的业务当中,请求量往往远远大于Redis能够承受的范围,为了保证Redis能够提供更高的并发能力、更加持久安全的服务,需要进行性能的横向扩展和容灾处理——主从复制、Sentinel、Cluster。
2.1 数据持久化
Redis的数据持久化机制分为RDB和AOF,RDB机制将所有的数据存储到一个文件中,AOF将所有的数据操作存储在一个文件中。
2.1.1 RDB机制
RDB通过将所有数据存储到一个文件中,并设置一定的存储间隔时长,实现数据的持久化。
RDB存储所有数据,存储一次消耗的时间久,同时存储间隔长,系统宕机后可能会导致严重的数据丢失现象。
2.1.2 AOF机制
AOF模仿MySql的redo-log,通过存储数据操作实现持久化。
AOF通过建立缓冲区存储数据操作记录,等待一定条件再将数据写入文件中,减少IO性能开销。AOF的存储间隔可以达到秒级,尽可能减少系统宕机导致的数据不一致现象。
2.1.2.1 AOF重写策略
随着时间的积累,数据操作越来越多,文件也越来越大,迫切需要减少数据的文件的容量。AOF的解决方法是启用重写策略,只记录数据的最终状态的操作,以减少存储数据操作的文件大小。具体操作是,写入数据至AOF文件后,额外有一个线程在后台进行重写,重写后替换原AOF文件。
但是这种方法存在替换前后数据不一致的现象,后台线程在重写过程中,主线程仍会接收数据操作,直接替换会造成数据不一致的情况发生。解决方法就是,使用rewrite-buffer记录重写过程中redis的数据操作,在重写完毕后,让重写文件进行补写。
2.2主从复制
为了提高Redis的可用性,避免一个缓存服务器宕机导致整个Redis服务不可用,提出了主从复制的概念。主从复制是一种数据复制技术,通过将主节点的数据复制一份给从节点,主节点负责写操作,从节点负责读操作和来自主节点更新操作,实现读写分离和冗余备份,提高系统的吞吐量。
2.2.1 主从复制流程
主从复制通过runId(记录主节点信息)和offset(记录数据偏移量)两个属性,实现全量复制和增量复制
2.2.1.1 全量复制
当某个节点无主节点时发生全量复制,体现出来就是runId为空,offset=-1
- 从节点发送空的
runId和offset=-1后,主节点接收到相应并确认是新节点后,返回本节点的runId和offset值,后台开启bgsave线程生成ROF文件(在此期间产生的新的数据操作存储在缓冲区中,不对ROF进行更新),从节点收到runId、offset并保存。 - 主节点将ROF文件发送给从节点,从节点文件后清空历史数据并执行相应文件,同时主节点发送buff中的数据操作至从节点,从节点进行更新。
2.2.1.2 数据同步
在进行了一次全量复制后,主从节点之间会建立一个TCP长连接,主节点依靠这个长连接向从节点传输写操作数据。
2.2.1.3 增量复制
由于网络抖动或分区,导致主节点与从节点是去联系,连接恢复后,主节点传输断联期间的写操作至从节点,称为增量复制,体现出来就是runId相等,offset不为-1
- 主节点会把断联期间收到的写操作命令写入
replication buffer,并将其中的数据写入至repl_backlog_buffer中。 - 从节点向主节点发送
psync命令,并携带已保存的runid和offset,主节点判断runid是否和自己的id一致,如果不一致则执行全量同步;主节点判断offset是否在环形缓冲区内,如果不在则执行全量同步。 - 主节点依靠
offset的差值,确定断联期间的写操作,主节点将断联期间的写操作传输给从节点。
2.3 哨兵机制
主从复制中,主节点如果出现宕机现象,需要人工进行修复或者手动指定节点,为了解决这个问题,提出了哨兵机制,实现对主节点的监控、故障转移和通知
2.3.1 监控机制
- 哨兵向哨兵、redis节点发送ping命令进行心跳检测,是判断下线的基础。
- 哨兵节点之间通过频道订阅确定和共享对于主节点的判断,是判断客观下线的依据。
- 哨兵节点向主节点与从节点发送info信息,确定主从节点的拓扑关系。
2.3.2 下线机制
- 主观下线:对于从节点,哨兵进行心跳检测时没有接收到信息,判定为主观下线。
- 客观下线:对于主节点,有超过半数的节点判定为主观下线,则判定为客观下线。
2.3.4 故障转移
一旦发生客观下线,就需要进行故障转移,首先确定哨兵节点的leader节点,从众多从节点中选出最合适的节点作为主节点,最后依靠leader节点发送命令,确定新的主节点,并重新建立拓扑关系。
2.3.4.1 选举机制
哨兵节点中,如果某节点有超过半数的投票,就确定为leader节点。
2.3.4.2 故障转移机制
- 从众多从节点中,确定最符合条件的节点为预备主节点。
- leader节点通过发送指令任命从节点为主节点,同时发送指令给从节点,与主节点建立联系。
2.4 集群
Redis集群的节点个数多,提高了服务的可靠性;将数据分散到各个节点中,提高系统的吞吐量,实现性能的横向扩展;节点与节点之间依靠gossip协议进行通信,实现去中心化、故障转移、节点增删。
2.4.1 槽位
Redis通过槽位机制实现数据分片,将数据分散到不同的节点中。
- 数据分片:通过计算数据的
hashCode,将数据分散到不同的槽位中。 - 节点增删:集群添加或删除节点时,不会影响全局槽位,只会导致部分节点的槽位和数据变化。
2.4.2 gossip通信
gossip是点对点通信,节点之间相互交换信息达到数据的传递和共享。
meet:通知有新节点的加入。ping:检测并传递节点的状态信息。pong:回复并传递节点的状态信息。fail:主节点A标记另一个主节点B处于下线状态时,就会发送fail信息,接收到fail信息的其它节点都会将该节点标记为下线状态。
2.4.2.1 节点增删
client通过发送指令,通知集群中任意一个节点A,关于新增节点B的ip和port。- 节点A发送
meet消息给新增节点B,B节点接收后回复pong命令,A回复ping命令,握手完成。 - 节点A通过gossip协议告知所有节点,关于新增节点B的
ip和port命令。
2.4.3 故障转移
当Redis的主节点出现故障时,其它主节点通过投票推举新的主节点
3. 高拓展
锁的本质是,通过设置资源个数,限制一段时间内资源的访问次数。
3.1 分布式锁
在分布式的应用中,各个服务模块隔离且资源不共享,进程级别的锁在分布式中无法使用。 在分布式中要通过锁实现服务之间的同步,就一定要保证,有一个服务的资源能够让分布式中所有的应用都能够访问——数据库脱颖而出,而Redis依靠其高可用、高性能的特点脱颖而出。
3.1.1 Redis锁的应用与升级
在实际开发中,Redis通过setnx指令,保证全局服务中只有唯一的线程能够拿到锁。
Redis通过setnx指令获取锁,使用delete释放锁。为了提高代码的健壮性,在程序运行的过程中,还需要考虑系统宕机、程序异常、线程阻塞、网络波动等异常造成的影响。
程序异常会导致线程关闭,锁不会被释放,发生死锁。- 设计
finally块强制释放锁。
- 设计
系统宕机会导致进程终止,锁不会被释放,发生死锁。- 设计锁的超时释放机制,使用
expire指令。
- 设计锁的超时释放机制,使用
- 设置锁和使用
expire指令不具备原子性,中途发生程序异常或系统宕机会导致expire不执行。仍然有可能发生死锁。- 保证设置锁和锁超时指令的原子性。
线程阻塞会导致锁超时释放,锁提前释放导致并发安全问题。- 设置令牌,保证各个线程设置的锁不会被误释放。
[!NOTE] 锁提前释放导致并发安全问题 线程A拿到锁并陷入阻塞,锁超时释放,线程B拿到锁并执行,线程A执行释放指令,这会导致线程B设置的锁会被提前释放,以此类推,这样还是会导致出现并发安全问题
线程阻塞导致令牌失效,锁的误释放引发并发安全问题。- 保证令牌判断和释放操作的原子性。
[!NOTE] 线程阻塞导致令牌失效 通过了令牌校验后,发生线程阻塞,引发异常释放。
线程阻塞导致锁超时释放,极端情况下,锁异常释放会导致并发安全问题。- 锁续命机制。设置锁超时释放是为了防止程序终止导致死锁问题,程序终止说明该进程中所有线程不会执行,换言之,锁续命机制既可以保证程序正常运行时不会发生超时释放,还可以保证程序终止时不再续命,可以正常超时释放,很好地解决了超时释放问题。
[!NOTE] 线程阻塞导致锁超时释放 当大量线程陷入阻塞,并导致锁超时释放后,线程一旦进入运行态,就会引发安全问题,