《Redis开发与运维》笔记

  • 为什么单线程还能那么快

    1. 纯内存访问,Redis将所有数据放在内存中,内存的响应时长大 约为100纳秒,这是Redis达到每秒万级别访问的重要基础。
    2. 非阻塞I/O,Redis使用epoll作为I/O多路复用技术的实现,再加上 Redis自身的事件处理模型将epoll中的连接、读写、关闭都转换为事件,不 在网络I/O上浪费过多的时间。
  • 对于字符串类型键,执行set命令会去掉过期时间,这个问题很容易 在开发中被忽视。

  • 设置过期时间为负值,则会立刻删除key。使用persist指令移除过期时间

  • 可以使用migrate指令将源redis中的key迁移到目标redis(两个redis实例)。其本质是 dump(源redis执行)、restore(目标redis执行)、del(源redis执行)三个命令的组合,不过是原子操作

  • scan命令
    file

scan命令用于增量遍历keys,类似于keys命令,但是scan命令可以指定分页以达到增量遍历的目的,时间复杂度为O1
但是如果在scan过程中key数量发生了变化,则获取的结果将和实际有偏差

  • flushdb/flushall指令用于清空数据库,flushall会清空所有1-16个数据库。可以使用rename指令将其重命名为一个随机字符串避免滥用

除了五种数据结构的存储,redis还提供了其他的功能

慢查询分析、pipeline、事务于Lua、Bitmaps、HyperLoglog、发布订阅、GEO
file

慢查询

每次执行命令分4步:

  1. 客户端向redis服务端发送命令
  2. 命令排队
  3. 执行命令
  4. 返回结果
    其中,慢查询分析只到前3步
  • 慢查询阈值
    在配置文件中加入以下配置

    slowlog-log-slower-than=1000000 // 记录大于1秒的命令,如果设为0,则记录所有命令
    slowlog-max-len=1000 // 最多记录多少条记录

  • 慢查询记录放在哪
    慢查询日志是存放在内存中的,可以通过命令进行查询

    slowlog get 3 // 获取3条慢查询记录,先进先出队列

Pipeline

批处理命令(如mget、mset)有效地节约了多命令执行时间,但大部分命令不支持批量操作,pipeline就是将多条命令打包一起发送,然后一起返回,降低了命令执行时间

  • 原生批处理命令与pipeline对比
    1. 原生批处理是原子的,pipeline不是
    2. 原生批处理命令是一个命令对应多个key,pipeline支持多个不同命令
    3. 原生批命令是redis服务端支持实现的,而pipeline需服务端和客户端共同实现
事务

redis提供了简单的事务功能,将多个命令放在 multi 和 exec 之间即可,事务过程中的命令并没有真正执行
file

注:

  1. redis中事务不支持回滚,如果事务中某个命令出错,其前面的命令仍会执行。
  2. 如果在事务中某个事务中用到的key被其他客户端修改了,则不会执行该事务,类似于乐观锁
    但是这些问题可以使用Lua脚本解决,即使用一段脚本来控制redis,且是原子操作
Bitmaps

Bitmaps可以实现对位操作
其本质就是一个字符串,例如 a 的二进制为1001,虽然Bitmaps存储的是 a,但可以对其二进制进行操作

setbit key 20 1 // 设置key的第20位的值为1(只能是01)
getbit key 20 // 获取key的第20位的值

可以使用 bitop 命令对多个bitmaps做交并非异或操作

HyperLogLog

一种技术算法,实际类型位字符串
通过HyperLogLog可以利用极小的内存空间完成独立总数的统计(粗略统计),例如统计IP、Email的独立个数(类似于Set.length())

发布订阅

redis不对订阅消息持久化
file

  1. 发布消息

    publish channel msg

  2. 订阅消息

    subscribe channel [channel…] // 会阻塞

GEO
  • Redis使用geohash将二维经纬度转换为一维字符串,字符串越长,表示的位置更精确,例如geohash长度为9时,精度在2米左右,两个字符串越相似,它们之间的距离越近,Redis利用字符串前缀匹配 算法实现相关的命令。

客户端通信协议

redis客户端与服务端之间使用RESP协议进行通信(相当于明文传输),该协议建立在TCP之上
该协议较为简单,例如客户端发送一条 set name hunt 给服务端,则其传输的数据为

*3  // 有三个参数
$3  // 第一个参数长度为3
SET 
$5 
hello 
$5 
world

redis的返回结果分为5种(在client-cli种操作之所以看不到这些信息,是因为redis自己处理了,可以使用telnet发送请求即可查看)

  1. 状态回复:响应的第一个字节为“+”,例如 +OK
  2. 错误回复:第一个字节为“-”
  3. 整数回复:第一个字节为“:”
  4. 字符串回复:第一个字节为“$”
  5. 多条字符串回复:第一个字节为“*”,例如 mget k1 k2

file

客户端管理

使用 client list 命令查看所有与redis服务端相连的客户端信息,例如ip,名称,连接时间,缓冲区大小等

输入缓冲区

redis服务端为每个连接的客户端分配了输入缓冲区,它的作用是将客户端发送的命令临时保存。同时redis会从输入缓冲区拉取命令并执行。一个输入缓冲区最大不能超过1G,超过则会关闭客户端

输出缓冲区

redis服务端为每个客户端分配了输出缓冲区,用于保存命令执行结果。出问题概率低。

  • 输出缓冲区由两部分组成:

    1. 固定缓冲区:16kb,接收比较小的执行结果。内部使用的是字节数组
    2. 动态缓冲区:接收比较大的执行结果,例如大的字符串、hgetall命令等。内部使用的是list
  • 输出缓冲区分为三种

    1. 普通客户端
    2. 发布订阅客户端
    3. slave客户端

异常处理案例

  • 内存陡增
    redis主节点内存使用达到最大内存上限,但是从节点内存使用正常,且客户端报了OOM的异常(即无法再向redis中存放数据)
    解决:使用clients list命令查看所有连接信息,找到输入/输出缓冲区信息,使用client kill杀掉异常的客户端

  • 客户端周期性报出超时异常,服务端没有明显异常,网络正常
    查询慢查询日志,发现只要慢查询出现客户端就会出现大量超时,则说明是该慢查询的原因导致单线程的redis阻塞

  • 为什么输入缓冲区越来越大?
    输入缓冲区过大主要是因为Redis的处理速度跟不上输入缓冲区的输入速度,并且每次进入输入缓冲区的命令包含了大量 bigkey,从而造成了输入缓冲区过大的情况。还有一种情况就是Redis发生了 阻塞,短期内不能处理命令,造成客户端输入的命令积压在了输入缓冲区, 造成了输入缓冲区过大。

  • 共享对象池
    Redis默认将 1-9999 数字存储起来复用,因为若每次单独存储这些数字则redisObject对象信息就大于真实存储的信息,得不偿失。
    例如:set num1 99,set num2 99 两个键其实使用的是同一个复用对象,可以使用 object refcount num1 查看对象引用次数,此时应该是 2
    注:当设置maxmemory并启用LRU相关淘汰策略时,共享对象池不起作用。
    对于ziplist编码的值对象,即使内部数据为整数也无法使用共享对象 451池,因为ziplist使用压缩且内存连续的结构,对象共享判断成本过高

  • 字符串预分配
    对字符串执行append操作,若修改后free空间不足1M,则再预分配一倍的内存,如果大于1M则再预分配1M内存。

  • 各类型和其编码方式对应关系
    file

graph LR
subgraph string
String --8字节长整型--> int --小于等于39字节--> embstr --大于39字节--> row
end

subgraph hash
Hash --key小于512个同时value均小于64字节--> ziplist1 --否则--> hashtable1
ziplist内部使用数组方式更省内存
end

subgraph set
Set --元素个数小于512个且都是整型--> intset --否则--> hashtable2
end

subgraph list
List --元素个数小于512个同时值均小于64字节--> ziplist2 --否则--> linkedlist
end

subgraph zset
Zset --元素个数小于128个且值均小于64字节--> ziplist3 --否则--> skiplist
end

主从结构

复制

参与复制的redis实例划分为主节点(master)和从节点(slave),默认清空下redis都是主节点。每个从节点只能有一个主节点,而每个主节点可以有多个从节点
复制数据流是单向的,只能从主节点复制到从节点

配置从节点方式
  1. 在配置文件中加入 slaveof masterHost masterPort
  2. 在redis-server启动命令后加入 --slaveof masterHost masterPort
  3. 直接使用命令 slaveof masterHost masterPort,即可以在运行期动态配置。如果在一个从节点中使用该命令,则会使其切换到新的从节点
查看复制相关状态

可以在主/从节点查看命令

info replication

断开复制

在从节点执行

slaveof no one

  • 断开复制关系后,从节点晋升为主节点
  • 从节点断开复制不会抛弃原有数据
复制过程

file

  1. 执行slaveof命令后,从节点保存主节点地址信息便返回,此时建立复制流程还没开始
  2. 从节点内部通过每秒的定时任务扫描配置的主节点信息,当发现新的主节点后便会创世与该节点建立网络连接
  3. 从节点会建立一个socket套接字,专门用于接收主节点发送的复制命令。
    建立连接成功后,从节点发送ping到主节点,如果发送之后没有收到主节点的回复(正常应该返回一个pong),如果无法建立连接,则会从2开始每秒重试,或执行slaveof no one。
  4. 主从复制连接正常后,首次建立复制时,主节点会把持有的数据通过 全量复制部分同步的方式发送给从节点
  5. 对于后续对主节点的数据操作,会持续同步给从节点
  • 全量复制
    一般只用于初次复制的场景,主节点执行bgsave命令生成RDB文件并发送给从节点。(可以通过配置使用无盘复制,即生成的RDB文件不保存在硬盘而是直接通过网络发送给从节点)
  • 部分复制
    主从复制过程中,从节点以外断开连接,如果重新连上,主节点不会发起全量复制,而是只补发丢失的数据。

从节点内部使用redis的 psync 命令进行全量/部分复制

psync {主节点运行id} {当前从节点已复制的数偏移宜量}

该命令需要如下三个组件:
复制偏移量:参与复制的主从节点自身都会维护一个复制偏移量,主/从节点在处理完写入命令后,会把对偏移量做累加。从节点每秒上报自身复制偏移量给主节点,主节点也会保存从节点的复制偏移量。使用 info replication 命令可以查看该偏移量
复制积压缓冲区:是保存在主节点上的一个固定长度队列,用于部分复制和复制命令丢失的数据补救。默认为1MB,当主节点有连接的从节点时被创建,此时向主节点写入数据,其不但会把命令发送给从节点,还会写入复制积压缓冲区。注:当从节点断开连接,主节点会根据记录的该从节点的偏移量,将后续的写操作记录在该复制积压缓冲区,如果后续从节点重新连接,且该缓冲区未满,则从该缓冲区部分复制,否则执行全量复制
主节点运行ID:每个redis节点每次启动后都会动态分配一个40位的十六进制的字符串作为运行ID。如果只使用ip+port的方式识别主节点,那么当主节点重启后变更了数据集(如替换了RDB/AOF文件),主节点再依据偏移量做主从复制是不安全的。因此,从节点一旦发现主节点ID有变化,就会做全量复制.
可以使用 debug reload命令重新加载RDB并保持ID不变,避免不必要的全量复制

主节点创建RDB和发送RDB快照期间,仍需要响应读写命令,此时主节点会把这期间的命令数据保存在复制客户端缓冲区内,当从节点加载完RDB后,主节点再把缓冲区内的数据发送给从节点

安全性

只读:默认情况下,要求从节点使用 slave-read-only=yes 配置为只读模式
传输延迟:主从复制时,会出现网络延迟干扰,使用 repl-disable-tcp-nodelay=TCP_NODELAY参数控制复制策略

  • 当关闭时,主节点产生的命令数据无论大小都会及时发送给从节点。适用于网络情况较好的时候
  • 当开启时,主节点会合并较小的TCP数据包从而节省带宽,默认发送时间间隔为40毫秒

拓扑集群

根据拓扑复杂性,可以分为三种:一主一从,一主多从,树状主从结构

  1. 一主一从
    file

用于主节点出现宕机提供故障转移支持。当应用写命令并发量较高且需持久化时,可以只在从节点开启AOF。当主节点需要关闭或重启时,应先使用 slaveof no one 断开主从节点复制关系,避免因主节点重启后数据被清空而导致从节点数据也被清空

  1. 一主多从
    file

对于读占比比较大的场景,可以将读命令发送到从节点来分担主节点的压力
从节点过多会导致过度消耗网络资源和主节点的负载

  1. 树状主从结构
    file

可以有效降低主节点的负载和要传给从节点的数据量,同时也能支持较多的读操作

心跳机制

主从节点建立后,彼此通过心跳机制保持连接

  1. 主从节点彼此都有心跳检测机制,各自模拟成对方的客户端进行通信,可以通过client list命令查看复制相关的客户端信息,主节点连接状态为flags=M,从节点为S。
  2. 主节点默认每隔10秒对从节点发送ping命令
  3. 从节点每隔1秒发送 replconf ack {复制偏移量} 命令,用于检测数据是否丢失。主节点会记录每个从节点最后发送该命令的时间,记录在 info replication 中的lag字段中,如果超过60秒,则判定下线,断开复制连接。如果后续从节点重新恢复连接,则心跳检测继续

异步复制

主节点将读写命令同步给从节点的过程是异步的,不需要等待复制完成

读写分离

主从结构下,可以做到写入在主节点,读取在从节点

可能出现的情况

  1. 数据延迟
    但可能会出现刚写入主节点就立刻在从节点读取,但读不到的情况
    可以编写额外的程序用于监控主从偏移量,一旦发现延迟过高则触发熔断或降级机制

  2. 读到过期数据
    当主节点存储大量设置了超时数据时,redis内部需要维护过期数据删除策略,删除策略分两种:惰性删除和定时删除
    惰性删除:主节点每次读取命令时都会检测key是否超时。注意:为了保证复制的一致性,从节点永远不会主动删除超时数据
    定时删除:主节点内部使用定时任务循环采样一定数量的key。默认每秒运行10次,当发现获取时执行del命令并同步给从节点。但是如果此时采样速度跟不上过期速度,那么从节点就暂时收不到del命令,就可能读取到过期数据。解决方法是:从节点读取数据之前会检查键过期时间决定是否返回数据

    1. 定时任务在每个数据库空间随机检查20个键,当发现过期时删除对应的键
    2. 如果超过25%的键过期,循环执行回收逻辑直到不足25%或者运行超时为止(慢模式默认25毫秒超时)
    3. 如果慢模式回收超时,则在报错之前会再次以快模式运行回收任务,快模式下超时时间为1毫秒,且2秒内只能运行一次,快慢模式的内部删除逻辑相同,只是超时时间不同

复制风暴

大量从节点对同一主节点或者对同一台机器的多个主节点短时间内发起全量复制的过程。
解决方案:减少从节点数量或采用树状主从结构,且尽量将主节点分散到多个物理机上,避免资源竞争

内存相关

内存溢出控制策略

当所用内存达到maxmemory设置的上限时会触发溢出控制策略,使用maxmemory-policy动态控制

  1. noeviction:默认策略,不会删除任何数据,拒绝所有写入操作并返回客户端OOM错误信息,此时Redis只响应读操作
  2. volatile-lru:根据LRU(Least Recent Used)算法删除设置了超时属性的键,直到腾出足够空间为止。如果没有可以删除的键,回退到noeviction策略。
  3. allkeys-lru:根据LRU算法删除键,不管数据有没有设置超时属性,直到腾出足够空间为止。
  4. allkeys-random:随机删除所有键,直到腾出足够空间为止。
  5. volatile-random:随机删除过期键,直到腾出足够空间为止。
  6. volatile-ttl:根据键值对象的ttl属性,删除最近将要过期数据,如果没有,回退到noeviction策略。
  • 如果设置了maxmemory参数,redis每次执行命令都会尝试执行回收内存操作,所以如果redis一直工作在内存溢出状态,且设置为非 noeviction 策略时,会频繁触发内存回收操作,影响redis性能

内存优化

redisObject

redis所有键值对象都使用redisObject类型来封装,包括string、hash、list、set、zset在内的所有数据类型

typedef struct redisObject {
    int type;  // 对象类型
    int encoding;  // 内部编码类型
    string lru;  // LRU计时时钟,记录对象最后一次被访问的时间,用于辅助LRU算法删除键数据
    int refcount;  // 当前对象被引用的次数,用于通过引用次数回收内存,当该值为0时,可以安全被回收
    void *ptr;  // 数据,如果数据类型为整数,则直接存储数据,否则表示指向数据的指针
}

优化建议1:可以使用 scan+object idletime 命令查询哪些键长时间未被访问,然后进行清理
优化建议2:建议将字符串长度控制在39字节以内,因为string在39字节以内时使用embstr编码,字符串数据SDS(下面具体数据类型有详细介绍)将和redisObject一起分配内存,故只需要分配一次内存即可

缩减键值对象

在设计key时,其长度越短越好
对value来说,常见的需求是把对象序列化为二进制数组放入redis,所以应在业务上尽量精简对象,去掉不必要的属性。并选择高效的序列化方式。或者存储压缩后的json、xml字符串

共享对象池

Redis会将 0-9999 的整数redisObject放在一个共享对象池中,当创建的value为这些整数时,直接引用共享对象池中的对象。所以应尽量使用整数对象以节省内存

  • 同时开启maxmemory和LRU淘汰策略后对象池无效。
    原因:因为每个redisObject对象中只有保存了 LRU计时时钟 LRU策略才会生效,但这就意味着共享对象池中的对象无法被多个key引用
  • ziplist编码方式无法使用共享对象池
    原因:ziplist使用压缩且内存连续的结构,对象共享判断成本过高
字符串优化
  • 所有字符串都使用SDS结构(见下数据结构分析),频繁的append操作会使得SDS频繁追加内存,造成内存碎片化,需避免
  • 一般较小但量大的json可以使用hash进行存储,因为在一定范围内,hash使用ziplist进行存储,节约内存
编码优化

redis为每一种数据类型提供了多种编码方式,调整各个编码方式转换配置规则,选择最合适自己业务的编码

  • ziplist编码
    为了节省内存,所有的数据都是采用线性连续的内存结构,可以作为hash、list、zset类型的底层数据结构实现。
    file
    注:encoding是一个多位的二进制字符,其前两位表示contents的编码方式,后面的位数表示其长度
    缺点:读写元素涉及内存的重新分配和释放及指针的移动,加大了操作的复杂性。

  • intset编码
    是set类型编码的一种,内部表现位存储有序、不重复的数据集
    file

注:set类型在一定阈值下使用该编码方式,超过阈值则升级为hashtable,且不再回退。升级操作会导致重新申请内存,并把原有数据转换后拷贝到新数组

控制键的数量

可以考虑将多个键合并到一个hash类型中,因为hash会使用ziplist进行优化,但是如果hash对象过大升级成为了hashtable反而会增加内存消耗,应避免

  • Sentinel(哨兵)节点本身就是独立的Redis节点,只不过他们比较特殊。他们不存储数据,只支持部分命令
    file

哨兵Sentinel

主从模式中,一旦主节点发生故障,就需要手动将一个从节点晋升为主节点,同时需要修改应用方主节点的地址,还需要命令其他从节点去复制新的主节点,整个过程都需要人工干预,易出错。且在此过程中可能造成数据丢失
Sentinel节点本身就是独立的redis节点,不过他们不存储数据,只支持部分命令

使用Sentinel搭建高可用架构

当主节点出现故障时,sentinel自动完成故障发现和故障转移,并通知应用方,从而实现真正的高可用

  • 简述故障转移过程
    Sentinel是一个分布式架构,其中包含若干个Sentinel节点和Redis数据节点,每个Sentinel节点会对数据节点和其余Sentinel节点进行监控。当发现某节点不可达时,会对节点做下线标识。如果某sentinel判断将要下线节点是主节点,则多个Sentinel会进行协商,当大多数sentinel都认为主节点不可达时,会共同选举出一个sentinel节点来完成自动故障转移工作

  • 创建过程

    1. 首先按照主从架构创建出多个redis数据节点并启动
    2. 创建sentinel配置文件,在配置文件中只用添加主节点ip+port信息和判断下线需要多少个sentinel同意(quorum字段),其启动后会自动寻找从节点
    3. 使用 redis-sentinel sentinel-config.confredis-server sentinel-config --sentinel 启动哨兵
    4. 可以使用 info Sentinel 命令查看其信息
  • 每个sentinel节点都定期发送ping命令来判断节点是否可达,如果超过了配置时间则判定不可达

原理

redis通过三个定时监控任务完成对各个节点的发现和监控

  1. 每隔10秒,每个Sentinel节点会向主节点和从节点发送info命令获取集群的最新拓扑结构
    其作用有:1)通过向主节点执行 info 命令,从而获取从节点信息。这也就是为什么只用对sentinel配置主节点的原因。2)当有新的节点加入时可以立即感知。3)节点不可达或故障转移后实时更新节点拓扑信息

  2. 每隔2秒,每隔Sentinel节点会向redis数据节点的__sentinel__:hello频道上发送该sentinel节点对主节点的判断以及当前sentinel节点的信息。同时每隔sentinel节点也会订阅该频道,来了解其他sentinel节点以及他们对主节点的判断

  3. 每隔1秒,每隔sentinel节点会向主节点、从节点、其他sentinel节点发送一条ping命令做心跳检测

主客观下线
  • 主观下线和客观下线(哨兵
    sentinel节点每过一秒向其他数据节点和sentinel节点发送ping做心跳检测,当他们超过配置 down-after-milliseconds 没有回复时,则该sentinel节点就会对该节点做失败判定,即主观下线。如果主观下线的是主数据节点时,就会向其他sentinel节点询问对主节点的判断,当超过配置的 quorum 个数的sentinel确认后会对该主节点做客观下线(注:此时还未真正下线做故障转移,还需要选取一个Sentinel领导才能做,该选举使用了Raft算法)。主观下线不会触发故障转移操作
    file

  • 主观下线和客观下线(集群
    主观下线:指某个节点认为另一个节点不可用,即下线状态,这个状态并不是最终的故障判定,只能代表一个节点的意见,可能存在误判情况。
    客观下线:指标记一个节点真正的下线,集群内多个节点都认为该节 点不可用,从而达成共识的结果。如果是持有槽的主节点故障,需要为该节 点进行故障转移。

故障转移过程
  1. Sentinel领导者从slave节点中选举master节点,选择过程:
    file
  2. Sentinel领导者从选举出来的master节点执行 slaveof no one 命令让其成为主节点
  3. Sentinel领导者会向剩余的从节点发送命令,让它们成为新主节点的从节点,使用parallel-syncs参数控制同时向新主节点同步数据的请求数量。
  4. Sentinel节点集合会将原来的主节点更新为从节点,并保持着对其关注,当其恢复后让其成为从节点。
领导者sentinel节点选举过程

客观下线后,还需要选举出一个领导者sentinel来完成主观下线和故障转移
redis使用了Raft算法实现领导者的选举,其基本过程如下:

  1. 每个在线的Sentinel节点都有资格成为领导者,当它确认主节点主观下线时,会向其他sentinel节点发送 sentinel is-master-down-by-addr命令,要求将自己设置为领导者(注:该命令同时会向其他sentinel节点确认主节点是否真的下线)
  2. 收到命令的Sentinel节点,如果还没同意过其他sentinel节点的领导者请求,就会同意该请求,否则拒绝。
  3. 一旦该Sentinel节点发现自己的票数已经大于等于 quorum或者一半以上的sentinel节点数,那么它将成为领导者
  4. 如果此过程没有选举出领导者,将会进入下一次选举

高可用的读写分离

  • 从节点的作用
    1)做故障转移。2)分担主节点的读压力

  • sentinel对所有发生的事件都会有通知,例如主观下线、主节点切换等,只要订阅这些通知就能实时感知集群中所有节点的状态,及时对异常信息进行处理,就可以达到高可用了

集群与分布式

当遇到单机内存、并发、流量等瓶颈时,可以采用Redis Cluster架构达到负载均衡的目的
注:集群自身已经依靠Gossip协议实现了高可用,不需要sentinel也行

数据分布理论

分布式数据库首先要解决把整个数据集按照分区规则映射到多个节点的问题,即把数据集划分到多个节点上。
重点需要关注的是数据分区规则,常见的分区汇则有哈希分区顺序分区两种:
file

哈希分区

创建的哈希分区分为如下几种

  1. 节点取余分区
    使用redis的key或者请求用户ID与节点总数取余,得到数据应储存在节点的角标
    优点:足够简单,常用于数据库的分库分表
    缺点:当节点数量发生变化时,数据节点映射关系需要重新计算

  2. 一致性哈希分区
    一致性哈希分区实现思路是为系统中每个节点分配一个token,范围一般在 0-2^32,这些token构成一个哈希环。
    数据读写执行节点查找操作时,先根据key计算hash值,然后顺时针找到第一个大于等于该哈希值的token节点。
    优点:加入和删除节点时只影响哈希环中相邻的节点
    缺点:

    1. 加减节点会造成哈希环中部分数据无法命中,需手动处理或忽略这部分数据,因此一致性哈希常用于缓存场景。例如:原本有两个节点A、B,其token分别是10、30.向其中加入一个hash值为15的key,此时它应该保存在B节点。现增加一个新的节点C,token为20,则再次查找这个hash为15的key时就会去C中找,自然找不到该数据
    2. 当使用少量节点时,节点数量的变化将大范围影响哈希环中数据的映射,故这种方式不适合节点少的分布式方案
    3. 普通的一致性哈希在分区的增减节点时需要增加一倍或者减去一半节点才能保证数据的负载均衡。(即在每两个节点中插入一个节点或删除一个节点)
  3. 虚拟槽分区(Redis Cluster采用的方法)
    使用分散度良好的哈希函数把所有数据映射到一个固定的整数集合中,这些整数就定义为槽(slot)。这个整数集合范围一半远远大于节点数。比如Redis Cluster槽的范围就是0-16383.槽是集群内数据管理和迁移的基本单位。采用大范围的槽主要目的是为了方便数据拆分和集群扩展。每个节点负责一定数量的槽
    例如:有5个节点,则每个节点平均负责3276个槽。由于采用高质量的哈希算法,每个槽所映射的数据通常比较均匀,将数据平均划分到5个节点进行数据分区

一致性哈希和虚拟槽的区别
  1. 虚拟槽不是闭合的,每个节点维护一定数量的槽,并且每个节点记录所有的槽和节点的对应关系
  2. 一致性hash使用虚拟节点来应对数据转移和hash分配不均的问题来保证数据的安全性和集群的可用性,而虚拟槽使用主从节点保证安全和可用
  3. 扩容和收缩时,一致性哈希会按照顺时针重新分布节点,而虚拟槽需要手动增删和分配槽位
Redis数据分区

redis cluster采用虚拟槽分区,所有的键根据哈希函数(CRC16)映射到0-16383整数槽内。每个节点负责维护一部分槽以及槽所映射的键值数据
注:一个槽对应多个key
file

集群功能限制

redis集群相对于单机在功能上存在一些限制:

  1. key批量操作支持有限,如mset、mget等目前只支持具有相同slot值的key进行批量操作
  2. key数据操作支持有限,也只能在统一节点上执行事务
  3. 不能将键值对象拆分成多个部分存储在不同的节点,例如将list拆分为多个list
  4. 集群模式只能使用0号数据库
  5. 主从结构只支持一层,即不能创建树状主从结构
集群的搭建

一共分为三步(可以使用redis-trib.rb 工具自动完成)

  1. 准备节点
    集群一般由多个节点组成,节点数量至少为6个才能保证高可用。每个节点需要开启配置 cluster-enabled yes 使得redis运行在集群模式下

    • 第一次启动如果没有集群配置文件,每个节点就会自动创建一份
    • 当集群内节点信息发生变化,例如添加节点、节点下线等,节点会自动保存集群状态到配置文件中,所以该文件不要手动修改。该文件同时记录了集群中所有节点的节点id,注意,不同于redis节点的runid,该节点id创建之后不会发生变化,节点重启后会读取该配置文件并获取之前的节点id
  2. 节点握手
    上一步只是启动了6个节点,但这些节点还并不知道其他节点的存在。节点握手指一批运行在集群模式下的节点通过 Gossip 协议彼此通信,达到感知对方的过程。节点握手是集群彼此通信的第一步,由客户端发起命令: cluster meet {要握手的ip} {要握手的端口},注:例如集群有两台机器,端口分别是 6379和6380,现需要他们握手,则使用redis-cli连接上6379,然后执行上述命令,ip和端口为6380的地址。6380收到消息后,保存6379的信息并回复pong,之后这两个节点定期ping/pong

    • 向集群中新添加一个节点时,只需要让其和集群中任意一个节点握手,则握手信息就会自动在集群中传播,其他节点就会发现该新节点并发起握手流程
    • 由于此时还没有分配槽,所以此时集群还不能正常工作,集群处于下线状态,所有的数据读写都被禁止
  3. 分配槽
    通过对应节点上执行 cluster addslots {0...5461} 命令将指定范围的槽分配给指定的节点,之后节点会进入在线状态
    例如,启动了6个节点,为其中3个节点分配了槽,剩下三个使用 cluster replicate {nodeId} 作这三个的从节点,其复制过程同主从结构复制模型

节点通信

通信流程

redis集群采用p2p的Gossip协议维护集群节点的元数据(即节点负责哪些数据,是否出现故障等状态信息)
事实上,在一定时间内,每个节点可能只直到集群中部分节点的信息,但只要这些节点彼此正常通信,最终他们会达到一致的状态,类似于流言(Gossip)的传播,从而达到集群状态同步。

基本过程

  1. 集群中每个节点都会单独开辟一个tcp通道用于节点间彼此通信
  2. 每个节点在固定周期内通过特定规则选择部分节点发送ping消息
  3. 接收到ping消息的节点用pong消息作为响应
Gossip协议

该协议主要职责就是信息交换
常用的Gossip消息可分为ping消息、pong、消息、meet消息、fail消息等

  • meet消息
    用于通知新节点的加入,通信完成后,通信双方会进行周期性的ping/pong
  • ping消息
    集群内交换最频繁的消息,集群内每个节点向多个其他节点发送ping消息,用于检测节点是否在线和彼此交换状态信息。ping消息中封装了自身节点和部分其他节点的状态数据,如果对方接收到ping消息则会查找其中是否包含新节点的信息,如果有,则会对新节点发送meet消息进行握手。同时本地更新其他节点的状态
  • pong消息
    接收到ping、meet消息后,返回pong消息,pong消息封装了自身状态数据。节点也可以向集群广播自身pong消息达到状态更新
  • fail消息
    当节点判定集群内另一个节点下线时,会向集群内广播一个fail消息,其他节点收到该消息后把对应节点更新为下线状态
节点选择

由于每个节点都保存了整个集群的所有节点信息,每次信息交换自然不可能给所有节点都进行信息交换(redis集群中的节点默认每秒执行10次信息交换)。由此,选择合适的节点进行通信就尤为重要
file

  1. 每秒随机选取5个最久没有通信的节点发送ping消息
  2. 每100毫秒都会扫描本地节点列表,如果发现节点最近一次接收pong消息的时间大于 cluster_node_timeout/2 则会立刻发送ping消息,防止该节点信息太长时间没更新
    注:由此可知,redis每秒会发送11个ping消息

集群伸缩/扩容

可以对集群中的节点进行动态扩容和收缩,其原理可理解为槽和对应数据在不同节点之间的灵活移动
如果希望加入一个新的节点实现扩容,执行完添加节点操作后,还需要通过相关命令把一部分槽和数据迁移给新节点

  • 数据迁移是使用命令逐个槽进行的,可以通过pipeline进行操作。迁移完成后,还需要对集群中所有主节点发送命令,告知哪些槽被迁移到了哪个节点上
  • 下线节点同扩容。迁移完成后还需要执行 cluster forget {nodeId} 命令让集群不再与该节点通信

请求路由

redis集群中任何节点都保存了所有节点信息和其维护的槽信息。客户端可以向集群中任何一台主节点发送数据请求,该节点会计算key的hash,如果对应自身的槽就直接进行处理,
否则

  1. 如果目标节点没有发生槽的迁移,则返回一条 MOVED 重定向信息给客户端,该信息中包含key应该对应的槽以及该槽对应的主机信息。客户端收到该重定向信息后,应重新请求返回的redis节点,该过程是手动的,可以通过加入 -c 参数使其自动处理
    客户端可以通过缓存这些重定向信息以便下次访问
  2. 如果目标节点正在发生槽的迁移,则返回一条 ASK 重定向信息给客户端,该信息内容同MOVED信息。当客户端重新访问目标节点时,其会先在本地找目标key,如果没找到则会去一个迁移缓存中找,所有以及迁移完毕的槽会保存在这里。如果在缓存中找到了,则会继续返回 ASK 信息给客户端

主客观下线

故障恢复

故障节点客观下线后,如果下线节点是持有槽的主节点,则需要在它的从节点中选举一个晋升,从而保证集群高可用。当从节点发现自己的主节点出现客观下线后,会触发故障恢复流程:
file

故障恢复流程
  1. 资格检查
    每个从节点都要检查最后与主节点断线的时间,超过一定时间则不具备故障转移资格
  2. 准备选举时间
    多个从节点符合故障转移资格后,会根据复制偏移量进行延迟选举,复制偏移量越小,延迟事件越短。
  3. 发起选举
    在集群内广播选举消息,并记录已发送过消息的状态,保证该从节点在一个配置纪元内只能发生一次选举
    每个主节点自身维护一个“配置纪元”标示当前主节点的版本,所有主节点的配置妓院都不相等,从节点会复制主节点的配置纪元。整个集群又维护一个全局的配置纪元,用于记录集群内所有主节点配置纪元的最大版本
    配置纪元会随着ping/pong信息在集群内传播,当发送方与接收方都是主节点,且配置纪元相等时,则代表出现了冲突,nodeId更大的一方将会递增全局配置纪元并赋值给当前节点来区分冲突
    配置纪元的作用:

    • 标识集群内每个主节点的不同版本和当前集群最大的版本
    • 每次集群发生重要的事件时(例如加入新的主节点、从节点竞争选举),都会递增全局纪元并赋值给相关主节点,用于记录这一关键事件
    • 主节点具有更大的配置纪元代表了更新的集群状态,因此当主节点间进行信息交换出现了slots等关键信息不一致时,以配置纪元更大的一方为准
  4. 选举投票
    著有持有槽的主节点才会处理故障选举消息,因为每个持有槽的节点在一个配置纪元内都有唯一一张选票,当接收到第一个请求投票的从节点消息时回复ACK消息作为投票,之后相同纪元内其他从节点的选举消息将被忽略
    选举过程其实就是一个领导者选举过程,当从节点收集到一半以上主节点的选票时,就可以执行替换主节点的操作
    如果在开始选举一段时间后,仍没有获得足够多的选票,该次选举作废,从节点自增节点的配置纪元并发起下一轮投票。
  5. 替换主节点
    完成主节点晋升后,向所有主节点发送一个pong消息,通知主节点晋升完毕

  • 集群倾斜
    不同节点之间数据量和请求量出现明显差异

    1. 数据倾斜
      分为:1)节点和槽分配严重不均,使用redis-trib.rb rebalance命令进行平衡。2)不同槽对应键数量差异过大。3)集合对象包含大量元素。4)内存相关配置不一致
    2. 请求倾斜
  • 缓存更新策略

    1. LRU(最近不常用)/LFU(最不频繁用)/FIFO剔除算法
      通常用于缓存使用量超过了预设的最大值时候,如何对现有的数据进行剔除,例如Redis使用 maxmemory-policy 进行配置
    2. 超时剔除
      给缓存时间设置过期时间,如Redis中的expire命令
    3. 主动更新
      对数据的一致性要求高,需要再真实数据更新后立即刷新缓存。例如可以利用消息系统或者其他方式通知缓存更新。
  • 缓存穿透
    缓存层和持久层都没命中查询

    1. 缓存空对象:持久层未命中也缓存一个空对象并设置一个较短的过期时间
    2. 布隆过滤器拦截
      file
  • 无底洞
    键值数据库由于通常采用哈希函数将 key映射到各个节点上,造成key的分布与业务无关,但是由于数据量和访问量的持续增长,造成需要添加大量节点做水平扩容,导致键值分布到更多的节点上,从而导致更多的计算和更多的不同节点间的网络请求。

    1. 命令本身的优化,例如优化SQL
    2. 减少网络通信次数,例如使用pipeline或批量操作
    3. 降低接入成本,例如客户端使用长连接、连接池、NIO等
  • 缓存雪崩(stampeding herd)
    如果缓存层由于某些原因不能提供服务,于是所有的请求都会到达存储层,存储层的调用量会暴增,造成存储层也级联宕机的情况

    1. 保证缓存层服务的高可用性,例如使用Redis Sentinel和Redis Cluster
    2. 以来隔离组件为后端限流并降级,例如 Hystrix
  • 热点key重建
    当前key是一个热点key,并发量较大,且该key的缓存重建不能在短时间内完成,例如需要复杂的计算或复杂的SQL等。则缓存失效瞬间,由大量线程来重建缓存,造成后端负载过大

    1. 使用互斥锁:只允许由一个线程重建缓存
    2. 永不过期:不适用expire设置过期时间,但是使用逻辑过期时间。如果发现超过逻辑过期时间,则使用单独线程进行缓存重建。但会出现缓存不一致的情况,即客户端获取的值不是最新的值
      file

String

内部编码
  • int:8个字节长整型
  • embstr:小于等于39个字节的字符串
  • raw:大于39个字节的字符串
  • SDS

    redis没有直接使用c语言的字符串表示,而是自己构建了一种名为简单动态字符串(SDS)的抽象数据类型表示字符串。
    例如 执行命令 set name "hunt"
    则会在redis中创建一个新的键值对,该键值对的键 "name" 和值 "hunt" 都采用SDS存储在redis中

    SDS的定义
    struct sdshdr {
    int len; // 记录buf数组中已使用字节的长度,即SDS字符串长度
    int free; // 记录buf数组中未使用的长度
    char buf[]; // 保存字符串
    }
    与C语言字符串相比的优势
    1. c字符串不记录字符串长度,如果需要获取长度,则需要从头到尾遍历
    2. 对字符串修改带来的开销,c字符串每次修改都需要重写分配内存避免泄漏或溢出。而SDS只用修改len和free值,如果超出buf数组长度才重写开辟空间
    3. 如果对SDS修改之后,len的值小于1MB,则程序将分配和len属性相同大小的新的空间,此时len==free
    4. 如果修改后,SDS长度大于1MB,则程序会额外再分配1MB的空间。例如修改后SDS长度为3MB,则层序会再额外分配1MB新空间,此时SDS的buf数组场地为 3MB+1MB+1byte(结束符1byte)
    5. 有些字符串中间就是包含了分隔符,则在C字符串中会被自动拆分

Hash

内部编码
  • ziplist(压缩列表):当哈希类型元素个数小于hash-max-ziplist-entries 配置(默认512个)、同时所有值都小于hash-max-ziplist-value配置(默认64 字节)时,Redis会使用ziplist作为哈希的内部实现,ziplist使用更加紧凑的 结构实现多个元素的连续存储,所以在节省内存方面比hashtable更加优秀。
  • hashtable(哈希表):当哈希类型无法满足ziplist的条件时,Redis会使 用hashtable作为哈希的内部实现,因为此时ziplist的读写效率会下降,而 hashtable的读写时间复杂度为O(1)。
字典的实现

Redis的字典使用哈希表作为底层实现,一个哈希表里面可以由多个哈希表节点,每个哈希表节点就保存了字典中的一个键值对

定义

file


// 哈希表
typedef struct dictht{
    dictEntry **table; // 哈希表数组,数组中的每个元素都是一个指向 `dictEntry` 结构的指针,每个dictEnry结构保存着一个键值对
    unsigned long size;  //哈希表大小
    unsigned long sizemask;  // 哈希表大小掩码,用于计算索引值,总是等于 size-1
    unsigned long used;  // 该哈希表已有节点数量
}

// 哈希表节点
typedef struct dictEntry {
    void *key; // 键

    // 值
    union{
        void *val;
        uint64_tu64;
        int64_ts64;
    }

    struct dictEntry *next; //指向下个哈希表节点,形成链表
}

// 字典
typedef struct dict {
    dictType *type; // 类型特定函数,该结构体中仅包含了计算哈希值的函数、复制键的函数、复制值的函数、对比键的函数、销毁键/值的函数
    void *privdate; // 私有数据
    dictht ht[2]; //哈希表,一般情况下只使用ht[0],ht[1]只会在对ht[0]进行rehash时使用
    in trehashidx; // rehash索引,当rehash不在进行时,值为-1
}

List

内部编码
  • ziplist(压缩列表):当列表的元素个数小于list-max-ziplist-entries配置 (默认512个),同时列表中每个元素的值都小于list-max-ziplist-value配置时 (默认64字节),Redis会选用ziplist来作为列表的内部实现来减少内存的使 用。
  • linkedlist(链表):当列表类型无法满足ziplist的条件时,Redis会使用 linkedlist作为列表的内部实现。

Set

内部编码
  • intset(整数集合):当集合中的元素都是整数且元素个数小于set-maxintset-entries配置(默认512个)时,Redis会选用intset来作为集合的内部实 现,从而减少内存的使用。
  • hashtable(哈希表):当集合类型无法满足intset的条件时,Redis会使 用hashtable作为集合的内部实现。

Zset

内部编码
  • ziplist(压缩列表):当有序集合的元素个数小于zset-max-ziplistentries配置(默认128个),同时每个元素的值都小于zset-max-ziplist-value配 置(默认64字节)时,Redis会用ziplist来作为有序集合的内部实现,ziplist 可以有效减少内存的使用。
  • skiplist(跳跃表):当ziplist条件不满足时,有序集合会使用skiplist作 为内部实现,因为此时ziplist的读写效率会下降。

持久化

file

RDB

把当前进程数据生成快照保存再硬盘的过程

手动触发
  1. save 命令:阻塞,一般不用
  2. bgsave 命令:持久化过程由子进程负责,阻塞只发生在fork子进程阶段,一般时间很短。
自动触发
  1. 使用 save m n 命令,表示在m秒内数据集存在n次修改时自动触发
  2. 如果从节点执行全量复制操作,主节点自动生成RDB文件发送给从节点
  3. 执行debug reload命令重新加载Redis时会触发save
  4. 默认情况下执行shutdown命令时,若没有开启AOF则自动执行bgsave
RDB文件处理

RDB文件保存在配置文件指定的目录下
Redis默认采用LZF算法对生成的RDB文件进行压缩处理,默认打开!
file

优缺点

file

AOF (append only file)

理解为每次执行命令都会记录一个日志(aof buf),重启redis时读取该日志文件
所有的写入命令都会追加到aof_buf缓冲区当中,AOF缓冲区根据对应的同步策略向硬盘同步数据
aof_buf缓冲区写入的数据同RESP协议内容(即客户端与服务端通信的协议)

  • 为什么需要aof_buf?
    Redis使用单线程处理命令,如果每次写AOF文件命令都直接追加到硬盘,则效率太低。再就是使用aof_buf就可以使用多种同步策略
同步策略
  1. always:命令写入aof_buf 后立即调用系统fsync操作同步到AOF文件,fsync完成后线程返回
    注:系统调用write和fsync说明

    • write操作会触发延迟写机制,Linux在内核提供页缓冲区来提高硬盘IO性能,write操作在写入系统缓冲区后直接返回,同步硬盘操作依赖于系统调度机制
    • fsync针对单个文件(比如AOF文件)做强制硬盘同步,fsync将阻塞直到写入硬盘后返回
  2. everysec:建议、默认。命令写入aof_buf后调用系统write操作,write完成后线程返回。fsync同步文件操作由专门的线程每秒调用一次
  3. no:命令写入aof_buf后调用系统write操作,不对AOF文件做fsync同步,同步硬盘操作由操作系统负责,通常同步周期最长30秒
重写机制

随着命令不断写入AOF,文件会越来越大。AOF文件重写是把Redis进程内的数据转化为命令同步到新的AOF文件的过程。这样AOF文件只保留最终数据的写入命令,去除多余无效命令(如del key、srem key)

手动触发

调用 bgrewriteaof 命令

自动触发

根据auto-aof-rewrite-min-size和auto-aof-rewrite-percentage参 数确定自动触发时机。

  • auto-aof-rewrite-min-size:表示运行AOF重写时文件最小体积,默认 为64MB。
  • auto-aof-rewrite-percentage:代表当前AOF文件空间 (aof_current_size)和上一次重写后AOF文件空间(aof_base_size)的比 值。

自动触发时机=aof_current_size>auto-aof-rewrite-minsize&&(aof_current_size-aof_base_size)/aof_base_size>=auto-aof-rewritepercentag

问题定位与优化
fork操作

当Redis做RDB或AOF重写时,必须执行fork创建子进程,虽然fork创建的子进程不需要拷贝父进程的物理内存空间,但是会复制父进程的空间内存页表,非常耗时
可以使用 info stats 命令查找 latest_fork_usec指标获取最近依次fork操作耗时
应当尽量避免使用虚拟化技术例如docker,KVM等。控制redis最大可用内存,因为fork耗时和内存使用量成正比。降低fork频率,例如减少AOF自动触发时机

AOF追加阻塞

当开启AOF并使用everysec为同步策略时,redis使用另一条线程每秒执行fsync同步硬盘,当硬盘资源繁忙时,如果上一次执行fsync命令时间超过2秒,则主线程会阻塞等待fsync执行结束。
由此可见everysec配置最多可能丢失2秒数据

单机多实例部署

单redis实例只使用一个cpu,则可以在一个多核机器上部署多个redis实例。
但是多实例会造成fork线程压力倍增,redis提供了一系列指标获取当前redis实例状态。可以单独开启一个外部程序,轮询所有的redis实例,判断其是否满足AOF条件,如果满足,则执行bgsave命令手动持久化

Leave a Comment