数据结构与对象
简单动态字符串
struct sdshdr {
int len;
int free;
char buf[];
}
- 获取长度方法复杂度为O(1)
- 二进制安全,可以有\0
- 扩容:如果小于1M,* 2,否则扩容1M
- 惰性空间释放(不立即回收多出的部分)
链表
typedef struct listNode {
struct listNode *prev;
struct listNode *next;
void *value;// 指向任意类型的指针
}
typedef struct list {
listNode *head;
listNode *tail;
unsigned long len;
// 节点值复制函数
void *(*dup) (void *ptr);
// 节点值释放函数
void (*free) (void *ptr);
// 节点值对比函数
void (*match) (void *ptr, void *key);
}
- 双向无环链表
- 获取头、尾、长度,时间复杂度均为O(1)
字典
typedef struct dictht {
dictEntry **table;
// hash表长度
unsigned long size;
// 用于计算下标, sizemask = size - 1
unsigned long sizemask;
// hash表中已有节点数
unsigned long used;
}
typedef struct dictEntry {
// 键
void *key;
// 值可以是一个不知道类型的指针,uint64_t整数,int64_t整数
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
struct dictEntry *next;
} dictEntry;
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// hash表
dictht ht[2];
// rehash索引
// 不在rehash过程中时,值为-1
int trehashidx;
} dict;
typedef struct dictType {
// 计算hash值的函数,用于计算hash值
unsigned int (*hashFunction) (const void *key);
// 复制键的函数,用来rehash时候复制键
void *(*keyDup) (void *privdata, const void *key);
// 复制值的函数,用来rehash时候复制值
void *(*valDup) (void *privdata, const void *obj);
// 对比键的函数,get的时候用来对比键
void *(*keyCompare) (void *privdata, const void *key1, const void *key2);
// 销毁键的函数,删除元素的时候用来删除键
void *(*keyDestructor) (void *privdata, void *key);
// 销毁值的函数,删除元素的时候用来删除值
void *(*valDestructor) (void *privdata, void *obj);
} dictType;
字典本身是dict这个结构体,dictht相当于hashmap的底层结构。
ht[0]是字典当前使用的值,ht[1]、trehashidx在rehash过程中有使用
hash过程
- hash = dict -> type->hashFunction(key);
- index = hash & dict->ht[x]->sizemask;
hash 冲突
链表地址法解决冲突。为了速度考虑,新增节点在链表头部。
rehash
rehash() {
ht[1].size = isExtend ? ht[0].size * 2 : 第一个大于ht[0].used 的 2^n;
rehash
ht[0] = ht[1] && ht[1] = null;
}
load_factory = ht[0].used / ht[0].size;
if (BGSAVE || BGREWRITEAOF)
if (load_factory > 5)
rehash();
else
if (load_factory > 1)
rehash();
BGSAVE || BGREWRITEAOF时redis会创建当前服务进程的子进程进行,而大多数操作系统会采用写时复制技术来优化子进程的使用效率。如果在写时复制期间执行rehash会占用过多的内存空间。所以要尽量避免
写时复制技术
父进程fork出子进程之后,子进程共享父进程的正文段,数据段,堆,栈这四个部分。任何一个进程修改共享页面都会复制出一个内存页副本,修改内存页副本。
渐进式hash
- ht[1] 分配空间,让字典同时持有两个hash表
- rehashidx = 0;表示rehash开始
- 在进行增删改查时,对ht[rehashidx++]执行rehash
- rehash结束时,rehashidx = -1
这期间的更新、查找、删除操作都会在两个hash表上进行。
新增操作只在ht[1]上进行
跳跃表
typedef struct zskiplistNode {
/* redis3.0版本中使用robj类型表示,但是在redis4.0.1中直接使用sds类型表示 */
sds ele;
double score;
struct zskiplistNode *backward;
/** 这里该成员是一种柔性数组,只是起到了占位符的作用,
在sizeof(struct zskiplistNode)的时候根本就不占空间,这和sdshdr结构的定义是类似的(sds.h文件);
如果想要分配一个struct zskiplistNode大小的空间,
那么应该的分配的大小为sizeof(struct zskiplistNode) + sizeof(struct zskiplistLevel) * count)。
其中count为柔性数组中的元素的数量
**/
struct zskiplistLevel {
/* 对应level的下一个节点 */
struct zskiplistNode *forward;
/* 从当前节点到下一个节点的跨度 */
unsigned int span;
} level[];
} zskiplistNode;
typedef struct zskiplist {
/* 跳跃表的头结点和尾节点,尾节点的存在主要是为了能够快速定位出当前跳跃表的最后一个节点,实现反向遍历 */
struct zskiplistNode *header, *tail;
/* 当前跳跃表的长度,保留这个字段的主要目的是可以再O(1)时间内获取跳跃表的长度 */
unsigned long length;
/* 跳跃表的节点中level最大的节点的level保存在该成员变量中。但是不包括头结点,头结点的level永远都是最大的值---ZSKIPLIST_MAXLEVEL = 32。level的值随着跳跃表中节点的插入和删除随时动态调整 */
int level;
} zskiplist;
位置查找
从头节点的最高指针开始,向后查找,如果下一个节点的值比他大,则level的index减一,继续循环,直到找到或者level=0结束
整数集合
typedef struct intset {
/*编码*/
uint32_t encoding;
/*长度*/
uint32_t length;
/*集合内容,按升序排列数组*/
int8_t(取决于encoding) contents[];
} intset;
升级
- resize
- 从后向前移动现有元素或者从前向后,取决于插入的元素是大于最大还是小于最小(不会是中间,因为中间的话不需要扩容,最大元素或者最小元素已经能存了)
- 在最后插入元素
降级
不支持降级
压缩列表
连锁更新
多个entry的previous_entry_length=1字节,entry长度为250到253之间,如果在entry0之前插入一个长度>=254的节点。则entry0的previous_entry_length变为5,entry的长度变为254-257,导致下一个节点也变为254-257连锁更新下去。删除操作也会触发连锁更新。
对象(redisObject)
字符串对象
三种保存方式
- int
字符串内容可以用long表示
- raw
字符串并且长度大于39字节
- embstr
字符串长度小于39字节
raw vs embstr
embstr只分配一次内存,回收时也只需要回收一部分,能更好的利用缓存带来的优势。
这个39字节后续由于字符串对象优化,变为了44字节。39也好,44也好,都是因为
REDIS_ENCODING_EMBSTR_SIZE_LIMIT set to 39.
The new value is the limit for the robj + SDS header + string +
null-term to stay inside the 64 bytes Jemalloc arena in 64 bits
systems.
编码转换
embstr是只读的,发生修改时会转变为raw。int在变化之后,如果不能用int保存也会变为raw
列表对象(L开头的命令)
两种保存方式
- ziplist
所有字符串对象的长度都小于64字节
列表对象保存的元素对象的数量小于512
- linkedlist
StringObject就是前面说的字符串对象
哈希对象(H开头的命令)
-
ziplist
所有字符串对象的长度都小于64字节
列表对象保存的元素对象的数量小于512
-
hashtable
集合对象(S开头的命令)
-
intset
集合中所有对象都是整数值
集合对象保存的元素数量不超过512个
-
hashtable
有序集合对象(Z开头的命令)
-
ziplist
元素和分值紧紧挨在一起
元素个数小于128
元素保存的所有元素的长度都小于64字节
-
skiplist
typedef struct zset {
zskiplist *zsl;
dict *dict;
} zset;
为了保证单值查询和范围查询的效率采用两种结构一起实现。实际中跳表和字典公用元素的成员和分值
对象类型检查
通过type字段实现
多态命令的实现
通过type & encoding
内存回收
采用引用计数器的方式值存在对象里
对象共享
字符串采用常量池 & 共享的方式使用,只共享包含整数值的字符串对象
空转时长
lru属性
redisObject
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
void *ptr;
int refcount;
unsigned lru:22;
}
数据库
过期时间
数据库中保存了过期字典
过期键的判定
- 看过期字典里有没有
- 有的话时间是不是当前时间大于过期时间
过期键删除策略
- 定时删除(Timer,过期时间到的时候立即删除)(CPU不友好)
- 定期删除(隔一段时间删除)
函数运行会随机从一定数量的数据库中,取出一定数量的随机键检查,并删除过期的
用activeExpireCycle记录进度 - 惰性删除(读取时删除)(内存不友好,CPU友好)
RDB
保存:
不保存过期键
载入:
主服务器不载入过期的键
从服务器载入过期的键,但是会被主服务器的同步覆盖掉
AOF
删除键时(惰性或定期删除)会向AOF日志追加删除指令
AOF重写
会忽略过期的键
内存淘汰策略
当前Redis3.0版本支持的淘汰策略有6种:
-
volatile-lru:从设置过期时间的数据集(server.db[i].expires)中挑选出最近最少使用的数据淘汰。没有设置过期时间的key不会被淘汰,这样就可以在增加内存空间的同时保证需要持久化的数据不会丢失。
-
volatile-ttl:除了淘汰机制采用LRU,策略基本上与volatile-lru相似,从设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰,ttl值越大越优先被淘汰。
-
volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰。当内存达到限制无法写入非过期时间的数据集时,可以通过该淘汰策略在主键空间中随机移除某个key。
-
allkeys-lru:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰,该策略要淘汰的key面向的是全体key集合,而非过期的key集合。
-
allkeys-random:从数据集(server.db[i].dict)中选择任意数据淘汰。
-
no-enviction:禁止驱逐数据,也就是当内存不足以容纳新入数据时,新写入操作就会报错,请求可以继续进行,线上任务也不能持续进行,采用no-enviction策略可以保证数据不被丢失,这也是系统默认的一种淘汰策略。
RDB
SAVE, BGSAVE, BGREWRITEAOF 三个命令不能同时执行。
除执行BGSAVE时,会接受 & 延迟处理BGREWRITEAOF外。其他情况都会被拒绝。
DB服务器维护了dirty和lastsave来解决是否进行数据保存,数据库每100ms执行一次。即可以制定多个检查时间和changes来决定是否进行RDB。
RDB关闭对于校验和的检验可以提高10%的性能
AOF
追加的三种策略
- always(每次同步)
- everysec(每秒同步)
- no(由操作系统决定)
后台AOF执行期间,指令会双写到AOF缓冲区和AOF重写缓冲区
如果磁盘较忙的时候,everysec策略会校验上一次写入时间是不是2秒之前,是的话才会写入,所以如果磁盘忙碌可能会丢2s的数据
持久化的选择
可以master关闭持久化,slave开启AOF,关闭AOF重写,在闲时定时重写
开始RDB来做异地灾备
多机数据库
复制
127.0.0.1:12345> SLAVEOF 127.0.0.1 6379
PSYNC
复制偏移量
主从服务器都会维护复制偏移量,用来标示是否同步了。
复制积压缓冲区
用来缓冲最近的操作
服务器运行ID
用来重联时,告诉主服务器,上一次同步的主服务器是谁
复制的实现
- 从服务器保存主服务器的ip和port
- 建立套接字连接,用来后续接收主服务器的命令 & RDB文件
- 从服务器ping主服务器检查网络状态。如果收不到返回证明状态不好,断开重连,主服务器返回忙则断开重连,主服务器返回PONG则成功
- 身份认证。只有主从服务器都开启验证且密码一样或者都没开启验证才会连接成功,否则断开重拾直到超过重试次数
- 从服务器发送端口信息,主服务器存起来
- 同步,之后主从服务器互为客户端
- 命令传播
心跳检测
主 To 从
PING, 默认10秒一次
从 To 主
REPLCONF ACK,频率是1秒
发送了offset(判断是否漏消息)lag(判断是否有掉线,网络状态是否不佳)
- 监测网络状态
- 复制实现最小从服务器策略,即满足条件之后,主服务器拒绝写入
- 配合积压缓冲区,检测命令丢失 & 同步
应用中的问题
- 延迟与不一致
slave-serve-stale-data用于设置,当发生主从延迟时,从节点是否还能够响应请求 - 数据过期问题
一般采用惰性 & 定期删除
主从模式下,早期的版本redis,由于从节点不主动删除数据,如果读取过期数据的读请求打到从节点,会查到脏数据,后期的版本,redis的从节点会判断是否过期。 - 复制超时问题
3.1
超时判断的意义
对于主节点,可以知道从节点状态,维护数据安全(发现可用从节点数不足),还可以释放主节点资源
对于从节点,可以重新建立和主节点的链接,避免长时间数据不一致
3.2
判断机制,repl-timeout用于判断是否超时的阈值,适用于主从两个节点
主节点:
判断距离上一次收到 REPLCONF ACK 的时间,超过阈值则释放
从节点:
处于建立链接阶段,距离上次收到主节点信息时间
处于数据同步阶段,接收主节点RDB文件超时,则释放
处于命令传播阶段,收到上一次主节点的PING消息的时间间隔
3.3
坑
内存过大
导致主节点在数据同步阶段时间过长触发超时,从节点会释放链接,重新要求同步,形成恶性循环
还会导致复制缓冲区溢出由于内存大或网络状态不好,可以通过client-output-buffer-limit slave控制
repl-ping-slave-period控制ping的间隔,该参数应明显小于 repl-timeout 值
主节点执行慢查询(keys .*这种)
安全重启:debug reload
可以用来安全重启主节点,主节点的runid不变
哨兵
链接之间的关系
哨兵之间只有命令链接,哨兵和服务器之间有命令链接和订阅链接
订阅链接主要用来发现其他哨兵。
如何发现其他的从服务器
通过向主服务器发送INFO消息,获取其他从服务器信息,并建立连接
主观下线
哨兵会每秒向与他建立了链接的主、从、其他哨兵每秒发送链接。如果发送无效回复(非+PONG、-LOADING、-MASTERDOWN)的时间超过了设置时间(down-after-milliseconds。ps.该值还用来判断哨兵、其他从服务器、其他主服务器)则认为主观下线
客观下线
哨兵询问其他哨兵,获得确认数大于自己设置的值(quorum)时认为客观下线
主备切换
选举领头哨兵
规则
所有哨兵都可以成为头哨兵
配置纪元每次选举之后加一
每个配置纪元一个哨兵只能选举一个头哨兵
过程
- 认为主观下线的哨兵要求所有其他哨兵选自己(runid是源哨兵的运行ID)
- 根据leader_runid & leader_epoch(先leader_epoch,再leader_runid)判断自己是不是被选成功,如果统计发现超过一半同意了,则认为成功
- 没有成功的则循环
选出新的主服务器
- 筛掉所有下线的从服务器
- 筛掉最近5秒内没回复过领头哨兵INFO指令的服务器
- 筛掉所有与已下线主服务器断开超过down-after-milliseconds * 10的从服务器
- 根据优先级
- 根据offset
- 根据运行ID(升序)
集群
数据分区方案
hash,一致性hash,槽分配。集群模式采用槽分配,某一段槽负责的节点挂了之后,会自动把槽重新分发给其他节点
集群数据结构存储
ClusterState记录当前节点视角下集群的状态
发布订阅
维护了字典pubsub_channels、链表pubsub_patterns。
PUBLISH命令通过访问pubsub_channels来发送命令给谁
通过pubsub_patterns来发送命令给模式订阅的客户端
cluster meet
后续再通过Gossip协议实现同步
ASK错误
CLUSTER SETSLOT IMPORTING命令的实现
CLUSTER SETSLOT MIGRATING命令的实现
ASK不会永久性的更新客户端对于这个槽的定向
集群的主从
故障转移过程中集群为只读状态
info memory相关参数
used_memory:虚拟内存 & 分配器分配的内存(used_memory_human显示更友好)
used_memory_rss:进程本身内存,内存碎片,分配器分配的内存,不包括虚拟内存
mem_fragmentation_ratio:计算方法used_memory_rss / used_memory,一般大于1,值越大内存碎片比例越高。小于1说明使用了虚拟内存,需要扩大redis内存。比值为1.03比较好
mem_allocator:ptmalloc,jemalloc,tcmalloc
区别
简述:jemalloc和tcmalloc采用本地缓存降低小内存分配时的锁竞争,加快分配效率,但是分别内存额外开销是:Jemalloc大概需要2%的额外开销。(tcmalloc 1%, ptmalloc最少8B)
jemalloc相对于tcmalloc对于内存碎片的处理更友好。(低地址分配,dirty page优先)
持久化相关坑
fork进程的过程(由于需要复制内存页)中会阻塞服务,导致无法响应请求。如果内存过大会导致阻塞时间过长(10G会达到百毫秒级别)