文章目录
本系列文章:
Redis进阶之路(一)5种数据类型、Redis常用命令
Redis学习之路(二)Jedis、持久化、事务
Redis学习之路(三)主从复制、键过期删除策略、内存溢出策略、慢查询
Redis学习之路(四)哨兵、集群、读写分离
Redis学习之路(五)缓存、分布式锁
一、哨兵
该章节对于Redis的配置基于Linux环境
。
1.1 哨兵的一些基本概念
名词 | 逻辑结构 | 物理结构 |
---|---|---|
主节点 | Redis主服务 / 数据库 | 一个独立的Redis进程 |
从节点 | Redis从服务 / 数据库 | 一个独立的Redis进程 |
Redis数据节点 | 主节点和从节点 | 主节点和从节点的进程 |
Sentinel节点 | 监控Redis数据节点 | 一个独立的Sentinel进程 |
Sentinel节点集合 | 若干Sentinel节点的抽象合集 | 若干Sentinel节点进程 |
Redis Sentinel | Redis高可用实现方案 | Sentinel节点集合和Redis数据节点进程 |
应用方 | 一个或多个客户端 | 一个或多个客户端进程或线程 |
Redis的主从复制模式可以将主节点的数据改变同步给从节点,这样从节点就可以起到两个作用:
- 作为主节点数据的一个备份,保证主节点在出现问题时数据尽量不丢失(主从复制是最终一致性)。
- 从节点可以扩展主节点的读能力。
主从复制也带来了以下问题:
- 一旦主节点出现故障,需要手动将一个从节点晋升为主节点,同时需要修改应用方的主节点地址,还需要命令其他从节点去复制新的主节点,整个过程都需要人工干预。(非高可用)
- 主节点的写能力受到单机的限制。(分布式问题)
- 主节点的存储能力受到单机的限制。(分布式问题)
单纯的主从复制,是非高可用的。Redis Sentinel能够解决这些问题。当主节点出现故障时,Redis Sentinel能自动完成故障发现和故障转移,并通知应用方,从而实现真正的高可用。
如果想使用Redis Sentinel的话,建议使用2.8以上版本,也就是v2版本的Redis Sentinel。
Redis Sentinel
是一个分布式架构,其中包含若干个Sentinel节点和Redis数据节点,每个Sentinel节点会对数据节点和其余Sentinel节点进行监控,当它发现节点不可达时,会对节点做下线标识。如果被标识的是主节点,它还会和其他Sentinel节点进行“协商”,当大多数Sentinel节点都认为主节点不可达时,它们会选举出一个Sentinel节点来完成自动故障转移的工作,同时会将这个变化实时通知给Redis应用方。
这里的分布式是指:Redis数据节点、Sentinel节点集合、客户端分布在多个物理节点的架构。
和Redis主从复制模式相比,Redis Sentinel只是多了若干Sentinel节点,图示:
以上图中的Redis Sentinel为例,说明一下故障转移的处理逻辑:
![](/qrcode.jpg)
- 节点出现故障,此时两个从节点与主节点失去连接,主从复制失败。
- 每个Sentinel节点通过定期监控发现主节点出现了故障。
- 多个Sentinel节点对主节点的故障达成一致,选举出sentinel-3节点作为领导者负责故障转移。
- Sentinel领导者节点执行了故障转移,过程:即选出一个从节点,对其执行
slaveof no one
命令使其成为新的主节点。更新应用方的主节点信息,重新启动应用方。客户端命令另一个从节点去复制新的主节点。待原来的主节点恢复后,让它去复制的主节点。
故障转移后整个Redis Sentinel的结构:
从上面的例子,可以看出Redis Sentinel具有以下几个功能:
监控
:Sentinel节点会定期检测Redis数据节点、其余Sentinel节点是否正常工作。通知
:Sentinel节点会将故障转移的结果通知给应用方。主节点故障转移
:实现从节点晋升为主节点并维护后续正确的主从关系(当一个Master不能正常工作时,哨兵(sentinel) 会开始一次自动故障迁移操作,它会将失效Master的其中一个Slave升级为新的Master, 并让失效Master的其他Slave改为复制新的Master; 当客户端试图连接失效的Master时,集群也会向客户端返回新Master的地址,使得集群可以使用Master代替失效Master
)。配置提供者
:在Redis Sentinel结构中,客户端在初始化的时候连接的是Sentinel节点集合,从中获取主节点信息。
哨兵的核心知识:
哨兵至少需要 3 个实例,来保证自己的健壮性
。哨兵 + redis 主从的部署架构,是不保证数据零丢失的,只能保证 redis 集群的高可用性
。- 对于哨兵 + redis 主从这种复杂的部署架构,尽量在测试环境和生产环境,都进行充足的测试和演练。
Redis Sentinel包含了若个Sentinel节点(Sentinel节点实际上就是独立的Redis节点,只不过它们有一些特殊,它们不存储数据,只支持部分命令),这样做的好处:
- 对于节点的故障判断是由多个Sentinel节点共同完成,可以有效地防止误判。
- Sentinel节点集合是由若干个Sentinel节点组成,即使个别Sentinel节点不可用,整个Sentinel机制依然正常运行。
哨兵模式的优点:
- 哨兵模式
可以主从切换,具备基本的故障转移能力
;- 哨兵模式具备主从模式的所有优点(由主从模式演变而来)。
哨兵模式的缺点:
- 哨兵模式
很难支持在线扩容操作
;- 集群的配置信息管理比较复杂。
1.2 哨兵的安装和部署
1.2.1 结构
以下图的结构为例:
物理结构:
1.2.2 部署Redis数据节点
- 1、主节点
主节点配置:
redis-6379.conf
port 6379
daemonize yes
logfile "6379.log"
dbfilename "dump-6379.rdb"
dir "/opt/soft/redis/data/"
启动主节点:
redis-server redis-6379.conf
要确认主节点是否已启动,一般ping
一下即可:
redis-cli -h 127.0.0.1 -p 6379 ping
- 2、从节点
两个从节点的配置是完全一样的(不过端口不同而已),和主节点相比的话,不一样的是添加了slaveof配置:
redis-6380.conf
port 6380
daemonize yes
logfile "6380.log"
dbfilename "dump-6380.rdb"
dir "/opt/soft/redis/data/"
slaveof 127.0.0.1 6379
启动从节点:
redis-server redis-6380.conf
redis-server redis-6381.conf
验证这两个节点是否启动的话,同样也是ping
一下:
redis-cli -h 127.0.0.1 -p 6380 ping
redis-cli -h 127.0.0.1 -p 6381 ping
此时的结构:
1.2.3 部署Sentinel节点
3个Sentinel节点的部署方法是完全一致的(端口不同),以其中一个为例:
redis-sentinel-26379.conf
port 26379
daemonize yes
logfile "26379.log"
dir /opt/soft/redis/data
sentinel monitor mymaster 127.0.0.1 6379 2
sentinel down-after-milliseconds mymaster 30000
sentinel parallel-syncs mymaster 1
sentinel failover-timeout mymaster 180000
Sentinel节点的默认端口是26379。
sentinel monitor mymaster 127.0.0.1 6379 2
命令的意思是:sentinel-1节点需要监控127.0.0.1:6379这个主节点,2代表判断主节点失败至少需要2个Sentinel节点同意,mymaster是主节点的别名。
Sentinel节点的启动方法有两种,一是使用redis-sentinel
命令,示例:
redis-sentinel redis-sentinel-26379.conf
二是使用redis-server
命令加–sentinel参数,示例:
redis-server redis-sentinel-26379.conf --sentinel
要确认Sentinel节点启动情况的话,可以使用如下命令:
上面的信息表示:Sentinel节点找到了主节点127.0.0.1:6379,发现了它的两个从节点,同时发现Redis Sentinel一共有3个Sentinel节点。
此时的结构:
有2点需要注意:
- 生产环境中建议Redis Sentinel的所有节点应该分布在不同的物理机上。
- Redis Sentinel中的数据节点和普通的Redis数据节点在配置上没有任何区别,只不过是添加了一些Sentinel节点对它们进行监控。
1.2.4 配置优化
Redis安装目录下有一个sentinel.conf,是默认的Sentinel节点配置文件,示例:
port 26379
dir /opt/soft/redis/data
sentinel monitor mymaster 127.0.0.1 6379 2
sentinel down-after-milliseconds mymaster 30000
sentinel parallel-syncs mymaster 1
sentinel failover-timeout mymaster 180000
port
和dir
分别代表Sentinel节点的端口和工作目录。
- 1、sentinel monitor
详细命令:sentinel monitor <master-name> <ip> <port> <quorum>
。
Sentinel节点会定期监控主节点,所以需要知道主节点的名称、IP和端口,quorum
表示要判定主节点最终不可达所需要的票数。
假设某个Sentinel初始节点配置:
当所有节点启动后,配置文件中的内容发生了变化,体现在三个方面:
- Sentinel节点自动发现了从节点、其余Sentinel节点。
- 去掉了默认配置,例如parallel-syncs、failover-timeout参数。
- 添加了配置纪元相关参数。
此时配置文件的内容变为:
<quorum>
参数用于故障发现和判定,一般建议将其设置为Sentinel节点的一半加1。
<quorum>
还与Sentinel节点的领导者选举有关,至少要有max(quorum,num(sentinels)/2+1)个Sentinel节点参与选举,才能选出领导者Sentinel,从而完成故障转移。
- 2、sentinel down-after-milliseconds
详细命令:sentinel down-after-milliseconds <master-name> <times>
。
每个Sentinel节点都要通过定期发送ping命令来判断Redis数据节点和其余Sentinel节点是否可达,如果超过了down-after-milliseconds配置的时间且没有有效的回复,则判定节点不可达,<times>
(单位为毫秒)就是超时时间。
down-after-milliseconds虽然以
<master-name>
为参数,但实际上对Sentinel节点、主节点、从节点的失败判定同时有效。
- 3、sentinel parallel-syncs
详细命令:sentinel parallel-syncs <master-name> <nums>
。
当Sentinel节点集合对主节点故障判定达成一致时,Sentinel领导者节点会做故障转移操作,选出新的主节点,原来的从节点会向新的主节点发起复制操作,parallel-syncs就是用来限制在一次故障转移之后,每次向新的主节点发起复制操作的从节点个数。以下是该参数分别配置为1和3时的情况:
- 4、sentinel failover-timeout
详细命令:sentinel failover-timeout <master-name> <times>
。
failover-timeout
通常被解释成故障转移超时时间,但实际上它作用于故
障转移的各个阶段:
a)选出合适从节点。
b)晋升选出的从节点为主节点。
c)命令其余从节点复制新的主节点。
d)等待原主节点恢复后命令它去复制新的主节点。
failover-timeout
的作用具体体现在四个方面:
- 如果Sentinel对一个主节点故障转移失败,那么下次再对该主节点做故障转移的起始时间是failover-timeout的2倍。
- 在b)阶段时,如果Sentinel节点向a)阶段选出来的从节点执行
slaveof no one
一直失败(例如该从节点此时出现故障),当此过程超过failover-timeout
时,则故障转移失败。- 在b)阶段如果执行成功,Sentinel节点还会执行info命令来确认a)阶段选出来的节点确实晋升为主节点,如果此过程执行时间超过
failover-timeout
时,则故障转移失败。- 如果c)阶段执行时间超过了
failover-timeout
(不包含复制时间),则故障转移失败。注意即使超过了这个时间,Sentinel节点也会最终配置从节点去同步最新的主节点。
- 5、sentinel auth-pass
详细命令:sentinel auth-pass <master-name> <password>
。
如果Sentinel监控的主节点配置了密码,则通过sentinel auth-pass
配置主节点的密码。 - 6、sentinel notification-script
详细命令:sentinel notification-script <master-name> <script-path>
。
sentinel notification-script
的作用是在故障转移期间,当一些警告级别的Sentinel事件发生(指重要事件,例如-sdown:客观下线、-odown:主观下线)时,会触发对应路径的脚本,并向脚本发送相应的事件参数。
比如在/opt/redis/scripts/下配置了notification.sh,该脚本会接收每个Sentinel节点传过来的事件参数,可以利用这些参数作为邮件或者短信报警依据。notification.sh的内容示例:
#!/bin/sh
# 获取所有参数
msg=$*
# 报警脚本或者接口,将 msg 作为参数
exit 0
如果需要该功能,就可以在Sentinel节点添加如下配置:
sentinel notification-script mymaster /opt/redis/scripts/notification.sh
- 7、sentinel client-reconfig-script
详细命令:sentinel client-reconfig-script <master-name> <script-path>
。
sentinel client-reconfig-script
的作用是在故障转移结束后,会触发对应路径的脚本,并向脚本发送故障转移结果的相关参数。比如在/opt/redis/scripts/下配置了client-reconfig.sh,该脚本会接收每个Sentinel节点传过来的故障转移结果参数,并触发类似短信和邮件报警。client-reconfig.sh内容示例:
#!/bin/sh
# 获取所有参数
msg=$*
# 报警脚本或者接口,将 msg 作为参数
exit 0
如果需要该功能,就可以在Sentinel节点添加如下配置:
sentinel client-reconfig-script mymaster /opt/redis/scripts/client-reconfig.sh
当故障转移结束,每个Sentinel节点会将故障转移的结果发送给对应的脚本,故障转移的结果对应的具体参数:
<master-name> <role> <state> <from-ip> <from-port> <to-ip> <to-port>
<master-name>
:主节点名。
<role>
:Sentinel节点的角色,分别是leader和observer,leader代表当前Sentinel节点是领导者,是它进行的故障转移;observer是其余Sentinel节点。
<from-ip>
:原主节点的ip地址。
<from-port>
:原主节点的端口。
<to-ip>
:新主节点的ip地址。
<to-port>
:新主节点的端口。
关于所涉及的脚本,有几点需要注意:
- 必须有可执行权限。
- 开头必须包含shell脚本头(例如#!/bin/sh)。
- Redis规定脚本的最大执行时间不能超过60秒,超过后脚本将被杀掉。
- 如果shell脚本以
exit 1
结束,那么脚本稍后重试执行。如果以exit 2
或者更高的值结束,那么脚本不会重试。正常返回值是exit 0
。
Redis Sentinel可以同时监控多个主节点,示例:
此时的结构为:
Sentinel节点也支持动态地设置参数,命令为:sentinel set <param> <value>
。sentinel set命令支持的参数:
参数 | 使用示例 | 含义 |
---|---|---|
quorum | sentinel set mymaster quorum 2 | 要判定主节点最终不可达所需要的票数 |
down-after-milliseconds | sentinel set down-after-milliseconds 30000 | 判断节点是否可达的超时时间 |
failover-timeout | sentinel set failover-timeout 36000 | 故障转移超时时间 |
parallel-syncs | sentinel set mymaster parallel-syncs 2 | 在一次故障转移之后,每次向新的主节点发起复制操作的从节点个数 |
notification-script | sentinel set mymaster notification-script /opt/xx.sh | 定义在故障转移期间的通知脚本 |
client-reconfig-script | sentinel set mymaster client-reconfig-script /opt/yy.sh | 定义在故障转移结束后,会触发的脚本 |
auth-pass | sentinel set mymaster auth-pass masterPassword | 匹配主节点密码 |
在使用上述命令时,需要注意的地方:
- sentinel set命令只对当前Sentinel节点有效。
- sentinel set命令如果执行成功会立即刷新配置文件,这点和Redis普通数据节点设置配置需要执行config rewrite刷新到配置文件不同。
- 建议所有Sentinel节点的配置尽可能一致,这样在故障发现和转移时比较容易达成一致。
1.2.5 部署技巧
- 1、Sentinel节点不应该部署在一台物理“机器”上
- 2、部署至少三个且奇数个的Sentinel节点。
- 3、只有一套Sentinel,还是每个主节点配置一套Sentinel?
Sentinel节点集合可以只监控一个主节点,也可以监控多个主节点。前者的结构:
后者的结构:
一套Sentinel,很明显这种方案在一定程度上降低了维护成本,因为只需要维护固定个数的Sentinel节点,集中对多个Redis数据节点进行管理就可以了。但是这同时也是它的缺点,如果这套Sentinel节点集合出现异常,可能会对多个Redis数据节点造成影响。
多套Sentinel,显然这种方案的优点和缺点和上面是相反的,每个Redis主节点都有自己的Sentinel节点集合,会造成资源浪费。但是优点也很明显,每套Redis Sentinel都是彼此隔离的。
如果Sentinel节点集合监控的是同一个业务的多个主节点集合,那么使用方案一、否则一般建议采用方案二。
1.3 哨兵相关的命令
接下来命令的介绍以下图为基础(Sentinel节点集合监控着两组主从模式的Redis数据节点):
- 1、sentinel masters
展示所有被监控的主节点状态以及相关的统计信息,示例:
127.0.0.1:26379> sentinel masters
1) 1) "name"
2) "mymaster-2"
3) "ip"
4) "127.0.0.1"
5) "port"
6) "6382"
......... 忽略 ............
2) 1) "name"
2) "mymaster-1"
3) "ip"
4) "127.0.0.1"
5) "port"
6) "6379"
......... 忽略 ............
- 2、sentinel master < master name >
展示指定名称的主节点状态以及相关的统计信息,示例:
- 3、sentinel slaves < master name >
展示指定名称的从节点状态以及相关的统计信息,示例:
- 4、sentinel sentinels< master name >
展示指定名称的Sentinel节点集合(不包含当前Sentinel节点),示例:
- 5、sentinel get-master-addr-by-name < master name >
返回指定名称的主节点的IP地址和端口,示例:
- 6、sentinel reset < pattern >
当前Sentinel节点对符合<pattern>
的主节点的配置进行重置,包含清除主节点的相关状态,重新发现从节点和Sentinel节点。示例:
- 7、sentinel failover < master name >
对指定名称的主节点进行强制故障转移,当故障转移完成后,其他Sentinel节点按照故障转移的结果更新自身配置,示例:
- 8、sentinel ckquorum < master name >
检测当前可达的Sentinel节点总数是否达到<quorum>
的个数。示例:
- 9、sentinel flushconfig
将Sentinel节点的配置强制刷到磁盘上。当外部原因(例如磁盘损坏)造成配置文件损坏或者丢失时,这个命令是很有用的。示例:
- 10、sentinel remove < master name >
取消当前Sentinel节点对于指定指定名称主节点的监控,示例:
- 11、sentinel monitor < master name > < ip > < port > < quorum >
监控指定的主节点,示例:
1.4 哨兵Java API
实现一个Redis Sentinel客户端的基本步骤:
- 遍历Sentinel节点集合获取一个可用的Sentinel节点。
- 通过
sentinel get-master-addr-by-name master-name
获取对应主节点的相关信息。- 验证当前获取的“主节点”是真正的主节点,这样做的目的是为了防止故障转移期间主节点的变化。
- 保持和Sentinel节点集合的“联系”,时刻获取关于主节点的相关“信息”。
此处使用Jedis2.8.2(简称Jedis)作为Redis的Java客户端。Jedis针对Redis Sentinel给出了一个JedisSentinelPool,其参数最全的构造方法:
public JedisSentinelPool(String masterName, Set<String> sentinels,
final GenericObjectPoolConfig poolConfig, final int connectionTimeout,
final int soTimeout,
final String password, final int database,
final String clientName)
masterName——主节点名
sentinels——Sentinel节点集合
poolConfig——common-pool连接池配置
connectTimeout——连接超时
soTimeout——读写超时
password——主节点密码
database——当前数据库索引
clientName——客户端名
参数较少的构造方法:
JedisSentinelPool jedisSentinelPool = new JedisSentinelPool(masterName,
sentinelSet, poolConfig, timeout);
timeout既代表连接超时又代表读写超时,password为空,database默认使用0,clientName为空。
JedisSentinelPool的使用示例:
Jedis jedis = null;
try {
jedis = jedisSentinelPool.getResource();
} catch (Exception e) {
logger.error(e.getMessage(), e);
} finally {
if (jedis != null)
jedis.close();
}
1.5 哨兵实现原理
哨兵基本原理是:心跳机制 + 投票裁决
。
1.5.1 三个定时监控任务
一套合理的监控机制是Sentinel节点判定节点不可达的重要保证,Redis Sentinel通过三个定时监控任务完成对各个节点发现和监控。
- 1、每隔10秒,每个Sentinel节点会向主节点和从节点发送
info
命令获取最新的拓扑结构
这个定时任务的作用具体可以表现在三个方面:
- 通过向主节点执行info命令,获取从节点的信息,这也是Sentinel节点不需要显式配置监控从节点的原因。
- 当有新的从节点加入时都可以立刻感知出来。
- 节点不可达或者故障转移后,可以通过info命令实时更新节点拓扑信息。
- 2、每隔2秒,每个Sentinel节点会向Redis数据节点的__sentinel__:hello频道上发送该Sentinel节点对于主节点的判断以及当前Sentinel节点的信息,同时每个Sentinel节点也会订阅该频道,来了解其他Sentinel节点以及它们对主节点的判断
这个定时任务的两个作用:
- 发现新的Sentinel节点:通过订阅主节点的__sentinel__:hello了解其他的Sentinel节点信息,如果是新加入的Sentinel节点,将该Sentinel节点信息保存起来,并与该Sentinel节点创建连接。
- Sentinel节点之间交换主节点的状态,作为后面客观下线以及领导者选举的依据。
- 3、每隔1秒,每个Sentinel节点会向主节点、从节点、其余Sentinel节点发送一条ping命令做一次心跳检测,来确认这些节点当前是否可达
通过前两个定时任务,Sentinel节点实现了和每个节点建立连接,第三个定时任务用来判断Sentinel节点和其他节点之间是否可达。
1.5.2 主观下线和客观下线
在上一小节的第三个定时任务中,每个Sentinel节点会每隔1秒对主节点、从节点、其他Sentinel节点发送ping命令做心跳检测,当这些节点超过down-after-milliseconds没有进行有效回复,Sentinel节点就会对该节点做失败判定,这个行为叫做主观下线
。
当Sentinel主观下线的节点是主节点时,该Sentinel节点会通过sentinel is-master-down-by-addr命令向其他Sentinel节点询问对主节点的判断,当超过<quorum>
个数,就称为客观下线
。
在进行客观下线判断时,用到了命令sentinel is-master-down-by-addr
,其详细参数:
sentinel is-master-down-by-addr <ip> <port> <current_epoch> <runid>
ip:主节点IP。
port:主节点端口。
current_epoch:当前配置纪元。
runid:此参数有两种类型:
1)当runid等于“*”时,作用是Sentinel节点直接交换对主节点下线的判定。
2)当runid等于当前Sentinel节点的runid时,作用是当前Sentinel节点希望目标Sentinel节点同意自己成为领导者的请求。
如sentinel-1节点对主节点做主观下线后,会向其余Sentinel节点(假设sentinel-2和sentinel-3节点)发送该命令:
sentinel is-master-down-by-addr 127.0.0.1 6379 0 *
返回结果包含三个参数,如下所示:
down_state:目标Sentinel节点对于主节点的下线判断,1是下线,0是在线。
leader_runid:当leader_runid等于“*”时,代表返回结果是用来做主节点是否不可达,当leader_runid等于具体的runid,代表目标节点同意runid成为领导者。
leader_epoch:领导者纪元。
1.5.3 主观下线和客观下线
当Sentinel节点对于主节点已经做了客观下线判断时,接下来Sentinel节点之间会做一个领导者选举的工作,选出一个Sentinel节点作为领导者进行故障转移的工作。Redis使用了Raft算法实现领导者选举。
Redis Sentinel进行领导者选举的大致思路:
- 每个在线的Sentinel节点都有资格成为领导者,当它确认主节点主观下线时候,会向其他Sentinel节点发送sentinel is-master-down-by-addr命令,要求将自己设置为领导者。
- 收到命令的Sentinel节点,如果没有同意过其他Sentinel节点的sentinel is-master-down-by-addr命令,将同意该请求,否则拒绝。
- 如果该Sentinel节点发现自己的票数已经大于等于max(quorum,num(sentinels)/2+1),那么它将成为领导者。
- 如果此过程没有选举出领导者,将进入下一次选举。
1.5.4 故障转移
领导者选举出的Sentinel节点负责故障转移,具体步骤如下:
- 在从节点列表中选出一个节点作为新的主节点,选择方法如下:
a)过滤:“不健康”(主观下线、断线)、5秒内没有回复过Sentinel节点ping响应、与主节点失联超过down-after-milliseconds*10秒。
b)选择slave-priority(从节点优先级)最高的从节点列表,如果存在则返回,不存在则继续。
c)选择复制偏移量最大的从节点(复制的最完整),如果存在则返回,不存在则继续。
d)选择runid最小的从节点。- Sentinel领导者节点会对第一步选出来的从节点执行slaveof no one命令让其成为主节点。
- Sentinel领导者节点会向剩余的从节点发送命令,让它们成为新主节点的从节点,复制规则和parallel-syncs参数有关。
- Sentinel节点集合会将原来的主节点更新为从节点,并保持着对其关注,当其恢复后命令它去复制新的主节点。
二、集群
Redis Cluster是Redis在3.0版本推出的分布式解决方案。示例:
图中描述的是六个redis实例构成的集群,6379端口为客户端通讯端口,16379端口为集群总线端口。
集群内部划分为16384个数据分槽,分布在(本例子中为:三个)主redis中
。从redis
中没有分槽,不会参与集群投票,也不会帮忙加快读取数据,仅仅作为主机的备份
。三个主节点中平均分布着16384数据分槽的三分之一,每个主节点之间不会存有有重复数据。
Cluster 集群结构特点:
- Redis Cluster 所有的物理节点都映射到 [ 0-16383 ] slot 上(不一定均匀分布),Cluster负责维护节点、桶、值之间的关系;
- 在 Redis 集群中放置一个 key-value 时,根据 CRC16(key) mod 16384 的值,从之前划分的 16384 个桶中选择一个;
- 所有的 Redis 节点彼此互联(PING-PONG 机制),内部使用二进制协议优化传输效率;
超过半数的节点检测到某个节点失效时则判定该节点失效
;- 使用端与 Redis 节点链接,不需要中间 proxy 层,直接可以操作,使用端不需要连接集群所有节点,连接集群中任何一个可用节点即可。
该方案优势:
去中心化
,集群最大可增加1000个节点,性能随节点增加而线性扩展。管理方便,后续可自行增加或摘除节点,移动分槽等等
。- 部分节点异常,不影响整体集群的可用性。
2.1 数据分布
分布式数据库首先要解决把整个数据集按照分区规则映射到多个节点的问题,即把数据集划分到多个节点上,每个节点负责整体数据的一个子集。
常见的分区规则有哈希分区和顺序分区两种。
顺序分区
,比如:1到100个数字,要保存在3个节点上,按照顺序分区,把数据平均分配三个节点上:1号到33号数据保存到节点1上,34号到66号数据保存到节点2上,67号到100号数据保存到节点3上。顺序分区常用在关系型数据库的设计
。
哈希分区:例如1到100个数字,对每个数字进行哈希运算,然后对每个数的哈希结果除以节点数进行取余,余数为1则保存在第1个节点上,余数为2则保存在第2个节点上,余数为0则保存在第3个节点,这样可以保证数据被打散,同时保证数据分布的比较均匀。
Redis Cluster采用的是哈希分区方式
。
分区方式 | 特点 |
---|---|
哈希分区 | 离散度好 数据分布与业务无关 无法顺序访问 |
顺序分区 | 离散度易倾斜 数据分布与业务有关 可以顺序访问 |
常见的哈希分区方式有几种:
- 1、节点取余分区
使用特定的数据,如Redis的键或用户ID,再根据节点数量N使用公式:hash(key)%N
计算出哈希值,用来决定数据映射到哪一个节点上。这种方案存在一个问题:当节点数量变化时,如扩容或收缩节点,数据节点映射关系需要重新计算,会导致数据的重新迁移。
比如有100个数据,对每个数据进行hash运算之后,与节点数进行取余运算,根据余数不同保存在不同的节点上。
节点取余方式是非常简单的一种分区方式,但数据迁移时,可能工作量较大。
节点取余分区的优点:
- 客户端分片;
- 配置简单:对数据进行哈希,然后取余。
节点取余分区的缺点:
- 数据节点伸缩时,导致数据迁移;
- 迁移数量和添加节点数据有关,建议翻倍扩容。
节点取余分区常用于数据库的分库分表规则
,一般采用预分区的方式,提前根据数据量规划好分区数,比如划分为512或1024张表,保证可支撑未来一段时间的数据量,再根据负载情况将表迁移到其他数据库中。扩容时通常采用翻倍扩容,避免数据映射全部被打乱导致全量迁移的情况。示例:
- 2、一致性哈希分区
一致性哈希分区的实现思路是为系统中每个节点分配一个token,范围一般在0~232 ,这些token构成一个哈希环。数据读写执行节点查找操作时,先根据key计算hash值,然后顺时针找到第一个大于等于该哈希值的token节点。
相比节点取余,这种方式最大的好处在于加入和删除节点只影响哈希环中相邻的节点,对其他节点无影响。
一致性哈希分区的缺点:
- 加减节点会造成哈希环中部分数据无法命中,需要手动处理或者忽略这部分数据,因此一致性哈希常用于缓存场景。
- 当使用少量节点时,节点变化将大范围影响哈希环中数据映射,因此这种方式不适合少量数据节点的分布式方案。
- 普通的一致性哈希分区在增减节点时需要增加一倍或减去一半节点才能保证数据和负载的均衡。
- 3、虚拟槽分区
该方式使用分散度良好的哈希函数把所有数据映射到一个固定范围的整数集合中,整数定义为槽。这个范围一般远远大于节点数,比如Redis Cluster槽范围是0~16383
。槽是集群内数据管理和迁移的基本单位。采用大范围槽的主要目的是为了方便数据拆分和集群扩展。每个节点会负责一定数量的槽。比如在下面的例子中,当前集群有5个节点,每个节点平均大约负责3276个槽:
虚拟槽分区特点:
使用服务端管理节点,槽,数据:例如Redis Cluster。
可以对数据打散,又可以保证数据分布均匀。
2.2 Redis的虚拟槽分区
Redis Cluser采用虚拟槽分区
,所有的键根据哈希函数映射到0~16383整数槽内,计算公式:slot=CRC16(key)&16383。每一个节点负责维护一部分槽以及槽所映射的键值数据。
虚拟槽分区步骤:
- 把16384槽按照节点数量进行平均分配,由节点进行管理
- 对每个key按照CRC16(
数据通信领域中最常用的一种差错校验码,其特征是信息字段和校验字段的长度可以任意选定
)规则进行hash运算- 把hash结果对16383进行取余
- 把余数发送给Redis节点
- 节点接收到数据,验证是否在自己管理的槽编号的范围
1)如果在自己管理的槽编号范围内,则把数据保存到数据槽中,然后返回执行结果;
2)如果在自己管理的槽编号范围外,则会把数据发送给正确的节点,由正确的节点来把数据保存在对应的槽中;
从上面可以看出:
Redis Cluster的节点之间会共享消息,每个节点都会知道是哪个节点负责哪个范围内的数据槽。
虚拟槽分布方式中,由于每个节点管理一部分数据槽,数据保存到数据槽中。当节点扩容或者缩容时,对数据槽进行重新分配迁移即可,数据不会丢失。
Redis虚拟槽分区的特点:
- 解耦数据和节点之间的关系,简化了节点扩容和收缩难度。
- 节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据。
Redis集群相对单机在功能上存在一些限制:
- key批量操作支持有限。如mset、mget,目前只支持在同一个槽的key执行批量操作。因为key如果不在一个槽内,就可能存在多个物理节点上。
- key事务操作支持有限。同理只支持多key在同一节点上的事务操作,当多个key分布在不同的节点上时无法使用事务功能。
- 单机下的Redis可以支持16个数据库,
集群模式下只能使用一个数据库空间,即db0
。- 复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构。
- 为什么redis集群的最大槽数是16384个?
Redis 集群有 16384 个哈希槽,每个 key 通过 CRC16 算法计算的结果,对 16384 取模后放到对应的编号在 0-16383 之间的哈希槽,集群的每个节点负责一部分哈希槽。
在redis节点发送心跳包时需要把所有的槽放到这个心跳包里,以便让节点知道当前集群信息,16384=16k,在发送心跳包时使用char进行bitmap压缩后是2k(2 * 8 (8 bit) * 1024(1k) = 2K),也就是说使用2k的空间创建了16k的槽数
。
虽然使用CRC16算法最多可以分配65535(2^16-1)个槽位,65535=65k,压缩后就是8k(8 * 8 (8 bit) * 1024(1k) = 8K),也就是说需要需要8k的心跳包,作者认为这样做不太值得;并且一般情况下一个redis集群不会有超过1000个master节点,所以16k的槽位是个比较合适的选择
。
2.3 搭建集群
搭建集群工作需要三个步骤:准备节点、节点握手、分配槽。
- 1、准备节点
集群一般由多个节点组成,节点数量至少为6个才能保证组成完整高可用的集群。每个节点需要开启配置cluster-enabled yes
,让Redis运行在集群模式下。建议为集群内所有节点统一目录,一般划分三个目录:conf、data、log,分别存放配置、数据和日志相关文件。
可以把6个节点配置统一放在conf目录下,示例:
# 节点端口
port 6379
# 开启集群模式
cluster-enabled yes
# 节点超时时间,单位毫秒
cluster-node-timeout 15000
# 集群内部配置文件
cluster-config-file "nodes-6379.conf"
其他配置和单机模式一致即可,配置文件命名规则redis-{port}.conf,准备好配置后启动所有节点,示例:
第一次启动时如果没有集群配置文件,它会自动创建一份,文件名称采用cluster-config-file
参数项控制,建议采用node-{port}.conf
格式定义,通过使用端口号区分不同节点,防止同一机器下多个节点彼此覆盖,造成集群信息异常。如果启动时存在集群配置文件,节点会使用配置文件内容初始化集群信息。
集群模式的Redis除了原有的配置文件之外又加了一份集群配置文件。当集群内节点信息发生变化,如添加节点、节点下线、故障转移等。节点会自动保存集群状态到配置文件中,不要手动修改。
如节点6379首次启动后生成的集群配置示例:
文件内容记录了集群初始状态,这里最重要的是节点ID,它是一个40位16进制字符串,用于唯一标识集群内一个节点。
- 2、节点握手
节点握手是指一批运行在集群模式下的节点通过Gossip协议彼此通信,达到感知对方的过程。节点握手由客户端发起,命令:cluster meet{ip}{port}
。示例:
cluster meet命令是一个异步命令,执行之后立刻返回。内部发起与目标节点进行握手通信,图示:
1、节点6379本地创建6380节点信息对象,并发送meet消息。
2、节点6380接受到meet消息后,保存6379节点信息并回复pong消息。
3、之后节点6379和6380彼此定期通过ping/pong消息进行正常的节点通信。
当执行完上述命令后,6379和6380就已经在集群里了。对节点6379和6380分别执行cluster nodes
命令,可以看到它们已经能够通信了:
对其它节点,也执行相同命令,让他们进入集群:
127.0.0.1:6379>cluster meet 127.0.0.1 6381
127.0.0.1:6379>cluster meet 127.0.0.1 6382
127.0.0.1:6379>cluster meet 127.0.0.1 6383
127.0.0.1:6379>cluster meet 127.0.0.1 6384
节点建立握手之后集群还不能正常工作,这时集群处于下线状态,所有的数据读写都被禁止。示例:
通过cluster info
命令可以获取集群当前状态:
被分配的槽(cluster_slots_assigned)是0,由于目前所有的槽没有分配到节点,因此集群无法完成槽到节点的映射。只有当16384个槽全部分配给节点后,集群才进入在线状态。
- 3、分配槽
Redis集群把所有的数据映射到16384个槽中。每个key会映射为一个固定的槽,只有当节点分配了槽,才能响应和这些槽关联的键命令。分配槽示例:
此时,执行cluster info
查看集群状态:
此时集群进入在线状态,所有的槽都已经分配给节点。
目前还有三个节点没有使用,在集群中,每个负责处理槽的节点应该具有从节点,保证当它出现故障时可以自动进行故障转移。集群模式下,Reids节点角色分为主节点和从节点。首次启动的节点和被分配槽的节点都是主节点
,从节点负责复制主节点槽信息和相关的数据。
使用cluster replicate {nodeId}
命令让一个节点成为从节点。其中命令执行必须在对应的从节点上执行,nodeId是要复制主节点的节点ID,示例:
Redis集群模式下的主从复制没有特殊的地方,依然支持全量和部分复制。在本例子中,完整的完整结构:
使用cluster nodes
命令查看集群状态和复制关系,示例:
到目前为止,已经搭建起了一个集群。它由6个节点构成,3个主节点负责处理槽和相关数据,3个从节点负责故障转移。
2.4 用redis-trib.rb搭建集群
redis-trib.rb是采用Ruby实现的Redis集群管理工具,可以用来快速搭建集群。使用redis-trib.rb之前需要安装Ruby依赖环境。
- 1、Ruby环境准备
安装Ruby:
安装rubygem redis依赖:
安装redis-trib.rb:
安装完Ruby环境后,执行redis-trib.rb
命令确认环境是否正确。 - 2、准备节点
跟之前一样准备好节点配置并启动:
- 3、创建集群
启动好6个节点之后,使用redis-trib.rb create命令完成节点握手和槽分配过程,示例:
--replicas
参数指定集群中每个主节点配备几个从节点,这里设置为1。我们出于测试目的使用本地IP地址127.0.0.1,如果部署节点使用不同的IP地址,redis-trib.rb会尽可能保证主从节点不分配在同一机器下,因此会重新排序节点列表顺序。节点列表顺序用于确定主从角色,先主节点之后是从节点。
需要注意给redis-trib.rb的节点地址必须是不包含任何槽/数据的节点,否则会拒绝创建集群
。 - 4、集群完整性检查
集群完整性指所有的槽都分配到存活的主节点上,只要16384个槽中有一个没有分配给节点则表示集群不完整。可以使用redis-trib.rb check命令检测之前创建的两个集群是否成功,check命令只需要给出集群中任意一个节点地址就可以完成整个集群的检查工作。示例:
当最后输出如下信息,提示集群所有的槽都已分配到节点:
2.5 节点通信
在分布式存储中需要提供维护节点元数据信息的机制,所谓元数据
是指:节点负责哪些数据,是否出现故障等状态信息。常见的元数据维护方式分为:集中式和P2P方式。Redis集群采用P2P的Gossip协议。
Gossip协议工作原理就是节点彼此不断通信交换信息,一段时间后所有的节点都会知道集群完整的信息。
通信过程说明:
- 集群中的每个节点都会单独开辟一个TCP通道,用于节点之间彼此通信,通信端口号在基础端口上加10000。
- 每个节点在固定周期内通过特定规则选择几个节点发送ping消息。
- 接收到ping消息的节点用pong消息作为响应。
Gossip协议的主要职责就是信息交换。常用的Gossip消息可分为:ping消息、pong消息、meet消息、fail消息等。
meet消息
:用于通知新节点加入。消息发送者通知接收者加入到当前集群,meet消息通信正常完成后,接收节点会加入到集群中并进行周期性的ping、pong消息交换。
ping消息
:集群内交换最频繁的消息,集群内每个节点每秒向多个其他节点发送ping消息,用于检测节点是否在线和交换彼此状态信息。ping消息发送封装了自身节点和部分其他节点的状态数据。
pong消息
:当接收到ping、meet消息时,作为响应消息回复给发送方确认消息正常通信。pong消息内部封装了自身状态数据。节点也可以向集群内广播自身的pong消息来通知整个集群对自身状态进行更新。
fail消息
:当节点判定集群内另一个节点下线时,会向集群内广播一个fail消息,其他节点接收到fail消息之后把对应节点更新为下线状态。
接收节点收到ping/meet消息时,执行解析消息头和消息体流程:
解析消息头过程
:消息头包含了发送节点的信息,如果发送节点是新节点且消息是meet类型,则加入到本地节点列表;如果是已知节点,则尝试更新发送节点的状态,如槽映射关系、主从角色等状态。解析消息体过程
:如果消息体的cluster MsgDataGossip数组包含的节点是新节点,则尝试发起与新节点的meet握手流程;如果是已知节点,则根据cluster MsgDataGossip中的flags字段判断该节点是否下线,用于故障转移。消息处理完后回复pong消息
,内容同样包含消息头和消息体,发送节点接收到回复的pong消息后,采用类似的流程解析处理消息并更新与接收节点最后通信时间,完成一次消息通信。
Redis集群中,节点通信的规则:
每个ping消息的数据量体现在消息头和消息体中,其中消息头主要占用空间是固定的,2KB,消息体则不固定。
2.6 集群伸缩
Redis的集群伸缩(集群节点上下线)示例:
Redis集群伸缩的原理可抽象为:槽和对应数据在不同节点之间灵活移动。
2.6.1 扩容集群
Redis集群扩容操作的步骤:1)准备新节点;2)加入集群;3)迁移槽和数据。
- 1、准备新节点
准备好新节点并运行在集群模式下,新节点建议跟集群内的节点配置保持一致,便于管理统一。启动这两个新节点:
- 2、加入集群
新节点依然采用cluster meet命令加入到现有集群中。在集群内任意节点执行cluster meet
命令让6385和6386节点加入进来,示例:
新节点刚开始都是主节点状态,但是由于没有负责的槽,所以不能接受任何读写操作。
对于新节点的后续操作,一般有两种:
- 为它迁移槽和数据实现扩容。
- 作为其他主节点的从节点负责故障转移。
正式环境建议使用redis-trib.rb add-node
命令加入新节点,该命令内部会执行新节点状态检查,如果新节点已经加入其他集群或者包含数据,则放弃集群加入操作。
如果手动执行cluster meet命令加入已经存在于其他集群的节点,会造成被加入节点的集群合并到现有集群的情况,从而造成数据丢失和错乱。
- 3、迁移槽和数据
数据迁移过程是逐个槽进行的,示例:
迁移流程图示:
流程:
- 对目标节点发送
cluster setslot {slot} importing {sourceNodeId}
命令,让目标节点准备导入槽的数据。- 对源节点发送
cluster setslot {slot} migrating {targetNodeId}
命令,让源节点准备迁出槽的数据。- 源节点循环执行
cluster getkeysinslot {slot} {count}
命令,获取count个属于槽{slot}的键。- 在源节点上执行
migrate {targetIp} {targetPort} ""0 {timeout} keys {keys...}
命令,把获取的键通过流水线(pipeline)机制批量迁移到目标节点,批量迁移版本的migrate命令在Redis3.0.6以上版本提供,之前的migrate命令只能单个键迁移。对于大量key的场景,批量键迁移将极大降低节点之间网络IO次数。- 重复执行步骤3和步骤4,直到槽下所有的键值数据迁移到目标节点。
- 向集群内所有主节点发送
cluster setslot {slot} node {targetNodeId}
命令,通知槽分配给目标节点。为了保证槽节点映射变更及时传播,需要遍历发送给所有主节点更新被迁移的槽指向新节点。
在将6385节点加入集群之后,是作为主节点的,此时还要在为其添加一个从节点。
2.6.2 收缩集群
收缩集群的流程:
- 首先需要确定下线节点是否有负责的槽,如果是,需要把槽迁移到其他节点,保证节点下线后整个集群槽节点映射的完整性。
- 当下线节点不再负责槽或者本身是从节点时,就可以通知集群内其他节点忘记下线节点,当所有的节点忘记该节点后可以正常关闭。
- 1、下线迁移槽
假设6381是主节点,负责槽(12288-16383),6384是它的从节点。下线6381之前需要把负责的槽迁移到其他节点:
这里直接使用redis-trib.rb reshard命令完成槽迁移。由于每次执行reshard命令只能有一个目标节点,因此需要执行3次reshard命令,分别迁移1365、1365、1366个槽。具体的命令为:redis-trib.rb reshard 127.0.0.1:6381
。 - 2、忘记节点
Redis提供了cluster forget {downNodeId}
命令实现该功能。
当节点接收到cluster forget {down NodeId}
命令后,会把nodeId指定的节点加入到禁用列表中,在禁用列表内的节点不再发送Gossip消息。禁用列表有效期是60秒,超过60秒节点会再次参与消息交换。也就是说当第一次forget命令发出后,有60秒的时间让集群内的所有节点忘记下线节点。
真实环境不建议直接使用cluster forget
命令下线节点,需要跟大量节点命令交互,实际操作起来过于繁琐并且容易遗漏forget节点。建议使用redis-trib.rb del-node {host:port} {downNodeId}
命令。
2.7 请求路由
Redis集群对客户端通信协议做了比较大的修改,为了追求性能最大化,并没有采用代理的方式而是采用客户端直连节点的方式。
2.7.1 请求重定向
在集群模式下,Redis接收任何键相关命令时首先计算键对应的槽,再根据槽找出所对应的节点,如果节点是自身,则处理键命令;否则回复MOVED重定向错误,通知客户端请求正确的节点。这个过程称为MOVED重定向
。
要查某个键对应的槽,示例:
使用redis-cli
命令时,可以加入-c参数支持自动重定向,简化手动发起重定向操作,示例:
redis-cli自动帮我们连接到正确的节点执行命令,这个过程是在redis-cli内部维护,实质上是client端接到MOVED信息之后再次发起请求,并不在Redis节点中完成请求转发。
从上面可以看出:键命令执行步骤主要分两步:计算槽,查找槽所对应的节点
。
根据MOVED重定向机制,客户端可以随机连接集群内任一Redis获取键所在节点,这种客户端又叫Dummy(傀儡)客户端,它优点是代码实现简单,对客户端协议影响较小,只需要根据重定向信息再次发送请求即可。但是它的弊端很明显,每次执行键命令前都要到Redis上进行重定向才能找到要执行命令的节点,额外增加了IO开销,这不是Redis集群高效的使用方式。正因为如此通常集群客户端都采用另一种实现:Smart客户端
。
2.7.2 Smart客户端
Smart客户端通过在内部维护slot→node的映射关系,本地就可实现键到节点的查找,从而保证IO效率的最大化。
Smart客户端在Jedis框架中的是JedisCluster,构造方法为:
public JedisCluster(Set<HostAndPort> jedisClusterNode, int connectionTimeout, int
soTimeout, int maxAttempts, final GenericObjectPoolConfig poolConfig) {
...
}
Set<HostAndPort> jedisClusterNode
:所有Redis Cluster节点信息(也可以是一部分,因为客户端可以通过槽自动发现其他节点)。
int connectionTimeout
:连接超时。
int soTimeout
:读写超时。
int maxAttempts
:重试次数。
GenericObjectPoolConfig poolConfig
:连接池参数,JedisCluster会为
Redis Cluster的每个节点创建连接池。
JedisCluster的初始化及使用的简单示例:
// 初始化所有节点 ( 例如 6 个节点 )
Set<HostAndPort> jedisClusterNode = new HashSet<HostAndPort>();
jedisClusterNode.add(new HostAndPort("10.10.xx.1", 6379));
jedisClusterNode.add(new HostAndPort("10.10.xx.2", 6379));
jedisClusterNode.add(new HostAndPort("10.10.xx.3", 6379));
jedisClusterNode.add(new HostAndPort("10.10.xx.4", 6379));
jedisClusterNode.add(new HostAndPort("10.10.xx.5", 6379));
jedisClusterNode.add(new HostAndPort("10.10.xx.6", 6379));
// 初始化 commnon-pool 连接池,并设置相关参数
GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig();
// 初始化 JedisCluster
JedisCluster jedisCluster = new JedisCluster(jedisClusterNode, 1000, 1000, 5, poolConfig
jedisCluster.set("hello", "world");
jedisCluster.get("hello");
使用JedisCluster的几点建议:
- JedisCluster包含了所有节点的连接池(JedisPool),所以建议JedisCluster使用单例。
- JedisCluster每次操作完成后,不需要管理连接池的借还,它在内部已经完成。
- JedisCluster一般不要执行close()操作,它会将所有JedisPool执行destroy操作。
集群虽然提供了分布式的特性,但是有些命令或者操作,诸如keys、flushall、删除指定格式的键,需要遍历所有节点才可以完成。删除指定格式键的示例:
// 从 RedisCluster 批量删除指定 pattern 的数据
public void delRedisClusterByPattern(JedisCluster jedisCluster, String pattern,
int scanCounter) {
// 获取所有节点的 JedisPool
Map<String, JedisPool> jedisPoolMap = jedisCluster.getClusterNodes();
for (Entry<String, JedisPool> entry : jedisPoolMap.entrySet()) {
// 获取每个节点的 Jedis 连接
Jedis jedis = entry.getValue().getResource();
// 只删除主节点数据
if (!isMaster(jedis)) {
continue;
}
// 使用 Pipeline 每次删除指定前缀的数据
Pipeline pipeline = jedis.pipelined();
// 使用 scan 扫描指定前缀的数据
String cursor = "0";
// 指定扫描参数:每次扫描个数和 pattern
ScanParams params = new ScanParams().count(scanCounter).match(pattern);
while (true) {
// 执行扫描
ScanResult<String> scanResult = jedis.scan(cursor, params);
// 删除的 key 列表
List<String> keyList = scanResult.getResult();
if (keyList != null && keyList.size() > 0) {
for (String key : keyList) {
pipeline.del(key);
}
// 批量删除
pipeline.syncAndReturnAll();
}
cursor = scanResult.getStringCursor();
// 如果游标变为 0 ,说明扫描完毕
if ("0".equals(cursor)) {
break;
}
}
}
}
// 判断当前 Redis 是否为 master 节点
private boolean isMaster(Jedis jedis) {
String[] data = jedis.info("Replication").split("\r\n");
for (String line : data) {
if ("role:master".equals(line.trim())) {
return true;
}
}
return false;
}
2.7.3 ASK重定向
当一个槽的数据从源节点迁移到目标节点时,期间可能出现一部分数据在源节点,而另一部分在目标节点。
当出现这种情况时,客户端键命令执行流程:
- 客户端根据本地slots缓存发送命令到源节点,如果存在键对象则直接执行并返回结果给客户端。
- 如果键对象不存在,则可能存在于目标节点,这时源节点会回复ASK重定向异常。格式如下:(error)ASK{slot}{targetIP}:{targetPort}。
- 客户端从ASK重定向异常提取出目标节点信息,发送asking命令到目标节点打开客户端连接标识,再执行键命令。如果存在则执行,不存在则返回不存在信息。
ASK重定向和MOVED的区别
:ASK重定向说明集群正在进行slot数据迁移,客户端无法知道什么时候迁移完成,因此只能是临时性的重定向,客户端不会更新slots缓存。但是MOVED重定向说明键对应的槽已经明确指定到新的节点,因此需要更新slots缓存。
为了兼容这种情况,在对批量key操作时,需要用到Pipeline。
2.8 故障转移
2.8.1 故障发现
故障发现是通过消息传播机制实现的,主要环节包括:主观下线(pfail)和客观下线(fail)。
- 1、主观下线
集群中每个节点都会定期向其他节点发送ping消息,接收节点回复pong消息作为响应。如果在cluster-node-timeout时间内通信一直失败,则发送节点会认为接收节点存在故障,把接收节点标记为主观下线(pfail
)状态。
主观下线简单来讲就是,当cluster-note-timeout时间内某节点无法与另一个节点顺利完成ping消息通信时,则将该节点标记为主观下线状态。 - 2、客观下线
当某个节点判断另一个节点主观下线后,相应的节点状态会跟随消息在集群内传播。当半数以上持有槽的主节点都标记某个节点是主观下线时,触发客观下线
流程。
2.8.2 故障恢复
故障节点变为客观下线后,如果下线节点是持有槽的主节点则需要在它的从节点中选出一个替换它,从而保证集群的高可用。下线主节点的所有从节点承担故障恢复的义务,当从节点通过内部定时任务发现自身复制的主节点进入客观下线时,将会触发故障恢复流程
:资格检查、准备选举时间、发起选举、选举投票、替换主节点。
- 1、资格检查
每个从节点都要检查最后与主节点断线时间,判断是否有资格替换故障的主节点。如果从节点与主节点断线时间超过cluster-node-time*cluster-slave-validity-factor
,则当前从节点不具备故障转移资格。参数cluster-slave-validity-factor
用于从节点的有效因子,默认为10。 - 2、准备选举时间
当从节点符合故障转移资格后,更新触发故障选举的时间,只有到达该时间后才能执行后续流程。
之所以采用延迟触发机制,主要是通过对多个从节点使用不同的延迟选举时间来支持优先级问题。复制偏移量越大说明从节点延迟越低,那么它应该具有更高的优先级来替换故障主节点。示例:
- 3、发起选举
当从节点定时任务检测到达故障选举时间(failover_auth_time
)到达后,发起选举流程:
- 更新配置纪元
配置纪元是一个只增不减的整数,每个主节点自身维护一个配置纪元标示当前主节点的版本,所有主节点的配置纪元都不相等,从节点会复制主节点的配置纪元。整个集群又维护一个全局的配置纪元,用于记录集群内所有主节点配置纪元的最大版本。
执行cluster info
命令可以查看配置纪元信息:
- 广播选举消息
在集群内广播选举消息,并记录已发送过消息的状态,保证该从节点在一个配置纪元内只能发起一次选举。
- 4、选举投票
只有持有槽的主节点才会处理故障选举消息,因为每个持有槽的节点在一个配置纪元内都有唯一的一张选票。投票过程其实是一个领导者选举的过程,如集群内有N个持有槽的主节点代表有N张选票。
使用集群内所有持有槽的主节点进行领导者选举,即使只有一个从节点也可以完成选举过程。
当从节点收集到N/2+1个持有槽的主节点投票(故障主节点也算在投票数内
)时,从节点可以执行替换主节点操作。
假设集群内节点规模是3主3从,其中有2个主节点部署在一台机器上,当这台机器宕机时,由于从节点无法收集到3/2+1个主节点选票将导致故障转移失败。这个问题也适用于故障发现环节。因此部署集群时所有主节点最少需要部署在3台物理机上才能避免该问题。
每个配置纪元代表了一次选举周期,如果在开始投票之后的cluster-node-timeout*2
时间内从节点没有获取足够数量的投票,则本次选举作废。从节点对配置纪元自增并发起下一轮投票,直到选举成功为止。
- 5、替换主节点
当从节点收集到足够的选票之后,触发替换主节点操作:
- 当前从节点取消复制变为主节点。
- 撤销故障主节点负责的槽,并把这些槽委派给自己。
- 向集群广播自己的pong消息,通知集群内所有的节点当前从节点变为主节点并接管了故障主节点的槽信息。
2.8.3 故障转移时间
主观下线识别时间 = cluster-node-timeout
。
主观下线状态消息传播时间 <= cluster-node-timeout/2
。消息通信机制对超过cluster-node-timeout/2未通信节点会发起ping消息,消息体在选择包含哪些节点时会优先选取下线状态节点,所以通常这段时间内能够收集到半数以上主节点的pfail报告从而完成故障发现。
从节点转移时间 <= 1000毫秒。由于存在延迟发起选举机制,偏移量最大的从节点会最多延迟1秒发起选举。通常第一次选举就会成功,所以从节点执行转移时间在1秒以内。
预估出故障转移时间:
failover-time( 毫秒 ) ≤ cluster-node-timeout + cluster-node-timeout/2 + 1000
故障转移时间跟cluster-node-timeout参数息息相关,默认15秒。配置时可以根据业务容忍度做出适当调整,但不是越小越好。
2.9 Redis集群运维
2.9.1 带宽消耗
集群内Gossip消息通信本身会消耗带宽,官方建议集群最大规模在1000以内。
集群内所有节点通过ping/pong消息彼此交换信息,节点间消息通信对带宽的消耗体现在以下几个方面:
消息发送频率
:跟cluster-node-timeout密切相关,当节点发现与其他节点最后通信时间超过cluster-node-timeout/2时会直接发送ping消息。消息数据量
:每个消息主要的数据占用包含:slots槽数组(2KB空间)和整个集群1/10的状态数据(10个节点状态数据约1KB)。节点部署的机器规模
:机器带宽的上线是固定的,因此相同规模的集群分布的机器越多每台机器划分的节点越均匀,则集群内整体的可用带宽越高。
集群带宽消耗主要分为:读写命令消耗+Gossip消息消耗
。因此搭建Redis集群时需要根据业务数据规模和消息通信成本做出合理规划:
在满足业务需要的情况下尽量避免大集群
。同一个系统可以针对不同业务场景拆分使用多套集群。这样每个集群既满足伸缩性和故障转移要求,还可以规避大规模集群的弊端。适度提高cluster-node-timeout降低消息发送频率
,同时cluster-node-timeout还影响故障转移的速度,因此需要根据自身业务场景兼顾二者的平衡。如果条件允许集群尽量均匀部署在更多机器上
。
2.9.2 Pub/Sub广播问题
Redis在2.0版本提供了Pub/Sub(发布/订阅)功能,用于针对频道实现消息的发布和订阅。但是在集群模式下内部实现对所有的publish命令都会向所有的节点进行广播,造成每条publish数据都会在集群内所有节点传播一次,加重带宽负担。
当频繁应用Pub/Sub功能时应该避免在大量节点的集群内使用,否则会严重消耗集群内网络带宽。针对这种情况建议使用sentinel结构专门用于Pub/Sub功能,从而规避这一问题。
2.9.3 集群倾斜
数据倾斜主要分为以下几种:
节点和槽分配严重不均。
不同槽对应键数量差异过大。
集合对象包含大量元素。
内存相关配置不一致。
- 1、节点和槽分配严重不均
针对每个节点分配的槽不均的情况,可以使用redis-trib.rb info {host:ip}
进行定位,示例:
当节点对应槽数量不均匀时,可以使用redis-trib.rb rebalance
命令进行平衡。 - 2、不同槽对应键数量差异过大
集合对象包含大量元素。对于大集合对象的识别可以使用redis-cli --bigkeys
命令识别,找出大集合之后可以根据业务场景进行拆分。 - 3、不同槽对应键数量差异过大
内存相关配置不一致。内存相关配置指hash-max-ziplist-value
、set-max-intset-entries
等压缩数据结构配置。
2.10 Redis集群会有写操作丢失吗
Redis并不能保证数据的强一致性,这意味着集群在特定的条件下可能会丢失写操作。
以下情况可能导致写操作丢失:
过期 key 被清理;
最大内存不足,导致 Redis 自动清理部分 key 以节省空间;
- 主库故障后自动重启,从库自动同步;
单独的主备方案,网络不稳定触发哨兵的自动切换主从节点,切换期间会有数据丢失。
2.11 Redis集群部署的例子
Redis集群共用10 台机器,5 台机器作为主节点,另外5台机器作为从节点,每个主实例挂了一个从实例,5 个主节点对外提供读写服务,每个节点的读写高峰qps可能可以达到每秒 5 万,5 台机器最多是 25 万读写请求/s。
机器的配置:32G 内存+ 8 核 CPU + 1T 磁盘,分配给Redis进程的是10G内存。一般线上生产环境,redis 的内存尽量不要超过 10G,超过 10G可能会有问题
。
5 台机器对外提供读写,一共有 50g 内存。
因为每个主实例都关联了一个从实例,所以是高可用的,任何一个主实例宕机,都会自动故障迁移,Redis 从实例会自动变成主实例继续提供读写服务。
2.11 读写分离怎么实现
使用读写分离的方式提升数据库的负载能力。将所有的查询处理都放到从服务器上,写处理放在主服务器。
2.11.1 使用Spring基于应用层实现
在进入Service之前,使用AOP来做出判断,是使用写库还是读库,判断依据可以根据方法名判断,比如说以query、find、get等开头的就走读库,其他的走写库。
具体实现:继承AbstractRoutingDataSource实现动态数据源切换
,然后使用ThreadLocal实现简单的读写分离
:
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DbContextHolder.getDbType();
}
}
public class DbContextHolder {
// 注意:数据源标识保存在线程变量中,避免多线程操作数据源时互相干扰
private static final ThreadLocal<String> contextHolder=new ThreadLocal<String>();
public static void setDbType(String dbType){
contextHolder.set(dbType);
}
public static String getDbType(){
String dbType=(String) contextHolder.get();
return dbType;
}
public static void clearDbType(){
contextHolder.remove();
}
}
@Component
@Aspect
public class DataSourceMethodInterceptor {
@Before("execution(* com.xxx.xxx.xxx.xxx.service.impl.*.*(..))")
public void dynamicSetDataSoruce(JoinPoint joinPoint) throws Exception {
String methodName = joinPoint.getSignature().getName();
// 查询读从库
if (methodName.startsWith("select") || methodName.startsWith("load") || methodName.startsWith("get") || methodName.startsWith("count") || methodName.startsWith("is")) {
DynamicDataSourceHolder.setDataSource("slave");
} else {
// 其他读主库
DynamicDataSourceHolder.setDataSource("master");
}
}
}
优点:
- 多数据源切换方便,由程序自动完成;
- 不需要引入中间件;
- 理论上支持任何数据库;
缺点:
- 由程序员完成,运维参与不到;
- 不能做到动态增加数据源;
2.11.2 使用中间件实现读写分离
比如使用阿里的mycat。
比如要求:
一主两从,做读写分离。
多个从库之间实现负载均衡。
可手动强制部分读请求到主库上。(因为主从同步有延迟,对实时性要求高的系统,可以将部分读请求也走主库)。
一主两从可以mybatis中配置:
<bean id="master" class="com.alibaba.druid.pool.DruidDataSource" init-method="init"
destroy-method="close">
<property name="driverClassName" value="com.mysql.jdbc.Driver"/>
<property name="url" value="${jdbc.url.master}"></property>
<property name="username" value="${jdbc.username.master}"></property>
<property name="password" value="${jdbc.password.master}"></property>
<property name="maxActive" value="100"/>
<property name="initialSize" value="10"/>
<property name="maxWait" value="60000"/>
<property name="minIdle" value="5"/>
</bean>
<bean id="slave1" class="com.alibaba.druid.pool.DruidDataSource" init-method="init"
destroy-method="close">
<property name="driverClassName" value="com.mysql.jdbc.Driver"/>
<property name="url" value="${jdbc.url.slave1}"></property>
<property name="username" value="${jdbc.username.slave1}"></property>
<property name="password" value="${jdbc.password.slave1}"></property>
<property name="maxActive" value="100"/>
<property name="initialSize" value="10"/>
<property name="maxWait" value="60000"/>
<property name="minIdle" value="5"/>
</bean>
<bean id="slave2" class="com.alibaba.druid.pool.DruidDataSource" init-method="init"
destroy-method="close">
<property name="driverClassName" value="com.mysql.jdbc.Driver"/>
<property name="url" value="${jdbc.url.slave2}"></property>
<property name="username" value="${jdbc.username.slave2}"></property>
<property name="password" value="${jdbc.password.slave2}"></property>
<property name="maxActive" value="100"/>
<property name="initialSize" value="10"/>
<property name="maxWait" value="60000"/>
<property name="minIdle" value="5"/>
</bean>
<bean id="randomStrategy" class="io.shardingjdbc.core.api.algorithm.masterslave.RandomMasterSlaveLoadBalanceAlgorithm" />
<master-slave:data-source id="shardingDataSource" master-data-source-name="master" slave-data-source-names="slave1,slave2" strategy-ref="randomStrategy" />
使用读写分离,可能会有主从同步延迟的问题,对于一些实时性要求比较高的业务,需强制部分读请求访问主库,可以使用HintManager.setMasterRouteOnly()方法实现
。