主从

知识准备

IO复用:

https://zhuanlan.zhihu.com/p/367591714

两种触发

水平触发:这次不处理,下次还是会触发

边缘触发:只触发一次

两种事件

可读事件:内核缓冲区有数据可读

可写事件:内核缓冲区还有空间即 对方tcp窗口还没满。在IO多路复用情况下,两个Socket啥事没干,可写事件是会一直触发的

Redis:

  1. 一般地客户端的可读事件会一直注册在那个epoll wait里的,没处理事件会持续触发,避免数据丢失
  2. 而写事件,只有在输出缓冲区有数据时注册,完成后立马注销

主从复制

命令执行以及架构

1
2
3
4
5
6
7
8

​主节点设requirepass, 从节点设masterauth password

slaveof host port #当前节点成为某个节点的从节点

slaveof no one #解除复制

REPLICAOF #5.0以上对slaveof的替代

主从复制详细过程

场景说明

  1. 初次建立主从关系,必定是全量同步。(从节点会把本身旧的数据给删了)
  2. 全量同步完成后,主从节点进入命令传播阶段,此时主节点会将每个写命令同时发送给所有从节点
  3. 如果此时网络闪断导致主从连接断开,之后重新连接时,从节点会发送PSYNC命令携带之前保存的主节点runID 和 复制偏移量 (很像断线重连哦
  4. 主节点检查runID 是否匹配,且复制偏移量是否还在复制积压缓冲区范围内
  5. 如果条件满足,主节点只需要发送缺失的那部分命令即可,这就是部分同步

初次主从同步

(从)执行slaveof ip host

1
2
3
4
5
6
slaveofCommand()   ->  replicationSetMaster()

replicationSetMaster(){
//改个状态然后啥都不做
server.repl_state = REDIS_REPL_CONNECT;
}

(从)下一次事件循环 【建立tcp连接】

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
serverCron()  -> replicationCron()  ->  connectWithMaster()

replicationCron(){
if (server.repl_state == REDIS_REPL_CONNECT) {
connectWithMaster()
}
}

connectWithMaster(){
anetTcpNonBlockConnect()
aeCreateFileEvent(server.el,fd,AE_READABLE|AE_WRITABLE,syncWithMaster,NULL)
server.repl_transfer_lastio = server.unixtime;
server.repl_transfer_s = fd;

// 将状态改为已连接
server.repl_state = REDIS_REPL_CONNECTING;
}


(从)再下一次事件循环 【发送ping】

1
2
3
4
5
6
7
8
9
10
11
syncWithMaster(){
if (server.repl_state == REDIS_REPL_CONNECTING) {
// 手动发送同步 PING ,暂时取消监听写事件
aeDeleteFileEvent(server.el,fd,AE_WRITABLE);
// 更新状态
server.repl_state = REDIS_REPL_RECEIVE_PONG;
// 同步发送 PING
syncWrite(fd,"PING\r\n",6,100);
return;
}
}

(主)两个事件,回pong,同步从节点信息给客户端

1
2
readQueryFromClient()  ->    pingCommand()   ->  addReply()
sendReplyToClient()

5,(从)主服务器写数据 触发可读事件 ( 触发注册了的syncWithMaster)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
//2.8之前,Redis从节点断线重连 智能全量重同步

syncWithMaster(){
// 接收 PONG 命令
if (server.repl_state == REDIS_REPL_RECEIVE_PONG){
// 手动同步接收 PONG ,暂时取消监听读事件
aeDeleteFileEvent(server.el,fd,AE_READABLE);
syncReadLine()//读取pong
}
//auth 认证
sendSynchronousCommand(fd,"AUTH",server.masterauth,NULL);
//REPLCONF命令,获取master监听的端口
sendSynchronousCommand(fd,"REPLCONF","listening-port",port,NULL);

// 根据返回的结果决定是执行部分 resync ,还是 full-resync
psync_result = slaveTryPartialResynchronization(fd);

// 可以执行部分 resync
if (psync_result == PSYNC_CONTINUE) {
redisLog(REDIS_NOTICE, "MASTER <-> SLAVE sync: Master accepted a Partial Resynchronization.");
// 返回
return;
}

// 主服务器不支持 PSYNC ,发送 SYNC
if (psync_result == PSYNC_NOT_SUPPORTED) {
// 向主服务器发送 SYNC 命令
syncWrite(fd,"SYNC\r\n",6,server.repl_syncio_timeout*1000)
}
//到这里 psync_result == PSYNC_FULLRESYNC 或 PSYNC_NOT_SUPPORTED
// 新建临时文件, 用来接收传输过来的rdb文件
snprintf(tmpfile,256,"temp-%d.%ld.rdb",(int)server.unixtime,(long int)getpid());
//安装读事件处理器(readQueryFromClient变为 readSyncBulkPayload)指针函数的改变
aeCreateFileEvent(server.el,fd, AE_READABLE,readSyncBulkPayload,NULL)
// 设置状态
server.repl_state = REDIS_REPL_TRANSFER;
}

slaveTryPartialResynchronization(){
//组装参数
//PSYNC ? -1 完整重同步
//PSYNC runid offset 部分重同步
// 向主服务器发送 PSYNC 命令 (同步请求并等待)
reply = sendSynchronousCommand(fd,"PSYNC",psync_runid,psync_offset,NULL);
if(reply == "+FULLRESYNC"){
return PSYNC_FULLRESYNC;
}
if(reply == "+CONTINUE"){
// 安装 readQueryFromClient 或者 sendReplyToClient
return PSYNC_CONTINUE;
}
return PSYNC_NOT_SUPPORTED;
}

6,(主)收到 sync命令,进行准备

两种:全同步装备rdb文件,部分重同步 同步命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
"sync"/"psync" readQueryFromClient()  -> syncCommand()

void syncCommand(redisClient *c){
// 如果这是一个从服务器,但与主服务器的连接仍未就绪,那么拒绝 SYNC
if (server.masterhost && server.repl_state != REDIS_REPL_CONNECTED) {
addReplyError(c,"Can't SYNC while not connected with my master");
return;
}
if("psync"){
if(masterTryPartialResynchronization()){
return;
}
}

if (server.rdb_child_pid != -1) {
//正好有可用的rdb文件
}
else{
//fork子进程进行rdb
rdbSaveBackground();
}
//设置状态
c->replstate = REDIS_REPL_WAIT_BGSAVE_END;
// 添加到 slave 列表中
listAddNodeTail(server.slaves,c);
}

rdb执行完后,为从客户端安装写事件

1
2
3
4
5
6
serverCron()   ->   backgroundSaveDoneHandler()  -> updateSlavesWaitingBgsave()

updateSlavesWaitingBgsave(){
//遍历所有slave,如果slave状态为 slave->replstate == REDIS_REPL_WAIT_BGSAVE_END
aeCreateFileEvent(server.el, slave->fd, AE_WRITABLE, sendBulkToSlave, slave)
}

发送rdb 文件

1
2
sendBulkToSlave()  //主
readSyncBulkPayload() //从

超时和典型问题

默认60s从节点不回复,视为超时

REPLCONF ACK:从节点每秒上报偏移量,主节点检测延迟

典型为题:

数据不一致场景:

网络延迟,解决:监控master和slave的offset

从节点执行key * 阻塞,解决:避免在从节点执行慢查询

全量复制风暴:

导致主节点压力大,解决:错峰同步,级联更新

https://zhuanlan.zhihu.com/p/115912936

https://zhuanlan.zhihu.com/p/439770090

https://blog.csdn.net/u014453898/article/details/109811000

https://cloud.tencent.com/developer/article/2141900

https://zhuanlan.zhihu.com/p/614204046

进行一次IO的时候(非零拷贝),是需要经过两个阶段的,分别是

  1. 第一阶段:等待内核把数据准备,即等待数据搞好
  2. 第二阶段:把数据拷贝到用户态

从操作系统来看,NIO指的是非阻塞IO,而从Java来看,NIO指的是IO多路复用

阻塞IO

应用程序向内核发起IO请求,第一阶段也阻塞,第二阶段也阻塞,直到数据从内核空间拷贝到用户空间

非阻塞IO(NIO)

应用 程序向内核发起IO请求,第一阶段不会阻塞,会返回去干别的事情,然后定时轮询,如果某次询问到内核数据准备好了。那么这时候就会直接等待数据从内核态拷贝到用户态

即第一节点不阻塞,第二阶段不阻塞

IO多路复用

应用程序向内核发起请求,开始阻塞监听多个请求,如果内核收到数据了,就会主动来告诉应用程序说数据好了,然后应用程序向内核发起数据请求,等待数据从内核态拷贝到应用态。

IO多路复用分为三种

  • select:底层采用数组,最多监听1024个fd,即1024个数据来源。会将已连接的socket存放到文件描述符集合中。然后for(Socket socket:Sockets){ if(socket.isOk()) {开始处理事件}} 。如果for循环之后没有满足条件的Socket,那么内核将进行休眠。当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的内核进程,即在socket可读写时唤醒,或者在超时后唤醒。当检测有事件产生时,将此Socket标记为可读可写,接着将整个集合拷贝回用户态,应用程序再对其进行遍历找到可读or可写的Socket,然后对其进行处理。处理完数据后继续调用select对其进行监听,而此时又要重新设置一遍集合(读,写,异常事件集合,三个集合)
  • 这里两次遍历,一次用户态,一次内核态。还有两次文件描述符的拷贝,拷贝来 以及拷贝回去
  • poll 底层采用的是链表,没有连接数的限制,将实际发生的事件与关心的事件分开,但是呢没有解决遍历的问题,处理好数据后重置fd对应的revent即可
  • epoll,底层采用红黑树,可以准确通知应用程序,不需要像poll和select一样遍历了。调用一次epoll_ctl就好了,它会注册一个fd,一旦这个fd就绪的话,内核会采取回调机制,迅速去激活这个fd,当进程调用epoll_wati时就会得到通知

  1. 通过epoll_create创建epoll对象,此时epoll对象的内核结构包含就绪链表红黑树就绪队列用于保存所有读写事件到来的socket红黑树用于保存所有待检测的socket
  2. 通过 **epoll_ctl **将待检测的socket,加入到红黑树中,并注册一个事件回调函数,当有事件到来的之后,会调用这个回调函数,进而通知到 epoll 对象。
  3. 调用 epoll_wait 等待事件的发生,当内核检测到事件发生后,调用该socket注册的回调函数,执行回调函数就能找到socket对应的epoll对象,然后会将事件加入到epoll对象的绪队列中,最后将就绪队列(其实是epoll_wait的时候传给内核态的)返回给应用层。
    • 水平触发LT:有fd就绪时,重复通知直到数据处理完成,默认方式 (Redis采用的方式
    • 边缘触发ET:有fd就绪时,通知一次,直到下次有新的数据来,才会再次通知

第一阶段阻塞,第二阶段也阻塞,但是相比于阻塞IO就是能同时监听多个Socket,仍然会有阻塞现象,如果某个请求卡壳,会影响其他请求(单个selector的情况下)

信号驱动IO

应用程序向内核发送信号,然后就可以去做别的事,内核如果有数据,调用信号通知应用,应用程序收到信号后,向内核请求数据,等待内核将数据拷贝到用户空间完成,返回,处理数据。

第一阶段不阻塞,第二阶段阻塞,相比于NIO,不需要主动去轮询去问,而是由内核主动通知

异步IO(第二和第一都不阻塞)

应用程序向内核发送信号,然后就可以去做别的事,内核如果有数据,先将数据拷贝到用户空间,然后调用信号通知应用,应用程序收到信号后,可以直接处理数据!

相比于信号驱动IO,第二阶段不阻塞了,但现在技术还不是很成熟,支持的AIO的操作系统和框架也不多

传统IO模型

一对一(一个连接一个线程/进程)

每一个请求到来时,大致都会按照:请求读取->请求解码->服务执行->编码响应->发送答复 这个流程去处理。

Reactor模型

Reactor:负责监听和分配事件,将I/O事件分派给对应的Handler。新的事件包含连接建立就绪、读就绪、写就绪等。

Acceptor:处理连接事件

Handler:执行非阻塞读/写事件

单reactor单线程

单reactor多线程

主从reactor多线程

主:负责创建连接请求

从:负责处理业务请求

操作系统为每个进程都分配独属于进程自己的 虚拟内存,人人有份,互不干涉

操作系统会提供一种映射机制,将虚拟内存地址 映射成 硬件上的实际物理内存地址

当程序要访问虚拟内存地址时,由操作系统转成物理内存地址这样就可以防止说你直接使用物理内存地址,导

致多个进程之间物理地址冲突。

于是我们就引申出两个概念

1.程序使用的内存地址叫虚拟内存地址

2.实际硬件里的空间地址叫物理内存地址

操作系统引入了虚拟地址,进程持有的虚拟内存地址会通过cpu里的MMU(内存管理单元)来转化成物理地址,然后进程再通过物理地址来访问内存

PS:其实就相当于引入了一中间层来协调防冲突,类似中介

32位操作系统内核态虚拟内存的前896内存是直接映射到物理内存的前896m区域的,是一对一映射

且这种映射是不会改变的

操作系统提供两种方式来管理虚拟内存和物理内存的关系,内存分段和内存分页

内存分段

:::color4
程序是由若干个逻辑分段构成的,比如由代码分段,数据分段,栈段,堆段组成。不同段是有不同的属性的,所以就用分段的形式把这些段分离出来

你可以把内存分段想象成一本书:

  • 书的目录分为 序言、正文、附录(这就是分段)。
  • 每一段(章节)都有一个起始页码(段基址)和页数范围(段长度)。
  • 你要找“正文第 5 页”,其实就是 正文起始页码 + 5

分段的缺点:

  1. 内存碎片的问题
  2. 第二个就是内存交换的效率低的问题

内存碎片主要分成外部碎片和内部碎片

分段内存管理可以做到根据段的大小分配合适的内存大小,所以不会出现内部内存碎片

但是由于每个段的内存不一样,有大有小,所以多个段未必能恰好使用到所有的内存空间,会产生多个不连续的物理内存空间,导致新的程序无法转载,所以会出现外部内存碎片的问题

总结就是随着段的申请和释放,会导致你的内存空间东一块西一块,新的程序来的时候需要一整块连续的,无法满足

解决外部内存碎片的方案就是 内存交换

内存交换就是指当你新进程来的时候,会把暂时没有用的内存数据搬到硬盘,腾空间给新进程用,然后等到那些搬到硬盘的数据需要用的时候再搬回来,搬回来的时候不一定得放到原始的位置上,而是会先进行内存紧缩,让内存空间变得连续

这个内存交换空间,在Linux中,就是我们常说的 swap空间,Linux专门在硬盘上划出这么一块空间,来进行内存与硬盘交换

:::

内存分页

:::color4
分页是指把虚拟和物理内存分为一段段固定内存的大小。这样一个大小固定且连续的内存空间我们就称之为页(好像是这样的哦,比如你书本的页,就是连续且大小固定的。但段就不一定了)。在Linux下,每个页的大小为 4kb

页表是存储在内存中的,而MMU就做这么一个工作,将虚拟地址转成物理地址

问题1:当进程访问的虚拟地址在表中查不到会怎么样

这时候系统会产生一个缺页的表现,会从用户态切成内核态,然后进行物理内存的分配,页表的更新。最后在返回用户态,恢复进程的运行

分页可以让我们程序在加载时不需要一次性把程序加载到物理内存,而是可以在处理好物理内存和虚拟内存之间的映射关系后,并不真的把页加载进去,而是在程序运行时,需要用到虚拟页中的指令和数据时,在把它加载到物理内存里去。

在分页机制下,虚拟内存里包含 虚拟页号和业内偏移。虚拟页号作为页表的索引,页表里又包含虚拟页号

和物理页号,这个物理页号和页内偏移量就构成了物理地址

在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) 个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储页表。

这 4MB 大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表

那么,100 个进程的话,就需要 400MB 的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。

多级页表

我们把这100多个页再分页,将一级页表分为1024个二级页表,每一个二级页表里包含1024个页表项

且如果某个一级页表的页表项没有被用到时就不去创建二级页表,大大节省了内存空间,当然这是建议在内存使用是稀疏的情况下

即一级页表有1024个页表项,每个页表项对应了4MB的内存空间,总共是4GB,如果一个程序只需要用400MB内存空间,只需要4KB(1024 * 4字节)(一级页表) + 100 * 4KB(二级页表) = 0.395MB的页表去存储,这比之前的4MB好多了

为什么不分级的页表就做不到内存节约呢?

我们从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了(中断了,cpu切到内核态,程序不能运行)。

原因是页表需要覆盖所有的虚拟内存空间,而如果不分级,那么所使用到的内存空间是非常大。那么这时分级的话,只需要1024个页表项(此时页表已经覆盖所有虚拟内存地址了,二级页表需要时再创建)

:::

段页管理

:::color4
内存分页和内存分段并不是对立的,我们可以吸取两者的优点

:::

为什么要使用虚拟内存

  • 虚拟内存可以使得运行时内存超过实际内存的大小,因为程序的运行符合局部性原理,cpu访问内存会有明显的重复性访问,那么就可以把那些暂时没用的内存数据都放到硬盘上
  • 由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相对独立的。进程也没法访问其他页表,这解决了多进程下的地址冲突问题
  • 页表里的页表项了除了物理地址外,还会有写比特位用来标识一些属性,比如控制页表的读写权限,标记该页是否存在。在内存访问方便,操作系统提供了更好的安全性

进程可以理解为一个动态的程序,进程是操作系统进行资源分配的基本单元

比如电脑上的QQ就是一个进程,微信也是

线程是操作系统进行调度的基本单位,进程占一个虚拟内存空间,而进程内的线程可以共享进程内的虚拟内存

线程的粒度更小,比如微信可以有多个线程,一个负责拉消息,一个负责发消息,一个负责下载文件

协程可以理解为用户态的线程,和线程的区别

  • 内存占用更小,大概有2k,且可以动态扩容。而线程有2m,线程是操作系统管理的实体,包括时间片,cpu,线程栈之类的,它占用的资源就比较大么。而协程不归操作系统管理,因为占用的资源就比较少
  • 上下文切换开销小,无需在用户态和内核态之间切来切去。线程的切换就需要保存和回复想线程上下文,需要耗费一定的时间和资源。而协程的保存就只需要保存栈帧之类的东西,因此成本是要比线程低的
  • 线程是由操作系统调度的,而协程则是由程序员控制,可以在不同任务之间来回切换,不需要等待操作系统调度
  • 线程是面向操作系统的,而协程是面向任务的。线程需要使用操作系统提供的api进行线程之间的通信和同步。而协程则可以使用语言级别的协程库进行协作式多任务

进程是怎么切换的

进程切换的步骤有几个。

第一个步骤是中断处理。也就是说进程切换其实是依赖中断来触发的,而后进入中断处理,开始执行切换的代码。

第二个步骤是保存当前进程的上下文。

第三个步骤是根据进程调度策略,选出下一个进程,这里叫做新进程。

第四个步骤是标记新老进程的状态。

第五个步骤是切换虚拟地址空间。

第六个步骤恢复新进程的上下文空间。

最后则是跳转到新进程的代码,开始执行。

简述

中断处理,保存上下文,选择新进程,切换与恢复

引导

进程上下文;虚拟地址空间切换;

从进程切换的这些步骤也能看出来,它的性能是比较差。

而作为对比,线程切换虽然看上去步骤也是类似的,但是实际上就要轻量很多。首先,线程切换不涉及虚拟地址空间切换,那么因为虚拟地址空间切换带来的各种缓存失效等问题都没有;其次,线程之间是共享很多资源的,如文件句柄等,这些共享资源在线程切换的时候都不需要处理。

总之,线程切换比进程切换快,主要是因为线程共享地址空间和资源,减少了地址空间切换和资源管理的开销。这在多线程编程中提供了更高的并发性和性能

为什么虚拟内存地址切换这么慢

进程都有自己的虚拟地址空间,因此虚拟地址空间切换一般就意味着进程切换了。

整个切换过程之所以慢,有很多因素。

第一个是触发用户态和内核态切换。一般来说,虚拟地址空间切换,都会涉及到用户态和内核态的切换的,因此虚拟地址空间是被内核管理的;

第二个是要重新加载 TLB。新的虚拟地址空间导致 TLB 缓存失效,所以需要重新加载新的页表项到 TLB 中;

第三个是页表切换,主要是更新 CPU 中页表基址寄存器,指向新的页表;

第四个是有可能触发 IO 操作,因为新的虚拟地址空间的内容可能在交换区上,需要重新加载进来内存中。

这几个步骤叠加就导致了虚拟地址空间切换是一个比较慢的过程。那么相比之下,同一个进程内的线程都是共享虚拟地址空间的,所以就不会触发虚拟地址空间切换,因此线程切换效率高很多。

进程状态

进程一共有 5 种状态,分别是新建、就绪、运行、阻塞和终止。

其中运行状态就是进程正在 CPU 上运行。

就绪则是说进程已处于准备运行的状态,万事俱备,只欠 CPU 了。

而阻塞状态就是进程正在等待某一事件而暂停运行,比如等待某资源为可用或等待 I/O 完成。即使CPU 空闲,该进程也不能运行。

在这五种状态中有四个比较关键的转换。

第一个是就绪态到运行态,这一般是因为进程被调度了,获得了 CPU 时间片;

第二个是运行态到就绪态,这一般是因为 CPU 时间片耗尽了;

第三个是运行态到阻塞态,这一般是因为进程执行了一些会引起阻塞的操作,最典型的就是 IO 操作;

第四个是阻塞态到就绪态,这一般是因为达成了唤醒条件,例如说 IO 操作完成了;

除了这五种状态以外,还有一种很特殊的状态,也就是所谓的僵尸进程。

当一个进程已经执行完毕并退出时,它本应该被操作系统完全清理掉,但如果没有正确地被清理,它就会变成僵尸进程。例如说在子进程终止后,但其父进程没有正确地调用wait()或waitpid()系统调用来获取子进程的终止状态。这样一来,子进程虽然已经停止运行,但其在进程表中的条目仍然存在。

简单来说,可以认为僵尸进程只会占一条进程表中的条目,但是如果僵尸进程太多了,导致这个进程表满了,那么操作系统就无法继续创建新的进程了。

进程同步

进程之间的同步方式有很多。

第一种是共享内存。也就是多个进程共享同一块内存区域,实现高效的数据交换,但是一般要配合别的同步机制来保护共享内存,避免出现并发问题;

第二种是管道。管道可以认为是一种数据结构,可以在进程之间传递数据,多用于父子进程之间;

第三种是信号量,它可以控制多个进程对共享资源的访问数量,用来实现互斥和同步,允许多个进程按照一定的顺序访问资源;

第四种是信号,用于通知进程某个事件的发生,是一种轻量级的异步通知机制;

第五种是文件锁,即通过锁定文件或文件的某一部分来实现进程间的同步;

那当然,类似锁、原子操作的也可以用来同步。

公司内部其实你做到三个9就已经很无敌了,99.9%,但你出去面试一定得说99.99%

你怎么算出来四个9的,如果你在公司待了一年,可以说去年计算了一下完全不可用大概有30分钟,可用性高

达 99.99%

为什么半小时就完全不可用,根据公司通报是运维把网络防火墙搞崩

网关节点的负载均衡

即解决客户端应该连哪个网关的问题

现实里的做法:DNS够了

面试:配置网关节点HTTP的信息

负载均衡的策略,最小连接数

再均衡

某个网关节点,比如华南节点,因为赛龙舟,然后这个节点上20000请求,且有些请求比较长就占着不走

那这时候就需要再均衡

  1. 判定什么时候需要再均衡
  2. 谁来发起再均衡
  3. 再均衡的过程是怎么样的?

谁判断?谁发起?

一个是客户端判定 客户端发起

网关节点判断,网关节点发起

客户端判定,客户端发起

基于消息延迟,借助滑动窗口时间来统计延迟,计算平均延迟。

(高并发情况下,使用RingBuffer来实现滑动窗口算法)近期几次消息的延迟,显著高于平均延迟,就说明网关节点高负载

基于错误率,借助滑动窗口,连续错误 + 错误率 > 阈值

客户端怎么再均衡?

断开连接,选择一个新节点然后连上

如果客户端发现全部节点都高负载?

后端扩容

websocket降级到普通http 请求

跨区域连接

但其实你只要监控和自动扩容做好,就不会有这种问题的了

网关节点怎么判定?怎么发起

判定:

1.自身的连接数

2.吞吐量

3.自身资源,cpu,内存等

怎么再均衡

1.分批迁移:网关节点主动发消息,然后客户端收到就要执行再均衡,换一个节点重连

2.网关节点可以主动发那会一个可用节点列表

如果网关需要抗每秒10万的高并发访问,你应该怎么对网关进行生产优化?

生成情况下一定得 集群化的

8c16g,对网关路由转发的请求,每秒抗1000+是不成问题的,1000台zuul网关机器

高配32c64g,每秒抗个小几万没问题的,几台zuul网关机器是没问题的

【限流层限制QPS带来的问题和替代方案】https://www.bilibili.com/video/BV1pVyGYZEob?vd_source=cae07b1dce3e6abe67fcf72c43031ede

本文主要谈谈Kafka用于实时数据通道场景的缺陷,以及如何在架构上进行弥补。

Kafka归属于消息队列类产品,其他竞品还有RabbitMQ、RocketMQ等,总的来说它们都是基于生产者、中介和消费者三种角色,提供高并发、大数据量场景下的消息传递。Kafka诞生自Hadoop生态,与生态中的其他组件具有更好的亲和性,在实时数据场景中往往是首选。随着数据实时应用的需求高涨,Kafka作为构建实时数据通道的核心组件,得到了广泛的应用。

Kafka本身不介入消息内容,需要生产者和消费者事先约定某种通讯契约(包括序列化框架和数据结构两部分)来编码和解码消息内容。这个通讯契约由参与双方系统约定而成,双方是对等关系,一旦发生变化需要双方重新协商。

对于消息队列场景,上述机制完全没问题。但在实时数据场景下,数据往往由生产侧CDC工具以抓取数据库的方式产生,那么通讯契约中的数据结构部分直接采用了生产系统的表结构,即由生产侧系统单方面定义的,对下游具有强制性。而且,当生产系统的表结构变化时,下游也不得不适配全表结构的变化,即使只需要部分字段的数据。可见,实时数据场景下,下游系统完全是从属关系,产生了大量冗余工作量。另外,表结构变更传递到下游系统,并没有自动化机制,容易产生时间延迟和沟通误差等问题。

Kafka作为一个实时数据的汇集点,并不能对上述两个问题进行有效控制,也就是本文所说的缺陷。

关于解决方案,首先是在Kafka上增加元数据管理模块,在实践中我们选择了Schema Registry,由confulent开源的元数据管理工具。整体架构如下图所示

每个topic都有schema,且随着topic中数据结构的变化,schema会产生多个版本,每个版本的schema具有全局唯一id。一条完整的消息就由schema id和data两部分构成,在消费端读取消息时可以根据id找回schema,进而解析消息。

可见,引入SR后系统具备了在Kafka通道中获取上游系统表结构继而解析消息的能力。当表结构发生变化时,CDC工具会自动推送schema给SR。市场上主流的CDC工具,如Oracel Golden Gate(OGG),已经提供了对Schema Registry的适配。

这样,我们解决了schema在上下游之间自动更新同步的问题。

在此基础上,我们又增加了对表结构的裁剪能力,即可以基于不同下游系统的需求对同一个topic进行差异化的读取字段内容。而裁剪后,也就形成了一个上下游对等关系的契约,降低了下游系统的无效耦合,从而消除了冗余工作量。更重要的是,裁剪的过程是零编码的,仅在交互界面上点选操作即可。这个裁剪工具并没有找到开源实现版本,所以我们自己进行了研发,取名为schema manager。

最后,我们基于schema registry和schema manager,开发了自适应的消息解析程序,封装为SDK。这样下游系统只需要按照SDK接口(兼容Kafka原生接口)订阅消息,即可完全屏蔽掉无关的上游变更内容,对上述一套实现机制完全无感。

最后,简单总结下答案,实时数据通道的四个能力:

  • Kafka的消息队列能力
  • 与生产侧打通的schema自动更新和管理能力
  • 面向消费侧需求的schema裁剪能力
  • 自适应schema变更的解析能力

通过这样的实时数据通道,上下游系统恢复到了对等通讯关系,基本清除了下游的冗余工作量。

https://app.diagrams.net/#G1ZI0SeLxjvF7EnIbyF7G0HyBjzioMod6b#%7B%22pageId%22%3A%22C5RBs43oDa-KdzZeNtuy%22%7D

Redis 3.0

由上图可以看到,Redis从整体来看其实就是一个while死循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
while(true){
//阻塞,直到被唤醒
//文件处理器
events = epoll_wait()
//处理每一个事件
for(event: events){
//这里的读事件是指 从网卡读 要拿取数据了
//这里的写事件是指 往网卡里写 要发送数据了
if(读事件){
//从socket缓冲区拿出数据
readQueryFormClient() ;
// while 循环处理 客户端缓冲区 只要构成一个完整的Redis协议命令
//在这也是pipeline机制实现原理
processInputBuffer();
//容易阻塞点!!!
//1、检查命令合法性,参数合法性
//2、客户端是否认证,集群转向ask、moved
//3、是否maxMemory,slave节点拒绝写命令
//4、订阅模式命令,发布模式命令
processCommand();
//1、执行命令 ---> 执行结果放到输出缓冲区,然后注册可写事件
// 什么是可写事件:客户端tcp窗口没满,就是可写
//2、慢命令 --> show log
//3、propagate Command --> feedAppendOnlyFile 把命令写入AOF缓冲区
// --> rePlicationFeedSlave 把命令发送给从服务器
call()
//epoll:水平触发,边缘触发。Redis这里属于水平触发
//epoll的坑
/**
水平模式:多次触发
边缘模式:一次没触发后就不会触发了
*/

}
else if(写事件){
/**
输出缓冲区:
buf静态缓冲区,固定大小
replay动态缓冲区,大响应时用
*/
//将输出缓冲区里的数据write回 客户端的socket(就是网卡缓冲区)
//如果write出错了,就返回(如果tcp窗口满了)
//如果缓冲区写完了,那么就卸载可写事件
sendReplyToClient()

}
}
//时间处理器
severcorn(100ms); //低频处理

//每次循环都会执行,跟epoll_wait() 被唤醒的频率有关
beforeSleep()
}
severcorn(){
watch dog 机制
更新信息 时钟 内存峰值 rss内存 关闭信号
客户端操作(超时,缓冲区)
清理过期件,渐进式hash
(易阻塞)重写aof rdb
辅助性aof落盘
客户端异步关闭
主从操作
集群操作
sentinel操作

}

beforeSleep(){
过期键清理
副本ack同步
(易阻塞)aof缓冲区落盘
集群维护 故障转移
}

aof和rdb调用关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 客户端的命令
lastsave
saveCommand -> rdbSave()
bgsaveCommand -> rdbSaveBackground() -> rdbSave()
bgrewriteaofCommand -> rewriteAppendOnlyFileBackground() fork子进程 -> rewriteAppendOnlyFile() 读取内存数据到临时文件

//低频调用
serverCron(){
//如果 BGSAVE 和 BGREWRITEAOF 都没有在执行,并且有一个 BGREWRITEAOF 在等待
if (server.rdb_child_pid == -1 && server.aof_child_pid == -1 && server.aof_rewrite_scheduled) {
rewriteAppendOnlyFileBackground(); //手动触发才会走到这里来
}
if (server.rdb_child_pid != -1 || server.aof_child_pid != -1){
backgroundSaveDoneHandler() //
backgroundRewriteDoneHandler()
}else{
//BGSAVE 和 BGREWRITEAOF 都没有在执行
rdbSaveBackground() // save 60 10000 参数格式,及默认
rewriteAppendOnlyFileBackground()
}
}

//高频调用
beforeSleep(){
//aof刷盘
flushAppendOnlyFile(0); //server.aof_buf 写到文件
}

aof

aof包含刷盘和重写两大块

aof 流程及刷盘

每执行一个redis写命令,会存到aof缓冲区。然后在文件事件结束后,依赖** beforeSleep()** 函数执行写入动作。

写命令 -> aof 缓冲区 -> write数据 -> 刷盘策略

flushAppendOnlyFile() 函数被 beforeSleep() 函数高频调用,每次调用都会调用 write函数(主线程执行),然后根据策略刷盘。

aof_fsync 的含义,每次都会调用write函数,但是刷盘时机可以配置

always 模式,也可能丢失数据,而且高频的调用 aof_fsync 会导致redis吞吐量下降。

PS:为什么高频调用会导致Redis,因为always模式下flush是在主线程,而不是跟everysec一样是异步线程

everysec 模式下,数据丢失风险 不止1秒!若刷盘上次刷盘不及时,或者文件事件执行过长(高并发时,且有阻塞性的大key导致)

no 依赖操作系统刷盘

思考:如果设置的刷盘策略为每秒,为什么write数据还是在主线程,而刷盘在异步线程。为什么不把write也放到异步线程呢?

:::color4
1.write是轻量级的,就是将用户态的数据拷贝到page cache,而flush需要等到IO事件结束,耗时长,更适合异步化

2.write的时候涉及 aof_buf,如果是异步的话,在多线程情况下,就需要引入锁机制

:::

重写aof流程

为什么

手动

自动重写aof时机

:::color4
此时没有进行rdb和重写aof(判断是否存在子进程)

aof当前体积 > 最小重写阈值

配置了自动重写百分比,且现在的体积 较于上次aof体积 增长率 大于 配置的值

:::

aof重写逻辑流程

:::color4

  1. fork 一个子进程 ,记录fork子进程时间,关闭字典rehash
    子进程:

1.关闭网络连接

2.创建临时文件名 tempfile = temp-rewriteaof-bg-%d.aof**** ,这里就是相当于在内存里创建了一个字符串

3.创建临时文件temp-rewriteaof-%d.aof

4.遍历所有db,写入键值对以及过期信息

5.temp-rewriteaof-%d aof 原子改名为 temp-rewriteaof-bg-%d.aof

6.unlink temp0-rewriteaof-%d.aof

//unlink:删除文件名 file1.txt。 将 inode 的链接计数器减 1,变为 1。

7.向父进程发送退出信号

  1. 在n个循环后子进程执行完毕,打开temp-rewrite-bg-%d aof
  2. 遍历aof重写缓冲区,数据追加到 temp-rewrite-bg-%d.aof
  3. 打开appendonl.aof,temp-rewrite-bg-%d.aof 改名为 appendonly.aof
  4. 更新Server aof_filename 等于tmpfile的 fd引用,并刷盘
  5. 异步关闭旧的aof文件fd

:::

伪代码:

if(xxx = fork() == 0){

}

else{

//关闭网络连接

}

整个过程中会有三个文件

appendonly.aof

temp-rewrite-bg-%d aof

temp-rewrite-%d aof

Redis异步线程

rewriteAppendOnlyFileBackground() -> bioCreateBackgroundJob()

redis3.0 使用异步线程的地方:

1,异步刷盘( fsync)

2,重写aof时,异步关闭文件

版本 关键异步任务 技术实现 演进意义
Redis 3.0 AOF持久化(fsync) bioCreateBackgroundJob创建后台线程 首次引入多线程,解决fsync阻塞主线程问题
Redis 4.0 大Key异步删除、数据库清空(UNLINK/FLUSHDB ASYNC) 独立BIO线程池(3个线程:关闭文件、AOF刷盘、惰性删除) 主线程与耗时操作解耦,提升稳定性
Redis 6.0 网络I/O读写、协议解析 I/O线程组(可配置数量) 网络吞吐提升300%,单节点性能突破20W QPS
Redis 7.0 RDB生成、AOF重写、集群任务分发 动态线程池(负载自适应) 优化后台任务并行性,减少尾延迟65%
Redis 7.4+ 哈希字段过期、向量数据处理(AI优化) 字段级异步过期线程

rdb流程

自动处理的默认配置

save xx xx

save xx xx

save xx xx

只要满足了以上一个就触发

1,记录开始的时间,fork子进程

子进程:

1,关闭网络fd

2,创建临时文件 temp-%d.rdb

3,遍历16个db的所有数据,写入到临时文件,刷盘并关闭fd

4,把临时文件重命名为dump.rdb

5,向父进程发送信号

2,计算fork所花时间,记录子进程id,关闭自动rehash

3,在n个循环后,收到子进程信号,更新一些信息

4,处理正在等待 BGSAVE 完成的那些 slave

cow 和 fork

cow内存计算逻辑

1个g,fork子进程之后 子进程和父进程同一块物理内存,总内存还是一个g,父进程还会写

1g内存

–> fork 子进程后,因为共享

还是1g内存

–> 父进程修改一遍 全部内存后 父进程持有1g内存

–> 子进程修改一遍 全部内存后 子进程持有1g内存

1 + 1 + 1=3?

但好像不是

共享内存都很小了

fork的内存计算问题

fork阻塞问题

主进程fork子进程本身也是有延迟的。父进程fork 超过5g内存,会有一定延迟,超过200ms

fork性能问题堪忧

LostPigxx

很好奇一点,fork对开源redis可以说是阿喀琉斯之踵了,我看antirez在twitter上也说尽可能的利用sharding来规避这个问题。
从技术上真的没办法解决fork的影响吗?

2020-12-24

logoV5

其实解决fork影响是os内核要做的,甚至os内核认为这点影响是理所当然,在它看来未必要做啥优化。
而使用者是应该认识到并避免它的。Redis之所以无法避免,还是因为它本身是个缓存,又想做持久化。

2020-12-24