一、事务
可以说是,Redis的事务相当于非常青春版的MySQL了,因为MySQL的原子性是有回滚机制的,所以隐式地提高了原子性的门槛,而Redis的事务是没有回滚机制的,也就是我打包把数据都发送而出错了我做不到恢复到初始状态。
• 弱化的原⼦性: redis 没有 “回滚机制”. 只能做到这些操作 “批量执⾏”. 不能做到 “⼀个失败就恢复到初始状态”.
• 不保证⼀致性: 不涉及 “约束”. 也没有回滚. MySQL 的⼀致性体现的是运⾏事务前和运⾏后 , 结果都是合理有效的, 不会出现中间⾮法状态.
• 不需要隔离性: 也没有隔离级别, 因为不会并发执⾏事务 (redis 单线程处理请求) .
• 不需要持久性: 是保存在内存的. 是否开启持久化, 是redis-server ⾃⼰的事情, 和事务⽆关.
Redis 事务本质上是在服务器上搞了⼀个 “事务队列”. 每次客⼾端在事务中进⾏⼀个操作, 都会把命令先发给服务器, 放到 “事务队列” 中,但是并不会⽴即执⾏。⽽是会在真正收到 EXEC 命令之后, 才真正执⾏队列中的所有操作。
命令合集:
MULTI – 开启⼀个事务. 执⾏成功返回 OK。
EXEC – 真正执⾏事务。
DISCARD – 放弃当前事务. 此时直接清空事务队列. 之前的操作都不会真正执⾏到。
WATCH – 在执⾏事务的时候, 如果某个事务中修改的值, 被别的客⼾端修改了, 此时就容易出现数据不⼀致的问题。
watch 在该客⼾端上监控⼀组具体的 key.
• 当开启事务的时候, 如果对 watch 的 key 进⾏修改, 就会记录当前 key 的 “版本号”. (版本号是个简单的整数, 每次修改都会使版本变⼤. 服务器来维护每个 key 的版本号情况)
• 在真正提交事务的时候, 如果发现当前服务器上的 key 的版本号已经超过了事务开始时的版本号, 就会让事务执⾏失败. (事务中的所有操作都不执⾏)。利用版本号能知道哪个操作是最晚执行的,这样就不会导致后执行的操作没有作用的情况,避免了歧义。
下图就是正确的了,因为晚执行的肯定要覆盖前面执行的命令的。
UNWATCH – 取消对 key 的监控。
二、主从复制
参与复制的 Redis 实例划分为主节点(master)和从节点(slave)。每个从结点只能有⼀个主节点,⽽⼀个主节点可以同时具有多个从结点。复制的数据流是单向的,只能由主节点到从节点。配置复制的⽅式有以下三种:
- 在配置⽂件中加⼊ slaveof {masterHost} {masterPort} 随 Redis 启动⽣效。
- 在 redis-server 启动命令时加⼊ --slaveof {masterHost} {masterPort} ⽣效。
- 直接使⽤ redis 命令:slaveof {masterHost} {masterPort} ⽣效。
- 可以通过 info replication 命令查看复制相关状态。
断开复制
slaveof 命令不但可以建⽴复制,还可以在从节点执⾏ slaveof no one 来断开与主节点复制关系。
例如在 6380 节点上执⾏ slaveof no one 来断开复制。
断开复制主要流程:
1)断开与主节点复制关系。
2)从节点晋升为主节点。
从节点断开复制后并不会抛弃原有数据,只是⽆法再获取主节点上的数据变化。
性质:
安全性:可以通过配置requirepass密码来进行密码验证,所有访问用户都需要输入密码进行校验才能进行访问。
只读:默认情况下,从节点使⽤ slave-read-only=yes 配置为只读模式。由于复制只能从主节点到从节点,对于从节点的任何修改主节点都⽆法感知,修改从节点会造成主从数据不⼀致。所以建议线上不要修改从节点的只读模式。
传输延迟
主从节点⼀般部署在不同机器上,复制时的⽹络延迟就成为需要考虑的问题,Redis 为我们提供了 repl-disable-tcp-nodelay 参数⽤于控制是否关闭 TCP_NODELAY,默认为 no,即开启 tcp_nodelay 功能,说明如下:
• 当关闭时,主节点产⽣的命令数据⽆论⼤⼩都会及时地发送给从节点,这样主从之间延迟会变⼩,但增加了⽹络带宽的消耗。适⽤于主从之间的⽹络环境良好的场景,如同机房部署。
• 当开启时,主节点会合并较⼩的 TCP 数据包从⽽节省带宽。默认发送时间间隔取决于 Linux 的内核,⼀般默认为 40 毫秒。这种配置节省了带宽但增⼤主从之间的延迟。适⽤于主从⽹络环境复杂的场景,如跨机房部署。
拓扑结构:
一主一从 – 简单,就是一个主节点一个从节点
一主多从 – 一个主节点很多个从节点围绕着它
树形拓扑 – 像树状一样,主节点下有从节点,从节点下也有从节点。
主从节点建⽴复制流程图:
1)保存主节点(master)的信息
开始配置主从同步关系之后,从节点只保存主节点的地址信息,此时建⽴复制流程还没有开始!
从统计信息可以看出,主节点的 ip 和 port 被保存下来,但是主节点的连接状态(master_link_status)是下线状态。
2)从节点(slave)内部通过每秒运⾏的定时任务维护复制相关逻辑,当定时任务发现存在新的主节点后,会尝试与主节点建⽴基于 TCP 的⽹络连接。如果从节点⽆法建⽴连接,定时任务会⽆限重试直到连接成功或者⽤⼾停⽌主从复制。
3)发送 ping 命令。连接建⽴成功之后,从节点通过 ping 命令确认主节点在应⽤层上是⼯作良好的。如果 ping 命令的结果 pong 回复超时,从节点会断开 TCP 连接,等待定时任务下次重新建⽴连接。
4)权限验证。如果主节点设置了 requirepass 参数,则需要密码验证,从节点通过配置 masterauth参数来设置密码。如果验证失败,则从节点的复制将会停⽌。
5)同步数据集。对于⾸次建⽴复制的场景,主节点会把当前持有的所有数据全部发送给从节点,这步操作基本是耗时最⻓的,所以⼜划分称两种情况:全量同步和部分同步。
6)命令持续复制。当从节点复制了主节点的所有数据之后,针对之后的修改命令,主节点会持续的把命令发送给从节点,从节点执⾏修改命令,保证主从数据的⼀致性。
全量复制和部分复制:
Redis 使⽤ psync 命令完成主从数据同步,同步过程分为:全量复制和部分复制。
• 全量复制:⼀般⽤于初次复制场景,Redis 早期⽀持的复制功能只有全量复制,它会把主节点全部数据⼀次性发送给从节点,当数据量较⼤时,会对主从节点和⽹络造成很⼤的开销。
• 部分复制:⽤于处理在主从复制中因⽹络闪断等原因造成的数据丢失场景,当从节点再次连上主节点后,如果条件允许,主节点会补发数据给从节点。因为补发的数据远⼩于全量数据,可以有效避免全量复制的过⾼开销。
PSYNC 的语法格式:
PSYNC replicationid offset
如果 replicationid 设为 ? 并且 offset 设为 -1 此时就是在尝试进⾏全量复制.
如果 replicationid offset 设为了具体的数值, 则是尝试进⾏部分复制.
1. replicationid/replid (复制id)(与runid做区分)
主节点的复制 id. 主节点重新启动, 或者从节点晋级成主节点, 都会⽣成⼀个 replicationid. (同⼀个节点, 每次重启, ⽣成的 replicationid 也会变化).从节点在和主节点建⽴连接之后, 就会获取到主节点的 replicationid.
关于 master_replid 和 master_replid2
每个节点需要记录两组 master_replid . 这个设定解决的问题场景是这样的:
⽐如当前有两个节点 A 和 B, A 为 master, B 为 slave.
此时 B 就会记录 A 的 master_replid.
如果⽹络出现抖动, B 以为 A 挂了, B ⾃⼰就会成为主节点. 于是 B 给⾃⼰分配了新的 master_replid.
此时就会使⽤ master_replid2 来保存之前 A 的 master_replid.
• 后续如果⽹络恢复了, B 就可以根据 master_replid2 找回之前的主节点.
• 后续如果⽹络没有恢复, B 就按照新的 master_replid ⾃成⼀派, 继续处理后续的数据.
2. offset (偏移量)
参与复制的主从节点都会维护⾃⾝复制偏移量。主节点(master)在处理完写⼊命令后,会把命令的字节⻓度做累加记录,统计信息在 info replication 中的 master_repl_offset 指标中。
从节点(slave)每秒钟上报⾃⾝的复制偏移量给主节点,因此主节点也会保存从节点的复制偏移量。
从节点在接受到主节点发送的命令后,也会累加记录⾃⾝的偏移量。
psync 运⾏流程
1)从节点发送 psync 命令给主节点,replid 和 offset 的默认值分别是 ? 和 -1.
2)主节点根据 psync 参数和⾃⾝数据情况决定响应结果:
• 如果回复 +FULLRESYNC replid offset,则从节点需要进⾏全量复制流程。
• 如果回复 +CONTINEU,从节点进⾏部分复制流程。
• 如果回复 -ERR,说明 Redis 主节点版本过低,不⽀持 psync 命令。从节点可以使⽤ sync 命令进⾏全量复制。
• psync ⼀般不需要⼿动执⾏. Redis 会在主从复制模式下⾃动调⽤执⾏.
• sync 会阻塞 redis server 处理其他请求. psync 则不会.
全量复制
1)从节点发送 psync 命令给主节点进⾏数据同步,由于是第⼀次进⾏复制,从节点没有主节点的运⾏ ID 和复制偏移量,所以发送 psync ? -1。
2)主节点根据命令,解析出要进⾏全量复制,回复 +FULLRESYNC 响应。
3)从节点接收主节点的运⾏信息进⾏保存。
4)主节点执⾏ bgsave 进⾏ RDB ⽂件的持久化。
5)主节点发送 RDB ⽂件给从节点,从节点保存 RDB 数据到本地硬盘。
6)主节点将从⽣成 RDB 到接收完成期间执⾏的写命令,写⼊缓冲区中,等从节点保存完 RDB ⽂件后,主节点再将缓冲区内的数据补发给从节点,补发的数据仍然按照 rdb 的⼆进制格式追加写⼊到收到的 rdb ⽂件中. 保持主从⼀致性。
7)从节点清空⾃⾝原有旧数据。
8)从节点加载 RDB ⽂件得到与主节点⼀致的数据。
9)如果从节点加载 RDB 完成之后,并且开启了 AOF 持久化功能,它会进⾏ bgrewrite 操作,得到最近的 AOF ⽂件。
部分复制
1)当主从节点之间出现⽹络中断时,如果超过 repl-timeout 时间,主节点会认为从节点故障并终端复制连接。
2)主从连接中断期间主节点依然响应命令,但这些复制命令都因⽹络中断⽆法及时发送给从节点,所以暂时将这些命令滞留在复制积压缓冲区中。
3)当主从节点⽹络恢复后,从节点再次连上主节点。
4)从节点将之前保存的 replicationId 和 复制偏移量作为 psync 的参数发送给主节点,请求进⾏部分复制。
5)主节点接到 psync 请求后,进⾏必要的验证。随后根据 offset 去复制积压缓冲区查找合适的数据,并响应 +CONTINUE 给从节点。
6)主节点将需要从节点同步的数据发送给从节点,最终完成⼀致性。
复制积压缓冲区
复制积压缓冲区是保存在主节点上的⼀个固定⻓度的队列,默认⼤⼩为 1MB,当主节点有连接的从节点(slave)时被创建,这时主节点(master)响应写命令时,不但会把命令发送给从节点,还会写⼊复制积压缓冲区。由于缓冲区本质上是先进先出的定⻓队列,所以能实现保存最近已复制数据的功能,⽤于部分复制和复制命令丢失的数据补救。
实时复制
主从节点在建⽴复制连接后,主节点会把⾃⼰收到的 修改操作 , 通过 tcp ⻓连接的⽅式, 源源不断的传输给从节点. 从节点就会根据这些请求来同时修改⾃⾝的数据. 从⽽保持和主节点数据的⼀致性.
另外, 这样的⻓连接, 需要通过⼼跳包的⽅式来维护连接状态. (这⾥的⼼跳是指应⽤层⾃⼰实现的⼼跳,⽽不是 TCP ⾃带的⼼跳).
1)主从节点彼此都有⼼跳检测机制,各⾃模拟成对⽅的客⼾端进⾏通信。
2)主节点默认每隔 10 秒对从节点发送 ping 命令,判断从节点的存活性和连接状态。
3)从节点默认每隔 1 秒向主节点发送 replconf ack {offset} 命令,给主节点上报⾃⾝当前的复制偏移量。
如果主节点发现从节点通信延迟超过 repl-timeout 配置的值(默认 60 秒),则判定从节点下线,断开复制客⼾端连接。从节点恢复连接后,⼼跳机制继续进⾏。
三、哨兵
Redis 的主从复制模式下,⼀旦主节点由于故障不能提供服务,需要⼈⼯进⾏主从切换,同时⼤量的客⼾端需要被通知切换到新的主节点上,对于上了⼀定规模的应⽤来说,这种⽅案是⽆法接受的,于是 Redis 从 2.8 开始提供了 Redis Sentinel(哨兵)加个来解决这个问题。
主从复制中假如说是从节点挂了还能忍受,无非也就少一个得力蘸酱,但是假如说是主节点挂了的话,就只能让运维人员加班加点了,得进行一系列操作将从节点提拔成为主节点,进行恢复集群的使用了。而聪明的程序员肯定就是不会这么复杂的人工操作了,所以就写了一个程序也就是哨兵机制来进行选拔出从节点提拔为主节点的思路。
Redis Sentinel 是⼀个分布式架构,其中包含若⼲个 Sentinel 节点和 Redis 数据节点,每个Sentinel 节点会对数据节点和其余 Sentinel 节点进⾏监控,当它发现节点不可达时,会对节点做下线表⽰。如果下线的是主节点,它还会和其他的 Sentinel 节点进⾏ “协商”,当⼤多数 Sentinel 节点对主节点不可达这个结论达成共识之后,它们会在内部 “选举” 出⼀个领导节点来完成⾃动故障转移的⼯作,同时将这个变化实时通知给 Redis 应⽤⽅。整个过程是完全⾃动的,不需要⼈⼯介⼊。
Redis Sentinel 相⽐于主从复制模式是多了若⼲(建议保持奇数)Sentinel 节点⽤于实现监控数据节点,哨兵节点会定期监控所有节点(包含数据节点和其他哨兵节点)。针对主节点故障的情况,故障转移流程⼤致如下:
1)主节点故障,从节点同步连接中断,主从复制停⽌。
2)哨兵节点通过定期监控发现主节点出现故障。哨兵节点与其他哨兵节点进⾏协商,达成多数认同主节点故障的共识。这步主要是防⽌该情况:出故障的不是主节点,⽽是发现故障的哨兵节点,该情况经常发⽣于哨兵节点的⽹络被孤⽴的场景下。
3)哨兵节点之间使⽤ Raft 算法选举出⼀个领导⻆⾊,由该节点负责后续的故障转移⼯作。
4)哨兵领导者开始执⾏故障转移:从节点中选择⼀个作为新主节点;让其他从节点同步新主节点;通知应⽤层转移到新主节点
Redis Sentinel 具有以下⼏个功能:
• 监控: Sentinel 节点会定期检测 Redis 数据节点、其余哨兵节点是否可达。
• 故障转移: 实现从节点晋升(promotion)为主节点并维护后续正确的主从关系。
• 通知: Sentinel 节点会将故障转移的结果通知给应⽤⽅。
• Redis 主节点如果宕机, 哨兵会把其中的⼀个从节点, 提拔成主节点.
• 当之前的 Redis 主节点重启之后, 这个主节点被加⼊到哨兵的监控中, 但是只会被作为从节点使⽤。
主观下线和客观下线
• 主观下线 (Subjectively Down, SDown): 哨兵感知到主节点没⼼跳了. 判定为主观下线.
• 客观下线 (Objectively Down, ODown): 多个哨兵达成⼀致意⻅, 才能认为 master 确实下线了。
-
主观下线
当 redis-master 宕机, 此时 redis-master 和三个哨兵之间的⼼跳包就没有了.此时, 站在三个哨兵的⻆度来看, redis-master 出现严重故障. 因此三个哨兵均会把 redis-master 判定为主观下线 (SDown) -
客观下线
此时, 哨兵 sentenal1, sentenal2, sentenal3 均会对主节点故障这件事情进⾏投票. 当故障得票数 >= 配置的法定票数之后。 -
选举出哨兵的 leader
接下来需要哨兵把剩余的 slave 中挑选出⼀个新的 master. 这个⼯作不需要所有的哨兵都参与. 只需要选出个代表 (称为 leader), 由 leader 负责进⾏ slave 升级到 master 的提拔过程.这个选举的过程涉及到 Raft 算法。
假定⼀共三个哨兵节点, S1, S2, S3
- 每个哨兵节点都给其他所有哨兵节点, 发起⼀个 “拉票请求”. (S1 -> S2, S1 -> S3, S2 -> S1, S2 -> S3,S3 -> S1, S3 -> S2)
- 收到拉票请求的节点, 会回复⼀个 “投票响应”. 响应的结果有两种可能, 投 or 不投.⽐如 S1 给 S2 发了个投票请求, S2 就会给 S1 返回投票响应.到底 S2 是否要投 S1 呢? 取决于 S2 是否给别⼈投过票了. (每个哨兵只有⼀票).
如果 S2 没有给别⼈投过票, 换⽽⾔之, S1 是第⼀个向 S2 拉票的, 那么 S2 就会投 S1. 否则则不投. - ⼀轮投票完成之后, 发现得票超过半数的节点, ⾃动成为 leader.
- 如果出现平票的情况 (S1 投 S2, S2 投 S3, S3 投 S1, 每⼈⼀票), 就重新再投⼀次即可.这也是为啥建议哨兵节点设置成奇数个的原因. 如果是偶数个, 则增⼤了平票的概率, 带来不必要的开销.
- leader 节点负责挑选⼀个 slave 成为新的 master. 当其他的 sentenal 发现新的 master 出现了, 就说明选举结束了.
这个leader也就是先下手为强,谁先下手进行拉票谁就能抢到别人给投的票,然而这个leader是谁并不重要,重要的是得选出来个leader为重要。
- leader 挑选出合适的 slave 成为新的 master
- ⽐较优先级. 优先级⾼(数值⼩的)的上位. 优先级是配置⽂件中的配置项( slave-priority 或者replica-priority ).
- ⽐较 replication offset 谁复制的数据多, ⾼的上位.
- ⽐较 run id , 谁的 id ⼩, 谁上位
当某个 slave 节点被指定为 master 之后,
- leader 指定该节点执⾏ slave no one , 成为 master
- leader 指定剩余的 slave 节点, 都依附于这个新 master
四、集群
在这么个大的互联网数据大爆炸的时代,几TB的数据显然是不能够的,也就是一台主从机器肯定是不够的,那么我们就需要引入集群这样的概念,也就是很多很多机器,形成一个大的集群。引⼊多组 Master / Slave , 每⼀组 Master / Slave 存储数据全集的⼀部分, 从⽽构成⼀个更⼤的整体, 称为 Redis 集群 (Cluster)。
在上述图中,
• Master1 和 Slave11 和 Slave12 保存的是同样的数据. 占总数据的 1/3
• Master2 和 Slave21 和 Slave22 保存的是同样的数据. 占总数据的 1/3
• Master3 和 Slave31 和 Slave32 保存的是同样的数据. 占总数据的 1/3
这三组机器存储的数据都是不同的.
每个 Slave 都是对应 Master 的备份(当 Master 挂了, 对应的 Slave 会补位成 Master).
每个红框部分都可以称为是⼀个 分⽚ (Sharding).
如果全量数据进⼀步增加, 只要再增加更多的分⽚, 即可解决。
数据分片算法
既然有了数据,有了分片的概念,那么怎么将数据分片成为了一个很棘手很值得探索的问题,我们有以下三种思想,只有最后一种思想才是redis真正的使用分片算法,前两种同样有很深的意义,因为我们很多其他小的客户需求是可以使用前两种的。
哈希求余
设有 N 个分⽚, 使⽤ [0, N-1] 这样序号进⾏编号.
针对某个给定的 key, 先计算 hash 值, 再把得到的结果 % N, 得到的结果即为分⽚编号.
例如, N 为 3. 给定 key 为 hello, 对 hello 计算 hash 值(⽐如使⽤ md5 算法), 得到的结果为bc4b2a76b9719d91 , 再把这个结果 % 3, 结果为 0, 那么就把 hello 这个 key 放到 0 号分⽚上。后续如果要取某个 key 的记录, 也是针对 key 计算 hash , 再对 N 求余, 就可以找到对应的分⽚编号了。
优点是简单高效,数据分配均匀。
缺点是⼀旦需要进⾏扩容, N 改变了, 原有的映射规则被破坏, 就需要让节点之间的数据相互传输, 重新排列, 以满⾜新的映射规则. 此时需要搬运的数据量是⽐较多的, 开销较⼤。
如上图,我们模3和模4进行对比一下,有85%的数据都需要移动。
⼀致性哈希算法
第⼀步, 把 0 -> 2^32-1 这个数据空间, 映射到⼀个圆环上. 数据按照顺时针⽅向增⻓.
第⼆步, 假设当前存在三个分⽚, 就把分⽚放到圆环的某个位置上。
第三步, 假定有⼀个 key, 计算得到 hash 值 H, 那么这个 key 映射到哪个分⽚呢? 规则很简单, 就是从 H 所在位置, 顺时针往下找, 找到的第⼀个分⽚, 即为该 key 所从属的分⽚
这就相当于, N 个分⽚的位置, 把整个圆环分成了 N 个管辖区间. Key 的 hash 值落在某个区间内, 就归对应区间管理。
如果来了三号分片的话,就是从0号分片薅下来一部分的空间划分给3号分片。
优点: ⼤⼤降低了扩容时数据搬运的规模, 提⾼了扩容操作的效率.
缺点: 数据分配不均匀 (有的多有的少, 数据倾斜).
哈希槽分区算法
hash_slot = crc16(key) % 16384
其中 crc16 也是⼀种 hash 算法,16384 其实是 16 * 1024, 也就是 2^14。
相当于是把整个哈希值, 映射到 16384 个槽位上, 也就是 [0, 16383]。然后再把这些槽位⽐较均匀的分配给每个分⽚. 每个分⽚的节点都需要记录⾃⼰持有哪些分⽚。
假设有分了三片的话,那么一般如下所示的分片规则:
• 0 号分⽚: [0, 5460], 共 5461 个槽位
• 1 号分⽚: [5461, 10922], 共 5462 个槽位
• 2 号分⽚: [10923, 16383], 共 5461 个槽位
如果需要进⾏扩容, ⽐如新增⼀个 3 号分⽚, 就可以针对原有的槽位进⾏重新分配.
⽐如可以把之前每个分⽚持有的槽位, 各拿出⼀点, 分给新分⽚.
⼀种可能的分配⽅式:
• 0 号分⽚: [0, 4095], 共 4096 个槽位
• 1 号分⽚: [5462, 9557], 共 4096 个槽位
• 2 号分⽚: [10924, 15019], 共 4096 个槽位
• 3 号分⽚: [4096, 5461] + [9558, 10923] + [15019, 16383], 共 4096 个槽位。
所以就衍生出两个问题,这个槽位为什么要有16384,我多一点不行吗?你既然有16384个槽位,那么分片顶天是有16384个吗?
我们直接看作者的答案:
节点之间通过⼼跳包通信. ⼼跳包中包含了该节点持有哪些 slots. 这个是使⽤位图这样的数据结构表⽰的. 表⽰ 16384 (16k) 个 slots, 需要的位图⼤⼩是 2KB. 如果给定的 slots 数更多了, ⽐如 65536个了, 此时就需要消耗更多的空间, 8 KB 位图表⽰了. 8 KB, 对于内存来说不算什么, 但是在频繁的⽹络⼼跳包中, 还是⼀个不⼩的开销的。
另⼀⽅⾯, Redis 集群⼀般不建议超过 1000 个分⽚. 所以 16k 对于最⼤ 1000 个分⽚来说是⾜够⽤的, 同时也会使对应的槽位配置位图体积不⾄于很⼤。
主节点宕机的处理流程
故障判定
集群中的所有节点, 都会周期性的使⽤⼼跳包进⾏通信.
- 节点 A 给 节点 B 发送 ping 包, B 就会给 A 返回⼀个 pong 包. ping 和 pong 除了 message type 属性之外, 其他部分都是⼀样的. 这⾥包含了集群的配置信息(该节点的id, 该节点从属于哪个分⽚,是主节点还是从节点, 从属于谁, 持有哪些 slots 的位图…).
- 每个节点, 每秒钟, 都会给⼀些随机的节点发起 ping 包, ⽽不是全发⼀遍. 这样设定是为了避免在节点很多的时候, ⼼跳包也⾮常多(⽐如有 9 个节点, 如果全发, 就是 9 * 8 有 72 组⼼跳了, ⽽且这是按照 N^2 这样的级别增⻓的).
- 当节点 A 给节点 B 发起 ping 包, B 不能如期回应的时候, 此时 A 就会尝试重置和 B 的 tcp 连接, 看能否连接成功. 如果仍然连接失败, A 就会把 B 设为 PFAIL 状态(相当于主观下线).
- A 判定 B 为 PFAIL 之后, 会通过 redis 内置的 Gossip 协议, 和其他节点进⾏沟通, 向其他节点确认 B的状态. (每个节点都会维护⼀个⾃⼰的 “下线列表”, 由于视⻆不同, 每个节点的下线列表也不⼀定相同).
- 此时 A 发现其他很多节点, 也认为 B 为 PFAIL, 并且数⽬超过总集群个数的⼀半, 那么 A 就会把 B 标记成 FAIL (相当于客观下线), 并且把这个消息同步给其他节点(其他节点收到之后, 也会把 B 标记成FAIL).
⾄此, B 就彻底被判定为故障节点了.
集群宕机?
某个分⽚, 所有的主节点和从节点都挂了
某个分⽚, 主节点挂了, 但是没有从节点
超过半数的 master 节点都挂了
故障迁移
所谓故障迁移, 就是指把从节点提拔成主节点, 继续给整个 redis 集群提供⽀持.
具体流程如下:
- 从节点判定⾃⼰是否具有参选资格. 如果从节点和主节点已经太久没通信(此时认为从节点的数据和主节点差异太⼤了), 时间超过阈值, 就失去竞选资格.
- 具有资格的节点, ⽐如 C 和 D, 就会先休眠⼀定时间. 休眠时间 = 500ms 基础时间 + [0, 500ms] 随机时间 + 排名 * 1000ms. offset 的值越⼤, 则排名越靠前(越⼩).
- ⽐如 C 的休眠时间到了, C 就会给其他所有集群中的节点, 进⾏拉票操作. 但是只有主节点才有投票资格.
- 主节点就会把⾃⼰的票投给 C (每个主节点只有 1 票). 当 C 收到的票数超过主节点数⽬的⼀半, C 就会晋升成主节点. (C ⾃⼰负责执⾏ slaveof no one, 并且让 D 执⾏ slaveof C).
- 同时, C 还会把⾃⼰成为主节点的消息, 同步给其他集群的节点. ⼤家也都会更新⾃⼰保存的集群结构信息
这就是大名鼎鼎的Raft算法,我们去进行选举谁并不重要,重要的是选一个冤大头出来顶着。
五、缓存 (cache)
核⼼思路就是把⼀些常⽤的数据放到触⼿可及(访问速度更快)的地⽅, ⽅便随时读取。
这里的触手可及是相对的概念,我们知道, 对于硬件的访问速度来说, 通常情况下: CPU 寄存器 > 内存 > 硬盘 > ⽹络。所以对于硬盘,内存就是它的触手可及的地方。
关系型数据库为什么性能不高?
- 数据库把数据存储在硬盘上, 硬盘的 IO 速度并不快. 尤其是随机访问.
- 如果查询不能命中索引, 就需要进⾏表的遍历, 这就会⼤⼤增加硬盘 IO 次数.
- 关系型数据库对于 SQL 的执⾏会做⼀系列的解析, 校验, 优化⼯作.
- 如果是⼀些复杂查询, ⽐如联合查询, 需要进⾏笛卡尔积操作, 效率更是降低很多.
为什么并发量⾼了就会宕机?
服务器每次处理⼀个请求, 都是需要消耗⼀定的硬件资源的. 所谓的硬件资源包括不限于 CPU,内存, 硬盘, ⽹络带宽…
⼀个服务器的硬件资源本⾝是有限的. ⼀个请求消耗⼀份资源, 请求多了, ⾃然把资源就耗尽了. 后续的请求没有资源可⽤, ⾃然就⽆法正确处理. 更严重的还会导致服务器程序的代码出现崩溃.
解决方法
• 开源: 引⼊更多的机器, 部署更多的数据库实例, 构成数据库集群. (主从复制, 分库分表等…)
• 节流: 引⼊缓存, 使⽤其他的⽅式保存经常访问的热点数据, 从⽽降低直接访问数据库的请求数量
Redis 访问速度⽐ MySQL 快很多. 或者说处理同⼀个访问请求, Redis 消耗的系统资源⽐MySQL 少很多. 因此 Redis 能⽀持的并发量更⼤.
• Redis 数据在内存中, 访问内存⽐硬盘快很多.
• Redis 只是⽀持简单的 key-value 存储, 不涉及复杂查询的那么多限制规则.
• 客⼾端访问业务服务器, 发起查询请求.
• 业务服务器先查询 Redis, 看想要的数据是否在 Redis 中存在.
◦ 如果已经在 Redis 中存在了, 就直接返回. 此时不必访问 MySQL 了.
◦ 如果在 Redis 中不存在, 再查询 MySQL。
缓存的更新策略
定期⽣成
每隔⼀定的周期(⽐如⼀天/⼀周/⼀个⽉), 对于访问的数据频次进⾏统计. 挑选出访问频次最⾼的前 N%的数据。
实时⽣成
先给缓存设定容量上限(可以通过 Redis 配置⽂件的 maxmemory 参数设定).
接下来把⽤⼾每次查询:
• 如果在 Redis 查到了, 就直接返回。
• 如果 Redis 中不存在, 就从数据库查, 把查到的结果同时也写⼊ Redis。
如果缓存已经满了(达到上限), 就触发缓存淘汰策略, 把⼀些 “相对不那么热⻔” 的数据淘汰掉。
按照上述过程, 持续⼀段时间之后 Redis 内部的数据⾃然就是 “热⻔数据” 了。
FIFO (First In First Out) 先进先出
把缓存中存在时间最久的 (也就是先来的数据) 淘汰掉.
LRU (Least Recently Used) 淘汰最久未使⽤的
记录每个 key 的最近访问时间. 把最近访问时间最⽼的 key 淘汰掉.
LFU (Least Frequently Used) 淘汰访问次数最少的
记录每个 key 最近⼀段时间的访问次数. 把访问次数最少的淘汰掉.
Random 随机淘汰
从所有的 key 中抽取幸运⼉被随机淘汰掉.
缓存预热 、缓存穿透、缓存雪崩、缓存击穿
缓存预热
使⽤ Redis 作为 MySQL 的缓存的时候, 当 Redis 刚刚启动, 或者 Redis ⼤批 key 失效之后, 此时由于Redis ⾃⾝相当于是空着的, 没啥缓存数据, 那么 MySQL 就可能直接被访问到, 从⽽造成较⼤的压⼒.因此就需要提前把热点数据准备好, 直接写⼊到 Redis 中. 使 Redis 可以尽快为 MySQL 撑起保护伞.热点数据可以基于之前介绍的统计的⽅式⽣成即可. 这份热点数据不⼀定⾮得那么 “准确”, 只要能帮助MySQL 抵挡⼤部分请求即可. 随着程序运⾏的推移, 缓存的热点数据会逐渐⾃动调整, 来更适应当前情况.
缓存穿透
访问的 key 在 Redis 和 数据库中都不存在. 此时这样的 key 不会被放到缓存上, 后续如果仍然在访问该key, 依然会访问到数据库.这就会导致数据库承担的请求太多, 压⼒很⼤.这种情况称为 缓存穿透.
没有参数校验导致想要访问的是不合法的key或者程序员误删了MySQL的库或者黑客进攻MySQL。
怎么解决?
• 针对要查询的参数进⾏严格的合法性校验. ⽐如要查询的 key 是⽤⼾的⼿机号, 那么就需要校验当前key 是否满⾜⼀个合法的⼿机号的格式.
• 针对数据库上也不存在的 key , 也存储到 Redis 中, ⽐如 value 就随便设成⼀个 “”. 避免后续频繁访问数据库.
• 使⽤布隆过滤器先判定 key 是否存在, 再真正查询.
缓存雪崩
短时间内⼤量的 key 在缓存上失效, 导致数据库压⼒骤增, 甚⾄直接宕机.
本来 Redis 是 MySQL 的⼀个护盾, 帮 MySQL 抵挡了很多外部的压⼒. ⼀旦护盾突然失效了, MySQL⾃⾝承担的压⼒骤增, 就可能直接崩溃.
怎么产生的?Redis挂了或者设置过过期时间的大量的key都过期了,因为有些key是需要设置过期时间的,防止缓存都占满了。
怎么解决呢?
• 部署⾼可⽤的 Redis 集群, 并且完善监控报警体系.
• 不给 key 设置过期时间 或者 设置过期时间的时候添加随机时间因⼦.
缓存击穿
相当于缓存雪崩的特殊情况. 针对热点 key , 突然过期了, 导致⼤量的请求直接访问到数据库上, 甚⾄引起数据库宕机,就比如春节那一天突然关于"春节"的热点key过期了,而并没有大量的热点key过期,仅仅是刚好的是大家在那一时段搜的最多的热点key丢失了这种情况叫做缓存击穿。
如何解决?
• 基于统计的⽅式发现热点 key, 并设置永不过期.
• 进⾏必要的服务降级. 例如访问数据库的时候使⽤分布式锁, 限制同时请求数据库的并发数.
六、分布式锁
锁?难道,难道?难道我要加入con?难道我要用生产者消费者模型,其实不然。我们这里的分布式锁是Redis的运用场景,也就是它的运用!那么我们用一个Redis就能表示一个分布式锁了,也就是一个Redis中存储的key-value键值对的信息就是一个锁,因为我们可以用Redis的弱化版的原子性!本质上就是使⽤⼀个公共的服务器, 来记录 加锁状态.这个公共的服务器可以是 Redis, 也可以是其他组件(⽐如 MySQL 或者 ZooKeeper 等), 还可以是我们⾃⼰写的⼀个服务。思路⾮常简单. 本质上就是通过⼀个键值对来标识锁的状态。
举个例子:
此时, 如果 买票服务器1 尝试买票, 就需要先访问 Redis, 在 Redis 上设置⼀个键值对. ⽐如 key 就是⻋次, value 随便设置个值 (⽐如 1)。
如果这个操作设置成功, 就视为当前没有节点对该 001 ⻋次加锁, 就可以进⾏数据库的读写操作. 操作完成之后, 再把 Redis 上刚才的这个键值对给删除掉。
如果在 买票服务器1 操作数据库的过程中, 买票服务器2 也想买票, 也会尝试给 Redis 上写⼀个键值对。
key 同样是⻋次. 但是此时设置的时候发现该⻋次的 key 已经存在了, 则认为已经有其他服务器正在持有锁, 此时 服务器2 就需要等待或者暂时放弃。
此时会出现一个情况,当我们第一台买票服务器设置了一个key,但是此时刚好这个服务器挂了,但是这个key是无法被删掉了,那么这个key一直占着这个资源不就完了吗?
过期时间
当 服务器1 加锁之后, 开始处理买票的过程中, 如果 服务器1 意外宕机了, 就会导致解锁操作 (删除该key) 不能执⾏. 就可能引起其他服务器始终⽆法获取到锁的情况。
为了解决这个问题, 可以在设置 key 的同时引⼊过期时间. 即这个锁最多持有多久, 就应该被释放。
此处可以用setnx ex nx 的操作,因为这个操作是一条指令,那么也就是原子性的!而我用setnx后加一个expire就是两条语句,那么就不是原子性的了,所以绝对会失败!
校验 id
对于 Redis 中写⼊的加锁键值对, 其他的节点也是可以删除的.
⽐如 服务器1 写⼊⼀个 “001”: 1 这样的键值对, 服务器2 是完全可以把 “001” 给删除掉的.
当然, 服务器2 不会进⾏这样的 “恶意删除” 操作, 不过不能保证因为⼀些 bug 导致 服务器2 把锁误删除.
为了解决上述问题, 我们可以引⼊⼀个校验 id.
⽐如可以把设置的键值对的值, 不再是简单的设为⼀个 1, ⽽是设成服务器的编号. 形如 “001”: “服务器1”.
这样就可以在删除 key (解锁)的时候, 先校验当前删除 key 的服务器是否是当初加锁的服务器, 如果是, 才能真正删除; 不是, 则不能删除。
这样当然是有问题啦~因为get命令和del命令两条命令明显不是原子性的!它是两条语句呀,会出现下面画图的问题。
原本我也不理解这个图,后来经过询问明白这个图是这样的,get是会校验id,但是del不会校验,也就是线程1的第二台服务器已经是校验过id了,那么就不用管后面删的是什么了,但是此时线程2插队了!用的是自己的数据,但此时线程1的第二台服务器已经校验过了,不管三七二十一就删了。
原文:原因: 这里加锁的时候:先查询判定,再进行del删除操作的,分为2步操作,并不是原子的。 所以会出现: 服务器1里面的线程A和线程B,分别先进行查询判定(GET),查询判定的时候其实就会通过id进行校验,校验通过之后才回去执行DEL删除操作。 所以在服务器1 线程B del删除之前,如果服务器2线程C 进行了加锁操作,线程B依然可以DEL掉,是因为DEL的时候并不校验,而是在第一步GET查询的时候已经校验过了。
lua脚本
那么就用lua脚本,本质上就是一条指令,也就是原子性的了。
if redis.call('get',KEYS[1]) == ARGV[1] then
return redis.call('del',KEYS[1])
else
return 0
end;
watch dog (看⻔狗)
上面设计仍然有问题,因为我们设置了过期时间,但是假如说过期时间到了,但是业务还没有完成怎么办?设计时间太长消耗太多cpu怎么办?
所谓 watch dog, 本质上是加锁的服务器上的⼀个单独的线程, 通过这个线程来对锁过期时间进⾏ “续约”.注意, 这个线程是业务服务器上的, 不是 Redis 服务器的!
举个具体的例⼦:
初始情况下设置过期时间为 10s. 同时设定看⻔狗线程每隔 3s 检测⼀次.那么当 3s 时间到的时候, 看⻔狗就会判定当前任务是否完成.
• 如果任务已经完成, 则直接通过 lua 脚本的⽅式, 释放锁(删除 key).
• 如果任务未完成, 则把过期时间重写设置为 10s. (即 “续约”)
这样就不担⼼锁提前失效的问题了. ⽽且另⼀⽅⾯, 如果该服务器挂了, 看⻔狗线程也就随之挂了, 此时⽆⼈续约, 这个 key ⾃然就可以迅速过期, 让其他服务器能够获取到锁了.
Redlock算法
简⽽⾔之, Redlock 算法的核⼼就是, 加锁操作不能只写给⼀个 Redis 节点, ⽽要写个多个!! 分布式系统中任何⼀个节点都是不可靠的. 最终的加锁成功结论是 “少数服从多数的”.
由于⼀个分布式系统不⾄于⼤部分节点都同时出现故障, 因此这样的可靠性要⽐单个节点来说靠谱不少.
问题提出:
服务器1 向 master 节点进⾏加锁操作. 这个写⼊ key 的过程刚刚完成, master 挂了; slave 节点升级成了新的 master 节点. 但是由于刚才写⼊的这个 key 尚未来得及同步给 slave 呢, 此时
就相当于 服务器1 的加锁操作形同虚设了, 服务器2 仍然可以进⾏加锁 (即给新的 master 写⼊ key. 因为新的 master 不包含刚才的 key)。
我们引⼊⼀组 Redis 节点. 其中每⼀组 Redis 节点都包含⼀个主节点和若⼲从节点. 并且组和组之间存储的数据都是⼀致的, 相互之间是 “备份” 关系(⽽并⾮是数据集合的⼀部分, 这点有别于 Redis cluster).加锁的时候, 按照⼀定的顺序, 写多个 master 节点. 在写锁的时候需要设定操作的 “超时时间”. ⽐如50ms. 即如果 setnx 操作超过了 50ms 还没有成功, 就视为加锁失败.
如果给某个节点加锁失败, 就⽴即再尝试下⼀个节点.
当加锁成功的节点数超过总节点数的⼀半, 才视为加锁成功.
如上图, ⼀共五个节点, 三个加锁成功, 两个失败, 此时视为加锁成功.这样的话, 即使有某些节点挂了, 也不影响锁的正确性.