03 | 复杂度分析(上):如何分析、统计算法的执行效率和资源消耗

我们都知道,数据结构和算法本身解决的是“快”和“省”的问题,即如何让代码运行得更快,如何让代码更省存储空间。所以,执行效率是算法一个非常重要的考量指标。那如何来衡量你编写的算法代码的执行效率呢?这里就要用到我们今天要讲的内容:时间、空间复杂度分析。

其实,只要讲到数据结构与算法,就一定离不开时间、空间复杂度分析。而且,我个人认为,复杂度分析是整个算法学习的精髓,只要掌握了它,数据结构和算法的内容基本上就掌握了一半

复杂度分析实在太重要了,因此我准备用两节内容来讲。希望你学完这个内容之后,无论在任何场景下,面对任何代码的复杂度分析,你都能做到“庖丁解牛”般游刃有余。

为什么需要复杂度分析?

你可能会有些疑惑,我把代码跑一遍,通过统计、监控,就能得到算法执行的时间和占用的内存大小。为什么还要做时间、空间复杂度分析呢?这种分析方法能比我实实在在跑一遍得到的数据更准确吗?

首先,我可以肯定地说,你这种评估算法执行效率的方法是正确的。很多数据结构和算法书籍还给这种方法起了一个名字,叫事后统计法。但是,这种统计方法有非常大的局限性。

1. 测试结果非常依赖测试环境

测试环境中硬件的不同会对测试结果有很大的影响。比如,我们拿同样一段代码,分别用 Intel Core i9 处理器和 Intel Core i3 处理器来运行,不用说,i9 处理器要比 i3 处理器执行的速度快很多。还有,比如原本在这台机器上 a 代码执行的速度比 b 代码要快,等我们换到另一台机器上时,可能会有截然相反的结果。

2. 测试结果受数据规模的影响很大

后面我们会讲排序算法,我们先拿它举个例子。对同一个排序算法,待排序数据的有序度不一样,排序的执行时间就会有很大的差别。极端情况下,如果数据已经是有序的,那排序算法不需要做任何操作,执行时间就会非常短。除此之外,如果测试数据规模太小,测试结果可能无法真实地反应算法的性能。比如,对于小规模的数据排序,插入排序可能反倒会比快速排序要快!

所以,我们需要一个不用具体的测试数据来测试,就可以粗略地估计算法的执行效率的方法。这就是我们今天要讲的时间、空间复杂度分析方法。

大 O 复杂度表示法

算法的执行效率,粗略地讲,就是算法代码执行的时间。但是,如何在不运行代码的情况下,用“肉眼”得到一段代码的执行时间呢?

这里有段非常简单的代码,求 1,2,3…n 的累加和。现在,我就带你一块来估算一下这段代码的执行时间。

1
2
3
4
5
6
7
8
int cal(int n) {
int sum = 0;
int i = 1;
for (; i <= n; ++i) {
sum = sum + i;
}
return sum;
}

从 CPU 的角度来看,这段代码的每一行都执行着类似的操作:读数据-运算-写数据。尽管每行代码对应的 CPU 执行的个数、执行的时间都不一样,但是,我们这里只是粗略估计,所以可以假设每行代码执行的时间都一样,为 unit_time。在这个假设的基础之上,这段代码的总执行时间是多少呢?

第 2、3 行代码分别需要 1 个 unit_time 的执行时间,第 4、5 行都运行了 n 遍,所以需要 2nunit_time 的执行时间,所以这段代码总的执行时间就是 (2n+2)unit_time。可以看出来,所有代码的执行时间 T(n) 与每行代码的执行次数成正比

按照这个分析思路,我们再来看这段代码。

1
2
3
4
5
6
7
8
9
10
11
int cal(int n) {
int sum = 0;
int i = 1;
int j = 1;
for (; i <= n; ++i) {
j = 1;
for (; j <= n; ++j) {
sum = sum + i * j;
}
}
}

我们依旧假设每个语句的执行时间是 unit_time。那这段代码的总执行时间 T(n) 是多少呢?

第 2、3、4 行代码,每行都需要 1 个 unit_time 的执行时间,第 5、6 行代码循环执行了 n 遍,需要 2n unit_time 的执行时间,第 7、8 行代码循环执行了 n2遍,所以需要 2n2 unit_time 的执行时间。所以,整段代码总的执行时间 T(n) = (2n2+2n+3)*unit_time。

尽管我们不知道 unit_time 的具体值,但是通过这两段代码执行时间的推导过程,我们可以得到一个非常重要的规律,那就是,所有代码的执行时间 T(n) 与每行代码的执行次数 n 成正比

我们可以把这个规律总结成一个公式。注意,大 O 就要登场了!

img

我来具体解释一下这个公式。其中,T(n) 我们已经讲过了,它表示代码执行的时间;n 表示数据规模的大小;f(n) 表示每行代码执行的次数总和。因为这是一个公式,所以用 f(n) 来表示。公式中的 O,表示代码的执行时间 T(n) 与 f(n) 表达式成正比。

所以,第一个例子中的 T(n) = O(2n+2),第二个例子中的 T(n) = O(2n2+2n+3)。这就是大 O 时间复杂度表示法。大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度(asymptotic time complexity),简称时间复杂度

当 n 很大时,你可以把它想象成 10000、100000。而公式中的低阶、常量、系数三部分并不左右增长趋势,所以都可以忽略。我们只需要记录一个最大量级就可以了,如果用大 O 表示法表示刚讲的那两段代码的时间复杂度,就可以记为:T(n) = O(n); T(n) = O(n2)。

时间复杂度分析

前面介绍了大 O 时间复杂度的由来和表示方法。现在我们来看下,如何分析一段代码的时间复杂度?我这儿有三个比较实用的方法可以分享给你。

1. 只关注循环执行次数最多的一段代码

我刚才说了,大 O 这种复杂度表示方法只是表示一种变化趋势。我们通常会忽略掉公式中的常量、低阶、系数,只需要记录一个最大阶的量级就可以了。所以,我们在分析一个算法、一段代码的时间复杂度的时候,也只关注循环执行次数最多的那一段代码就可以了。这段核心代码执行次数的 n 的量级,就是整段要分析代码的时间复杂度。

为了便于你理解,我还拿前面的例子来说明。

1
2
3
4
5
6
7
8
int cal(int n) {
int sum = 0;
int i = 1;
for (; i <= n; ++i) {
sum = sum + i;
}
return sum;
}

其中第 2、3 行代码都是常量级的执行时间,与 n 的大小无关,所以对于复杂度并没有影响。循环执行次数最多的是第 4、5 行代码,所以这块代码要重点分析。前面我们也讲过,这两行代码被执行了 n 次,所以总的时间复杂度就是 O(n)。

2. 加法法则:总复杂度等于量级最大的那段代码的复杂度

我这里还有一段代码。你可以先试着分析一下,然后再往下看跟我的分析思路是否一样。

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
int cal(int n) {
int sum_1 = 0;
int p = 1;
for (; p < 100; ++p) {
sum_1 = sum_1 + p;
}

int sum_2 = 0;
int q = 1;
for (; q < n; ++q) {
sum_2 = sum_2 + q;
}

int sum_3 = 0;
int i = 1;
int j = 1;
for (; i <= n; ++i) {
j = 1;
for (; j <= n; ++j) {
sum_3 = sum_3 + i * j;
}
}

return sum_1 + sum_2 + sum_3;
}

这个代码分为三部分,分别是求 sum_1、sum_2、sum_3。我们可以分别分析每一部分的时间复杂度,然后把它们放到一块儿,再取一个量级最大的作为整段代码的复杂度。

第一段的时间复杂度是多少呢?这段代码循环执行了 100 次,所以是一个常量的执行时间,跟 n 的规模无关。

这里我要再强调一下,即便这段代码循环 10000 次、100000 次,只要是一个已知的数,跟 n 无关,照样也是常量级的执行时间。当 n 无限大的时候,就可以忽略。尽管对代码的执行时间会有很大影响,但是回到时间复杂度的概念来说,它表示的是一个算法执行效率与数据规模增长的变化趋势,所以不管常量的执行时间多大,我们都可以忽略掉。因为它本身对增长趋势并没有影响。

那第二段代码和第三段代码的时间复杂度是多少呢?答案是 O(n) 和 O(n2),你应该能容易就分析出来,我就不啰嗦了。

综合这三段代码的时间复杂度,我们取其中最大的量级。所以,整段代码的时间复杂度就为 O(n2)。也就是说:总的时间复杂度就等于量级最大的那段代码的时间复杂度。那我们将这个规律抽象成公式就是:

如果 T1(n)=O(f(n)),T2(n)=O(g(n));那么 T(n)=T1(n)+T2(n)=max(O(f(n)), O(g(n))) =O(max(f(n), g(n))).

3. 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积

我刚讲了一个复杂度分析中的加法法则,这儿还有一个乘法法则。类比一下,你应该能“猜到”公式是什么样子的吧?

如果 T1(n)=O(f(n)),T2(n)=O(g(n));那么 T(n)=T1(n)T2(n)=O(f(n))O(g(n))=O(f(n)*g(n)).

也就是说,假设 T1(n) = O(n),T2(n) = O(n2),则 T1(n) * T2(n) = O(n3)。落实到具体的代码上,我们可以把乘法法则看成是嵌套循环,我举个例子给你解释一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int cal(int n) {
int ret = 0;
int i = 1;
for (; i < n; ++i) {
ret = ret + f(i);
}
}

int f(int n) {
int sum = 0;
int i = 1;
for (; i < n; ++i) {
sum = sum + i;
}
return sum;
}

我们单独看 cal() 函数。假设 f() 只是一个普通的操作,那第 4~6 行的时间复杂度就是,T1(n) = O(n)。但 f() 函数本身不是一个简单的操作,它的时间复杂度是 T2(n) = O(n),所以,整个 cal() 函数的时间复杂度就是,T(n) = T1(n) T2(n) = O(nn) = O(n2)。

我刚刚讲了三种复杂度的分析技巧。不过,你并不用刻意去记忆。实际上,复杂度分析这个东西关键在于“熟练”。你只要多看案例,多分析,就能做到“无招胜有招”。

几种常见时间复杂度实例分析

虽然代码千差万别,但是常见的复杂度量级并不多。我稍微总结了一下,这些复杂度量级几乎涵盖了你今后可以接触的所有代码的复杂度量级。

img

对于刚罗列的复杂度量级,我们可以粗略地分为两类,多项式量级非多项式量级。其中,非多项式量级只有两个:O(2n) 和 O(n!)。

当数据规模 n 越来越大时,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长。所以,非多项式时间复杂度的算法其实是非常低效的算法。因此,关于 NP 时间复杂度我就不展开讲了。我们主要来看几种常见的多项式时间复杂度

1. O(1)

首先你必须明确一个概念,O(1) 只是常量级时间复杂度的一种表示方法,并不是指只执行了一行代码。比如这段代码,即便有 3 行,它的时间复杂度也是 O(1),而不是 O(3)。

1
2
3
int i = 8;
int j = 6;
int sum = i + j;

我稍微总结一下,只要代码的执行时间不随 n 的增大而增长,这样代码的时间复杂度我们都记作 O(1)。或者说,一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度也是Ο(1)

2. O(logn)、O(nlogn)

对数阶时间复杂度非常常见,同时也是最难分析的一种时间复杂度。我通过一个例子来说明一下。

1
2
3
4
i=1;
while (i <= n) {
i = i * 2;
}

根据我们前面讲的复杂度分析方法,第三行代码是循环执行次数最多的。所以,我们只要能计算出这行代码被执行了多少次,就能知道整段代码的时间复杂度。

从代码中可以看出,变量 i 的值从 1 开始取,每循环一次就乘以 2。当大于 n 时,循环结束。还记得我们高中学过的等比数列吗?实际上,变量 i 的取值就是一个等比数列。如果我把它一个一个列出来,就应该是这个样子的:

img

所以,我们只要知道 x 值是多少,就知道这行代码执行的次数了。通过 2x=n 求解 x 这个问题我们想高中应该就学过了,我就不多说了。x=log2n,所以,这段代码的时间复杂度就是 O(log2n)。

现在,我把代码稍微改下,你再看看,这段代码的时间复杂度是多少?

1
2
3
4
i=1;
while (i <= n) {
i = i * 3;
}

根据我刚刚讲的思路,很简单就能看出来,这段代码的时间复杂度为 O(log3n)。

实际上,不管是以 2 为底、以 3 为底,还是以 10 为底,我们可以把所有对数阶的时间复杂度都记为 O(logn)。为什么呢?

我们知道,对数之间是可以互相转换的,log3n 就等于 log32 log2n,所以 O(log3n) = O(C log2n),其中 C=log32 是一个常量。基于我们前面的一个理论:在采用大 O 标记复杂度的时候,可以忽略系数,即 O(Cf(n)) = O(f(n))。所以,O(log2n) 就等于 O(log3n)。因此,在对数阶时间复杂度的表示方法里,我们忽略对数的“底”,统一表示为 O(logn)。

如果你理解了我前面讲的 O(logn),那 O(nlogn) 就很容易理解了。还记得我们刚讲的乘法法则吗?如果一段代码的时间复杂度是 O(logn),我们循环执行 n 遍,时间复杂度就是 O(nlogn) 了。而且,O(nlogn) 也是一种非常常见的算法时间复杂度。比如,归并排序、快速排序的时间复杂度都是 O(nlogn)。

3. O(m+n)、O(m*n)

我们再来讲一种跟前面都不一样的时间复杂度,代码的复杂度由两个数据的规模来决定。老规矩,先看代码!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int cal(int m, int n) {
int sum_1 = 0;
int i = 1;
for (; i < m; ++i) {
sum_1 = sum_1 + i;
}

int sum_2 = 0;
int j = 1;
for (; j < n; ++j) {
sum_2 = sum_2 + j;
}

return sum_1 + sum_2;
}

从代码中可以看出,m 和 n 是表示两个数据规模。我们无法事先评估 m 和 n 谁的量级大,所以我们在表示复杂度的时候,就不能简单地利用加法法则,省略掉其中一个。所以,上面代码的时间复杂度就是 O(m+n)。

针对这种情况,原来的加法法则就不正确了,我们需要将加法规则改为:T1(m) + T2(n) = O(f(m) + g(n))。但是乘法法则继续有效:T1(m)T2(n) = O(f(m) f(n))。

空间复杂度分析

前面,咱们花了很长时间讲大 O 表示法和时间复杂度分析,理解了前面讲的内容,空间复杂度分析方法学起来就非常简单了。

前面我讲过,时间复杂度的全称是渐进时间复杂度表示算法的执行时间与数据规模之间的增长关系。类比一下,空间复杂度全称就是渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系

我还是拿具体的例子来给你说明。(这段代码有点“傻”,一般没人会这么写,我这么写只是为了方便给你解释。)

1
2
3
4
5
6
7
8
9
10
11
void print(int n) {
int i = 0;
int[] a = new int[n];
for (i; i <n; ++i) {
a[i] = i * i;
}

for (i = n-1; i >= 0; --i) {
print out a[i]
}
}

跟时间复杂度分析一样,我们可以看到,第 2 行代码中,我们申请了一个空间存储变量 i,但是它是常量阶的,跟数据规模 n 没有关系,所以我们可以忽略。第 3 行申请了一个大小为 n 的 int 类型数组,除此之外,剩下的代码都没有占用更多的空间,所以整段代码的空间复杂度就是 O(n)。

我们常见的空间复杂度就是 O(1)、O(n)、O(n2 ),像 O(logn)、O(nlogn) 这样的对数阶复杂度平时都用不到。而且,空间复杂度分析比时间复杂度分析要简单很多。所以,对于空间复杂度,掌握刚我说的这些内容已经足够了。

内容小结

基础复杂度分析的知识到此就讲完了,我们来总结一下。

复杂度也叫渐进复杂度,包括时间复杂度和空间复杂度,用来分析算法执行效率与数据规模之间的增长关系,可以粗略地表示,越高阶复杂度的算法,执行效率越低。常见的复杂度并不多,从低阶到高阶有:O(1)、O(logn)、O(n)、O(nlogn)、O(n2 )。等你学完整个专栏之后,你就会发现几乎所有的数据结构和算法的复杂度都跑不出这几个。

img

复杂度分析并不难,关键在于多练。 之后讲后面的内容时,我还会带你详细地分析每一种数据结构和算法的时间、空间复杂度。只要跟着我的思路学习、练习,你很快就能和我一样,每次看到代码的时候,简单的一眼就能看出其复杂度,难的稍微分析一下就能得出答案。

极客时间版权所有:https://time.geekbang.org/column/article/40036

02 | 如何抓住重点,系统高效地学习数据结构与算法

你是否曾跟我一样,因为看不懂数据结构和算法,而一度怀疑是自己太笨?实际上,很多人在第一次接触这门课时,都会有这种感觉,觉得数据结构和算法很抽象,晦涩难懂,宛如天书。正是这个原因,让很多初学者对这门课望而却步。

我个人觉得,其实真正的原因是你没有找到好的学习方法没有抓住学习的重点。实际上,数据结构和算法的东西并不多,常用的、基础的知识点更是屈指可数。只要掌握了正确的学习方法,学起来并没有看上去那么难,更不需要什么高智商、厚底子。

还记得大学里每次考前老师都要划重点吗?今天,我就给你划划我们这门课的重点,再告诉你一些我总结的学习小窍门。相信有了这些之后,你学起来就会有的放矢、事半功倍了。

什么是数据结构?什么是算法?

大部分数据结构和算法教材,在开篇都会给这两个概念下一个明确的定义。但是,这些定义都很抽象,对理解这两个概念并没有实质性的帮助,反倒会让你陷入死抠定义的误区。毕竟,我们现在学习,并不是为了考试,所以,概念背得再牢,不会用也就没什么用。

虽然我们说没必要深挖严格的定义,但是这并不等于不需要理解概念。 下面我就从广义和狭义两个层面,来帮你理解数据结构与算法这两个概念。

从广义上讲,数据结构就是指一组数据的存储结构。算法就是操作数据的一组方法。

图书馆储藏书籍你肯定见过吧?为了方便查找,图书管理员一般会将书籍分门别类进行“存储”。按照一定规律编号,就是书籍这种“数据”的存储结构。

那我们如何来查找一本书呢?有很多种办法,你当然可以一本一本地找,也可以先根据书籍类别的编号,是人文,还是科学、计算机,来定位书架,然后再依次查找。笼统地说,这些查找方法都是算法。

从狭义上讲,也就是我们专栏要讲的,是指某些著名的数据结构和算法,比如队列、栈、堆、二分查找、动态规划等。这些都是前人智慧的结晶,我们可以直接拿来用。我们要讲的这些经典数据结构和算法,都是前人从很多实际操作场景中抽象出来的,经过非常多的求证和检验,可以高效地帮助我们解决很多实际的开发问题。

那数据结构和算法有什么关系呢?为什么大部分书都把这两个东西放到一块儿来讲呢?

这是因为,数据结构和算法是相辅相成的。数据结构是为算法服务的,算法要作用在特定的数据结构之上。 因此,我们无法孤立数据结构来讲算法,也无法孤立算法来讲数据结构。

比如,因为数组具有随机访问的特点,常用的二分查找算法需要用数组来存储数据。但如果我们选择链表这种数据结构,二分查找算法就无法工作了,因为链表并不支持随机访问。

数据结构是静态的,它只是组织数据的一种方式。如果不在它的基础上操作、构建算法,孤立存在的数据结构就是没用的。

现在你对数据结构与算法是不是有了比较清晰的理解了呢?有了这些储备,下面我们来看看,究竟该怎么学数据结构与算法。

学习这个专栏需要什么基础?

看到数据结构和算法里的“算法”两个字,很多人就会联想到“数学”,觉得算法会涉及到很多深奥的数学知识。那我数学基础不是很好,学起来会不会很吃力啊?

数据结构和算法课程确实会涉及一些数学方面的推理、证明,尤其是在分析某个算法的时间、空间复杂度的时候,但是这个你完全不需要担心。

这个专栏不会像《算法导论》那样,里面有非常复杂的数学证明和推理。我会由浅入深,从概念到应用,一点一点给你解释清楚。你只要有高中数学水平,就完全可以学习。

当然,我希望你最好有些编程基础,如果有项目经验就更好了。这样我给你讲数据结构和算法如何提高效率、如何节省存储空间,你就会有很直观的感受。因为,对于每个概念和实现过程,我都会从实际场景出发,不仅教你“是什么”,还会教你“为什么”,并且告诉你遇到同类型问题应该“怎么做”。

学习的重点在什么地方?

提到数据结构和算法,很多人就很头疼,因为这里面的内容实在是太多了。这里,我就帮你梳理一下,应该先学什么,后学什么。你可以对照看看,你属于哪个阶段,然后有针对地进行学习。

想要学习数据结构与算法,首先要掌握一个数据结构与算法中最重要的概念——复杂度分析。

这个概念究竟有多重要呢?可以这么说,它几乎占了数据结构和算法这门课的半壁江山,是数据结构和算法学习的精髓。

数据结构和算法解决的是如何更省、更快地存储和处理数据的问题,因此,我们就需要一个考量效率和资源消耗的方法,这就是复杂度分析方法。所以,如果你只掌握了数据结构和算法的特点、用法,但是没有学会复杂度分析,那就相当于只知道操作口诀,而没掌握心法。只有把心法了然于胸,才能做到无招胜有招!

所以,复杂度分析这个内容,我会用很大篇幅给你讲透。你也一定要花大力气来啃,必须要拿下,并且要搞得非常熟练。否则,后面的数据结构和算法也很难学好。

搞定复杂度分析,下面就要进入数据结构与算法的正文内容了。

为了让你对数据结构和算法能有个全面的认识,我画了一张图,里面几乎涵盖了所有数据结构和算法书籍中都会讲到的知识点。

img
(图谱内容较多,建议长按保存后浏览)

但是,作为初学者,或者一个非算法工程师来说,你并不需要掌握图里面的所有知识点。很多高级的数据结构与算法,比如二分图、最大流等,这些在我们平常的开发中很少会用到。所以,你暂时可以不用看。我还是那句话,咱们学习要学会找重点。如果不分重点地学习,眉毛胡子一把抓,学起来肯定会比较吃力。

所以,结合我自己的学习心得,还有这些年的面试、开发经验,我总结了20 个最常用的、最基础数据结构与算法,不管是应付面试还是工作需要,只要集中精力逐一攻克这 20 个知识点就足够了。

这里面有 10 个数据结构:数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、Trie 树;10 个算法:递归、排序、二分查找、搜索、哈希算法、贪心算法、分治算法、回溯算法、动态规划、字符串匹配算法。

掌握了这些基础的数据结构和算法,再学更加复杂的数据结构和算法,就会非常容易、非常快。

在学习数据结构和算法的过程中,你也要注意,不要只是死记硬背,不要为了学习而学习,而是要学习它的“来历”“自身的特点”“适合解决的问题”以及“实际的应用场景”。对于每一种数据结构或算法,我都会从这几个方面进行详细讲解。只要你掌握了我每节课里讲的内容,就能在开发中灵活应用。

学习数据结构和算法的过程,是非常好的思维训练的过程,所以,千万不要被动地记忆,要多辩证地思考,多问为什么。如果你一直这么坚持做,你会发现,等你学完之后,写代码的时候就会不由自主地考虑到很多性能方面的事情,时间复杂度、空间复杂度非常高的垃圾代码出现的次数就会越来越少。你的编程内功就真正得到了修炼。

一些可以让你事半功倍的学习技巧

前面我给你划了学习的重点,也讲了学习这门课需要具备的基础。作为一个过来人,现在我就给你分享一下,专栏学习的一些技巧。掌握了这些技巧,可以让你化被动为主动,学起来更加轻松,更加有动力!

1. 边学边练,适度刷题

“边学边练”这一招非常有用。建议你每周花 1~2 个小时的时间,集中把这周的三节内容涉及的数据结构和算法,全都自己写出来,用代码实现一遍。这样一定会比单纯地看或者听的效果要好很多!

有面试需求的同学,可能会问了,那我还要不要去刷题呢?

我个人的观点是可以“适度”刷题,但一定不要浪费太多时间在刷题上。我们学习的目的还是掌握,然后应用。除非你要面试 Google、Facebook 这样的公司,它们的算法题目非常非常难,必须大量刷题,才能在短期内提升应试正确率。如果是应对国内公司的技术面试,即便是 BAT 这样的公司,你只要彻底掌握这个专栏的内容,就足以应对。

2. 多问、多思考、多互动

学习最好的方法是,找到几个人一起学习,一块儿讨论切磋,有问题及时寻求老师答疑。 但是,离开大学之后,既没有同学也没有老师,这个条件就比较难具备了。

不过,这也就是咱们专栏学习的优势。专栏里有很多跟你一样的学习者。你可以多在留言区写下自己的疑问、思考和总结,也可以经常看看别人的留言,和他们进行互动。

除此之外,如果你有疑问,你可以随时在留言区给我留言,我只要有空就会及时回复你。你不要担心问的问题太小白。因为我初学的时候,也常常会被一些小白问题困扰。不懂一点都不丢人,只要你勇敢提出来,我们一起解决了就可以了。

我也会力争每节课都最大限度地给你讲透,帮你扫除知识盲点,而你要做的就是,避免一知半解,要想尽一切办法去搞懂我讲的所有内容。

3. 打怪升级学习法

学习的过程中,我们碰到最大的问题就是,坚持不下来。 是的,很多基础课程学起来都非常枯燥。为此,我自己总结了一套“打怪升级学习法”。

游戏你肯定玩过吧?为什么很多看起来非常简单又没有乐趣的游戏,你会玩得不亦乐乎呢?这是因为,当你努力打到一定级别之后,每天看着自己的经验值、战斗力在慢慢提高,那种每天都在一点一点成长的成就感就不由自主地产生了。

所以,我们在枯燥的学习过程中,也可以给自己设立一个切实可行的目标,就像打怪升级一样。

比如,针对这个专栏,你就可以设立这样一个目标:每节课后的思考题都认真思考,并且回复到留言区。当你看到很多人给你点赞之后,你就会为了每次都能发一个漂亮的留言,而更加认真地学习。

当然,还有很多其他的目标,比如,每节课后都写一篇学习笔记或者学习心得;或者你还可以每节课都找一下我讲得不对、不合理的地方……诸如此类,你可以总结一个适合你的“打怪升级攻略”。

如果你能这样学习一段时间,不仅能收获到知识,你还会有意想不到的成就感。因为,这其实帮你改掉了一点学习的坏习惯。这个习惯一旦改掉了,你的人生也会变得不一样。

4. 知识需要沉淀,不要想试图一下子掌握所有

在学习的过程中,一定会碰到“拦路虎”。如果哪个知识点没有怎么学懂,不要着急,这是正常的。因为,想听一遍、看一遍就把所有知识掌握,这肯定是不可能的。学习知识的过程是反复迭代、不断沉淀的过程。

如果碰到“拦路虎”,你可以尽情地在留言区问我,也可以先沉淀一下,过几天再重新学一遍。所谓,书读百遍其义自见,我觉得是很有道理的!

我讲的这些学习方法,不仅仅针对咱们这一个课程的学习,其实完全适用任何知识的学习过程。你可以通过这个专栏的学习,实践一下这些方法。如果效果不错,再推广到之后的学习过程中。

内容小结

今天,我带你划了划数据结构和算法的学习重点,复杂度分析,以及 10 个数据结构和 10 个算法。

这些内容是我根据平时的学习和工作、面试经验积累,精心筛选出来的。只要掌握这些内容,应付日常的面试、工作,基本不会有问题。

除此之外,我还给你分享了我总结的一些学习技巧,比如边学边练、多问、多思考,还有两个比较通用的学习方法,打怪升级法和沉淀法。掌握了这些学习技巧,可以让你学习过程中事半功倍。所以,你一定要好好实践哦!

极客时间版权所有: https://time.geekbang.org/column/article/40011

01 | 为什么要学习数据结构和算法

你是不是觉得数据结构和算法,跟操作系统、计算机网络一样,是脱离实际工作的知识?可能除了面试,这辈子也用不着?

尽管计算机相关专业的同学在大学都学过这门课程,甚至很多培训机构也会培训这方面的知识,但是据我了解,很多程序员对数据结构和算法依旧一窍不通。还有一些人也只听说过数组、链表、快排这些最最基本的数据结构和算法,稍微复杂一点的就完全没概念。

当然,也有很多人说,自己实际工作中根本用不到数据结构和算法。所以,就算不懂这块知识,只要 Java API、开发框架用得熟练,照样可以把代码写得“飞”起来。事实真的是这样吗?

今天我们就来详细聊一聊,为什么要学习数据结构和算法。

想要通关大厂面试,千万别让数据结构和算法拖了后腿

很多大公司,比如 BAT、Google、Facebook,面试的时候都喜欢考算法、让人现场写代码。有些人虽然技术不错,但每次去面试都会“跪”在算法上,很是可惜。那你有没有想过,为什么这些大公司都喜欢考算法呢?

校招的时候,参加面试的学生通常没有实际项目经验,公司只能考察他们的基础知识是否牢固。社招就更不用说了,越是厉害的公司,越是注重考察数据结构与算法这类基础知识。相比短期能力,他们更看中你的长期潜力。

你可能要说了,我不懂数据结构与算法,照样找到了好工作啊。那我是不是就不用学数据结构和算法呢?当然不是,你别忘了,我们学任何知识都是为了“用”的,是为了解决实际工作问题的,学习数据结构和算法自然也不例外。

业务开发工程师,你真的愿意做一辈子 CRUD boy 吗?

如果你是一名业务开发工程师,你可能要说,我整天就是做数据库 CRUD(增删改查),哪里用得到数据结构和算法啊?

是的,对于大部分业务开发来说,我们平时可能更多的是利用已经封装好的现成的接口、类库来堆砌、翻译业务逻辑,很少需要自己实现数据结构和算法。但是,不需要自己实现,并不代表什么都不需要了解

如果不知道这些类库背后的原理,不懂得时间、空间复杂度分析,你如何能用好、用对它们?存储某个业务数据的时候,你如何知道应该用 ArrayList,还是 Linked List 呢?调用了某个函数之后,你又该如何评估代码的性能和资源的消耗呢?

作为业务开发,我们会用到各种框架、中间件和底层系统,比如 Spring、RPC 框架、消息中间件、Redis 等等。在这些基础框架中,一般都揉和了很多基础数据结构和算法的设计思想。

比如,我们常用的 Key-Value 数据库 Redis 中,里面的有序集合是用什么数据结构来实现的呢?为什么要用跳表来实现呢?为什么不用二叉树呢?

如果你能弄明白这些底层原理,你就能更好地使用它们。即便出现问题,也很容易就能定位。因此,掌握数据结构和算法,不管对于阅读框架源码,还是理解其背后的设计思想,都是非常有用的。

在平时的工作中,数据结构和算法的应用到处可见。我来举一个你非常熟悉的例子:如何实时地统计业务接口的 99% 响应时间?

你可能最先想到,每次查询时,从小到大排序所有的响应时间,如果总共有 1200 个数据,那第 1188 个数据就是 99% 的响应时间。很显然,每次用这个方法查询的话都要排序,效率是非常低的。但是,如果你知道“堆”这个数据结构,用两个堆可以非常高效地解决这个问题。

基础架构研发工程师,写出达到开源水平的框架才是你的目标!

现在互联网上的技术文章、架构分享、开源项目满天飞,照猫画虎做一套基础框架并不难。我就拿 RPC 框架举例。

不同的公司、不同的人做出的 RPC 框架,架构设计思路都差不多,最后实现的功能也都差不多。但是有的人做出来的框架,Bug 很多、性能一般、扩展性也不好,只能在自己公司仅有的几个项目里面用一下。而有的人做的框架可以开源到 GitHub 上给很多人用,甚至被 Apache 收录。为什么会有这么大的差距呢?

我觉得,高手之间的竞争其实就在细节。这些细节包括:你用的算法是不是够优化,数据存取的效率是不是够高,内存是不是够节省等等。这些累积起来,决定了一个框架是不是优秀。所以,如果你还不懂数据结构和算法,没听说过大 O 复杂度分析,不知道怎么分析代码的时间复杂度和空间复杂度,那肯定说不过去了,赶紧来补一补吧!

对编程还有追求?不想被行业淘汰?那就不要只会写凑合能用的代码!

何为编程能力强?是代码的可读性好、健壮?还是扩展性好?我觉得没法列,也列不全。但是,在我看来,性能好坏起码是其中一个非常重要的评判标准。但是,如果你连代码的时间复杂度、空间复杂度都不知道怎么分析,怎么写出高性能的代码呢?

你可能会说,我在小公司工作,用户量很少,需要处理的数据量也很少,开发中不需要考虑那么多性能的问题,完成功能就可以,用什么数据结构和算法,差别根本不大。但是你真的想“十年如一日”地做一样的工作吗?

经常有人说,程序员 35 岁之后很容易陷入瓶颈,被行业淘汰,我觉得原因其实就在此。有的人写代码的时候,从来都不考虑非功能性的需求,只是完成功能,凑合能用就好;做事情的时候,也从来没有长远规划,只把眼前事情做好就满足了。

我曾经面试过很多大龄候选人,简历能写十几页,经历的项目有几十个,但是细看下来,每个项目都是重复地堆砌业务逻辑而已,完全没有难度递进,看不出有能力提升。久而久之,十年的积累可能跟一年的积累没有任何区别。这样的人,怎么不会被行业淘汰呢?

如果你在一家成熟的公司,或者 BAT 这样的大公司,面对的是千万级甚至亿级的用户,开发的是 TB、PB 级别数据的处理系统。性能几乎是开发过程中时刻都要考虑的问题。一个简单的 ArrayList、Linked List 的选择问题,就可能会产生成千上万倍的性能差别。这个时候,数据结构和算法的意义就完全凸显出来了。

其实,我觉得,数据结构和算法这个东西,如果你不去学,可能真的这辈子都用不到,也感受不到它的好。但是一旦掌握,你就会常常被它的强大威力所折服。之前你可能需要费很大劲儿来优化的代码,需要花很多心思来设计的架构,用了数据结构和算法之后,很容易就可以解决了。

内容小结

我们学习数据结构和算法,并不是为了死记硬背几个知识点。我们的目的是建立时间复杂度、空间复杂度意识,写出高质量的代码,能够设计基础架构,提升编程技能,训练逻辑思维,积攒人生经验,以此获得工作回报,实现你的价值,完善你的人生。

所以,不管你是业务开发工程师,还是基础架构工程师;不管你是初入职场的初级工程师,还是工作多年的资深架构师,又或者是想转人工智能、区块链这些热门领域的程序员,数据结构与算法作为计算机的基础知识、核心知识,都是必须要掌握的。

掌握了数据结构与算法,你看待问题的深度,解决问题的角度就会完全不一样。因为这样的你,就像是站在巨人的肩膀上,拿着生存利器行走世界。数据结构与算法,会为你的编程之路,甚至人生之路打开一扇通往新世界的大门。

极客时间版权所有: https://time.geekbang.org/column/article/39972

第20讲 | CDN:你去小卖部取过快递么?

上一节,我们看到了网站的一般访问模式。

当一个用户想访问一个网站的时候,指定这个网站的域名,DNS 就会将这个域名解析为地址,然后用户请求这个地址,返回一个网页。就像你要买个东西,首先要查找商店的位置,然后去商店里面找到自己想要的东西,最后拿着东西回家。

那这里面还有没有可以优化的地方呢?

例如你去电商网站下单买个东西,这个东西一定要从电商总部的中心仓库送过来吗?原来基本是这样的,每一单都是单独配送,所以你可能要很久才能收到你的宝贝。但是后来电商网站的物流系统学聪明了,他们在全国各地建立了很多仓库,而不是只有总部的中心仓库才可以发货。

电商网站根据统计大概知道,北京、上海、广州、深圳、杭州等地,每天能够卖出去多少书籍、卫生纸、包、电器等存放期比较长的物品。这些物品用不着从中心仓库发出,所以平时就可以将它们分布在各地仓库里,客户一下单,就近的仓库发出,第二天就可以收到了。

这样,用户体验大大提高。当然,这里面也有个难点就是,生鲜这类东西保质期太短,如果提前都备好货,但是没有人下单,那肯定就坏了。这个问题,我后文再说。

我们先说,我们的网站访问可以借鉴“就近配送”这个思路。

全球有这么多的数据中心,无论在哪里上网,临近不远的地方基本上都有数据中心。是不是可以在这些数据中心里部署几台机器,形成一个缓存的集群来缓存部分数据,那么用户访问数据的时候,就可以就近访问了呢?

当然是可以的。这些分布在各个地方的各个数据中心的节点,就称为边缘节点

由于边缘节点数目比较多,但是每个集群规模比较小,不可能缓存下来所有东西,因而可能无法命中,这样就会在边缘节点之上。有区域节点,规模就要更大,缓存的数据会更多,命中的概率也就更大。在区域节点之上是中心节点,规模更大,缓存数据更多。如果还不命中,就只好回源网站访问了。

img

这就是CDN 的分发系统的架构。CDN 系统的缓存,也是一层一层的,能不访问后端真正的源,就不打扰它。这也是电商网站物流系统的思路,北京局找不到,找华北局,华北局找不到,再找北方局。

有了这个分发系统之后,接下来就是,客户端如何找到相应的边缘节点进行访问呢?

还记得我们讲过的基于 DNS 的全局负载均衡吗?这个负载均衡主要用来选择一个就近的同样运营商的服务器进行访问。你会发现,CDN 分发网络也是一个分布在多个区域、多个运营商的分布式系统,也可以用相同的思路选择最合适的边缘节点。

img

在没有 CDN 的情况下,用户向浏览器输入 www.web.com 这个域名,客户端访问本地 DNS 服务器的时候,如果本地 DNS 服务器有缓存,则返回网站的地址;如果没有,递归查询到网站的权威 DNS 服务器,这个权威 DNS 服务器是负责 web.com 的,它会返回网站的 IP 地址。本地 DNS 服务器缓存下 IP 地址,将 IP 地址返回,然后客户端直接访问这个 IP 地址,就访问到了这个网站。

然而有了 CDN 之后,情况发生了变化。在 web.com 这个权威 DNS 服务器上,会设置一个 CNAME 别名,指向另外一个域名 www.web.cdn.com,返回给本地 DNS 服务器。

当本地 DNS 服务器拿到这个新的域名时,需要继续解析这个新的域名。这个时候,再访问的就不是 web.com 的权威 DNS 服务器了,而是 web.cdn.com 的权威 DNS 服务器,这是 CDN 自己的权威 DNS 服务器。在这个服务器上,还是会设置一个 CNAME,指向另外一个域名,也即 CDN 网络的全局负载均衡器。

接下来,本地 DNS 服务器去请求 CDN 的全局负载均衡器解析域名,全局负载均衡器会为用户选择一台合适的缓存服务器提供服务,选择的依据包括:

  • 根据用户 IP 地址,判断哪一台服务器距用户最近;
  • 用户所处的运营商;
  • 根据用户所请求的 URL 中携带的内容名称,判断哪一台服务器上有用户所需的内容;
  • 查询各个服务器当前的负载情况,判断哪一台服务器尚有服务能力。

基于以上这些条件,进行综合分析之后,全局负载均衡器会返回一台缓存服务器的 IP 地址。

本地 DNS 服务器缓存这个 IP 地址,然后将 IP 返回给客户端,客户端去访问这个边缘节点,下载资源。缓存服务器响应用户请求,将用户所需内容传送到用户终端。如果这台缓存服务器上并没有用户想要的内容,那么这台服务器就要向它的上一级缓存服务器请求内容,直至追溯到网站的源服务器将内容拉到本地。

CDN 可以进行缓存的内容有很多种。

保质期长的日用品比较容易缓存,因为不容易过期,对应到就像电商仓库系统里,就是静态页面、图片等,因为这些东西也不怎么变,所以适合缓存。

img

还记得这个接入层缓存的架构吗?在进入数据中心的时候,我们希望通过最外层接入层的缓存,将大部分静态资源的访问拦在边缘。而 CDN 则更进一步,将这些静态资源缓存到离用户更近的数据中心外。越接近客户,访问性能越好,时延越低。

但是静态内容中,有一种特殊的内容,也大量使用了 CDN,这个就是前面讲过的流媒体

CDN 支持流媒体协议,例如前面讲过的 RTMP 协议。在很多情况下,这相当于一个代理,从上一级缓存读取内容,转发给用户。由于流媒体往往是连续的,因而可以进行预先缓存的策略,也可以预先推送到用户的客户端。

对于静态页面来讲,内容的分发往往采取拉取的方式,也即当发现未命中的时候,再去上一级进行拉取。但是,流媒体数据量大,如果出现回源,压力会比较大,所以往往采取主动推送的模式,将热点数据主动推送到边缘节点。

对于流媒体来讲,很多 CDN 还提供预处理服务,也即文件在分发之前,经过一定的处理。例如将视频转换为不同的码流,以适应不同的网络带宽的用户需求;再如对视频进行分片,降低存储压力,也使得客户端可以选择使用不同的码率加载不同的分片。这就是我们常见的,“我要看超清、标清、流畅等”。

对于流媒体 CDN 来讲,有个关键的问题是防盗链问题。因为视频是要花大价钱买版权的,为了挣点钱,收点广告费,如果流媒体被其他的网站盗走,在人家的网站播放,那损失可就大了。

最常用也最简单的方法就是HTTP 头的 refer 字段, 当浏览器发送请求的时候,一般会带上 referer,告诉服务器是从哪个页面链接过来的,服务器基于此可以获得一些信息用于处理。如果 refer 信息不是来自本站,就阻止访问或者跳到其它链接。

refer 的机制相对比较容易破解,所以还需要配合其他的机制。

一种常用的机制是时间戳防盗链。使用 CDN 的管理员可以在配置界面上,和 CDN 厂商约定一个加密字符串。

客户端取出当前的时间戳,要访问的资源及其路径,连同加密字符串进行签名算法得到一个字符串,然后生成一个下载链接,带上这个签名字符串和截止时间戳去访问 CDN。

在 CDN 服务端,根据取出过期时间,和当前 CDN 节点时间进行比较,确认请求是否过期。然后 CDN 服务端有了资源及路径,时间戳,以及约定的加密字符串,根据相同的签名算法计算签名,如果匹配则一致,访问合法,才会将资源返回给客户。

然而比如在电商仓库中,我在前面提过,有关生鲜的缓存就是非常麻烦的事情,这对应着就是动态的数据,比较难以缓存。怎么办呢?现在也有动态 CDN,主要有两种模式

  • 一种为生鲜超市模式,也即边缘计算的模式。既然数据是动态生成的,所以数据的逻辑计算和存储,也相应的放在边缘的节点。其中定时从源数据那里同步存储的数据,然后在边缘进行计算得到结果。就像对生鲜的烹饪是动态的,没办法事先做好缓存,因而将生鲜超市放在你家旁边,既能够送货上门,也能够现场烹饪,也是边缘计算的一种体现。
  • 另一种是冷链运输模式,也即路径优化的模式。数据不是在边缘计算生成的,而是在源站生成的,但是数据的下发则可以通过 CDN 的网络,对路径进行优化。因为 CDN 节点较多,能够找到离源站很近的边缘节点,也能找到离用户很近的边缘节点。中间的链路完全由 CDN 来规划,选择一个更加可靠的路径,使用类似专线的方式进行访问。

对于常用的 TCP 连接,在公网上传输的时候经常会丢数据,导致 TCP 的窗口始终很小,发送速度上不去。根据前面的 TCP 流量控制和拥塞控制的原理,在 CDN 加速网络中可以调整 TCP 的参数,使得 TCP 可以更加激进地传输数据。

可以通过多个请求复用一个连接,保证每次动态请求到达时。连接都已经建立了,不必临时三次握手或者建立过多的连接,增加服务器的压力。另外,可以通过对传输数据进行压缩,增加传输效率。

所有这些手段就像冷链运输,整个物流优化了,全程冷冻高速运输。不管生鲜是从你旁边的超市送到你家的,还是从产地送的,保证到你家是新鲜的。

小结

好了,这节就到这里了。咱们来总结一下,你记住这两个重点就好。

  • CDN 和电商系统的分布式仓储系统一样,分为中心节点、区域节点、边缘节点,而数据缓存在离用户最近的位置。
  • CDN 最擅长的是缓存静态数据,除此之外还可以缓存流媒体数据,这时候要注意使用防盗链。它也支持动态数据的缓存,一种是边缘计算的生鲜超市模式,另一种是链路优化的冷链运输模式。

第12讲 | TCP协议(下):西行必定多妖孽,恒心智慧消磨难

我们前面说到玄奘西行,要出网关。既然出了网关,那就是在公网上传输数据,公网往往是不可靠的,因而需要很多的机制去保证传输的可靠性,这里面需要恒心,也即各种重传的策略,还需要有智慧,也就是说,这里面包含着大量的算法

如何做个靠谱的人?

TCP 想成为一个成熟稳重的人,成为一个靠谱的人。那一个人怎么样才算靠谱呢?咱们工作中经常就有这样的场景,比如你交代给下属一个事情以后,下属到底能不能做到,做到什么程度,什么时候能够交付,往往就会有应答,有回复。这样,处理事情的过程中,一旦有异常,你也可以尽快知道,而不是交代完之后就石沉大海,过了一个月再问,他说,啊我不记得了。

对应到网络协议上,就是客户端每发送的一个包,服务器端都应该有个回复,如果服务器端超过一定的时间没有回复,客户端就会重新发送这个包,直到有回复。

这个发送应答的过程是什么样呢?可以是上一个收到了应答,再发送下一个。这种模式有点像两个人直接打电话,你一句,我一句。但是这种方式的缺点是效率比较低。如果一方在电话那头处理的时间比较长,这一头就要干等着,双方都没办法干其他事情。咱们在日常工作中也不是这样的,不能你交代你的下属办一件事情,就一直打着电话看着他做,而是应该他按照你的安排,先将事情记录下来,办完一件回复一件。在他办事情的过程中,你还可以同时交代新的事情,这样双方就并行了。

如果使⽤这种模式,其实需要你和你的下属就不能靠脑⼦了,⽽是要都准备⼀个本⼦,你每交代下属⼀个事情,双方的本子都要记录⼀下。

当你的下属做完⼀件事情,就回复你,做完了,你就在你的本⼦上将这个事情划去。同时你的本⼦上每件事情都有时限,如果超过了时限下属还没有回复,你就要主动重新交代⼀下:上次那件事情,你还没回复我,咋样啦?

既然多件事情可以一起处理,那就需要给每个事情编个号,防止弄错了。例如,程序员平时看任务的时候,都会看 JIRA 的 ID,而不是每次都要描述一下具体的事情。在大部分情况下,对于事情的处理是按照顺序来的,先来的先处理,这就给应答和汇报工作带来了方便。等开周会的时候,每个程序员都可以将 JIRA ID 的列表拉出来,说以上的都做完了,⽽不⽤⼀个个说。

如何实现一个靠谱的协议?

TCP 协议使用的也是同样的模式。为了保证顺序性,每一个包都有一个 ID。在建立连接的时候,会商定起始的 ID 是什么,然后按照 ID 一个个发送。为了保证不丢包,对于发送的包都要进行应答,但是这个应答也不是一个一个来的,而是会应答某个之前的 ID,表示都收到了,这种模式称为累计确认或者累计应答cumulative acknowledgment)。

为了记录所有发送的包和接收的包,TCP 也需要发送端和接收端分别都有缓存来保存这些记录。发送端的缓存里是按照包的 ID 一个个排列,根据处理的情况分成四个部分。

第一部分:发送了并且已经确认的。这部分就是你交代下属的,并且也做完了的,应该划掉的。

第二部分:发送了并且尚未确认的。这部分是你交代下属的,但是还没做完的,需要等待做完的回复之后,才能划掉。

第三部分:没有发送,但是已经等待发送的。这部分是你还没有交代给下属,但是马上就要交代的。

第四部分:没有发送,并且暂时还不会发送的。这部分是你还没有交代给下属,而且暂时还不会交代给下属的。

这里面为什么要区分第三部分和第四部分呢?没交代的,一下子全交代了不就完了吗?

这就是我们上一节提到的十个词口诀里的“流量控制,把握分寸”。作为项目管理人员,你应该根据以往的工作情况和这个员工反馈的能力、抗压力等,先在心中估测一下,这个人一天能做多少工作。如果工作布置少了,就会不饱和;如果工作布置多了,他就会做不完;如果你使劲逼迫,人家可能就要辞职了。

到底一个员工能够同时处理多少事情呢?在 TCP 里,接收端会给发送端报一个窗口的大小,叫Advertised window。这个窗口的大小应该等于上面的第二部分加上第三部分,就是已经交代了没做完的加上马上要交代的。超过这个窗口的,接收端做不过来,就不能发送了。

于是,发送端需要保持下面的数据结构。

img

  • LastByteAcked:第一部分和第二部分的分界线
  • LastByteSent:第二部分和第三部分的分界线
  • LastByteAcked + AdvertisedWindow:第三部分和第四部分的分界线

    对于接收端来讲,它的缓存里记录的内容要简单一些。

    第一部分:接受并且确认过的。也就是我领导交代给我,并且我做完的。

    第二部分:还没接收,但是马上就能接收的。也即是我自己的能够接受的最大工作量。

    第三部分:还没接收,也没法接收的。也即超过工作量的部分,实在做不完。

    对应的数据结构就像这样。

    img

  • MaxRcvBuffer:最大缓存的量;

  • LastByteRead 之后是已经接收了,但是还没被应用层读取的;
  • NextByteExpected 是第一部分和第二部分的分界线。

    第二部分的窗口有多大呢?

    NextByteExpected 和 LastByteRead 的差其实是还没被应用层读取的部分占用掉的 MaxRcvBuffer 的量,我们定义为 A。

    AdvertisedWindow 其实是 MaxRcvBuffer 减去 A。

    也就是:AdvertisedWindow=MaxRcvBuffer-((NextByteExpected-1)-LastByteRead)。

    那第二部分和第三部分的分界线在哪里呢?NextByteExpected 加 AdvertisedWindow 就是第二部分和第三部分的分界线,其实也就是 LastByteRead 加上 MaxRcvBuffer。

    其中第二部分里面,由于受到的包可能不是顺序的,会出现空挡,只有和第一部分连续的,可以马上进行回复,中间空着的部分需要等待,哪怕后面的已经来了。

    顺序问题与丢包问题

    接下来我们结合一个例子来看。

    还是刚才的图,在发送端来看,1、2、3 已经发送并确认;4、5、6、7、8、9 都是发送了还没确认;10、11、12 是还没发出的;13、14、15 是接收方没有空间,不准备发的。

    在接收端来看,1、2、3、4、5 是已经完成 ACK,但是没读取的;6、7 是等待接收的;8、9 是已经接收,但是没有 ACK 的。

    发送端和接收端当前的状态如下:

  • 1、2、3 没有问题,双方达成了一致。

  • 4、5 接收方说 ACK 了,但是发送方还没收到,有可能丢了,有可能在路上。
  • 6、7、8、9 肯定都发了,但是 8、9 已经到了,但是 6、7 没到,出现了乱序,缓存着但是没办法 ACK。

    根据这个例子,我们可以知道,顺序问题和丢包问题都有可能发生,所以我们先来看确认与重发的机制

    假设 4 的确认到了,不幸的是,5 的 ACK 丢了,6、7 的数据包丢了,这该怎么办呢?

    一种方法就是超时重试,也即对每一个发送了,但是没有 ACK 的包,都有设一个定时器,超过了一定的时间,就重新尝试。但是这个超时的时间如何评估呢?这个时间不宜过短,时间必须大于往返时间 RTT,否则会引起不必要的重传。也不宜过长,这样超时时间变长,访问就变慢了。

    估计往返时间,需要 TCP 通过采样 RTT 的时间,然后进行加权平均,算出一个值,而且这个值还是要不断变化的,因为网络状况不断的变化。除了采样 RTT,还要采样 RTT 的波动范围,计算出一个估计的超时时间。由于重传时间是不断变化的,我们称为自适应重传算法Adaptive Retransmission Algorithm)。

    如果过一段时间,5、6、7 都超时了,就会重新发送。接收方发现 5 原来接收过,于是丢弃 5;6 收到了,发送 ACK,要求下一个是 7,7 不幸又丢了。当 7 再次超时的时候,有需要重传的时候,TCP 的策略是超时间隔加倍每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍两次超时,就说明网络环境差,不宜频繁反复发送。

    超时触发重传存在的问题是,超时周期可能相对较长。那是不是可以有更快的方式呢?

    有一个可以快速重传的机制,当接收方收到一个序号大于下一个所期望的报文段时,就检测到了数据流中的一个间格,于是发送三个冗余的 ACK,客户端收到后,就在定时器过期之前,重传丢失的报文段。

    例如,接收方发现 6、8、9 都已经接收了,就是 7 没来,那肯定是丢了,于是发送三个 6 的 ACK,要求下一个是 7。客户端收到 3 个,就会发现 7 的确又丢了,不等超时,马上重发。

    还有一种方式称为Selective AcknowledgmentSACK)。这种方式需要在 TCP 头里加一个 SACK 的东西,可以将缓存的地图发送给发送方。例如可以发送 ACK6、SACK8、SACK9,有了地图,发送方一下子就能看出来是 7 丢了。

    流量控制问题

    我们再来看流量控制机制,在对于包的确认中,同时会携带一个窗口的大小。

    我们先假设窗口不变的情况,窗口始终为 9。4 的确认来的时候,会右移一个,这个时候第 13 个包也可以发送了。

    img

    这个时候,假设发送端发送过猛,会将第三部分的 10、11、12、13 全部发送完毕,之后就停止发送了,未发送可发送部分为 0。

    img

    当对于包 5 的确认到达的时候,在客户端相当于窗口再滑动了一格,这个时候,才可以有更多的包可以发送了,例如第 14 个包才可以发送。

    img

    如果接收方实在处理的太慢,导致缓存中没有空间了,可以通过确认信息修改窗口的大小,甚至可以设置为 0,则发送方将暂时停止发送。

    我们假设一个极端情况,接收端的应用一直不读取缓存中的数据,当数据包 6 确认后,窗口大小就不能再是 9 了,就要缩小一个变为 8。

    img

    这个新的窗口 8 通过 6 的确认消息到达发送端的时候,你会发现窗口没有平行右移,而是仅仅左面的边右移了,窗口的大小从 9 改成了 8。

    img

    如果接收端还是一直不处理数据,则随着确认的包越来越多,窗口越来越小,直到为 0。

    img

    当这个窗口通过包 14 的确认到达发送端的时候,发送端的窗口也调整为 0,停止发送。

    img

    如果这样的话,发送方会定时发送窗口探测数据包,看是否有机会调整窗口的大小。当接收方比较慢的时候,要防止低能窗口综合征,别空出一个字节来就赶快告诉发送方,然后马上又填满了,可以当窗口太小的时候,不更新窗口,直到达到一定大小,或者缓冲区一半为空,才更新窗口。

    这就是我们常说的流量控制。

    拥塞控制问题

    最后,我们看一下拥塞控制的问题,也是通过窗口的大小来控制的,前面的滑动窗口 rwnd 是怕发送方把接收方缓存塞满,而拥塞窗口 cwnd,是怕把网络塞满。

    这里有一个公式 LastByteSent – LastByteAcked <= min {cwnd, rwnd} ,是拥塞窗口和滑动窗口共同控制发送的速度。

    那发送方怎么判断网络是不是满呢?这其实是个挺难的事情,因为对于 TCP 协议来讲,他压根不知道整个网络路径都会经历什么,对他来讲就是一个黑盒。TCP 发送包常被比喻为往一个水管里面灌水,而 TCP 的拥塞控制就是在不堵塞,不丢包的情况下,尽量发挥带宽。

    水管有粗细,网络有带宽,也即每秒钟能够发送多少数据;水管有长度,端到端有时延。在理想状态下,水管里面水的量 = 水管粗细 x 水管长度。对于到网络上,通道的容量 = 带宽 × 往返延迟。

    如果我们设置发送窗口,使得发送但未确认的包为为通道的容量,就能够撑满整个管道。

    img

    如图所示,假设往返时间为 8s,去 4s,回 4s,每秒发送一个包,每个包 1024byte。已经过去了 8s,则 8 个包都发出去了,其中前 4 个包已经到达接收端,但是 ACK 还没有返回,不能算发送成功。5-8 后四个包还在路上,还没被接收。这个时候,整个管道正好撑满,在发送端,已发送未确认的为 8 个包,正好等于带宽,也即每秒发送 1 个包,乘以来回时间 8s。

    如果我们在这个基础上再调大窗口,使得单位时间内更多的包可以发送,会出现什么现象呢?

    我们来想,原来发送一个包,从一端到达另一端,假设一共经过四个设备,每个设备处理一个包时间耗费 1s,所以到达另一端需要耗费 4s,如果发送的更加快速,则单位时间内,会有更多的包到达这些中间设备,这些设备还是只能每秒处理一个包的话,多出来的包就会被丢弃,这是我们不想看到的。

    这个时候,我们可以想其他的办法,例如这个四个设备本来每秒处理一个包,但是我们在这些设备上加缓存,处理不过来的在队列里面排着,这样包就不会丢失,但是缺点是会增加时延,这个缓存的包,4s 肯定到达不了接收端了,如果时延达到一定程度,就会超时重传,也是我们不想看到的。

    于是 TCP 的拥塞控制主要来避免两种现象,包丢失超时重传。一旦出现了这些现象就说明,发送速度太快了,要慢一点。但是一开始我怎么知道速度多快呢,我怎么知道应该把窗口调整到多大呢?

    如果我们通过漏斗往瓶子里灌水,我们就知道,不能一桶水一下子倒进去,肯定会溅出来,要一开始慢慢的倒,然后发现总能够倒进去,就可以越倒越快。这叫作慢启动。

    一条 TCP 连接开始,cwnd 设置为一个报文段,一次只能发送一个;当收到这一个确认的时候,cwnd 加一,于是一次能够发送两个;当这两个的确认到来的时候,每个确认 cwnd 加一,两个确认 cwnd 加二,于是一次能够发送四个;当这四个的确认到来的时候,每个确认 cwnd 加一,四个确认 cwnd 加四,于是一次能够发送八个。可以看出这是指数性的增长

    涨到什么时候是个头呢?有一个值 ssthresh 为 65535 个字节,当超过这个值的时候,就要小心一点了,不能倒这么快了,可能快满了,再慢下来。

    每收到一个确认后,cwnd 增加 1/cwnd,我们接着上面的过程来,一次发送八个,当八个确认到来的时候,每个确认增加 1/8,八个确认一共 cwnd 增加 1,于是一次能够发送九个,变成了线性增长。

    但是线性增长还是增长,还是越来越多,直到有一天,水满则溢,出现了拥塞,这时候一般就会一下子降低倒水的速度,等待溢出的水慢慢渗下去。

    拥塞的一种表现形式是丢包,需要超时重传,这个时候,将 sshresh 设为 cwnd/2,将 cwnd 设为 1,重新开始慢启动。这真是一旦超时重传,马上回到解放前。但是这种方式太激进了,将一个高速的传输速度一下子停了下来,会造成网络卡顿。

    前面我们讲过快速重传算法。当接收端发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速的重传,不必等待超时再重传。TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,cwnd 减半为 cwnd/2,然后 sshthresh = cwnd,当三个包返回的时候,cwnd = sshthresh + 3,也就是没有一夜回到解放前,而是还在比较高的值,呈线性增长。

    img

    就像前面说的一样,正是这种知进退,使得时延很重要的情况下,反而降低了速度。但是如果你仔细想一下,TCP 的拥塞控制主要来避免的两个现象都是有问题的。

    第一个问题是丢包并不代表着通道满了,也可能是管子本来就漏水。例如公网上带宽不满也会丢包,这个时候就认为拥塞了,退缩了,其实是不对的。

    第二个问题是 TCP 的拥塞控制要等到将中间设备都填充满了,才发生丢包,从而降低速度,这时候已经晚了。其实 TCP 只要填满管道就可以了,不应该接着填,直到连缓存也填满。

    为了优化这两个问题,后来有了TCP BBR 拥塞算法。它企图找到一个平衡点,就是通过不断的加快发送速度,将管道填满,但是不要填满中间设备的缓存,因为这样时延会增加,在这个平衡点可以很好的达到高带宽和低时延的平衡。

    img

    小结

    好了,这一节我们就到这里,总结一下:

  • 顺序问题、丢包问题、流量控制都是通过滑动窗口来解决的,这其实就相当于你领导和你的工作备忘录,布置过的工作要有编号,干完了有反馈,活不能派太多,也不能太少;

  • 拥塞控制是通过拥塞窗口来解决的,相当于往管道里面倒水,快了容易溢出,慢了浪费带宽,要摸着石头过河,找到最优值。

第11讲 | TCP协议(上):因性恶而复杂,先恶后善反轻松

上一节,我们讲的 UDP,基本上包括了传输层所必须的端口字段。它就像我们小时候一样简单,相信“网之初,性本善,不丢包,不乱序”。

后来呢,我们都慢慢长大,了解了社会的残酷,变得复杂而成熟,就像 TCP 协议一样。它之所以这么复杂,那是因为它秉承的是“性恶论”。它天然认为网络环境是恶劣的,丢包、乱序、重传,拥塞都是常有的事情,一言不合就可能送达不了,因而要从算法层面来保证可靠性。

TCP 包头格式

我们先来看 TCP 头的格式。从这个图上可以看出,它比 UDP 复杂得多。

img

首先,源端口号和目标端口号是不可少的,这一点和 UDP 是一样的。如果没有这两个端口号。数据就不知道应该发给哪个应用。

接下来是包的序号。为什么要给包编号呢?当然是为了解决乱序的问题。不编好号怎么确认哪个应该先来,哪个应该后到呢。编号是为了解决乱序问题。既然是社会老司机,做事当然要稳重,一件件来,面临再复杂的情况,也临危不乱。

还应该有的就是确认序号。发出去的包应该有确认,要不然我怎么知道对方有没有收到呢?如果没有收到就应该重新发送,直到送达。这个可以解决不丢包的问题。作为老司机,做事当然要靠谱,答应了就要做到,暂时做不到也要有个回复。

TCP 是靠谱的协议,但是这不能说明它面临的网络环境好。从 IP 层面来讲,如果网络状况的确那么差,是没有任何可靠性保证的,而作为 IP 的上一层 TCP 也无能为力,唯一能做的就是更加努力,不断重传,通过各种算法保证。也就是说,对于 TCP 来讲,IP 层你丢不丢包,我管不着,但是我在我的层面上,会努力保证可靠性。

这有点像如果你在北京,和客户约十点见面,那么你应该清楚堵车是常态,你干预不了,也控制不了,你唯一能做的就是早走。打车不行就改乘地铁,尽力不失约。

接下来有一些状态位。例如 SYN 是发起一个连接,ACK 是回复,RST 是重新连接,FIN 是结束连接等。TCP 是面向连接的,因而双方要维护连接的状态,这些带状态位的包的发送,会引起双方的状态变更。

不像小时候,随便一个不认识的小朋友都能玩在一起,人大了,就变得礼貌,优雅而警觉,人与人遇到会互相热情的寒暄,离开会不舍的道别,但是人与人之间的信任会经过多次交互才能建立。

还有一个重要的就是窗口大小。TCP 要做流量控制,通信双方各声明一个窗口,标识自己当前能够的处理能力,别发送的太快,撑死我,也别发的太慢,饿死我。

作为老司机,做事情要有分寸,待人要把握尺度,既能适当提出自己的要求,又不强人所难。除了做流量控制以外,TCP 还会做拥塞控制,对于真正的通路堵车不堵车,它无能为力,唯一能做的就是控制自己,也即控制发送的速度。不能改变世界,就改变自己嘛。

作为老司机,要会自我控制,知进退,知道什么时候应该坚持,什么时候应该让步。

通过对 TCP 头的解析,我们知道要掌握 TCP 协议,重点应该关注以下几个问题:

  • 顺序问题 ,稳重不乱;
  • 丢包问题,承诺靠谱;
  • 连接维护,有始有终;
  • 流量控制,把握分寸;
  • 拥塞控制,知进知退。

    TCP 的三次握手

    所有的问题,首先都要先建立一个连接,所以我们先来看连接维护问题。

    TCP 的连接建立,我们常常称为三次握手。

    A:您好,我是 A。

    B:您好 A,我是 B。

    A:您好 B。

    我们也常称为“请求 -> 应答 -> 应答之应答”的三个回合。这个看起来简单,其实里面还是有很多的学问,很多的细节。

    首先,为什么要三次,而不是两次?按说两个人打招呼,一来一回就可以了啊?为了可靠,为什么不是四次?

    我们还是假设这个通路是非常不可靠的,A 要发起一个连接,当发了第一个请求杳无音信的时候,会有很多的可能性,比如第一个请求包丢了,再如没有丢,但是绕了弯路,超时了,还有 B 没有响应,不想和我连接。

    A 不能确认结果,于是再发,再发。终于,有一个请求包到了 B,但是请求包到了 B 的这个事情,目前 A 还是不知道的,A 还有可能再发。

    B 收到了请求包,就知道了 A 的存在,并且知道 A 要和它建立连接。如果 B 不乐意建立连接,则 A 会重试一阵后放弃,连接建立失败,没有问题;如果 B 是乐意建立连接的,则会发送应答包给 A。

    当然对于 B 来说,这个应答包也是一入网络深似海,不知道能不能到达 A。这个时候 B 自然不能认为连接是建立好了,因为应答包仍然会丢,会绕弯路,或者 A 已经挂了都有可能。

    而且这个时候 B 还能碰到一个诡异的现象就是,A 和 B 原来建立了连接,做了简单通信后,结束了连接。还记得吗?A 建立连接的时候,请求包重复发了几次,有的请求包绕了一大圈又回来了,B 会认为这也是一个正常的的请求的话,因此建立了连接,可以想象,这个连接不会进行下去,也没有个终结的时候,纯属单相思了。因而两次握手肯定不行。

    B 发送的应答可能会发送多次,但是只要一次到达 A,A 就认为连接已经建立了,因为对于 A 来讲,他的消息有去有回。A 会给 B 发送应答之应答,而 B 也在等这个消息,才能确认连接的建立,只有等到了这个消息,对于 B 来讲,才算它的消息有去有回。

    当然 A 发给 B 的应答之应答也会丢,也会绕路,甚至 B 挂了。按理来说,还应该有个应答之应答之应答,这样下去就没底了。所以四次握手是可以的,四十次都可以,关键四百次也不能保证就真的可靠了。只要双方的消息都有去有回,就基本可以了。

    好在大部分情况下,A 和 B 建立了连接之后,A 会马上发送数据的,一旦 A 发送数据,则很多问题都得到了解决。例如 A 发给 B 的应答丢了,当 A 后续发送的数据到达的时候,B 可以认为这个连接已经建立,或者 B 压根就挂了,A 发送的数据,会报错,说 B 不可达,A 就知道 B 出事情了。

    当然你可以说 A 比较坏,就是不发数据,建立连接后空着。我们在程序设计的时候,可以要求开启 keepalive 机制,即使没有真实的数据包,也有探活包。

    另外,你作为服务端 B 的程序设计者,对于 A 这种长时间不发包的客户端,可以主动关闭,从而空出资源来给其他客户端使用。

    三次握手除了双方建立连接外,主要还是为了沟通一件事情,就是TCP 包的序号的问题

    A 要告诉 B,我这面发起的包的序号起始是从哪个号开始的,B 同样也要告诉 A,B 发起的包的序号起始是从哪个号开始的。为什么序号不能都从 1 开始呢?因为这样往往会出现冲突。

    例如,A 连上 B 之后,发送了 1、2、3 三个包,但是发送 3 的时候,中间丢了,或者绕路了,于是重新发送,后来 A 掉线了,重新连上 B 后,序号又从 1 开始,然后发送 2,但是压根没想发送 3,但是上次绕路的那个 3 又回来了,发给了 B,B 自然认为,这就是下一个包,于是发生了错误。

    因而,每个连接都要有不同的序号。这个序号的起始序号是随着时间变化的,可以看成一个 32 位的计数器,每 4ms 加一,如果计算一下,如果到重复,需要 4 个多小时,那个绕路的包早就死翘翘了,因为我们都知道 IP 包头里面有个 TTL,也即生存时间。

    好了,双方终于建立了信任,建立了连接。前面也说过,为了维护这个连接,双方都要维护一个状态机,在连接建立的过程中,双方的状态变化时序图就像这样。

    img

    一开始,客户端和服务端都处于 CLOSED 状态。先是服务端主动监听某个端口,处于 LISTEN 状态。然后客户端主动发起连接 SYN,之后处于 SYN-SENT 状态。服务端收到发起的连接,返回 SYN,并且 ACK 客户端的 SYN,之后处于 SYN-RCVD 状态。客户端收到服务端发送的 SYN 和 ACK 之后,发送 ACK 的 ACK,之后处于 ESTABLISHED 状态,因为它一发一收成功了。服务端收到 ACK 的 ACK 之后,处于 ESTABLISHED 状态,因为它也一发一收了。

    TCP 四次挥手

    好了,说完了连接,接下来说一说“拜拜”,好说好散。这常被称为四次挥手。

    A:B 啊,我不想玩了。

    B:哦,你不想玩了啊,我知道了。

    这个时候,还只是 A 不想玩了,也即 A 不会再发送数据,但是 B 能不能在 ACK 的时候,直接关闭呢?当然不可以了,很有可能 A 是发完了最后的数据就准备不玩了,但是 B 还没做完自己的事情,还是可以发送数据的,所以称为半关闭的状态。

    这个时候 A 可以选择不再接收数据了,也可以选择最后再接收一段数据,等待 B 也主动关闭。

    B:A 啊,好吧,我也不玩了,拜拜。

    A:好的,拜拜。

    这样整个连接就关闭了。但是这个过程有没有异常情况呢?当然有,上面是和平分手的场面。

    A 开始说“不玩了”,B 说“知道了”,这个回合,是没什么问题的,因为在此之前,双方还处于合作的状态,如果 A 说“不玩了”,没有收到回复,则 A 会重新发送“不玩了”。但是这个回合结束之后,就有可能出现异常情况了,因为已经有一方率先撕破脸。

    一种情况是,A 说完“不玩了”之后,直接跑路,是会有问题的,因为 B 还没有发起结束,而如果 A 跑路,B 就算发起结束,也得不到回答,B 就不知道该怎么办了。另一种情况是,A 说完“不玩了”,B 直接跑路,也是有问题的,因为 A 不知道 B 是还有事情要处理,还是过一会儿会发送结束。

    那怎么解决这些问题呢?TCP 协议专门设计了几个状态来处理这些问题。我们来看断开连接的时候的状态时序图

    img

    断开的时候,我们可以看到,当 A 说“不玩了”,就进入 FIN_WAIT_1 的状态,B 收到“A 不玩”的消息后,发送知道了,就进入 CLOSE_WAIT 的状态。

    A 收到“B 说知道了”,就进入 FIN_WAIT_2 的状态,如果这个时候 B 直接跑路,则 A 将永远在这个状态。TCP 协议里面并没有对这个状态的处理,但是 Linux 有,可以调整 tcp_fin_timeout 这个参数,设置一个超时时间。

    如果 B 没有跑路,发送了“B 也不玩了”的请求到达 A 时,A 发送“知道 B 也不玩了”的 ACK 后,从 FIN_WAIT_2 状态结束,按说 A 可以跑路了,但是最后的这个 ACK 万一 B 收不到呢?则 B 会重新发一个“B 不玩了”,这个时候 A 已经跑路了的话,B 就再也收不到 ACK 了,因而 TCP 协议要求 A 最后等待一段时间 TIME_WAIT,这个时间要足够长,长到如果 B 没收到 ACK 的话,“B 说不玩了”会重发的,A 会重新发一个 ACK 并且足够时间到达 B。

    A 直接跑路还有一个问题是,A 的端口就直接空出来了,但是 B 不知道,B 原来发过的很多包很可能还在路上,如果 A 的端口被一个新的应用占用了,这个新的应用会收到上个连接中 B 发过来的包,虽然序列号是重新生成的,但是这里要上一个双保险,防止产生混乱,因而也需要等足够长的时间,等到原来 B 发送的所有的包都死翘翘,再空出端口来。

    等待的时间设为 2MSL,MSLMaximum Segment Lifetime报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL 域,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。协议规定 MSL 为 2 分钟,实际应用中常用的是 30 秒,1 分钟和 2 分钟等。

    还有一个异常情况就是,B 超过了 2MSL 的时间,依然没有收到它发的 FIN 的 ACK,怎么办呢?按照 TCP 的原理,B 当然还会重发 FIN,这个时候 A 再收到这个包之后,A 就表示,我已经在这里等了这么长时间了,已经仁至义尽了,之后的我就都不认了,于是就直接发送 RST,B 就知道 A 早就跑了。

    TCP 状态机

    将连接建立和连接断开的两个时序状态图综合起来,就是这个著名的 TCP 的状态机。学习的时候比较建议将这个状态机和时序状态机对照着看,不然容易晕。

    img

    在这个图中,加黑加粗的部分,是上面说到的主要流程,其中阿拉伯数字的序号,是连接过程中的顺序,而大写中文数字的序号,是连接断开过程中的顺序。加粗的实线是客户端 A 的状态变迁,加粗的虚线是服务端 B 的状态变迁。

    小结

    好了,这一节就到这里了,我来做一个总结:

  • TCP 包头很复杂,但是主要关注五个问题,顺序问题,丢包问题,连接维护,流量控制,拥塞控制;

  • 连接的建立是经过三次握手,断开的时候四次挥手,一定要掌握的我画的那个状态图。

第10讲 | UDP协议:因性善而简单,难免碰到城会玩

讲完了 IP 层以后,接下来我们开始讲传输层。传输层里比较重要的两个协议,一个是 TCP,一个是 UDP。对于不从事底层开发的人员来讲,或者对于开发应用的人来讲,最常用的就是这两个协议。由于面试的时候,这两个协议经常会被放在一起问,因而我在讲的时候,也会结合着来讲。

TCP 和 UDP 有哪些区别?

一般面试的时候我问这两个协议的区别,大部分人会回答,TCP 是面向连接的,UDP 是面向无连接的。

什么叫面向连接,什么叫无连接呢?在互通之前,面向连接的协议会先建立连接。例如,TCP 会三次握手,而 UDP 不会。为什么要建立连接呢?你 TCP 三次握手,我 UDP 也可以发三个包玩玩,有什么区别吗?

所谓的建立连接,是为了在客户端和服务端维护连接,而建立一定的数据结构来维护双方交互的状态,用这样的数据结构来保证所谓的面向连接的特性。

例如,TCP 提供可靠交付。通过 TCP 连接传输的数据,无差错、不丢失、不重复、并且按序到达。我们都知道 IP 包是没有任何可靠性保证的,一旦发出去,就像西天取经,走丢了、被妖怪吃了,都只能随它去。但是 TCP 号称能做到那个连接维护的程序做的事情,这个下两节我会详细描述。而UDP 继承了 IP 包的特性,不保证不丢失,不保证按顺序到达。

再如,TCP 是面向字节流的。发送的时候发的是一个流,没头没尾。IP 包可不是一个流,而是一个个的 IP 包。之所以变成了流,这也是 TCP 自己的状态维护做的事情。而UDP 继承了 IP 的特性,基于数据报的,一个一个地发,一个一个地收。

还有TCP 是可以有拥塞控制的。它意识到包丢弃了或者网络的环境不好了,就会根据情况调整自己的行为,看看是不是发快了,要不要发慢点。UDP 就不会,应用让我发,我就发,管它洪水滔天。

因而TCP 其实是一个有状态服务,通俗地讲就是有脑子的,里面精确地记着发送了没有,接收到没有,发送到哪个了,应该接收哪个了,错一点儿都不行。而 UDP 则是无状态服务。 通俗地说是没脑子的,天真无邪的,发出去就发出去了。

我们可以这样比喻,如果 MAC 层定义了本地局域网的传输行为,IP 层定义了整个网络端到端的传输行为,这两层基本定义了这样的基因:网络传输是以包为单位的,二层叫帧,网络层叫包,传输层叫段。我们笼统地称为包。包单独传输,自行选路,在不同的设备封装解封装,不保证到达。基于这个基因,生下来的孩子 UDP 完全继承了这些特性,几乎没有自己的思想。

UDP 包头是什么样的?

我们来看一下 UDP 包头。

前面章节我已经讲过包的传输过程,这里不再赘述。当我发送的 UDP 包到达目标机器后,发现 MAC 地址匹配,于是就取下来,将剩下的包传给处理 IP 层的代码。把 IP 头取下来,发现目标 IP 匹配,接下来呢?这里面的数据包是给谁呢?

发送的时候,我知道我发的是一个 UDP 的包,收到的那台机器咋知道的呢?所以在 IP 头里面有个 8 位协议,这里会存放,数据里面到底是 TCP 还是 UDP,当然这里是 UDP。于是,如果我们知道 UDP 头的格式,就能从数据里面,将它解析出来。解析出来以后呢?数据给谁处理呢?

处理完传输层的事情,内核的事情基本就干完了,里面的数据应该交给应用程序自己去处理,可是一台机器上跑着这么多的应用程序,应该给谁呢?

无论应用程序写的使用 TCP 传数据,还是 UDP 传数据,都要监听一个端口。正是这个端口,用来区分应用程序,要不说端口不能冲突呢。两个应用监听一个端口,到时候包给谁呀?所以,按理说,无论是 TCP 还是 UDP 包头里面应该有端口号,根据端口号,将数据交给相应的应用程序。
img

当我们看到 UDP 包头的时候,发现的确有端口号,有源端口号和目标端口号。因为是两端通信嘛,这很好理解。但是你还会发现,UDP 除了端口号,再没有其他的了。和下两节要讲的 TCP 头比起来,这个简直简单得一塌糊涂啊!

UDP 的三大特点

UDP 就像小孩子一样,有以下这些特点:

第一,沟通简单,不需要一肚子花花肠子(大量的数据结构、处理逻辑、包头字段)。前提是它相信网络世界是美好的,秉承性善论,相信网络通路默认就是很容易送达的,不容易被丢弃的。

第二,轻信他人。它不会建立连接,虽然有端口号,但是监听在这个地方,谁都可以传给他数据,他也可以传给任何人数据,甚至可以同时传给多个人数据。

第三,愣头青,做事不懂权变。不知道什么时候该坚持,什么时候该退让。它不会根据网络的情况进行发包的拥塞控制,无论网络丢包丢成啥样了,它该怎么发还怎么发。

UDP 的三大使用场景

基于 UDP 这种“小孩子”的特点,我们可以考虑在以下的场景中使用。

第一,需要资源少,在网络情况比较好的内网,或者对于丢包不敏感的应用。这很好理解,就像如果你是领导,你会让你们组刚毕业的小朋友去做一些没有那么难的项目,打一些没有那么难的客户,或者做一些失败了也能忍受的实验性项目。

我们在第四节讲的 DHCP 就是基于 UDP 协议的。一般的获取 IP 地址都是内网请求,而且一次获取不到 IP 又没事,过一会儿还有机会。我们讲过 PXE 可以在启动的时候自动安装操作系统,操作系统镜像的下载使用的 TFTP,这个也是基于 UDP 协议的。在还没有操作系统的时候,客户端拥有的资源很少,不适合维护一个复杂的状态机,而是因为是内网,一般也没啥问题。

第二,不需要一对一沟通,建立连接,而是可以广播的应用。咱们小时候人都很简单,大家在班级里面,谁成绩好,谁写作好,应该表扬谁惩罚谁,谁得几个小红花都是当着全班的面讲的,公平公正公开。长大了人心复杂了,薪水、奖金要背靠背,和员工一对一沟通。

UDP 的不面向连接的功能,可以使得可以承载广播或者多播的协议。DHCP 就是一种广播的形式,就是基于 UDP 协议的,而广播包的格式前面说过了。

对于多播,我们在讲 IP 地址的时候,讲过一个 D 类地址,也即组播地址,使用这个地址,可以将包组播给一批机器。当一台机器上的某个进程想监听某个组播地址的时候,需要发送 IGMP 包,所在网络的路由器就能收到这个包,知道有个机器上有个进程在监听这个组播地址。当路由器收到这个组播地址的时候,会将包转发给这台机器,这样就实现了跨路由器的组播。

在后面云中网络部分,有一个协议 VXLAN,也是需要用到组播,也是基于 UDP 协议的。

第三,需要处理速度快,时延低,可以容忍少数丢包,但是要求即便网络拥塞,也毫不退缩,一往无前的时候。记得曾国藩建立湘军的时候,专门招出生牛犊不怕虎的新兵,而不用那些“老油条”的八旗兵,就是因为八旗兵经历的事情多,遇到敌军不敢舍死忘生。

同理,UDP 简单、处理速度快,不像 TCP 那样,操这么多的心,各种重传啊,保证顺序啊,前面的不收到,后面的没法处理啊。不然等这些事情做完了,时延早就上去了。而 TCP 在网络不好出现丢包的时候,拥塞控制策略会主动的退缩,降低发送速度,这就相当于本来环境就差,还自断臂膀,用户本来就卡,这下更卡了。

当前很多应用都是要求低时延的,它们可不想用 TCP 如此复杂的机制,而是想根据自己的场景,实现自己的可靠和连接保证。例如,如果应用自己觉得,有的包丢了就丢了,没必要重传了,就可以算了,有的比较重要,则应用自己重传,而不依赖于 TCP。有的前面的包没到,后面的包到了,那就先给客户展示后面的嘛,干嘛非得等到齐了呢?如果网络不好,丢了包,那不能退缩啊,要尽快传啊,速度不能降下来啊,要挤占带宽,抢在客户失去耐心之前到达。

由于 UDP 十分简单,基本啥都没做,也就给了应用“城会玩”的机会。就像在和平年代,每个人应该有独立的思考和行为,应该可靠并且礼让;但是如果在战争年代,往往不太需要过于独立的思考,而需要士兵简单服从命令就可以了。

曾国藩说哪支部队需要诱敌牺牲,也就牺牲了,相当于包丢了就丢了。两军狭路相逢的时候,曾国藩说上,没有带宽也要上,这才给了曾国藩运筹帷幄,城会玩的机会。同理如果你实现的应用需要有自己的连接策略,可靠保证,时延要求,使用 UDP,然后再应用层实现这些是再好不过了。

基于 UDP 的“城会玩”的五个例子

我列举几种“城会玩”的例子。

“城会玩”一:网页或者 APP 的访问

原来访问网页和手机 APP 都是基于 HTTP 协议的。HTTP 协议是基于 TCP 的,建立连接都需要多次交互,对于时延比较大的目前主流的移动互联网来讲,建立一次连接需要的时间会比较长,然而既然是移动中,TCP 可能还会断了重连,也是很耗时的。而且目前的 HTTP 协议,往往采取多个数据通道共享一个连接的情况,这样本来为了加快传输速度,但是 TCP 的严格顺序策略使得哪怕共享通道,前一个不来,后一个和前一个即便没关系,也要等着,时延也会加大。

QUIC(全称Quick UDP Internet Connections快速 UDP 互联网连接)是 Google 提出的一种基于 UDP 改进的通信协议,其目的是降低网络通信的延迟,提供更好的用户互动体验。

QUIC 在应用层上,会自己实现快速连接建立、减少重传时延,自适应拥塞控制,是应用层“城会玩”的代表。这一节主要是讲 UDP,QUIC 我们放到应用层去讲。

“城会玩”二:流媒体的协议

现在直播比较火,直播协议多使用 RTMP,这个协议我们后面的章节也会讲,而这个 RTMP 协议也是基于 TCP 的。TCP 的严格顺序传输要保证前一个收到了,下一个才能确认,如果前一个收不到,下一个就算包已经收到了,在缓存里面,也需要等着。对于直播来讲,这显然是不合适的,因为老的视频帧丢了其实也就丢了,就算再传过来用户也不在意了,他们要看新的了,如果老是没来就等着,卡顿了,新的也看不了,那就会丢失客户,所以直播,实时性比较比较重要,宁可丢包,也不要卡顿的。

另外,对于丢包,其实对于视频播放来讲,有的包可以丢,有的包不能丢,因为视频的连续帧里面,有的帧重要,有的不重要,如果必须要丢包,隔几个帧丢一个,其实看视频的人不会感知,但是如果连续丢帧,就会感知了,因而在网络不好的情况下,应用希望选择性的丢帧。

还有就是当网络不好的时候,TCP 协议会主动降低发送速度,这对本来当时就卡的看视频来讲是要命的,应该应用层马上重传,而不是主动让步。因而,很多直播应用,都基于 UDP 实现了自己的视频传输协议。

“城会玩”三:实时游戏

游戏有一个特点,就是实时性比较高。快一秒你干掉别人,慢一秒你被别人爆头,所以很多职业玩家会买非常专业的鼠标和键盘,争分夺秒。

因而,实时游戏中客户端和服务端要建立长连接,来保证实时传输。但是游戏玩家很多,服务器却不多。由于维护 TCP 连接需要在内核维护一些数据结构,因而一台机器能够支撑的 TCP 连接数目是有限的,然后 UDP 由于是没有连接的,在异步 IO 机制引入之前,常常是应对海量客户端连接的策略。

另外还是 TCP 的强顺序问题,对战的游戏,对网络的要求很简单,玩家通过客户端发送给服务器鼠标和键盘行走的位置,服务器会处理每个用户发送过来的所有场景,处理完再返回给客户端,客户端解析响应,渲染最新的场景展示给玩家。

如果出现一个数据包丢失,所有事情都需要停下来等待这个数据包重发。客户端会出现等待接收数据,然而玩家并不关心过期的数据,激战中卡 1 秒,等能动了都已经死了。

游戏对实时要求较为严格的情况下,采用自定义的可靠 UDP 协议,自定义重传策略,能够把丢包产生的延迟降到最低,尽量减少网络问题对游戏性造成的影响。

“城会玩”四:IoT 物联网

一方面,物联网领域终端资源少,很可能只是个内存非常小的嵌入式系统,而维护 TCP 协议代价太大;另一方面,物联网对实时性要求也很高,而 TCP 还是因为上面的那些原因导致时延大。Google 旗下的 Nest 建立 Thread Group,推出了物联网通信协议 Thread,就是基于 UDP 协议的。

“城会玩”五:移动通信领域

在 4G 网络里,移动流量上网的数据面对的协议 GTP-U 是基于 UDP 的。因为移动网络协议比较复杂,而 GTP 协议本身就包含复杂的手机上线下线的通信协议。如果基于 TCP,TCP 的机制就显得非常多余,这部分协议我会在后面的章节单独讲解。

小结

好了,这节就到这里了,我们来总结一下:

  • 如果将 TCP 比作成熟的社会人,UDP 则是头脑简单的小朋友。TCP 复杂,UDP 简单;TCP 维护连接,UDP 谁都相信;TCP 会坚持知进退;UDP 愣头青一个,勇往直前;
  • UDP 虽然简单,但它有简单的用法。它可以用在环境简单、需要多播、应用层自己控制传输的地方。例如 DHCP、VXLAN、QUIC 等。

第8讲 | 世界这么大,我想出网关:欧洲十国游与玄奘西行

前几节,我主要跟你讲了宿舍里和办公室里用到的网络协议。你已经有了一些基础,是时候去外网逛逛了!

怎么在宿舍上网?

还记得咱们在宿舍的时候买了台交换机,几台机器组了一个局域网打游戏吗?可惜啊,只能打局域网的游戏,不能上网啊!盼啊盼啊,终于盼到大二,允许宿舍开通网络了。学校给每个宿舍的网口分配了一个 IP 地址。这个 IP 是校园网的 IP,完全由网管部门控制。宿舍网的 IP 地址多为 192.168.1.x。校园网的 IP 地址,假设是 10.10.x.x。

这个时候,你要在宿舍上网,有两个办法:

第一个办法,让你们宿舍长再买一个网卡。这个时候,你们宿舍长的电脑里就有两张网卡。一张网卡的线插到你们宿舍的交换机上,另一张网卡的线插到校园网的网口。而且,这张新的网卡的 IP 地址要按照学校网管部门分配的配置,不然上不了网。这种情况下,如果你们宿舍的人要上网,就需要一直开着宿舍长的电脑。

第二个办法,你们共同出钱买个家庭路由器(反正当时我们买不起)。家庭路由器会有内网网口和外网网口。把外网网口的线插到校园网的网口上,将这个外网网口配置成和网管部的一样。内网网口连上你们宿舍的所有的电脑。这种情况下,如果你们宿舍的人要上网,就需要一直开着路由器。

这两种方法其实是一样的。只不过第一种方式,让你的宿舍长的电脑,变成一个有多个口的路由器而已。而你买的家庭路由器,里面也跑着程序,和你宿舍长电脑里的功能一样,只不过是一个嵌入式的系统。

当你的宿舍长能够上网之后,接下来,就是其他人的电脑怎么上网的问题。这就需要配置你们的网卡。当然 DHCP 是可以默认配置的。在进行网卡配置的时候,除了 IP 地址,还需要配置一个Gateway的东西,这个就是网关

你了解 MAC 头和 IP 头的细节吗?

一旦配置了 IP 地址和网关,往往就能够指定目标地址进行访问了。由于在跨网关访问的时候,牵扯到 MAC 地址和 IP 地址的变化,这里有必要详细描述一下 MAC 头和 IP 头的细节。

img

在 MAC 头里面,先是目标 MAC 地址,然后是源 MAC 地址,然后有一个协议类型,用来说明里面是 IP 协议。IP 头里面的版本号,目前主流的还是 IPv4,服务类型 TOS 在第三节讲 ip addr 命令的时候讲过,TTL 在第 7 节讲 ICMP 协议的时候讲过。另外,还有 8 位标识协议。这里到了下一层的协议,也就是,是 TCP 还是 UDP。最重要的就是源 IP 和目标 IP。先是源 IP 地址,然后是目标 IP 地址。

在任何一台机器上,当要访问另一个 IP 地址的时候,都会先判断,这个目标 IP 地址,和当前机器的 IP 地址,是否在同一个网段。怎么判断同一个网段呢?需要 CIDR 和子网掩码,这个在第三节的时候也讲过了。

如果是同一个网段,例如,你访问你旁边的兄弟的电脑,那就没网关什么事情,直接将源地址和目标地址放入 IP 头中,然后通过 ARP 获得 MAC 地址,将源 MAC 和目的 MAC 放入 MAC 头中,发出去就可以了。

如果不是同一网段,例如,你要访问你们校园网里面的 BBS,该怎么办?这就需要发往默认网关 Gateway。Gateway 的地址一定是和源 IP 地址是一个网段的。往往不是第一个,就是第二个。例如 192.168.1.0/24 这个网段,Gateway 往往会是 192.168.1.1/24 或者 192.168.1.2/24。

如何发往默认网关呢?网关不是和源 IP 地址是一个网段的么?这个过程就和发往同一个网段的其他机器是一样的:将源地址和目标 IP 地址放入 IP 头中,通过 ARP 获得网关的 MAC 地址,将源 MAC 和网关的 MAC 放入 MAC 头中,发送出去。网关所在的端口,例如 192.168.1.1/24 将网络包收进来,然后接下来怎么做,就完全看网关的了。

网关往往是一个路由器,是一个三层转发的设备。啥叫三层设备?前面也说过了,就是把 MAC 头和 IP 头都取下来,然后根据里面的内容,看看接下来把包往哪里转发的设备。

在你的宿舍里面,网关就是你宿舍长的电脑。一个路由器往往有多个网口,如果是一台服务器做这个事情,则就有多个网卡,其中一个网卡是和源 IP 同网段的。

很多情况下,人们把网关就叫作路由器。其实不完全准确,而另一种比喻更加恰当:路由器是一台设备,它有五个网口或者网卡,相当于有五只手,分别连着五个局域网。每只手的 IP 地址都和局域网的 IP 地址相同的网段,每只手都是它握住的那个局域网的网关。

任何一个想发往其他局域网的包,都会到达其中一只手,被拿进来,拿下 MAC 头和 IP 头,看看,根据自己的路由算法,选择另一只手,加上 IP 头和 MAC 头,然后扔出去。

静态路由是什么?

这个时候,问题来了,该选择哪一只手?IP 头和 MAC 头加什么内容,哪些变、哪些不变呢?这个问题比较复杂,大致可以分为两类,一个是静态路由,一个是动态路由。动态路由下一节我们详细地讲。这一节我们先说静态路由。

静态路由,其实就是在路由器上,配置一条一条规则。这些规则包括:想访问 BBS 站(它肯定有个网段),从 2 号口出去,下一跳是 IP2;想访问教学视频站(它也有个自己的网段),从 3 号口出去,下一跳是 IP3,然后保存在路由器里。

每当要选择从哪只手抛出去的时候,就一条一条的匹配规则,找到符合的规则,就按规则中设置的那样,从某个口抛出去,找下一跳 IPX。

IP 头和 MAC 头哪些变、哪些不变?

对于 IP 头和 MAC 头哪些变、哪些不变的问题,可以分两种类型。我把它们称为“欧洲十国游”型和“玄奘西行”型

之前我说过,MAC 地址是一个局域网内才有效的地址。因而,MAC 地址只要过网关,就必定会改变,因为已经换了局域网。两者主要的区别在于 IP 地址是否改变。不改变 IP 地址的网关,我们称为转发网关;改变 IP 地址的网关,我们称为NAT 网关

“欧洲十国游”型

结合这个图,我们先来看“欧洲十国游”型。

img

服务器 A 要访问服务器 B。首先,服务器 A 会思考,192.168.4.101 和我不是一个网段的,因而需要先发给网关。那网关是谁呢?已经静态配置好了,网关是 192.168.1.1。网关的 MAC 地址是多少呢?发送 ARP 获取网关的 MAC 地址,然后发送包。包的内容是这样的:

  • 源 MAC:服务器 A 的 MAC
  • 目标 MAC:192.168.1.1 这个网口的 MAC
  • 源 IP:192.168.1.101
  • 目标 IP:192.168.4.101

包到达 192.168.1.1 这个网口,发现 MAC 一致,将包收进来,开始思考往哪里转发。

在路由器 A 中配置了静态路由之后,要想访问 192.168.4.0/24,要从 192.168.56.1 这个口出去,下一跳为 192.168.56.2。

于是,路由器 A 思考的时候,匹配上了这条路由,要从 192.168.56.1 这个口发出去,发给 192.168.56.2,那 192.168.56.2 的 MAC 地址是多少呢?路由器 A 发送 ARP 获取 192.168.56.2 的 MAC 地址,然后发送包。包的内容是这样的:

  • 源 MAC:192.168.56.1 的 MAC 地址
  • 目标 MAC:192.168.56.2 的 MAC 地址
  • 源 IP:192.168.1.101
  • 目标 IP:192.168.4.101

包到达 192.168.56.2 这个网口,发现 MAC 一致,将包收进来,开始思考往哪里转发。

在路由器 B 中配置了静态路由,要想访问 192.168.4.0/24,要从 192.168.4.1 这个口出去,没有下一跳了。因为我右手这个网卡,就是这个网段的,我是最后一跳了。

于是,路由器 B 思考的时候,匹配上了这条路由,要从 192.168.4.1 这个口发出去,发给 192.168.4.101。那 192.168.4.101 的 MAC 地址是多少呢?路由器 B 发送 ARP 获取 192.168.4.101 的 MAC 地址,然后发送包。包的内容是这样的:

  • 源 MAC:192.168.4.1 的 MAC 地址
  • 目标 MAC:192.168.4.101 的 MAC 地址
  • 源 IP:192.168.1.101
  • 目标 IP:192.168.4.101

包到达服务器 B,MAC 地址匹配,将包收进来。

通过这个过程可以看出,每到一个新的局域网,MAC 都是要变的,但是 IP 地址都不变。在 IP 头里面,不会保存任何网关的 IP 地址。所谓的下一跳是,某个 IP 要将这个 IP 地址转换为 MAC 放入 MAC 头。

之所以将这种模式比喻称为欧洲十国游,是因为在整个过程中,IP 头里面的地址都是不变的。IP 地址在三个局域网都可见,在三个局域网之间的网段都不会冲突。在三个网段之间传输包,IP 头不改变。这就像在欧洲各国之间旅游,一个签证就能搞定。

img

“玄奘西行”型

我们再来看“玄奘西行”型。

这里遇见的第一个问题是,局域网之间没有商量过,各定各的网段,因而 IP 段冲突了。最左面大唐的地址是 192.168.1.101,最右面印度的地址也是 192.168.1.101,如果单从 IP 地址上看,简直是自己访问自己,其实是大唐的 192.168.1.101 要访问印度的 192.168.1.101。

怎么解决这个问题呢?既然局域网之间没有商量过,你们各管各的,那到国际上,也即中间的局域网里面,就需要使用另外的地址。就像出国,不能用咱们自己的身份证,而要改用护照一样,玄奘西游也要拿着专门取经的通关文牒,而不能用自己国家的身份证。

首先,目标服务器 B 在国际上要有一个国际的身份,我们给它一个 192.168.56.2。在网关 B 上,我们记下来,国际身份 192.168.56.2 对应国内身份 192.168.1.101。凡是要访问 192.168.56.2,都转成 192.168.1.101。

于是,源服务器 A 要访问目标服务器 B,要指定的目标地址为 192.168.56.2。这是它的国际身份。服务器 A 想,192.168.56.2 和我不是一个网段的,因而需要发给网关,网关是谁?已经静态配置好了,网关是 192.168.1.1,网关的 MAC 地址是多少?发送 ARP 获取网关的 MAC 地址,然后发送包。包的内容是这样的:

  • 源 MAC:服务器 A 的 MAC
  • 目标 MAC:192.168.1.1 这个网口的 MAC
  • 源 IP:192.168.1.101
  • 目标 IP:192.168.56.2

包到达 192.168.1.1 这个网口,发现 MAC 一致,将包收进来,开始思考往哪里转发。

在路由器 A 中配置了静态路由:要想访问 192.168.56.2/24,要从 192.168.56.1 这个口出去,没有下一跳了,因为我右手这个网卡,就是这个网段的,我是最后一跳了。

于是,路由器 A 思考的时候,匹配上了这条路由,要从 192.168.56.1 这个口发出去,发给 192.168.56.2。那 192.168.56.2 的 MAC 地址是多少呢?路由器 A 发送 ARP 获取 192.168.56.2 的 MAC 地址。

当网络包发送到中间的局域网的时候,服务器 A 也需要有个国际身份,因而在国际上,源 IP 地址也不能用 192.168.1.101,需要改成 192.168.56.1。发送包的内容是这样的:

  • 源 MAC:192.168.56.1 的 MAC 地址
  • 目标 MAC:192.168.56.2 的 MAC 地址
  • 源 IP:192.168.56.1
  • 目标 IP:192.168.56.2

包到达 192.168.56.2 这个网口,发现 MAC 一致,将包收进来,开始思考往哪里转发。

路由器 B 是一个 NAT 网关,它上面配置了,要访问国际身份 192.168.56.2 对应国内身份 192.168.1.101,于是改为访问 192.168.1.101。

在路由器 B 中配置了静态路由:要想访问 192.168.1.0/24,要从 192.168.1.1 这个口出去,没有下一跳了,因为我右手这个网卡,就是这个网段的,我是最后一跳了。

于是,路由器 B 思考的时候,匹配上了这条路由,要从 192.168.1.1 这个口发出去,发给 192.168.1.101。

那 192.168.1.101 的 MAC 地址是多少呢?路由器 B 发送 ARP 获取 192.168.1.101 的 MAC 地址,然后发送包。内容是这样的:

  • 源 MAC:192.168.1.1 的 MAC 地址
  • 目标 MAC:192.168.1.101 的 MAC 地址
  • 源 IP:192.168.56.1
  • 目标 IP:192.168.1.101

包到达服务器 B,MAC 地址匹配,将包收进来。

从服务器 B 接收的包可以看出,源 IP 为服务器 A 的国际身份,因而发送返回包的时候,也发给这个国际身份,由路由器 A 做 NAT,转换为国内身份。

从这个过程可以看出,IP 地址也会变。这个过程用英文说就是Network Address Translation,简称NAT

其实这第二种方式我们经常见,现在大家每家都有家用路由器,家里的网段都是 192.168.1.x,所以你肯定访问不了你邻居家的这个私网的 IP 地址的。所以,当我们家里的包发出去的时候,都被家用路由器 NAT 成为了运营商的地址了。

很多办公室访问外网的时候,也是被 NAT 过的,因为不可能办公室里面的 IP 也是公网可见的,公网地址实在是太贵了,所以一般就是整个办公室共用一个到两个出口 IP 地址。你可以通过 https://www.whatismyip.com/ 查看自己的出口 IP 地址。

小结

好了,这一节内容差不多了,我来总结一下:

  • 如果离开本局域网,就需要经过网关,网关是路由器的一个网口;
  • 路由器是一个三层设备,里面有如何寻找下一跳的规则;
  • 经过路由器之后 MAC 头要变,如果 IP 不变,相当于不换护照的欧洲旅游,如果 IP 变,相当于换护照的玄奘西行。

第3讲 | ifconfig:最熟悉又陌生的命令行

上一节结尾给你留的一个思考题是,你知道怎么查看 IP 地址吗?

当面试听到这个问题的时候,面试者常常会觉得走错了房间。我面试的是技术岗位啊,怎么问这么简单的问题?

的确,即便没有专业学过计算机的人,只要倒腾过电脑,重装过系统,大多也会知道这个问题的答案:在 Windows 上是 ipconfig,在 Linux 上是 ifconfig。

那你知道在 Linux 上还有什么其他命令可以查看 IP 地址吗?答案是 ip addr。如果回答不上来这个问题,那你可能没怎么用过 Linux。

那你知道 ifconfig 和 ip addr 的区别吗?这是一个有关 net-tools 和 iproute2 的“历史”故事,你刚来到第三节,暂时不用了解这么细,但这也是一个常考的知识点。

想象一下,你登录进入一个被裁剪过的非常小的 Linux 系统中,发现既没有 ifconfig 命令,也没有 ip addr 命令,你是不是感觉这个系统压根儿没法用?这个时候,你可以自行安装 net-tools 和 iproute2 这两个工具。当然,大多数时候这两个命令是系统自带的。

安装好后,我们来运行一下 ip addr。不出意外,应该会输出下面的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
root@test:~# ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
link/ether fa:16:3e:c7:79:75 brd ff:ff:ff:ff:ff:ff
inet 10.100.122.2/24 brd 10.100.122.255 scope global eth0
valid_lft forever preferred_lft forever
inet6 fe80::f816:3eff:fec7:7975/64 scope link
valid_lft forever preferred_lft forever

这个命令显示了这台机器上所有的网卡。大部分的网卡都会有一个 IP 地址,当然,这不是必须的。在后面的分享中,我们会遇到没有 IP 地址的情况。

IP 地址是一个网卡在网络世界的通讯地址,相当于我们现实世界的门牌号码。既然是门牌号码,不能大家都一样,不然就会起冲突。比方说,假如大家都叫六单元 1001 号,那快递就找不到地方了。所以,有时候咱们的电脑弹出网络地址冲突,出现上不去网的情况,多半是 IP 地址冲突了。

如上输出的结果,10.100.122.2 就是一个 IP 地址。这个地址被点分隔为四个部分,每个部分 8 个 bit,所以 IP 地址总共是 32 位。这样产生的 IP 地址的数量很快就不够用了。因为当时设计 IP 地址的时候,哪知道今天会有这么多的计算机啊!因为不够用,于是就有了 IPv6,也就是上面输出结果里面 inet6 fe80::f816:3eff:fec7:7975/64。这个有 128 位,现在看来是够了,但是未来的事情谁知道呢?

本来 32 位的 IP 地址就不够,还被分成了 5 类。现在想想,当时分配地址的时候,真是太奢侈了。

img

在网络地址中,至少在当时设计的时候,对于 A、B、 C 类主要分两部分,前面一部分是网络号,后面一部分是主机号。这很好理解,大家都是六单元 1001 号,我是小区 A 的六单元 1001 号,而你是小区 B 的六单元 1001 号。

下面这个表格,详细地展示了 A、B、C 三类地址所能包含的主机的数量。在后文中,我也会多次借助这个表格来讲解。

img

这里面有个尴尬的事情,就是 C 类地址能包含的最大主机数量实在太少了,只有 254 个。当时设计的时候恐怕没想到,现在估计一个网吧都不够用吧。而 B 类地址能包含的最大主机数量又太多了。6 万多台机器放在一个网络下面,一般的企业基本达不到这个规模,闲着的地址就是浪费。

无类型域间选路(CIDR)

于是有了一个折中的方式叫作无类型域间选路,简称CIDR。这种方式打破了原来设计的几类地址的做法,将 32 位的 IP 地址一分为二,前面是网络号,后面是主机号。从哪里分呢?你如果注意观察的话可以看到,10.100.122.2/24,这个 IP 地址中有一个斜杠,斜杠后面有个数字 24。这种地址表示形式,就是 CIDR。后面 24 的意思是,32 位中,前 24 位是网络号,后 8 位是主机号。

伴随着 CIDR 存在的,一个是广播地址,10.100.122.255。如果发送这个地址,所有 10.100.122 网络里面的机器都可以收到。另一个是子网掩码,255.255.255.0。

将子网掩码和 IP 地址进行 AND 计算。前面三个 255,转成二进制都是 1。1 和任何数值取 AND,都是原来数值,因而前三个数不变,为 10.100.122。后面一个 0,转换成二进制是 0,0 和任何数值取 AND,都是 0,因而最后一个数变为 0,合起来就是 10.100.122.0。这就是网络号将子网掩码和 IP 地址按位计算 AND,就可得到网络号。

公有 IP 地址和私有 IP 地址

在日常的工作中,几乎不用划分 A 类、B 类或者 C 类,所以时间长了,很多人就忘记了这个分类,而只记得 CIDR。但是有一点还是要注意的,就是公有 IP 地址和私有 IP 地址。

img

我们继续看上面的表格。表格最右列是私有 IP 地址段。平时我们看到的数据中心里,办公室、家里或学校的 IP 地址,一般都是私有 IP 地址段。因为这些地址允许组织内部的 IT 人员自己管理、自己分配,而且可以重复。因此,你学校的某个私有 IP 地址段和我学校的可以是一样的。

这就像每个小区有自己的楼编号和门牌号,你们小区可以叫 6 栋,我们小区也叫 6 栋,没有任何问题。但是一旦出了小区,就需要使用公有 IP 地址。就像人民路 888 号,是国家统一分配的,不能两个小区都叫人民路 888 号。

公有 IP 地址有个组织统一分配,你需要去买。如果你搭建一个网站,给你学校的人使用,让你们学校的 IT 人员给你一个 IP 地址就行。但是假如你要做一个类似网易 163 这样的网站,就需要有公有 IP 地址,这样全世界的人才能访问。

表格中的 192.168.0.x 是最常用的私有 IP 地址。你家里有 Wi-Fi,对应就会有一个 IP 地址。一般你家里地上网设备不会超过 256 个,所以 /24 基本就够了。有时候我们也能见到 /16 的 CIDR,这两种是最常见的,也是最容易理解的。

不需要将十进制转换为二进制 32 位,就能明显看出 192.168.0 是网络号,后面是主机号。而整个网络里面的第一个地址 192.168.0.1,往往就是你这个私有网络的出口地址。例如,你家里的电脑连接 Wi-Fi,Wi-Fi 路由器的地址就是 192.168.0.1,而 192.168.0.255 就是广播地址。一旦发送这个地址,整个 192.168.0 网络里面的所有机器都能收到。

但是也不总都是这样的情况。因此,其他情况往往就会很难理解,还容易出错。

举例:一个容易“犯错”的 CIDR

我们来看 16.158.165.91/22 这个 CIDR。求一下这个网络的第一个地址、子网掩码和广播地址。

你要是上来就写 16.158.165.1,那就大错特错了。

/22 不是 8 的整数倍,不好办,只能先变成二进制来看。16.158 的部分不会动,它占了前 16 位。中间的 165,变为二进制为‭10100101‬。除了前面的 16 位,还剩 6 位。所以,这 8 位中前 6 位是网络号,16.158.<101001>,而 <01>.91 是机器号。

第一个地址是 16.158.<101001><00>.1,即 16.158.164.1。子网掩码是 255.255.<111111><00>.0,即 255.255.252.0。广播地址为 16.158.<101001><11>.255,即 16.158.167.255。

这五类地址中,还有一类 D 类是组播地址。使用这一类地址,属于某个组的机器都能收到。这有点类似在公司里面大家都加入了一个邮件组。发送邮件,加入这个组的都能收到。组播地址在后面讲述 VXLAN 协议的时候会提到。

讲了这么多,才讲了上面的输出结果中很小的一部分,是不是觉得原来并没有真的理解 ip addr 呢?我们接着来分析。

在 IP 地址的后面有个 scope,对于 eth0 这张网卡来讲,是 global,说明这张网卡是可以对外的,可以接收来自各个地方的包。对于 lo 来讲,是 host,说明这张网卡仅仅可以供本机相互通信。

lo 全称是loopback,又称环回接口,往往会被分配到 127.0.0.1 这个地址。这个地址用于本机通信,经过内核处理后直接返回,不会在任何网络中出现。

MAC 地址

在 IP 地址的上一行是 link/ether fa:16:3e:c7:79:75 brd ff:ff:ff:ff:ff:ff,这个被称为MAC 地址,是一个网卡的物理地址,用十六进制,6 个 byte 表示。

MAC 地址是一个很容易让人“误解”的地址。因为 MAC 地址号称全局唯一,不会有两个网卡有相同的 MAC 地址,而且网卡自生产出来,就带着这个地址。很多人看到这里就会想,既然这样,整个互联网的通信,全部用 MAC 地址好了,只要知道了对方的 MAC 地址,就可以把信息传过去。

这样当然是不行的。 一个网络包要从一个地方传到另一个地方,除了要有确定的地址,还需要有定位功能。 而有门牌号码属性的 IP 地址,才是有远程定位功能的。

例如,你去杭州市网商路 599 号 B 楼 6 层找刘超,你在路上问路,可能被问的人不知道 B 楼是哪个,但是可以给你指网商路怎么去。但是如果你问一个人,你知道这个身份证号的人在哪里吗?可想而知,没有人知道。

MAC 地址更像是身份证,是一个唯一的标识。它的唯一性设计是为了组网的时候,不同的网卡放在一个网络里面的时候,可以不用担心冲突。从硬件角度,保证不同的网卡有不同的标识。

MAC 地址是有一定定位功能的,只不过范围非常有限。你可以根据 IP 地址,找到杭州市网商路 599 号 B 楼 6 层,但是依然找不到我,你就可以靠吼了,大声喊身份证 XXXX 的是哪位?我听到了,我就会站起来说,是我啊。但是如果你在上海,到处喊身份证 XXXX 的是哪位,我不在现场,当然不会回答,因为我在杭州不在上海。

所以,MAC 地址的通信范围比较小,局限在一个子网里面。例如,从 192.168.0.2/24 访问 192.168.0.3/24 是可以用 MAC 地址的。一旦跨子网,即从 192.168.0.2/24 到 192.168.1.2/24,MAC 地址就不行了,需要 IP 地址起作用了。

网络设备的状态标识

解析完了 MAC 地址,我们再来看 <BROADCAST,MULTICAST,UP,LOWER_UP> 是干什么的?这个叫作net_device flags网络设备的状态标识

UP 表示网卡处于启动的状态;BROADCAST 表示这个网卡有广播地址,可以发送广播包;MULTICAST 表示网卡可以发送多播包;LOWER_UP 表示 L1 是启动的,也即网线插着呢。MTU1500 是指什么意思呢?是哪一层的概念呢?最大传输单元 MTU 为 1500,这是以太网的默认值。

上一节,我们讲过网络包是层层封装的。MTU 是二层 MAC 层的概念。MAC 层有 MAC 的头,以太网规定连 MAC 头带正文合起来,不允许超过 1500 个字节。正文里面有 IP 的头、TCP 的头、HTTP 的头。如果放不下,就需要分片来传输。

qdisc pfifo_fast 是什么意思呢?qdisc 全称是queueing discipline,中文叫排队规则。内核如果需要通过某个网络接口发送数据包,它都需要按照为这个接口配置的 qdisc(排队规则)把数据包加入队列。

最简单的 qdisc 是 pfifo,它不对进入的数据包做任何的处理,数据包采用先入先出的方式通过队列。pfifo_fast 稍微复杂一些,它的队列包括三个波段(band)。在每个波段里面,使用先进先出规则。

三个波段(band)的优先级也不相同。band 0 的优先级最高,band 2 的最低。如果 band 0 里面有数据包,系统就不会处理 band 1 里面的数据包,band 1 和 band 2 之间也是一样。

数据包是按照服务类型(Type of Service,TOS)被分配多三个波段(band)里面的。TOS 是 IP 头里面的一个字段,代表了当前的包是高优先级的,还是低优先级的。

队列是个好东西,后面我们讲云计算中的网络的时候,会有很多用户共享一个网络出口的情况,这个时候如何排队,每个队列有多粗,队列处理速度应该怎么提升,我都会详细为你讲解。

小结

怎么样,看起来很简单的一个命令,里面学问很大吧?通过这一节,希望你能记住以下的知识点,后面都能用得上:

  • IP 是地址,有定位功能;MAC 是身份证,无定位功能;
  • CIDR 可以用来判断是不是本地人;
  • IP 分公有的 IP 和私有的 IP。后面的章节中我会谈到“出国门”,就与这个有关。

第2讲 | 网络分层的真实含义是什么?

长时间从事计算机网络相关的工作,我发现,计算机网络有一个显著的特点,就是这是一个不仅需要背诵,而且特别需要将原理烂熟于胸的学科。很多问题看起来懂了,但是就怕往细里问,一问就发现你懂得没有那么透彻。

我们上一节列了之后要讲的网络协议。这些协议本来没什么稀奇,每一本教科书都会讲,并且都要求你背下来。因为考试会考,面试会问。可以这么说,毕业了去找工作还答不出这类题目的,那你的笔试基本上也就挂了。

当你听到什么二层设备、三层设备、四层 LB 和七层 LB 中层的时候,是否有点一头雾水,不知道这些所谓的层,对应的各种协议具体要做什么“工作”?

这四个问题你真的懂了吗?

因为教科书或者老师往往会打一个十分不恰当的比喻:为什么网络要分层呀?因为不同的层次之间有不同的沟通方式,这个叫作协议。例如,一家公司也是分“层次”的,分总经理、经理、组长、员工。总经理之间有他们的沟通方式,经理和经理之间也有沟通方式,同理组长和员工。有没有听过类似的比喻?

那么第一个问题来了。请问经理在握手的时候,员工在干什么?很多人听过 TCP 建立连接的三次握手协议,也会把它当知识点背诵。同理问你,TCP 在进行三次握手的时候,IP 层和 MAC 层对应都有什么操作呢?

除了上面这个不恰当的比喻,教科书还会列出每个层次所包含的协议,然后开始逐层地去讲这些协议。但是这些协议之间的关系呢?却很少有教科书会讲。

学习第三层的时候会提到,IP 协议里面包含目标地址源地址。第三层里往往还会学习路由协议。路由就像中转站,我们从原始地址 A 到目标地址 D,中间经过两个中转站 A->B->C->D,是通过路由转发的。

那么第二个问题来了。A 知道自己的下一个中转站是 B,那从 A 发出来的包,应该把 B 的 IP 地址放在哪里呢?B 知道自己的下一个中转站是 C,从 B 发出来的包,应该把 C 的 IP 地址放在哪里呢?如果放在 IP 协议中的目标地址,那包到了中转站,怎么知道最终的目的地址是 D 呢?

教科书不会通过场景化的例子,将网络包的生命周期讲出来,所以你就会很困惑,不知道这些协议实际的应用场景是什么。

再问你一个问题。你一定经常听说二层设备、三层设备。二层设备处理的通常是 MAC 层的东西。那我发送一个 HTTP 的包,是在第七层工作的,那是不是不需要经过二层设备?或者即便经过了,二层设备也不处理呢?或者换一种问法,二层设备处理的包里,有没有 HTTP 层的内容呢?

最终,我想问你一个综合的问题。从你的电脑,通过 SSH 登录到公有云主机里面,都需要经历哪些过程?或者说你打开一个电商网站,都需要经历哪些过程?说得越详细越好。

实际情况可能是,很多人会答不上来。尽管对每一层都很熟悉,但是知识点却串不起来。

上面的这些问题,有的在这一节就会有一个解释,有的则会贯穿我们整个课程。好在后面一节中我会举一个贯穿的例子,将很多层的细节讲过后,你很容易就能把这些知识点串起来。

网络为什么要分层?

这里我们先探讨第一个问题,网络为什么要分层?因为,是个复杂的程序都要分层。

理解计算机网络中的概念,一个很好的角度是,想象网络包就是一段 Buffer,或者一块内存,是有格式的。同时,想象自己是一个处理网络包的程序,而且这个程序可以跑在电脑上,可以跑在服务器上,可以跑在交换机上,也可以跑在路由器上。你想象自己有很多的网口,从某个口拿进一个网络包来,用自己的程序处理一下,再从另一个网口发送出去。

当然网络包的格式很复杂,这个程序也很复杂。复杂的程序都要分层,这是程序设计的要求。比如,复杂的电商还会分数据库层、缓存层、Compose 层、Controller 层和接入层,每一层专注做本层的事情。

程序是如何工作的?

我们可以简单地想象“你”这个程序的工作过程。

img

当一个网络包从一个网口经过的时候,你看到了,首先先看看要不要请进来,处理一把。有的网口配置了混杂模式,凡是经过的,全部拿进来。

拿进来以后,就要交给一段程序来处理。于是,你调用process_layer2(buffer)。当然,这是一个假的函数。但是你明白其中的意思,知道肯定是有这么个函数的。那这个函数是干什么的呢?从 Buffer 中,摘掉二层的头,看一看,应该根据头里面的内容做什么操作。

假设你发现这个包的 MAC 地址和你的相符,那说明就是发给你的,于是需要调用process_layer3(buffer)。这个时候,Buffer 里面往往就没有二层的头了,因为已经在上一个函数的处理过程中拿掉了,或者将开始的偏移量移动了一下。在这个函数里面,摘掉三层的头,看看到底是发送给自己的,还是希望自己转发出去的。

如何判断呢?如果 IP 地址不是自己的,那就应该转发出去;如果 IP 地址是自己的,那就是发给自己的。根据 IP 头里面的标示,拿掉三层的头,进行下一层的处理,到底是调用 process_tcp(buffer) 呢,还是调用 process_udp(buffer) 呢?

假设这个地址是 TCP 的,则会调用process_tcp(buffer)。这时候,Buffer 里面没有三层的头,就需要查看四层的头,看这是一个发起,还是一个应答,又或者是一个正常的数据包,然后分别由不同的逻辑进行处理。如果是发起或者应答,接下来可能要发送一个回复包;如果是一个正常的数据包,就需要交给上层了。交给谁呢?是不是有 process_http(buffer) 函数呢?

没有的,如果你是一个网络包处理程序,你不需要有 process_http(buffer),而是应该交给应用去处理。交给哪个应用呢?在四层的头里面有端口号,不同的应用监听不同的端口号。如果发现浏览器应用在监听这个端口,那你发给浏览器就行了。至于浏览器怎么处理,和你没有关系。

浏览器自然是解析 HTML,显示出页面来。电脑的主人看到页面很开心,就点了鼠标。点击鼠标的动作被浏览器捕获。浏览器知道,又要发起另一个 HTTP 请求了,于是使用端口号,将请求发给了你。

你应该调用send_tcp(buffer)。不用说,Buffer 里面就是 HTTP 请求的内容。这个函数里面加一个 TCP 的头,记录下源端口号。浏览器会给你目的端口号,一般为 80 端口。

然后调用send_layer3(buffer)。Buffer 里面已经有了 HTTP 的头和内容,以及 TCP 的头。在这个函数里面加一个 IP 的头,记录下源 IP 的地址和目标 IP 的地址。

然后调用send_layer2(buffer)。Buffer 里面已经有了 HTTP 的头和内容、TCP 的头,以及 IP 的头。这个函数里面要加一下 MAC 的头,记录下源 MAC 地址,得到的就是本机器的 MAC 地址和目标的 MAC 地址。不过,这个还要看当前知道不知道,知道就直接加上;不知道的话,就要通过一定的协议处理过程,找到 MAC 地址。反正要填一个,不能空着。

万事俱备,只要 Buffer 里面的内容完整,就可以从网口发出去了,你作为一个程序的任务就算告一段落了。

揭秘层与层之间的关系

知道了这个过程之后,我们再来看一下原来困惑的问题。

首先是分层的比喻。所有不能表示出层层封装含义的比喻,都是不恰当的。总经理握手,不需要员工在吧,总经理之间谈什么,不需要员工参与吧,但是网络世界不是这样的。正确的应该是,总经理之间沟通的时候,经理将总经理放在自己兜里,然后组长把经理放自己兜里,员工把组长放自己兜里,像套娃娃一样。那员工直接沟通,不带上总经理,就不恰当了。

现实生活中,往往是员工说一句,组长补充两句,然后经理补充两句,最后总经理再补充两句。但是在网络世界,应该是总经理说话,经理补充两句,组长补充两句,员工再补充两句。

那 TCP 在三次握手的时候,IP 层和 MAC 层在做什么呢?当然是 TCP 发送每一个消息,都会带着 IP 层和 MAC 层了。因为,TCP 每发送一个消息,IP 层和 MAC 层的所有机制都要运行一遍。而你只看到 TCP 三次握手了,其实,IP 层和 MAC 层为此也忙活好久了。

这里要记住一点:只要是在网络上跑的包,都是完整的。可以有下层没上层,绝对不可能有上层没下层。

所以,对 TCP 协议来说,三次握手也好,重试也好,只要想发出去包,就要有 IP 层和 MAC 层,不然是发不出去的。

经常有人会问这样一个问题,我都知道那台机器的 IP 地址了,直接发给他消息呗,要 MAC 地址干啥?这里的关键就是,没有 MAC 地址消息是发不出去的。

所以如果一个 HTTP 协议的包跑在网络上,它一定是完整的。无论这个包经过哪些设备,它都是完整的。

所谓的二层设备、三层设备,都是这些设备上跑的程序不同而已。一个 HTTP 协议的包经过一个二层设备,二层设备收进去的是整个网络包。这里面 HTTP、TCP、 IP、 MAC 都有。什么叫二层设备呀,就是只把 MAC 头摘下来,看看到底是丢弃、转发,还是自己留着。那什么叫三层设备呢?就是把 MAC 头摘下来之后,再把 IP 头摘下来,看看到底是丢弃、转发,还是自己留着。

小结

总结一下今天的内容,理解网络协议的工作模式,有两个小窍门:

  • 始终想象自己是一个处理网络包的程序:如何拿到网络包,如何根据规则进行处理,如何发出去;
  • 始终牢记一个原则:只要是在网络上跑的包,都是完整的。可以有下层没上层,绝对不可能有上层没下层。