C++语言
1. new和malloc的区别
最主要的区别:
1、 类型不同:
malloc是标准库函数,new是C++的运算符
2、会不会调用构造函数:
malloc在创建时不会自动执行构造函数,在free时不会调用析构函数
3、自动计算空间:
new会自动计算所需要的内存空间,malloc要手动计算字字节数
4、 返回类型
new成功分配内存后,返回具体类型的指针。malloc成功分配内存后返回void类型指针,需要强制类型转换成实际的指针类型。
5、 函数重载
new分为两步完成:1、new操作,2、new构造,new操作对应于malloc,但new操作可以重载,从而实现自定义内存分配策略,例如:不做内存分配,或者分配到非内存设备。malloc不能重载。
2. 惊群(鲸群)效应
惊群效应(thundering herd)是指多进程(多线程)在同时阻塞等待同一个事件的时候(休眠状态),如果等待的这个事件发生,那么他就会唤醒等待的所有进程(或者线程),但是最终却只能有一个进程(线程)获得这个时间的“控制权”,对该事件进行处理,而其他进程(线程)获取“控制权”失败,只能重新进入休眠状态,这种现象和性能浪费就叫做惊群效应。导致 CPU 像个搬运工,频繁地在寄存器和运行队列之间奔波,更多的时间花在了进程(线程)切换,
如何解决:
分为两种情况:
- accept惊群
- epoll惊群
2.1 accpet惊群
其实在linux2.6版本以后,linux内核已经解决了accept()函数的“惊群”现象,大概的处理方式就是,当内核接收到一个客户连接后,只会唤醒等待队列上的第一个进程(线程),所以如果服务器采用accept阻塞调用方式,在最新的linux系统中已经没有“惊群效应”了
2.2 epoll惊群
如果多个进程/线程阻塞在监听同一个监听socket fd的epoll_wait上,当有一个新的连接到来时,所有的进程都会被唤醒。
解决办法是通过加互斥锁。
C++如何定位段错误
段错误:
- 在某个函数内开的数组过大,导致该函数的栈无法容纳数组,造成爆栈。
- 解决方法:把数组保存为全局变量
- 指针越界。
如何定位段错误:
利用core dump 事后调试,可以快速定位段错误
- 查看系统 查看系统是否有对 core 文件的限制
ulimit -a
- 一般情况下都有限制,使用
$ulimit -c unlimited
,解除限制 - 用g++编译:
g++ -g
,然后执行程序 - 然后当前目录下会多core.* 的文件
- 接着用gdb调试该程序,当运行到段错误时,会定位到该行附近
虚函数如何实现多态(虚函数表)
虚函数表:是一个指向函数地址的指针数组,存储了该类中所有的虚函数地址。
虚函数表中保存着该类的所有虚函数的入口地址。
- 如果子类没有重写了父类的虚函数,那么子类的虚函数表保存的就是父类的虚函数入口地址。
- 如果子类重写了父类的虚函数,那么子类的虚函数表保存的就是自己本身的虚函数入口地址。
基类如果存在虚函数,那么在创建子类对象中,除了成员函数与成员变量外,编译器就会自动生成一个指向 该类的虚函数表(这里是类ClassB) 的指针,叫作虚函数表指针。通过虚函数表指针,父类指针即可调用该虚函数表中所有的虚函数。
如果一个类中有虚函数,那么该类就会有一个虚函数表。这个虚函数表是属于这个类的,而不是属于这个类的对象的,所以所有该类的实例化对象中都会有一个虚函数表指针去指向该类的虚函数表。
注意:虚函数表的出现只是为了用于多态。
所以,虚函数如何实现多态?
实现的原理是,编译器会在子类的虚函数表中查找该虚函数对应的地址,并在运行时根据实际指向的对象来调用相应的虚函数。
虚函数表保存在哪?
假设有一个基类ClassA,一个继承了该基类的派生类ClassB,并且基类中有虚函数,派生类实现了基类的虚函数。
我们在代码中运用多态这个特性时,通常以两种方式起手:
(1) ClassA *a = new ClassB();
(2) ClassB b; ClassA *a = &b;
以上两种方式都是用基类指针去指向一个派生类实例,区别在于第1个用了new关键字而分配在堆上,第2个分配在栈上。
请看上图,不同两种方式起手仅仅影响了派生类对象实例存在的位置。
以左图为例,ClassA *a是一个栈上的指针。
该指针指向一个在堆上实例化的子类对象。基类如果存在虚函数,那么在子类对象中,除了成员函数与成员变量外,编译器会自动生成一个指向该类的虚函数表(这里是类ClassB) 的指针,叫作虚函数表指针。通过虚函数表指针,父类指针即可调用该虚函数表中所有的虚函数。
引用和指针有什么区别
- 本质:在底层上看,其实两者应该是没有区别的,引用和指针对应的都是指向某个地址,但在实际语言特性上看又是有区别的。
- 能否为空:指针可以为空;而引用不能为NULL且在定义时必须初始化。
- 能否改变指向:指针在初始化后可以改变指向,而引用在初始化之后不可再改变
- 大小:指针的大小是指针本身的大小,4个字节;引用的大小是所指向的变量的大小,因为引用只是一个别名而已。
- 参数传递:当把指针作为参数进行传递时,是将一个实参传的一个拷贝传递给形参,两者指向的地址相同,但不是一个变量,在函数中改变这个指针变量不影响实参;而改变引用就会影响本身。
- 引用的本质:引用的本质就是带有const的指针。
- 安全性:引用比指针更安全。由于不存在空引用,并且引用一旦被初始化为指向一个对象,它就不能被改变为另一个对象的引用,因此引用很安全。对于指针来说,它可以随时指向别的对象,并且可以不被初始化,或为NULL,所以不安全。
总而言之——它们的这些差别都可以归结为"指针指向一块内存,它的内容是所指内存的地址;而引用则是某块内存的别名"。
虚构造函数与虚析构函数
先说结论:构造函数不可以是虚函数,而析构函数可以且常常是虚函数。
构造函数
构造函数可以是虚函数吗?
虚函数的调用是通过虚函数表来查找的,而虚函数表由类的实例化对象的虚函数表指针指向,该指针存放在对象的内部空间中,需要调用构造函数完成初始化。如果构造函数是虚函数,那么调用构造函数就需要去找vptr,但此时vptr还没有初始化!所以构造函数不能是虚函数。
析构函数
父类的析构函数可以且常常是虚函数的原因
当使用多态(前提)时,即父类指针指向子类对象;如果父类析构函数不是虚函数时,当delete时,父类指针只会调用父类析构函数,不会调用子类析构函数。 因此需要将父类的析构函数变为虚析构函数,以此才调用子类的析构函数。
父类的析构函数可以是纯虚函数吗?
答案是可以,这样做的目的是避免实例化对象。
基类析构函数虽然可以声明为纯虚,但是仍必须实现析构函数,否则派生类无法继承,也无法编译通过。
C++如何调用C语言
extern 'c'
要在C++程序中调用C语言编写的函数,可以使用extern "C"语法,它告诉编译器使用C语言的命名和调用规则来编译链接代码。
为什么要把函数的不会改变值的引用类型的形参定义为常量类型
把函数不会改变的形参定义为普通引用是一个比较常见的错误,如下例
void f1(string & a){}; //错误
void f2(const string & a){}; //正确
int main
{
f1("abc"); //错误
f2("abc"); //正确
}
需要把函数不会改变值的引用类型的形参定义为常量的原因是
- 可以传入右值,例如:“abc”, 1
- 可以传入const引用对象
- 其他函数将他们形参定义为常量引用,那么如果某个函数没有把引用类型定义成常量那将无法在其他函数中使用。
必须需要使用初始化列表来初始化数据的情况
- 在初始化成员变量是对象的情况,并且该对象没有无参构造函数的情况
- 初始化类内引用或者const的成员数据
- 对于继承自父类的成员对象,并且父类没有无参构造函数的情况,也要使用初始化列表初始化
内联函数
引入内联函数的目的是为了解决程序中函数调用的效率问题,这么说吧,程序在编译器编译的时候,编译器将程序中出现的内联函数的函数名用内联函数的函数体进行替换,而对于其他的函数,都是在运行时候才被替代。这其实就是个空间代价换时间的i节省。所以内联函数一般都是1-5行的小函数。在使用内联函数时要留神:
- 在内联函数内不允许使用循环语句和switch语句;
- 类声明处实现的普通成员函数默认都是内联函数
不过inline修饰的函数,只是一个对编译器的建议,要不要做成内联函数,实际还要看编译器自己判断。
C++深拷贝与浅拷贝
浅拷贝和深拷贝其实是针对拷贝对象存在指针成员的情况而言的,当存在指针成员而且浅拷贝发生,就会使得指针被拷贝一份但指针指向内容没有拷贝,也就是它们指向的内容是同一份,会存在内存释放时造成内存泄漏的风险:两个对象被释放调用两次delete,而实际指向内容只有一份而程序崩溃。深拷贝就是基于这种情况,把指针指向的内容也拷贝了一份,一个类要实现深拷贝就要实现拷贝构造函数。
C++有几种多态
多态共分为两种
- 静态多态:也称编译时多态,在编译确定的多态关系所以是静态多态。实现的形式是:运算符重载和函数重载。这些函数名字一样,但传入参数和返回参数不同,就会出现一个函数,不同传参就会实现不同的动作/效果,(原理)这种实现是编译器给后续命名上进行加工实现的,但对于程序员而言,它们还是一个名字。
- 动态多态:运行时多态,函数的调用地址不能在编译期间确定,只有在运行时才能确定。实现机制是虚函数。当父类指针或者引用所指向的对象不同时,父类本身或者重写了父类的虚函数的子类时,那么就会实现多态。(原理)这种实现是利用虚函数表,保存了该类的所有虚函数的入口地址,再通过虚函数表指针,父类指针或者引用即可调用该虚函数表中所有的虚函数。
类模板和模板类
类模板其实就是被template修饰的类,他的数据成员的类型以及返回值都是不确定的,这种类是一个模板,你可以传入符合要求的数据类型,从而实例化一个具体类;而模板类就是类模板实例化的一个具体类。
什么函数不能声明成虚函数
- 普通函数:虚函数是针对类而言的,把普通函数声明成虚函数没有意义
- 构造函数:看前面
- 内联函数:内联函数是编译时确定,在编译时就替换了调用处的函数名,而虚函数是运行时才确定,所以没办法声明成虚函数
- 友元函数:C++不支持友元的继承,并且友元函数不算类的成员函数。
传递不定长参数列表
- 实参类型相同:可以使用initilizer_list,initilizer_list里的值是常量值(const),无法改变initilizer_list里的值
- 实参类型不同:可变参数模板
- 省略符:一般用于与c语言函数交互的程序
C++的四种类型转换
1. static_cast
- 用于基本数据类型之间的转换。
- 以及将子类转为父类(安全)。
- 不可以用于父类转子类(没有检查功能,不能判断是否安全)
2. dynamic_cast
dynamic_cast:用于将父类的指针(或引用)转换成子类的指针(或引用)。
向上转型: 子类的指针(或引用)→ 父类的指针(或引用)。
向下转型: 父类的指针(或引用)→ 子类的指针(或引用)。
子类指针转为父类指针是语法天然支持的,不需要进行转换,而父类指针转为子类指针,需要使用dynamic_cast来进行强制转换(dynamic_cast可以检查是否安全,安全才转,不安全则返回NULL)。
具体来说:当父类指针指向子类对象,那么dynamic_cast会转换成功,如果父类指针指向父类对象,那么dynamic_cast会转换失败并返回一个空指针。
3. const_cast
const_cast用于删除变量的const属性,转换后就可以对const变量的值进行修改。
4. reinterpret_cast
reinterpret_cast用于两个不相关类型之间的转换。它可以把一个指针转换成一个整数,也可以把一个整数转换成一个指针。四个转换中最不安全的。
RAII
RAII作用:可以自动化的管理内存、避免内存泄露。
在构造函数中申请分配资源,在析构函数中释放资源。 因为C++的语言机制保证了,当一个对象创建的时候,自动调用构造函数,当对象超出作用域的时候会自动调用析构函数。所以,可以将需要申请资源的对象封装成RAII类,从而实现自动化的内存管理。
智能指针是RAII最好的例子。
操作系统
介绍下虚拟内存
为什么要有虚拟内存:
- 简化内存管理:虚拟内存使得每个进程都有自己的地址空间,操作系统只需要管理虚拟地址空间和物理内存的映射关系,而不需要考虑不同进程之间的地址冲突等问题。
- 提高安全性:每个进程拥有自己的虚拟地址空间,可以避免不同进程之间相互干扰,从而提高系统的安全性。
- 提高内存利用率:通过将虚拟地址空间分页,可以将不常用的页交换到磁盘上,从而释放物理内存,提高内存利用率。
所以,虚拟内存是指操作系统为每个进程提供的一种抽象机制,它使得每个进程都拥有一个连续且私有的地址空间。这个虚拟地址空间可能会大于物理内存的大小,这就需要通过内存管理单元(MMU)来将虚拟地址转化为物理地址。
虚拟内存空间分布
一共六部分:
- 代码段:这是存放可执行程序代码(机器码)的区域。
- 数据段:存放了已经初始化的全局变量和静态变量。
- BSS段:保存未初始化的全局变量和静态变量。
- 堆:这是动态分配内存的区域,并且可以根据需要动态增长或缩小。
- 栈:保存函数调用信息、局部变量、函数参数的区域。
- 文件映射和匿名映射区:程序在运行过程中,还需要依赖动态链接库,这些动态链接库以**.so文件形式存储在磁盘中,也需要加载进内存,称为文件映射区**。除此之外,还有用于内存文件映射的系统调用mmap,会将文件与内存进行映射,那么映射的这块内存也需要在虚拟地址空间中有一块区域进行存储,称为匿名映射区。
堆和栈的区别
栈:由操作系统自动分配与释放,主要用于存放函数的参数值、局部变量等,
堆:由开发人员分配与释放,若开发人员不释放,程序结束时由OS回收。主要用于保存由malloc与new的资源。
区别:
- 管理方式不同。栈由系统自动分配释放,不用我们手动控制。堆的分配与释放由程序员控制,容易产生内存泄漏。
- 空间大小不同。每个进程拥有的栈的空间会远远小于堆空间。具体来说栈的大小64位的Windows默认为1MB,Linux默认为10MB;堆大小理论来说约为虚拟内存大小。
- 生长方式不同。堆是从下往上生长,内存地址由低到高。栈是从上往下生长,内存地址由高到低。
- 分配方式不同。堆仅支持动态分配,没有静态分配的堆。栈支持静态分配与动态分配。静态分配由OS完成,比如局部变量的分配。动态分配由
alloca()
函数分配,但是栈的动态分配与堆不同,他的动态分配由OS释放。 - 分配效率不同。堆的效率远低于栈。具体来说栈由操作系统自动分配,会在硬件层级对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是由C/C++提供的库函数或运算符来完成申请与管理,实现机制较为复杂,频繁的内存申请容易产生内存碎片。
信息队列
信息队列的工作原理是:消息队列是存储消息的线性表,是消息在传输过程中的容器。消息队列一经创建,即可以向队列中写入指定类型的消息,其他进程则可以从该队列中取出指定类型的消息。
使用场景:
异步:多应用对消息队列中同一消息进行处理,应用间并发处理消息,相比串行处理,减少处理时间;
解耦:消息队列减少了服务之间的耦合性,不同的服务可以通过消息队列进行通信,而不用关心彼此的实现细节,只要定义好消息的格式就行。
削峰:当上下游系统处理能力存在差距的时候,利用消息队列做一个通用的”载体”。在下游有能力处理的时候,再进行分发与处理。
计算机网络
OSI七层模型
TCP与UDP区别
1、TCP面向连接(如打电话要先拨号建立连接);UDP是无连接的,即发送数据之前不需要建立连接
2、TCP提供可靠的服务。也就是说,通过TCP连接传送的数据,不出错,不重复,不丢失,不失序 ,但是不保证报文的界限; UDP尽最大努力交付,即不能保证可靠的交付
3、UDP没有拥塞控制与流量控制,因此网络出现拥塞不会使源主机的发送速率降低(对实时应用很有用,如IP电话,实时视频会议等)
4、每一条TCP连接只能是点到点的;UDP支持一对一,一对多,多对一和多对多的交互通信
5、TCP首部开销大,有20字节;UDP的首部开销小,只有8个字节
6、TCP的逻辑通信信道是全双工的可靠信道,UDP则是不可靠的,单向数据传输的信道
7、
TCP是面向字节流的,它把上面应用层交下来的数据看成无结构的字节流,TCP会根据当前网络的拥塞状态来确定每个报文段的大小;
UDP是面向报文的,发送方的UDP对应用层交下来的报文,不合并,不拆分,只是在其上面加上首部后就交给了下面的网络层,无论应用层交给UDP多长的报文,它统统发送,一次发送一个。而对接收方,接到后直接去除首部,交给上面的应用层就完成任务了。因此,它需要应用层控制报文的大小
如何保证可靠
1、 连接前的握手、断开连接的挥手:确保了数据的可靠传输
2、序列号、确认应答、超时重传 数据到达接收方,接收方需要发出一个确认应答,表示已经收到该数据段,并且确认号,确认号说明了它下一次需要接收的数据序列号,保证数据传输有序。如果发送方迟迟未收到确认应答,那么可能是发送的数据丢失,也可能是确认应答丢失,这时发送方在等待一段时间后进行重传。
3、窗口控制 :TCP会利用窗口控制来提高传输速度,意思是在一个窗口大小内,不用一定等到应答才能发送下一段数据,窗口大小就是无需等待确认而可以继续发送数据的最大值。如果不使用窗口控制,每一个没收到确认应答的数据都要重发。 使用窗口控制,如果数据段1001-2000丢失,后面数据每次传输,确认应答都会不停发送序号为1001的应答,表示我要接收1001开始的数据,发送端如果收到3次相同应答,就会立刻进行重发;数据一旦丢失,接收端会一直提醒。
- 流量控制:通过捎带技术告诉发送发送方空闲缓冲区大小。
5、拥塞控制 :原因:因为有太多发送方向以过高的速率发送数据,从而导致网络拥塞。通过使用拥塞控制的手段,来限制发送速率:
(1)慢启动阶段:定义拥塞窗口,一开始将该窗口大小设为1,之后每次收到一次确认应答(一次成功来回传输),将拥塞窗口大小*2
(2)拥塞避免阶段:拥塞避免是只当拥塞窗口大小达到这个阈值,拥塞窗口的值不再指数上升,而是+1
(3)快恢复:将报文段的超时重传看做拥塞,则一旦发生超时重传,我们就将阈值设为当前窗口大小的一半,并且窗口大小变为1,重新进入慢启动过程。
(4)快速重传:3次重复确认应答,立即重传,并进入拥塞避免阶段。
MTU与MSS
MTU = MSS(一般为1460) + TCP头部长度(20字节) + IP层头部长度(20字节)。
MTU:一般是由硬件所决定的,具体来说MTU是网络设备(如路由器、交换机)能够处理的最大数据包大小,如以太网中,MTU=1500字节。
MSS:是指在传输层中,能够传输最大TCP报文段大小,不包括TCP头部。他是由TCP协议控制的,根据网络状况和协商结果动态调整的。
序列号与确认号
序列号:本本文段发送的第一个字节数据在原数据流中的对应的第一个字节的序号,用于标识该数据包中的数据在整个数据流中的位置。每次发送数据时都会加上已经发送过的数据长度。
确认号:期望下一个收到的数据包的序列号,也就是说假设确认号为x+1,说明x及x之前序号的数据已经收到了。
面向字节流
为什么UDP 是面向报文的协议:
当用户消息通过 UDP 协议传输时,操作系统不会对消息进行拆分,在组装好 UDP 头部后就交给网络层来处理,所以发出去的 UDP 报文中的数据部分就是完整的用户消息,也就是每个 UDP 报文就是一个用户消息的边界,这样接收方在接收到 UDP 报文后,读一个 UDP 报文就能读取到一个完整的用户消息。
TCP协议是面向字节流的,即将应用层传输的数据看作是一个字节流,不关心它们的含义和边界。这种设计可以提高数据传输的效率和灵活性,但也要求应用层在接收数据时需要自己处理数据的边界和含义。
因此,我们不能认为一个用户消息对应一个 TCP 报文,正因为这样,所以 TCP 是面向字节流的协议。
当两个消息的某个部分内容被分到同一个 TCP 报文时,就是我们常说的 TCP 粘包问题,这时接收方不知道消息的边界的话,是无法读出有效的消息。
如何解决粘包问题
粘包的问题出现是因为不知道一个用户消息的边界在哪,如果知道了边界在哪,接收方就可以通过边界来划分出有效的用户消息。
一般有三种方式分包的方式:
- 固定长度的消息;
- 特殊字符作为边界;
- 自定义消息结构。
固定长度的消息
:
这种是最简单方法,即每个用户消息都是固定长度的,比如规定一个消息的长度是 64 个字节,当接收方接满 64 个字节,就认为这个内容是一个完整且有效的消息。但是这种方式灵活性不高,实际中很少用
特殊字符作为边界
:
类似HTTP协议一样。我们可以在两个用户消息之间插入一个特殊的字符串,这样接收方在接收数据时,读到了这个特殊字符,就把认为已经读完一个完整的消息。
HTTP 通过设置回车符、换行符作为 HTTP 报文协议的边界。
有一点要注意,这个作为边界点的特殊字符,如果刚好消息内容里有这个特殊字符,我们要对这个字符转义,避免被接收方当作消息的边界点而解析到无效的数据。
自定义消息结构
我们可以自定义一个消息结构,由包头和数据组成,其中包头包是固定大小的,而且包头里有一个字段来说明紧随其后的数据有多大
当接收方接收到包头的大小(比如 4 个字节)后,就解析包头的内容,于是就可以知道数据的长度,然后接下来就继续读取数据,直到读满数据的长度,就可以组装成一个完整到用户消息来处理了。
为什么两次握手不行
1. 为什么两次握手不行
如果没有第三次握手,由于第二次握手过程,数据可能存在丢包问题,导致客户端没有收到,但是服务器以为客户端收到了,因此客户端会一直等到超时,就重新进行握手连接请求。而在服务器第一次发送第二个握手时,由于服务器以为连接建立成功了,就会维护这个连接,并等待客户端发送数据。因此,这可能会导致服务器可能会维护很多虚假的连接而浪费资源。
2. 为什么四次握手不行?
在第三次握手,服务器等待客户端确认信息,只要等到第三次握手,B才正式建立连接,这样其实已经够了。如果要增加第四次确认也可以,但是握手次数的增多并不能保证通信达到稳定。所以只需要达到一个最基本的可以建立连接的条件(三次握手)就可以了。
连接队列
在 TCP 三次握手的时候,Linux 内核会维护两个队列,分别是:
- 半连接队列:当服务器接收到一个SYN请求(第一次握手)时,就会放入半连接队列。
- 全连接队列:当接收到第三次握手时,会从半连接队列取出相应的对象,并将其放到全连接队列。
- 进程通过调用accept(),将全连接队列的队头元素取出,若队列为空,则阻塞。
每一个socket执行listen时,内核都会自动创建一个半连接队列和全连接队列。
不管是半连接队列还是全连接队列,都有最大长度限制,超过限制时,默认情况都会丢弃报文。
连接队列的数据结构
- 半连接队列:本质是哈希表。原因是需要查找效率为O(1)
- 全连接队列:本质是链表。原因是每次accept,能O(1)的从队头取元素。
SYN攻击
SYN 攻击方式最直接的表现就会把 TCP 半连接队列塞满,这样当 TCP 半连接队列满了,后续再在收到 SYN 报文就会丢弃,导致真正的客户端无法和服务端建立连接
避免 SYN 攻击方式,可以有以下三种方法:
- 最直接的办法:增加TCP半连接队列的size
- 利用SYN cookie:利用SYN cookie就可以不使用SYN半连接队列的情况下成功建立连接,相当于绕过了SYN半连接来建立连接
- 减少第二次握手重传的次数
如何利用cookie来避免SYN攻击:
在第一次握手到达时,服务器会根据源和目的IP和端口号,以及散列函数初始化一个TCP序列号,被称作SYN cookie。生成后,服务器会发送这个带有特殊初始序列号的第二次握手。最重要的是,服务器并不维护该cookie或者关于这次连接的任何其他状态信息。
如果客户端合法:第三次握手来时,服务器只需要再把源和目的IP和端口号,运行同样散列函数,如果该函数的结果+1与客户中的第三次握手的ACK号相同的话,服务器就认为该ACK是对应于较早的SYN报文段,因此是合法的。
如果客户端不合法,那么客户不会返回第三次握手,初始的第一次握手由于服务器没有维护任何信息,所以不会造成危害。
SYN cookies存在的缺点:
- cookies方案虽然能防 SYN 泛洪攻击,但是也有一些问题。因为服务端并不会保存连接信息,所以如果第三次握手在传输过程中数据包丢了,也不会重发第二次握手的信息
- 编码解码cookies,都是比较耗CPU的,利用这一点,如果此时攻击者构造大量的第三次握手包(ACK包),同时带上各种瞎编的cookies信息,服务端收到ACK包后以为是正经cookies,跑去解码(耗CPU),最后发现不是正经数据包后才丢弃。这种通过构造大量ACK包去消耗服务端资源的攻击,叫ACK攻击,受到攻击的服务器可能会因为CPU资源耗尽导致没能响应正经请求。
get、post区别
本质区别:GET是从服务器上获得数据;POST是向服务器传递数据
具体来说:
1、安全性问题
get由于是把传输的参数拼接在url中,所以get不安全
而post是把内容写入body当中,所以post相对来说更加安全
2、缓存性:
get请求是可以缓存的
post请求不可以缓存
3、后退页面的反应
get请求页面后退时,不产生影响
post请求页面后退时,会重新提交请求
4、传输数据的大小
get一般传输数据大小不超过2k-4k(根据浏览器不同,限制不一样,但相差不大)
post请求传输数据的大小根据php.ini 配置文件设定,也可以无限大。
5、数据包
GET产生一个TCP数据包;POST产生两个TCP数据包。对于GET方式的请求,浏览器会把http header和data一并发送出去,服务器响应200(返回数据);而对于POST,浏览器先发送header,服务器响应100 continue,浏览器再发送data,服务器响应200 ok(返回数据)。
OSI 7层网络模型
每一层负责的职能都不同,如下:
应用层,负责给应用程序提供统一的接口;
表示层,数据表示、加密和解密、数据压缩和解压缩等。
会话层,会话层的作用是负责在应用程序之间建立维护并拆除会话连接。
传输层,负责进程-进程的数据传输;
网络层,负责端-端的数据的路由、转发、分片;
数据链路层,负责两点之间数据的封帧和差错检测,以及 MAC 寻址;
物理层,负责在物理网络中传输数据帧;
HTTP状态码
HTTP有5种类型的状态码,具体的:
-
1xx- 信息:属于提示信息,请求已被服务器接收,继续处理。
-
2xx-成功:表示服务器成功处理了客户端的请求,也是我们最愿意看到的状态。
- 「200 OK」是最常见的成功状态码,表示一切正常。如果是非 HEAD 请求,服务器返回的响应头都会有 body 数据。
- 「204 No Content」也是常见的成功状态码,与 200 OK 基本相同,但响应头没有 body 数据。
-
3xx- 重定向:表示客户端请求的资源发生了变动,需要客户端用新的 URL 重新发送请求。
- 「301 Moved Permanently」表示永久重定向,说明请求的资源已经不存在了,需改用新的 URL 再次访问。
- 「302 Found」表示临时重定向,说明请求的资源还在,但暂时需要用另一个 URL 来访问。
- 301 和 302 都会在响应头里使用字段 Location,指明后续要跳转的 URL,浏览器会自动重定向新的 URL。
- 「304 Not Modified」不具有跳转的含义,表示资源未修改,重定向已存在的缓冲文件,也称缓存重定向,也就是告诉客户端可以继续使用缓存资源,用于缓存控制。
-
4xx- 请求错误:表示客户端发送的报文有误,服务器无法处理。
- 「400 Bad Request」表示客户端请求的报文有错误,但只是个笼统的错误。
- 「403 Forbidden」表示服务器禁止访问资源,并不是客户端的请求出错。
- 「404 Not Found」表示请求的资源在服务器上不存在或未找到,所以无法提供给客户端。
-
5xx- 服务器错误:表示客户端请求报文正确,但是服务器处理时内部发生了错误,属于服务器端的错误码。
- 「500 Internal Server Error」与 400 类型,是个笼统通用的错误码,服务器发生了什么错误,我们并不知道。
- 「501 Not Implemented」表示客户端请求的功能还不支持,类似“即将开业,敬请期待”的意思。
- 「502 Bad Gateway」通常是服务器作为网关或代理时返回的错误码,表示服务器自身工作正常,访问后端服务器发生了错误。
- 「503 Service Unavailable」表示服务器当前很忙,暂时无法响应客户端,类似“网络服务正忙,请稍后重试”的意思。
session与cookie
http作为一种无状态协议,HTTP/1.1 引入cookie保存连接状态
- cookie:cookie是服务器传给客户端并保存在客户端的一段文本,这个 Cookie是有大小,数量限制的!!用户下次访问这个服务器时,数据通过请求头又被完整地给带回服务器,服务器根据这些信息来判断不同的用户。
- session:session保存在服务器中。Session是基于Cookie来工作的。同一个客户端每次访问服务器时,只要当浏览器在第一次访问服务器时,服务器设置一个session_id并保存一些信息(例如登陆就保存用户信息,视具体情况),并把这个id通过Cookie存到客户端,客户端每次和服务器交互时只传这个session_id,就可以实现维持浏览器和服务器的状态
使用 Session 维护用户登录状态的过程如下:
- 用户进行登录时,用户提交包含用户名和密码的表单,放入 HTTP 请求报文中;
- 服务器验证该用户名和密码,如果正确则把用户信息存储到 Redis 中,它在 Redis 中的 Key 为 Session ID;
- 服务器返回的响应报文的 Set-Cookie 首部字段包含了这个 Session ID,客户端收到响应报文之后将该 Cookie 值存入浏览器中;
- 客户端之后对同一个服务器进行请求时会包含该 Cookie 值,服务器收到之后提取出 Session ID,从 Redis 中取出用户信息,继续之前的业务操作。
如果浏览器禁用了Cookie,会使用一种叫做URL重写的技术来进行会话跟踪,即每次HTTP交互,URL后面都会被附加上一个session_id,服务端据此来识别用户。
https 与http区别
- 安全性:HTTP 是超文本传输协议,信息是明文传输,存在安全风险的问题。HTTPS 则解决 HTTP 不安全的缺陷,在 TCP 和 HTTP 网络层之间加入了 SSL/TLS 安全协议,使得报文能够加密传输。
- 连接过程:HTTP 连接建立相对简单, TCP 三次握手之后便可进行 HTTP 的报文传输。而 HTTPS 在 TCP 三次握手之后,还需进行 SSL/TLS 的握手过程,才可进入加密报文传输。
- 端口:两者的默认端口不一样,HTTP 默认端口号是 80,HTTPS 默认端口号是 443。
- 数字证书:HTTPS 协议需要向 CA(证书权威机构)申请数字证书,来保证服务器的身份是可信的。
输入网址到显示页面的全过程
1、输入网址
2、解析URL,生成HTTP请求信息
3、使用ARP协议获取网关路由器的MAC地址
4、DNS解析获取域名对应的IP地址
5、建立TCP请求
6、web浏览器向web服务器发送HTTP请求
7、web服务器做出应答
8、浏览器渲染页面
9、web服务器关闭TCP连接
2、解析URL,生成HTTP请求信息
浏览器做的第一步工作就是要对 URL 进行解析从而获取 Web 服务器和文件名,接下来就是根据这些信息来生成 HTTP 请求消息了。
3、使用ARP协议获取网关路由器的MAC地址
为了获取域名对应的IP地址,我们需要向本地DNS服务器发送DNS查询报文
而再向本地DNS服务器发送DNS查询报文之前,我们需要将DNS查询报文发送到网关路由器,由网关路由器将DNS查询报文转交给本地DNS服务器。
所以我们需要得知网关路由器的IP地址与MAC地址。根据DHCP协议,本地浏览器已经知道网关路由器的IP地址了,因此浏览器还需使用ARP协议获取MAC地址,具体来说:
- 1、本地浏览器生成一个具有网关IP地址的ARP查询报文,并将该ARP报文由交换机进行全网内广播。
- 2、网关路由器接收到该ARP查询报文,发现ARP报文中目标IP地址匹配自己的IP地址,就会进行ARP回答,将装有MAC地址的帧返回给交换机,再由交换机返回给本地。
因此我们获取了网关的IP地址和MAC地址,接下来可以开始进入DNS查询。
4、DNS解析获取域名对应的IP地址
一旦请求发起,浏览器首先要解析这个域名
- 1、先通过本地host文件查找是否有与该域名相关的规则,如果有就直接使用host里的IP地址
- 2、如果没有的话,就通过DNS请求(这里插入3、),向本地DNS服务器发送DNS查询报文获取域名对应的IP地址。本地DNS服务器一般都是网络接入服务器商提供,例如电信,移动等
- 3、本地DNS服务器内有缓存,如果有缓存对应的域名,则直接返回,否则进行迭代查询:向根DNS服务器查询下一个DNS地址,直到查询到域名的解析服务器地址,域名的解析服务器返回该域名的IP地址。
- 4、最后,本地DNS获取到该域名的IP地址后,返回给浏览器,并把这个对应关系保存在缓存中,以备下次别的用户查询时,可以直接返回结果,加快网络访问。
5、建立TCP请求
在HTTP工作开始之前,web浏览器首先要通过网络与web服务器建立连接,该连接是通过TCP的三次握手来完成的。
为什么要先建立TCP呢?
因为HTTP是比TCP更高层次的应用层协议,根据规则,只有低层协议建立之后才能进行更高层次协议的连接,因此要先建立TCP连接,一般TCP连接的端口号是80
6、web浏览器向web服务器发送HTTP请求
建立了TCP连接之后,web浏览器就会向web服务器发起一个http请求。
一个典型的 http request header 一般需要包括请求的方法,例如 GET 或者 POST 等
7、web服务器做出应答
经过前面的步骤,数据包抵达服务器后,服务器会匹配mac地址与IP地址,是否符合,如果符合的话,看IP头中的协议项,得知上层是TCP协议。然后会看TCP的头部,匹配序列号是否在窗口内,如果是的话放入缓存并返回ACK,如果不是就丢弃。
TCP头部中还有端口号,于是服务器就会知道是HTTP进程想要这个包,就把这个数据包发送给HTTP进程。
HTTP服务器此时拿到了浏览器的HTTP请求报文,到这一步,它会把它的处理结果返回,也就是返回一个HTPP响应报文。
HTTP响应报文穿上TCP、IP、MAC 头部,从网卡出去,交由交换机转发到网关路由器,网关路由器把HTPP响应报文转发给下一跳路由器,一路跳,直到到客户的网关路由器,客户的网关路由器转发给交换机,并由交换机转发给本地浏览器进程。
8、浏览器渲染页面
此时,浏览器进程已经收到 HTTP 响应报文,浏览器会根据响应报文去渲染页面333,此时页面就显示出来了。
9、 web服务器关闭TCP连接
最后浏览器与服务器断开链接,会进行TCP的四次挥手
Mysql
MySQL常见的存储引擎InnoDB、MyISAM的区别?适用场景分别是?
1)事务:MyISAM不支持,InnoDB支持
2)锁级别: MyISAM 表级锁,InnoDB 支持行级锁及外键约束
3)MyISAM存储表的总行数;InnoDB不存储总行数;
4)MyISAM采用非聚集索引,B+树叶子存储指向数据文件的指针。InnoDB主键索引采用聚集索引,B+树叶子存储数据
存储引擎的适用场景
在选择存储引擎时,应该根据应用系统的特点选择合适的存储引擎。对于复杂的应用系统,还可以根据实际情况选择多种存储引擎进行组合。
- InnoDB: 如果应用对事务的完整性有比较高的要求,在并发条件下要求数据的一致性,还包含很多的更新、删除操作,则 InnoDB 是比较合适的选择
- MyISAM: 如果应用是以读操作和插入操作为主,只有很少的更新和删除操作,并且对事务的完整性、并发性要求不高,那这个存储引擎是比较合适的。(日志、电商评论/足迹)
ACID、脏读 不可重复读 幻读
四大特性ACID:
-
原子性(Atomicity):事务是不可分割的最小操作单元,要么全部成功,要么全部失败
-
一致性(Consistency):事务的一致性是指事务的执行不能破坏数据库数据的完整性和一致性,一个事务在执行之前和执行之后,数据库都必须处以一致性状态。
比如:如果从A账户转账到B账户,不可能因为A账户扣了钱,而B账户没有加钱 -
隔离性(Isolation):事务的隔离性是指在并发环境中,并发的事务是互相隔离的,一个事务的执行不能被其它事务干扰。也就是说,不同的事务并发操作相同的数据时,每个事务都有各自完整的数据空间
-
持久性(Durability):事务一旦提交或回滚,它对数据库中的数据的改变就是永久的
-
脏读:一个事务读到了另一个事务尚未提交的数据
-
不可重复读:重复读某一行数据时,所读的数据和前一次不同。解决办法:将事务的隔离级别提升到可重复读。
-
幻读:某事务重复查询同一范围的数据时,后一次查询看到了前一次查询没有看到的行。幻读仅专指新插入的行。解决办法:将事务隔离级别提升到串型化,或者使用MVCC-多版本并发控制。
mysql的四种隔离级别
SQL标准的事务隔离级别包括:读未提交、 读提交、可重复读和串行化):
读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。
读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。
可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。 但这种方法却无法锁住insert的数据。(所以会产生幻读)
串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突 的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
什么是索引
MySQL索引是一种特殊的数据结构,用于提高数据库表中数据的查询速度,但是会降低增删改的效率。它们是一种可选的结构。在MySQL中,索引通常是用B+树算法实现的。
索引最大的好处是提高查询速度,但是索引也是有缺点的,比如:
- 需要占用物理空间,数量越大,占用空间越大;
- 创建索引和维护索引要耗费时间,这种时间随着数据量的增加而增大;
- 会降低表的增删改的效率,因为每次增删改索引,B+ 树为了维护索引有序性,都需要进行动态维护。
我们可以按照四个角度来分类索引。
- 按「数据结构」分类:B+tree索引、Hash索引、Full-text索引。
- 按「物理存储」分类:聚簇索引(主键索引)、二级索引(辅助索引)。
- 按「字段特性」分类:主键索引、唯一索引、普通索引、前缀索引。
- 按「字段个数」分类:单列索引(建立在单列上)、联合索引(建立在多列上)。
在创建表时,InnoDB 存储引擎会根据不同的场景选择不同的列作为聚簇索引:
- 如果有主键,默认会使用主键作为聚簇索引的索引键(key);
- 如果没有主键,就选择第一个不包含 NULL 值的唯一列作为聚簇索引的索引键(key);
- 在上面两个都没有的情况下,InnoDB 将自动生成一个隐式自增 id 列作为聚簇索引的索引键(key);
除了主键索引,其它索引都属于辅助索引(二级索引或非聚簇索引)。创建的主键索引和二级索引默认使用的是 B+Tree 索引。
主键索引的 B+Tree 和二级索引的 B+Tree 区别如下(这是sql优化的关键!):
- 主键索引的 B+Tree 的叶子节点存放的是实际数据,所有完整的用户记录都存放在主键索引的 B+Tree 的叶子节点里;
- 二级索引的 B+Tree 的叶子节点存放的是主键值,而不是实际数据。
所以,在使用二级索引查询数据时,可能会产生回表查询。例如:
select * from product where product_no = '0002';
会先检二级索引中的 B+Tree 的索引值(商品编码,product_no),找到对应的叶子节点,然后获取主键值, 然后再通过主键索引中的 B+Tree 树查询到对应的叶子节点,然后获取整行数据。 这个过程叫「回表」,也就是说要查两个 B+Tree 才能查到数据。如下图(图中叶子节点之间我画了单向链表,但是实际上是双向链表.):
覆盖索引:不过,当查询的数据是能在二级索引的 B+Tree 的叶子节点里查询到,这时就不用再查主键索引查,比如下面这条查询语句:
select id from product where product_no = '0002';
这种在二级索引的 B+Tree 就能查询到结果的过程就叫作「覆盖索引」,也就是只需要查一个 B+Tree 就能找到数据。
索引总结:
在我看来,索引其实是主键索引(聚簇索引)、唯一索引、普通索引、前缀索引。而索引的数据结构有四种,分别是B+Tree,哈希,R-tree,Full-text。而根据物理存储的形式分,主键索引又称为聚簇索引,其他三种索引又称为二级索引(创建的主键索引和二级索引默认使用的是 B+Tree 索引。)。Mysql是通过聚簇索引来查找每一行的数据的。
为什么B+树更适合做索引的数据结构(更适合存储数据)
为什么B+树更适合存储数据?
首先明确,在B/B+树中,一个节点存放在一个磁盘块中。在B树中,非叶节点存放了该关键字对应的存储地址,而在B+树中,只有叶子节点才会存放关键字对应的存储地址,所以可以使一个磁盘块可以包含更多的关键字,使得B+树的阶更大,树更矮,读取磁盘的次数更少,查找更快。
其次,B+ 树有大量的冗余节点(所有非叶子节点都是冗余索引),这些冗余索引让 B+ 树在插入、删除的效率都更高,因为对树结构的影响较小,比如删除根节点的时候,不会像 B 树那样会发生复杂的树的变化;
B+ 树叶子节点之间用链表连接了起来,有利于范围查询,而 B 树要实现范围查询,因此只能通过树的遍历来完成范围查询,这会涉及多个节点的磁盘 I/O 操作,范围查询效率不如 B+ 树。
那为什么不使用红黑树或者平衡二叉树来存? 因为相比于二叉树,B+树的高度更低,搜索效率高。
索引失效的情况
这里简单说一下,发生索引失效的情况:
- 当我们使用左或者左右模糊匹配的时候,也就是 like %xx 或者 like %xx%这两种方式都会造成索引失效;
- 当我们在查询条件中对索引列做了计算、函数、类型转换操作,这些情况下都会造成索引失效;
- 联合索引要能正确使用需要遵循最左匹配原则,也就是按照最左优先的方式进行索引的匹配,否则就会导致索引失效。
- WHERE 子句中使用 OR 操作符时,如果 OR 操作符前后的条件列中,有一列或多列不是索引列,会导致索引失效
索引下推
索引下推是把本应该在 server 层进行筛选的条件,下推到存储引擎层来进行筛选判断,这样能有效减少回表,改善查询效率。
例如:有一个联合索引(name, age),现在有以下查询语句
select * from t_user where name like 'a%' and age = 17;
这条语句从最左匹配原则上来说是不符合采用联合索引的的,原因在于只有name用的索引,但是age并没有用到。
如果不用索引下推的执行过程:
第一步:利用索引找出带a的数据行:aa ab ac ad,假设有4条索引数据
第二步:利用这四条索引对应的主键值(也就是id),逐一做回表查询,并将查询到的整行发送给server层
第三步:由server层判断age是否满足要求,最后只留下aa
如果用索引下推的执行过程:
第一步:利用索引找出带a的数据行:aa ab ac ad,假设有4条索引数据
第二步:根据 age = 17,对四条索引数据进行判断,最后留下aa(注意,这一步没有进行回表,因为这是联合索引,索引中有存储age的信息)
第三步:将符合条件的索引对应的主键进行回表查询,最终找到行数据并返回给server层
Redis
RDB持久化
每隔一段时间就将内存中的数据集快照
写入磁盘。RDB持久化既可以手动执行,也可以定期执行。
RDB持久化生成的文件是经过压缩的二进制文件
RDB文件的创建与载入
有两个命令可以进行RDB持久化
SAVE
:由服务器进程直接执行RDB持久化操作,直到文件创建完毕,所以该命令会阻塞服务器,服务器不能处理任何命令。BGSAVE
:会创建一个子进程来生成 RDB 文件,这样可以避免主线程的阻塞,可以继续响应用户请求;
RDB持久化的特点:
- 优先使用AOF文件:因为AOF文件更新的频率通常比RDB文件更新频率高,所以,如果服务器同时开启AOF和RDB持久化,那服务器会优先使用AOF文件来还原数据库。
- 高效:如果需要进行大规模数据的恢复,且对于数据恢复的完整性不是非常敏感,那RDB方式要比AOF方式更加的高效(因为保存的是压缩的二进制文件)。
- RDB的缺点是:在服务器发生故障时,丢失的数据会比 AOF 持久化的方式更多,因为 RDB 快照是全量快照的方式,因此执行的频率不能太频繁,否则会影响 Redis 性能,而 AOF 日志是增量保存,可以以秒级的方式记录操作命令,所以丢失的数据就相对更少。
因为BESAVE
可以不阻塞服务器进程情况下执行,Redis 还可以通过配置文件的选项来实现每隔一段时间自动执行一次 bgsave 命令,默认会提供以下配置:
save 900 1
save 300 10
save 60 10000
只要满足上面条件的任意一个,就会执行 bgsave,它们的意思分别是:
- 900 秒之内,对数据库进行了至少 1 次修改;
- 300 秒之内,对数据库进行了至少 10 次修改;
- 60 秒之内,对数据库进行了至少 10000 次修改。
关于save和bgsave的比较
总结
优点:
- 适合大规模的数据恢复
- 对数据完整性和一致性要求不高更适合使用
- 节省磁盘空间
- 恢复速度快
缺点:
- 虽然Redis在fork时使用了写时拷贝技术,但是如果数据庞大时还是比较消耗性能。
- 在备份周期在一定间隔时间做一次备份,所以如果Redis意外down掉的话,就会丢失最后一次快照后的所有修改。
AOF持久化
如果存在AOF文件,会优先使用AOF恢复数据。
Redis 在执行完一条写操作命令后,就会把该命令以追加的方式写入到一个文件里(增量保存),然后 Redis 重启时,会读取该文件记录的命令,然后逐一执行命令的方式来进行数据恢复。
- redis启动时会读取该文件,然后将AOF文件中的写指令从前到后重新执行一次以完成数据的恢复工作
写入操作:
命令会先写入AOF buf缓冲区,再定期(通过配置appendfsync
来设置同步频率)通过调用fsync() 函数来同步到AOF文件(磁盘)中:
always
:始终同步,每次Redis的写入都会立刻同步到AOF文件;性能较差但数据完整性比较好everysec
: 每秒同步,每秒同步到AOF文件一次,如果宕机,本秒的数据可能丢失。注意:使用该策略时,是额外由一个线程专门负责执行的no
: redis不主动进行同步,只有缓冲区写满了才会同步。
重写
关于Rewrite 重写:
AOF采用文件追加方式,文件会越来越大。为避免出现此种情况,新增了重写机制, 当AOF文件的大小超过所设定的阈值时,Redis服务器会 创建一个新的经过压缩的AOF文件来替代现有的AOF文件。
重写的机制:
redis4.0版本后的重写,是指fork()出子进程,然后子进程把rdb 的快照,以二进制的形式附在新的aof头部,作为已有的历史数据,替换掉原来的流水账操作。
什么时候重写:
Redis会记录上次重写时的AOF大小,默认配置是当AOF文件大小是上次rewrite后大小的一倍且文件大于64M时触发。
重写流程:
(1)通过判断是否当前AOF文件是上一次重写的两倍,并且文件大于64M(默认配置)来触发重写。
(2)主进程fork出子进程执行重写操作,保证主进程不会阻塞。
(3)在重写过程中,服务器执行完一个写请求后,还会同时写入aof_buf缓冲区和aof_rewrite_buf重写缓冲区,保证原AOF文件完整以及新AOF文件生成期间的新的数据修改动作不会丢失。
(4)子进程写完新的AOF文件后,向主进程发信号。主进程接收到信号后,会调用信号处理函数把AOF重写缓冲区中的数据追加到新的AOF文件,此时重写完的AOF文件保存的数据库状态就和当前数据库状态一致。
(5)使用新的AOF文件覆盖旧的AOF文件,完成AOF重写。
总结
优点:
- 备份机制更稳健,丢失数据概率更低
- 可读的日志文本,通过操作AOF稳健,可以处理误操作
缺点:
- 比起RDB占用更多的磁盘空间。
- 恢复备份速度要慢。
- 每次读写都同步的话,有一定的性能压力。
redis的过期键删除策略
redis提供了三种过期key删除策略
1、定时删除
在设置某个 key 的过期时间同时,我们创建一个定时器,让定时器在该过期时间到来时,立即执行对其进行删除的操作。
- 优点:可以保证过期key可以被尽快的删除,并且释放所占用的内存
- 缺点:对CPU不友好,当过期键过多时,删除过期键会占用一部分cpu资源,影响服务器性能
2、惰性删除
当某个键过期时,只有用到这个键才会检查该键是否过期,如果检查到过期了才删除。也就是说,如果这个键一直不用,那么这个键值对就会一直存在
- 优点:对CPU友好,只有在使用该键时才进行过期检查,这样就不会把 CPU 资源花费在其他无关紧要的键值对的过期删除上。
- 缺点:如果一些键值对永远不会被再次用到,那么将不会被删除,最终会造成内存泄漏,无用的垃圾数据占用了大量的资源,但是服务器却不能去删除。
3、定期删除
每隔一段时间就对一些 key 进行采样检查,检查是否过期,如果过期就进行删除
1、采样一定个数的key,采样的个数可以进行配置,并将其中过期的 key 全部删除;
2、如果过期 key 的占比超过可接受的过期 key 的百分比,则重复删除的过程,直到过期key的比例降至可接受的过期 key 的百分比以下。
- 优点:定期删除,通过控制定期删除执行的时长和频率,可以减少删除操作对 CPU 的影响,同时也能较少因过期键带来的内存的浪费。
- 执行的频率不太好控制。频率过快对 CPU 不友好,如果过慢了就会对内存不太友好,并且过期的键值对不能及时的被删除掉
redis过期删除策略:
redis实际使用的过期键删除策略是定期删除+惰性删除:
redis会每隔一段时间就会对一些key进行采样,执行定期删除。除了定期删除之外,它还会使用惰性策略来删除过期的 key,所谓惰性策略就是在客户端访问这个 key 的时候,redis 对 key 的过期时间进行检查,如果过期了就立即删除。定期删除是集中处理,惰性删除是零散处理。
通过配合使用这两种删除策略,服务器可以很好地合理使用cpu时间和避免浪费内存空间之间取得平衡。
rdb对过期健的处理
- 生成rdb文件:在生成时,程序会对键进行检查,如果过期,则不放入rdb文件
- 载入rdb文件:分两种情况。如果是以主服务器载入,则在载入时会检查键是否过期,如果过期则不载入该键。如果以从服务器载入,不管过期与否,都会载入,不过从库会根据自己的逻辑时间判断这个过期键是否过期,从而避免读取到过期的数据;过期键会通过与主服务器同步而删除。
aof对过期健的处理:
- aof持久化运行时:如果某个键过期了,但是他还没被删除,那么aof文件不会有任何操作,但是当过期健被删除时,程序会向aof文件追加一条del命令来显式标记该键已经被删除
- aof重写时,会检查键是否过期,如果过期则不会保存到重写后的aof文件。
redis内存淘汰策略
redis 中的使用过程中,随着写数据的增加,Redis 中的内存不够用了,这时候就需要 Redis 的内存淘汰策略了。
Redis中提供了8种内存淘汰策略:
volatile-lru:针对设置了过期时间的key,使用LRU算法进行淘汰
allkeys-lru:针对所有key使用LRU算法进行淘汰
volatile-lfu:针对设置了过期时间的key,使用LFU算法进行淘汰
allkeys-lfu:针对所有key使用LFU算法进行淘汰
volatile-random: 从设置了过期时间的key中随机删除
allkeys-random: 从所有key中随机删除
volatile-ttl:针对设置了过期时间的key,优先淘汰更早过期的键
noeviction(默认策略):不删除键,返回错误OOM,只能读取不能写入
LRU
传统的LRU时基于链表的,链表中的元素按照操作时间进行排序,最近操作过的就会移动到表头,当需要内存淘汰时,删除尾部元素即可。
但是Redis LRU没有使用这样的方式实现:原因是需要管理链表,带来额外的开销。并且如果有大量数据访问,需要频繁移动链表
Redis采用的是在对象中添加一个额外的字段,用于记录最后一次访问时间,采用随机抽样的方式淘汰数据:随机选5个值,然后淘汰最久未使用的。
LFU最近最不常用
它的核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。
LFU 算法会记录每个数据的访问次数。当一个数据被再次访问时,就会增加该数据的访问次数。 然后根据访问次数进行删除
redis遇到大key的影响
删除大key时:使用unlink
而不是del
,因为del
是在主线程处理的,这样会导致 Redis 主线程卡顿。而unlink
会把删除操作交给后台线程来执行,好处是不会导致 Redis 主线程卡顿。
大key对aof持久化的影响:
由于AOF 日志有三种写回磁盘的策略,不同的策略受到的影响不同
- aways:如果写入是一个大 Key,会阻塞主线程比较久,因为当写入的数据量很大的时候,数据同步到硬盘这个过程是很耗时的。
- everysec:由于是异步执行 fsync() 函数,所以大 Key 持久化的过程(数据同步磁盘)不会影响主线程。
- no:当使用 No 策略的时候,由于永不执行 fsync() 函数,所以大 Key 持久化的过程不会影响主线程。
大key对重写和rdb影响:
AOF 重写机制和 RDB 快照的过程,都会分别通过 fork() 函数创建一个子进程来处理任务。
在创建子进程的过程中,操作系统会把父进程的「页表」复制一份给子进程,这个页表记录着虚拟地址和物理地址映射关系,而不会复制物理内存(写时复制,读时共享)。
但是!在通过 fork() 函数创建子进程的时候,虽然不会复制父进程的物理内存,但是内核会把父进程的页表复制一份给子进程,如果页表很大,那么这个复制过程是会很耗时的,那么在执行 fork 函数的时候就会发生阻塞现象。
并且,如果在创建完子进程后,父进程对大key进行了修改,那么内核就会发生写时复制,会在内存中复制一份大key,也会比较耗时,于是父进程(主线程)就会发生阻塞。
总结:
大key对重写和rdb会造成阻塞父进程的影响,会发生在两个事件上
- 在fork时,需要复制页表给子进程,因为key很大,所以页表很大,就会发生阻塞。
- 在创建完子进程后,如果父进程修改大key,就会发生写时复制,也会发生阻塞。
主从复制的实现
第一次同步工作(全量复制):
- 主线程执行besave命令生成rdb文件,并将文件传送给从服务器,并将这期间的写操作,写入replication buffer缓冲区中,以确保数据一致性
- 从服务器收到rdb文件后,载入rdb文件。
- 将记录在replication buffer中的写命令发送给从服务器,从服务器执行这些命令,将从服务器的状态更新至当前主服务器的状态。
命令传播:
在完成第一次同步后,双方会维护一个TCP连接,后续主服务器可以通过这个连接继续将写操作命令传播给从服务器,通过这种方式来保证第一次同步后的主从服务器的数据一致性。
如果主从服务器网络连接断开了呢?,会导致断开连接期间,从服务器的数据与主服务器数据不一致,如何解决同步呢?
利用两个变量:
- 复制积压缓冲区:这是一个环形队列,当主服务器进行命令传播时,不仅会将写命令发送给所有从服务器,还会将命令写入环形队列中。
- replication offset:主从都有各自的offset,主服务器用于标记自己写到哪,从服务器标记自己同步到哪。
当发生断线重连时,主服务器根据从服务器发送的offset来确定执行哪种同步:
- 如果从服务器需要同步的数据还保存在复制积压缓冲区中,那么就执行增量复制,将增量的数据写入replication buffer缓冲区。
- 否则,如果从服务器需要同步的数据已经不在缓冲区中,就执行和第一次同步一样的全量复制
集群
将数据分布在不同的服务器上,以此来降低系统对单主节点的依赖,从而提高 Redis 服务的读写性能。
Redis集群通过分片方式来处理保存在数据库中的键值对:一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中。具体保存键值对的步骤如下:
- 根据键值对的 key,按照 CRC16 算法计算出一个值,再用 该值对哈希槽的总数(16384) 取模,得到 0~16383 范围内的模数,在放入对应编号的哈希槽中。
这些哈希槽怎么被映射到具体的 Redis 节点上的呢?有两种方案:
- 平均分配: Redis 会默认的把所有哈希槽平均分布到集群节点上。比如集群中有 9 个节点,则每个节点上槽的个数为 16384/9 个。
- 手动分配: 手动指定每个节点上的哈希槽个数。
redis如何实现高可用
- 主从复制
- 哨兵陌生
- 切片集群模式
如何使用redis实现分布式锁
分布式锁的介绍:
分布式锁解决了分布式系统中共享资源的访问问题,可以通过redis实现分布式锁;
为什么使用redis实现分布式锁:
因为redis本身就是一个可以被多个客户端共享访问,正好就是一个共享存储系统,可以用来保存分布式锁,而且redis读写性能高,可以应对高并发的锁场景。
如何用redis实现分布式锁
基于redis实现分布式锁,对于加锁操作,需要满足下列三个条件 (其实就是创建一个set类型键值对充当锁,利用NX创建成功说明加锁,创建失败说明已经有人占用锁)
- NX参数:加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,但需要以原子操作的方式完成,所以,我们使用 SET 命令带上 NX (key不存在才插入成功)选项来实现加锁;
- 过期时间:锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,我们在 SET 命令执行时加上 PX 选项,设置其过期时间;
- 标识客户端:锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,我们使用 SET 命令设置锁变量值(也就是设置value值),每个客户端设置的值是一个唯一值,用于标识客户端;
例如:
//创建一个lock_key的键,值为unique_value。
//NX表示lock_key不存在菜创建成功,PX表示设置过期时间。
SET lock_key unique_value NX PX 10000
对于解锁而言:
解锁其实就是把lock_key这个键值对删掉,但是需要判断执行解锁的客户端就是加锁的客户端,所以需要先判断锁的值能否对应上,再执行官删除。
由于解锁是有两个操作(判断+删除),所以需要 Lua 脚本来保证解锁的原子性。
// 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
项目篇
1. Web服务器
1.1 面试项目介绍
( 整体) 这个项目是一个我独立开发的,基于Linux的轻量级多线程Web服务器,主要运用了C++语言进行开发,经过webbench测试,可实现上万的并发用户连接。为了提高服务器性能和处理能力,( 技术栈) 项目中采用了比如说:线程池、非阻塞socket编程、EpollI/O复用技术、Linux线程操作、信号量、文件IO操作、proactor事件处理的并发模型、状态机解析HTTP请求报文、锁以及MySQL等技术。
( 模块) 项目主要分为以下几个模块进行开发
- 线程池的实现
- 锁机制与信号量机制的实现
- http连接处理机制的实现(包括状态机解析请求报文,回复响应报文等)
- 定时器处理非活动用户连接的实现
- Mysql数据库及数据库连接池的实现
- 登陆与注册功能的实现
( 难点) 项目中遇到的难点有
难点
1.安全的问题:SQL注入:主要原因是程序对用户输入数据的合法性没有判断和处理,导致攻击者可以在WEB服务器中事先定义好的 SQL 语句中添加额外的 SQL 语句,在管理员不知情的情况下实现非法操作,以此来实现欺骗服务器来对数据库执行非授权的操作。
解决办法:
-1. 过滤输入内容,校验字符串:过滤输入内容就是在数据提交到数据库之前,就把用户输入中的不合法字符剔除掉。可以使用编程语言提供的处理函数或自己的处理函数来进行过滤,还可以使用正则表达式匹配安全的字符串
2.在测试的时候发现了问题,使用Webbench对服务器进行压力测试,创建1000个客户端,并发访问服务器10s,正常情况下有接近8万个HTTP请求访问服务器。
结果显示仅有7个请求被成功处理,0个请求处理失败,服务器也没有返回错误。此时,从浏览器端访问服务器,发现该请求也不能被处理和响应,必须将服务器重启后,浏览器端才能访问正常。
如何解决:
首先先排查:通过查询服务器运行日志,对服务器接收HTTP请求连接,HTTP处理逻辑两部分进行排查。
日志中显示,7个请求报文为:GET / HTTP/1.0的HTTP请求被正确处理和响应,排除HTTP处理逻辑错误。所以重点放在接收HTTP请求连接部分。其中,服务器端接收HTTP请求的连接步骤为socket -> bind -> listen -> accept
错误原因:错误使用epoll的ET模式。
epoll_wait检测到文件描述符有事件发生,则将其通知给应用程序,应用程序必须立即处理该事件。当连接较少时,全连接队列不会变满,即使listenfd设置成ET非阻塞,不使用while一次性读取完,也不会出现Bug。若此时1000个客户端同时对服务器发起连接请求,连接过多会造成全连接队列变满。但accept并没有使用while一次性读取完,只读取一个。因此,连接过多导致全连接队列中剩下的连接都得不到处理,同时新的连接也不会到来。
解决办法:使用while来读取accept,直到accept返回负数即可解决。
3.如何提高服务器的并发能力:调整了epoll使用边缘触发模式、使用了线程池,使用数据库连接池、处理非活跃用户等。
4.内存泄漏问题: 采用智能指针和RAII来防止内存泄漏
( 总结) 综上所述,这个基于Linux的轻量级多线程Web服务器项目充分利用了多种技术,从而提高了服务器的处理能力、并发性能和响应速度。
1.2 实现的功能:
- 线程池。
- HTTP请求报文的解析(主从状态机),HTTP响应报文的回复。
- 定时器实现检查非活动连接。
- 实现了get/post两种HTTP请求解析,通过post请求实现了登陆、注册功能。
- 通过数据库连接池方式实现了对Mysql数据库的访问。
1.3 工作流程
同步I/O模型的工作流程如下(epoll_wait为例):
主线程往epoll内核事件表注册socket上的读就绪事件。
主线程调用epoll_wait等待socket上有数据可读
当socket上有数据可读,epoll_wait通知主线程,主线程从socket循环读取数据,直到没有更多数据可读,然后将读取到的数据封装成一个请求对象并插入请求队列。
睡眠在请求队列上某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往epoll内核事件表中注册该socket上的写就绪事件
主线程调用epoll_wait等待socket可写。
当socket上有数据可写,epoll_wait通知主线程。主线程往socket上写入服务器处理客户请求的结果。
1.4 额外补充:五种I/O模型
- 阻塞IO:调用者调用了某个函数,等待这个函数返回,期间什么也不做,不停的去检查这个函数有没有返回,必须等这个函数返回才能进行下一步动作
- 非阻塞IO:非阻塞等待,每隔一段时间就去检测IO事件是否就绪。没有就绪就可以做其他事。非阻塞I/O执行系统调用总是立即返回,不管时间是否已经发生,若时间没有发生,则返回-1,此时可以根据errno区分这两种情况,对于accept,recv和send,事件未发生时,errno通常被设置成eagain
- 信号驱动IO:linux用套接口进行信号驱动IO,安装一个信号处理函数,进程继续运行并不阻塞,当IO时间就绪,进程收到SIGIO信号。然后处理IO事件。
- IO复用:linux用select/poll函数实现IO复用模型,这两个函数也会使进程阻塞,但是和阻塞IO所不同的是这两个函数可以同时阻塞多个IO操作。而且可以同时对多个读操作、写操作的IO函数进行检测。知道有数据可读或可写时,才真正调用IO操作函数
- 异步IO:linux中,可以调用aio_read函数告诉内核描述字缓冲区指针和缓冲区的大小、文件偏移及通知的方式,然后立即返回,当内核将数据拷贝到缓冲区后,再通知应用程序。
注意:阻塞I/O,非阻塞I/O,信号驱动I/O和I/O复用都是同步I/O。同步I/O指内核向应用程序通知的是就绪事件,比如只通知有客户端连接,要求用户代码自行执行I/O操作,异步I/O是指内核向应用程序通知的是完成事件,比如读取客户端的数据后才通知应用程序,由内核完成I/O操作。
2. Skiplist
2.1 面试项目介绍
3. 科研项目
3.1 项目介绍
这个项目是学校为了培养优秀硕士生而设立的重点科研项目,拨款研究经费1w元,并且仅为全校前1%的硕士生发放项目资格,支持该项目的硕士生必须成绩优秀和有优秀的代码能力。
( 项目背景) 该项目主要研究多用户移动边缘计算(MEC)网络的网络通信场景。目标是通过卸载优化和资源调度的优化来提高MEC网络的性能和效率。
(模块与技术栈)
在卸载的优化方面,我们使用了深度强化学习,dqn、ddpg算法,设计了一套智能卸载策略,使用强化学习来学习并预测最优的卸载策略。
在资源调度方面,我们主要使用了凸优化的数学优化算法,我们使用matlab平台,对系统资源来进行建模与分析,并使用凸优化算法实现了不同资源(带宽、算力等)的分配策略,从而实现资源的最优分配。
在模型训练方面,因为模型训练时间比较长,所以考虑了使用多进程加速模型训练。后面去网上查看别人是怎么解决这个办法的,看到别人通过多进程加速。于是自己也尝试使用多进程加速模型训练:这种训练方式有点像分布式训练,或者联邦学习吧。1 多个子进程拿到主进程的网络参数后去探索环境,并将所得到的数据通过进程间通信的方式(共享内存区)传回主进程。2 接着主进程根据各个子进程所得到的数据,扔到记忆库中供网络训练。3 最后,将更新的网络参数再传回子进程,然后子进程再次开始训练。主进程可以一边learn的同时,一边得到新的数据。而不用像传统的强化学习算法一样,先收集数据,再learn,learn完在收集数据。
(难点)
多进程训练时,遇到了很多问题:
在使用多进程时,遇到了以下几个问题
- 内存占用过高
- 进程间通信问题:之前使用pipe在进行数据传递时,主进程没来得及读,而容易造成数据丢失的现象,后面改成使用共享内存区,减少了数据的拷贝和通信开销
- 进程管理复杂:因为子进程要实现创建、启动训练,数据传输等操作,会增加代码的复杂度。
3.2项目提问
算法篇
如何从1亿个数中找出最大的100个数(top K问题)
考虑内存大小是否够用,如果不够用则分治:将1亿个数据分成100份。
采用最小堆。首先读入前100个数来创建大小为100的最小堆,建堆的时间复杂度为O(mlogm)(m为数组的大小即为100),然后遍历后续的数字,并于堆顶(最小)数字进行比较。如果比最小的数小,则继续读取后续数字;如果比堆顶数字大,则替换堆顶元素并重新调整堆为最小堆。整个过程直至1亿个数全部遍历完为止。然后按照中序遍历的方式输出当前堆中的所有100个数字。该算法的时间复杂度为O(nmlogm),空间复杂度是100(常数)。
面经
京东1面
- 项目
- 讲一下C++的封装继承多态
- 空类的大小
- 结构体里定义一个静态int类型成员变量,结构体的大小
- 讲一下static关键字
- 内联函数
- 递归函数可以使用内联函数吗
- 什么情况下必须用初始化列表
- 构造函数一般不能是虚函数,析构函数一般为虚函数的原因
- 讲一下STL的容器
- 智能指针
- 左值引用与右值引用
- 讲一下进程和线程,进程和线程区别
- 讲一下IO复用技术,select、epoll的区别
- TCP三次挥手,四次挥手
腾讯音乐1面
- 3次握手
- 为什么2次不行?
- TCP与UDP的区别
- HTTP的状态码
- 上服务器一般会进行什么操作
- top命令(监控linux的系统状况,是常用的性能分析)
- Python优点和缺点
- Python数组去重
- Python装饰器
- Redis持久化
更多推荐
八股文汇总
发布评论