集群
单实例redis架构
最开始的一主N从加上读写分离,Redis作为缓存单实例貌似也还不错,并且有Sentinel哨兵机制,可以实现主从故障迁移。
单实例一主两从+读写分离结构:
如果单实例只作为缓存使用,那么除了在服务故障或者阻塞时会出现缓存击穿问题,可能会有很多请求一起搞死MySQL。
如果单实例作为主存,那么问题就比较大了,因为涉及到持久化问题,无论是bgsave还是aof都会造成刷盘阻塞,此时造成服务请求成功率下降,这个并不是单实例可以解决的,因为由于作为主存储,持久化是必须的。
所以我们期待一个多主多从的Redis系统,这样无论作为主存还是作为缓存,压力和稳定性都会提升
集群和分片
官方集群引入slot的概念进行数据分片,之后将数据slot分配到多个Master结点,Master结点再配置N个从结点,从而组成了多实例sharding版本的官方集群架构。
Redis Cluster 是一个可以在多个 Redis 节点之间进行数据共享的分布式集群,Redis-Cluster采用无中心结构,每个节点保存数据和整个集群状态,每个节点都和其他所有节点连接。在服务端,通过节点之间的特殊协议进行通讯,这个特殊协议就充当了中间层的管理部分的通信协议,这个协议称作Gossip流言协议。
分布式系统一致性协议的目的就是为了解决集群中多结点状态通知的问题,是管理集群的基础,如图展示了基于Gossip协议的官方集群架构图:
redis集群工作原理
Cluster中的每个节点都维护一份在自己看来当前整个集群的状态,主要包括:
- 当前集群状态
- 集群中各节点所负责的slots信息,及其migrate状态
- 集群中各节点的master-slave状态
- 集群中各节点的存活状态及不可达投票
基于Gossip协议当集群状态变化时,如新节点加入、slot迁移、节点宕机、slave提升为新Master,我们希望这些变化尽快的被发现,传播到整个集群的所有节点并达成一致。节点之间相互的心跳(PING,PONG,MEET)及其携带的数据是集群状态传播最主要的途径。
- 所有的redis节点彼此互联(PING-PONG机制),内部使用二进制协议优化传输速度和带宽。
- 节点的fail是通过集群中超过半数的节点检测失效时才生效。
- 客户端与redis节点直连,不需要中间proxy层.客户端不需要连接集群所有节点,连接集群中任何一个可用节点即可。
- redis-cluster把所有的物理节点映射到
[0-16383]
slot上(不一定是平均分配),cluster 负责维护node<->slot<->value。 - Redis集群预分好16384个桶,当需要在 Redis 集群中放置一个 key-value 时,根据
CRC16(key) mod 16384
的值,决定将一个key放到哪个桶中
数据同步机制
单点故障
作为一个高可用的缓存系统单点宕机是不允许的,因此就出现了主从架构,对主节点的数据进行多个备份,如果主节点挂点,可以立刻切换状态最好的从节点为主节点,对外提供写服务,并且其他从节点向新主节点同步数据,确保整个Redis缓存系统的高可用。
如图展示了一个一主两从读写分离的Redis系统主节点故障迁移的过程,整个过程并没有停止正常工作,大大提高了系统的高可用
redis默认使用异步复制
客户端与服务端的交互
redis实例运行于单独的进程,应用系统和redis通过redis协议进行交互,在redis协议上,客户端和服务器可以实现多种典型的交互模式:
串行的请求/响应模式,双工的请求/响应模式,原子化的批量请求/响应模式(事务)、发布/订阅模式、脚本化的批量执行。
客户端/服务端协议
redis的交互协议分为两部分,网络模型和序列化协议。前者讨论数据交互的组织方式,后者讨论数据本身如何序列化。
- 网络交互
redis协议位于tcp层之上,即客户端和redis实例保持双工的连接
客户端和服务器端交互的内容是序列化后相应类型的协议数据,服务器端为每个客户端建立对应的连接,在应用层维护一系列状态保存在connection
,connection
间相互无关联。在redis中,connection通过redisClient结构体实现(全双工:在发送数据的时候也能接受数据;半双工:发送数据和接受数据不能同时进行)
- 序列化协议
客户端/服务器端交互的是序列化后的协议数据,在redis中,协议数据分为不同的类型,每种类型的数据均以CRLF(\r\n)结束,通过数据的首字符区分类型
(1)inline command
这类数据表示redis命令,首字符为redis命令名的字符,格式为str1 str2 str3。例如:”EXISTS key 1”,首字符为’E’,表示redis检查key1是否存在这个命令。
命令和参数以空格分割
(2)simple command(回复)
首字符为’+’,后续字符为string内容,且该string不能包含’\r’或’\n’两个字符;最后以’\r\n’结束。例如”+OK\r\n”这5个字符,表示”OK”这个string数据。
simple string本身不包含转移,所以客户端的反序列化效率很高,直接将’+’和最后两个字节’\r\n’之间的内容拷贝即可。
(3)bulk string(字符串)
对于string本身内容包含了’\r’或者’\n’的情况,simple string不再适用。解法通常有两种:转义和长度自描述。前者使得反序列化效率低下(需要遍历每一个字节),
redis采用的是后者,称为bulk string。bulk string首字符为’$’,紧随其后的是string数据的长度,’\r\n’之后紧跟着string的内容本身(可以包含包括’\r’和’\n’),
最后以’\r\n’结束。例如:”$12\r\nhello\r\nworld\r\n”.
对于”空字符串和null”,要通过’$’之后的数字进行区分:”$0\r\n\r\n”这六个字节表示空字符串,”$-1\r\n”这五个字节表示null
(4)error(错误)
对于服务器端返回的内容,客户端需要有简单的手段识别它是正常执行结果还是异常信息,并对此分别处理。
error的首字符为’-‘,
(5)integer(整数)
以”:”字符开头,紧跟着整形数字本身,最后”\r\n”结尾。
(6)array(数组)
以”_”字符开头,紧跟着数组的长度。例如:”_2\r\n+abc\r\n:9\r\n”表示一个长度为2的数组[“abc”,”9”]
数组长度为”0”或者”-1”分别表示空数组或者null
数组的元素本身也可以是数组,多级数组其实是树状结构,采用类似先序遍历的方式序列化,例如:”_2\r\n_2\r\n:1\r\n:2\r\n*1\r\n+abc\r\n”表示[[1,2],[“abc”]]
(7)C/S两端使用的协议数据类型
由客户端发送给服务器端的类型:inline command、由bulk string组成的array。
由服务器端发送给客户端的类型为除了inline command之外的所有类型,并根据客户端命令或者交互模式的不同进行确定。例如
- 请求/响应模式下,对客户端发送的EXISTS key 1命令,返回interger型数据
- 发布/订阅模式下,对channel订阅者推送的信息,采用array型数据
请求/响应模式
对上述数据结构的基本操作,都是通过请求响应模式完成的。同一个连接上,请求/响应模式如下:
- 交互方向:客户端发送请求数据,服务器发送响应数据;
- 对应关系:每一个请求数据有且仅有一个对应的响应数据
- 时序:响应数据的发送发生在”服务器完全接收到其对应的请求数据”之后
- 串行化实现
最简单的实现方式为串行化实现,即在同一个连接上,客户端收完第一个请求响应后,再发起第二个请求。
这种串行化问题在于,每一次请求的发送都依赖于上一次请求的响应结果完全接收,同一个连接的每秒吞吐量低。
redis对单个请求的处理时间通常比局域网的延迟小一个量级,因此在串行化模式下,单连接的大部分时间都处于网络等待,没有充分利用服务器的处理能力。
- pipeline
由于依赖的tcp协议本身是全双工的,请求/响应即便穿插进行,也不会发生请求和响应数据的混淆,因此可以将请求数据批量发送到服务器,
再批量地从服务器连接的字节流中依次读取每个响应数据,可极大地提高单连接吞吐量
【这种模式适合于批量的独立写入操作(每次写入的数据值不依赖于上一次请求的执行结果)】
pipeline的实现取决于客户端,需要考虑以下方面:
- 通过批量请求发送还是异步化请求发送来实现;
- 非异步化的批量发送下需要考虑每个批次的数据量,避免连接buffer满之后的死锁
- 对使用者如何封装接口,使得pipeline使用简单。
- 事务模式
当同时存在多个客户端时,一个客户端批量发送的每一条命令和另一个客户端的命令在redis服务器端看来是同等的,其执行顺序可能存在交叉,
类似于多个串行请求/响应模式的客户端并发发送命令的结果。当我们需要将批量执行的语句原子化时,需要引入redis的事务模式。
一次事务的多条命令以原子化的方式执行,不同命令相互时序不再交叉。
(1)入队/执行分离的事务原子性
客户端通过和redis服务器两阶段的交互做到批量命令原子化执行的事务效果
- 入队阶段:客户端将请求发送到服务端,后者将其暂存在连接对象对应的请求队列中
- 执行阶段:发送完一个批次的所有请求后,redis服务器依次执行连接队列中的所有请求。由于单个实例的redis仅单线程执行所有请求,
一个连接的请求在执行批量请求的过程中,不会执行其他客户端的请求
由于redis执行器单线程的一次执行的粒度是”命令”,所以为了让批量的请求一次性全部执行,引入”批量执行命令”:EXEC
由MUTIL命令开启事务,随后发送的请求都只暂存在服务器端的连接上,最后通过EXEC一次性批量执行(发生实际的数据修改),并将所有执行结果作为一个响应,
以array类型的协议数据返回给客户端
(2)事务的一致性
当入队阶段出现语法错误时,不执行后续的EXEC,不会对数据产生实际影响。当EXEC中有一条请求执行失败时,后续请求继续执行,只在返回客户端的array型响应中
标记这条出错的结果,由客户端的应用程序决定如何恢复,redis本身不包含回滚机制(执行到一半的批量操作必须继续执行完)。
回滚机制的缺失使得redis的事务实现极大地简化:无须为事务引进数据版本机制,无须为每个操作引入逆操作。所以严格来讲,redis的事务并不是一致的。
(3)事务的只读操作
批量请求在服务器端一次性执行,应用程序需要一开始就在入队阶段(未真正执行时)确定每次写操作的值,也就是说,每个请求的参数取值不能依赖上一次请求的执行结果
只读操作放在批量执行中没有任何意义:它的结果既不会改变事务的执行行为、也不会改变redis的数据状态。所以入队的请求应该全是写操作。
一个事务常常需要包含只读操作,应用程序根据只读操作的结果控制事务的流程或者后序操作的参数。例如a账号转账给b账号10元,业务事务的逻辑如下
100 <== GET a
100 <== GET b
OK <== MUTIL
QUEUED <== SET b 110
QUEUED <== SET a 90
[1,1] <== EXEC
然而,操作是作为独立命令执行的,如果穿插了其他客户端的其他语句,可能会导致最终结果不一致(事务覆盖)
(4)乐观锁的可串行化事务隔离
redis通过WATCH机制实现乐观锁解决上述一致性问题:
- 将本次事务涉及的所有key注册未观察模式,假设此时逻辑时间为tstart;
- 执行只读操作
- 根据只读操作的结果组装写操作命令并发送到服务器端入队
- 发送原子化的批量命令EXEC试图执行连接的请求队列中的命令,假设此时逻辑时间为tcommit
执行时有以下两种情况:
- 假设前面注册为观察模式的key有一个或多个,在tstart和tcommit之间被修改过,那么EXEC将直接失败,拒绝执行(此时客户端必须重试业务事务以完成需要的操作)
- 否则顺序执行
(5). 事务实现
事务的状态保存在redisClient中,通过两个属性控制:
typedef struct redisClient{
int flags;
mutilState mstate;
}redisClient
flag包含多个bit,其中两个bit分别标记了:当前连接处于MUTIL和EXEC之间,当前连接的WATCH之后到现在它所观察的key是否被修改过
watch机制通过维护在redisDB中的全局map实现
(6)事务交互模式
综上,一个连接上的事务,交互模式如下:
- 客户端发送四类请求:监听相关(WATCH、UNWATCH)、只读请求、写请求的批量执行或放弃执行请求(EXEC/DISCARD)、写请求的入队(MUTIL和EXEC/DISCARD之间)
- 交互时序为:开启对读写主键的监听、只读操作、MUTIL请求、根据前面只读操作的结果编排/参数赋值/入队写操作、一次性批量执行队列中的写操作
通过上述模式,事务隔离级别可以达到可串行化级别
- 脚本模式
redis允许客户端向服务器提交一个脚本,后者结构化地(分支,循环)编排业务事务中的多个redis操作,脚本还可获取每次操作的结果作为下次操作的入参。
使得服务器端的逻辑嵌入成为可能。
- 发布/订阅模式
redis还有一种交互模式是一个客户端触发,多个客户端被动接收,通过服务器的中转,称为发布/订阅模式
发布端和订阅端通过channel关联
channel分为两类:
- 普通channel:订阅者通过SUBSCRIBE/UNSUBSCRIBE将自己绑定/解绑到某个channel,发布者的publish命令指定某个消息发送到哪个channel,再由channel转发
- pattern channel:订阅者通过PSUBSCRIBE/PSUBSCRIBE将自己绑定/解绑到某个pattern channel上;发布者的publish命令指定某个信息发送到某个channel,再转发
单机处理逻辑
面对高吞吐量的访问需求,同一个db这个key-value的hashtable面临着来自客户端的并发访问。为了保证客户端并发访问时hashtable的线程安全,redis单线程地处理
来自所有客户端地并发请求
redis服务器端对于命令地处理是单线程地的,但是I/O层面却同时面对多个客户端并发地提供服务,并发到内部单线程的转化通过多路复用框架实现。
持久化
redis对外提供数据访问服务时使用的时贮存在内存中的数据,这些数据将在redis重启后消失。为了让数据在重启后得以恢复,redis具备将数据持旧化到本地磁盘的能力。
- 基于全量模式的持久化(RDB)
基于全量的持久化即在持久化触发的时刻,将当时的状态(所有db的key-value值)完全保存下来,形成一个snapshot。当redis重启时,通过加载最近一个snapshot,
可将redis恢复至最近一次持久化时的状态上。
该持久化默认开启,一次性把redis中全部的数据保存一份存储在硬盘中,如果数据非常多(10-20G)就不适合频繁该持久化操作。
快照持久化在本地硬盘自动保存的文件名默认叫dump.rdb。
写入:
SAVE可以由客户端显式触发,也可以在redis shutdown时触发(单线程串行化地执行一个一个命令)
save 900 1
save 300 10
save 60 10000
BGSAVE写入磁盘快照的状态源于子进程fork时的redis数据状态,因此父进程一旦完成fork,后续执行的新的客户端命令对数据状态产生变更将不会反映在本次快照文件中(手动发起快照持久化操作指令:)
./redis-cli bgsave
#如果指定地址:
./redis-cli -h ip -p port bgsave
BGSAVE和SAVE相比优势时持久化期间可以持续提供数据读写服务,作为代价,子进程fork时,涉及父进程内存的复制,其存在期间增加服务器内存的开销。
使用BGSAVE需要保证redis服务器空闲内存足够
恢复:
redis1从本地磁盘加载持久化之前的文件。
- 基于增量模式持久化(AOF)
基于增量持久化数据,可以通过对定初始状态之后的变迁回放,恢复出数据的终态
本质:把用户执行的每个”写”指令(添加、修改、删除)都备份到文件中,还原数据的时候就是执行具体写指令而已;
开启AOF持久化(注意会清空redis已有的内部数据):在redis-conf配置文件中开启并保存持久化文件(默认为appendonly.aof)
配置文件被修改,需要杀死旧进程,再根据新的配置文件启动;
配置信息
- appendsync everysec 每秒追加一次
- appendsync always 每次收到写命令就立即强制写入磁盘,最慢的,但是保证完全的持久化,不推荐使用
- appendsync no 完全依赖os,性能最好,持久化没保证
增量备份的db在本地硬盘名为appendonly.rdb
分布式redis
redis作为数据存储系统,无论数据存储在内存中还是持久化到本地,作为单实例结点,会存在以下问题:
- 数据量伸缩:单实例redis存储的key-value对的数量受限于单机的内存和磁盘容量。
- 访问量伸缩:单实例redis单线程地运行,吞吐量受限于单次请求处理的平均时耗
- 单点故障
基于上述问题,基于分布式的解决方案如下:
- 水平拆分:分布式环境下,结点分为不同的分组,每个分组处理业务数据的一个子集,分组之间的数据无交集
- 主备复制:同一份业务数据存在多个副本,对数据的每次访问根据一定规则分发到某一个或多个副本上执行。
- 故障转移:当业务数据所在结点故障时,这部分业务数据转移到其他节点上进行,使得故障节点在恢复期间,对应的业务数据仍然可用。