Redis

 

在Redis中,实现高可用的技术主要包括持久化、复制、哨兵和集群

  1. 持久化:持久化是最简单的高可用方法(有时甚至不被归为高可用的手段),主要作用是数据备份,即将数据存储在硬盘,保证数据不会因进程退出而丢失。
  2. 复制:复制是高可用Redis的基础,哨兵和集群都是在复制基础上实现高可用的。复制主要实现了数据的多机备份,以及对于读操作的负载均衡和简单的故障恢复。缺陷:故障恢复无法自动化;写操作无法负载均衡;存储能力受到单机的限制。
  3. 哨兵:在复制的基础上,哨兵实现了自动化的 故障恢复。缺陷:写操作无法负载均衡;存储能力受到单机的限制。
  4. 集群:通过集群,Redis解决了写操作无法负载均衡,以及存储能力受到单机限制的问题,实现了较为完善的高可用方案。

1、​​数据类型​

1.1 基本数据类型

1.String(字符串)

结构:key:value

用途:

  1. 缓存用户数据:用户tocken
  2. 计数功能:使用 incr 等命令
  3. 共享session:适用于分布式系统,单点登录
  4. 限速:用户频繁发送请求,对该用户进行限速 (涉及命令 set nx ex)
# 设置一个键的值 
SET k v

# 如果 k 存在,不操作,如果不存在,则存入 v2 -- 用于分布式锁
setnx k v2

set k v xx # 必须k存在,才能存放成功

# 获取一个建的值
GET k

# 删除键对
DEL k

# 将value解析为整形同时 + 1
incr value

# 将value解析为整形同时 + n
incrby key n

# 同时添加了多个
mset k1 v1 k2 v2

# 同时获取多个
mget k1 k2

# 设置key的过期时间
set k v ex 5 # 秒
set k v px 5 # 毫秒


2.Hash(hash表)

结构:key/key:value、key:value、key:value...

用途:

存放结构化对象:存放用户数据信息,以cookie为key,用户信息作为value

# 添加数据
hset k k1 v1

# 不存在的情况下设置 k 的 k1 设置为 v1
hsetnx k k1 v1

# 同时设置多个 k1 k2
hmset k k1 v1 k2 v2

# 获取 k1 的value
hget k k1

# 获取 k1 k2 的value
hmget k k1 k2

# 给k1+1
hincrby k k1 5

# 测试 k1 是否存在
hexists k k1

# 返回 key 的数量
hlen k

# 删除指定 k1
hdel k k1

# 返回所有的 key
hkeys k

# 返回所有val
hvals k

# 返回所有的 ey 和 value
hgetall k


3.List(数组)

结构:key:value、value、value

特点:一个 key 里边可以存多个 value 的数据类型,有序的

用途

可以通过它实现多种数据结构:

  • lpush + lpop = stack (栈)
  • lpush + rpop = Queue (队列)
  • lpush + itrim = Capped Collection (有限集合)
  • lpush + brpop = Message Queue (消息队列)
# 左插入
lpush mylist a b c

# 右插入
rpush mylist x y z

# 弹出元素
lpop mylist

# 弹出元素
rpop mylist

# 显示数据集合
lrange mylist 0 -1

# 显示长度
llen mylist

#删除
lrem mylist count value

# 显示指定索引的值
lindex mylist 2

# 索引设值,n为值
lset mylist 2 n

# 剪切key对应的链接,切[start, stop]一段并把改制重新赋给key
ltrim mylist 0 4

# 在a前边插入b
linsert mylist before a b

# 在a后边插入b
linsert mylist after a

# 转移列表的数据
rpoplpush list list2


4.Set(集合)

结构:key:value、value、value ...

特点:无序、确定、随机

用途:

给用户添加标签,涉及命令:sadd、sinter

5.ZSet

数据形式:key:value、value、value ...

有序集合,顾名思义,有顺序的集合,主要体现在它有一个特定的值来标识。

用途:

1、用户积分排行榜

2、记录用户获赞数,对用户根据赞数进行管理

# 添加成员
# score:分数(排分依据)
# value:内容
zadd key score value1 score value2 score value3

# 获取成员的个数
zcard key

# 获取成员的分数
zscore key value

# 获取成员的排名
# zrank:从低到高
# zrevank:从高到底
zrank key value
zrevrank key value

# 删除成员
zrem key value

# 获取指定排名范围的成员
zrange key start end [withscores]
zrange key start end [withscores]

# 返回指定分数范围成员
zrangebyscore key min max [withscores] [limit offset count]


1.2 特殊数据类型

1.BitMap

它是通过一个bit位来表示某个元素对应的值或者状态,其中的 key 就是对应元素本身,实际上底层也是通过对字符串的操作来实现

用途:

记录用户的在线状态

# 设置在线状态
setBit online 0 1;

# 设置离线状态
setBit online 0 0;

# 获取状态
getBit online 0;

# 获取在线人数
bitCount online;
setbit key offset value
# offset必须是数字,value 是 0 或 1

# 例子

setbit k 0 1
setbit k 2 1
setbit k 5 1

# 此时 下表为0、2、5的值就是1,剩下全是零 (默认就是0),如下图


Redis_主从复制

2.Geo

这个数据类型是3.2出的新类型,用于存储地址位置信息,并对这些信息进行操作

# 将给定的空间元素(纬度、经度、名字)添加到指定的键里面 
geoadd

# 从键里面返回所有给定位置元素的位置(经度和纬度)
geopos

# 返回两个给定位置之间的距离
geodist

# 以给定的经纬度为中心, 返回键包含的位置元素当中, 与中心的距离不超过给定最大距离的所有位置元素
georadius

georadiusbymember

geohash


3.HyperLogLog(基数统计)

这个结构可以非常省内存的去统计各种计数

局限性:只能统计数量,不能知道具体内容

用途:

1、注册Ip数

2、每日访问IP数

3、页面实时UV

4、在线用户数

PFADD databases "redis" "MongoDB" "MySQL"

PFADD databases "redis" # 不能重复添加

# 统计有多少个数
PFCOUNT databases

PFADD nosql "Redis" "MongoDB" "Memcached"

PFADD RDBMS "MySQL" "MSSQL" "PostgreSQL"

# 对nosql和rdbms进行并集,同时去重复
PFMERGE databases nosql RDBMS

PFCOUNT databases


1.3 Jedis常用方法API

 ​

1.键操作

方法

说明

jedis.flushDB()

清除数据

boolean jedis.exists(String key)

判断某个键是否存在

jedis.set(String key, String value)

新增键值对(key, value)

Set jedis.keys("*")

获取所有key

jedis.del(String key)

删除键为key的数据项

jedis.expire(String key, int seconds)

设置键为key的过期时间为seconds秒

jedis.ttl(String key)

获取键为key数据项的剩余生存时间(秒)

jedis.persist(String key)

移除键为key属性项的生存时间限制

jedis.type()

查看键为key所对应value的数据类型

2.字符串操作

方法

说明

jedis.set(String key, String value)

增加(或覆盖)数据项

jedis.setnx(String key, String value)

不覆盖增加数据项(重复不插入)

jedis.setex(String key, String value)

增加数据项并设置有效时间

jedis.del(String key)

删除键为key的数据项

jedis.get(String key)

获取键为key对应的value

jedis.append(String key, String s)

在key对应value后面扩展字符串s

jedis.mset(String k1, String v1, String k2, String v2, ...)

增加多个键值对

String[] jedis.mget(String k1, String k2, ...)

获取多个key对应value

jedis.del(new String[]{String k1, String k2, ...})

删除多个key对应数据项

String jedis.getSet(String key, String value)

获取key对应value并更新value

String jedis.getrange(String key, int i, int j)

获取key对应value第i到j字符

详解见博客

2、Redis的事务

2.1 概念

Redis 事务的本质是一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。由于redis是单线程来处理所有client的请求的所以做到这点是很容易的。

总结说:redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。

  1. redis的事务错误处理与关系型数据库事务错误处理区别:
    如果事务块中某一条命令出错,关系型数据库的事务会执行回滚,而redis不会执行回滚,而是会继续执行后续的命令。因为redis的事务没有关系型数据库的回滚(rollback)功能。因此需要开发者在事务执行出错时自己处理。
  2. Redis事务没有隔离级别的概念:
    批量操作在发送 EXEC 命令前被放入队列缓存,并不会被实际执行,也就不存在事务内的查询要看到事务里的更新,事务外查询不能看到。
  3. Redis不保证原子性:
    Redis中,单条命令是原子性执行的,但事务不保证原子性,且没有回滚。事务中任意命令执行失败,其余的命令仍会被执行。
  4. Redis事务的三个阶段:
  • 开始事务
  • 命令入队
  • 执行事务

2.2 redis事务(Transaction)命令

命令

说明

watch

开启监视,用于监视一个或多个key,如果在事务执行之前这个或(这些)key被其他命令所改动,事务将被中断。

unwatch

取消监视,用于取消watch命令对所有key的监视。

multi

开始事务,用于标记一个事务块的开始,之后的所有命令都存放在队列,等遇到exec命令再执行。

DISCARD

取消事务,放弃执行事务块内的所有命令。

exec

执行事务内命令,假如某个key处于被监视状态。那么只有在此key的值符合条件时才执行,不然事务被中断,返回false。该命令返回事务内所有结果集。

2.3 Redis事务使用案例

  1. 正常执行
    Redis_数据_02
  2. 放弃事务
    Redis_redis_03
  3. 若在事务队列中存在命令性错误(类似于java编译性错误),则执行EXEC命令时,所有命令都不会执行
    Redis_持久化_04
  4. 若在事务队列中存在语法性错误(类似于java的1/0的运行时异常),则执行EXEC命令时,其他正确命令会被执行,错误命令抛出异常。
    Redis_主从复制_05
  5. 使用watch
  • 案例一:使用watch检测balance,事务期间balance数据未变动,事务执行成功
    Redis_客户端_06
  • 案例二:使用watch检测balance,在开启事务后(标注1处),在新窗口执行标注2中的操作,更改balance的值,模拟其他客户端在事务执行期间更改watch监控的数据,然后再执行标注1后命令,执行EXEC后,事务未成功执行。
    Redis_redis_07
    Redis_主从复制_08
    一但执行 EXEC 开启事务的执行后,无论事务使用执行成功, WARCH 对变量的监控都将被取消。
    故当事务执行失败后,需重新执行WATCH命令对变量进行监控,并开启新的事务进行操作。

3、redis的发布/订阅(pub/sub)

发布订阅(pub/sub)是一种消息通信模式,主要的目的是解耦消息发布者和消息订阅者之间的耦合。

Redis作为一个pub/sub server,在订阅者和发布者之间起到了消息路由的功能。

订阅者可以通过subscribe和psubscribe命令向redis server订阅自己感兴趣的消息类型,redis将消息类型称为通道(channel)。

当发布者通过publish命令向redis server发送特定类型的消息时,订阅该消息类型的全部client都会收到此消息,这里消息的传递是多对多的,一个client可以订阅多个 channel,也可以向多个channel发送消息。

3.1 订阅和发布

为了订阅foo和bar,一个客户端发出一个订阅的频道名称操作:

  • SUBSCRIBE foo bar

此时,从另一个客户端我们发出关于频道名称为second的发布操作:

  • PUBLISH foo Hello

这时第一个客户端就会收到它订阅的频道的消息:

  1. "message"
  2. "foo"
  3. "haha Hello"

3.2 模式匹配订阅

订阅操作也可以通过模式匹配来进行:

  • PSUBSCRIBE foo news.*

(表示订阅所有news.开头的频道,例如:news.art.figurative, news.music.jazz)

3.3 取消订阅

UNSUBSCRIBE和PUNSUBSCRIBE(模式匹配)

4、Redis的持久化

 

持久化的功能:Redis是内存数据库,数据都是存储在内存中,为了避免进程退出导致数据的永久丢失,需要定期将Redis中的数据以某种形式(数据或命令)从内存保存到硬盘;当下次Redis重启时,利用持久化文件实现数据恢复。除此之外,为了进行灾难备份,可以将持久化文件拷贝到一个远程位置。

Redis持久化分为RDB持久化和AOF持久化:前者将当前数据保存到硬盘,后者则是将每次执行的写命令保存到硬盘(类似于MySQL的binlog);由于AOF持久化的实时性更好,即当进程意外退出时丢失的数据更少,因此AOF是目前主流的持久化方式,不过RDB持久化仍然有其用武之地。

区别:

命令

RDB

AOF

启动优先级

体积

恢复速度

数据安全性

容易丢数据

根据策略决定

4.1 RDB

RDB持久化是把当前进程数据生成快照保存到磁盘上的过程,由于是某一时刻的快照,那么快照中的值要早于或者等于内存中的值。

生成的rdb文件的名称以及存储位置由redis.conf中的dbfilenamedir两个参数控制,默认生成的rdb文件是dump.rdb。

1.触发方式

触发RDB持久化的方式有2种,分别是手动触发和自动触发。

手动触发

redis客户端执行save命令和bgsave命令都可以触发RDB持久化,但是两者还是有区别的。

1.使用save命令时是使用redis的主进程进行持久化,此时会阻塞redis服务,造成服务不可用直到持久化完成,线上环境不建议使用;

2.bgsave命令是fork一个子进程,使用子进程去进行持久化,主进程只有在fork子进程时会短暂阻塞,fork操作完成后就不再阻塞,主进程可以正常进行其他操作。

3.bgsave是针对save阻塞主进程所做的优化,后续所有的自动触发都是使用bgsave进行操作。

2.自动触发

在以下4种情况时会自动触发

  • redis.conf中配置save m n,即在m秒内有n次修改时,自动触发bgsave生成rdb文件;
  • 主从复制时,从节点要从主节点进行全量复制时也会触发bgsave操作,生成当时的快照发送到从节点;
  • 执行debug reload命令重新加载redis时也会触发bgsave操作;
  • 默认情况下执行shutdown命令时,如果没有开启aof持久化,那么也会触发bgsave操作;

3.关闭RDB持久化

如果要关闭RDB持久化可以用两种方法:

  • 执行以下命令(redis-cli):
config set save ""


  • 修改配置文件
// 打开该行注释save ""// 注释掉以下内容# save 900 1# save 300 10# save 60 10000


4.流程

RDB持久化的流程图如下所示:

Redis_主从复制_09

具体流程如下:

  1. redis客户端执行bgsave命令或者自动触发bgsave命令;
  2. 主进程判断当前是否已经存在正在执行的子进程,如果存在,那么主进程直接返回;
  3. 如果不存在正在执行的子进程,那么就fork一个新的子进程进行持久化数据,fork过程是阻塞的,fork操作完成后主进程即可执行其他操作;
  4. 子进程先将数据写入到临时的rdb文件中,待快照数据写入完成后再原子替换旧的rdb文件;
  5. 同时发送信号给主进程,通知主进程rdb持久化完成,主进程更新相关的统计信息(info Persitence下的rdb_*相关选项)。

5.优缺点

优点:

  • RDB文件是某个时间节点的快照,默认使用LZF算法进行压缩,压缩后的文件体积远远小于内存大小,适用于备份、全量复制等场景;
  • Redis加载RDB文件恢复数据要远远快于AOF方式;

缺点:

  • RDB方式实时性不够,无法做到秒级的持久化;
  • 每次调用bgsave都需要fork子进程,fork子进程属于重量级操作,频繁执行成本较高;
  • RDB文件是二进制的,没有可读性,AOF文件在了解其结构的情况下可以手动修改或者补全;
  • 版本兼容RDB文件问题;

4.2 AOF

AOF方式持久化是使用文本协议将每次的写命令记录到aof文件中,经过文件重写后记录最终的数据生成命令,在redis启动时,通过执行aof文件中的命令恢复数据。

AOF方式主要解决了数据实时性持久化的问题,aof方式对于兼顾数据安全性和性能非常有帮助。

1.开启AOF

开启AOF模式持久化需要修改redis.conf文件中的如下配置:

# 开启aofappendonly true# aof文件名称appendfilename "appendonly.aof"# aof文件存储位置dir ./


也可以在redis客户端使用命令行的方式开启或者关闭aof

# 开启aofconfig set appendonly yes# 关闭aofconfig set appendonly no


2.AOF持久化流程

Redis_客户端_10

  • append
    aof文件只记录写命令,不记录读命令,当服务端接收到写命令后,redis会将命令写入到aof缓冲区中,之所以写入缓冲区而不直接写入aof文件中是因为如果每次都将命令直接写入到文件中,那么redis的性能将完全取决于硬盘的读写能力,这与redis性能至上的理念不符,另外,写入缓冲区中也便于使用不同的同步策略。
  • sync
    文件同步,即将aof缓冲区中的命令同步到aof文件中,redis提供三种策略以供选择,由参数appendfsync控制,三种策略分别是:

always: 表示命令append到缓冲区以后调用系统fsync操作同步到aof文件中,fsync操作完成后主线程返回;

no: 表示命令写入aof缓冲区后调用操作系统write操作,不对aof文件做fsync同步,同步到硬盘操作由操作系统负责,通常同步周期最长30秒;

everysec: 表示命令写入aof缓冲区后调用操作系统write操作,write操作完成后主线程返回,由专门的线程每秒去进行fsync同步文件操作

默认使用everysec,兼顾性能和安全性,很显然,使用always时每次都要等同步完成后才能返回,这个性能是很低的;同理使用no时,虽然不用每次都同步aof文件,但是同步操作周期不可控,数据安全性得不到保障,因此还是使用默认的everysec兼顾安全性和性能,每一秒同步一次,也就是在突发状况下最多丢失1秒的数据。

  • 重写(rewrite)

随着写命令越来越多,aof文件的体积也越来越大,此时就需要重写机制来按照特定的机制清除或者合并命令从而达到减小文件体积,便于redis重启加载的目的。

3.重写机制

重写规则:

  • 进程内已经过期的数据不再写入文件;
  • 只保存最终数据的写入命令,如set a 1, set a 2, set a 3,此时只保留最终的set a 3;
  • 多条写命令合并为一条命令,如lpush list 1, lpush list 2, lpush list 3合并为lpush list 1,2,3,同时为了防止单条命令过大,对于list、set、zset、hash等以64个元素为界限拆分为多条命令;

触发:

  • 手动触发
    手动执行​​bgrewriteaof​​命令即可触发aof重写
  • 自动触发
    自动触发与redis.conf中的​​auto-aof-rewrite-min-size​​和​​auto-aof-rewrite-percentage​​配置有关,默认配置如下:
auto-aof-rewrite-percentage 100auto-aof-rewrite-min-size 64mb


auto-aof-rewrite-min-size: 表示触发aof重写时aof文件的最小体积,默认64m
auto-aof-rewrite-percentage: 表示当前aof文件空间和上一次重写后aof文件空间的比值,默认是aof文件体积翻倍时触发重写

auto-aof-rewrite-percentage的计算方法:

auto-aof-rewrite-percentage =(当前aof文件体积 - 上次重写后aof文件体积)/ 上次重写后aof文件体积 * 100%

自动触发的条件:

(当前aof文件体积 > auto-aof-rewrite-min-size) && (auto-aof-rewrite-percentage的计算值 > 配置文件中配置的auto-aof-rewrite-percentage值)

重写流程:

Redis_redis_11

  1. 手动或者自动触发文件重写后主进程需要先判断当前是否有子进程存在,如果存在则直接返回,不存在则fork子进程;
  2. fork操作完成后,主进程即可响应其他命令,在子进程生成新的aof文件过程中,主进程仍然维持原来的流程以保证原有aof机制的正确性;
  3. 在子进程生成新的aof文件过程中主进程执行的新命令同时会被写入到aof重写缓冲区中,当新aof文件生成后再将这一部分命令写入到新aof文件中,防止数据丢失;
  4. 子进程根据内存快照,根据重写规则生成新的aof文件,每次批量写入硬盘数据量由配置​​aof-rewrite-incremental-fsync​​控制,默认为32MB,防止单次刷盘数据过多造成硬盘阻塞;
  5. 父进程把aof重写缓冲区的数据写入到新的aof文件中;
  6. 使用新aof文件替换旧的aof文件并发送信号给主进程表示重写完成。

4.优缺点

优点

  • 数据安全性较高,每隔1秒同步一次数据到aof文件,最多丢失1秒数据;
  • aof文件相比rdb文件可读性较高,便于灾难恢复;

缺点

  • 虽然经过文件重写,但是aof文件的体积仍然比rdb文件体积大了很多,不便于传输且数据恢复速度也较慢
  • aof的恢复速度要比rdb的恢复速度慢

4.3 fork以及copy_on_write

fork

不论RDB方式去创建一个新的rdb文件还是AOF方式重写aof文件,都需要fork一个子进程去处理以便在不阻塞主进程的情况下完成rdb文件的生成以及aof文件的重写,下面我们简单了解一下什么是fork以及使用到的copy_on_write写时复制技术。

何为fork?简而言之就是创建一个主进程的副本,创建的子进程除了进程id,其余任何内容都和主进程完全一致,这就是fork。

fork创建的子进程独立于主进程而存在,虽然两个进程内存空间的内容完全一致,但是对于内存的写入、修改以及文件的映射都是独立的,两个进程不会相互影响。

通过fork技术完美的解决了快照的问题,只需要某个时间点的内存中的数据,而父进程可以继续对自己的内存进行修改、写入而不会影响子进程的内存,这既不会阻塞主进程也不影响生成快照。

通过fork子进程的方式虽然能够完美解决不阻塞的情况下创建快照的问题,但是又会引入以下的问题:

子进程和主进程拥有相同的内存空间,就相当于瞬间将内存的使用量提高了一倍,假设服务器是16GB内存,主进程占用10GB,那么此时再创建子进程还需奥10GB,很明显超过了总内存,这很显然是存在很大问题的,即使不超过总内存,fork时将内存使用量提高一倍也是不可取的。

COW

写时拷贝(COW)就是为了解决这个问题而出现,那么什么是COW呢?

COW的主要作用就是将拷贝推迟到写操作真正发生时,这也就避免了大量无意义的拷贝。

什么意思呢?

意思是说在fork子进程时,父子进程会被内核分配到不同的虚拟内存空间中,对于父子进程来说它们访问的是不同的内存空间,但是两个虚拟内存空间映射的仍然是相同的物理内存,也就是说在fork完成后未发生任何修改时,父子进程对应的物理内存是同一份。

如果此时主进程执行了修改或者写入操作?因为有了修改或写入操作,此时父子进程内存就会出现不一致的情况,由于是主进程进行的修改,因此内核会为主进程要修改的内存块创建一个副本供主进程进行修改而不改变子进程的内存,也就是谁发生了修改就要为谁创建相应的副本。

linux中内存的复制是以内存页为单位的(4KB),也就是会为发生改变的内存页创建副本。

COW技术弥补了fork进程时内存翻倍的情况,fork操作为子进程访问父进程提供了支持,COW减少了额外的开销,这两者是Redis能够使用子进程进行快照持久化的核心。

COW原理:

fork()之后,kernel把父进程中所有的内存页的权限都设为read-only,然后子进程的地址空间指向父进程。当父子进程都只读内存时,相安无事。当其中某个进程写内存时,CPU硬件检测到内存页是read-only的,于是触发页异常中断(page-fault),陷入kernel的一个中断例程。中断例程中,kernel就会 把触发的异常的页复制一份,于是父子进程各自持有独立的一份。

COW优点:

  • 减少不必要的资源分配,只有在发生改变时才创建修改的内存页的副本,而不是创建整个内存的副本;
  • 减少fork子进程的时间,因为cow的存在,fork子进程时只需要复制主进程的空间内存页表即可,而不需要复制物理内存,因此大大提高了fork子进程的速度。

COW缺点:

  • 如果fork之后,父子进程都需要进行大量修改,那么就会出现大量的分页错误(页异常中断page-fault),这就有点得不偿失了。

但是对于redis来说,子进程只是用来生成快照的,并不会进行修改或者写入操作,也就不存在上述所说的问题了。

4.4 重启加载

Redis支持单独启动RDB或者单独启用AOF,也支持同时启用RDB和AOF,redis重启时加载流程如下所示:

Redis_主从复制_12

  1. redis重启时判断是否开启aof,如果开启了aof,那么就优先加载aof文件;
  2. 如果aof存在,那么就去加载aof文件,加载成功的话redis重启成功,如果aof文件加载失败,那么会打印日志表示启动失败,此时可以去修复aof文件后重新启动;
  3. 若aof文件不存在,那么redis就会转而去加载rdb文件,如果rdb文件不存在,redis直接启动成功;
  4. 如果rdb文件存在就会去加载rdb文件恢复数据,如加载失败则打印日志提示启动失败,如加载成功,那么redis重启成功,且使用rdb文件恢复数据;

4.5 持久化过程中需要注意的问题

1.aof追加阻塞

aof追加阻塞是指在开启aof持久化时,默认使用的是everysec同步策略,此时有一个额外的线程同步aof缓冲区中的内容到磁盘上的aof文件,如果在同步过程中由于磁盘io过高导致的redis主进程阻塞;

出现aof阻塞的根本原因是磁盘负载过高,redis主进程会监控同步线程每次同步aof缓冲区内容到aof文件所耗费的时间,如果距离上次同步成功的时间在2s内,那么主线程就直接返回,如果距离上次同步成功的时间超过2s,redis主进程就会阻塞,直到同步完成。

具体的流程图如下所示:

Redis_客户端_13

发生aof追加阻塞时会严重影响redis的性能,造成该现象的主要原因是磁盘高负载,那么相应的解决方案也要从磁盘负载上来解决。

解决方案:

  1. redis尽量不要与其他高磁盘消耗的服务部署在一起,如rabbitmq等消息队列,mysql等数据库服务;
  2. 配置开启no-appendfsync-on-rewrite=yes,表示在重写期间不做fsync操作;
  3. 单机配置多个redis实例的情况下,不同实例分盘存储aof文件以减轻单个磁盘的压力;

2.fork阻塞耗时问题

无论生成rdb文件还是重写aof文件,都会使用fork创建一个子进程来处理,这样就不会阻塞主进程了,虽然fork出来的子进程不会阻塞主进程,但是fork的过程中还是会阻塞主进程。也就是说子进程创建过程中还是会阻塞主进程影响redis对外提供服务。

前面提过fork过程中使用写时复制技术,并不会真正的复制物理内存,但是会复制主进程的空间内存页表,例如主进程为10G内存,大概要复制20M的空间页表,也就是说主进程内存越大,需要复制的空间内存页表越大,fork所需的时间越长,redis阻塞的时间越长,因此fork操作的优化点在于主进程的内存大小,另外有的虚拟化技术也会加大fork的时间,如Xen虚拟机。

因此从主进程内存和虚拟机化技术两个方面来优化fork阻塞耗时问题:

  1. 尽量使用物理机或者高效支持fork的虚拟化技术;
  2. 控制Redis实例最大可用内存,fork耗时和redis主进程内存量成正比;
  3. 降低fork操作的频率,如调高auto-aof-rewrite-min-size的值以减少aof重写的次数,或者主从复制时减少全量复制等;
  4. 合理配置linux内存分配策略,防止由于物理内存不足导致的fork失败;

5、Redis的M/S(主从复制)

博客:​​javascript:void(0)​

5.1 概念

通过持久化功能,Redis保证了即使在服务器重启的情况下也不会丢失(或少量丢失)数据,但是由于数据是存储在一台服务器上的,如果这台服务器出现故障,比如硬盘坏了,也会导致数据丢失。

为了避免单点故障,我们需要将数据复制多份部署在多台不同的服务器上,即使有一台服务器出现故障其他服务器依然可以继续提供服务。

这就要求当一台服务器上的数据更新后,自动将更新的数据同步到其他服务器上,这时候就用到了Redis的主从复制。

主从复制,是指将一台Redis服务器的数据,复制到其他的Redis服务器。前者称为主节点(master),后者称为从节点(slave);数据的复制是单向的,只能由主节点到从节点。

默认情况下,每台Redis服务器都是主节点;且一个主节点可以有多个从节点(或没有从节点),但一个从节点只能有一个主节点。

Redis_redis_14

Redis提供了复制(replication)功能来自动实现多台redis服务器的数据同步(每天19点 新闻联播,基本从cctv1-8,各大卫视都会播放)

我们可以通过部署多台redis,并在配置文件中指定这几台redis之间的主从关系,主负责写入数据,同时把写入的数据实时同步到从机器,这种模式叫做主从复制,即master/slave,并且redis默认master用于写,slave用于读,向slave写数据会导致错误。

主从复制的作用

  1. 数据冗余:主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式。
  2. 故障恢复:当主节点出现问题时,可以由从节点提供服务,实现快速的故障恢复;实际上是一种服务的冗余。
  3. 负载均衡:在主从复制的基础上,配合读写分离,可以由主节点提供写服务,由从节点提供读服务(即写Redis数据时应用连接主节点,读Redis数据时应用连接从节点),分担服务器负载;尤其是在写少读多的场景下,通过多个从节点分担读负载,可以大大提高Redis服务器的并发量。
  4. 高可用基石:除了上述作用以外,主从复制还是哨兵和集群能够实施的基础,因此说主从复制是Redis高可用的基础。

5.2 使用

1.建立复制

需要注意,主从复制的开启,完全是在从节点发起的;不需要我们在主节点做任何事情。

从节点开启主从复制,有3种方式:

(1)配置文件

在从服务器的配置文件中加入:slaveof

(2)启动命令

redis-server启动命令后加入 --slaveof

(3)客户端命令

Redis服务器启动后,直接通过客户端执行命令:slaveof ,则该Redis实例成为从节点。

上述3种方式是等效的,下面以客户端命令的方式为例,看一下当执行了slaveof后,Redis主节点和从节点的变化。

2.实例

准备工作:启动两个节点

方便起见,实验所使用的主从节点是在一台机器上的不同Redis实例,其中主节点监听6379端口,从节点监听6380端口;从节点监听的端口号可以在配置文件中修改:

Redis_主从复制_15

启动后可以看到:

Redis_持久化_16

两个Redis节点启动后(分别称为6379节点和6380节点),默认都是主节点。

建立复制

此时在6380节点执行slaveof命令,使之变为从节点:

Redis_redis_17

观察效果

下面验证一下,在主从复制建立后,主节点的数据会复制到从节点中。

(1)首先在从节点查询一个不存在的key:

Redis_redis_18

(2)然后在主节点中增加这个key:

Redis_客户端_19

(3)此时在从节点中再次查询这个key,会发现主节点的操作已经同步至从节点:

Redis_redis_20

(4)然后在主节点删除这个key:

Redis_redis_21

(5)此时在从节点中再次查询这个key,会发现主节点的操作已经同步至从节点:

Redis_持久化_22

3.断开复制

通过slaveof 命令建立主从复制关系以后,可以通过slaveof no one断开。需要注意的是,从节点断开复制后,不会删除已有的数据,只是不再接受主节点新的数据变化。

从节点执行slaveof no one后,打印日志如下所示;可以看出断开复制后,从节点又变回为主节点。

Redis_数据_23

主节点打印日志如下:

Redis_数据_24

5.3 主从复制的实现原理

主从复制过程大体可以分为3个阶段:连接建立阶段(即准备阶段)、数据同步阶段、命令传播阶段;下面分别进行介绍。

1. 连接建立阶段

该阶段的主要作用是在主从节点之间建立连接,为数据同步做好准备。

步骤1:保存主节点信息

从节点服务器内部维护了两个字段,即masterhost和masterport字段,用于存储主节点的ip和port信息。

需要注意的是,slaveof是异步命令,从节点完成主节点ip和port的保存后,向发送slaveof命令的客户端直接返回OK****,实际的复制操作在这之后才开始进行。

这个过程中,可以看到从节点打印日志如下:

Redis_持久化_25

步骤2:建立socket连接

从节点每秒1次调用复制定时函数replicationCron(),如果发现了有主节点可以连接,便会根据主节点的ip和port,创建socket连接。如果连接成功,则:

从节点:为该socket建立一个专门处理复制工作的文件事件处理器,负责后续的复制工作,如接收RDB文件、接收命令传播等。

主节点:接收到从节点的socket连接后(即accept之后),为该socket创建相应的客户端状态,并将从节点看做是连接到主节点的一个客户端,后面的步骤会以从节点向主节点发送命令请求的形式来进行。

这个过程中,从节点打印日志如下:

Redis_redis_26

步骤3:发送ping命令

从节点成为主节点的客户端之后,发送ping命令进行首次请求,目的是:检查socket连接是否可用,以及主节点当前是否能够处理请求。

从节点发送ping命令后,可能出现3种情况:

(1)返回pong:说明socket连接正常,且主节点当前可以处理请求,复制过程继续。

(2)超时:一定时间后从节点仍未收到主节点的回复,说明socket连接不可用,则从节点断开socket连接,并重连。

(3)返回pong以外的结果:如果主节点返回其他结果,如正在处理超时运行的脚本,说明主节点当前无法处理命令,则从节点断开socket连接,并重连。

在主节点返回pong情况下,从节点打印日志如下:

Redis_持久化_27

步骤4:身份验证

如果从节点中设置了masterauth选项,则从节点需要向主节点进行身份验证;没有设置该选项,则不需要验证。从节点进行身份验证是通过向主节点发送auth命令进行的,auth命令的参数即为配置文件中的masterauth的值。

如果主节点设置密码的状态,与从节点masterauth的状态一致(一致是指都存在,且密码相同,或者都不存在),则身份验证通过,复制过程继续;如果不一致,则从节点断开socket连接,并重连。

步骤5:发送从节点端口信息

身份验证之后,从节点会向主节点发送其监听的端口号(前述例子中为6380),主节点将该信息保存到该从节点对应的客户端的slave_listening_port字段中;该端口信息除了在主节点中执行info Replication时显示以外,没有其他作用。

2. 数据同步阶段

主从节点之间的连接建立以后,便可以开始进行数据同步,该阶段可以理解为从节点数据的初始化。具体执行的方式是:从节点向主节点发送psync命令(Redis2.8以前是sync命令),开始同步。

数据同步阶段是主从复制最核心的阶段,根据主从节点当前状态的不同,可以分为全量复制和部分复制,下面会有一章专门讲解这两种复制方式以及psync命令的执行过程,这里不再详述。

需要注意的是,在数据同步阶段之前,从节点是主节点的客户端,主节点不是从节点的客户端;而到了这一阶段及以后,主从节点互为客户端。原因在于:在此之前,主节点只需要响应从节点的请求即可,不需要主动发请求,而在数据同步阶段和后面的命令传播阶段,主节点需要主动向从节点发送请求(如推送缓冲区中的写命令),才能完成复制。

3. 命令传播阶段

数据同步阶段完成后,主从节点进入命令传播阶段;在这个阶段主节点将自己执行的写命令发送给从节点,从节点接收命令并执行,从而保证主从节点数据的一致性。

在命令传播阶段,除了发送写命令,主从节点还维持着心跳机制:PING和REPLCONF ACK。由于心跳机制的原理涉及部分复制,因此将在介绍了部分复制的相关内容后单独介绍该心跳机制。

延迟与不一致

需要注意的是,命令传播是异步的过程,即主节点发送写命令后并不会等待从节点的回复;因此实际上主从节点之间很难保持实时的一致性,延迟在所难免。数据不一致的程度,与主从节点之间的网络状况、主节点写命令的执行频率、以及主节点中的repl-disable-tcp-nodelay配置等有关。

repl-disable-tcp-nodelay no:该配置作用于命令传播阶段,控制主节点是否禁止与从节点的TCP_NODELAY;默认no,即不禁止TCP_NODELAY。当设置为yes时,TCP会对包进行合并从而减少带宽,但是发送的频率会降低,从节点数据延迟增加,一致性变差;具体发送频率与Linux内核的配置有关,默认配置为40ms。当设置为no时,TCP会立马将主节点的数据发送给从节点,带宽增加但延迟变小。

一般来说,只有当应用对Redis数据不一致的容忍度较高,且主从节点之间网络状况不好时,才会设置为yes;多数情况使用默认值no。

5.4【数据同步阶段】全量复制和部分复制

在Redis2.8以前,从节点向主节点发送sync命令请求同步数据,此时的同步方式是全量复制;在Redis2.8及以后,从节点可以发送psync命令请求同步数据,此时根据主从节点当前状态的不同,同步方式可能是全量复制或部分复制。后文介绍以Redis2.8及以后版本为例。

  1. 全量复制:用于初次复制或其他无法进行部分复制的情况,将主节点中的所有数据都发送给从节点,是一个非常重型的操作。
  2. 部分复制:用于网络中断等情况后的复制,只将中断期间主节点执行的写命令发送给从节点,与全量复制相比更加高效。需要注意的是,如果网络中断时间过长,导致主节点没有能够完整地保存中断期间执行的写命令,则无法进行部分复制,仍使用全量复制。

1. 全量复制

Redis通过psync命令进行全量复制的过程如下:

(1)从节点判断无法进行部分复制,向主节点发送全量复制的请求;或从节点发送部分复制的请求,但主节点判断无法进行部分复制;具体判断过程需要在讲述了部分复制原理后再介绍。

(2)主节点收到全量复制的命令后,执行bgsave,在后台生成RDB文件,并使用一个缓冲区(称为复制缓冲区)记录从现在开始执行的所有写命令

(3)主节点的bgsave执行完成后,将RDB文件发送给从节点;从节点首先清除自己的旧数据,然后载入接收的RDB文件,将数据库状态更新至主节点执行bgsave时的数据库状态

(4)主节点将前述复制缓冲区中的所有写命令发送给从节点,从节点执行这些写命令,将数据库状态更新至主节点的最新状态

(5)如果从节点开启了AOF,则会触发bgrewriteaof的执行,从而保证AOF文件更新至主节点的最新状态

下面是执行全量复制时,主从节点打印的日志;可以看出日志内容与上述步骤是完全对应的。

主节点的打印日志如下:

Redis_数据_28

从节点打印日志如下图所示:

Redis_主从复制_29

其中,有几点需要注意:从节点接收了来自主节点的89260个字节的数据;从节点在载入主节点的数据之前要先将老数据清除;从节点在同步完数据后,调用了bgrewriteaof。

通过全量复制的过程可以看出,全量复制是非常重型的操作:

(1)主节点通过bgsave命令fork子进程进行RDB持久化,该过程是非常消耗CPU、内存(页表复制)、硬盘IO的;关于bgsave的性能问题,可以参考 ​​深入学习Redis(2):持久化​

(2)主节点通过网络将RDB文件发送给从节点,对主从节点的带宽都会带来很大的消耗

(3)从节点清空老数据、载入新RDB文件的过程是阻塞的,无法响应客户端的命令;如果从节点执行bgrewriteaof,也会带来额外的消耗

2. 部分复制

由于全量复制在主节点数据量较大时效率太低,因此Redis2.8开始提供部分复制,用于处理网络中断时的数据同步。

部分复制的实现,依赖于三个重要的概念:

(1)复制偏移量

主节点和从节点分别维护一个复制偏移量(offset),代表的是主节点向从节点传递的字节数;主节点每次向从节点传播N个字节数据时,主节点的offset增加N;从节点每次收到主节点传来的N个字节数据时,从节点的offset增加N。

offset用于判断主从节点的数据库状态是否一致:如果二者offset相同,则一致;如果offset不同,则不一致,此时可以根据两个offset找出从节点缺少的那部分数据。例如,如果主节点的offset是1000,而从节点的offset是500,那么部分复制就需要将offset为501-1000的数据传递给从节点。而offset为501-1000的数据存储的位置,就是下面要介绍的复制积压缓冲区。

(2)复制积压缓冲区

复制积压缓冲区是由主节点维护的、固定长度的、先进先出(FIFO)队列,默认大小1MB;当主节点开始有从节点时创建,其作用是备份主节点最近发送给从节点的数据。注意,无论主节点有一个还是多个从节点,都只需要一个复制积压缓冲区。

在命令传播阶段,主节点除了将写命令发送给从节点,还会发送一份给复制积压缓冲区,作为写命令的备份;除了存储写命令,复制积压缓冲区中还存储了其中的每个字节对应的复制偏移量(offset)。由于复制积压缓冲区定长且是先进先出,所以它保存的是主节点最近执行的写命令;时间较早的写命令会被挤出缓冲区。

由于该缓冲区长度固定且有限,因此可以备份的写命令也有限,当主从节点offset的差距过大超过缓冲区长度时,将无法执行部分复制,只能执行全量复制。反过来说,为了提高网络中断时部分复制执行的概率,可以根据需要增大复制积压缓冲区的大小(通过配置repl-backlog-size);例如如果网络中断的平均时间是60s,而主节点平均每秒产生的写命令(特定协议格式)所占的字节数为100KB,则复制积压缓冲区的平均需求为6MB,保险起见,可以设置为12MB,来保证绝大多数断线情况都可以使用部分复制。

从节点将offset发送给主节点后,主节点根据offset和缓冲区大小决定能否执行部分复制:

  • 如果offset偏移量之后的数据,仍然都在复制积压缓冲区里,则执行部分复制;
  • 如果offset偏移量之后的数据已不在复制积压缓冲区中(数据已被挤出),则执行全量复制。
(3)服务器运行ID(runid)

每个Redis节点(无论主从),在启动时都会自动生成一个随机ID(每次启动都不一样),由40个随机的十六进制字符组成;runid用来唯一识别一个Redis节点。通过info Server命令,可以查看节点的runid:

Redis_redis_30

主从节点初次复制时,主节点将自己的runid发送给从节点,从节点将这个runid保存起来;当断线重连时,从节点会将这个runid发送给主节点;主节点根据runid判断能否进行部分复制:

  • 如果从节点保存的runid与主节点现在的runid相同,说明主从节点之前同步过,主节点会继续尝试使用部分复制(到底能不能部分复制还要看offset和复制积压缓冲区的情况);
  • 如果从节点保存的runid与主节点现在的runid不同,说明从节点在断线前同步的Redis节点并不是当前的主节点,只能进行全量复制。

3. psync命令的执行

在了解了复制偏移量、复制积压缓冲区、节点运行id之后,本节将介绍psync命令的参数和返回值,从而说明psync命令执行过程中,主从节点是如何确定使用全量复制还是部分复制的。

psync命令的执行过程可以参见下图(图片来源:《Redis设计与实现》):

Redis_持久化_31

(1)首先,从节点根据当前状态,决定如何调用psync命令:

  • 如果从节点之前未执行过slaveof或最近执行了slaveof no one,则从节点发送命令为psync ? -1,向主节点请求全量复制;
  • 如果从节点之前执行了slaveof,则发送命令为psync ,其中runid为上次复制的主节点的runid,offset为上次复制截止时从节点保存的复制偏移量。

(2)主节点根据收到的psync命令,及当前服务器状态,决定执行全量复制还是部分复制:

  • 如果主节点版本低于Redis2.8,则返回-ERR回复,此时从节点重新发送sync命令执行全量复制;
  • 如果主节点版本够新,且runid与从节点发送的runid相同,且从节点发送的offset之后的数据在复制积压缓冲区中都存在,则回复+CONTINUE,表示将进行部分复制,从节点等待主节点发送其缺少的数据即可;
  • 如果主节点版本够新,但是runid与从节点发送的runid不同,或从节点发送的offset之后的数据已不在复制积压缓冲区中(在队列中被挤出了),则回复+FULLRESYNC ,表示要进行全量复制,其中runid表示主节点当前的runid,offset表示主节点当前的offset,从节点保存这两个值,以备使用。

4. 部分复制演示

在下面的演示中,网络中断几分钟后恢复,断开连接的主从节点进行了部分复制;为了便于模拟网络中断,本例中的主从节点在局域网中的两台机器上。

网络中断

网络中断一段时间后,主节点和从节点都会发现失去了与对方的连接(关于主从节点对超时的判断机制,后面会有说明);此后,从节点便开始执行对主节点的重连,由于此时网络还没有恢复,重连失败,从节点会一直尝试重连。

主节点日志如下:

Redis_数据_32

从节点日志如下:

Redis_主从复制_33

网络恢复

网络恢复后,从节点连接主节点成功,并请求进行部分复制,主节点接收请求后,二者进行部分复制以同步数据。

主节点日志如下:

Redis_主从复制_34

从节点日志如下:

Redis_客户端_35

5.5【命令传播阶段】心跳机制

在命令传播阶段,除了发送写命令,主从节点还维持着心跳机制:PING和REPLCONF ACK。心跳机制对于主从复制的超时判断、数据安全等有作用。

1.主->从:PING

每隔指定的时间,主节点会向从节点发送PING命令,这个PING命令的作用,主要是为了让从节点进行超时判断。

PING发送的频率由repl-ping-slave-period参数控制,单位是秒,默认值是10s。

关于该PING命令究竟是由主节点发给从节点,还是相反,有一些争议;因为在Redis的官方文档中,对该参数的注释中说明是从节点向主节点发送PING命令,如下图所示:

Redis_redis_36

但是根据该参数的名称(含有ping-slave),以及代码实现,我认为该PING命令是主节点发给从节点的。相关代码如下:

Redis_客户端_37

2. 从->主:REPLCONF ACK

在命令传播阶段,从节点会向主节点发送REPLCONF ACK命令,频率是每秒1次;命令格式为:REPLCONF ACK {offset},其中offset指从节点保存的复制偏移量。REPLCONF ACK命令的作用包括:

(1)实时监测主从节点网络状态:该命令会被主节点用于复制超时的判断。此外,在主节点中使用info Replication,可以看到其从节点的状态中的lag值,代表的是主节点上次收到该REPLCONF ACK命令的时间间隔,在正常情况下,该值应该是0或1,如下图所示:

Redis_主从复制_38

(2)检测命令丢失:从节点发送了自身的offset,主节点会与自己的offset对比,如果从节点数据缺失(如网络丢包),主节点会推送缺失的数据(这里也会利用复制积压缓冲区)。注意,offset和复制积压缓冲区,不仅可以用于部分复制,也可以用于处理命令丢失等情形;区别在于前者是在断线重连后进行的,而后者是在主从节点没有断线的情况下进行的。

(3)辅助保证从节点的数量和延迟:Redis主节点中使用min-slaves-to-write和min-slaves-max-lag参数,来保证主节点在不安全的情况下不会执行写命令;所谓不安全,是指从节点数量太少,或延迟过高。例如min-slaves-to-write和min-slaves-max-lag分别是3和10,含义是如果从节点数量小于3个,或所有从节点的延迟值都大于10s,则主节点拒绝执行写命令。而这里从节点延迟值的获取,就是通过主节点接收到REPLCONF ACK命令的时间来判断的,即前面所说的info Replication中的lag值。

5.6 应用中的问题

1. 读写分离及其中的问题

在主从复制基础上实现的读写分离,可以实现Redis的读负载均衡:由主节点提供写服务,由一个或多个从节点提供读服务(多个从节点既可以提高数据冗余程度,也可以最大化读负载能力);在读负载较大的应用场景下,可以大大提高Redis服务器的并发量。下面介绍在使用Redis读写分离时,需要注意的问题。

(1)延迟与不一致问题

前面已经讲到,由于主从复制的命令传播是异步的,延迟与数据的不一致不可避免。如果应用对数据不一致的接受程度程度较低,可能的优化措施包括:优化主从节点之间的网络环境(如在同机房部署);监控主从节点延迟(通过offset)判断,如果从节点延迟过大,通知应用不再通过该从节点读取数据;使用集群同时扩展写负载和读负载等。

在命令传播阶段以外的其他情况下,从节点的数据不一致可能更加严重,例如连接在数据同步阶段,或从节点失去与主节点的连接时等。从节点的slave-serve-stale-data参数便与此有关:它控制这种情况下从节点的表现;如果为yes(默认值),则从节点仍能够响应客户端的命令,如果为no,则从节点只能响应info、slaveof等少数命令。该参数的设置与应用对数据一致性的要求有关;如果对数据一致性要求很高,则应设置为no。

(2)数据过期问题

在单机版Redis中,存在两种删除策略:

  • 惰性删除:服务器不会主动删除数据,只有当客户端查询某个数据时,服务器判断该数据是否过期,如果过期则删除。
  • 定期删除:服务器执行定时任务删除过期数据,但是考虑到内存和CPU的折中(删除会释放内存,但是频繁的删除操作对CPU不友好),该删除的频率和执行时间都受到了限制。

在主从复制场景下,为了主从节点的数据一致性,从节点不会主动删除数据,而是由主节点控制从节点中过期数据的删除。由于主节点的惰性删除和定期删除策略,都不能保证主节点及时对过期数据执行删除操作,因此,当客户端通过Redis从节点读取数据时,很容易读取到已经过期的数据。

Redis 3.2中,从节点在读取数据时,增加了对数据是否过期的判断:如果该数据已过期,则不返回给客户端;将Redis升级到3.2可以解决数据过期问题。

(3)故障切换问题

在没有使用哨兵的读写分离场景下,应用针对读和写分别连接不同的Redis节点;当主节点或从节点出现问题而发生更改时,需要及时修改应用程序读写Redis数据的连接;连接的切换可以手动进行,或者自己写监控程序进行切换,但前者响应慢、容易出错,后者实现复杂,成本都不算低。

(4)总结

在使用读写分离之前,可以考虑其他方法增加Redis的读负载能力:如尽量优化主节点(减少慢查询、减少持久化等其他情况带来的阻塞等)提高负载能力;使用Redis集群同时提高读负载能力和写负载能力等。如果使用读写分离,可以使用哨兵,使主从节点的故障切换尽可能自动化,并减少对应用程序的侵入。

2. 复制超时问题

主从节点复制超时是导致复制中断的最重要的原因之一,本小节单独说明超时问题,下一小节说明其他会导致复制中断的问题。

超时判断意义

在复制连接建立过程中及之后,主从节点都有机制判断连接是否超时,其意义在于:

(1)如果主节点判断连接超时,其会释放相应从节点的连接,从而释放各种资源,否则无效的从节点仍会占用主节点的各种资源(输出缓冲区、带宽、连接等);此外连接超时的判断可以让主节点更准确的知道当前有效从节点的个数,有助于保证数据安全(配合前面讲到的min-slaves-to-write等参数)。

(2)如果从节点判断连接超时,则可以及时重新建立连接,避免与主节点数据长期的不一致。

判断机制

主从复制超时判断的核心,在于repl-timeout参数,该参数规定了超时时间的阈值(默认60s),对于主节点和从节点同时有效;主从节点触发超时的条件分别如下:

(1)主节点:每秒1次调用复制定时函数replicationCron(),在其中判断当前时间距离上次收到各个从节点REPLCONF ACK的时间,是否超过了repl-timeout值,如果超过了则释放相应从节点的连接。

(2)从节点:从节点对超时的判断同样是在复制定时函数中判断,基本逻辑是:

  • 如果当前处于连接建立阶段,且距离上次收到主节点的信息的时间已超过repl-timeout,则释放与主节点的连接;
  • 如果当前处于数据同步阶段,且收到主节点的RDB文件的时间超时,则停止数据同步,释放连接;
  • 如果当前处于命令传播阶段,且距离上次收到主节点的PING命令或数据的时间已超过repl-timeout值,则释放与主节点的连接。

主从节点判断连接超时的相关源代码如下:

/* Replication cron function, called 1 time per second. */void replicationCron(void) {    static long long replication_cron_loops = 0;     /* Non blocking connection timeout? */    if (server.masterhost &&        (server.repl_state == REDIS_REPL_CONNECTING ||         slaveIsInHandshakeState()) &&         (time(NULL)-server.repl_transfer_lastio) > server.repl_timeout)    {        redisLog(REDIS_WARNING,"Timeout connecting to the MASTER...");        undoConnectWithMaster();    }     /* Bulk transfer I/O timeout? */    if (server.masterhost && server.repl_state == REDIS_REPL_TRANSFER &&        (time(NULL)-server.repl_transfer_lastio) > server.repl_timeout)    {        redisLog(REDIS_WARNING,"Timeout receiving bulk data from MASTER... If the problem persists try to set the 'repl-timeout' parameter in redis.conf to a larger value.");        replicationAbortSyncTransfer();    }     /* Timed out master when we are an already connected slave? */    if (server.masterhost && server.repl_state == REDIS_REPL_CONNECTED &&        (time(NULL)-server.master->lastinteraction) > server.repl_timeout)    {        redisLog(REDIS_WARNING,"MASTER timeout: no data nor PING received...");        freeClient(server.master);    }     //此处省略无关代码……     /* Disconnect timedout slaves. */    if (listLength(server.slaves)) {        listIter li;        listNode *ln;        listRewind(server.slaves,&li);        while((ln = listNext(&li))) {            redisClient *slave = ln->value;            if (slave->replstate != REDIS_REPL_ONLINE) continue;            if (slave->flags & REDIS_PRE_PSYNC) continue;            if ((server.unixtime - slave->repl_ack_time) > server.repl_timeout)            {                redisLog(REDIS_WARNING, "Disconnecting timedout slave: %s",                    replicationGetSlaveName(slave));                freeClient(slave);            }        }    }     //此处省略无关代码…… }


需要注意的坑

下面介绍与复制阶段连接超时有关的一些实际问题:

(1)数据同步阶段:在主从节点进行全量复制bgsave时,主节点需要首先fork子进程将当前数据保存到RDB文件中,然后再将RDB文件通过网络传输到从节点。如果RDB文件过大,主节点在fork子进程+保存RDB文件时耗时过多,可能会导致从节点长时间收不到数据而触发超时;此时从节点会重连主节点,然后再次全量复制,再次超时,再次重连……这是个悲伤的循环。为了避免这种情况的发生,除了注意Redis单机数据量不要过大,另一方面就是适当增大repl-timeout值,具体的大小可以根据bgsave耗时来调整。

(2)命令传播阶段:如前所述,在该阶段主节点会向从节点发送PING命令,频率由repl-ping-slave-period控制;该参数应明显小于repl-timeout值(后者至少是前者的几倍)。否则,如果两个参数相等或接近,网络抖动导致个别PING命令丢失,此时恰巧主节点也没有向从节点发送数据,则从节点很容易判断超时。

(3)慢查询导致的阻塞:如果主节点或从节点执行了一些慢查询(如keys *或者对大数据的hgetall等),导致服务器阻塞;阻塞期间无法响应复制连接中对方节点的请求,可能导致复制超时。

3. 复制中断问题

主从节点超时是复制中断的原因之一,除此之外,还有其他情况可能导致复制中断,其中最主要的是复制缓冲区溢出问题。

复制缓冲区溢出

前面曾提到过,在全量复制阶段,主节点会将执行的写命令放到复制缓冲区中,该缓冲区存放的数据包括了以下几个时间段内主节点执行的写命令:bgsave生成RDB文件、RDB文件由主节点发往从节点、从节点清空老数据并载入RDB文件中的数据。当主节点数据量较大,或者主从节点之间网络延迟较大时,可能导致该缓冲区的大小超过了限制,此时主节点会断开与从节点之间的连接;这种情况可能引起全量复制->复制缓冲区溢出导致连接中断->重连->全量复制->复制缓冲区溢出导致连接中断……的循环。

复制缓冲区的大小由client-output-buffer-limit slave {hard limit} {soft limit} {soft seconds}配置,默认值为client-output-buffer-limit slave 256MB 64MB 60,其含义是:如果buffer大于256MB,或者连续60s大于64MB,则主节点会断开与该从节点的连接。该参数是可以通过config set命令动态配置的(即不重启Redis也可以生效)。

当复制缓冲区溢出时,主节点打印日志如下所示:

Redis_持久化_39

需要注意的是,复制缓冲区是客户端输出缓冲区的一种,主节点会为每一个从节点分别分配复制缓冲区;而复制积压缓冲区则是一个主节点只有一个,无论它有多少个从节点。

4. 各场景下复制的选择及优化技巧

在介绍了Redis复制的种种细节之后,现在我们可以来总结一下,在下面常见的场景中,何时使用部分复制,以及需要注意哪些问题。

(1)第一次建立复制

此时全量复制不可避免,但仍有几点需要注意:如果主节点的数据量较大,应该尽量避开流量的高峰期,避免造成阻塞;如果有多个从节点需要建立对主节点的复制,可以考虑将几个从节点错开,避免主节点带宽占用过大。此外,如果从节点过多,也可以调整主从复制的拓扑结构,由一主多从结构变为树状结构(中间的节点既是其主节点的从节点,也是其从节点的主节点);但使用树状结构应该谨慎:虽然主节点的直接从节点减少,降低了主节点的负担,但是多层从节点的延迟增大,数据一致性变差;且结构复杂,维护相当困难。

(2)主节点重启

主节点重启可以分为两种情况来讨论,一种是故障导致宕机,另一种则是有计划的重启。

主节点宕机

主节点宕机重启后,runid会发生变化,因此不能进行部分复制,只能全量复制。

实际上在主节点宕机的情况下,应进行故障转移处理,将其中的一个从节点升级为主节点,其他从节点从新的主节点进行复制;且故障转移应尽量的自动化,后面文章将要介绍的哨兵便可以进行自动的故障转移。

安全重启:debug reload

在一些场景下,可能希望对主节点进行重启,例如主节点内存碎片率过高,或者希望调整一些只能在启动时调整的参数。如果使用普通的手段重启主节点,会使得runid发生变化,可能导致不必要的全量复制。

为了解决这个问题,Redis提供了debug reload的重启方式:重启后,主节点的runid和offset****都不受影响,避免了全量复制。

如下图所示,debug reload重启后runid和offset都未受影响:

Redis_持久化_40

但debug reload是一柄双刃剑:它会清空当前内存中的数据,重新从RDB文件中加载,这个过程会导致主节点的阻塞,因此也需要谨慎。

(3)从节点重启

从节点宕机重启后,其保存的主节点的runid会丢失,因此即使再次执行slaveof,也无法进行部分复制。

(4)网络中断

如果主从节点之间出现网络问题,造成短时间内网络中断,可以分为多种情况讨论。

第一种情况:网络问题时间极为短暂,只造成了短暂的丢包,主从节点都没有判定超时(未触发repl-timeout);此时只需要通过REPLCONF ACK来补充丢失的数据即可。

第二种情况:网络问题时间很长,主从节点判断超时(触发了repl-timeout),且丢失的数据过多,超过了复制积压缓冲区所能存储的范围;此时主从节点无法进行部分复制,只能进行全量复制。为了尽可能避免这种情况的发生,应该根据实际情况适当调整复制积压缓冲区的大小;此外及时发现并修复网络中断,也可以减少全量复制。

第三种情况:介于前述两种情况之间,主从节点判断超时,且丢失的数据仍然都在复制积压缓冲区中;此时主从节点可以进行部分复制。

5. 复制相关的配置

这一节总结一下与复制有关的配置,说明这些配置的作用、起作用的阶段,以及配置方法等;通过了解这些配置,一方面加深对Redis复制的了解,另一方面掌握这些配置的方法,可以优化Redis的使用,少走坑。

配置大致可以分为主节点相关配置、从节点相关配置以及与主从节点都有关的配置,下面分别说明。

(1)与主从节点都有关的配置

首先介绍最特殊的配置,它决定了该节点是主节点还是从节点:

  1. slaveof :Redis启动时起作用;作用是建立复制关系,开启了该配置的Redis服务器在启动后成为从节点。该注释默认注释掉,即Redis服务器默认都是主节点。
  2. repl-timeout 60:与各个阶段主从节点连接超时判断有关,见前面的介绍。
(2)主节点相关配置
  1. repl-diskless-sync no:作用于全量复制阶段,控制主节点是否使用diskless复制(无盘复制)。所谓diskless复制,是指在全量复制时,主节点不再先把数据写入RDB文件,而是直接写入slave的socket中,整个过程中不涉及硬盘;diskless复制在磁盘IO很慢而网速很快时更有优势。需要注意的是,截至Redis3.0,diskless复制处于实验阶段,默认是关闭的。
  2. repl-diskless-sync-delay 5:该配置作用于全量复制阶段,当主节点使用diskless复制时,该配置决定主节点向从节点发送之前停顿的时间,单位是秒;只有当diskless复制打开时有效,默认5s。之所以设置停顿时间,是基于以下两个考虑:(1)向slave的socket的传输一旦开始,新连接的slave只能等待当前数据传输结束,才能开始新的数据传输 (2)多个从节点有较大的概率在短时间内建立主从复制。
  3. client-output-buffer-limit slave 256MB 64MB 60:与全量复制阶段主节点的缓冲区大小有关,见前面的介绍。
  4. repl-disable-tcp-nodelay no:与命令传播阶段的延迟有关,见前面的介绍。
  5. masterauth :与连接建立阶段的身份验证有关,见前面的介绍。
  6. repl-ping-slave-period 10:与命令传播阶段主从节点的超时判断有关,见前面的介绍。
  7. repl-backlog-size 1mb:复制积压缓冲区的大小,见前面的介绍。
  8. repl-backlog-ttl 3600:当主节点没有从节点时,复制积压缓冲区保留的时间,这样当断开的从节点重新连进来时,可以进行部分复制;默认3600s。如果设置为0,则永远不会释放复制积压缓冲区。
  9. min-slaves-to-write 3与min-slaves-max-lag 10:规定了主节点的最小从节点数目,及对应的最大延迟,见前面的介绍。
(3)从节点相关配置
  1. slave-serve-stale-data yes:与从节点数据陈旧时是否响应客户端命令有关,见前面的介绍。
  2. slave-read-only yes:从节点是否只读;默认是只读的。由于从节点开启写操作容易导致主从节点的数据不一致,因此该配置尽量不要修改。

6. 单机内存大小限制

在 ​​深入学习Redis(2):持久化​​ 一文中,讲到了fork操作对Redis单机内存大小的限制。实际上在Redis的使用中,限制单机内存大小的因素非常之多,下面总结一下在主从复制中,单机内存过大可能造成的影响:

(1)切主:当主节点宕机时,一种常见的容灾策略是将其中一个从节点提升为主节点,并将其他从节点挂载到新的主节点上,此时这些从节点只能进行全量复制;如果Redis单机内存达到10GB,一个从节点的同步时间在几分钟的级别;如果从节点较多,恢复的速度会更慢。如果系统的读负载很高,而这段时间从节点无法提供服务,会对系统造成很大的压力。

(2)从库扩容:如果访问量突然增大,此时希望增加从节点分担读负载,如果数据量过大,从节点同步太慢,难以及时应对访问量的暴增。

(3)缓冲区溢出:(1)和(2)都是从节点可以正常同步的情形(虽然慢),但是如果数据量过大,导致全量复制阶段主节点的复制缓冲区溢出,从而导致复制中断,则主从节点的数据同步会全量复制->复制缓冲区溢出导致复制中断->重连->全量复制->复制缓冲区溢出导致复制中断……的循环。

(4)超时:如果数据量过大,全量复制阶段主节点fork+保存RDB文件耗时过大,从节点长时间接收不到数据触发超时,主从节点的数据同步同样可能陷入全量复制->超时导致复制中断->重连->全量复制->超时导致复制中断……的循环。

此外,主节点单机内存除了绝对量不能太大,其占用主机内存的比例也不应过大:最好只使用50%-65%的内存,留下30%-45%的内存用于执行bgsave命令和创建复制缓冲区等。

7. info Replication

在Redis客户端通过info Replication可以查看与复制相关的状态,对于了解主从节点的当前状态,以及解决出现的问题都会有帮助。

主节点:

Redis_持久化_41

从节点:

Redis_数据_42

对于从节点,上半部分展示的是其作为从节点的状态,从connectd_slaves开始,展示的是其作为潜在的主节点的状态。

info Replication中展示的大部分内容在文章中都已经讲述,这里不再详述。

5.7 总结

  1. 主从复制的作用:宏观的了解主从复制是为了解决什么样的问题,即数据冗余、故障恢复、读负载均衡等。
  2. 主从复制的操作:即slaveof命令。
  3. 主从复制的原理:主从复制包括了连接建立阶段、数据同步阶段、命令传播阶段;其中数据同步阶段,有全量复制和部分复制两种数据同步方式;命令传播阶段,主从节点之间有PING和REPLCONF ACK命令互相进行心跳检测。
  4. 应用中的问题:包括读写分离的问题(数据不一致问题、数据过期问题、故障切换问题等)、复制超时问题、复制中断问题等,然后总结了主从复制相关的配置,其中repl-timeout、client-output-buffer-limit slave等对解决Redis主从复制中出现的问题可能会有帮助。

主从复制虽然解决或缓解了数据冗余、故障恢复、读负载均衡等问题,但其缺陷仍很明显:故障恢复无法自动化;写操作无法负载均衡;存储能力受到单机的限制;这些问题的解决,需要哨兵和集群的帮助。

6、Redis的Sentinel(哨兵模式)

更多信息参考博客:​​https://www.jianshu.com/p/06ab9daf921d​

6.1 概述

主从切换技术的方法是:当主服务器宕机后,需要手动把一台从服务器切换为主服务器,这就需要人工干预,费事费力,还会造成一段时间内服务不可用。这不是一种推荐的方式,更多时候,我们优先考虑哨兵模式

Redis_持久化_43

哨兵的核心功能是主节点的自动故障转移。下面是Redis官方文档对于哨兵功能的描述:

  • 监控(Monitoring):哨兵会不断地检查主节点和从节点是否运作正常。
  • 自动故障转移(Automatic failover):当主节点不能正常工作时,哨兵会开始自动故障转移操作,它会将失效主节点的其中一个从节点升级为新的主节点,并让其他从节点改为复制新的主节点。
  • 配置提供者(Configuration provider):客户端在初始化时,通过连接哨兵来获得当前Redis服务的主节点地址。
  • 通知(Notification):哨兵可以将故障转移的结果发送给客户端。

其中,监控和自动故障转移功能,使得哨兵可以及时发现主节点故障并完成转移;而配置提供者和通知功能,则需要在与客户端的交互中才能体现。

然而一个哨兵进程对Redis服务器进行监控,可能会出现问题,为此,我们可以使用多个哨兵进行监控。

各个哨兵之间还会进行监控,这样就形成了多哨兵模式。

6.2 架构

它由两部分组成,哨兵节点和数据节点:

  • 哨兵节点:哨兵系统由一个或多个哨兵节点组成,哨兵节点是特殊的redis节点,不存储数据。
  • 数据节点:主节点【master】和从节点【slave】都是数据节点。

Redis_客户端_44

假设主服务器宕机,哨兵1先检测到这个结果,系统并不会马上进行failover过程,仅仅是哨兵1主观的认为主服务器不可用,这个现象成为主观下线。当后面的哨兵也检测到主服务器不可用,并且数量达到一定值时,那么哨兵之间就会进行一次投票,投票的结果由一个哨兵发起,进行failover[故障转移]操作。切换成功后,就会通过发布订阅模式,让各个哨兵把自己监控的从服务器实现切换主机,这个过程称为客观下线

6.3 部署

这一部分将部署一个简单的哨兵系统,包含1个主节点、2个从节点和3个哨兵节点。方便起见:所有这些节点都部署在一台机器上(局域网IP:192.168.92.128),使用端口号区分;节点的配置尽可能简化。

1. 部署主从节点

哨兵系统中的主从节点,与普通的主从节点配置是一样的,并不需要做任何额外配置。下面分别是主节点(port=6379)和2个从节点(port=6380/6381)的配置文件。

#redis-6379.confport 6379daemonize yeslogfile "6379.log"dbfilename "dump-6379.rdb" #redis-6380.confport 6380daemonize yeslogfile "6380.log"dbfilename "dump-6380.rdb"slaveof 192.168.92.128 6379 #redis-6381.confport 6381daemonize yeslogfile "6381.log"dbfilename "dump-6381.rdb"slaveof 192.168.92.128 6379


配置完成后,依次启动主节点和从节点:

redis-server redis-6379.confredis-server redis-6380.confredis-server redis-6381.conf


节点启动后,连接主节点查看主从状态是否正常,如下图所示:

Redis_主从复制_45

2. 部署哨兵节点

哨兵节点本质上是特殊的Redis节点。

3个哨兵节点的配置几乎是完全一样的,主要区别在于端口号的不同(26379/26380/26381),下面以26379节点为例介绍节点的配置和启动方式;配置部分尽量简化,更多配置会在后面介绍。

#sentinel-26379.confport 26379daemonize yeslogfile "26379.log"sentinel monitor mymaster 192.168.92.128 6379 2


其中,sentinel monitor mymaster 192.168.92.128 6379 2 配置的含义是:该哨兵节点监控192.168.92.128:6379这个主节点,该主节点的名称是mymaster,最后的2的含义与主节点的故障判定有关:至少需要2个哨兵节点同意,才能判定主节点故障并进行故障转移。

哨兵节点的启动有两种方式,二者作用是完全相同的:

redis-sentinel sentinel-26379.confredis-server sentinel-26379.conf --sentinel


按照上述方式配置和启动之后,整个哨兵系统就启动完毕了。可以通过redis-cli连接哨兵节点进行验证,如下图所示:可以看出26379哨兵节点已经在监控mymaster主节点(即192.168.92.128:6379),并发现了其2个从节点和另外2个哨兵节点。

Redis_数据_46

此时如果查看哨兵节点的配置文件,会发现一些变化,以26379为例:

Redis_主从复制_47

其中,dir只是显式声明了数据和日志所在的目录(在哨兵语境下只有日志);known-slave和known-sentinel显示哨兵已经发现了从节点和其他哨兵;带有epoch的参数与配置纪元有关(配置纪元是一个从0开始的计数器,每进行一次领导者哨兵选举,都会+1;领导者哨兵选举是故障转移阶段的一个操作,在后文原理部分会介绍)。

3. 演示故障转移

哨兵的4个作用中,配置提供者和通知需要客户端的配合,本文将在下一章介绍客户端访问哨兵系统的方法时详细介绍。这一小节将演示当主节点发生故障时,哨兵的监控和自动故障转移功能。

(1)首先,使用kill命令杀掉主节点:

Redis_redis_48

(2)如果此时立即在哨兵节点中使用info Sentinel命令查看,会发现主节点还没有切换过来,因为哨兵发现主节点故障并转移,需要一段时间。

Redis_客户端_49

(3)一段时间以后,再次在哨兵节点中执行info Sentinel查看,发现主节点已经切换成6380节点。

Redis_持久化_50

但是同时可以发现,哨兵节点认为新的主节点仍然有2个从节点,这是因为哨兵在将6380切换成主节点的同时,将6379节点置为其从节点;虽然6379从节点已经挂掉,但是由于哨兵并不会对从节点进行客观下线(其含义将在原理部分介绍),因此认为该从节点一直存在。当6379节点重新启动后,会自动变成6380节点的从节点。下面验证一下。

(4)重启6379节点:可以看到6379节点成为了6380节点的从节点。

Redis_数据_51

(5)在故障转移阶段,哨兵和主从节点的配置文件都会被改写。

对于主从节点,主要是slaveof配置的变化:新的主节点没有了slaveof配置,其从节点则slaveof新的主节点。

对于哨兵节点,除了主从节点信息的变化,纪元(epoch)也会变化,下图中可以看到纪元相关的参数都+1了。

Redis_数据_52

4. 总结

哨兵系统的搭建过程,有几点需要注意:

(1)哨兵系统中的主从节点,与普通的主从节点并没有什么区别,故障发现和转移是由哨兵来控制和完成的。

(2)哨兵节点本质上是redis节点。

(3)每个哨兵节点,只需要配置监控主节点,便可以自动发现其他的哨兵节点和从节点。

(4)在哨兵节点启动和故障转移阶段,各个节点的配置文件会被重写(config rewrite)。

(5)本章的例子中,一个哨兵只监控了一个主节点;实际上,一个哨兵可以监控多个主节点,通过配置多条sentinel monitor即可实现。

6.4 客户端访问哨兵系统

上一小节演示了哨兵的两大作用:监控和自动故障转移,本小节则结合客户端演示哨兵的另外两个作用:配置提供者和通知

1. 代码示例

在介绍客户端的原理之前,先以Java客户端Jedis为例,演示一下使用方法:下面代码可以连接我们刚刚搭建的哨兵系统,并进行各种读写操作(代码中只演示如何连接哨兵,异常处理、资源关闭等未考虑)。

public static void testSentinel() throws Exception {         String masterName = "mymaster";         Set<String> sentinels = new HashSet<>();         sentinels.add("192.168.92.128:26379");         sentinels.add("192.168.92.128:26380");         sentinels.add("192.168.92.128:26381");          JedisSentinelPool pool = new JedisSentinelPool(masterName, sentinels); //初始化过程做了很多工作         Jedis jedis = pool.getResource();         jedis.set("key1", "value1");         pool.close();}


2. 客户端原理

Jedis客户端对哨兵提供了很好的支持。如上述代码所示,我们只需要向Jedis提供哨兵节点集合和masterName,构造JedisSentinelPool对象;然后便可以像使用普通redis连接池一样来使用了:通过pool.getResource()获取连接,执行具体的命令。

在整个过程中,我们的代码不需要显式的指定主节点的地址,就可以连接到主节点;代码中对故障转移没有任何体现,就可以在哨兵完成故障转移后自动的切换主节点。之所以可以做到这一点,是因为在JedisSentinelPool的构造器中,进行了相关的工作;主要包括以下两点:

(1)遍历哨兵节点,获取主节点信息:遍历哨兵节点,通过其中一个哨兵节点+masterName获得主节点的信息;该功能是通过调用哨兵节点的sentinel get-master-addr-by-name命令实现,该命令示例如下:

Redis_客户端_53

一旦获得主节点信息,停止遍历(因此一般来说遍历到第一个哨兵节点,循环就停止了)。

(2)增加对哨兵的监听:这样当发生故障转移时,客户端便可以收到哨兵的通知,从而完成主节点的切换。具体做法是:利用redis提供的发布订阅功能,为每一个哨兵节点开启一个单独的线程,订阅哨兵节点的+switch-master频道,当收到消息时,重新初始化连接池。

3. 总结

通过客户端原理的介绍,可以加深对哨兵功能的理解:

(1)配置提供者:客户端可以通过哨兵节点+masterName获取主节点信息,在这里哨兵起到的作用就是配置提供者。

需要注意的是,哨兵只是配置提供者,而不是代理。二者的区别在于:如果是配置提供者,客户端在通过哨兵获得主节点信息后,会直接建立到主节点的连接,后续的请求(如set/get)会直接发向主节点;如果是代理,客户端的每一次请求都会发向哨兵,哨兵再通过主节点处理请求。

举一个例子可以很好的理解哨兵的作用是配置提供者,而不是代理。在前面部署的哨兵系统中,将哨兵节点的配置文件进行如下修改:

sentinel monitor mymaster 192.168.92.128 6379 2改为sentinel monitor mymaster 127.0.0.1 6379 2


然后,将前述客户端代码在局域网的另外一台机器上运行,会发现客户端无法连接主节点;这是因为哨兵作为配置提供者,客户端通过它查询到主节点的地址为127.0.0.1:6379,客户端会向127.0.0.1:6379建立redis连接,自然无法连接。如果哨兵是代理,这个问题就不会出现了。

(2)通知:哨兵节点在故障转移完成后,会将新的主节点信息发送给客户端,以便客户端及时切换主节点。

6.5 基本原理

1. 哨兵节点支持的命令

哨兵节点作为运行在特殊模式下的redis节点,其支持的命令与普通的redis节点不同。在运维中,我们可以通过这些命令查询或修改哨兵系统;不过更重要的是,哨兵系统要实现故障发现、故障转移等各种功能,离不开哨兵节点之间的通信,而通信的很大一部分是通过哨兵节点支持的命令来实现的。下面介绍哨兵节点支持的主要命令。

(1)基础查询:通过这些命令,可以查询哨兵系统的拓扑结构、节点信息、配置信息等。

  • info sentinel:获取监控的所有主节点的基本信息
  • sentinel masters:获取监控的所有主节点的详细信息
  • sentinel master mymaster:获取监控的主节点mymaster的详细信息
  • sentinel slaves mymaster:获取监控的主节点mymaster的从节点的详细信息
  • sentinel sentinels mymaster:获取监控的主节点mymaster的哨兵节点的详细信息
  • sentinel get-master-addr-by-name mymaster:获取监控的主节点mymaster的地址信息,前文已有介绍
  • sentinel is-master-down-by-addr:哨兵节点之间可以通过该命令询问主节点是否下线,从而对是否客观下线做出判断

(2)增加/移除对主节点的监控

sentinel monitor mymaster2 192.168.92.128 16379 2:与部署哨兵节点时配置文件中的sentinel monitor功能完全一样,不再详述

sentinel remove mymaster2:取消当前哨兵节点对主节点mymaster2的监控

(3)强制故障转移

sentinel failover mymaster:该命令可以强制对mymaster执行故障转移,即便当前的主节点运行完好;例如,如果当前主节点所在机器即将报废,便可以提前通过failover命令进行故障转移。

2. 基本原理

关于哨兵的原理,关键是了解以下几个概念。

(1)定时任务:每个哨兵节点维护了3个定时任务。定时任务的功能分别如下:通过向主从节点发送info命令获取最新的主从结构;通过发布订阅功能获取其他哨兵节点的信息;通过向其他节点发送ping命令进行心跳检测,判断是否下线。

(2)主观下线:在心跳检测的定时任务中,如果其他节点超过一定时间没有回复,哨兵节点就会将其进行主观下线。顾名思义,主观下线的意思是一个哨兵节点“主观地”判断下线;与主观下线相对应的是客观下线。

(3)客观下线:哨兵节点在对主节点进行主观下线后,会通过sentinel is-master-down-by-addr命令询问其他哨兵节点该主节点的状态;如果判断主节点下线的哨兵数量达到一定数值,则对该主节点进行客观下线。

需要特别注意的是,客观下线是主节点才有的概念;如果从节点和哨兵节点发生故障,被哨兵主观下线后,不会再有后续的客观下线和故障转移操作。

(4)选举领导者哨兵节点:当主节点被判断客观下线以后,各个哨兵节点会进行协商,选举出一个领导者哨兵节点,并由该领导者节点对其进行故障转移操作。

监视该主节点的所有哨兵都有可能被选为领导者,选举使用的算法是Raft算法;Raft算法的基本思路是先到先得:即在一轮选举中,哨兵A向B发送成为领导者的申请,如果B没有同意过其他哨兵,则会同意A成为领导者。选举的具体过程这里不做详细描述,一般来说,哨兵选择的过程很快,谁先完成客观下线,一般就能成为领导者。

(5)故障转移:选举出的领导者哨兵,开始进行故障转移操作,该操作大体可以分为3个步骤:

  • 在从节点中选择新的主节点:选择的原则是,首先过滤掉不健康的从节点;然后选择优先级最高的从节点(由slave-priority指定);如果优先级无法区分,则选择复制偏移量最大的从节点;如果仍无法区分,则选择runid最小的从节点。
  • 更新主从状态:通过slaveof no one命令,让选出来的从节点成为主节点;并通过slaveof命令让其他节点成为其从节点。
  • 将已经下线的主节点(即6379)设置为新的主节点的从节点,当6379重新上线后,它会成为新的主节点的从节点。

通过上述几个关键概念,可以基本了解哨兵的工作原理。为了更形象的说明,下图展示了领导者哨兵节点的日志,包括从节点启动到完成故障转移。

Redis_主从复制_54

6.6 配置与实践建议

1. 配置

下面介绍与哨兵相关的几个配置。

(1) sentinel monitor {masterName} {masterIp} {masterPort} {quorum}

sentinel monitor是哨兵最核心的配置,在前文讲述部署哨兵节点时已说明,其中:masterName指定了主节点名称,masterIp和masterPort指定了主节点地址,quorum是判断主节点客观下线的哨兵数量阈值:当判定主节点下线的哨兵数量达到quorum时,对主节点进行客观下线。建议取值为哨兵数量的一半加1。

(2) sentinel down-after-milliseconds {masterName} {time}

sentinel down-after-milliseconds与主观下线的判断有关:哨兵使用ping命令对其他节点进行心跳检测,如果其他节点超过down-after-milliseconds配置的时间没有回复,哨兵就会将其进行主观下线。该配置对主节点、从节点和哨兵节点的主观下线判定都有效。

down-after-milliseconds的默认值是30000,即30s;可以根据不同的网络环境和应用要求来调整:值越大,对主观下线的判定会越宽松,好处是误判的可能性小,坏处是故障发现和故障转移的时间变长,客户端等待的时间也会变长。例如,如果应用对可用性要求较高,则可以将值适当调小,当故障发生时尽快完成转移;如果网络环境相对较差,可以适当提高该阈值,避免频繁误判。

(3) sentinel parallel-syncs {masterName} {number}

sentinel parallel-syncs与故障转移之后从节点的复制有关:它规定了每次向新的主节点发起复制操作的从节点个数。例如,假设主节点切换完成之后,有3个从节点要向新的主节点发起复制;如果parallel-syncs=1,则从节点会一个一个开始复制;如果parallel-syncs=3,则3个从节点会一起开始复制。

parallel-syncs取值越大,从节点完成复制的时间越快,但是对主节点的网络负载、硬盘负载造成的压力也越大;应根据实际情况设置。例如,如果主节点的负载较低,而从节点对服务可用的要求较高,可以适量增加parallel-syncs取值。parallel-syncs的默认值是1。

(4) sentinel failover-timeout {masterName} {time}

sentinel failover-timeout与故障转移超时的判断有关,但是该参数不是用来判断整个故障转移阶段的超时,而是其几个子阶段的超时,例如如果主节点晋升从节点时间超过timeout,或从节点向新的主节点发起复制操作的时间(不包括复制数据的时间)超过timeout,都会导致故障转移超时失败。

failover-timeout的默认值是180000,即180s;如果超时,则下一次该值会变为原来的2倍。

(5)除上述几个参数外,还有一些其他参数,如安全验证相关的参数,这里不做介绍。

2. 实践建议

(1)哨兵节点的数量应不止一个,一方面增加哨兵节点的冗余,避免哨兵本身成为高可用的瓶颈;另一方面减少对下线的误判。此外,这些不同的哨兵节点应部署在不同的物理机上。

(2)哨兵节点的数量应该是奇数,便于哨兵通过投票做出“决策”:领导者选举的决策、客观下线的决策等。

(3)各个哨兵节点的配置应一致,包括硬件、参数等;此外,所有节点都应该使用ntp或类似服务,保证时间准确、一致。

(4)哨兵的配置提供者和通知客户端功能,需要客户端的支持才能实现,如前文所说的Jedis;如果开发者使用的库未提供相应支持,则可能需要开发者自己实现。

(5)当哨兵系统中的节点在docker(或其他可能进行端口映射的软件)中部署时,应特别注意端口映射可能会导致哨兵系统无法正常工作,因为哨兵的工作基于与其他节点的通信,而docker的端口映射可能导致哨兵无法连接到其他节点。例如,哨兵之间互相发现,依赖于它们对外宣称的IP和port,如果某个哨兵A部署在做了端口映射的docker中,那么其他哨兵使用A宣称的port无法连接到A。

6.7 总结

本文首先介绍了哨兵的作用:监控、故障转移、配置提供者和通知;然后讲述了哨兵系统的部署方法,以及通过客户端访问哨兵系统的方法;再然后简要说明了哨兵实现的基本原理;最后给出了关于哨兵实践的一些建议。

在主从复制的基础上,哨兵引入了主节点的自动故障转移,进一步提高了Redis的高可用性;但是哨兵的缺陷同样很明显:哨兵无法对从节点进行自动故障转移,在读写分离场景下,从节点故障会导致读服务不可用,需要我们对从节点做额外的监控、切换操作。

此外,哨兵仍然没有解决写操作无法负载均衡、及存储能力受到单机限制的问题;这些问题的解决需要使用集群。

7、Redis的集群

7.1 概述

集群,即Redis Cluster,是Redis 3.0开始引入的分布式存储方案。

集群由多个节点(Node)组成,Redis的数据分布在这些节点中。集群中的节点分为主节点和从节点:只有主节点负责读写请求和集群信息的维护;从节点只进行主节点数据和状态信息的复制。

7.2 集群的作用

1、数据分区:数据分区(或称数据分片)是集群最核心的功能。

集群将数据分散到多个节点,一方面突破了Redis单机内存大小的限制,存储容量大大增加;另一方面每个主节点都可以对外提供读服务和写服务,大大提高了集群的响应能力。

Redis单机内存大小受限问题,在介绍持久化和主从复制时都有提及;例如,如果单机内存太大,bgsave和bgrewriteaof的fork操作可能导致主进程阻塞,主从环境下主机切换时可能导致从节点长时间无法提供服务,全量复制阶段主节点的复制缓冲区可能溢出……。

2、高可用:集群支持主从复制和主节点的自动故障转移(与哨兵类似);当任一节点发生故障时,集群仍然可以对外提供服务。

7.3 集群的搭建

这一部分我们将搭建一个简单的集群:共6个节点,3主3从。方便起见:所有节点在同一台服务器上,以端口号进行区分;配置从简。3个主节点端口号:7000/7001/7002,对应的从节点端口号:8000/8001/8002。

集群的搭建有两种方式:(1)手动执行Redis命令,一步步完成搭建;(2)使用Ruby脚本搭建。二者搭建的原理是一样的,只是Ruby脚本将Redis命令进行了打包封装;在实际应用中推荐使用脚本方式,简单快捷不容易出错。下面分别介绍这两种方式。

1. 执行Redis命令搭建集群

集群的搭建可以分为四步:(1)启动节点:将节点以集群模式启动,此时节点是独立的,并没有建立联系;(2)节点握手:让独立的节点连成一个网络;(3)分配槽:将16384个槽分配给主节点;(4)指定主从关系:为从节点指定主节点。

实际上,前三步完成后集群便可以对外提供服务;但指定从节点后,集群才能够提供真正高可用的服务。

(1)启动节点

集群节点的启动仍然是使用redis-server命令,但需要使用集群模式启动。下面是7000节点的配置文件(只列出了节点正常工作关键配置,其他配置(如开启AOF)可以参照单机节点进行):

#redis-7000.confport 7000cluster-enabled yescluster-config-file "node-7000.conf"logfile "log-7000.log"dbfilename "dump-7000.rdb"daemonize yes


其中的cluster-enabled和cluster-config-file是与集群相关的配置。

cluster-enabled yes****:Redis实例可以分为单机模式(standalone)和集群模式(cluster);cluster-enabled yes可以启动集群模式。在单机模式下启动的Redis实例,如果执行info server命令,可以发现redis_mode一项为standalone,如下图所示:

Redis_redis_55

集群模式下的节点,其redis_mode为cluster,如下图所示:

Redis_客户端_56

cluster-config-file:该参数指定了集群配置文件的位置。每个节点在运行过程中,会维护一份集群配置文件;每当集群信息发生变化时(如增减节点),集群内所有节点会将最新信息更新到该配置文件;当节点重启后,会重新读取该配置文件,获取集群信息,可以方便的重新加入到集群中。也就是说,当Redis节点以集群模式启动时,会首先寻找是否有集群配置文件,如果有则使用文件中的配置启动,如果没有,则初始化配置并将配置保存到文件中。集群配置文件由Redis节点维护,不需要人工修改。

编辑好配置文件后,使用redis-server命令启动该节点:

redis-server redis-7000.conf


节点启动以后,通过cluster nodes命令可以查看节点的情况,如下图所示。

Redis_客户端_57

其中返回值第一项表示节点id,由40个16进制字符串组成,节点id与 ​​主从复制​​ 一文中提到的runId不同:Redis每次启动runId都会重新创建,但是节点id只在集群初始化时创建一次,然后保存到集群配置文件中,以后节点重新启动时会直接在集群配置文件中读取。

其他节点使用相同办法启动,不再赘述。需要特别注意,在启动节点阶段,节点是没有主从关系的,因此从节点不需要加slaveof配置。

(2)节点握手

节点启动以后是相互独立的,并不知道其他节点存在;需要进行节点握手,将独立的节点组成一个网络。

节点握手使用cluster meet {ip} {port}命令实现,例如在7000节点中执行cluster meet 192.168.72.128 7001,可以完成7000节点和7001节点的握手;注意ip使用的是局域网ip而不是localhost或127.0.0.1,是为了其他机器上的节点或客户端也可以访问。此时再使用cluster nodes查看:

Redis_持久化_58

在7001节点下也可以类似查看:

Redis_redis_59

同理,在7000节点中使用cluster meet命令,可以将所有节点加入到集群,完成节点握手:

cluster meet 192.168.72.128 7002cluster meet 192.168.72.128 8000cluster meet 192.168.72.128 8001cluster meet 192.168.72.128 8002


执行完上述命令后,可以看到7000节点已经感知到了所有其他节点:

Redis_持久化_60

通过节点之间的通信,每个节点都可以感知到所有其他节点,以8000节点为例:

Redis_客户端_61

(3)分配槽

在Redis集群中,借助槽实现数据分区,具体原理后文会介绍。集群有16384个槽,槽是数据管理和迁移的基本单位。当数据库中的16384个槽都分配了节点时,集群处于上线状态(ok);如果有任意一个槽没有分配节点,则集群处于下线状态(fail)。

cluster info命令可以查看集群状态,分配槽之前状态为fail:

Redis_持久化_62

分配槽使用cluster addslots命令,执行下面的命令将槽(编号0-16383)全部分配完毕:

redis-cli -p 7000 cluster addslots {0..5461}redis-cli -p 7001 cluster addslots {5462..10922}redis-cli -p 7002 cluster addslots {10923..16383}


此时查看集群状态,显示所有槽分配完毕,集群进入上线状态:

Redis_数据_63

(4)指定主从关系

集群中指定主从关系不再使用slaveof命令,而是使用cluster replicate命令;参数使用节点id。

通过cluster nodes获得几个主节点的节点id后,执行下面的命令为每个从节点指定主节点:

redis-cli -p 8000 cluster replicate be816eba968bc16c884b963d768c945e86ac51aeredis-cli -p 8001 cluster replicate 788b361563acb175ce8232569347812a12f1fdb4redis-cli -p 8002 cluster replicate a26f1624a3da3e5197dde267de683d61bb2dcbf1


此时执行cluster nodes查看各个节点的状态,可以看到主从关系已经建立。

Redis_redis_64

至此,集群搭建完毕。

2. 使用Ruby脚本搭建集群

在{REDIS_HOME}/src目录下可以看到redis-trib.rb文件,这是一个Ruby脚本,可以实现自动化的集群搭建。

(1)安装Ruby环境

以Ubuntu为例,如下操作即可安装Ruby环境:

apt-get install ruby #安装ruby环境gem install redis #gem是ruby的包管理工具,该命令可以安装ruby-redis依赖


(2)启动节点

与第一种方法中的“启动节点”完全相同。

(3)搭建集群

redis-trib.rb脚本提供了众多命令,其中create用于搭建集群,使用方法如下:

./redis-trib.rb ``create` `--replicas 1 192.168.72.128:7000 192.168.72.128:7001 192.168.72.128:7002 192.168.72.128:8000 192.168.72.128:8001 192.168.72.128:8002


其中:--replicas=1表示每个主节点有1个从节点;后面的多个{ip:port}表示节点地址,前面的做主节点,后面的做从节点。使用redis-trib.rb搭建集群时,要求节点不能包含任何槽和数据。

执行创建命令后,脚本会给出创建集群的计划,如下图所示;计划包括哪些是主节点,哪些是从节点,以及如何分配槽。

Redis_数据_65

输入yes确认执行计划,脚本便开始按照计划执行,如下图所示。

Redis_redis_66

至此,集群搭建完毕。

3. 集群方案设计

设计集群方案时,至少要考虑以下因素:

(1)高可用要求:根据故障转移的原理,至少需要3个主节点才能完成故障转移,且3个主节点不应在同一台物理机上;每个主节点至少需要1个从节点,且主从节点不应在一台物理机上;因此高可用集群至少包含6个节点。

(2)数据量和访问量:估算应用需要的数据量和总访问量(考虑业务发展,留有冗余),结合每个主节点的容量和能承受的访问量(可以通过benchmark得到较准确估计),计算需要的主节点数量。

(3)节点数量限制:Redis官方给出的节点数量限制为1000,主要是考虑节点间通信带来的消耗。在实际应用中应尽量避免大集群;如果节点数量不足以满足应用对Redis数据量和访问量的要求,可以考虑:(1)业务分割,大集群分为多个小集群;(2)减少不必要的数据;(3)调整数据过期策略等。

(4)适度冗余:Redis可以在不影响集群服务的情况下增加节点,因此节点数量适当冗余即可,不用太大。

7.4 集群的基本原理

集群最核心的功能是数据分区,因此首先介绍数据的分区规则;然后介绍集群实现的细节:通信机制和数据结构;最后以cluster meet(节点握手)、cluster addslots(槽分配)为例,说明节点是如何利用上述数据结构和通信机制实现集群命令的。

1. 数据分区方案

数据分区有顺序分区、哈希分区等,其中哈希分区由于其天然的随机性,使用广泛;集群的分区方案便是哈希分区的一种。

哈希分区的基本思路是:对数据的特征值(如key)进行哈希,然后根据哈希值决定数据落在哪个节点。常见的哈希分区包括:哈希取余分区、一致性哈希分区、带虚拟节点的一致性哈希分区等。

衡量数据分区方法好坏的标准有很多,其中比较重要的两个因素是(1)数据分布是否均匀(2)增加或删减节点对数据分布的影响。由于哈希的随机性,哈希分区基本可以保证数据分布均匀;因此在比较哈希分区方案时,重点要看增减节点对数据分布的影响。

(1)哈希取余分区

哈希取余分区思路非常简单:计算key的hash值,然后对节点数量进行取余,从而决定数据映射到哪个节点上。该方案最大的问题是,当新增或删减节点时,节点数量发生变化,系统中所有的数据都需要重新计算映射关系,引发大规模数据迁移。

(2)一致性哈希分区

一致性哈希算法将整个哈希值空间组织成一个虚拟的圆环,如下图所示,范围为0-2^32-1;对于每个数据,根据key计算hash值,确定数据在环上的位置,然后从此位置沿环顺时针行走,找到的第一台服务器就是其应该映射到的服务器。

Redis_主从复制_67

图片来源:​​javascript:void(0)​

与哈希取余分区相比,一致性哈希分区将增减节点的影响限制在相邻节点。以上图为例,如果在node1和node2之间增加node5,则只有node2中的一部分数据会迁移到node5;如果去掉node2,则原node2中的数据只会迁移到node4中,只有node4会受影响。

一致性哈希分区的主要问题在于,当节点数量较少时,增加或删减节点,对单个节点的影响可能很大,造成数据的严重不平衡。还是以上图为例,如果去掉node2,node4中的数据由总数据的1/4左右变为1/2左右,与其他节点相比负载过高。

(3)带虚拟节点的一致性哈希分区

该方案在一致性哈希分区的基础上,引入了虚拟节点的概念。Redis集群使用的便是该方案,其中的虚拟节点称为槽(slot)。槽是介于数据和实际节点之间的虚拟概念;每个实际节点包含一定数量的槽,每个槽包含哈希值在一定范围内的数据。引入槽以后,数据的映射关系由数据hash->实际节点,变成了数据hash->槽->实际节点。

在使用了槽的一致性哈希分区中,槽是数据管理和迁移的基本单位。槽解耦了数据和实际节点之间的关系,增加或删除节点对系统的影响很小。仍以上图为例,系统中有4个实际节点,假设为其分配16个槽(0-15); 槽0-3位于node1,4-7位于node2,以此类推。如果此时删除node2,只需要将槽4-7重新分配即可,例如槽4-5分配给node1,槽6分配给node3,槽7分配给node4;可以看出删除node2后,数据在其他节点的分布仍然较为均衡。

槽的数量一般远小于2^32,远大于实际节点的数量;在Redis集群中,槽的数量为16384。

下面这张图很好的总结了Redis集群将数据映射到实际节点的过程:

Redis_客户端_68

图片修改自:​​javascript:void(0)​

(1)Redis对数据的特征值(一般是key)计算哈希值,使用的算法是CRC16。

(2)根据哈希值,计算数据属于哪个槽。

(3)根据槽与节点的映射关系,计算数据属于哪个节点。

2. 节点通信机制

集群要作为一个整体工作,离不开节点之间的通信。

两个端口

在哨兵系统中,节点分为数据节点和哨兵节点:前者存储数据,后者实现额外的控制功能。在集群中,没有数据节点与非数据节点之分:所有的节点都存储数据,也都参与集群状态的维护。为此,集群中的每个节点,都提供了两个TCP端口:

  • 普通端口:即我们在前面指定的端口(7000等)。普通端口主要用于为客户端提供服务(与单机节点类似);但在节点间数据迁移时也会使用。
  • 集群端口:端口号是普通端口+10000(10000是固定值,无法改变),如7000节点的集群端口为17000。集群端口只用于节点之间的通信,如搭建集群、增减节点、故障转移等操作时节点间的通信;不要使用客户端连接集群接口。为了保证集群可以正常工作,在配置防火墙时,要同时开启普通端口和集群端口。

Gossip协议

节点间通信,按照通信协议可以分为几种类型:单对单、广播、Gossip协议等。重点是广播和Gossip的对比。

广播是指向集群内所有节点发送消息;优点是集群的收敛速度快(集群收敛是指集群内所有节点获得的集群信息是一致的),缺点是每条消息都要发送给所有节点,CPU、带宽等消耗较大。

Gossip协议的特点是:在节点数量有限的网络中,每个节点都“随机”的与部分节点通信(并不是真正的随机,而是根据特定的规则选择通信的节点),经过一番杂乱无章的通信,每个节点的状态很快会达到一致。Gossip协议的优点有负载(比广播)低、去中心化、容错性高(因为通信有冗余)等;缺点主要是集群的收敛速度慢。

消息类型

集群中的节点采用固定频率(每秒10次)的定时任务进行通信相关的工作:判断是否需要发送消息及消息类型、确定接收节点、发送消息等。如果集群状态发生了变化,如增减节点、槽状态变更,通过节点间的通信,所有节点会很快得知整个集群的状态,使集群收敛。

节点间发送的消息主要分为5种:meet消息、ping消息、pong消息、fail消息、publish消息。不同的消息类型,通信协议、发送的频率和时机、接收节点的选择等是不同的。

  • MEET消息:在节点握手阶段,当节点收到客户端的CLUSTER MEET命令时,会向新加入的节点发送MEET消息,请求新节点加入到当前集群;新节点收到MEET消息后会回复一个PONG消息。
  • PING消息:集群里每个节点每秒钟会选择部分节点发送PING消息,接收者收到消息后会回复一个PONG消息。PING消息的内容是自身节点和部分其他节点的状态信息;作用是彼此交换信息,以及检测节点是否在线。PING消息使用Gossip协议发送,接收节点的选择兼顾了收敛速度和带宽成本,具体规则如下:(1)随机找5个节点,在其中选择最久没有通信的1个节点(2)扫描节点列表,选择最近一次收到PONG消息时间大于cluster_node_timeout/2的所有节点,防止这些节点长时间未更新。
  • PONG消息:PONG消息封装了自身状态数据。可以分为两种:第一种是在接到MEET/PING消息后回复的PONG消息;第二种是指节点向集群广播PONG消息,这样其他节点可以获知该节点的最新信息,例如故障恢复后新的主节点会广播PONG消息。
  • FAIL消息:当一个主节点判断另一个主节点进入FAIL状态时,会向集群广播这一FAIL消息;接收节点会将这一FAIL消息保存起来,便于后续的判断。
  • PUBLISH消息:节点收到PUBLISH命令后,会先执行该命令,然后向集群广播这一消息,接收节点也会执行该PUBLISH命令。

3. 数据结构

节点需要专门的数据结构来存储集群的状态。所谓集群的状态,是一个比较大的概念,包括:集群是否处于上线状态、集群中有哪些节点、节点是否可达、节点的主从状态、槽的分布……

节点为了存储集群状态而提供的数据结构中,最关键的是clusterNode和clusterState结构:前者记录了一个节点的状态,后者记录了集群作为一个整体的状态。

clusterNode

clusterNode结构保存了一个节点的当前状态,包括创建时间、节点id、ip和端口号等。每个节点都会用一个clusterNode结构记录自己的状态,并为集群内所有其他节点都创建一个clusterNode结构来记录节点状态。

下面列举了clusterNode的部分字段,并说明了字段的含义和作用:

typedef struct clusterNode {    //节点创建时间    mstime_t ctime;     //节点id    char name[REDIS_CLUSTER_NAMELEN];     //节点的ip和端口号    char ip[REDIS_IP_STR_LEN];    int port;     //节点标识:整型,每个bit都代表了不同状态,如节点的主从状态、是否在线、是否在握手等    int flags;        //配置纪元:故障转移时起作用,类似于哨兵的配置纪元    uint64_t configEpoch;        //槽在该节点中的分布:占用16384/8个字节,16384个比特;每个比特对应一个槽:比特值为1,则该比特对应的槽在节点中;比特值为0,则该比特对应的槽不在节点中    unsigned char slots[16384/8];        //节点中槽的数量    int numslots;        …………        } clusterNode;


除了上述字段,clusterNode还包含节点连接、主从复制、故障发现和转移需要的信息等。

clusterState

clusterState结构保存了在当前节点视角下,集群所处的状态。主要字段包括:

typedef struct clusterState {     //自身节点    clusterNode *myself;     //配置纪元    uint64_t currentEpoch;     //集群状态:在线还是下线    int state;     //集群中至少包含一个槽的节点数量    int size;     //哈希表,节点名称->clusterNode节点指针    dict *nodes;      //槽分布信息:数组的每个元素都是一个指向clusterNode结构的指针;如果槽还没有分配给任何节点,则为NULL    clusterNode *slots[16384];     …………     } clusterState;


除此之外,clusterState还包括故障转移、槽迁移等需要的信息。

4. 集群命令的实现

这一部分将以cluster meet(节点握手)、cluster addslots(槽分配)为例,说明节点是如何利用上述数据结构和通信机制实现集群命令的。

cluster meet

假设要向A节点发送cluster meet命令,将B节点加入到A所在的集群,则A节点收到命令后,执行的操作如下:

  1. A为B创建一个clusterNode结构,并将其添加到clusterState的nodes字典中
  2. A向B发送MEET消息
  3. B收到MEET消息后,会为A创建一个clusterNode结构,并将其添加到clusterState的nodes字典中
  4. B回复A一个PONG消息
  5. A收到B的PONG消息后,便知道B已经成功接收自己的MEET消息
  6. 然后,A向B返回一个PING消息
  7. B收到A的PING消息后,便知道A已经成功接收自己的PONG消息,握手完成
  8. 之后,A通过Gossip协议将B的信息广播给集群内其他节点,其他节点也会与B握手;一段时间后,集群收敛,B成为集群内的一个普通节点

通过上述过程可以发现,集群中两个节点的握手过程与TCP类似,都是三次握手:A向B发送MEET;B向A发送PONG;A向B发送PING。

cluster addslots

集群中槽的分配信息,存储在clusterNode的slots数组和clusterState的slots数组中,两个数组的结构前面已做介绍;二者的区别在于:前者存储的是该节点中分配了哪些槽,后者存储的是集群中所有槽分别分布在哪个节点。

cluster addslots命令接收一个槽或多个槽作为参数,例如在A节点上执行cluster addslots {0..10}命令,是将编号为0-10的槽分配给A节点,具体执行过程如下:

  1. 遍历输入槽,检查它们是否都没有分配,如果有一个槽已分配,命令执行失败;方法是检查输入槽在clusterState.slots[]中对应的值是否为NULL。
  2. 遍历输入槽,将其分配给节点A;方法是修改clusterNode.slots[]中对应的比特为1,以及clusterState.slots[]中对应的指针指向A节点
  3. A节点执行完成后,通过节点通信机制通知其他节点,所有节点都会知道0-10的槽分配给了A节点

7.5 客户端访问集群

1. redis-cli

当节点收到redis-cli发来的命令(如set/get)时,过程如下:

(1)计算key属于哪个槽:CRC16(key) & 16383

集群提供的cluster keyslot命令也是使用上述公式实现,如:

Redis_数据_69

(2)判断key所在的槽是否在当前节点:假设key位于第i个槽,clusterState.slots[i]则指向了槽所在的节点,如果clusterState.slots[i]==clusterState.myself,说明槽在当前节点,可以直接在当前节点执行命令;否则,说明槽不在当前节点,则查询槽所在节点的地址(clusterState.slots[i].ip/port),并将其包装到MOVED错误中返回给redis-cli。

(3)redis-cli收到MOVED错误后,根据返回的ip和port重新发送请求。

下面的例子展示了redis-cli和集群的互动过程:在7000节点中操作key1,但key1所在的槽9189在节点7001中,因此节点返回MOVED错误(包含7001节点的ip和port)给redis-cli,redis-cli重新向7001发起请求。

Redis_持久化_70

上例中,redis-cli通过-c指定了集群模式,如果没有指定,redis-cli无法处理MOVED错误:

Redis_主从复制_71

2. Smart客户端

redis-cli这一类客户端称为Dummy客户端,因为它们在执行命令前不知道数据在哪个节点,需要借助MOVED错误重新定向。与Dummy客户端相对应的是Smart客户端。

Smart客户端(以Java的JedisCluster为例)的基本原理:

(1)JedisCluster初始化时,在内部维护slot->node的缓存,方法是连接任一节点,执行cluster slots命令,该命令返回如下所示:

Redis_主从复制_72

(2)此外,JedisCluster为每个节点创建连接池(即JedisPool)。

(3)当执行命令时,JedisCluster根据key->slot->node选择需要连接的节点,发送命令。如果成功,则命令执行完毕。如果执行失败,则会随机选择其他节点进行重试,并在出现MOVED错误时,使用cluster slots重新同步slot->node的映射关系。

下面代码演示了如何使用JedisCluster访问集群(未考虑资源释放、异常处理等):

public static void test() {   Set<HostAndPort> nodes = new HashSet<>();   nodes.add(new HostAndPort("192.168.72.128", 7000));   nodes.add(new HostAndPort("192.168.72.128", 7001));   nodes.add(new HostAndPort("192.168.72.128", 7002));   nodes.add(new HostAndPort("192.168.72.128", 8000));   nodes.add(new HostAndPort("192.168.72.128", 8001));   nodes.add(new HostAndPort("192.168.72.128", 8002));   JedisCluster cluster = new JedisCluster(nodes);   System.out.println(cluster.get("key1"));   cluster.close();}


注意事项如下:

(1)JedisCluster中已经包含所有节点的连接池,因此JedisCluster要使用单例。

(2)客户端维护了slot->node映射关系以及为每个节点创建了连接池,当节点数量较多时,应注意客户端内存资源和连接资源的消耗。

(3)Jedis较新版本针对JedisCluster做了一些性能方面的优化,如cluster slots缓存更新和锁阻塞等方面的优化,应尽量使用2.8.2及以上版本的Jedis。

7.6 实践须知

1. 集群伸缩

实践中常常需要对集群进行伸缩,如访问量增大时的扩容操作。Redis集群可以在不影响对外服务的情况下实现伸缩;伸缩的核心是槽迁移:修改槽与节点的对应关系,实现槽(即数据)****在节点之间的移动。例如,如果槽均匀分布在集群的3个节点中,此时增加一个节点,则需要从3个节点中分别拿出一部分槽给新节点,从而实现槽在4个节点中的均匀分布。

增加节点

假设要增加7003和8003节点,其中8003是7003的从节点;步骤如下:

(1)启动节点:方法参见集群搭建

(2)节点握手:可以使用cluster meet命令,但在生产环境中建议使用redis-trib.rb的add-node工具,其原理也是cluster meet,但它会先检查新节点是否已加入其它集群或者存在数据,避免加入到集群后带来混乱。

redis-trib.rb add-node 192.168.72.128:7003 192.168.72.128 7000redis-trib.rb add-node 192.168.72.128:8003 192.168.72.128 7000


(3)迁移槽:推荐使用redis-trib.rb的reshard工具实现。reshard自动化程度很高,只需要输入redis-trib.rb reshard ip:port (ip和port可以是集群中的任一节点),然后按照提示输入以下信息,槽迁移会自动完成:

  • 待迁移的槽数量:16384个槽均分给4个节点,每个节点4096个槽,因此待迁移槽数量为4096
  • 目标节点id:7003节点的id
  • 源节点的id:7000/7001/7002节点的id

(4)指定主从关系:方法参见集群搭建

减少节点

假设要下线7000/8000节点,可以分为两步:

(1)迁移槽:使用reshard将7000节点中的槽均匀迁移到7001/7002/7003节点

(2)下线节点:使用redis-trib.rb del-node工具;应先下线从节点再下线主节点,因为若主节点先下线,从节点会被指向其他主节点,造成不必要的全量复制。

redis-trib.rb del-node 192.168.72.128:7001 {节点8000的id}redis-trib.rb del-node 192.168.72.128:7001 {节点7000的id}


ASK错误

集群伸缩的核心是槽迁移。在槽迁移过程中,如果客户端向源节点发送命令,源节点执行流程如下:

Redis_持久化_73

图片来源:《Redis设计与实现》

客户端收到ASK错误后,从中读取目标节点的地址信息,并向目标节点重新发送请求,就像收到MOVED错误时一样。但是二者有很大区别:ASK错误说明数据正在迁移,不知道何时迁移完成,因此重定向是临时的,SMART客户端不会刷新slots缓存;MOVED错误重定向则是(相对)永久的,SMART客户端会刷新slots缓存。

2. 故障转移

在 ​​哨兵​​ 一文中,介绍了哨兵实现故障发现和故障转移的原理。虽然细节上有很大不同,但集群的实现与哨兵思路类似:通过定时任务发送PING消息检测其他节点状态;节点下线分为主观下线和客观下线;客观下线后选取从节点进行故障转移。

与哨兵一样,集群只实现了主节点的故障转移;从节点故障时只会被下线,不会进行故障转移。因此,使用集群时,应谨慎使用读写分离技术,因为从节点故障会导致读服务不可用,可用性变差。

这里不再详细介绍故障转移的细节,只对重要事项进行说明:

节点数量:在故障转移阶段,需要由主节点投票选出哪个从节点成为新的主节点;从节点选举胜出需要的票数为N/2+1;其中N为主节点数量(包括故障主节点),但故障主节点实际上不能投票。因此为了能够在故障发生时顺利选出从节点,集群中至少需要3个主节点(且部署在不同的物理机上)。

故障转移时间:从主节点故障发生到完成转移,所需要的时间主要消耗在主观下线识别、主观下线传播、选举延迟等几个环节;具体时间与参数cluster-node-timeout有关,一般来说:

故障转移时间(毫秒) ≤ 1.5 * cluster-node-timeout + 1000

cluster-node-timeout的默认值为15000ms(15s),因此故障转移时间会在20s量级。

3. 集群的限制及应对方法

由于集群中的数据分布在不同节点中,导致一些功能受限,包括:

(1)key批量操作受限:例如mget、mset操作,只有当操作的key都位于一个槽时,才能进行。针对该问题,一种思路是在客户端记录槽与key的信息,每次针对特定槽执行mget/mset;另外一种思路是使用Hash Tag,将在下一小节介绍。

(2)keys/flushall等操作:keys/flushall等操作可以在任一节点执行,但是结果只针对当前节点,例如keys操作只返回当前节点的所有键。针对该问题,可以在客户端使用cluster nodes获取所有节点信息,并对其中的所有主节点执行keys/flushall等操作。

(3)事务/Lua脚本:集群支持事务及Lua脚本,但前提条件是所涉及的key必须在同一个节点。Hash Tag可以解决该问题。

(4)数据库:单机Redis节点可以支持16个数据库,集群模式下只支持一个,即db0。

(5)复制结构:只支持一层复制结构,不支持嵌套。

4. Hash Tag

Hash Tag原理是:当一个key包含 {} 的时候,不对整个key做hash,而仅对 {} 包括的字符串做hash****。

Hash Tag可以让不同的key拥有相同的hash值,从而分配在同一个槽里;这样针对不同key的批量操作(mget/mset等),以及事务、Lua脚本等都可以支持。不过Hash Tag可能会带来数据分配不均的问题,这时需要:(1)调整不同节点中槽的数量,使数据分布尽量均匀;(2)避免对热点数据使用Hash Tag,导致请求分布不均。

下面是使用Hash Tag的一个例子;通过对product加Hash Tag,可以将所有产品信息放到同一个槽中,便于操作。

Redis_主从复制_74

5. 参数优化

cluster_node_timeout

cluster_node_timeout参数在前面已经初步介绍;它的默认值是15s,影响包括:

(1)影响PING消息接收节点的选择:值越大对延迟容忍度越高,选择的接收节点越少,可以降低带宽,但会降低收敛速度;应根据带宽情况和应用要求进行调整。

(2)影响故障转移的判定和时间:值越大,越不容易误判,但完成转移消耗时间越长;应根据网络状况和应用要求进行调整。

cluster-require-full-coverage

前面提到,只有当16384个槽全部分配完毕时,集群才能上线。这样做是为了保证集群的完整性,但同时也带来了新的问题:当主节点发生故障而故障转移尚未完成,原主节点中的槽不在任何节点中,此时会集群处于下线状态,无法响应客户端的请求。

cluster-require-full-coverage参数可以改变这一设定:如果设置为no,则当槽没有完全分配时,集群仍可以上线。参数默认值为yes,如果应用对可用性要求较高,可以修改为no,但需要自己保证槽全部分配。

6. redis-trib.rb

redis-trib.rb提供了众多实用工具:创建集群、增减节点、槽迁移、检查完整性、数据重新平衡等;通过help命令可以查看详细信息。在实践中如果能使用redis-trib.rb工具则尽量使用,不但方便快捷,还可以大大降低出错概率。

8、Redis的Sharding(分片机制)

8.1 概念

分区是分割数据到多个Redis实例的处理过程,因此每个实例只保存key的一个子集。

如果只使用一个redis实例时,其中保存了服务器中全部的缓存数据,这样会有很大风险,如果单台redis服务宕机了将会影响到整个服务。解决的方法就是我们可以采用分片/分区的技术,将原来一台服务器维护的整个缓存,现在换为由多台服务器共同维护内存空间。

8.2 分片的实现

思路:

采用在一台主机上实现分片的方式,所以只需要在该主机上配置启动三台redis的实例即可。因为redis默认使用的端口号为6379,所以这里我们分别使用6379、6380以及6381三个端口来实现。

Redis_数据_75

配置:

  1. 进入到redis的安装目录下,创建一个shard文件夹,然后将redis的配置文件"redis.conf"复制一份,取名为"redis-6379.conf"(作为6379这台实例的配置文件)。然后将该文件移动到shard文件夹下,再将"redis-6379.conf"复制两份,一个叫"redis-6380.conf"(作为6380这台实例的配置文件),一个叫"redis-6381.conf"(作为6381这台实例的配置文件)。
    Redis_redis_76
  2. 修改6379这台实例的配置文件,因为端口6379默认就是redis的端口,所以只需要指定该实例的持久化片区(文件)即可。
    Redis_客户端_77
  3. 修改6380实例的配置文件。
    修改该实例占用的端口为6380
    Redis_客户端_78
    修改pid
    Redis_持久化_79
    修改dump文件名
    Redis_客户端_80
  4. 修改6381实例的配置文件
    修改该实例占用的端口为6381
    Redis_数据_81
    修改pidRedis_持久化_82
    修改dump文件名
    Redis_主从复制_83

启动测试:

  1. 启动3台redis实例
    Redis_持久化_84
  2. 测试redis分区有两种方式,对既定的key有不同的方式来选择这个key存放到哪个实例中,也就是说有不同的系统来映射某个key到某个Redis的服务。
  • 最简单的分区方式为范围分区,就是映射一定范围的对象到特定的Redis实例。比如,ID从0到10000的用户会保存到实例R0,ID从10001到 20000的用户会保存到R1,以此类推。
  • 另外一种方式是hash一致算法实现分区,对key值进行hash一致性计算后得到结果,最终将数据保存到某一台redis实例中,具体的hash一致性算法可以自行百度一下。
  1. 说明:测试采用junit写的测试方法,方法中定义了一个redis分片的连接池,分别添加用于测试的三个节点实例,然后向redis中增加10个记录并使用Redis Desktop Manager这个工具查看添加结果。
    @Testpublic void test02(){ //定义redis的配置 PoolConfig poolconfig = new PoolConfig(); poolconfig.setMaxTotal(1000); //表示redis的最大连接数——最大1000个线程 poolconfig.setMinIdle(5); //表示最小空闲数量 //定义redis的多个节点机器 List<JedisShardInfo> list = new ArrayList<>(); //为集合添加参数 list.add(new JedisShardInfo("192.168.161.139", 6379)); list.add(new JedisShardInfo("192.168.161.139", 6380)); list.add(new JedisShardInfo("192.168.161.139", 6381)); //定义redis分片连接池 ShardedJedisPool jedisPool = new ShardedJedisPool(poolconfig, list); //获取连接操作redis ShardedJedis shardedJedis = jedisPool.getResource(); //向redis中添加20个记录查看分片结果 for(int i = 0; i < 10; i++){ //增加的记录格式为 key=NUM_i value=i shardedJedis.set("NUM_"+i, ""+i); }}

Redis_数据_85

Redis_主从复制_86

Redis_数据_87

说明:测试结果发现10个记录中有1个分到了6379区,3个分到了6380区,另外6个分到了6381区,因为是采用记录的key值来进行hash一致性算法来确定记录的存放区域,所以即使重新分区都不会改变记录的存放地址,所以仍然可以根据key值来获取到对应的value值。

8.3 分片的不足

  1. 分区是多台redis共同作用的,如果其中一台出现了宕机现象,则整个分片都将不能使用,虽然是在一定程度上缓减了内存的压力,但是没有实现高可用。
  2. 涉及多个key的操作通常是不被支持的。举例来说,当两个set映射到不同的redis实例上时,你就不能对这两个set执行交集操作。
  3. 涉及多个key的redis事务不能使用。
  4. 当使用分区时,数据处理较为复杂,比如你需要处理多个rdb/aof文件,并且从多个实例和主机备份持久化文件。

高可用的解决方案:可以采用哨兵机制实现主从复制从而实现高可用。

9、缓存穿透 | 缓存击穿 | 缓存雪崩

9.1 缓存穿透

缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求。

如发起为id为“-1”的数据或id为特别大不存在的数据。

这时的用户很可能是攻击者,攻击会导致数据库压力过大。

解决办法:

  1. 接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;
  2. 从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击。

9.2 缓存击穿

缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。

解决方案:

  1. 设置热点数据永远不过期。
  2. 加互斥锁,互斥锁参考代码如下:
public static String getData(String key) throws InterruptedException {    //从缓存读取数据    String result = getDataFromRedis(key);    //缓存中不存在数据    if(result == null){        //去获取锁,获取成功,去数据库取数据        if(reenLock.tryLock()){            //从数据获取数据            result = getDataFromMysql(key);            //更新缓存数据            if(result != null){                setDataToCache(key, result);            }            //释放锁            reenLock.unlock();        }        //获取锁失败        else{            Thread.sleep(100);            result = getData(key);        }    }    return result;}


9.3 缓存雪崩

缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至down机。

和缓存击穿不同的是:缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。

解决方案:

  1. 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
  2. 如果缓存数据库是分布式部署,将热点数据均匀分布在不同搞得缓存数据库中。
  3. 设置热点数据永远不过期。

10、应用实例

10.1 redis 带有分片的哨兵连接池(ShardedJedisSentinelPool)

ShardedJedisSentinelPool.java

import java.util.ArrayList;  
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Logger;
import java.util.regex.Pattern;

import org.apache.commons.pool2.PooledObject;
import org.apache.commons.pool2.PooledObjectFactory;
import org.apache.commons.pool2.impl.DefaultPooledObject;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;

import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPubSub;
import redis.clients.jedis.JedisShardInfo;
import redis.clients.jedis.Protocol;
import redis.clients.jedis.ShardedJedis;
import redis.clients.jedis.exceptions.JedisConnectionException;
import redis.clients.util.Hashing;
import redis.clients.util.Pool;

/**
* 带有sharded的sentinelpool
*
* @author guweiqiang
*/
public class ShardedJedisSentinelPool extends Pool<ShardedJedis> {

public static final int MAX_RETRY_SENTINEL = 10;

protected final Logger log = Logger.getLogger(getClass().getName());

protected GenericObjectPoolConfig poolConfig;

protected int timeout = Protocol.DEFAULT_TIMEOUT;

private int sentinelRetry = 0;

protected String password;

protected int database = Protocol.DEFAULT_DATABASE;

protected Set<MasterListener> masterListeners = new HashSet<MasterListener>();

private volatile List<HostAndPort> currentHostMasters;

public ShardedJedisSentinelPool(List<String> masters, Set<String> sentinels) {
this(masters, sentinels, new GenericObjectPoolConfig(),
Protocol.DEFAULT_TIMEOUT, null, Protocol.DEFAULT_DATABASE);
}

public ShardedJedisSentinelPool(List<String> masters,
Set<String> sentinels, String password) {
this(masters, sentinels, new GenericObjectPoolConfig(),
Protocol.DEFAULT_TIMEOUT, password);
}

public ShardedJedisSentinelPool(final GenericObjectPoolConfig poolConfig,
List<String> masters, Set<String> sentinels) {
this(masters, sentinels, poolConfig, Protocol.DEFAULT_TIMEOUT, null,
Protocol.DEFAULT_DATABASE);
}

public ShardedJedisSentinelPool(List<String> masters,
Set<String> sentinels, final GenericObjectPoolConfig poolConfig,
int timeout, final String password) {
this(masters, sentinels, poolConfig, timeout, password,
Protocol.DEFAULT_DATABASE);
}

public ShardedJedisSentinelPool(List<String> masters,
Set<String> sentinels, final GenericObjectPoolConfig poolConfig,
final int timeout) {
this(masters, sentinels, poolConfig, timeout, null,
Protocol.DEFAULT_DATABASE);
}

public ShardedJedisSentinelPool(List<String> masters,
Set<String> sentinels, final GenericObjectPoolConfig poolConfig,
final String password) {
this(masters, sentinels, poolConfig, Protocol.DEFAULT_TIMEOUT, password);
}

public ShardedJedisSentinelPool(List<String> masters,
Set<String> sentinels, final GenericObjectPoolConfig poolConfig,
int timeout, final String password, final int database) {
this.poolConfig = poolConfig;
this.timeout = timeout;
this.password = password;
this.database = database;

List<HostAndPort> masterList = initSentinels(sentinels, masters);
initPool(masterList);
}

public void destroy() {
for (MasterListener m : masterListeners) {
m.shutdown();
}

super.destroy();
}

public List<HostAndPort> getCurrentHostMaster() {
return currentHostMasters;
}

private void initPool(List<HostAndPort> masters) {
if (!equals(currentHostMasters, masters)) {
StringBuffer sb = new StringBuffer();
for (HostAndPort master : masters) {
sb.append(master.toString());
sb.append(" ");
}
log.info("Created ShardedJedisPool to master at [" + sb.toString()
+ "]");
List<JedisShardInfo> shardMasters = makeShardInfoList(masters);
initPool(poolConfig, new ShardedJedisFactory(shardMasters,
Hashing.MURMUR_HASH, null));
currentHostMasters = masters;
}
}

private boolean equals(List<HostAndPort> currentShardMasters,
List<HostAndPort> shardMasters) {
if (currentShardMasters != null && shardMasters != null) {
if (currentShardMasters.size() == shardMasters.size()) {
for (int i = 0; i < currentShardMasters.size(); i++) {
if (!currentShardMasters.get(i).equals(shardMasters.get(i)))
return false;
}
return true;
}
}
return false;
}

private List<JedisShardInfo> makeShardInfoList(List<HostAndPort> masters) {
List<JedisShardInfo> shardMasters = new ArrayList<JedisShardInfo>();
for (HostAndPort master : masters) {
JedisShardInfo jedisShardInfo = new JedisShardInfo(
master.getHost(), master.getPort(), timeout);
jedisShardInfo.setPassword(password);

shardMasters.add(jedisShardInfo);
}
return shardMasters;
}

private List<HostAndPort> initSentinels(Set<String> sentinels,
final List<String> masters) {

Map<String, HostAndPort> masterMap = new HashMap<String, HostAndPort>();
List<HostAndPort> shardMasters = new ArrayList<HostAndPort>();

log.info("Trying to find all master from available Sentinels...");

for (String masterName : masters) {
HostAndPort master = null;
boolean fetched = false;

while (!fetched && sentinelRetry < MAX_RETRY_SENTINEL) {
for (String sentinel : sentinels) {
final HostAndPort hap = toHostAndPort(Arrays
.asList(sentinel.split(":")));

log.fine("Connecting to Sentinel " + hap);

Jedis jedis = null;
try {
jedis = new Jedis(hap.getHost(), hap.getPort());
master = masterMap.get(masterName);
if (master == null) {
List<String> hostAndPort = jedis
.sentinelGetMasterAddrByName(masterName);
if (hostAndPort != null && hostAndPort.size() > 0) {
master = toHostAndPort(hostAndPort);
log.fine("Found Redis master at " + master);
shardMasters.add(master);
masterMap.put(masterName, master);
fetched = true;
jedis.disconnect();
break;
}
}
} catch (JedisConnectionException e) {
log.warning("Cannot connect to sentinel running @ "
+ hap + ". Trying next one.");
} finally {
if(jedis != null){
jedis.close();
}
}
}

if (null == master) {
try {
log.severe("All sentinels down, cannot determine where is "
+ masterName
+ " master is running... sleeping 1000ms, Will try again.");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
fetched = false;
sentinelRetry++;
}
}

// Try MAX_RETRY_SENTINEL times.
if (!fetched && sentinelRetry >= MAX_RETRY_SENTINEL) {
log.severe("All sentinels down and try " + MAX_RETRY_SENTINEL
+ " times, Abort.");
throw new JedisConnectionException(
"Cannot connect all sentinels, Abort.");
}
}

// All shards master must been accessed.
if (masters.size() != 0 && masters.size() == shardMasters.size()) {

log.info("Starting Sentinel listeners...");
for (String sentinel : sentinels) {
final HostAndPort hap = toHostAndPort(Arrays.asList(sentinel
.split(":")));
MasterListener masterListener = new MasterListener(masters,
hap.getHost(), hap.getPort());
masterListeners.add(masterListener);
masterListener.start();
}
}

return shardMasters;
}

private HostAndPort toHostAndPort(List<String> getMasterAddrByNameResult) {
String host = getMasterAddrByNameResult.get(0);
int port = Integer.parseInt(getMasterAddrByNameResult.get(1));

return new HostAndPort(host, port);
}

/**
* PoolableObjectFactory custom impl.
*/
protected static class ShardedJedisFactory implements
PooledObjectFactory<ShardedJedis> {
private List<JedisShardInfo> shards;
private Hashing algo;
private Pattern keyTagPattern;

public ShardedJedisFactory(List<JedisShardInfo> shards, Hashing algo,
Pattern keyTagPattern) {
this.shards = shards;
this.algo = algo;
this.keyTagPattern = keyTagPattern;
}

public PooledObject<ShardedJedis> makeObject() throws Exception {
ShardedJedis jedis = new ShardedJedis(shards, algo, keyTagPattern);
return new DefaultPooledObject<ShardedJedis>(jedis);
}

public void destroyObject(PooledObject<ShardedJedis> pooledShardedJedis)
throws Exception {
final ShardedJedis shardedJedis = pooledShardedJedis.getObject();
for (Jedis jedis : shardedJedis.getAllShards()) {
try {
try {
jedis.quit();
} catch (Exception e) {

}
jedis.disconnect();
} catch (Exception e) {

}
}
}

public boolean validateObject(
PooledObject<ShardedJedis> pooledShardedJedis) {
try {
ShardedJedis jedis = pooledShardedJedis.getObject();
for (Jedis shard : jedis.getAllShards()) {
if (!shard.ping().equals("PONG")) {
return false;
}
}
return true;
} catch (Exception ex) {
return false;
}
}

public void activateObject(PooledObject<ShardedJedis> p)
throws Exception {

}

public void passivateObject(PooledObject<ShardedJedis> p)
throws Exception {

}
}

protected class JedisPubSubAdapter extends JedisPubSub {
@Override
public void onMessage(String channel, String message) {
}

@Override
public void onPMessage(String pattern, String channel, String message) {
}

@Override
public void onPSubscribe(String pattern, int subscribedChannels) {
}

@Override
public void onPUnsubscribe(String pattern, int subscribedChannels) {
}

@Override
public void onSubscribe(String channel, int subscribedChannels) {
}

@Override
public void onUnsubscribe(String channel, int subscribedChannels) {
}
}

protected class MasterListener extends Thread {

protected List<String> masters;
protected String host;
protected int port;
protected long subscribeRetryWaitTimeMillis = 5000;
protected Jedis jedis;
protected AtomicBoolean running = new AtomicBoolean(false);

protected MasterListener() {
}

public MasterListener(List<String> masters, String host, int port) {
this.masters = masters;
this.host = host;
this.port = port;
}

public MasterListener(List<String> masters, String host, int port,
long subscribeRetryWaitTimeMillis) {
this(masters, host, port);
this.subscribeRetryWaitTimeMillis = subscribeRetryWaitTimeMillis;
}

public void run() {

running.set(true);

while (running.get()) {

jedis = new Jedis(host, port);

try {
jedis.subscribe(new JedisPubSubAdapter() {
@Override
public void onMessage(String channel, String message) {
log.fine("Sentinel " + host + ":" + port
+ " published: " + message + ".");

String[] switchMasterMsg = message.split(" ");

if (switchMasterMsg.length > 3) {

int index = masters.indexOf(switchMasterMsg[0]);
if (index >= 0) {
HostAndPort newHostMaster = toHostAndPort(Arrays
.asList(switchMasterMsg[3],
switchMasterMsg[4]));
List<HostAndPort> newHostMasters = new ArrayList<HostAndPort>();
for (int i = 0; i < masters.size(); i++) {
newHostMasters.add(null);
}
Collections.copy(newHostMasters,
currentHostMasters);
newHostMasters.set(index, newHostMaster);

initPool(newHostMasters);
} else {
StringBuffer sb = new StringBuffer();
for (String masterName : masters) {
sb.append(masterName);
sb.append(",");
}
log.fine("Ignoring message on +switch-master for master name "
+ switchMasterMsg[0]
+ ", our monitor master name are ["
+ sb + "]");
}

} else {
log.severe("Invalid message received on Sentinel "
+ host
+ ":"
+ port
+ " on channel +switch-master: "
+ message);
}
}
}, "+switch-master");

} catch (JedisConnectionException e) {

if (running.get()) {
log.severe("Lost connection to Sentinel at " + host
+ ":" + port
+ ". Sleeping 5000ms and retrying.");
try {
Thread.sleep(subscribeRetryWaitTimeMillis);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
} else {
log.fine("Unsubscribing from Sentinel at " + host + ":"
+ port);
}
}
}
}

public void shutdown() {
try {
log.fine("Shutting down listener on " + host + ":" + port);
running.set(false);
// This isn't good, the Jedis object is not thread safe
jedis.disconnect();
} catch (Exception e) {
log.severe("Caught exception while shutting down: "
+ e.getMessage());
}
}
}
}


TestShardedSentinelPool.java

import java.util.ArrayList;  
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import org.apache.commons.pool2.impl.GenericObjectPoolConfig;

import redis.clients.jedis.ShardedJedis;

/**
* 带有sharded的sentinelpool
*
* @author guweiqiang
*/
public class TestShardedSentinelPool {

public static void main(String[] args) {
GenericObjectPoolConfig config = new GenericObjectPoolConfig();
config.setMaxTotal(10000);
config.setMaxIdle(100);
config.setMinIdle(0);
config.setMaxWaitMillis(15000);

List<String> masters = new ArrayList<String>();
masters.add("mymaster47");
masters.add("mymaster50");

Set<String> sentinels = new HashSet<String>();
sentinels.add("172.19.59.47:26379");
sentinels.add("172.19.59.48:26379");
sentinels.add("172.19.59.50:26379");
sentinels.add("172.19.59.50:36379");

ShardedJedisSentinelPool shardedJedisSentinelPool = new ShardedJedisSentinelPool(masters, sentinels, config, 60000);
ShardedJedis shardedJedis = null;

try{
shardedJedis = shardedJedisSentinelPool.getResource(); // 从pool里获取一个jedis
shardedJedis.set("k8", "88888888888");
System.out.println(shardedJedis.get("k8"));
shardedJedis = shardedJedisSentinelPool.getResource(); // 从pool里获取一个jedis
shardedJedis.set("k7", "77777777777");
System.out.println(shardedJedis.get("k7"));
}catch(Exception e){
e.printStackTrace();
}finally{
shardedJedisSentinelPool.returnResource(shardedJedis); // 归还一个jedis到pool中
if(shardedJedisSentinelPool != null) {
shardedJedisSentinelPool.destroy();
}
}
}

}


运行结果:

2017-8-24 10:25:24 com.suning.test.redispool.ShardedJedisSentinelPool initSentinels  
信息: Trying to find all master from available Sentinels...
2017-8-24 10:25:24 com.suning.test.redispool.ShardedJedisSentinelPool initSentinels
信息: Starting Sentinel listeners...
2017-8-24 10:25:24 com.suning.test.redispool.ShardedJedisSentinelPool initPool
信息: Created ShardedJedisPool to master at [172.19.59.47:6379 172.19.59.50:6379 ]
88888888888
77777777777


10.2 redis 哨兵连接池(JedisSentinelPool)——不通过spring

Jedis的JedisSentinelPool源代码、Sharded源代码分析:​​javascript:void(0)​

import java.util.HashSet;  
import java.util.Set;
import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisSentinelPool;


/**
* 直连JedisSentinelPool
*
*/
public class TestRedisSentinel {

public static void main(String[] args) {
// 定义sentinel set
Set<String> sentinels = new HashSet<String>();
HostAndPort hp1 = new HostAndPort("172.19.59.50", 26379);
String sentinel1 = hp1.toString();
sentinels.add(sentinel1);
HostAndPort hp2 = new HostAndPort("172.19.59.50", 36379);
String sentinel2 = hp2.toString();
sentinels.add(sentinel2);
HostAndPort hp3 = new HostAndPort("172.19.59.50", 46379);
String sentinel3 = hp3.toString();
sentinels.add(sentinel3);
HostAndPort hp4 = new HostAndPort("172.19.59.50", 56379);
String sentinel4 = hp4.toString();
sentinels.add(sentinel4);

// 创建JedisSentinelPool对象
JedisSentinelPool sentinelPool = new JedisSentinelPool("mymaster", sentinels);
System.out.println("current master: " + sentinelPool.getCurrentHostMaster().toString());

// 使用sentinelPool获取jedis对象
Jedis master = sentinelPool.getResource();
// 操作redis
master.set("username", "test");
sentinelPool.returnResource(master);

Jedis master2 = sentinelPool.getResource();// 注意:一定要每次重新从pool中取一个连接,否则会一直访问老master。
String value = master2.get("username");
System.out.println("username: " + value);

// 销毁jedis对象和sentinelpool
master2.close();
sentinelPool.destroy();
}
}


10.3 jedis哨兵模式下感知主节点变更

 ​

10.4 扩展Redis的Jedis客户端,哨兵模式读请求走Slave集群

 

10.5 Redis 主从、哨兵Sentinel、Jedis