一、数据类型
(1)简单动态字符串
分为三个部分:
- length,长度
- free,剩余空间
- buf,字符数组,用来保存真正的字符串
SDS与C字符串的区别:
- 获取字符串长度,SDS是O(n)级别的。C获取字符串长度需要遍历。
- SDS有效杜绝了缓冲区溢出问题,C在拷贝字符串的时候默认空间够用,不自动检测空间大小。
- SDS降低了内存重新分配次数,体现在空间预分配和惰性释放上。
- SDS是二进制安全的,C字符串使用了
\0
(空格)表示字符串结尾,导致C字符串只能保存某种编码格式的文件,无法保存音视频数据。可以存储序列化后的图片,最大512MB。 - SDS兼容部分C字符串函数,SDS中的
buf
中依然使用\0
表示结尾,这样可以复用string.h
的函数。
(2)链表
Redis的发布订阅、慢查询、监视器中有使用链表结构。
Redis的客户端状态保存和客户端输出缓冲区构建也用了链表。
Redis的链表包含头指针、尾指针、链表长度。特性如下:
- 双端链表,每个节点都有next指针和prev指针,因此获取链表前后节点的复杂度是O(1)。
- 无环链表。
- 带表头和表尾指针。
- 链表长度计数器,获取链表长度的复杂度也是O(1)。
- 多态,链表节点使用
void*
指针来保存节点数据。
(3)字典
Redis使用Hash表构建的字典这个数据类型。
由三部分构成:
- Hash表数组
- 长度
- Hash表大小的掩码
节点由键值对和指向下一个节点的指针构成,指针用于解决冲突。Redis内部采用murmur hash算法生成Hash值,然后根据Hash值和该节点得到的掩码进行与运算得到索引值。
由于Hash算法总会发生冲突,出于性能考虑,Redis总会将冲突的节点放到链表最前,以保证时间复杂度是O(1)。
随着数据规则的增加,Hash冲突变多,就会触发ReHash操作,由于依次ReHash非常占用资源,Redis采用了渐进式ReHash。
Redis 的字典 dict 中包含两个哈希表 dictht,这是为了方便进行 rehash 操作。在扩容时,将其中一个 dictht 上的键值对 rehash 到另一个 dictht 上面,完成之后释放空间并交换两个 dictht 的角色。
(4)跳表
有序的数据结构,每个节点通过多个指针实现快速访问。
Redis的跳表仅作为有序集合键底层实现和集群节点中的内部结构使用。跳表比平衡树实现简单,且效率相当。
在跳表节点中,元素如下:
-
head指向头节点
- tail指向尾节点
- level用于记录该跳表内最大的那个节点的层
- length用于记录跳表的长度
层具备两个属性,前进指针和跨度。前进指针用于访问tail方向的节点,跨度存储前进指针指向节点到该节点的距离。
后退指针用于访问该节点head方向的节点。
分值,用来保存节点的分值。在跳表中节点按照分值从小到大排列。
成员对象,用来保存该节点的成员对象。它指向一个SDS,这个成员对象在每个节点必须是唯一的,但是SDS存储的内容可以相同。
redis并没有紧靠节点实现跳表,而是创建了另一个数据结构保存跳表,zskiplist。
节点查找的时间复杂度平均在O(logN),最坏在O(N)。
(5)整数集合
是Redis中集合键的实现,当集合键的数据量不大的时候就使用整数集合,否则改用跳表。
跳表的实现包括:编码方式、元素个数、保存元素的数组。
整数集合的升级:当添加新元素时,如果这个数据类型范围是最大的,那么就会触发升级,首先根据新元素扩展空间,然后升级原有的数据类型,最后将新元素添加到集合。
数据类型升级的优点:
- 提升灵活性,C是静态数据类型的语言,为了避免错误,一把不会将不同的数据类型保存在同一个数据结构,但是具备升级功能后可以随意保存任何整数类型的数据。
- 节约内存。
(6)压缩列表
压缩列表ZipList是列表键和哈希键的实现之一。当一个列表只包含一小部分整数或者字符串的时候,redis就会使用ZipList保存数据。
- zipbytes:记录整个ZipList占用的内存空间大小
- zltail:记录表尾到起始地址的距离
- zllen:记录压缩列表的元素个数
- entryN:表示N个元素
- zlend:特殊标记用来表示ZipList的末尾
压缩列表节点中,previous_entry_length
表示前一个节点的长度,可以是1byte或者5byte,encoding记录节点的数据类型和长度,最后的content保存节点数据。
连锁更新:ZipList在特殊条件下连续出现扩展空间的操作。一般情况下,新增和删除会触发连锁更新。连锁更新在最坏的情况下需要进行m次空间分配操作,因此每次空间分配的时间复杂度是O(m),一次连锁更新的时间复杂度是O(㎡)。实际上由于ziplist的节点的连续性和节点的数量小,所以即使出现连锁更新,一般情况下平均时间复杂度也是O(N)级别的。
二、对象系统、应用场景
Redis并没有直接使用数据结构来实现键值数据库,而是基于这些数据结构创建了对象系统。Redis在执行命令之前会先判断该对象可不可以执行该命令,使用对象系统的另一个好处是可以针对不同的场景设置多种不同的数据结构以优化使用效率。
Redis支持5中数据类型:String、Hash、List、Set(无序集合)、ZSet(有序集合)。
对象的类型。对象使用了type属性记录对象的类型。这个类型是5个常量,对于redis数据库保存的键值对来说,key总是字符串对象。
对象的编码。对象的ptr指针属性指向了底层数据结构的具体实现,对象的encoding属性记录了对象使用的编码,总共有8个常量,用来表述对象的底层数据结构。每个对象至少使用了两种以上的不同编码。
(1)String
字符串对象。编码可以是int,raw,embstr。
- 如果字符串对象保存的是整数,并且这个整数可以表示为*long类型(将void转换为long)**,那么对象编码就会设置为int。
- 如果保存的是字符串值,并且这个字符串值的长度大于32byte,那么对象编码就会设置为raw,并使用SDS保存。
- 其他就会使用embstr编码方式。
embstr与raw的区别:embstr专门用于保存短字符串,和raw一样都使用redisObject和sdshdr来表示字符串对象,但是raw会调用两次内存分配函数来创建空间。embstr只会一次分配,空间依次包含redisObject和sdshdr。embstr分配和释放内存都是一次,并且内存空间连续。
可以对 String 进行自增自减运算,从而实现计数器功能。Redis 这种内存型数据库的读写性能非常高,很适合存储频繁读写的计数量。
(2)List
列表对象。编码方式可以是ziplist和linkedlist。当满足列表对象保存的都是字符串元素且长度小于64byte,列表对象保存元素数量小于512个时使用ziplist。具体可以修改list-max-ziplist-value
和list-max-ziplist-entries
的值。
增删快,最新消息排行等功能,消息队列(可以通过 lpush 和 rpop 写入和读取消息)。
(3)Hash
哈希对象。编码可以是ziplist和hashlist。
当使用ziplist的时候会先将保存了key的ziplistnode压入ziplist表尾,然后再将保存了value的ziplistnode压入ziplist表尾。
当ziplist使用hashlist的编码方式的时候,字典的每个键和值都是一个字符串对象。
当哈希对象所保存的键值对的key和value的字符串长度都小于64byte并且保存的键值对数量小于512个的时候就会使用ziplist。具体可以修改hash-max-ziplist-value
和hash-max-ziplist-entries
。
适合存储对象,并且可以像数据库中update一个属性一样只修改某一项属性值。
(4)Set
集合对象。编码方式是intset和hashtable。当保存的都是整数值并且元素数量小于512的时候使用整数集合intset类型。可以通过set-max-intset-entries
设置。
Set 可以实现交集、并集等操作,从而实现共同好友等功能。
(5)ZSet
有序集合对象。有序集合对象编码可以是ziplist和skiplist。
ziplist中,score较小的元素靠近表头。
skiplist使用zset结构作为底层实现。
一个zset包含一个dict和一个skiplist。zset中的skiplist按照分值从大到小保存了所有集合元素,每个skiplistnode保存一个集合元素。
dict为有序集合创建了一个成员分值的映射,dict的key保存元素成员,value保存分值。分值是double类型。
虽然zset同时使用dict和skiplist保存数据,但是这些数据是共享的,而不是存储了两份。
当有序集合元素少于128个,并且元素长度都小于64byte,那么将会使用ziplist编码方式,否则使用dict+skiplist的方式。修改zset-max-ziplist-entries
和zset-max-ziplist-value
可以进行配置。
ZSet 可以实现有序性操作,从而实现排行榜等功能。
为什么有序集合对象同时使用skiplist和dict?
理论上只使用其中任何一种都会发生一些性能损失。
-
如果只使用dict,虽然查找是O(1)的,但是当执行zrank或者zrange的时候复杂度就会变成O(NlogN),以及额外的O(N)的内存空间,因为需要创建一个array进行排序。
- 如果只使用skiplist,范围型的操作优点都会保留,但是查找的复杂度会上升到O(logN)。
(6)其他场景
会话缓存:可以使用 Redis 来统一存储多台应用服务器的会话信息。
分布式锁:可以使用 Redis 自带的 SETNX 命令实现分布式锁,除此之外,还可以使用官方提供的 RedLock 分布式锁实现。
查找表:例如HTTPDNS的实现。
(7)与Memcache比较
- Memcached 仅支持字符串类型,而 Redis 支持五种不同的数据类型,可以更灵活地解决问题。
- Redis 支持两种持久化策略:RDB 快照和 AOF 日志,而 Memcached 不支持持久化。
- Memcached 不支持分布式,只能通过在客户端使用一致性哈希来实现分布式存储,这种方式在存储和查询时都需要先在客户端计算一次数据所在的节点。Redis Cluster 实现了分布式的支持。
- 在 Redis 中,并不是所有数据都一直存储在内存中,可以将一些很久没用的 value 交换到磁盘,而 Memcached 的数据则会一直在内存中。Memcached 将内存分割成特定长度的块来存储数据,以完全解决内存碎片的问题。但是这种方式会使得内存的利用率不高。
(8)类型检查和命令多态
Redis中的命令基本可以分为两种类型,一是可以操作任何key的,另一种只是支持特定key的。类型检查通过redisObject的type实现。类似于del,expire是基于类型的多态,而llen是基于编码的多态。
(9)基于引用计数的内存回收
计数信息保存在int redisObject.refcount
中。在crate的时候refcount=1,只要被使用一次就会+1,当不再被使用就会-1,当refcount==0时,该redisObject被删除。
(10)对象共享
当一个redisObject中的值与另一个redisObject的值相同时,Redis会创建引用进行共享,而不是重新创建。当然,这会修改对象的引用计数属性。
Redis不共享String对象:共享string对象需要验证,整数值复杂度为O(1),字符串值是O(N),当包含多个值就会提升为O(㎡)。
(11)空转时长
redisObject包含一个lru的属性用来记录最后一次访问时间,当服务器打开maxmemory
和maxmemory-policy
选项的时候,并设置内存回收算法为volatile-lru
的时候,空转时长较高的对象会被优先释放。空转时长通过当前时间 - lru值
算出。
三、数据库、客户端、服务器、Lua
(1)数据库
redis的数据库全部都保存在数据库状态结构redisServer中的db数组中,db数组的每一项一个redisDb结构。在redis初始化服务器的时候会根据redisServer中的dbnum来决定创建多少个数据库。该属性有redis.conf中的database
决定。
redis客户端默认操作db[0]这个数据库,当执行SELECT指令的时候redis客户端就会修改redisClient这个结构体里边的db属性,这个db代表当前要操作的服务端数据库,也是redisDb类型的。
redis到目前为止,仍然没有指令返回当前操作的到底是那一个数据库,因此执行指令时显式切换较为优雅。
在redisDb中,dict字典保存了数据库全部的键值对,这个dict类型的dict叫做键空间。字典的键一直都是字符串类型。redisDb中的expires字典保存了所有键值对的过期时间,由于过期时间是Unix时间戳,因此字典的值是long long类型,键是一个指针,指向键空间的某个键对象。TTL指令以秒返回剩余生存时间,而PTTL则是毫秒级别的。
(2)内存淘汰策略
可以设置内存最大使用量,当内存使用量超出时,会施行数据淘汰策略。Redis 具体有 6 种淘汰策略:
策略 | 解释 |
---|---|
volatile-ttl | 从已设置过期时间的数据集中挑选将要过期的数据淘汰 |
volatile-random | 从已设置过期时间的数据集中任意选择数据淘汰 |
allkeys-lru | 从所有数据集中挑选最近最少使用的数据淘汰 |
allkeys-random | 从所有数据集中任意选择数据进行淘汰 |
noeviction | 禁止淘汰数据 |
volatile-lru | 从已设置过期时间的数据集中挑选最近最少使用的数据淘汰 |
Redis 4.0 引入了 volatile-lfu
和 allkeys-lfu
淘汰策略,LFU 策略通过统计访问频率,将访问频率最少的键值对淘汰。
(3)过期键删除策略
-
定时删除
-
惰性删除
惰性删除由
db.c/expireIfNeeded
函数实现,每次指令执行都会执行该函数。 -
定期删除
定期删除由
redis.c/activeExpireCycle
函数实现,每当redis执行周期性操作函数redis.c/serverCron
的时候,activeExpireCycle
就会被调用,然后该函数就会遍历数据库。
RDB模式下的过期键
-
当执行SAVE或者BGSAVE指令时,会创建一个新的rdb文件,该文件不会保存过期键,因此对新生成的rdb文件不会有影响。
- 当服务器以master模式运行时,在rdb载入操作中会检查过期键,过期的键不会被载入。
- 如果以slave模式运行,无论是否过期都会被载入,当与master同步时会清空。
AOF模式下的过期键
- 当数据库的某个键过期,aof不会收到影响,而是在aof文件追加一条记录显式说明该键已经过期。在生成aof文件时,过程与rdb相同。
复制模式下的过期键
- 此时过期键删除策略由master控制。master删除过期键后会向所以slave发送DEL指令。然而当没有接收到DEL指令时即使键过期也不会删除,也会像非过期键一样对待。
(4)客户端
Redis服务器是典型的一对多程序,服务器为客户端建立了redisClient结构,这个结构保存了Redis客户端的状态信息。
- clients属性是一个链表,该链表保存了所有redis客户端信息。
- fd属性是客户端正在使用的套接字描述符,伪客户端是-1,正常的客户端是大于-1的任意整数。伪客户端来源于AOF文件或者Lua脚本。
- name属性是一个SDS字符串对象,默认redis客户端是没有名字的。
-
flags属性记录了客户端的角色。可以是单个标志或者是多个标志。
- REDIS_MASTER表示主服务器
- REDIS_SLAVE表示从服务器
- REDIS_LUA_CLIENT表示Lua伪客户端
- REDIS_FORCE_AOF表示强制写入AOF文件,在PUBSUB机制中用到
- REDIS_FORCE_REPL:SCRIPT_LOAD命令在加载脚本的时候也可能造成修改,因此服务器需要使用REDIS_FORCE_REPL标志强制复制给所有服务器。
- querybuf客户端输入缓冲区用于保存客户端发送的命令请求,这个缓冲区会动态的变大变小,但是最大不超过1GB。否则服务器会关闭这个客户端。
- argv和argc用来存储命令参数和命令参数的个数。
- 每个redis客户端都有两个输出缓冲区,一个大小固定,一个可以动态扩容。
- buf是一个大小为REDIS_REPLY_CHUNK_BYTES大小的字节数组
- bufops则记录了buf数组目前使用的数量。buf数组默认大小为16KB。可变大小缓冲区是由链表reply记录,链表中存储字符串对象
- authenticated,身份验证的值为0表示未经过验证,为1表示通过验证。
- ctime记录客户端创建时间,lastinteraction记录最后一次交互的时间,obuf_soft_limit_reached_time记录输出缓冲区第一次到达软限制的时间。
- lua_client是负责执行Lua脚本的伪客户端
当第一次到达软限制时间超过一定时长之后将会被清零,否则服务器就会关闭该客户端。如果用户设置了timeout配置项,客户端是主服务器,也就是打开了REDIS_MASTER标志,从服务器正在被BLPOP命令阻塞,也就是打开了REDIS_BLOCKED标志,或者正在执行 SUBSCRIBE、PSUBSCRIBE等订阅命令,那么客户端即使空转时间超过timeout也不会被关闭。
(5)服务器
-
serverCron更新时间缓存,由于Redis中很多功能需要读取系统时间,而频繁执行系统调用将会降低服务效率,因此使用unixtime保存秒级UNIX时间戳,使用mstime保存毫秒级UNIX时间戳。但是为键设置过期时间,添加慢查询日志等高精度时间操作还是会执行系统调用。
-
serverCron更新LRU时钟,lruclock保存了服务的LRU时钟,数据库键的空转时间就是lruclock-lru计算出。Redis每10秒更新一次。
-
serverCron更新系统每秒钟执行的命令次数,trackOperationsPerSecond函数会每100毫秒就执行一次,用来抽样统计进行估算。
-
serverCron更新服务内存峰值,每次执行serverCron都当前使用的内存量,并存储到stat_peak_memory中。
-
serverCron处理SIGTERM信号,会关联sigtermHandler函数,这个函数负责接到SIGTERM信号的时候打开shutdown_asap标识。每次serverCron函数运行都会对服务器状态shutdown_asap进行检查,并根据该值来裁决是否关闭服务器。
-
serverCron管理客户端资源,检查连接超时或者是否内容过大。
-
serverCron管理数据库资源,用来删除过期键或者进行字典收缩。
-
serverCron执行被延迟的BGREWRITEAOF,每次都会检查aof_rewrite_scheduled值是否为1,是就执行被推迟的BGREWRITEAOF指令。
-
serverCron检查持久化操作的运行状态,服务进程通过rdb_child_pid和aof_child_pid记录BGSAVE和BGREWRITEAOF命令的子进程PID,如果值不为-1,那么程序就会执行一次wait3函数用来检查子进程是否有信号发来服务器进程。
-
serverCron将AOF缓冲区内容写入AOF文件。
-
serverCron关闭异步客户端。
- serverCron修改cronloops次数,该值记录serverCron的运行次数。
(6)Lua解释器
-
Redis从2.6开始支持嵌入式的lua。通过lua脚本,可以在redis中原子的执行多个Redis命令。通过evel命令执行lua脚本,只要被evel命令执行过以后,以后便可以使用evelsha通过脚本的sha1执行对应的脚本。
-
Redis的lua环境与真正的lua环境有所不同,Redis创建lua环境首先通过lua_open创建一个基础环境,然后载入函数库,完成创建一个包含所有可执行命令的全局表格,紧接着就会载入随机数函数和排序函数。然后创建对lua环境的保护,防止忘记使用local关键字而创建全局变量。最后,将lua环境与redisServer.lua创建关联。
-
Redis还会创建一个用于执行lua代码的伪Redis客户端和用于保存lua脚本的lua_scripts字典。
-
script flush命令用于清空Redis中保存的脚本。
-
script exists通过sha1判断是否存在该脚本。
-
script load用于加载lua脚本。
-
script kill用于终止脚本。如果配置文件添加了lua-time-limit参数,每次执行lua脚本将会创建一个超时处理hook程序,如果超时的脚本没有写操作,那么将会终止该脚本。
- lua脚本的复制,redisServer中的repl_scriptcache_dict字典的key是sha1值,valur是null。当指定的sha1出现在该字典中时,表示该sha1对应的脚本已经复制到了其他redis服务器。
四、持久化机制
(1)RDB
Redis中的RDB持久化功能既可以手动执行又可以自动定期执行。
RDB持久化功能生成的是一个压缩二进制文件。在Redis中使用SAVE或者BGSAVE来手动RDB持久化。SAVE命令会阻塞服务器进程,而BGSAVE会fork一个新的进程进行持久化。在执行SAVE命令的时候,由于服务器进程会被阻塞,所以服务器会拒绝所有客户端命令。
当执行BGSAVE的时候,服务器进程会重新fork一个新的进程,原来的服务器进程会继续接受客户端的响应。但是在执行BGSAVE的过程中客户端如果发送SAVE命令就会被拒绝,原因是防止产生竞争条件,在执行期间也会拒绝BGSAVE,因为也会产生竞争条件。
在Redis启动的时候会自动扫描RDB文件,如果存在就会自动加载。
由于AOF文件的更新频率要比RDB文件的更新频率高,因此在同时开启的时候,服务器进程会优先使用AOF还原数据库状态。
服务器进程RDB持久化使用rdb.c/rdbSave
函数,加载RDB文件使用rdb.c/rdbLoad
函数。
如果这个期间客户端发送了BGREWRITEAOF命令,那么就会延迟到BGSAVE指令执行完成再执行。但是BGREWRITEAOF和BGSAVE命令不会产生什么冲突,只是两个子进程同时操作磁盘会产生大量的IO。
Redis自动保存条件保存在redisServer中的saveparams数组中,在saveparam结构体中的seconds是秒数,changes是修改数。除此之外,redisServer中的dirty属性记录距离上次持久化成功之后数据被修改的次数,是long long类型的,lastsave属性是一个Unix时间戳,记录上一次持久化命令执行成功的时间。
Redis周期性操作函数serverCron默认每隔100ms执行一次。
RDB文件结构:
- RDB文件开头是REDIS部分,长度为5字节,保存着
“REDIS”
字符串,用来在载入RDB文件时,检查该文件是否为RDB文件 - db_version长度为4字节,这个整数值记录了RDB文件的版本号
- database部分包含任意个数据库
- EOF常量的长度是1字节,标志着RDB文件结束
- check_sum是一个8字节的无符号整数,保存校验和,根据前面4个部分计算
RDB文件中的database中保存的每个数据库都有SELECTDB,db_number,key_value_pairs三个部分,当读取到SELECTDB常量的时候,Redis服务器进程就会知道下一个将会是一个数据库号码,这个号码可以是1字节、2字节、5字节。当程序读取到db_number的时候,服务器进程会立刻调用SELECT指令进行数据库切换,之后加载key_value_pairs部分。
在key_value_pairs部分分为3个部分:type,key,value。
-
type长度为1字节,用来至指明键值对的值对象底层数据结构类型。
-
key是字符串常量。带有过期时间的key_value_pairs会在头部新增EXPIRETIME_MS和ms字段,EXPIRETIME长度为1字节,ms字段长度8字节,是一个Unix时间戳。
-
value中的字符串对象,当字符串对象的长度是REDIS_STRING_RAW类型的时候,如果数据大于20字节将会进行压缩,前提条件是打开了
rdbcompression
。-
没有压缩的字符串结构是:len,string用来记录长度和字符串内容。
- 压缩后字符串使用的结构是:REDIS_RDB_ENC_LF,compressed_len,origin_len,compressed_string。
-
REDIS_RDB_ENC_LF表示使用LZF算法。compressed_len记录压缩后长度,origin_len记录原始长度,compressed_string记录数据内容。
在redis.conf文件中配置save 60 1000
就会每隔60秒,有超过1000个Key发生了变更,就会生成一个新的dump.rdb,就是当前的内存完整数据快照。类似于这样的检查点可以设置多个。每次快照生成完成之后就会替换原来的快照文件。
(2)AOF
Redis中的AOF持久化是通过追加Redis服务进程所执行的写命令来保存状态的。被写入AOF文件的所有命令都是以Redis的命令请求协议格式保存的。redis的AOF数据都是纯文本数据。
Redis持久化功能可以实现为命令追加,文件写入,文件同步三个部分。
- 命令追加,当AOF功能打开以后,数据将会被追加到
redisServer.aof_buf
的SDS字符串缓冲区中。 - 文件写入与同步:由于redis服务进程是一个时间循环loop,因此服务器每结束一个时间循环都会调用flushAppendOnlyFile函数,该函数的行为由appendfsync配置参数决定,当设置为always的时候会追加所有内容,everysec是如果距离上次超过1秒才会写入,no是写入所有内容,但不进行同步,由操作系统缓冲机制自行决定。默认值是everysec。
AOF持久化的安全与效率:
- 当always启用的时候,持久化最为安全,但是效率会降低。
- 当everysec启用的时候,效率很高,即使出现故障,也只会丢失1秒的数据。
- 当no启用的时候,文件写入时足够快的,但是不会写入到具体的文件,具体何时同步到物理磁盘,由系统决定,故障时将会丢失到上次同步数据。
AOF文件的载入恢复:Redis服务器进程会创建一个伪客户端(不带网络连接),然后读取AOF文件,依次执行。
AOF文件重写:
-
由于每一条命令都会被记录,因此会导致AOF文件的体积急剧膨胀。
-
AOF文件重写会创建一个新的AOF文件,将原始AOF文件指令进行合并。
-
在实际的重写过程中,为了防止客户端输入输出缓冲区溢出,处理列表、哈希表、集合、有序集合的时候会先检查元素数量,如果超过了
redis.h/REDIS_AOF_REWRITE_ITEMS_PER_CMD
中的数值,就会将命令重新分割。 -
目前最大值为64。
-
AOF重写程序中的aof_rewrite函数可能会造成大量的磁盘IO,由于Redis使用单个线程处理网络请求,所以如果直接调用将会阻塞Redis服务,所以Redis在调用aof_rewrite函数的时候会fork一个新的进程。
-
由于在服务器重写期间还在继续执行网络请求,可能会造成数据不一致,因此Redis设置了AOF重写缓冲区,当Redis服务进程执行完一条写指令的时候会同时修改AOF缓冲区和AOF重写缓冲区。
- 当重写完成之后,会向父进程发送信号,这时首先会将AOF重写缓冲区的内容刷新到AOF文件,然后对AOF文件改名,原子性的覆盖现有的AOF文件,从而完成新旧AOF文件替换。
- 在Redis2.4之前,需要手动通过BGREWRITEAOF命令进行持久化,在Redis2.4之后就自动进行重写了。AOF重写也是在内存创建一个新的AOF替换原先旧的AOF文件。
- 在redis.conf中配置
auto-aof-rewrite-percentage 100
和auto-aof-rewrite-min-size 64mb
,当前AOF文件超过64MB后判断本地AOF日志是否超过上次AOF日志的100%,如果是就rewrite。 - 如果在追加AOF文件的时候出现宕机,导致AOF文件破损,那么可以通过
redis-check-aof --fix
命令来修复破损的AOF文件。
PUBSUB虽然没有修改数据库,但是向所有订阅者发送消息具有副作用,所有订阅者可能都会接收到消息后发生改变,服务器需要使用 REDIS_FORCE_AOF标志强制将这个命令写入AOF文件。
Redis默认是关闭AOF持久化的,默认是打开RDB持久化的,可以使用appendonly yes
打开AOF持久化机制。
五、事件
Redis中的事件分为两种: 时间事件和文件事件
-
时间事件对Redis服务进程需要在给定时间点或者给定时间段内的定时操作做出抽象。
Redis的时间事件分为定时事件和周期事件。
时间事件的属性有事件id,事件到达时间戳的when,处理函数timeProc。
这个时间事件是何种类型取决于时间事件处理器,如果返回AE_NOMORE,执行一次就会被删除,否则就会更新when的值。Redis的时间事件全部都放到一个无序列表中,新事件插入到表头。
由于Redis只使用一个时间事件处理器redisCron,所以使用无序列表并不会影响效率。
-
文件事件对Redis服务进程套接字进行抽象。
Redis基于reactor模式开发了自己的文件事件处理器。使用IO多路复用监听多个套接字,当套接字发生变化的时候就会执行相应的文件处理器。IO多路复用处理器监听多个套接字,然后有文件事件分发器分发到对应的文件事件处理器上,也就是处理函数。
IO多路复用程序通过队列实现有序的,同步的,每次只处理一个请求的方式发送到文件事件分发器。Redis支持select,epoll,evport,kqueue的多路复用函数,在编译Redis源码的时候Redis通过条件编译自动选择最高性能的IO多路复用函数。
当套接字变得即可读又可写的时候,Redis会先处理读请求再处理写请求。
在Redis中使用aeProcessEvents函数负责事件的处理调度。由于文件事件是随机的,因此一直执行文件事件,当时间事件到达的时候再执行时间事件。
在Redis中事件不会出现抢占,当文件事件写入数据大小超过预定值时会主动break将数据留到下次再写入,时间事件对于耗时操作也会fork新的进程来执行。
六、哨兵机制
(1)概述
Sentinal机制是Redis高可用的一个解决方案。一套哨兵集群可以监控多套Redis集群。由多个哨兵实例组成。哨兵系统可以监视任意个master和slave节点。哨兵节点默认在26379端口运行。启动并初始化Redis哨兵:
redis-sentinel sentinel.conf
redis-server sentinel.conf --sentinel # 或者执行该命令
当一个sentinel启动的时候需要执行以下步骤:
- 初始化服务器
- 将普通Redis服务器使用的代码替换为Sentinel专用代码
- 初始化Sentinel状态
- 根据给定的配置文件,初始化Sentinel的监视主服务器列表
- 创建连向主服务器的网络连接
Sentinal服务器与普通Redis服务器的区别:
-
Sentinel实质上是一个运行在特殊模式下的redis服务器,Redis服务器在初始化的时候可能会载入RDB和AOF文件,但是Sentinel不使用数据库所以不会载入。普通的Redis使用
redis.c/redisCommandTable
作为服务器的命令表,而Sentinel使用sentinel.c/sentinelCmds
作为服务器的命令表。 - 运行在Sentinel模式下的Redis服务器状态保存在sentinelState结构体中,其中master字典记录了所有被sentinel监视的主服务器相关信息。每一个key是被监视主服务器的名字,Value是
sentinel.c/sentinelRedisInstance
结构。
(2)集群变动
Sentinel会默认10秒向master请求一次信息,通过INFO命令。master会向Sentinel返回本身的信息和slave信息列表。
新Slave节点加入:当Sentinel发现有新的slave出现的时候,Sentinel会为这个新的slave服务器创建相应的实例结构,sentinel还会创建连接到slave的命令连接和订阅连接。
Sentinal会每隔两秒通过命令连接向所有被监视的master和slave发送命令。该命令向__sentinel__:hello
频道发送一条信息。
互相更新认知:对于监视同一个服务器的多个Sentinel来说,一个Sentinel发送的信息会被其他Sentinel接收到,这些信息会被用于更新其他的Sentinel对发送信息的Sentinel的认知,也会被用于更新其他的Sentinel对被监视服务器的认知。
新Sentinal加入:新启动的Sentinel将会成为Redis master节点的客户端,Sentinel会向master节点创建两个网络连接:命令连接用于向master发送命令,并接收命令的回复订阅连接是专门订阅__sentinel__:hello
频道。当一个Sentinel通过频道信息发现一个新的Sentinel的时候,不仅会更新sentinels字典中的相应的实例结构,而且还会创建一个新的sentinel的命令连接,而且新的Sentinel也会向这个Sentinel创建一个命令连接,最终监视同一个master节点的Sentinel形成监视网络。
为什么Sentinel之间不会创建订阅连接?因为Sentinel需要通过接受master或者slave发来的频道信息来发现未知的新的Sentinel,所以才需要创建订阅连接,而相互已知的Sentinel只需要命令连接来进行通信就可以了。
(3)检测主观下线状态
Sentinel会默认每隔1秒与其创建了命令连接的实例发送PING命令,Sentinel配置文件中的down-after-milliseconds
选项指定了Sentinel判断实例进入主观下线的时间,如果一个实例在down-after-milliseconds
毫秒内连续向Sentinel返回无效回复,那么Sentinel会修改这个实例对应的结构,在flags中打开SRI_S_DOWN标志,以此来表示这个服务器进入了主观下线状态。
这配置条目不仅会被Sentinel用来判断master的主观下线状态,还会被用来判断master属下所有slave节点以及所有同样监控该master 节点的其他Sentinel的主观下线状态。
设置多个Sentinel的主观下线时间不相同时,只有多个Sentinel都认为主观下线的时候才会主观下线。
(4)检测客观下线状态
当Sentinel从其他的Sentinel那里接收到足够数量的已下线判断以后,Sentinel就会将从服务器判断为客观下线,并对主服务器执行故障转移操作。
当一个Sentinel接收到另一个Sentinel发来的SENTINEL is-master-down-by
命令的时候,目标Sentinel会分析并取出命令请求中包含的各个参数,并根据其中的主服务器IP和端口号检查主服务器是否已经下线,然后向源Sentinel返回一条包含三个参数的multi bulk回复作为SENTINEL is-master-down-by
命令的回复。
这三个参数分别是返回目标Sentinel对master的检查结果,Sentinel的runid,目标Sentinel的配置纪元。根据其他Sentinel发回的命令回复,Sentinel会统计其他Sentinel同意master的已下线的数量,当这个数量到达指定的配置数量的时候,Sentinel会将flags属性设置为SRI_O_DOWN
标识。表示master已经进入了客观下线的状态。
(5)选举Sentinel头领机制
当一个master客观下线的时候,监视这个master的sentinel会进行协商,选举出一个领头的sentinel,并由领头的Sentinel进行故障转移操作。
- 每次选举都会导致所有的Sentinel计数器自增1
- 在每个计数器里边,所有的Sentinel都有一次机会将某个Sentinel设置为局部的领头Sentinel
- 每个发现master客观下线的Sentinel都会要求其他的Sentinel将自己设置为局部头领
- 设置Sentinel局部头领的原则是先到先得
-
目标sentinel在接收到
SENTINEL is-master-down-by-addr
命令之后,将会向源sentinel返回一条命令记录,回复中的leader_runid
参数和leader_epoch
参数分别记录了目标Sentinel的局部领头 - 如果有某个Sentinel被半数以上的Sentinel设置为局部领头Sentinel,那么就会成为领头Sentinel
- 因为领头Sentinel的产生需要半数以上的Sentinel支持,并且每个Sentinel在每个runid里边只能设置一次局部领头Sentinel,所以在每个runid里面,只会出现一个领头Sentinel
- 如果在给定时间内,没有一个Sentinel被选举成为领头的Sentinel,那么各个Sentinel将在一段时间之后重新选举,直到选举出Sentinel为止
(6)Redis集群Master节点选举
- 删除列表中所有处于下线或者断线的slave节点
- 删除列表中所有最近5秒没有成功回复领头sentinel的INFO命令的slave节点
- 删除列表中所有与已下线master节点断开连接超过
down-after-milliseconds * 10
毫秒的slave节点 - 之后领头的sentinel将根据slave的优先级对列表中的slave节点进行排序,并选举出优先级最高的节点
- 如果多个slave具有相同的优先级,那么领头的sentinel将会按照slave的复制偏移量对具有相同最高优先级的所有slave进行排序,选出复制偏移量最大的slave
- 如果复制偏移量还有最大的,那么会按照runid进行排序,选择最小的
- 当新的master出现之后,领头的Sentinel就会让已经下线的master属下的所有slave节点复制新的master节点,这一个动作可以通过SLAVEOF实现
- 可以通过
parallel-syncs
配置新选出来的Master同时有多少个Slave进行连接 - 可以通过
failover-timout
配置故障转移的超时时间
(7)解决异步复制和集群脑裂
如果 master 节点由于网络故障导致与集群脱离,sentinal 检测到 master 没有 存活就认为 master 宕机了,但实际上 master 并没有宕机,而此时 sentinal 集群会 将其中的一个slave节点提升为master节点。这样集群中就会存在两个master节 点,导致脑裂问题。有 “min-slave-max-log” 这个配置,就可以确保一旦 slave 复制数据或者 ack 延时太长,就认为可能 master 宕机后丢失的数据太多,那么就 会拒绝写请求,这样可以把 master 宕机时由于部分数据未同步到 slave 导致数据 丢失降低到可控范围内。如果一个master出现了错误,跟其他slave丢失了连接, 那么上面的配置可以确保如果不能继续给指定位置数量的 slave 发送数据,而且 slave 超过 10 秒没有给自己 ack 消息,那么就直接拒绝客户端的写请求。这样脑 裂后的就 master 就不会接收客户端的新数据,也就避免了数据丢失,因此也就 只会丢失 10 秒的数据。
min-slaves-to-write 1
和min-slaves-max-lag 10
要求至少有1个Slave节点,数据复制和数据同步的ACK消息延迟不超过10秒,一旦超过限制,Master就拒绝接受请求。
七、集群
一个redis集群通常由多个节点组成,在刚开始的时候,每个节点都是互相独立的,都处于只包含在自己的集群中,要组件一个真正可以工作的集群,必须将各个独立的节点连接起来,构成多个节点的集群。
Redis服务器在启动的时候会根据clsuter-enabled
配置选项是否为yes
来决定是否开启服务器的集群模式。
连接各个节点的工作可以使用cluster meet
指令完成。向一个节点发送该指令,可以让一个节点与指定的ip和port进行握手,当握手成功的时候,节点就会将ip和port所指定的节点添加到当前节点的集群中。
处于集群模式的redis还会继续使用redisServer来保存服务器状态,只有那些只有在集群模式下使用的数据,节点将他们保存在了cluster.h/clusterNode结构,cluster.h/clusterLink结构、cluster.h/clusterState结构。
- clusterNode结构保存了一个节点的当前状态,其中的link属性是一个clusterLink结构,该结构保存了连接节点所需的相关信息。
- redisClient和clusterLink结构的不同之处是redisClient结构中的套接字和缓冲区是用于连接客户端的,而clusterLink结构中的套接字和缓冲区是用于连接节点的。
-
在clusterNode中size属性的值为0,表示目前集群没有任何的节点在处理槽,因此结构中的state属性值为REDIS_CLUSTER_FAIL,这表示集群目前处于下线状态。
- 节点的clusterNode结构的flags属性都是REDIS_NODE_MASTER,说明这是主节点。
Redis集群通过分片的方式来保存数据库中的kay-value,集群中的数据库分为16384个slot,数据库中的每个key都属于这16384其中的一个,集群中的每个节点可以处理0到16384个槽。
当数据库中的16384个槽都有节点在处理的时候,集群处于上线状态,没有一个槽在处理就是下线状态。
槽指派:通过cluster addslots
命令可以将一个或者多个槽指派给节点负责。
clusterNode结构的slots属性和numslots属性记录了节点负责处理哪些槽,slots属性是一个二进制的数组,这个数组的大小是2048个字节,Redis从0开始索引,对数组中的16384个二进制位进行编号,并根据索引i上的二进制位的值来判断节点是否负责处理槽。
对于程序检查节点是否负责处理该槽和指派槽到节点的时间复杂度为O(1)。一个节点除了记录自己需要处理哪些槽,还会将自己处理哪些槽的消息发送到集群中的其他节点。
在集群中执行命令:在对数据库的16384个槽进行指派后,集群就会上线状态,这个时候整个集群就可以接受客户端的指令了。当客户端发送指令到集群的时候会计算该指令想要操作的数据是否在该服务器上,如果不在就会返回MOVED错误到客户端,客户端会重新根据MOVED的错误提示请求另一个服务器。服务端通过计算CRC16(Key) & 16384
来判断属于哪个槽。然后通过clusterState.slots[i]==clusterState.myself
判断是否属于该节点。
当单机Redis在运行的时候,MOVED错误才会被打印出来。集群节点和单机数据库的区别就是集群节点只会使用0号数据库。
重新分片:Redis的重新分片操作可以将任意数量的Redis节点,Redis集群自动进行数据分片,每个Master节点都承载一部分数据。
在Redis集群模式下,每个Redis节点都开放6379端口作为服务端口,在服务端口上加10000开启一个用于连接Cluster Bus的端口用于集群通信。Cluster Bus协议是一种二进制协议。
Redis Cluster不好做读写分离,读写请求全部落到主实例上,从实例本质是热备高可用,不好与Lua整合。TwemProxy节点的上下线会有手工成本,但是支持Redis集群+读写分离,支持Redis Cli协议,可以直接与Nginx+Lua整合。
Redis cluster 节点之间通过 gossip 协议进行通信,与集中式不同,redis 不会 将集群元数据集中存储在某一个的节点上,而是相互之间不断的通信,保证整个 集群所有节点的数据都是完整的。集中式的好处在于,元数据的更新和读取时效 性非常好,一旦元数据出现问题,立即更新元数据到集中式存储中,其他节点读 取的时候可以马上获取到;但是所有元数据的更新压力全部集中在一个地方,导 致元数据的存储有压力。gossip 好处在于元数据的更新比较分散,不是集中在一 个地方,更新请求会陆陆续续打到所有节点上去更新,有一定的延时,降低了压 力;缺点就是元数据更新有延时,可能导致集群的一些操作会有滞后。每一个节 点都有一个专门用于节点间通信的端口,一般是 redis 自己提供服务的端口号 +10000。
Gossip 协议包含多种消息,比如:ping, pong, meet, fail 等。meet 指令是某个 节点给新节点发送 meet 指令,让新节点加入 redis 的集群,然后新节点就会开始 与其他节点进行通信。ping 指令是每个节点都会频繁的给其他节点发送 ping,其 中包含自己的状态还有自己维护的集群的元数据,相互通过 ping 交换元数据。 每个节点都会频繁的发送 ping 给其他集群,频繁的交换数据,进行元数据的更 新。pong 指令是返回 ping 和 meet,包含自己的状态和其他信息,也可以用于信 息广播和更新。
ping 消息深入:ping 执行很频繁,而且还需要携带元数据,所以会加重网络 的负担。每个节点每秒钟会执行 10 次 ping,每次会选择 5 个最久没有通信的其 他节点。如果发现某个节点的演示超过 cluster_node_timeout / 2 , 那么立即发送 ping, 避免数据交换延时过长。所以 cluster_node_timeout 可以调节,如果调节 比较大,那么会降低发送的频率。每次 ping,一个是带上自身节点的全部信息, 另一个是带上 1/10 其他节点的信息发送出去,进行数据交换。至少包含 3 个其 他节点信息,最多包含 总结点数-2 个其他节点的信息。
由于基于重定向的客户端,非常消耗网络 IO,因为大部分情况下,可能都会 出现一次请求重定向才可以正确的找到节点。所以大部分的客户端都是 smart 的 redis 客户端。smart jedis 本地维护了一份 hashslot-node 映射表,大部分情况下直 接走本地缓存就可以找到,不需要进行重定向操作。下面介绍 Jedis 的 Cluster 工 作原理:在 JedisCluster 初始化的时候,就会随机选择一个节点,初始化 hashslot-node 映射表,同时为每一个节点创建一个 JedisPool 连接池。每次基于 JedisPool 执行操作,首先 JedisCluster 都会在本地计算 key 的 hashslot,然后在本 地映射表找到对应的节点。如果发现对应的节点返回 moved,那么利用该节点的 元数据更新映射表。如果找不到就会重试,5 次以后就会抛出 JedisClusterMaxRedirectionException 的异常。如果 hashslot 正在迁移,那么就会 返回 ask 重定向给 Jedis.jedis 收到 ask 重定向之后,会重新定位到目标节点去执 行,但是因为 ask 发生在 hashslot 迁移的过程中,所以不会更新 hashslot 本地缓 存。moved 是会更新本地缓存的。
八、多机复制、慢日志
(1)多机复制
Redis通过slaveof命令或者通过slaveof配置项让一个服务进程去复制另一个服务进程。在Redis2.8之前使用旧版本的复制功能,之后更换了新的复制机制。
旧版,在旧版功能中,首先sync命令非常耗时,其次就是断线后重新复制,中间可能存在很多命令,这是理想化的。
- 旧版同步:将从服务器的数据库状态更新至主服务器的状态。从服务器向主服务器发送sync指令,主服务器执行bgsave指令,后台生成一个rdb文件,并使用缓冲区记录从现在开始执行的所有命令,主服务器将rdb文件发送到从服务器,从服务器载入rdb文件,然后主服务器将缓冲区数据再发送给从服务器。
- 旧版命令传播:由于通过同步实现的一致性并不是一成不变的,所以主服务器会将造成不一致的命令发送给从服务器。
新版
-
新版完整重同步。用于处理初次复制。
-
新版部分重同步。用于处理断线情况,将连接断开期间执行的写命令发送给从服务器。
因此新版Redis使用psync命令。主从服务器分别维护一个offset,主服务器只要发送n字节的offset就加n,从服务器只要接收n字节数据就加n。如果处于一致性状态,两者offset是相同的。
复制积压缓冲区是主服务器维护的一个固定长度的fifo队列,默认大小1MB。
当进行命令传播的时候,还会将传播的命令写入该队列,如果断线导致的offset存在复制积压缓冲区,那么就执行部分重复制,否则执行完整重复制。
复制积压缓冲区大小可以通过
repl-backlog-size
调整。服务器运行id在部分重同步需要用到,id是一个40个随机hex字符。主服务器根据id标识哪一台从服务器需要进行何种操作。 - 在命令传播阶段,从服务器会每秒发送一次
replconf ack <offset>
指令,这个指令包含从服务器的offset,主要用于检测主从网络状态,辅助实现min-slaves
选项,检测命令丢失。min-slaves-to-write
和min-slaves-max-lag
两个选项可以防止主服务器在不安全情况下执行写命令。
(2)慢日志
慢查询日志用于记录超时的指令。通过slowlog-log-slower-than
指定最长的执行时间,单位是微秒。
slowlog-max-len
指定记录多少条慢日志,redisServer中的slowlog链表记录了所有的慢日志,slowlog_entry_id
用于记录慢日志唯一ID。
九、发布订阅、事务、监视器
(1)发布订阅
数据库通知功能在Redis2.8增加,实现了客户端订阅功能。在Redis2.4中新增了pubsub命令,用于查看频道或者模式的相关信息。
Redis的发布与订阅与publish,subscribe,psubscribe,unsubscribe等命令组成。在redisServer.pubsub_channels
字典中保存了订阅关系,key是某个被订阅的频道,value是个订阅节点列表。
保存模式的时候将会建立pubsub_patterns
列表,里边保存了pubsubPattern结构,该结构保存模式pattern和client。pubsub numpat
用于返回服务器当前被订阅模式的数量。
(2)事务
Redis通过multi开启事务,通过exec执行事务。每个Redis客户端都保存当前是否处于事务状态。watch命令是乐观锁,用于在执行exec之前监听一些key是否被修改过,如果被修改过,在exec执行是会向客户端返回空回复。
每一个Redis数据库都保存了一个watched_keys
字典,key是被监视的key,value是监视该key的客户端节点列表。
每当执行操作的时候都会调用mutil.c
中的touchWatchKey函数,用于检查字典。
Redis不支持rollback机制,作者解释说,事务执行错误一般是编程出错,引入rollback将会导致Redis的设计臃肿。在事务执行过程中即使发生错误,该事务也会被继续执行下去。
Redis的一致性命令入队时体现为如果没有相应的指令,那么会拒绝该事务。但是在Redis2.6.5中即使出现该错误也会继续执行正确的命令。在执行期间的一致性体现为即使出错也会执行。
在停机时的一致性应该分为无持久化,RDB持久化,AOF持久化三中方式讨论。无论那种持久化方式,都不会影响一致性。
Redis的持久性仅在aof模式下开启appendfsync=always
时才会具有持久性。无论何种方式,在事务最后加上SAVE都会具有持久性。
(3)监视器
Redis通过monitor
命令让客户端变成一个监视器,实时接收并打印出服务器当前处理的命令请求。当执行该命令的时候会打开客户端的标记属性,然后将客户端追加到服务器端的监视器列表中。
十、架构
如何让Redis支撑几十万的QPS,99.99%高可用,TB级海量数据?
- Redis集群
- 读写分离
- Master开启持久化的意义(否则Master宕机后导致Slave自动清空)
-
CronTable定时备份到云服务
- Redis集群多机复制
- 主从复制的Offset断点续传
- 无磁盘化复制(打开
repl-diskless-sync yes
使得Redis直接在内存创建RDB文件,发送给Slave,数据不会落盘,开启repl-diskless-sync-delay 5
等待一段时间再进行复制,因为要等待更多的Slave重新连接过来) - 过期Key处理,Master会模拟一条DEL命令发送给Slave
- Slave节点配置
slaveof IP PORT
开启从节点配置 - 节点间认证
masterauth PASSWORD
和requirepass PASSWORD
- 通过
redis-benchmark -h IP -c CLI_NUMBER -n REQ_NUMBER -d DATA_SIZE
进行压测 - 哨兵机制
如何支撑高性能读写,并且将并发发挥到极致?
- 数据量决定是否使用Redis Cluster,如果很小直接使用Replication+Sentinel模式即可
- 并发量决定要几个Slave节点(Replication+Sentinel模式)
- SpringBoot整合EHCache支持服务本地堆缓存,作为最后一个缓存防线。EHCache支持磁盘、内存和堆外内存。
高并发场景下,数据库与缓存读写不一致怎么办?
- Cache Aside Pattern:读取的时候先读取缓存,如果没有再读取数据库,然后写入缓存。更新数据时先删除缓存,在写数据库。为什么是删除缓存而不是更新?因为很多缓存经过了复杂的计算而不是直接读数据库,更新缓存的代价很高。
- 当进行Cache Aside Pattern的时候删除缓存失败导致数据不一致。所以应该先删除缓存再更新数据库。
- 删除缓存与更新数据库异步串行化。内存Queue。
如何解决大Value缓存的全量更新导致效率低下?
缓存数据的维度化拆分。
如何提高Redis缓存命中率?
将Nginx设计成为分发层和应用层。分发层的Nginx负责流量分发逻辑和策略,里边是根绝业务定义的规则。将某个业务路由到固定的业务Nginx,保证Nginx只会在Redis读取一次数据,往后的请求全部走Nginx本地缓存。应用层的Nginx主要是多级缓存读取的控制逻辑。
如何解决高并发场景下缓存重建时的分布式并发冲突问题?
在重建缓存时读取的数据都是相同的,更新时出现问题就是分布式环境下的并发冲突,可以通过严格情况下分布式锁实现。
如何解决Redis冷启动缓存穿透问题?
Flink、Storm实时统计预测热数据进行缓存预热。Nginx+Lua上报到Kafka,Flink、Storm消费数据进行消费。
如何解决热点数据导致单机负载瞬间过高问题?
基于storm的实时热点发现
如何避免分布式系统中的服务可用性问题?
- 资源隔离:限制资源使用情况,避免出现Bug导致疯狂占用资源
- 限流:限制系统涌入的最大流量
- 熔断:后端依赖出现故障之后拒绝访问
- 降级:释放服务器资源以保证核心任务的正常运行
如何解决缓存雪崩问题?
- 事前:Redis集群的本身高可用
- 事中:分层缓存,Hystrix(用Hystrix封装保护Redis)
- 事后:Redis备份、快速预热机制
TewmProxy+Redis 读写分离解决方案
由于 redis cluster 不好做读写分离,读写请求全部落在主实例上,如果要扩展 写 QPS,或者是扩展读 QPS,都是需要扩展主实例的数量,从实例就是用作热 备和高可用。不好跟 Nginx+Lua 直接整合,Lua 的 redis 客户端不太支持 redis cluster,中间需要中转 Java 服务。不好做树状的集群结构。但是 redis cluater 很 方便,相当于是上下线节点,集群扩容运维工作很轻松而且高可用自动切换也比 较方便。twemproxy+redis 节点的上下线都需要手动维护,但是支持 redis 集群的 读写分离,支持 redis cli 协议,可以直接与 Nginx+Lua 整合,并且很容易搭建树 状结构。
Redis 哨兵模式 99.99%高可用解决方案
Sentinal 是 redis 集群中的一个很重要的组件,主要功能如下:
(1)集群监控:负责监控 redis 的 master 节点进程是否工作。
(2)消息通知:如果某个 redis 实例有故障,那么 sentinal 负责发送消息作为报警通知给管理员。
(3)故障转移:如果 master 节点挂掉了,会自动转移到 slave 节点上。
(4)配置中心:如果发生了故障,通知客户端新的 master 节点 IP 地址。
Sentinal 本身也是分布式的,作为一个 sentinal 集群运行,各个 sentinal 之间相互协调。故障转移的时候,判断一个 master 节点是否宕机了需要大部分 sentinal 都同意才可以。即使部分的 sentinal 节点挂掉了 sentinal 集群还是可以正常工作 的。sentinal 至少需要 3 个实例才可以运行,来保证 sentinal 系统的健壮性。sentinal + redis 主从架构部署不能保证数据零丢失问题,只能保证 redis 集群的高可用性。 对于 sentinal + redis 这种复杂的集群架构,应该尽量在生产环境下多做测试。 sentinal 集群必须是 2 个节点以上才可以工作,当 master 节点宕机的时候,两个 sentinal 节点只需要一个 sentinal 认为 redis 集群 master 宕机就可以进行切换,同 时两个 sentinal 节点会选举出一个 slave 节点充当 master 节点。此时大多数的 sentinal 是处于运行状态的。当 majority=2(sentinal 的数量)的时候,当这两个 sentinal 中的其中一个 sentinal 宕机的时候由于只有一个 sentinal 节点此时根据 majority 的配置即使发现 master 节点宕机也无法实现节点的切换。经典的 sentinal + redis 集群是一主二从加三哨兵模式,并且设置 quorum=2,majority=2。当 master 节点宕机的时候所在的 sentinal 如果也发生宕机,那么选举和节点切换还是可以 正常进行的。
分布式一致性 Hash 算法优化解决方案
Redis 集群并没有使用一致性hash,而是引入了哈希槽的概念。
在传统的 Hash 算法中,当客户端有查询请求的时候,对 key 进行 hash 然后 使用 master 节点数目进行取模,结果就是对应的 master 节点。但是当其中一个 master 宕机以后,会导致部分数据不可用,在一次查询就会对剩下的 master 节点 取模运算,然后会导致全部的 master 节点的大部分数据全部失效,在高并发场 景下会导致大量的请求流入数据库,而且还会导致大量的缓存重建。
在一致性 Hash 算法中,首选会假设一个圆环,在圆环上均匀分布了很多的 点,这些点代表了不同的Hash,当查询请求的key过来以后,会将key进行Hash 运算,然后去圆环上找到对应的点,如果该点没有 master 节点,那么就按照顺 时针方向查找最近的一个 master 节点。当任何一个 master 节点宕机时,只有之 前在宕机的 master 的数据会受到影响,因为还会查询其他的 master 节点,会导 致 1/N 的流量瞬间涌入数据库。但是会存在缓存热点问题,大量的 QPS 会涌入 一个 master 节点。因此需要添加虚拟的 master 节点来缓解数据的负载均衡问题。 就是分别将每一个 master 节点在环上复制多份,而且一致性 Hash 算法可以实现 缓存的自动迁移。
Redis cluster 有固定的 16384 个 hash slot, 对每一个 key 进行计算 crc16 值, 然后再使用 16384 进行取模,可以获取 key 对应的 hash slot。redis cluster 中每一 个 master 都会持有部分的 slot,比如有 3 个 master,那么可能每个 master 持有 5000 多个 hash slot。hash slot 让节点的增加和删除变得简单。增加一个 master 的 hash slot 移动部分过去,减少一个 master 就将 hash slot 移动到其他 master 上 去移动 hash slot 的成本是非常低的。客户端的 api,可以对指定的数据,让他们 走同一个 hash slot,通过 hash tag 来实现。
在 redis cluster 架构下,每个 redis 节点需要开放两个端口,在 redis 节点上 16379 端口用来进行节点间通信,也就是 cluster bus 集群总线,它主要用来故障 检测、配置更新和故障转移授权,cluster bus 使用了一种二进制协议,主要是为 了节点之间进行高效的数据交换,占用更少的网络带宽和处理时间。
为什么Redis集群的最大槽数是16384个?
在redis节点发送心跳包时需要把所有的槽放到这个心跳包里,以便让节点知道当前集群信息,16384=16k,在发送心跳包时使用char
进行bitmap压缩后是2k(2 * 8 (8 bit) * 1024(1k) = 2K
),也就是说使用2k的空间创建了16k的槽数。
虽然使用CRC16算法最多可以分配65535(2^16-1)个槽位,65535=65k,压缩后就是8k(8 * 8 (8 bit) * 1024(1k) = 8K
),也就是说需要需要8k的心跳包,作者认为这样做不太值得;并且一般情况下一个redis集群不会有超过1000个master节点,所以16k的槽位是个比较合适的选择。