Redis
2021-08-25 19:12:20 0 举报
AI智能生成
Redis
作者其他创作
大纲/内容
分支主题
数据结构
简单动态字符串(SDS)
SDS 与 C字符串的使用
无需对字符串值进行修改的地方, 使用C字符串
需要对字符串修改的地方使用SDS表示字符串值
需要对字符串修改的地方使用SDS表示字符串值
结构组成
sds.h/sdshdr(struct)
int len
buf数组中使用的字节数量 = SDS保存字符串的长度(不包括最后的\0)
int free
buf数组中未使用的字节数量
char buf[]
字节数组, 保存字符串
SDS与C字符串的区别
常数复杂度获取字符串的长度
C
O(n)遍历
SDS
SDS 的len属性记录了SDS本身的长度, 可以O(1)获取
杜绝缓冲区溢出
C
调用某些C的字符串库函数(strcat), 因为不知道字符串的长度信息, 会造成缓冲区溢出
SDS
SDS API对字符修改的时候, 需要先检查SDS的空间是否足够, 如果不够会自动扩展至执行操作需要的大小,再执行实际的修改
sdscat(str1, str2) 会先检查str1的buf是否有足够的空间容纳str2, 不够的话先扩容再执行
减少修改字符串带来的内存重分配次数
C
- 增大字符串, 需要先扩展底层数组的大小, 否则会缓冲区溢出
- 减少字符串, 需要先释放底层数组不使用的那部分空间, 否则会造成内存泄露
- 减少字符串, 需要先释放底层数组不使用的那部分空间, 否则会造成内存泄露
SDS
空间预分配(增大)
修改后SDS的长度(len)<1MB
为这个SDS分配额外和SDS长度相同的额外未使用空间(free=len)
修改后SDS的长度(len)>=1MB
为这个SDS分配1MB未使用的空间(free)
惰性空间释放(减少)
需要缩短SDS的时候, 不立即使用内存重分配来回收多出来的字节, 而是将这些多出来的空间记录到free属性中,
这样将来如果需要进行增长操作, 未使用的空间就可以用上
这样将来如果需要进行增长操作, 未使用的空间就可以用上
二进制安全
C
字符串中除了末尾不能包含空字符, 否则被认为是字符串的结尾, 不能保存二进制数据
SDS
- SDS API 以处理二进制的方式来处理保存在buf数组里的数据, 不做任何限制, 假设, 过滤, 写入什么样子读出来就是什么样子
- 不仅可以保存文本数据也可以保存任意格式的二进制数据
- 不仅可以保存文本数据也可以保存任意格式的二进制数据
兼容部分C字符串函数
SDS API二进制安全但是仍然遵守C字符串以空字符串结尾的管理, 这样可以兼容部分C字符串函数
链表
双向无环链表
双端 prev next指针可以O(1)获取前置和后置结点
无环 head.prev = NULL tail.next = NULL
O(1)获取头尾结点
O(1) 通过len属性获取链表结点个数
多态 void\*保存结点值, 包括dup clear match函数的比较值都是void* 可以保存不同类型的值
结构组成
ListNode
struct ListNode *prev
指向前驱结点的指针
struct ListNode *next
指向后继结点的指针
void *value
可以保存不同类型的值(void* 可以转换成任意类型指针, 提供了很大的灵活度)
List
链表属性
*head, *tail, long len: 链表头尾结点和结点数量
链表结点函数
*dup, *free, *match: 复制, 释放, 对比结点所保存的值
字典
哈希表是Redis字典的底层实现, 一个哈希表可以有多个哈希表结点, 每个哈希表结点保存了字典中的一个键值对
结构组成
哈希表结点(dictEntry)
每个dictEntry结构保存一个键值对
void *key
键
void *val
值
struct dictEntry *next
hash碰撞在一个位置的结点形成一个单链表, 新节点添加到链表的表头位置O(1)
哈希表 (dictht)
table
- 是一个指针数组, 二级指针指向一个一个数组的起始位置
- 数组中每个元素都是一个指向一个dict.h/dictEntry结构的指针
- 每个dictEntry结构保存着一个键值对
- 数组中每个元素都是一个指向一个dict.h/dictEntry结构的指针
- 每个dictEntry结构保存着一个键值对
size
哈希表的大小, table数组的大小
used
已有的结点数量
sizemask(=size-1)
和哈希值决定了一个键被放到table数组的那一个索引上
字典(dict)
dictType *type
void *privatedata
void *privatedata
针对不同类型的键值对, 创建多态字典而设置的
type
- 指向dictType结构的指针, 每个dictType结构保存了一簇用于操作特定类型键值对的函数
- redis会为用途不同的字典设置不同类型的函数
- redis会为用途不同的字典设置不同类型的函数
计算hash值, 复制键, 复制值, 对比键, 销毁键, 销毁值
privatedata
保存了要传给哪些类型特定函数的可选参数
ht
大小为2的数组, 数组中的每个项都是一个dictht哈希表
- ht[0]是正常使用的哈希表
- ht[1]只会对ht[0]进行rehash的时候使用
- ht[1]只会对ht[0]进行rehash的时候使用
rehashindex
rehashindex=-1 表示目前没有进行rehash
rehash
当哈希表保存的键值对数量太多或者太少时, 需要对哈希表的大小进行相应的扩展和收缩
哈希表的扩展和收缩
执行扩展操作的条件
- 服务器没有执行BGSAVE || BGREWRITEAOF 命令, 并且哈希表的负载因子>=1
- 服务器正在执行BGSAVE || BGREWRITEAOF 命令, 并且哈希表的负载因子>=5
- 服务器正在执行BGSAVE || BGREWRITEAOF 命令, 并且哈希表的负载因子>=5
负载因子
- load_factor = ht[0].used / ht[0].size
- 负载因子 = 已保存的结点数量 / 哈希表大小
- 负载因子 = 已保存的结点数量 / 哈希表大小
BGSAVE || BGREWRITEAOF 命令
- BGSAVE || BGREWRITEAOF 命令执行过程中 Redis需要创建子进程, 通过COW优化子进程的效率
- 所以需要提高扩展操作所需的负载因子来避免子进程存在期间进行哈希表扩展操作,
- 所以需要提高扩展操作所需的负载因子来避免子进程存在期间进行哈希表扩展操作,
收缩操作的必要条件
负载因子<0.1
rehash的步骤
为字典的ht[1]哈希表分配空间, 分配的空间取决与执行的操作和ht[0].used的大小
- 如果执行的扩展操作, ht[1] 大小 = 第一个大于等于 ht[0].used*2的2^n
- 如果执行的是收缩操作, ht[1]大小=第一个大于等于ht[0].used的2^n
- 如果执行的是收缩操作, ht[1]大小=第一个大于等于ht[0].used的2^n
将保存在ht[0]中的键值对rehash到ht[1]上
根据键值对的键值以及ht[1].sizemask重新计算键的哈希值和索引值, 然后将键值对放到ht[1]对应索引的位置
ht[0]所有的键值对迁移到ht[1]上以后释放ht[0] 将ht[1]置为ht[0], 并在ht[1]的位置上创建一个空白的哈希表
渐进式rehash
rehash的操作不是一次性集中式的完成, 而是分多次渐进式的完成的
为什么分多次渐进式的进行rehash
如果ht[0]保存的键值对数量过多, 一次性将这些键值对全部rehash到ht[1]庞大的计算量可能导致服务器停止一段时间,
使用分多次渐进式的rehash可以避免这种因为瞬时的大量计算导致的服务器停止
使用分多次渐进式的rehash可以避免这种因为瞬时的大量计算导致的服务器停止
渐进式rehash的步骤
1. 为ht[1]分配空间, 让字典同时持有ht[0] 和 ht[1]两个哈希表
2. 字典中维护一个索引计数器遍变量 rehashidx=0
3. rehash期间进行的对字典的增删改查操作除了指定操作, 将对应的索引上的所有键值对rehash到ht[1], 此次rehash工作完成后rehashidx+1
4. 当ht[0]所有的键值对都rehash到ht[1]以后, rehashidx=-1标志rehash操作完成
这样分而治之的方式, 可以将rehash键值对所需的工作均摊到每个队字典的增删改查操作中, 避免了集中式rehash带来的庞大的计算量
2. 字典中维护一个索引计数器遍变量 rehashidx=0
3. rehash期间进行的对字典的增删改查操作除了指定操作, 将对应的索引上的所有键值对rehash到ht[1], 此次rehash工作完成后rehashidx+1
4. 当ht[0]所有的键值对都rehash到ht[1]以后, rehashidx=-1标志rehash操作完成
这样分而治之的方式, 可以将rehash键值对所需的工作均摊到每个队字典的增删改查操作中, 避免了集中式rehash带来的庞大的计算量
渐进式rehash期间的哈希表操作
查找
先在ht[0]中找, 没有找到会到ht[1]中找
添加
新添加的键值对一律添加到ht[1], 保证了ht[0]的数量只减不增
跳跃表
跳跃表是一种有序数据结构, 是有序集合的底层实现之一
redis.h/zskiplistNode
struct zskiplistLevel level[]
struct zskiplistNode *forward
前进指针用于方位位于表尾方向的结点
int span
跨度记录了前进指针指向的结点与当前结点的距离
- 跨度实际与遍历操作无关, 是用来计算排位的(rank)
- 在查找某个结点的过程中, 将沿途访问过的所有层的跨度累计起来, 得到的结果就是目标结点在跳跃表中的排位
- 跨度实际与遍历操作无关, 是用来计算排位的(rank)
- 在查找某个结点的过程中, 将沿途访问过的所有层的跨度累计起来, 得到的结果就是目标结点在跳跃表中的排位
- 结点的level数组可以包含多个元素, 每个元素都是一个指向其他节点的指针, 可以通过这些层来加快访问其他节点的速度
- 每次创建新的跳跃表结点都会随机生成一个1-32之间的值作为level数组的大小即高度
- 每次创建新的跳跃表结点都会随机生成一个1-32之间的值作为level数组的大小即高度
从表头向表尾遍历时, 访问会沿着层的前进指针进行
后退指针(backward)
- BW是后退指针, 指向位于当前结点的前一个结点, 用于从表尾向表头遍历
- 每个结点只有一个BW指针, 只能回退到前一个结点
- 每个结点只有一个BW指针, 只能回退到前一个结点
分值(score)
结点按照各自的分值从小到大排列(double类型)
成员对象(obj)
- 是一个指针, 指向一个字符串对象, 字符串对象保存一个SDS的值
- 同一个跳跃表中, 各个结点保存的成员对象必须是唯一的, 但是多个结点保存的分值可以相同
- 分值相同的结点将按照成员对象在字典序中的大小来进行排序
- 成员对象小的结点排在前面, 大的在后面
- 同一个跳跃表中, 各个结点保存的成员对象必须是唯一的, 但是多个结点保存的分值可以相同
- 分值相同的结点将按照成员对象在字典序中的大小来进行排序
- 成员对象小的结点排在前面, 大的在后面
redis.h/zskiplist
head tail
跳跃表的头尾结点
level
记录目前跳跃表内, 层数最大的那个结点的层数(表头结点的层数不算在内)
length
跳跃表的长度即跳跃表的结点数(表头结点不算在内)
整数集合
整数集合的底层实现是数组, 数组以有序, 无重复的方式保存集合元素, 在需要的时候根据新添加元素的类型对数组的类型进行调整
结构组成
intset.h/intset
encoding
int16_t int32_t int64_t
length
数组的长度, 整数集合包含的元素数量
contents[]
整数集合的每个元素都是contents数组的一个数组项(item) 各个项在数组中从小到大排列并且不重复
content数组的真正类型取决于encoding属性的值
升级 与 降级
升级
当添加的新元素类型比整数集合所有元素的类型都要长时, 整数集合需要先升级, 才能将新元素添加到整数集合
升级步骤
1. 根据新元素的类型, 扩展整数集合底层数组的空间大小, 并为新元素分配空间
2. 将底层数组所有的元素都转换成和新元素相同的类型, 并将类型转换后的元素放置到正确的位置上,需要维持底层数组的有序性质
3. 将新元素添加到底层数组中
2. 将底层数组所有的元素都转换成和新元素相同的类型, 并将类型转换后的元素放置到正确的位置上,需要维持底层数组的有序性质
3. 将新元素添加到底层数组中
升级的好处
提升灵活性
C语言是静态类型语言, 不会将不同类型值放在同一数据结构,
因为整数集合通过自动升级底层数组来适应新元素, 可以将 int16_t, int32_t int64_t类型的整数添加到集合不担心类型错误
因为整数集合通过自动升级底层数组来适应新元素, 可以将 int16_t, int32_t int64_t类型的整数添加到集合不担心类型错误
节约内存
让集合能同时保存三种类型的值, 但是只在需要升级的时候才扩展, 尽量节约内存
不支持降级
压缩列表
压缩列表是一种为节约内存而开发的顺序型数据结构
压缩列表
一系列特殊编码的连续内存块组成的顺序型数据结构
一个压缩列表可以包含任意多个结点, 每个结点都可以保存一个字节数组或者一个整数值
一个压缩列表可以包含任意多个结点, 每个结点都可以保存一个字节数组或者一个整数值
zlbytes
记录压缩列表占用的内存字节数
zltail
记录压缩列表尾结点距离压缩列表的起始地址有多少字节, 可以通过这个偏移量直接访问尾结点
zllen
压缩列表包含的结点数量
entryX
压缩列表包含的各个结点
zlend
特殊值 0xFF(255) 标记压缩列表的末端
压缩列表结点
每个压缩列表结点可以保存一个字节数组或者一个整数值
previous_entry_length
以字节为单位, 记录前一个结点的长度, 属性的长度可以是1 || 5 个字节
- 前一个结点的长度<254bytes previous_entry_length属性长度是1字节, 前一个结点长度就保存在这个属性中
- 前一个结点的长度>=254bytes previous_entry_length属性长度是5字节, 属性的第一字节被设置为0xFE(254), 而之后的四个字节保存前一个结点的长度
- 前一个结点的长度>=254bytes previous_entry_length属性长度是5字节, 属性的第一字节被设置为0xFE(254), 而之后的四个字节保存前一个结点的长度
可以通过指针运算, 根据当前结点的起始地址计算前一个结点的起始地址
encoding
记录了结点的content属性所保存数据的类型和长度
content
保存结点的值, 结点值可以是一个字节数组, 或者整数, 类型和长度由encoding决定
数据类型的应用
String
字符串
数值
incr
抢购, 秒杀, 详情页, 点赞, 评论, 通过这个操作, 实现
bitmap
setbit bitcount bitpos bitop
用户系统, 统计用户登录天数
- key:用户 value:代表365天的bit
- 365位bit表示这个人第几天登录
- 通过 bitcount 统计某个窗口的1就是登录的天数
- 365位bit表示这个人第几天登录
- 通过 bitcount 统计某个窗口的1就是登录的天数
#对应天登陆
setbit key 1 1
setbit key 7 1
setbit key 364 1
#统计最后两天登陆的天数
bitcount key -2 -1
setbit key 1 1
setbit key 7 1
setbit key 364 1
#统计最后两天登陆的天数
bitcount key -2 -1
统计活跃用户
- key: 时间日期 value:每个位代表一个用户, 为1就是这一天这个bit对应的用户登录
- 连续登录连续日期 & 统计1的数量就是连续登录的人数
- 连续登录连续日期 & 统计1的数量就是连续登录的人数
#user1登录两天 user7登录1天
setbit 20190101 1 1
setbit 20190102 1 1
setbit 20190102 7 1
#两天的数据做按位&复制到destkey
bitop & destkey 20190101 20190102
#统计所有用户中两天都登陆的 1就说明登录了
bitcount destkey 0 -1
setbit 20190101 1 1
setbit 20190102 1 1
setbit 20190102 7 1
#两天的数据做按位&复制到destkey
bitop & destkey 20190101 20190102
#统计所有用户中两天都登陆的 1就说明登录了
bitcount destkey 0 -1
List
栈, 队列, 数组
文章评论列表, 粉丝列表
hash
对field进行数值计算 点赞, 收藏, 详情页
set
场景: 抽奖
无序去重, 集合操作多
交并差集, 粉丝列表共同关注, 推荐朋友的关注
随机事件
SRANDMEMBER key count
正数: 取出一个去重的结果集, 不能超过集合数量
负数: 取出一个带重复的结果集, 一定满足数量
如果0不返回
spop
取出一个
正数: 取出一个去重的结果集, 不能超过集合数量
负数: 取出一个带重复的结果集, 一定满足数量
如果0不返回
spop
取出一个
sortedset
排名前几的用户
对象
对象类型与编码
数据库每创建一个键值对, 都会至少创建两个对象用来表示键和值 "
每个对象都是一个redisObject
每个对象都是一个redisObject
类型 type
type属性记录了对象的类型
- 键总是一个字符串对象
- 值可以是字符串, 列表, 哈希, 集合, 有序集合对象中的一种
- 值可以是字符串, 列表, 哈希, 集合, 有序集合对象中的一种
type <数据库键>:
返回键对应值的对象类型, 因为键总是字符串对象
编码 encoding
记录了对象所使用的编码, 也就是对象使用了什么数据结构作为对象的底层实现
每种类型的对象都使用了至少两种不同的编码
void *ptr
对象的ptr指针指向底层实现数据结构, 数据结构由对象的encoding属性决定
字符串对象(int embstr raw)
编码
int
字符串对象保存的是整数值, 并且这个整数值可以用long类型来表示
embstr
保存字符串, 长度<=32字节
embstr编码通过调用一次内存分配一块连续的空间 依次包含redisObject和sdshdr两个结构
raw
使用SDS来保存这个字符串值 长度>32字节
raw调用两次内存分配分别创建redisObject 和 sdshdr结构
编码的转换
int embstr在条件满足的情况下会转换成raw
int -> raw
使这个对象从保存整数值变成保存字符串值
embstr -> raw
embstr编码的字符串没有任何修改程序, 实际上是只读的
对这个对象修改的时候转换成raw(append)
列表对象(ziplist linkedlist)
编码
ziplist
ziplist编码的列表对象底层实现为压缩列表, 每个压缩列表结点保存一个列表元素,
子主题
linkedlist
- linkedlist编码的列表对象底层实现为双端链表
- 每个双端链表结点保存了一个字符串对象, 字符串对象保存了一个列表元素
- 每个双端链表结点保存了一个字符串对象, 字符串对象保存了一个列表元素
编码的转换
使用ziplist编码
- 列表对象保存的所有字符串元素的长度都小于64字节
- 列表对象保存的元素数量小于512
- 列表对象保存的元素数量小于512
不能满足这两个条件的都需要使用linkedlist编码
哈希对象(ziplist hashtable)
编码
ziplist
ziplist编码的哈希对象底层使用压缩列表
- 新的键值对加入哈希对象, 先将保存了键的压缩列表结点推入压缩列表结尾
- 再将保存了值的压缩列表结点推入压缩列表结尾
- 再将保存了值的压缩列表结点推入压缩列表结尾
保证了同一键值对的两个结点总是紧挨在一起的, 键在前, 值在后
先添加到哈希对象的键值对在表头方向, 后添加的在表尾方向
hashtable
hashtable编码的哈希对象使用字典作为底层实现, 哈希对象的每个键值对都由一个字典键值对来保存
- 字典的每个键都是一个字符串对象, 对象保存了键值对的键
- 字典的每个值都是一个字符串对象, 对象保存了键值对的值
- 字典的每个值都是一个字符串对象, 对象保存了键值对的值
编码转换
使用ziplist编码
- 哈希对象保存的所有键值对的键和值的字符串长度都<64字节
- 哈希对象保存的所有键值对的数量<512
- 哈希对象保存的所有键值对的数量<512
不满足的都需要使用hashtable
集合对象(intset hashtable)
编码
intset
使用整数集合作为底层实现,
hashtable
使用字典作为底层实现,
字典的每个键都是一个字符串对象,
每个字符串对象包含一个集合元素,
字典的值全部设为NULL
字典的每个键都是一个字符串对象,
每个字符串对象包含一个集合元素,
字典的值全部设为NULL
编码转换
intset编码的条件
- 集合对象所有元素都是整数值
- 元素个数<=512
- 元素个数<=512
不满足的需要用hashtable编码
有序集合对象(ziplist skiplist)
有序集合的每一个元素的成员都是一个字符串对象
每个元素的分值都是一个double类型的浮点数
每个元素的分值都是一个double类型的浮点数
编码
ziplist
使用压缩列表作为底层实现
每个集合元素使用两个挨在一起的压缩列表结点保存,
第一个保存元素的成员(member) 第二个保存元素的分值(score)
压缩列表内的集合元素按照分值从小到大排序
第一个保存元素的成员(member) 第二个保存元素的分值(score)
压缩列表内的集合元素按照分值从小到大排序
skiplist
使用zset结构作为底层实现, 一个zset结构同时包含一个字典和一个跳跃表
zsl跳跃表
- 按分值从小到大保存了所有集合的元素,
- 每个跳跃表结点都保存了一个集合元素
- 跳跃表结点的object 属性保存了元素的成员
- score属性保存了元素的分值
- 通过这个跳跃表可以对有序集合进行范围型操作 (ZRANK ZRANGE)
- 每个跳跃表结点都保存了一个集合元素
- 跳跃表结点的object 属性保存了元素的成员
- score属性保存了元素的分值
- 通过这个跳跃表可以对有序集合进行范围型操作 (ZRANK ZRANGE)
dict字典
- 为有序集合创建一个从成员到分值的映射
- 字典的每个键值对都保存了一个集合元素
- 字典的键保存了一个元素的成员
- 字典的值保存了一个元素的分值
- 可以用O(1)复杂度找到给定成员的分值
- ZSCORE就时根据这一特性实现的
- 字典的每个键值对都保存了一个集合元素
- 字典的键保存了一个元素的成员
- 字典的值保存了一个元素的分值
- 可以用O(1)复杂度找到给定成员的分值
- ZSCORE就时根据这一特性实现的
为什么同是使用跳跃表和字典来实现?
编码转换
使用ziplist的条件
- 有序集合元素数量<128
- 保存元素的成员长度都<64字节
- 保存元素的成员长度都<64字节
不能满足的都要使用skiplist编码
redis线程模型
redis是单线程指的是worker线程只有一个是单线程的, 但是可能存在多个IO线程
redis线程模型的工作流程
多个socket请求到达服务器, 通过EPOLL多路复用器, 对于有读写时间发生的socket, 多个IO线程把请求读入内存
IO多线程加快了从网卡到内核复制数据的过程也加快了数据返回的过程
worker线程对于多个计算的请求按顺序一个fd一个fd的读取, 计算
计算完成后, 多个IO线程将计算完成的结果写回socket的缓冲区, 返回给客户端
redis单线程快的原因
纯内存操作的计算, 内存存储数据, 读写操作不涉及磁盘IO
redis提供对于不同类型的value不同的操作命令, 实现了计算向数据移动
IO模型采用的多路复用的EPOLL
IO线程将请求数据从网卡读到内核缓冲区后, redis一个fd一个fd的读取数据, 对于一个fd内的操作请求是有序的, 但是对于多个socket的请求以何种顺序读取是不保证的因为无法保证何时socket会有事件发生
为了避免多个socket存在对于一个key的请求, 应该通过负载均衡将一个key的请求放入一个socket, 这样保证了事务
worker线程单线程避免了多线程的上下文切换以及加锁解锁的问题
秒杀场景用多线程DB和单线程Redis对比
多线程DB
单线程redis
分布式场景相关
缓存穿透
数据库不存在, 缓存不存在(并发访问一个数据库没有的key)
原因
被恶意攻击, 故意请求不存在key
解决方案
接口层校验(如用户鉴权, id基础校验, id<=0拦截)
数据库缓存都没有的key的值设置成key-null键值对放入缓存,设置较短的过期时间 这样对这一个key的请求直接在缓存找到null返回, 防止对一个key的暴力攻击
布隆过滤器, 一定不存在的key被bitmap过滤掉, 避免了对DB的查询压力
分布式锁
对于一个key的n个并发请求, 去另一个redis实例上抢关于这个key的锁(set key val nx ttl time)
保证了对一个key的n个请求一次只有一个能抢到锁, 并且到达数据库, 发现不存在, 回写redis缓存 key-null, 释放锁
其他请求发现锁释放了, 先检查缓存有没有这个key, 发现存在, 直接拿到null值返回
缓存击穿
数据库存在, 缓存不存在(并发访问一个数据库有, 缓存没有的key)
原因
一个热点key过期
之前没有被访问过, 也就没有被放入到缓存, 但是突然有大量并发的请求
解决方案
设置热点key永远不过期
可以解决对于热点key的访问, 但是无法解决对于不是热点key的并发请求
分布式锁
这里抢到锁线程, 会访问到key对于的真实值, 然后回写这个真实的key-val
缓存雪崩
数据库存在, 缓存不存在(并发访问大量没有缓存的key)
原因
热点key大面积过期
解决方案
设置热点key过期时间随机, 防止同一时间大量数据过期
缓存预热
缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统。这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据!
分布式锁
每个key都对应一把锁, 需要对key进行分片, 对于不同key的请求到不同的分片中, 降低了对于一个redis实例的大量分布式锁的请求
核心思想,
避免重复请求
分布式锁, 保证一个key的大量请求只有一个到达DB, 并且回写key-val 其他请求可以直接在缓存获取数据, 无需再访问DB
过滤无效请求
对于不存在的key, 在redis层就过滤掉不会到达DB, 利用布隆过滤器 过滤无效请求
缓存降级
当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使是有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。
服务降级的目的,是为了防止Redis服务故障,导致数据库跟着一起发生雪崩问题。因此,对于不重要的缓存数据,可以采取服务降级策略,例如一个比较常见的做法就是,Redis出现问题,不去数据库查询,而是直接返回默认值给用户。
布隆过滤器
原理
定义多个hash函数, 添加数据时, 将数据根据每个hash函数进行hash得到hash值, 将hash值的二进制数在bitmap中的对应bit位改为1,
一个数据的添加会将多个bit改为1, 目的在于减少hash碰撞
一个数据的添加会将多个bit改为1, 目的在于减少hash碰撞
查询数据: 根据hash函数计算hash值, 如果对于的二进制数的某一bit是1但是bitmap中却是0, 那么这个key一定不存在, 不会再去数据库中进行查找
优点
占用内存小
增加和查询数据的时间复杂度为 O(k) k为hash函数的个数
增加和查询数据的时间复杂度为 O(k) k为hash函数的个数
缺点
不能准确判断元素在集合中
不能获取元素本身
不能从布隆过滤器中删除元素, 如果要删除需要在每个bit上加上计数
不能获取元素本身
不能从布隆过滤器中删除元素, 如果要删除需要在每个bit上加上计数
分布式锁
第三方redis实例实现
setnx/xx
- setnx 不存在设置 对应的是新建
- setxx 存在设置 对应的是更新
EX seconds: 设定过期时间,单位为秒
PX milliseconds: 设定过期时间,单位为毫秒
NX: 仅当key不存在时设置值
XX: 仅当key存在时设置值
- setxx 存在设置 对应的是更新
EX seconds: 设定过期时间,单位为秒
PX milliseconds: 设定过期时间,单位为毫秒
NX: 仅当key不存在时设置值
XX: 仅当key存在时设置值
实现方法
setnx + setex
- 如果不存在设置, 并且设置超时时间
- 因为是两个操作, 不是原子的, 可能先设置了 然后服务器宕机, 造成这个锁没有设置过期时间导致死锁
- 因为是两个操作, 不是原子的, 可能先设置了 然后服务器宕机, 造成这个锁没有设置过期时间导致死锁
set (key,value, nx, px)
将不存在设置(nx)以及设置超时时间(px)变成原子操作
拿到锁的A线程, 执行事件超过了自己设置的锁的过期时间
1. 超时的那部分操作时不安全的, 因为线程A已经不再拥有锁了,
2. 需要redission监控锁的超时时间, 续期锁的超时时间
2. 需要redission监控锁的超时时间, 续期锁的超时时间
加锁和释放锁不是一个线程的问题
1. 同样是超时以后才结束当前线程的操作, 超时部分操作不安全
2. 执行完了以后会删除锁, 但是可能超时后线程B拿到了锁, 但是此时线程A结束又删除了B拿到的锁
3. 需要在value中存入唯一的UUID, 删除时判断是不是自己设置的锁, 是就删除
4. 但是get delete 是两步操作, 不是原子, 可能get的时候是自己设置的, 但是delete之前被别的线程设置,
5. 需要使用lua脚本保证get delete是原子操作
2. 执行完了以后会删除锁, 但是可能超时后线程B拿到了锁, 但是此时线程A结束又删除了B拿到的锁
3. 需要在value中存入唯一的UUID, 删除时判断是不是自己设置的锁, 是就删除
4. 但是get delete 是两步操作, 不是原子, 可能get的时候是自己设置的, 但是delete之前被别的线程设置,
5. 需要使用lua脚本保证get delete是原子操作
不可重入
1. 同一个线程不能拿着锁还能获取锁
2. 对于一个锁设置一个计数器, 计数器=0 才能释放锁
2. 对于一个锁设置一个计数器, 计数器=0 才能释放锁
主从复制实现HA
1. 加锁在主服务器, 但是还没复制到从服务器就宕机了, 锁的数据丢失了
2. 使用redlock, 向多机的redis服务器申请锁, 超过一半以上的服务器返回成功, 就是获取锁成功, 并且获取锁成功的时间必须小于锁的过期时间, 不然获取成功的时候, 有的服务器的锁已经过期释放了
2. 使用redlock, 向多机的redis服务器申请锁, 超过一半以上的服务器返回成功, 就是获取锁成功, 并且获取锁成功的时间必须小于锁的过期时间, 不然获取成功的时候, 有的服务器的锁已经过期释放了
redlock
总结
缓存不存在去抢锁
无论哪种情况, 缓存不存在, 都去另一个redis集合取抢对应key的锁, 抢到锁的才可以访问DB, 抢不到的在另一个redis集合自旋
一个key的多个请求只有一个到达数据库
都是对于每个key无论多少个请求都只有一个可以访问数据库, 这样再回写缓存, 就避免了大量重复的key请求, 或者不存在的key请求压崩了数据库
缓存淘汰与过期删除
Redis过期键
设置过期时间
- EXPIRE <key> <ttl> 生存时间设置为ttl秒
- PEXPIRE <key> <ttl> 生存时间设置为ttl毫秒
- EXPIREAT <key> <timestamp> 过期时间设置为 timestamp秒数时间戳
- PEXPIREAT <key> <timestamp> 过期时间设置为 timestamp毫秒数时间戳
- PEXPIRE <key> <ttl> 生存时间设置为ttl毫秒
- EXPIREAT <key> <timestamp> 过期时间设置为 timestamp秒数时间戳
- PEXPIREAT <key> <timestamp> 过期时间设置为 timestamp毫秒数时间戳
保存键的过期时间
redisDb结构的expires字典保存了数据库中所有键的过期时间, 过期字典
过期字典的键是一个指针, 指向了键空间的某个键对象
过期字典的值是一个long long类型的整数, 保存了键所指向的数据库键对象的过期时间
一个毫秒精度的UNIX时间戳
一个毫秒精度的UNIX时间戳
当客户端执行PEXPIREAT命令, 为一个数据库键设置过期时间, 服务器会在数据库的过期字典中关联给定的数据库键和过期时间
缓存过期策略
定时删除
对有过期时间的key设置定时器, 到过期时间立即清除,
对内存友好, 但是占用大量CPU资源处理过期的数据, 影响缓存的响应时间和吞吐量
对内存友好, 但是占用大量CPU资源处理过期的数据, 影响缓存的响应时间和吞吐量
惰性删除
访问带有过期时间的key才会判断key是否过期, 过期则清除
节省CPU资源, 但是过期key占用大量内存, 极端情况, 过期key没有被再次访问, 不会被清除, 一直占据内存
节省CPU资源, 但是过期key占用大量内存, 极端情况, 过期key没有被再次访问, 不会被清除, 一直占据内存
定期删除
每隔一定时间, 扫描一定数量的数据库的expire字典中一定数量的key(随机), 清除已经过期的
可以调整定时扫描的时间间隔和每次扫描的时间限制, 使得CPU和内存消耗平衡
可以调整定时扫描的时间间隔和每次扫描的时间限制, 使得CPU和内存消耗平衡
Redis使用的过期策略
redis服务器实际使用惰性删除和定期删除两种, 通过两者的配合在合理使用CPU时间和避免浪费内存中平衡
复制AOF文件时如何处理过期
复制AOF文件的时候会忽略过期键
但是Slaves连接到master时, Slaves不会独立删除过期key, 只有等到master传来删除过期key的指令才会删除
Redis缓存淘汰
内存空间不足了, 我必须淘汰一部分空间来放我的新的缓存
淘汰策略
1. noeviction:默认禁止驱逐数据。内存不够使用时,对申请内存的命令报错。
2. volatile-lru:从设置了过期时间的数据中淘汰最近没使用的数据。
3. volatile-ttl:从设置了过期时间的数据中淘汰即将要过期的数据。
4. volatile-random:从设置了过期时间的数据中随机淘汰数据。
5. allkeys-lru:淘汰最近没使用的数据。
6. allkeys-random:随机淘汰数据。
2. volatile-lru:从设置了过期时间的数据中淘汰最近没使用的数据。
3. volatile-ttl:从设置了过期时间的数据中淘汰即将要过期的数据。
4. volatile-random:从设置了过期时间的数据中随机淘汰数据。
5. allkeys-lru:淘汰最近没使用的数据。
6. allkeys-random:随机淘汰数据。
数据库和缓存不一致
我们对数据进行修改, 到底是先删除缓存还是先写数据库?
先删缓存, 再写数据库
问题
高并发场景, 一个线程删除了缓存, 还没来得及写数据库, 第二个线程读取了数据, 发现缓存不存在, 进入数据库读到了旧的数据, 读完将旧数据回写到redis, 但是这时第一个线程已经将新的值写到缓存, 新值被读到的旧值覆盖了, 缓存中存在脏数据
解决方案
先将缓存修改为特殊值(-999),客户端读缓存发现是默认值, 休眠, 再次查Redis, 但是特殊值对业务有侵入, 休眠时间可能多次重复, 对性能有影响
延时双删, 先删除缓存, 再写数据库, 休眠一会再次删除缓存, 但是如果写操作很频繁, 同样会造成脏数据
先写数据库, 再删缓存
问题
数据库写完了, 删除缓存失败, 数据仍然不一致
解决方案
给缓存设置过期时间, 过期时间内, 缓存数据不更新
将热点数据设置为永不过期, 但是在value中写入一个逻辑上的过期时间,另起一个后台线程,扫描这些key, 对于逻辑上过期的缓存, 进行更新
总结
我们始终只能保证一定时间内的最终一致性
Redis持久化机制
RDB(Redis DataBase)
将某一时刻的缓存快照以二进制的方式写入磁盘
主动触发
save(阻塞)
阻塞Redis服务器进程, 直到RDB 文件创建完毕为止, 阻塞期间, 服务器不能处理任何请求
bgsave(非阻塞)
fork一个子进程去创建RDB文件, 服务器进程继续处理请求
SAVE命令会被拒绝, 服务器禁止SAVE BGSAVE同时执行, 避免父子进程同时执行两个rdbSave, 防止产生竞争条件
BGSAVE也会被拒绝, 防止两个BGSAVE产生竞争条件
BGREWRITEAOF和BGSAVE不能同时执行
BGSAVE执行期间, BGREWRITEAOF会被延迟到BGSAVE执行完毕以后执行
BGREWRITEAOF正在执行, BGSAVE 会被拒绝
BGREWRITEAOF和BGSAVE的实际工作都是子进程完成的, 不会冲突, 但是出于性能的考虑, 并发的两个子进程同时执行大量的磁盘写入操作, 造成性能的降低
fork的子进程和主进程共享同一份数据, 但是主动修改了数据的一方会先将待修改数据复制到另一个page再做修改, 彼此之间通过COW避免影响
被动触发
save m n
在m秒内, 如果有n个键发生改变, 自动触发持久化, 通过bgsave执行, 如果设置多个, 满足其一就会触发
SAVE由服务器进程执行保存, BGSAVE由派生的子进程执行保存
SAVE阻塞服务器, BGSAVE不阻塞服务器
SAVE阻塞服务器, BGSAVE不阻塞服务器
主从同步
全量同步会自动触发bgsave命令, 生成rdb发送给从节点
优缺点
优点
整个redis数据库只包含一个文件dump.rdb, 方便持久化
容灾性好, 方便备份, RDB是紧凑单一的文件, 方便传输到远端的数据中心, 适合灾难恢复
通过父进程fork子进程+COW的方式由子进程完成保存快照的工作, 父进程不需要其他IO操作, 所以IO最大化
恢复大数据集时, 比AOF更快
缺点
redis宕机, 损失的可能是很长时间段的数据, 因为设置save m n 条件出发才会保存,
数据集较大时, fork也会耗时很久, 不能响应客户端请求, AOF也需要fork, 但是可以通过重写日志文件的频率提高数据集的耐久度
AOF(Append Only File)
AOF持久化的实现
命令追加
AOF 持久化功能打开, 服务器执行完一个写命令, 会以协议格式将被执行的写命令追加到服务器状态的aof_buf缓冲区末尾
AOF文件的写入与同步
Redis服务器进程是一个事件循环, 循环中的文件事件负责接收客服端命令请求, 已经向客服端发生回复
时间事件负责执行向serverCron函数这样的定时运行函数
服务器每次结束一个事件循环前, 会调用flushAppendOnlyFile函数, 考虑是否需要将aof_buf缓冲区的内容写入和保存到AOF文件里面
时间事件负责执行向serverCron函数这样的定时运行函数
服务器每次结束一个事件循环前, 会调用flushAppendOnlyFile函数, 考虑是否需要将aof_buf缓冲区的内容写入和保存到AOF文件里面
appendfsync
always
每个操作都将aof_buf内容写入AOF文件 并且同步到磁盘
everysec(默认)
将aof_buf缓冲区的内容写入AOF文件, 如果上次同步AOF文件的时间超过1秒中, 再次同步AOF文件到磁盘
no
将aof_buf缓冲区的内容写入AOF文件, 不对文件进行同步, 何时同步交给操作系统(OS buffer满了就同步)
AOF重写
对于数据库的一个键值对, 读取键现在的值, 用一条插入命令去记录键值对, 代替之前记录这个键值对的多条命令
重写过程
创建新的AOF文件
遍历数据库
1. 忽略空数据库
2. 写入SELECT命令+数据库号码
3. 遍历数据库所有的键(或略过期键)
4. GET 获取键的值
5. SET命令重写键
2. 写入SELECT命令+数据库号码
3. 遍历数据库所有的键(或略过期键)
4. GET 获取键的值
5. SET命令重写键
AOF后台重写
Redis将AOF重写程序放到子进程进行
- 子进程AOF重写, 父进程可以继续处理客户端请求
- 子进程带有服务器进程的数据副本, 使用子进程而不是线程, 避免使用锁的情况下保证数据的安全性
- 子进程带有服务器进程的数据副本, 使用子进程而不是线程, 避免使用锁的情况下保证数据的安全性
子进程AOF重写, 父进程继续接受客户端请求可能会对服务器状态修改, 使得服务器当前状态和AOF重写后文件保存的数据库状态不同
AOF重写缓冲区
在服务器创建子进程以后使用
Redis服务器执行完一个写命令之后, 他会同时将这个写命令发送给AOF缓冲区和AOF重写缓冲区
子进程执行AOF重写期间, 服务器进程需要执行三个工作
1. 执行客户端发送的命令
2. 将执行的写命令追加到AOF缓冲区
3. 将执行的写命令追加到AOF重写缓冲区
2. 将执行的写命令追加到AOF缓冲区
3. 将执行的写命令追加到AOF重写缓冲区
保证了
- AOF缓冲区的内容会定期被写入同步到AOF文件
- 创建子进程开始, 服务器执行的写命令都会被记录到AOF重写缓冲区
- 创建子进程开始, 服务器执行的写命令都会被记录到AOF重写缓冲区
子进程完成AOF后, 向父进程发送一个信号, 父进程接到信号后, 调用信号处理函数
1. 将AOF重写缓冲区的内容写入到新的AOF文件, 新的AOF文件所保存的数据库状态将和服务器当前的数据库状态一致
2. 对新的AOF文件改名, atomic原子地覆盖现有的AOF文件, 完成新旧两个AOF文件替换
2. 对新的AOF文件改名, atomic原子地覆盖现有的AOF文件, 完成新旧两个AOF文件替换
整个AOF后台重写过程, 只有信号处理阶段会阻塞服务器, 其他时候都不会阻塞父进程
4.x版本的整合策略
子进程把内存的数据以RDB的形式追加到AOF文件的头部, 重写时间点后的指令依然追加日志
重写后依然追加日志,但是回复的时候先RDB恢复(RDB恢复更快), 再增量回复日志, 性能更好
AOF文件的载入与数据还原
AOF文件保存了重建数据库状态所需的所有写命令, 只需读入并重新执行一遍AOF文件里面保存的写命令, 就可以还原服务器关闭之前的数据库状态
还原步骤
1. 创建一个不带网络连接的伪客户端, 因为redis 命令只能在客户端上下文执行
2. 从AOF文件中分析并读取一条写命令
3. 伪客户端执行读出的写命令
4. 直到所有AOF文件中的写命令处理完
2. 从AOF文件中分析并读取一条写命令
3. 伪客户端执行读出的写命令
4. 直到所有AOF文件中的写命令处理完
优缺点
优点
数据更好的备份, 不同的fsync(always, everysec, no) 策略达到不同的数据一致性, 后台线程进行同步, 主线程仍然处理客户端请求,
AOF是只进行日志追加的日志文件, 未执行完整的写入命令可以通过redis-check-aof工具修复
AOF文件体积过大时可以重写AOF文件, 缩小占用空间, 并且重写阶段客户端的新的请求, 可以写入aof_rewrite_buf, 最后追加进新的AOF文件, 最大程度保证数据的一致性
AOF是以协议格式保存, 更容易分析, 如果不小心进行flushall, 只要AOF没有重写, 可以导出AOF文件移除末尾的FlushAll, 回复FlushAll之前的状态
缺点
相同的数据集, AOF文件通常大于RDB文件的体积, 启动恢复过程较慢
根据是以的fsync策略, AOF速度慢于RDB
AOF vs RDB
- AOF文件比RDB更新频率高,优先使用AOF还原数据。
- AOF比RDB更安全也更大
- RDB性能比AOF好
- 如果两个都配了优先加载AOF
- AOF比RDB更安全也更大
- RDB性能比AOF好
- 如果两个都配了优先加载AOF
Redis事务
Redis事务的概念
Redis 事务通过MULTI、EXEC、DISCARD、WATCH等一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。
在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。
redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。
也可以基于Lua脚本实现事务,Redis可以保证脚本内的命令一次性、按顺序地执行,
Redis事务的三个阶段
事务开始 MULTI
命令入队
事务执行 EXEC
事务执行过程中,如果服务端收到有EXEC、DISCARD、WATCH、MULTI之外的请求,将会把请求放入队列中排队
命令入队
事务执行 EXEC
事务执行过程中,如果服务端收到有EXEC、DISCARD、WATCH、MULTI之外的请求,将会把请求放入队列中排队
Redis事务相关命令
WATCH
WATCH 命令是一个乐观锁,可以为 Redis 事务提供 check-and-set (CAS)行为。
可以监控一个或多个键,一旦其中有一个键被修改(或删除),之后的事务就不会执行,监控一直持续到EXEC命令。
可以监控一个或多个键,一旦其中有一个键被修改(或删除),之后的事务就不会执行,监控一直持续到EXEC命令。
MULTI
MULTI命令用于开启一个事务,它总是返回OK。 MULTI执行之后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个队列中,当EXEC命令被调用时,所有队列中的命令才会被执行。
EXEC
EXEC:执行所有事务块内的命令。返回事务块内所有命令的返回值,按命令执行的先后顺序排列。 当操作被打断时,返回空值 nil 。
DISCARD
通过调用DISCARD,客户端可以清空事务队列,并放弃执行事务, 并且客户端会从事务状态中退出。
事务中的错误
事务执行EXEC之前
语法错误(参数数量错误, 参数名错误), 内存不足
发生错误, 服务器记录命令入队失败的情况, 执行EXEC时, 拒绝执行这个事务并自动放弃这个事务
事务执行EXEC之后
命令处理了错误类型的键, 列表命令用在了字符串键上
不进行处理, 事务中其他命令继续执行
为什么Redis不支持回滚(roll back)
只会在语法错误时执行发生错误, 不能再入队时发现,(命令作用在错误的类型)
而这些编程的错误可以通过检查来避免, 没必要通过一种额外的成本来规避
不需要支持回滚, 可以保持内部的简单快速
Redis集群方案
哨兵模式(sentinel)
Sentinel是Redis高可用的解决方法
由一个或多个Sentinel实例组成的Sentinel系统, 可以监视任意多个主服务器以及这些主服务器的所有从服务器
被监视的主服务器下线, 自动将下线主服务器的某个从服务器升级为新的主服务器, 新的主服务器代替下线的主服务处理命令请求
功能
集群监控:
负责监控 redis master 和 slave 进程是否正常工作。
消息通知:
如果某个 redis 实例有故障,那么哨兵负责发送消息作为报警通知给管理员。
故障转移:
如果 master node 挂掉了,会自动转移到 slave node 上。
配置中心:
如果故障转移发生了,通知 client 客户端新的 master 地址。
启动并初始化Sentinel
redis-sentinel <sentinel.conf的路径>
redis-server <sentinel.conf的路径> --sentinel
redis-server <sentinel.conf的路径> --sentinel
初始化服务器
Sentinel本质是特殊模式下的Redis服务器, 需要先初始化Redis服务器
功能不同, 初始化过程不同, 不需要使用数据库 无需载入RDB AOF文件
功能不同, 初始化过程不同, 不需要使用数据库 无需载入RDB AOF文件
使用Sentinel专用代码
初始化Sentinel状态
初始化一个sentinel.c/sentinelState结构 保存服务器中所有和Sentinel功能相关的状态
初始化Sentinel状态的masters属性
masters字典记录所有被Sentinel监视的主服务器的相关信息
字典键是被监视主服务器的名字
字典的值是被监视主服务器对应的sentinel.c/sentinelRedisInstance结构
可以是主,从服务器, 或者另外一个Sentinel
创建连向主服务器的网络连接
每个被Sentinel监视的主服务器, Sentinel会创建两个连向主服务器的异步网络连接
命令连接
专门用于向主服务器发送命令, 接受命令回复
订阅连接
订阅主服务器的 _sentinel_:hello频道
通过给定的配置文件, 初始化Sentinel监视主服务器列表, 创建连向主服务器的网络连接
与主从服务器的信息交互
获取主服务器的信息
默认十秒一次频率的向被监视的主服务器发送INFO命令, 获取主服务器信息
主服务器本身信息
run_id, role
根据这些信息更新主服务器的实例结构
主服务器属下从服务器信息
从服务器slave字符串开头的行记录, ip port, 根据ip+port可以自动发现这些从服务器
用于更新主服务器实例结构的slaves字典, 字典记录了主服务器的从服务器名单
字典键是Sentinel自动设置的从服务器名字, ip:port 格式
值是从服务器对应的实例结构
值是从服务器对应的实例结构
获取从服务器信息
Sentinel发现主服务器有新的从服务器后, 除了创建相应的实例结构外, 还会创建连接到从服务器的命令连接和订阅连接
创建连接后 会以十秒一次的频率通过命令连接向从服务器发送INFO命令
向主服务器和从服务器发送信息
默认 Sentinel以两秒一次的频率 通过命令连接向所有被监视的主从服务器放以下格式的命令
接收来自主从服务器的频道信息
每个与Sentinel连接的服务器, Sentinel既通过命令连接向服务器的 _sentinel_ : hello频道发送信息, 又通过订阅连接从服务器的 _sentinel_ : hello频道接受信息
对于监视同一个服务器的多个Sentinel, 一个Sentinel发送的信息会被其他Sentinel接收到, 这些信息会被用于更新其他Sentinel对发送信息Sentinel的认知, 也会用于更新其他Sentinel对被监视服务器的认知
更新Sentinels字典
Sentinel为主服务器创建的实例结构中Sentinels字典保存了除Sentinel本身以外所有其他监视这个主服务器的Sentinel资料
- 字典键是一个Sentinel的名字 ip:port的形式
- 值是键对应的Sentinel的实例结构
- 值是键对应的Sentinel的实例结构
创建连向其他Sentinel的命令连接
当Sentinel通过频道信息发现一个新的Sentinel时,
- 为新的Sentinel在sentinels字典中创建相应的实例结构,
- 还会创建一个连向新的Sentinel的命令连接
- 新的Sentinel同样会创建连向这个Sentinel的命令连接
- 最终监视同一主服务器的Sentinel形参互联的网络
- 还会创建一个连向新的Sentinel的命令连接
- 新的Sentinel同样会创建连向这个Sentinel的命令连接
- 最终监视同一主服务器的Sentinel形参互联的网络
检测服务器下线
检测主观下线状态
默认 Sentinel会以每秒一次的频率向所有与他创建了命令连接的实例(主从服务器, Sentinel) 发送PING命令,
通过实例返回的PING命令回复判断实例是否在线
通过实例返回的PING命令回复判断实例是否在线
有效回复
+PONG; -LOADING; -MASTERDOWN
无效回复
除了有效回复外的其他回复
如果一个实例在down_after_milliseconds毫秒内持续向Sentinel返回无效回复,
Sentinel修改这个实例的flags属性, 打开SRI_S_DOWN标识, 标识实例进入主观下线
Sentinel修改这个实例的flags属性, 打开SRI_S_DOWN标识, 标识实例进入主观下线
检查客观下线状态
当Sentinel判断主服务为主观下线后, 为了确认这个主服务器是否真的下线,
他会向同样监视这个主服务器的其他Sentinel询问, 看他们是否也认为这个主服务器下线了,
当Sentinel接收到足够数量的已下线判断之后, Sentinel就会将从服务器判定为客观下线, 并对主服务器执行故障转移操作
他会向同样监视这个主服务器的其他Sentinel询问, 看他们是否也认为这个主服务器下线了,
当Sentinel接收到足够数量的已下线判断之后, Sentinel就会将从服务器判定为客观下线, 并对主服务器执行故障转移操作
发送 SENTINEL is-master-down-by-addr命令
向其他sentinel发送 主观下线服务器的 ip, port, 自己的current_epoch, runid
current_epoch, runid用于选举领头sentinel
接受 SENTINEL is-master-down-by-addr命令
根据主服务器的IP PORT 检查主服务器是否下线
返回三个参数的回复
down_state
返回自己对于服务器是否下线的判断, 1代表下线, 0代表未下线
leader_runid
*仅仅用于检测主服务器是否下线; 局部领头Sentinel运行ID, 用于选举领头sentinel
leader_epoch
接收sentinel的局部领头sentinel的配置纪元, 用于选举领头sentinel
接受 SENTINEL is-master-down-by-addr回复
- 统计其他Sentinel同意主服务器下线的数量
- 超出配置指定判断客观下线所需的数量
- 不同 Sentinel判断主观下线的数量不同, 取决于启动时载入的配置参数是多少
- 将主服务器实例结构flags属性SRI_O_DOWN标识打开
- 标识主服务器进入客观下线
- 超出配置指定判断客观下线所需的数量
- 不同 Sentinel判断主观下线的数量不同, 取决于启动时载入的配置参数是多少
- 将主服务器实例结构flags属性SRI_O_DOWN标识打开
- 标识主服务器进入客观下线
选举领头 Sentinel
当一个主服务器被判断为客观下线,
监视这个主服务器的各个Sentinel会进行协商, 选举一个领头Sentinel,
并由领头Sentinel对下线主服务器进行故障转移
基于raft算法
监视这个主服务器的各个Sentinel会进行协商, 选举一个领头Sentinel,
并由领头Sentinel对下线主服务器进行故障转移
基于raft算法
所有在线Sentinel都可以被选为Sentinel, 不论选举是否成功, 所有的Sentinel的配置纪元的值都会自增一次
一个配置纪元, 所有Sentinel都有一次将某个Sentinel设置为局部领头Sentinel的机会
发现主服务器进入客观下线的Sentinel都会要求其他Sentinel将自己设为局部领头Sentinel
(携带epoch runid就是为了让接收方sentinel选举发送方为局部领头)
(携带epoch runid就是为了让接收方sentinel选举发送方为局部领头)
Sentinel设置局部领头Sentinel的规则是先到先得, 回复中国的leader_runId 和 leader_epoch是选举的sentinel
如果某个Sentinel被半数以上的Sentinel设置为局部领头, 这个Sentinel称为Sentinel(半数以上保证了唯一性)
如果给定时限没有选出, 过一段时间再继续选举直到选出领头Sentinel为止
故障转移
领头Sentinel对已下线的主服务器进行故障转移
选出新的主服务器
在线状态并且最近5秒回复过领头sentinel并且与下线主服务器连接的从服务器
优先级相同, 选复制偏移量最大的; 复制偏移量相同 选运行ID最小的
向选中的从服务器发送 SLAVEOF no one
修改从服务器的复制目标
让下线服务器的从服务器复制新的主服务器 SLAVEOF <newMaster_ip> <newMaster_port>
旧的主服务器变成从服务器
下线主服务器设置为新的主服务器的从服务器
主从复制模式
SLAVEOF 让一个服务器取复制另一个服务器, 被复制的是主服务器, 对主服务器进行复制的称为从服务器
完全复制
同步(SYNC)
将从服务器状态更新至和主服务器一致
1. 从服务器向主服务器发送SYNC命令
2. 主服务器收到SYNC命令, 执行BGSAVE, 生成RDB文件, 并利用缓冲区记录从现在开始的所有命令
3. 主服务器完成BGSAVE, 将生成的RDB发给从服务器, 从服务器接受RDB文件,导入将数据库状态更新
4. 主服务器将记录在缓冲区的命令发送给从服务器, 从服务器执行这些命令, 主从服务器状态一致
2. 主服务器收到SYNC命令, 执行BGSAVE, 生成RDB文件, 并利用缓冲区记录从现在开始的所有命令
3. 主服务器完成BGSAVE, 将生成的RDB发给从服务器, 从服务器接受RDB文件,导入将数据库状态更新
4. 主服务器将记录在缓冲区的命令发送给从服务器, 从服务器执行这些命令, 主从服务器状态一致
命令传播
主服务器状态被修改, 让主从服务器数据库状态回到一致
将客户端造成主服务器状态改变的命令传播给从服务器, 要求从服务器执行, 执行后主从服务器回到一致的状态
部分复制
用于断线后重连, 只发送断开期间主服务器的写命令, 从服务器执行这些命令就可以将数据库状态更新到一致
原理就是根据从服务器缺失的数据能否在主服务器复制积压缓冲区中找到决定是否部分复制
原理就是根据从服务器缺失的数据能否在主服务器复制积压缓冲区中找到决定是否部分复制
实现原理
复制偏移量
执行复制的双方 主从服务器分别维护一个复制偏移量
- 主服务器传播N个字节的数据就在复制偏移量+N
- 从服务器接收到主服务器传播来的N个字节, 就在自己的复制偏移量+N
- 如果主从服务器的复制偏移量不同则处于不同步的状态
- 断线重连后的从服务器向主服务器发送PSYNC命令, 携带自己当前的复制偏移量
- 主服务器根据从服务器的复制偏移量决定执行完全还是部分重同步
- 从服务器接收到主服务器传播来的N个字节, 就在自己的复制偏移量+N
- 如果主从服务器的复制偏移量不同则处于不同步的状态
- 断线重连后的从服务器向主服务器发送PSYNC命令, 携带自己当前的复制偏移量
- 主服务器根据从服务器的复制偏移量决定执行完全还是部分重同步
复制积压缓冲区
主服务器维护的固定长度的FIFO队列, 队列满了以后队首的字符被弹出, 新的字符入队尾
- 主服务器传播命令也会将命令写入复制积压缓冲区, 所以复制积压缓冲区保留了一部分最近传播的写命令
- 缓冲区会为每个字节记录对应的复制偏移量
- 从服务器重连后, 会通过PSYNC将自己的复制偏移量(offset)发送个主服务器
- 主服务器决定执行什么同步
- 如果offset之后的数据仍然存在于缓冲区, 执行部分重同步
- 相反需要执行完整重同步操作
- 缓冲区会为每个字节记录对应的复制偏移量
- 从服务器重连后, 会通过PSYNC将自己的复制偏移量(offset)发送个主服务器
- 主服务器决定执行什么同步
- 如果offset之后的数据仍然存在于缓冲区, 执行部分重同步
- 相反需要执行完整重同步操作
服务器运行ID
每个Redis服务器在服务器启动的时候都会生成40个随机的十六进制字符组成的运行ID
- 初次复制的时候 主服务器会把自己的运行ID发送给从服务器, 从服务器保存起来
- 从服务器断线重连后, 会向当前正在连接的服务器发送之前保存的运行ID
- 如果这个ID和自己的ID想用会尝试执行部分重同步
- 如果不同, 执行完整重同步
- 从服务器断线重连后, 会向当前正在连接的服务器发送之前保存的运行ID
- 如果这个ID和自己的ID想用会尝试执行部分重同步
- 如果不同, 执行完整重同步
PSYNC命令的实现
从服务器没有复制过任何主服务器, 或者之前执行了 PSYNC of no one, 会发送 PSYNC ? -1 命令主动请求进行完整重同步
从服务器复制过某个主服务器, 会发送 PSYNC <runid> <offset>
接到PSYNC主服务器会有三种回复
+FULLRESYNC <runid> <offset> 表示执行完整重同步
- runid 主服务器的id, 从服务器保存这个id
- offset 主服务器当前的offset , 作为从服务器的初始复制偏移量
- offset 主服务器当前的offset , 作为从服务器的初始复制偏移量
+CONTINUE 表示执行部分重同步
从服务器等待主服务器发送缺少的那部分数据
-ERR
主服务器版本低, 无法识别PSYNC
分片(sharding)
基本原理
- 通过哈希的方式,将数据分片,每个节点均分存储一定哈希槽区间的数据,默认分配了16384 个槽位
- 每份数据分片会存储在多个互为主从的多节点上
- 数据写入先写主节点,再同步到从节点(支持配置为阻塞同步)
- 同一分片多个节点间的数据不保持一致性
- 读取数据时,当客户端操作的key没有分配在该节点上时,redis会返回转向指令,指向正确的节点
- 扩容时时需要需要把旧节点的数据迁移一部分到新节点
- 在 redis cluster 架构下,每个 redis 要放开两个端口号,比如一个是 6379,另外一个就是 加1w 的端口号,比如 16379。
- 每份数据分片会存储在多个互为主从的多节点上
- 数据写入先写主节点,再同步到从节点(支持配置为阻塞同步)
- 同一分片多个节点间的数据不保持一致性
- 读取数据时,当客户端操作的key没有分配在该节点上时,redis会返回转向指令,指向正确的节点
- 扩容时时需要需要把旧节点的数据迁移一部分到新节点
- 在 redis cluster 架构下,每个 redis 要放开两个端口号,比如一个是 6379,另外一个就是 加1w 的端口号,比如 16379。
节点
Redis集群由多个结点组成, 一开始每个结点相互独立, 多个独立的结点连接起来构成一个包含多个节点的集群
CLUSTER MEET <ip> <port>, 结点加入指定的结点的集群
集群数据结构
clusterNode
保存了节点当前的状态
并为集群中其他所有节点创建相应的clusterNode结构
并为集群中其他所有节点创建相应的clusterNode结构
节点创建时间, 名字, 当前的配置纪元, IP PORT
clusterState
记录当前节点视角下 集群目前处于的状态,
在线|下线, 包含多少个节点, 配置纪元
槽指派
集群的整个数据库被分为16384个槽(slot), 数据库的每个键都属于这个16384个槽中的一个,
CLUSTER ADDSLOTS [i,i+1,...,j] 将槽[i:j]指派给当前结点
数据库中所有的槽都有结点在处理的时候, 集群处于上线状态(ok), 相反存在任何一个槽没有被处理, 集群处于下线状态(nail)
记录结点的槽指派信息
clusterNode结构的slots 属性 和 numslot属性记录了结点负责处理哪些槽
slots属性是一个二进制位数组, 长度为 16384/8个字节, 包含16384个二进制位
槽从0~16383编号, 根据索引i上的二进制位是否为1判断结点是否处理槽i
传播结点的槽指派信息
每个结点会将自己的slots数组通过消息发送给集群中的其他节点
每个接收到slots数组的结点都会将数组保存到发送这个slots数组信息的对应结点的clusterState.nodes.clusterNode结构里
以集群中的每个结点都会知道数据库中的16384个槽分别被指派给了集群中的哪个结点
记录集群所有槽的指派信息
clusterState结构的slots数组记录了集群所有16384个槽的指派信息
slots包含16384项, 每个数组项都是一个指向clusterNode结构的指针
clusterState.slots解决了clusterNode.slots无法法高效解决的问题
如果只有clusterNode.slots
需要遍历clusterNode.nodes字典, 遍历所有的clusterNode结构直到找到负责处理槽i的结点 O(N)
通过clusterState
访问clusterState.slots[i] O(1)
clusterNode.slots仍然是必要的
- 需要将某个结点的槽指派信息发送给其他节点, 只需要将结点的clusterNode.slots数组发出去即可 O(1)
- 如果单独使用clusterState.slots需要遍历数组找到结点负责处理的槽再发送出去 O(16384)
- 如果单独使用clusterState.slots需要遍历数组找到结点负责处理的槽再发送出去 O(16384)
clusterNode.slots数组记录了clusterNode结构所代表的结点的槽指派信息
clusterState.slots数组记录了集群所有槽的指派信息
clusterState.slots数组记录了集群所有槽的指派信息
CLUSTER ADDSLOTS命令的实现
遍历所有的输入槽, 检查是否已经被指派(clusterState.slots[i]==NULL), 如果已经被指派则返回错误
再次遍历所有的输入槽,
1. clusterState.slots[i] = clusterState.myself, 修改对应数组的指针指向自己
2. clusterNode.slots 对应槽i的二进制位置为1, 修改自己结点的slots信息
2. clusterNode.slots 对应槽i的二进制位置为1, 修改自己结点的slots信息
执行完毕通过消息告诉集群中其他节点自己负责处理哪些槽, 让其他节点直到槽指派的更新
集群中执行命令
解析客户端请求处理的键属于哪个槽, 判断是否属于自己
指派给自己, 就执行这个命令
没有指派给自己, 返回一个MOVED错误, 指引客户端转向至正确的结点, 并再次发送想要执行的命令
计算键属于哪个槽
CRC16(key) & 16384, CRC计算key的校验和, &16384 计算一个介于0~16384的槽号
判断槽是否由当前结点负责处理
clusterState.slots[i] = clusterNode.myself 槽i由当前结点负责可以执行命令
clusterState.slots[i] != clusterNode.myself 不由当前结点负责,
根据clusterState.slots[i]指向的clusterNode结构记录的 IP+PORT 向客户端返回一个MOVED, 指引客户端指向正在处理槽i 的结点
MOVED 错误
结点发现键所在的槽不是由自己负责处理会返回一个MOVED错误
并通过键找到负责处理槽的结点的IP+PORT返回给用户, 指引客户端转向负责槽的结点
并通过键找到负责处理槽的结点的IP+PORT返回给用户, 指引客户端转向负责槽的结点
MOVED <slot> <ip>:<port>
一个集群客户端通常与多个集群结点建立套接字连接, 结点转向实际上就是换一个套接字发送命令
如果客户端与想要转向的结点没有建立连接, 回显根据MOVED错误提供的ip port连接节点再转向
如果客户端与想要转向的结点没有建立连接, 回显根据MOVED错误提供的ip port连接节点再转向
结点数据库的实现
结点只能使用0号数据库, 单机数据库没有限制
结点会用clusterState结构中的slots_to_keys跳跃表保存槽和键之间的关系
结点会用clusterState结构中的slots_to_keys跳跃表保存槽和键之间的关系
scores: 槽号, member:键值
重新分片
将任意数量已经指派给某个结点的槽位指派给另一个结点, 并且相关槽所属的键值对都从源结点移动到目标结点
目标结点准备导入槽的键值对
CLUSTER SETSLOT <slot> IMPORTING <source_id>
clusterState结构的 importing_slots_from数组记录当前结点正在从其他节点导入的槽
importing_slots_from[i] 值不为NULL, 而是指向 一个clusterNode结构, 表示当前结点正在从clusterNode代表的结点导入槽i
源结点准备迁移槽的键值对
CLUSTER SETSLOT <slot> MIGRATING <target_id>
clusterState结构的 migrating_slots_to数组记录了当前结点正在迁移至其他节点的槽
migrating_slots_to[i] 指向一个clusterNode结构说明当前结点正在将槽[i]迁移至clusterNode代表的结点
判断源结点是否保存了属于槽的键
是, 将这些键迁移到目标结点
将槽指派给目标结点
ASK错误
客户端请求的数据库键属于被正在被迁移的槽
- 先查找自己槽, 查看键是否在槽中, 在就执行命令
- 不在, 这个键有可能被迁移到目标结点, 源结点返回一个ASK错误, 指引客户端向导入槽的目标结点发送请求
- 不在, 这个键有可能被迁移到目标结点, 源结点返回一个ASK错误, 指引客户端向导入槽的目标结点发送请求
ASKING命令
ASKING命令需要做的就是打开发送该命令的客户端的REDIS_ASKING 标识
针对访问了数据原本属于的结点, 但是已经迁移到新结点了, 打开这个标识访问正在被导入数据的结点, 只有带了这个标识, 新节点才会返回正在被带入的数据
客户端向节点发送一个关于槽i的命令,
- 结点检查槽i是否指派给自己, 有就执行
- 没有指派给自己, 检查自己是否正在导入槽i clusterState.importing_slots_from[i]
- 如果正在导入槽i, 检查客户端是否带有REDIS_ASKING标识, 带了就破例执行一次关于槽i的命令
- 客户端接收到ASK错误并转向正在导入槽i的结点的时候, 客户端会先发送一个ASKING命令, 再发送自己想要执行的命令
- REDIS_ASKING 是一次性标识, 执行了一个带有REDIS_ASKING 标识的客户端发送的命令之后, 客户端的REDIS_ASKING 标识就会被移除
- 没有指派给自己, 检查自己是否正在导入槽i clusterState.importing_slots_from[i]
- 如果正在导入槽i, 检查客户端是否带有REDIS_ASKING标识, 带了就破例执行一次关于槽i的命令
- 客户端接收到ASK错误并转向正在导入槽i的结点的时候, 客户端会先发送一个ASKING命令, 再发送自己想要执行的命令
- REDIS_ASKING 是一次性标识, 执行了一个带有REDIS_ASKING 标识的客户端发送的命令之后, 客户端的REDIS_ASKING 标识就会被移除
ASK错误和MOVED错误的区别
(已经完成迁移)MOVED错误代表槽的负责权以及从一个结点转移到另一个结点了,
- 客户端请求一个结点, 当前结点没有被指派键所在的槽i
- 找到被指派槽i 的结点返回给客户端并指引客户端访问负责槽i的结点
- 找到被指派槽i 的结点返回给客户端并指引客户端访问负责槽i的结点
(正在迁移)ASK错误代表两个结点在迁移槽的时候使用的临时措施
- 客户端请求一个结点, 当前结点指派了键对应的槽i
- 查找数据库, 但是没有找到键, 查看对应槽是否正在迁移
- 正在迁移, 返回一个ASK错误, 带有槽i迁移至的目标结点的信息,
- 查找数据库, 但是没有找到键, 查看对应槽是否正在迁移
- 正在迁移, 返回一个ASK错误, 带有槽i迁移至的目标结点的信息,
复制与故障转移
Redis集群分为主节点 从节点, 主节点负责处理槽, 从节点负责复制某个主节点
设置从节点
CLUSTER REPLICATE <node_id>
让接收命令的节点成为node_id所指定的从节点, 并开始对主节点进行复制
clusterState.nodes字典中找到node_id对应的ClusterNode结构
将自己的clusterState.myself.slaveof 指针指向这个结构,
修改自己在clusterState.myself.flags中的属性, 打开REDIS_NODE_SLAVE标识 表示变为从节点
SLAVEOF <master_ip> <master_port> 对主节点进行复制
结点成为从节点 复制 主节点 这个信息变成消息发送给集群其他节点
集群中的所有结点都会在代表主节点clusterNode结构的slaves属性和numslaves属性记录正在复制这个主节点的从节点名单
故障检测
集群每个结点都会定期向集群其他节点发送PING消息, 检测对方是否在线
接受PING消息的结点B没有在规定时间返回一个PONG消息, 发送PING消息的结点A会将接受PING消息的结点标记为疑似下线(probable fail)
ClusterNode.nodes中找到疑似下线的clusterNode结构, 将flags属性REDIS_NODE_PFAIL标识打开
超过半数以上负责槽的结点标记一个结点下线, 这个结点就下线了
故障转移
从节点发现自己正在复制的主节点进入下线状态, 从节点对主节点进行故障转移
- 复制下线主节点的从节点里 会有一个从节点被选中
- 被选中的从节点执行SLAVEOF no one 命令成为新的主节点
- 新的主节点撤销所有下线主节点的槽指派, 并指派给自己
- 新的主节点向集群广播一条PONG消息, 集群其他节点直到这个节点变成主节点, 并且接管原来下线主节点负责的槽
- 新的主节点开始接收和自己负责处理的槽相关的命令请求, 故障转移完成
- 被选中的从节点执行SLAVEOF no one 命令成为新的主节点
- 新的主节点撤销所有下线主节点的槽指派, 并指派给自己
- 新的主节点向集群广播一条PONG消息, 集群其他节点直到这个节点变成主节点, 并且接管原来下线主节点负责的槽
- 新的主节点开始接收和自己负责处理的槽相关的命令请求, 故障转移完成
选举新的主节点
1. 开始故障转移, 集群配置纪元的值自增1
2. 对于每个配置纪元, 集群每个负责处理槽的主节点有一次投票的机会, 会投给第一个要求自己投票的从结点
3. 从节点发现正在复制的结点下线, 从结点会向集群广播一条 CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST消息, 要求所有收到消息的有投票权的主节点向这个从节点投票
4. 拥有投票权的主节点会向第一个向自己发送消息的结点投票, 投完不能再投
5. 当一个从节点收到了集群中N个主节点中的 至少N/2+1 就成为了新的主节点
6. 没有从节点收到足够的票, 集群进入新的配置纪元继续选举直到选出主节点
7. 这个选取Sentinel领头相似都是基于Raft算法
2. 对于每个配置纪元, 集群每个负责处理槽的主节点有一次投票的机会, 会投给第一个要求自己投票的从结点
3. 从节点发现正在复制的结点下线, 从结点会向集群广播一条 CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST消息, 要求所有收到消息的有投票权的主节点向这个从节点投票
4. 拥有投票权的主节点会向第一个向自己发送消息的结点投票, 投完不能再投
5. 当一个从节点收到了集群中N个主节点中的 至少N/2+1 就成为了新的主节点
6. 没有从节点收到足够的票, 集群进入新的配置纪元继续选举直到选出主节点
7. 这个选取Sentinel领头相似都是基于Raft算法
Redis vs Memcached
数据存储类型
Redis: V有类型, 并且有本地方法, 在客户端执行对应的计算, 返回
计算向数据移动
memcached: V没有本地方法, 需要取回全量的数据, 通过IO客户端取回数据反序列化, 再对数据进行相应的操作
数据向计算移动
移动数据成本大于移动计算
网络IO模型
Redis:
worker线程单线, IO多线程, EPOLL多路复用器
Memcached
多线程非阻塞IO
持久化支持
Redis:
RDB AOF
Memcached
不支持
0 条评论
下一页