一个http请求的整个流程
负载域名解析的DNS服务
首先,用户访问一个域名,会经过DNS解析
DNS,它和http协议一样是位于应用层的协议,主要提供域名到IP的解析服务。其实我们不用域名也可以访问目标主机的服务,但是IP本身不是那么容易记,使用域名进行替换使得用户更容易记住。
加速静态内容访问速度的CDN
在很多的大型网站,会引入CDN来加速静态内容的访问。
CDN:内容分发网络。CDN其实就是一种网络缓存技术,能够把一些相对稳定的资源放到距离最终用户较近的地方,一方面可以节省整个广域网的带宽消耗,另一方面可以提升用户的访问速度,改进用户体验。我们一点会把静态的文件(图片、脚本、静态页面)放到CDN中。
HTTP协议通信原理
域名被成功解析以后,客户端和服务端之间,是怎么建立连接并且如何通信呢?
通信:tcp和udp这两个通信协议,以及建立连接的握手过程。而http协议的通信是基于tcp/ip协议上的一个应用层协议,应用层协议除了http还有哪些(FTP、DNS、SMTP、Telnet)。
涉及到网络协议,我们需要知道OSI七层网络模型和TCP/TP四层概念模型,OSI七层网络模型包含(应用层、表示层、会话层、网络层、数据链路层)。
请求发起过程,在tcp/ip四层模型中所做的事情
当应用程序TCP传入数据时,数据被送入协议栈,然后逐个通过每一层直到被当作一串比特流传入网络。其中每一层对收到的数据都要增加一些首部信息(有时还要增加尾部信息)
客户端如何找到目标服务
在客户端发起请求的时候,我们会在数据链路层去组装目标机器的MAC地址,目标机器的mac的地址怎么得到呢?这里就涉及到了一个ARP协议,ARP协议是工作在网络层的协议,它负责将IP地址解析为MAC地址,这个协议简单来说就是已知目标机器的ip,需要获得目标机器的mac地址。(发送一个广播消息,这个ip是谁的,谁来认领。认领ip的机器会发送一个mac地址的响应)
有了这个目标MAC地址,数据包在链路上广播,MAC的网卡才能发现,这个包是给它的。MAC的网卡把包收进来,然后打开IP包,发信ip的地址也是自己的,再打开TCP包,发现端口是自己,也就是80端口哦,而这个时候这台机器上有一个ngnix是监听80端口。
于是将请求提交给nginx,nginx返回一个网页。然后将网址需要发回请求的机器。然后层层封装,最后到mac层。因为来的时候有源MAC地址,返回的时候,源mac变成了目标mac,返回给请求的机器。
为了避免每次都用ARP请求,机器本地也会进行ARP缓存。当然机器会不断的上线下线,IP也可能会变,所以ARP的mac地址缓存过一段时间就会过期。
接收端收到数据包以后的处理过程
当目的的主机收到一个以太网数据帧时,数据就开始从协议栈中由底向上升,同时去掉各层协议加上的报文首部。每层协议都要去检查报文首部中的协议标识,以确定接受数据的上层协议。
为什么有了MAC层还要走IP层呢?
mac地址是唯一的,那么理论上,在任何两个设备之间,我应该都可以通过mac地址发送数据,为什么还需要ip地址?
mac地址就好像一个人的身份证,但是和人所在的位置没有关系,人是会移动的,知道一个的身份证号并不能找到这个人,mac地址类似,它是和设备的生产者,批次,日期之类的关联起来,知道一个设备的mac,并不能在网络中将数据发送给它,除非它和发送方在同一个网络内。
所以要实现机器之间的通信,我们还需要有ip地址的概念,ip地址表达的是当前机器在网络中的位置。类似于详细地址的概念。通过ip层的寻址,我们能知道何种路径在全世界任意两台Internet上的机器间传输数据。
TCP/IP的分层管理
TCP/IP协议按照层次分成4层:应用层、传输层、网络层、数据链路层。对于分层这个概念,好比我们的分布式架构体系中会分为业务层、服务层、基础支撑层。比如docker,也是基于分层来实现的。所以我们会发现,复杂的程序都需要分层,这个是软件设计的要求,每层专注于当前领域的事情。如果某些地方需要修改,我们只需要把变动的层替换掉就行了,一方面改动影响较少,另一个方面整个架构的灵活性也更高。最后,在分层之后,整个架构的设计也变的相对简单了。
分层负载
一次http请求过来。一定会从应用层到传输层,完成整个交互。只要是在网络上跑的数据包。都是完整的。可以有下层没有上层,绝对不可能有上层没下层。
二层负载
二层负载是针对MAC,负载均衡服务器对外依然提供一个VIP(虚IP),集群中不同的机器采用相同IP地址,但是机器的MAC地址不一样。当负载均衡服务器接收到请求后,通过改写报文的目标mac地址的方式将请求转发到目标机器实现负载均衡。
二层负载均衡会通过一个虚拟MAC地址接受请求,然后再分配到真实的MAC地址。
三层负载均衡
三层负载是针对IP,和二层负载均衡类似,负载均衡服务器对外依然提供一个VIP(虚IP),但是集群中不同的机器采用不同的IP地址。当负载均衡服务器接收到请求之后,根据不同的负载均衡算法,通过IP将请求转发至不同的真是服务器。
三层负载均衡会通过一个虚拟IP地址接受请求,然后在分配到真实的IP地址。
四层负载均衡
四层均衡负载工作在OSI模型的传输层,由于在传输层,只有TCP/IP协议,这两种协议中包含源IP、目标IP以外,还包含源端口号以及目的端口号。四层负载均衡服务器在接受到客户端请求后,以后通过修改数据库包的地址信息(IP+端口号)将流量转发到应用服务器。
四层通过虚拟IP+端口接受请求,然后再分配到真实的服务器。
七层负载均衡
七层负载均衡工作在OSI模型的应用层,应用层协议较多,常用http、redis、dns等。七层负载均衡就可以基于这些负载协议来负载。这些应用层协议中会包含很多有意义的内容。比如同一个web服务器的负载均衡,除了根据IP加端口进行负载外,还可以根据七层的URL、浏览器类来决定是否要进行负载均衡。
七层通过虚拟的URL或主机名接受请求,然后在分配到真实的服务器。
TCP/IP协议的深入分析
TCP握手协议
所以TCP消息的可靠性首先来自于有效的连接建立。所以在数据进行传输前,需要通过三次握手建立一个连接,所谓的三次握手就是建立TCP连接是,需要客户端和服务端总共发送3个包确认连接的建立,在socket编程中,这个过程由客户端执行connect来触发。
第一次握手(SYN=1,seq=x)
客户端发送一个TCP的SYN标示位置1的包,指明客户端打算连接的服务器的端口,以及初始序号X,保存在包头的序列号(squence Number)字段里。发送完毕后,客户端进入SYN_SEND状态。
第二次握手(SYN=1,ACK=1,seq=y,ACK=x+1)
服务器发回确认包(ACK)应答。即SYN标志位和ACK标志位均为1。服务器端选择自己ISN序列号,放到Seq域里,同时将确认序号(Acknowledgament Number)设置为客户的ISN加1,即X+1。发送完毕后,服务器端进入SYN_RCVD状态。
第三次握手(ACK=1,ACK=y+1)客户端再次发送确认包(ACK),SYN标志位为0,ACK标志位为1,并且把服务器发来ACK的序号字段+1,放在确定字段中发送给对方,并且在数据段写入ISN完毕后,客户端进入ESTABLISHED状态,当服务器端接收到这个包时,也进入ESTABLISHED状态,TCP握手结束。
那TCP在三次握手的时候。ip层和MAC层在做什么呢?当然是TCP发送每一个消息,都会带着IP层和MAC层了。因为,TCP每发送一个消息,IP层和MAC层的所有机制都要运行一遍。而你只能看到TCP三次握手,其实这里面IP和mac也在工作。
SYN攻击
在三次握手过程中,Server发送SYN-ACK之后,收到Client的ACK之前的TCP连接成为半连接(half-open connect),此时Server处于SYN_RCVD状态,当收到ACK后,Server转入ESTABLISHED状态。SYN攻击就是在Client在短时间内伪造大量不存在的IP地址,并向Server不断的发送SYN包,Server回复确认包,并等待Client的确认,由于源地址是不存在的,因此,Server需要不断重发直至超时,这些伪造的SYN包将占用未连接队列,导致正常的SYN请求因为队列满而被丢弃,从而引起网络堵塞甚至系统瘫痪。SYN攻击时一种典型的DDOS攻击,检测SYN攻击的方式非常简单,即当Server上有大量半连接状态且源IP地址是随机的,则可以断定遭到了SYN攻击了。
TCP四次挥手协议
四次挥手表示TCP断开连接的时候,需要客户端和服务端总共发送4个包以确认连接的断开;客户端或服务器均可主动发起挥手动作(因为TCP是一个全双工协议),在socket编程中,任何一方执行close()操作即可产生挥手操作。
单工:数据传输只支持数据在一个方向上传输
半双工:数据传输允许数据在两个方向传输,但是在某一时刻,只允许在一个方向上传输,实际上有点像切换方向的单工
半双工:数据通信允许数据同时在两个方向上传输,因此全双工是连个单工通信方式的结合,它要求发送设备和接受设备都有独立的接受和发送能力
第一次挥手(FIN=1,seq=x)
假设客户想要关闭连接,客户端发送一个FIN标志信息为1的包,表示自己已经没有数据可以发送了,但是仍然可以接受数据。发送完毕后,客户端进入FIN_WAIT_1状态。
第二次挥手(ACK=1,ACK=X+1)
服务端确认客户端FIN包,发送一个确认包,表明自己接受客户端关闭连接的请求,但还没准备好关闭连接。发送完毕后,服务器端进入CLOSE_WAIT状态,客户端接收到这个确认包之后,进入FIN_WAIT_2状态,等待服务器端关闭连接。
第三次挥手(FIN=1,seq=w)
服务器端准备好关闭连接时,向客户端发送结束连接请求,FIN置为1.发送完毕后,服务器端进入LAST_ACK状态,等待来自客户端的最后一个ACK。
第四次挥手(ACK=1,ACK=W+1)
客户端接收到服务器端的关闭请求,发送一个确认包,进入TIME_WAIT状态,等待可能出现的要求重传的ACK包。
服务器端接收到这个确认包之后,关闭连接,进入CLOSED状态。
客户端等待了某个固定时间(两个最大段生命周期,2MSL,2Maximum Segment Lifetime)之后,没有收到服务器端的ACK,认为服务器端已经正常关闭连接,于是自己也关闭连接,进入CLOSED状态。
举例:
假设 Client 端发起中断连接请求,也就是发送 FIN 报文。Server 端接到 FIN 报文后,意思是 说"我 Client 端没有数据要发给你了",但是如果你还有数据没有发送完成,则不必急着关闭 Socket,可以继续发送数据。所以你先发送 ACK,"告诉 Client 端,你的请求我收到了,但是 我还没准备好,请继续你等我的消息"。这个时候 Client 端就进入 FIN_WAIT 状态,继续等待 Server 端的 FIN 报文。当 Server 端确定数据已发送完成,则向 Client 端发送 FIN 报文,"告 诉 Client 端,好了,我这边数据发完了,准备好关闭连接了"。Client 端收到 FIN 报文后,"就 知道可以关闭连接了,但是他还是不相信网络,怕 Server 端不知道要关闭,所以发送 ACK 后 进入 TIME_WAIT 状态,如果 Server 端没有收到 ACK 则可以重传。“,Server 端收到 ACK 后, "就知道可以断开连接了"。Client 端等待了 2MSL 后依然没有收到回复,则证明 Server 端已 正常关闭,那好,我 Client 端也可以关闭连接了。Ok,TCP 连接就这样关闭了!
问题:
为什么连接的时候是三次握手,关闭的时候却是四次握手?
三次握手是因为当server端收到Client端的SYN连接请求报文后,可以直接发送SYN+ACK报文。其中ACK报文是用来应答的,SYN报文是用来同步的。但是关闭连接是,当Server端收到FIN报文时,很可能并不会立即关闭SOCKET(因为可能还有消息没有处理完),所以只能先回复一个ACK报文,告诉Clinet端,“你发送的FIN报文我收到了”。只有等到我Server端所有的报文都发送完了,我才能发送FIN报文,因此不能一起发送。故需要第四步握手。
为什么TIME_WAIT状态需要经过2MSL(最大报文段生存时间)才能返回CLOSE状态?
虽然按道理,四个报文都发送完毕,我们可以直接进入CLOSED状态,但是我们必须假设网络是不可靠的,有可能最后一个ACK丢失。所以TIME_WAIT状态就是用来重发可能丢失的ACK报文。
使用协议来通信(socket)
TCP、UDP 都是在基于 Socket 概念上为某类应用场景而扩展出的传输协议,那么什么是 socket 呢?socket 是一种 抽象层,应用程序通过它来发送和接收数据,就像应用程序打开一个文件句柄,把数据读写 到磁盘上一样。使用 socket 可以把应用程序添加到网络中,并与处于同一个网络中的其他应 用程序进行通信。不同类型的 Socket 与不同类型的底层协议簇有关联。主要的 socket 类型 为流套接字(stream socket)和数据报文套接字(datagram socket)。 stream socket 把 TCP 作为端对端协议(底层使用 IP 协议),提供一个可信赖的字节流服务。数据报文套接字 (datagram socket)使用 UDP 协议(底层同样使用 IP 协议)提供了一种“尽力而为”的数据 报文服务。
基于TCP协议实现通信
实现一个简单的从客户端发送一个消息到服务端的功能
//server端
public class ServerTest {
public static void main(String[] args) {
ServerSocket serverSocket = null;
BufferedReader bufferedReader = null;
try {
/**
* TCP 的服务端要先监听一个端口,一般是先调用bind 函数,给这个 Socket 赋予一个 IP 地址和端
* 口。为什么需要端口呢?要知道,你写的是一个应用程序,当一个网络包来的时候,内核要通过 TCP 头里
* 面的这个端口,来找到你这个应用程序,把包给你。为什么要 IP 地址呢?有时候,一台机器会有多个网
* 卡,也就会有多个 IP 地址,你可以选择监听所有的网卡,也可以选择监听一个网卡,这样,只有发给这
* 个网卡的包,才会给你。
*/
serverSocket = new ServerSocket(8084);
/**
* 阻塞等待客户端连接
* 接下来,服务端调用 accept 函数,拿出一个已经完成的连接进行处理。如果还没有完成,就要等着。
*/
Socket accept = serverSocket.accept();
/**
* 连接建立成功之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一 样。
*/
bufferedReader = new BufferedReader(new InputStreamReader(accept.getInputStream()));
System.out.println(bufferedReader.readLine());
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
bufferedReader.close();
} catch (IOException e) {
e.printStackTrace();
}
if (bufferedReader != null) {
try {
bufferedReader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
//client端
public static void main(String[] args) {
Socket socket = null;
PrintWriter writer = null;
try {
socket = new Socket("127.0.0.1", 8084);
writer = new PrintWriter(socket.getOutputStream(), true);
writer.println("hello world");
} catch (Exception e) {
e.printStackTrace();
} finally {
if (socket != null) {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (writer != null) {
writer.close();
}
}
}
基于TCP实现双向通信对话功能
TCP是一个全双工协议,数据通信允许数据同时在两个方向传输,因此全双工是两个单工通信方式的结合,它要求发送设备和接收设备都有独立的接受和发送能力。
socket链接建立以及通信的模型
理解TCP的通信原理及IO阻塞
通过上面简单的案例,基本清楚了在java应用程序中如何使用socket套接字来建立一个基于tcp协议的通信流程。
了解TCP协议的通信过程
对TCP通信来说,每个TCP Socket的内核中都有一个发送缓存区和一个接收缓冲区,TCP的全双工的工作模式及TCP的滑动窗口就是依赖于这两个独立的Buffer和该Buffer的填充状态。
接收缓冲区把数据缓存到内核,如应用进程一直没有调用Socket的read方法进行读取,那么该数据会一直被缓存在缓冲区内。不管进程是否读取Socket,对端发来的数据都会经过内核接收并缓存到Socket的内核接收缓冲区。
read所要做的工作,就是把内核接收缓冲区中的数据复制到应用层用户的Buffer里。
进程调用Socket的send发送数据的时候,一般情况下是将数据从应用层用户的Buffer里复制到Socket的内核发送缓冲区,然后send就会在上层返回。换句话说,send返回时,数据不一定会被发送到对端。
前面提到,Socket的接受缓冲区被TCP用来缓存网络上收到的数据,一直保存到应用进程读走为止。如果应用进程一直没有读取,那么Buffer满了以后,出现的情况是:通过对端TCP协议中的窗口关闭,保证tcp接收缓冲区不会移除,保证了TCP是可靠传输的。如果对方无视窗口大小发出超出窗口大小的数据,那么接收方会把这些数据丢弃。
滑动窗口协议
这个过程涉及到了TCP的滑动窗口协议,滑动窗口是一种流量控制技术。早期事务网络通信中,通信双方都不会考虑网络的拥挤情况直接发送数据。由于大家不知道网络拥塞状态,同时发送数据,导致中间节点阻塞掉包,谁也发不了数据,所有就有了滑动窗口机制来解决此问题;发送和接收方都会维护一个数据帧的序列,这个序列被称作窗口。
发送窗口
就是发送端允许连续发送的帧的序号表。
发送端可以不等待应答而连续发送的最大帧称为发送窗口的尺寸。
接受窗口
接收方允许接收的帧的序号表,凡落在接收窗口内的帧,接收方都必须处理,落在接收窗口外的帧被丢弃。
接收方每次允许接收的帧数称为接收窗口的尺寸。
在线滑动窗口演示功能
https://media.pearsoncmg.com/aw/ecs_kurose_compnetwork_7/cw/content/interactiveanima tions/selective-repeat-protocol/index.html
阻塞队列
在前面的代码演示中,我们通过 socket.accept 去接收一个客户端请求,accept 是一个阻塞的方法,意味着 TCP 服务器一次 只能处理一个客户端请求,当一个客户端向一个已经被其他客户端占用的服务器发送连接请 求时,虽然在连接建立后可以向服务端发送数据,但是在服务端处理完之前的请求之前,却 不会对新的客户端做出响应,这种类型的服务器称为“迭代服务器”。迭代服务器是按照顺序处 理客户端请求,也就是服务端必须要处理完前一个请求才能对下一个客户端的请求进行响应。 但是在实际应用中,我们不能接收这样的处理方式。所以我们需要一种方法可以独立处理每 一个连接,并且他们之间不会相互干扰。而 Java 提供的多线程技术刚好满足这个需求,这个机制使得服务器能够方便处理多个客户端的请求。
一个客户端对应一个线程
为每一个客户端创建一个线程实际上会存在一些弊端,因为创建一个线程需要占用CPU的资源和内存资源。另外,随着线程数增加,系统资源将会成为瓶颈最终达到一个不可控的状态,所以我们还可以通过线程池来实现多个客户端请求的功能,因为线程池是可控的。
非阻塞模型
上面这种模型虽然优化了IO的处理方式,但是,不管是线程池还是单个线程,线程本身的处理个数是有限的,对于操作系统来说,如果线程数太多会造成CPU上下文切换的开销。因此这种方式不能解决根本问题。
所以在java1.4以后,引入了NIO(new IO)的功能。
阻塞IO
当客户端的数据从网卡缓冲区复制到内核缓冲区之前,服务端一直会阻塞。以socket接口为例,进程空间中调用recvfrom,进程从调用recvfrom开始到它返回的整段时间内都是被阻塞的,因此被称为阻塞IO模型。
非阻塞IO
如果我们希望这台服务器能够处理更多的连接,如何去优化?
使用非阻塞IO模型,非阻塞IO模型的原理就是进程空间调用recvfrom,如果这个时候内核缓冲区没有数据的话,就直接返回一个EWOULDBLOCK错误。然后应用程序通过不断轮询来检查这个状态,看内核是不是有数据过来了。
I/O复用模型
I/O多路复用的本质是通过一种机制(系统内核缓冲I/O数据),让单个线程可以监视多个文件描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。
什么是fd:在inunx中,内核所有的外部设备都当成是一个文件来操作,对一个文件的读写会调用内核提供的系统命令,返回一个fd(文件描述符)。而对于一个socket的读写也会有相应的文件描述符,成为socketfd
常见的IO多路复用方式有【select、poll、epoll】,都是LinuxAPI提供的IO复用方式。
select:
进程可以通过把一个或者多个fd传递给select系统调用,进程会阻塞在select操作上 ,这样select可以帮我们检测多个fd是否处于就绪状态。
这个模式有两个缺点
1.由于它能够同时监听多个文件描述符,假如说有1000个,这个时候如果其中一个fd处于就绪状态,那么当前进程需要线性轮询所有的fd,也是就监听的fd越多,性能开销越大。
2.同时,select在单个进程中能打开的fd是有限制的,默认是1024,对于那些需要支持单机上万的TCP连接来说确实有些少。
epoll:
linux还提供了epoll的系统调用,epoll是基于事件驱动方式来代替顺序描述,因此性能相对来说更高,主要原理是,当被监听的fd中,有fd就绪时,会告知当前进程具体哪一个fd就绪,那么当前进程只需要去从指定的fd上读取数据即可。
另外。epoll所能支持的fd上线是操作系统的最大文件句柄,这个额数字要远远大于1024.
【由于epoll能够通过事件告知应用进程哪个fd是可读的,所以我们也称这种IO为异步非阻塞,当然它是伪异步的,因为它还需要去把数据从内核同步复制到用户空间中,真正的异步非阻塞,应该是数据已经完全准备好了,我只需要从用户空间读就行】
多路复用的好处
I/O多路复用可以通过把多个I/O的阻塞复用到同一个select的阻塞上,从而使得系统在在单线程的情况下可以同时处理多个客户端请求。它的最大优势是系统开销小,并不需要创建新的进程或者线程,降低了系统的资源开销。
一台机器理论上能支持的连接数
首先,在确定最大连接数之间,需要先了解一下系统如何标识一个tcp连接。系统用一个四元组来唯一标识一个TCP连接:(source_ip,source_port,destination_ip,destination_port)。即(源IP,源端口,目的IP,目的端口)四个元素的组合中有一个元素不一样,那就可以区别不同的连接。
比如:
你的 IP 地址是 11.1.2.3, 在 8080 端口监听那么当一个来自 22.4.5.6 ,端口为 5555 的连接到达后,那么建立的这条连接的四元组为 : (11.1.2.3, 8080, 22.4.5.6, 5555) 这时,假设上面的那个客户(22.4.5.6)发来第二条连接请求,端口为 6666,那么,新连接 的四元组为(11.1.2.3, 8080, 22.4.5.6, 5555)那么,你主机的 8080 端口建立了两条连接;
通常来说,服务端是固定一个监听端口,比如8080,等待客户端的连接请求。在不考虑地址重用的情况下,及时server端有多个ip,但是本地监听的端口是独立的。所以对于tcp连接的4元组中,如果destination_ip和destination_port不变。那么只有source_ip和source_port是可变的,因此最大的tcp连接数应该为客户端的ip数乘以客户端的端口数。在IPV4中,不考虑ip分类等因素,最大的ip数为2的32次方;客户端最大的端口数为2的16次方,也就是65536.也就是服务端单机最大的tcp连接数约为2的48次方。
当然这只是一个理论值,以linux服务器为例,实际的连接数还要取决于:
1.内存大小(因为每个tcp连接都需要占用一定的内存)
2.文件句柄限制,每一个tcp连接都需要占用一个文件描述符,一旦这个文件描述符使用完了,新来的连接会返回一个”can't open so many files“的异常。那么如何去调整呢?
a)可以执行【ulimit -n】得到当前一个进程最大能打开1024个文件,所以要采用此默认配置最多也就是可以并发上千个TCP连接
b)可以通过【vim /etc/security/limits.conf】去修改系统最大文件打开数的限制
* soft nofile 2048
* hard nofile 2048
* 表示修改所有用户限制、soft/hard 表示软限制还是硬限制,2048 表示修改以后的值
c)可以通过【cat /proc/sys/fs/file-max】查看linux系统级最大打开文件数限制,表示当前这个服务器最多能同时打开多少个文件
3.带宽资源的限制