129-系统性能调优必知必会

开篇词 | 万变不离其宗，性能优化也有章可循

你好，我是陶辉。欢迎你和我一起学习“系统性能优化”。

从 2004 年毕业到现在，无论我在华为、思科、腾讯、阿里巴巴等等哪家公司，我的工作总是与“性能”相伴，从底层、应用层到前端，我一直都很关心系统如何能够服务更多的用户，提供更快的体验。换句话说啊，性能优化最根本的目的，还是要跟上业务的发展脚步。

在我看来，程序员所做的工作，就是把现实世界中的问题，用数据、模型来抽象，再用计算机的计算能力把问题解决掉，虽然 IT 设备的运行速度日新月异，但业务要处理的数据在现实世界中近乎是无限的，而我们的计算能力相对则极为有限。所以，我们需要提升计算的效率，需要更苛刻地使用计算机和网络设备，做性价比更高的事儿。

而从十几年的性能优化工作中我发现，性能不只对产品的攻城掠地至关重要，它也是程序员价值的重要体现，特别是它在工作面试、技术等级晋升上也扮演着核心角色。

比如，在大多数拥有技术职级晋升体系的公司里，为了保障公平性，一般都是由跨部门的专家组成评委会的，而其他部门的高级专家在不熟悉候选人业务的情况下，只能去考察底层的硬核知识，而性能问题又是最有区分度的问题。如果你始终埋头在业务中，不关心更通用的性能优化方法论，将在技术等级晋升上非常吃亏。

再比如，你在面试互联网大厂时，面试官总会问许多超出工作范围的性能问题，为什么会这样呢？当然你可以感慨甚至抱怨，这不就是“面试造火箭，入职拧螺丝”嘛，但你也可以从面试官的角度来看这个问题，你会发现性能就是最好的面试题，它从算法到架构，既能考察候选人的潜力，也能考察候选人的工程能力。如果候选人具备系统的性能优化方法论，那么无论在架构设计还是应用模块开发上，他的代码可扩展性都会更好，消耗的计算力、带宽、磁盘等 IT 资源也更少！

这么看来，不论是为了满足业务发展的需求，还是在面试、晋升场景中有更好的表现，如果你希望成为高薪高效的 10X 程序员，那么，系统地学习性能优化就是一门必修课。

那具体应该从哪里入手呢？当然是看需求。当下的后端几乎都是分布式系统，那么对应的，我们面对的课题也就是如何全面提升复杂集群的性能。然而，如果你在 Google 上搜索如何优化分布式系统的性能，你只能找到孤零零的几篇文章，而谈到分布式系统的多数书籍也都在讨论容错、事务、流控等概念的实现，很少有文章介绍如何优化整个系统的性能。这恰恰就是我想做这门课的初衷。

我希望把自己这些年来在分布式性能领域所遇到的问题和解决方案，归纳总结、抽离萃取，梳理出一条系统化的性能学习路径交付给你，告诉你我眼中的性能问题本质是怎样的。在我看来啊，性能优化的本质就是最大化整个系统的综合效率，为了达到这个目标，我们需要从空间、时间维度上，不断地优化基础资源间的协作方式。

文稿中我给你总结了一份系统性能优化核心关注点的知识脑图，你可以同步看一下：

通过这份脑图，你会发现，我是从 4 个方面来梳理的，这其实就是我们在提升一个新系统的性能时，可以入手的 4 个层次。

首先，你可以从提升单机进程的性能入手，包括高效地使用主机的 CPU、内存、磁盘等硬件，通过并发编程提升吞吐量，根据业务特性选择合适的算法。

其次，分布式系统是由各个组件通过网络连接在一起，所以优化传输层网络可以让所有组件同时受益。具体优化时，你可以从降低请求的时延，提升总体吞吐量两个方向入手。

再次呢，你要对业务消息采用更高效的编码方式，这既包括协议头、包体的优化，也包括 TLS 安全层的性能提升。具体优化时，既要深入静态编码，也要从动态的增量编码上优化。同时，调整消息的交互方式也能提升性能。

最后，我们再从集群整体上进行架构层面的优化。基于 ACP、AKF、NWR 等分布式理论，我们的优化方向仍然是降低时延和提升吞吐量，但实现方式则要运用分而治之的思想，调度集群中的所有结点协作配合，完成性能优化目标。

我这么介绍完吧，我猜你心里可能会有一些问题，好像这些内容都挺熟悉呀？是的，咱们这门课的知识点并不是非常新的前沿知识，而是我们每天 coding 时都在面对的日常问题。这里的关键是我们得系统地掌握这些知识点，在心中构建出性能优化树状知识图谱，然后我们才能更有底气地优化整个系统的性能。

另外，你可能也会感觉到，哎，好像性能优化也没那么复杂吧，感觉知识点不多呀！如果你是这么想的，那我要先夸夸你，我们就是要有这样的信心，先不给自己设限，不能还没开始就怕了。不过有了信心和动力后呢，也还是要再听我多说几句。

性能问题其实是计算机体系的底层问题，它涉及到的知识面非常广，我们的课程不可能覆盖全部领域。我最希望给到你的，是基于自己的经历和经验，对知识做一次筛选和过滤，把我已经构建起来的性能优化体系给到你，但同时，我能保证我们在解决性能优化中一些典型问题的同时，可以关联到绝大部分领域，对于任何一个领域，如果你需要进一步深入学习，你也能够知道自己的目标和路径。

好了，课程我就介绍到这里，你准备好了吗？那在正式开始之前，我们先来定下学习目标吧。

如果你需要从架构层面优化整个系统，那么这门课可以拓展你的知识面，告诉你如何优化架构才能让整体服务获得最大性能。那你的学习目标就要聚焦在知识体系上，你需要阶段性地做总结分享。

如果你刚开始接触性能优化，这门课则可以给你打牢基础，告诉你影响性能的底层因素，在实践中优化你的程序，看到立竿见影的效果。那我对你的要求就是每节课都要认真完成课后思考题，学习过程中有任何问题都要及时提问，不积压不懂的地方。

当然了，我的“要求”主要是供你参考的，欢迎你在留言区写写自己的学习计划。最后，我还特别希望听你说说，你目前的工作状态，是否意识到了性能问题的重要性，是否已经在解决很多性能相关的棘手问题，是否自己也总结了一些性能优化思路，分享出来，我们一起交流。

讲到最后，我都有点激动了，我一直认为性能优化并非是架构师的专属技能，只要我们有清晰的路径，积硅步成千里，我们都可以用更好的体验、更低的成本来服务更多的用户，还能轻松应对大厂面试，完成公司技术体系内的晋升，拿到更高的薪资。

那还等啥，从现在开始，我们就一起解锁分布式系统的性能优化吧！

01 | CPU缓存：怎样写代码能够让CPU执行得更快？

你好，我是陶辉。

这是课程的第一讲，我们先从主机最重要的部件 CPU 开始，聊聊如何通过提升 CPU 缓存的命中率来优化程序的性能。

任何代码的执行都依赖 CPU，通常，使用好 CPU 是操作系统内核的工作。然而，当我们编写计算密集型的程序时，CPU 的执行效率就开始变得至关重要。由于 CPU 缓存由更快的 SRAM 构成（内存是由 DRAM 构成的），而且离 CPU 核心更近，如果运算时需要的输入数据是从 CPU 缓存，而不是内存中读取时，运算速度就会快很多。所以，了解 CPU 缓存对性能的影响，便能够更有效地编写我们的代码，优化程序性能。

然而，很多同学并不清楚 CPU 缓存的运行规则，不知道如何写代码才能够配合 CPU 缓存的工作方式，这样，便放弃了可以大幅提升核心计算代码执行速度的机会。而且，越是底层的优化，适用范围越广，CPU 缓存便是如此，它的运行规则对分布式集群里各种操作系统、编程语言都有效。所以，一旦你能掌握它，集群中巨大的主机数量便能够放大优化效果。

接下来，我们就看看，CPU 缓存结构到底是什么样的，又该如何优化它？

CPU 的多级缓存

刚刚我们提到，CPU 缓存离 CPU 核心更近，由于电子信号传输是需要时间的，所以离 CPU 核心越近，缓存的读写速度就越快。但 CPU 的空间很狭小，离 CPU 越近缓存大小受到的限制也越大。所以，综合硬件布局、性能等因素，CPU 缓存通常分为大小不等的三级缓存。

CPU 缓存的材质 SRAM 比内存使用的 DRAM 贵许多，所以不同于内存动辄以 GB 计算，它的大小是以 MB 来计算的。比如，在我的 Linux 系统上，离 CPU 最近的一级缓存是 32KB，二级缓存是 256KB，最大的三级缓存则是 20MB（Windows 系统查看缓存大小可以用 wmic cpu 指令，或者用CPU-Z这个工具）。

你可能注意到，三级缓存要比一、二级缓存大许多倍，这是因为当下的 CPU 都是多核心的，每个核心都有自己的一、二级缓存，但三级缓存却是一颗 CPU 上所有核心共享的。

程序执行时，会先将内存中的数据载入到共享的三级缓存中，再进入每颗核心独有的二级缓存，最后进入最快的一级缓存，之后才会被 CPU 使用，就像下面这张图。

缓存要比内存快很多。CPU 访问一次内存通常需要 100 个时钟周期以上，而访问一级缓存只需要 4~5 个时钟周期，二级缓存大约 12 个时钟周期，三级缓存大约 30 个时钟周期（对于 2GHZ 主频的 CPU 来说，一个时钟周期是 0.5 纳秒。你可以在 LZMA 的Benchmark中找到几种典型 CPU 缓存的访问速度）。

如果 CPU 所要操作的数据在缓存中，则直接读取，这称为缓存命中。命中缓存会带来很大的性能提升，因此，我们的代码优化目标是提升 CPU 缓存的命中率。

当然，缓存命中率是很笼统的，具体优化时还得一分为二。比如，你在查看 CPU 缓存时会发现有 2 个一级缓存（比如 Linux 上就是上图中的 index0 和 index1），这是因为，CPU 会区别对待指令与数据。比如，“1+1=2”这个运算，“+”就是指令，会放在一级指令缓存中，而“1”这个输入数字，则放在一级数据缓存中。虽然在冯诺依曼计算机体系结构中，代码指令与数据是放在一起的，但执行时却是分开进入指令缓存与数据缓存的，因此我们要分开来看二者的缓存命中率。

提升数据缓存的命中率

我们先来看数据的访问顺序是如何影响缓存命中率的。

比如现在要遍历二维数组，其定义如下（这里我用的是伪代码，在 GitHub 上我为你准备了可运行验证的 C/C++、Java示例代码，你可以参照它们编写出其他语言的可执行代码）：

       int array[N][N];

你可以思考一下，用 array[j][i]和 array[i][j]访问数组元素，哪一种性能更快？

       for(i = 0; i < N; i+=1) {
           for(j = 0; j < N; j+=1) {
               array[i][j] = 0;
           }
       }

在我给出的 GitHub 地址上的 C++ 代码实现中，前者 array[j][i]执行的时间是后者 array[i][j]的 8 倍之多（请参考traverse_2d_array.cpp，如果使用 Python 代码，traverse_2d_array.py 由于数组容器的差异，性能差距不会那么大）。

为什么会有这么大的差距呢？这是因为二维数组 array 所占用的内存是连续的，比如若长度 N 的值为 2，那么内存中从前至后各元素的顺序是：

array[0][0]，array[0][1]，array[1][0]，array[1][1]。

如果用 array[i][j]访问数组元素，则完全与上述内存中元素顺序一致，因此访问 array[0][0]时，缓存已经把紧随其后的 3 个元素也载入了，CPU 通过快速的缓存来读取后续 3 个元素就可以。如果用 array[j][i]来访问，访问的顺序就是：

array[0][0]，array[1][0]，array[0][1]，array[1][1]

此时内存是跳跃访问的，如果 N 的数值很大，那么操作 array[j][i]时，是没有办法把 array[j+1][i]也读入缓存的。

到这里我们还有 2 个问题没有搞明白：

为什么两者的执行时间有约 7、8 倍的差距呢？

载入 array[0][0]元素时，缓存一次性会载入多少元素呢？

其实这两个问题的答案都与 CPU Cache Line 相关，它定义了缓存一次载入数据的大小，Linux 上你可以通过 coherency_line_size 配置查看它，通常是 64 字节。

因此，我测试的服务器一次会载入 64 字节至缓存中。当载入 array[0][0]时，若它们占用的内存不足 64 字节，CPU 就会顺序地补足后续元素。顺序访问的 array[i][j]因为利用了这一特点，所以就会比 array[j][i]要快。也正因为这样，当元素类型是 4 个字节的整数时，性能就会比 8 个字节的高精度浮点数时速度更快，因为缓存一次载入的元素会更多。

因此，遇到这种遍历访问数组的情况时，按照内存布局顺序访问将会带来很大的性能提升。

再来看为什么执行时间相差 8 倍。在二维数组中，其实第一维元素存放的是地址，第二维存放的才是目标元素。由于 64 位操作系统的地址占用 8 个字节（32 位操作系统是 4 个字节），因此，每批 Cache Line 最多也就能载入不到 8 个二维数组元素，所以性能差距大约接近 8 倍。（用不同的步长访问数组，也能验证 CPU Cache Line 对性能的影响，可参考我给你准备的Github上的测试代码）。

关于 CPU Cache Line 的应用其实非常广泛，如果你用过 Nginx，会发现它是用哈希表来存放域名、HTTP 头部等数据的，这样访问速度非常快，而哈希表里桶的大小如 server_names_hash_bucket_size，它默认就等于 CPU Cache Line 的值。由于所存放的字符串长度不能大于桶的大小，所以当需要存放更长的字符串时，就需要修改桶大小，但 Nginx 官网上明确建议它应该是 CPU Cache Line 的整数倍。

为什么要做这样的要求呢？就是因为缓存是按照 64 字节的整数倍来访问内存的，哈希表的桶按此大小排列布局，就可以尽量减少访问内存的次数。比如，若桶大小为 64 字节，那么根据地址获取字符串时只需要访问一次内存，而桶大小为 50 字节，会导致最坏 2 次访问内存，而 70 字节最坏会有 3 次访问内存。

如果你在用 Linux 操作系统，可以通过一个名叫 Perf 的工具直观地验证缓存命中的情况（可以用 yum install perf 或者 apt-get install perf 安装这个工具，这个网址中有大量案例可供参考）。

执行 perf stat 可以统计出进程运行时的系统信息（通过 -e 选项指定要统计的事件，如果要查看三级缓存总的命中率，可以指定缓存未命中 cache-misses 事件，以及读取缓存次数 cache-references 事件，两者相除就是缓存的未命中率，用 1 相减就是命中率。类似的，通过 L1-dcache-load-misses 和 L1-dcache-loads 可以得到 L1 缓存的命中率），此时你会发现 array[i][j]的缓存命中率远高于 array[j][i]。

当然，perf stat 还可以通过指令执行速度反映出两种访问方式的优劣，如下图所示（instructions 事件指明了进程执行的总指令数，而 cycles 事件指明了运行的时钟周期，二者相除就可以得到每时钟周期所执行的指令数，缩写为 IPC。如果缓存未命中，则 CPU 要等待内存的慢速读取，因此 IPC 就会很低。array[i][j]的 IPC 值也比 array[j][i]要高得多）：

提升指令缓存的命中率

说完数据的缓存命中率，再来看指令的缓存命中率该如何提升。

我们还是用一个例子来看一下。比如，有一个元素为 0 到 255 之间随机数字组成的数组：

int array[N];
for (i = 0; i < TESTN; i++) array[i] = rand() % 256;

接下来要对它做两个操作：一是循环遍历数组，判断每个数字是否小于 128，如果小于则把元素的值置为 0；二是将数组排序。那么，先排序再遍历速度快，还是先遍历再排序速度快呢？

for(i = 0; i < N; i++) {
       if (array [i] < 128) array[i] = 0;
}
sort(array, array +N);

我先给出答案：先排序的遍历时间只有后排序的三分之一（参考 GitHub 中的branch_predict.cpp 代码）。为什么会这样呢？这是因为循环中有大量的 if 条件分支，而 CPU含有分支预测器。

当代码中出现 if、switch 等语句时，意味着此时至少可以选择跳转到两段不同的指令去执行。如果分支预测器可以预测接下来要在哪段代码执行（比如 if 还是 else 中的指令），就可以提前把这些指令放在缓存中，CPU 执行时就会很快。当数组中的元素完全随机时，分支预测器无法有效工作，而当 array 数组有序时，分支预测器会动态地根据历史命中数据对未来进行预测，命中率就会非常高。

究竟有多高呢？我们还是用 Linux 上的 perf 来做个验证。使用 -e 选项指明 branch-loads 事件和 branch-load-misses 事件，它们分别表示分支预测的次数，以及预测失败的次数。通过 L1-icache-load-misses 也能查看到一级缓存中指令的未命中情况。

下图是我在 GitHub 上为你准备的验证程序执行的 perf 分支预测统计数据（代码见这里），你可以看到，先排序的话分支预测的成功率非常高，而且一级指令缓存的未命中率也有大幅下降。

C/C++ 语言中编译器还给应用程序员提供了显式预测分支概率的工具，如果 if 中的条件表达式判断为“真”的概率非常高，我们可以用 likely 宏把它括在里面，反之则可以用 unlikely 宏。当然，CPU 自身的条件预测已经非常准了，仅当我们确信 CPU 条件预测不会准，且我们能够知晓实际概率时，才需要加入这两个宏。

#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)
if (likely(a == 1)) …

提升多核 CPU 下的缓存命中率

前面我们都是面向一个 CPU 核心谈数据及指令缓存的，然而现代 CPU 几乎都是多核的。虽然三级缓存面向所有核心，但一、二级缓存是每颗核心独享的。我们知道，即使只有一个 CPU 核心，现代分时操作系统都支持许多进程同时运行。这是因为操作系统把时间切成了许多片，微观上各进程按时间片交替地占用 CPU，这造成宏观上看起来各程序同时在执行。

因此，若进程 A 在时间片 1 里使用 CPU 核心 1，自然也填满了核心 1 的一、二级缓存，当时间片 1 结束后，操作系统会让进程 A 让出 CPU，基于效率并兼顾公平的策略重新调度 CPU 核心 1，以防止某些进程饿死。如果此时 CPU 核心 1 繁忙，而 CPU 核心 2 空闲，则进程 A 很可能会被调度到 CPU 核心 2 上运行，这样，即使我们对代码优化得再好，也只能在一个时间片内高效地使用 CPU 一、二级缓存了，下一个时间片便面临着缓存效率的问题。

因此，操作系统提供了将进程或者线程绑定到某一颗 CPU 上运行的能力。如 Linux 上提供了 sched_setaffinity 方法实现这一功能，其他操作系统也有类似功能的 API 可用。我在 GitHub 上提供了一个示例程序（代码见这里），你可以看到，当多线程同时执行密集计算，且 CPU 缓存命中率很高时，如果将每个线程分别绑定在不同的 CPU 核心上，性能便会获得非常可观的提升。Perf 工具也提供了 cpu-migrations 事件，它可以显示进程从不同的 CPU 核心上迁移的次数。

小结

今天我给你介绍了 CPU 缓存对程序性能的影响。这是很底层的性能优化，它对各种编程语言做密集计算时都有效。

CPU 缓存分为数据缓存与指令缓存，对于数据缓存，我们应在循环体中尽量操作同一块内存上的数据，由于缓存是根据 CPU Cache Line 批量操作数据的，所以顺序地操作连续内存数据时也有性能提升。

对于指令缓存，有规律的条件分支能够让 CPU 的分支预测发挥作用，进一步提升执行效率。对于多核系统，如果进程的缓存命中率非常高，则可以考虑绑定 CPU 来提升缓存命中率。

思考题

最后请你思考下，多线程并行访问不同的变量，这些变量在内存布局是相邻的（比如类中的多个变量），此时 CPU 缓存就会失效，为什么？又该如何解决呢？欢迎你在留言区与大家一起探讨。

感谢阅读，如果你觉得这篇文章对你有一些启发，也欢迎把它分享给你的朋友。

02 | 内存池：如何提升内存分配的效率？

你好，我是陶辉。

上一讲我们提到，高频地命中 CPU 缓存可以提升性能。这一讲我们把关注点从 CPU 转移到内存，看看如何提升内存分配的效率。

或许有同学会认为，我又不写底层框架，内存分配也依赖虚拟机，并不需要应用开发者了解。如果你也这么认为，我们不妨看看这个例子：在 Linux 系统中，用 Xmx 设置 JVM 的最大堆内存为 8GB，但在近百个并发线程下，观察到 Java 进程占用了 14GB 的内存。为什么会这样呢？

这是因为，绝大部分高级语言都是用 C 语言编写的，包括 Java，申请内存必须经过 C 库，而 C 库通过预分配更大的空间作为内存池，来加快后续申请内存的速度。这样，预分配的 6GB 的 C 库内存池就与 JVM 中预分配的 8G 内存池叠加在一起，造成了 Java 进程的内存占用超出了预期。

掌握内存池的特性，既可以避免写程序时内存占用过大，导致服务器性能下降或者进程 OOM（Out Of Memory，内存溢出）被系统杀死，还可以加快内存分配的速度。在系统空闲时申请内存花费不了多少时间，但是对于分布式环境下繁忙的多线程服务，获取内存的时间会上升几十倍。

另一方面，内存池是非常底层的技术，当我们理解它后，可以更换适合应用场景的内存池。在多种编程语言共存的分布式系统中，内存池有很广泛的应用，优化内存池带来的任何微小的性能提升，都将被分布式集群巨大的主机规模放大，从而带来整体上非常可观的收益。

接下来，我们就通过对内存池的学习，看看如何提升内存分配的效率。

隐藏的内存池

实际上，在你的业务代码与系统内核间，往往有两层内存池容易被忽略，尤其是其中的 C 库内存池。

当代码申请内存时，首先会到达应用层内存池，如果应用层内存池有足够的可用内存，就会直接返回给业务代码，否则，它会向更底层的 C 库内存池申请内存。比如，如果你在 Apache、Nginx 等服务之上做模块开发，这些服务中就有独立的内存池。当然，Java 中也有内存池，当通过启动参数 Xmx 指定 JVM 的堆内存为 8GB 时，就设定了 JVM 堆内存池的大小。

你可能听说过 Google 的 TCMalloc 和 FaceBook 的 JEMalloc，它们也是 C 库内存池。当 C 库内存池无法满足内存申请时，才会向操作系统内核申请分配内存。如下图所示：

回到文章开头的问题，Java 已经有了应用层内存池，为什么还会受到 C 库内存池的影响呢？这是因为，除了 JVM 负责管理的堆内存外，Java 还拥有一些堆外内存，由于它不使用 JVM 的垃圾回收机制，所以更稳定、持久，处理 IO 的速度也更快。这些堆外内存就会由 C 库内存池负责分配，这是 Java 受到 C 库内存池影响的原因。

其实不只是 Java，几乎所有程序都在使用 C 库内存池分配出的内存。C 库内存池影响着系统下依赖它的所有进程。我们就以 Linux 系统的默认 C 库内存池 Ptmalloc2 来具体分析，看看它到底对性能发挥着怎样的作用。

C 库内存池工作时，会预分配比你申请的字节数更大的空间作为内存池。比如说，当主进程下申请 1 字节的内存时，Ptmalloc2 会预分配 132K 字节的内存（Ptmalloc2 中叫 Main Arena），应用代码再申请内存时，会从这已经申请到的 132KB 中继续分配。

如下所示（你可以在这里找到示例程序，注意地址的单位是 16 进制）：

# cat /proc/2891/maps | grep heap
01643000-01664000 rw-p 00000000 00:00 0     [heap]

当我们释放这 1 字节时，Ptmalloc2 也不会把内存归还给操作系统。Ptmalloc2 认为，与其把这 1 字节释放给操作系统，不如先缓存着放进内存池里，仍然当作用户态内存留下来，进程再次申请 1 字节的内存时就可以直接复用，这样速度快了很多。

你可能会想，132KB 不多呀？为什么这一讲开头提到的 Java 进程，会被分配了几个 GB 的内存池呢？这是因为多线程与单线程的预分配策略并不相同。

每个子线程预分配的内存是 64MB（Ptmalloc2 中被称为 Thread Arena，32 位系统下为 1MB，64 位系统下为 64MB）。如果有 100 个线程，就将有 6GB 的内存都会被内存池占用。当然，并不是设置了 1000 个线程，就会预分配 60GB 的内存，子线程内存池最多只能到 8 倍的 CPU 核数，比如在 32 核的服务器上，最多只会有 256 个子线程内存池，但这也非常夸张了，16GB（64MB * 256 = 16GB）的内存将一直被 Ptmalloc2 占用。

回到本文开头的问题，Linux 下的 JVM 编译时默认使用了 Ptmalloc2 内存池，因此每个线程都预分配了 64MB 的内存，这造成含有上百个 Java 线程的 JVM 多使用了 6GB 的内存。在多数情况下，这些预分配出来的内存池，可以提升后续内存分配的性能。

然而，Java 中的 JVM 内存池已经管理了绝大部分内存，确实不能接受莫名多出来 6GB 的内存，那该怎么办呢？既然我们知道了 Ptmalloc2 内存池的存在，就有两种解决办法。

首先可以调整 Ptmalloc2 的工作方式。通过设置 MALLOC_ARENA_MAX 环境变量，可以限制线程内存池的最大数量，当然，线程内存池的数量减少后，会影响 Ptmalloc2 分配内存的速度。不过由于 Java 主要使用 JVM 内存池来管理对象，这点影响并不重要。

其次可以更换掉 Ptmalloc2 内存池，选择一个预分配内存更少的内存池，比如 Google 的 TCMalloc。

这并不是说 Google 出品的 TCMalloc 性能更好，而是在特定的场景中的选择不同。而且，盲目地选择 TCMalloc 很可能会降低性能，否则 Linux 系统早把默认的内存池改为 TCMalloc 了。

TCMalloc 和 Ptmalloc2 是目前最主流的两个内存池，接下来我带你通过对比 TCMalloc 与 Ptmalloc2 内存池，看看到底该如何选择内存池。

选择 Ptmalloc2 还是 TCMalloc？

先来看 TCMalloc 适用的场景，它对多线程下小内存的分配特别友好。

比如，在 2GHz 的 CPU 上分配、释放 256K 字节的内存，Ptmalloc2 耗时 32 纳秒，而 TCMalloc 仅耗时 10 纳秒（测试代码参见这里）。差距超过了 3 倍，为什么呢？这是因为，Ptmalloc2 假定，如果线程 A 申请并释放了的内存，线程 B 可能也会申请类似的内存，所以它允许内存池在线程间复用以提升性能。

因此，每次分配内存，Ptmalloc2 一定要加锁，才能解决共享资源的互斥问题。然而，加锁的消耗并不小。如果你监控分配速度的话，会发现单线程服务调整为 100 个线程，Ptmalloc2 申请内存的速度会变慢 10 倍。TCMalloc 针对小内存做了很多优化，每个线程独立分配内存，无须加锁，所以速度更快！

而且，线程数越多，Ptmalloc2 出现锁竞争的概率就越高。比如我们用 40 个线程做同样的测试，TCMalloc 只是从 10 纳秒上升到 25 纳秒，只增长了 1.5 倍，而 Ptmalloc2 则从 32 纳秒上升到 137 纳秒，增长了 3 倍以上。

下图是 TCMalloc 作者给出的性能测试数据，可以看到线程数越多，二者的速度差距越大。所以，当应用场景涉及大量的并发线程时，换成 TCMalloc 库也更有优势！

图片来源：TCMalloc : Thread-Caching Malloc

那么，为什么 GlibC 不把默认的 Ptmalloc2 内存池换成 TCMalloc 呢？因为 Ptmalloc2 更擅长大内存的分配。

比如，单线程下分配 257K 字节的内存，Ptmalloc2 的耗时不变仍然是 32 纳秒，但 TCMalloc 就由 10 纳秒上升到 64 纳秒，增长了 5 倍以上！现在 TCMalloc 反过来比 Ptmalloc2 慢了 1 倍！这是因为 TCMalloc 特意针对小内存做了优化。

多少字节叫小内存呢？TCMalloc 把内存分为 3 个档次，小于等于 256KB 的称为小内存，从 256KB 到 1M 称为中等内存，大于 1MB 的叫做大内存。TCMalloc 对中等内存、大内存的分配速度很慢，比如我们用单线程分配 2M 的内存，Ptmalloc2 耗时仍然稳定在 32 纳秒，但 TCMalloc 已经上升到 86 纳秒，增长了 7 倍以上。

所以，如果主要分配 256KB 以下的内存，特别是在多线程环境下，应当选择 TCMalloc；否则应使用 Ptmalloc2，它的通用性更好。

从堆还是栈上分配内存？

不知道你发现没有，刚刚讨论的内存池中分配出的都是堆内存，如果你把在堆中分配的对象改为在栈上分配，速度还会再快上 1 倍（具体测试代码可以在这里找到）！为什么？

可能有同学还不清楚堆和栈内存是如何分配的，我先简单介绍一下。

如果你使用的是静态类型语言，那么，不使用 new 关键字分配的对象大都是在栈中的。比如：

C/C++/Java语言：int a = 10;

否则，通过 new 或者 malloc 关键字分配的对象则是在堆中的：

C语言：int * a = (int*) malloc(sizeof(int));
C++语言：int * a = new int;
Java语言：int a = new Integer(10);

另外，对于动态类型语言，无论是否使用 new 关键字，内存都是从堆中分配的。

了解了这一点之后，我们再来看看，为什么从栈中分配内存会更快。

这是因为，由于每个线程都有独立的栈，所以分配内存时不需要加锁保护，而且栈上对象的尺寸在编译阶段就已经写入可执行文件了，执行效率更高！性能至上的 Golang 语言就是按照这个逻辑设计的，即使你用 new 关键字分配了堆内存，但编译器如果认为在栈中分配不影响功能语义时，会自动改为在栈中分配。

当然，在栈中分配内存也有缺点，它有功能上的限制。一是， 栈内存生命周期有限，它会随着函数调用结束后自动释放。在堆中分配的内存，并不随着分配时所在函数调用的结束而释放，它的生命周期足够使用；二是，栈的容量有限，如 CentOS 7 中是 8MB 字节，如果你申请的内存超过限制会造成栈溢出错误（比如，递归函数调用很容易造成这种问题），而堆则没有容量限制。

所以，当我们分配内存时，如果在满足功能的情况下，可以在栈中分配的话，就选择栈。

小结

最后我们对这一讲做个小结。

进程申请内存的速度，以及总内存空间都受到内存池的影响。知道这些隐藏内存池的存在，是提升分配内存效率的前提。

隐藏着的 C 库内存池，对进程的内存开销有很大的影响。当进程的占用空间超出预期时，你需要清楚你正在使用的是什么内存池，它对每个线程预分配了多大的空间。

不同的 C 库内存池，都有它们最适合的应用场景，例如 TCMalloc 对多线程下的小内存分配特别友好，而 Ptmalloc2 则对各类尺寸的内存申请都有稳定的表现，更加通用。

内存池管理着堆内存，它的分配速度比不上在栈中分配内存。只是栈中分配的内存受到生命周期和容量大小的限制，应用场景更为有限。然而，如果有可能的话，尽量在栈中分配内存，它比内存池中的堆内存分配速度快很多！

OK，今天我们从内存分配的角度聊了分布式系统性能提升的内容，希望学习过今天的内容后，你知道如何最快速地申请到内存，了解你正在使用的内存池，并清楚它对进程最终内存大小的影响。即使对第三方组件，我们也可以通过 LP_PRELOAD 环境变量，在程序启动时更换最适合的 C 库内存池（Linux 中通过 LD_PRELOAD 修改动态库来更换内存池，参见示例代码）。

内存分配时间虽然不起眼，但时刻用最快的方法申请内存，正是高手与初学者的区别，相似算法的性能差距就体现在这些编码细节上，希望你能够重视它。

思考题

最后，留给你一个思考题。分配对象时，除了分配内存，还需要初始化对象的数据结构。内存池对于初始化对象有什么帮助吗？欢迎你在留言区与大家一起探讨。

感谢阅读，如果你觉得这篇文章对你有一些启发，也欢迎把它分享给你的朋友。

03 | 索引：如何用哈希表管理亿级对象？

你好，我是陶辉。

上一讲我们谈到，Ptmalloc2 为子线程预分配了 64MB 内存池，虽然增大了内存消耗，但却加快了分配速度，这就是以空间换时间的思想。

在内存有限的单片机上运行嵌入式程序时，我们会压缩数据的空间占用，以时间换空间；但在面向海量用户的分布式服务中，使用更多的空间建立索引，换取更短的查询时间，则是我们管理大数据的常用手段。

比如现在需要管理数亿条数据，每条数据上有许多状态，有些请求在查询这些状态，有些请求则会根据业务规则有条件地更新状态，有些请求会新增数据，每条数据几十到几百字节。如果需要提供微秒级的访问速度，该怎么实现？（注意，以上非功能性约束并不苛刻，对于低 ARPU，即每用户平均收入低的应用，使用更少的资源实现同等功能非常重要。）

这种情况你会面对大量数据，显然，遍历全部数据去匹配查询关键字，会非常耗时。如果使用额外的空间为这些数据创建索引，就可以基于索引实现快速查找，这是常用的解决方案。比如，我们用标准库里提供的字典类容器存放对象，就是在数据前增加了索引，其本质就是以空间换时间。

当然，索引有很多，哈希表、红黑树、B 树都可以在内存中使用，如果我们需要数据规模上亿后还能提供微秒级的访问速度，那么作为最快的索引，哈希表是第一选择。

为什么选择哈希表？

为什么说哈希表是最快的索引呢？我们怎么定量评价索引快慢呢？

实地运行程序统计时间不是个好主意，因为它不只受数据特性、数据规模的影响，而且难以跨环境比较。巴菲特说过：“近似的正确好过精确的错误。”用近似的时间复杂度描述运行时间，好过实地运行得出的精确时间。

“时间复杂度”经过了详细的数学运算，它的运算过程我就不详细展开讲了。时间复杂度可以很好地反映运行时间随数据规模的变化趋势，就如下图中，横轴是数据规模，纵轴是运行时间，随着数据规模的增长，水平直线 1 不随之变化，也就是说，运行时间不变，是最好的曲线。用大 O 表示法描述时间复杂度，哈希表就是常量级的 O(1)，数据规模增长不影响它的运行时间，所以 Memcached、Redis 都在用哈希表管理数据。

图片来自英文wiki

为什么哈希表能做到 O(1) 时间复杂度呢？

首先，哈希表基于数组实现，而数组可以根据下标随机访问任意元素。数组之所以可以随机访问，是因为它由连续内存承载，且每个数组元素的大小都相等。于是，当我们知道下标后，把下标乘以元素大小，再加上数组的首地址，就可以获得目标访问地址，直接获取数据。

其次，哈希函数直接把查询关键字转换为数组下标，再通过数组的随机访问特性获取数据。比如，如果关键字是字符串，我们使用 BKDR 哈希算法将其转换为自然数，再以哈希数组大小为除数，对它进行求余，就得到了数组下标。如下图所示，字符串 abc 经过哈希函数的运算，得到了下标 39，于是数据就存放在数组的第 39 个元素上。（注意，这是个很糟糕的哈希函数，它使用的基数是 256，即 2 的 8 次方，下文我们会解释它为什么糟糕。）

这样，哈希函数的执行时间是常量，数组的随机访问也是常量，时间复杂度就是 O(1)。

实际上并非只有哈希表的时间复杂度是 O(1)，另一种索引“位图”，它的时间复杂度也是 O(1)。不过本质上，它是哈希表的变种，限制每个哈希桶只有 1 个比特位，所以，虽然它消耗的空间更少，但仅用于辅助数据的主索引，快速判断对象是否存在。

位图常用于解决缓存穿透的问题，也常用于查找数组中的可用对象，比如下图中通过批量判断位图数组的比特位（对 CPU 缓存也很友好），找到数据数组中的对应元素。

当然，logN 也是不错的曲线，随着数据规模的增长，运行时间的增长是急剧放缓的。红黑树的时间复杂度就是 O(logN)。如果需求中需要做范围查询、遍历，由于哈希表没办法找到关键字相邻的下一个元素，所以哈希表不支持这类操作，我们可以选择红黑树作为索引。采用二分法的红黑树，检索 1 万条数据需要做 14 次运算，1 亿条也只需要 27 次而已。

如果红黑树过大，内存中放不下时，可以改用 B 树，将部分索引存放在磁盘上。磁盘访问速度要比内存慢很多，但 B 树充分考虑了机械磁盘寻址慢、顺序读写快的特点，通过多分支降低了树高，减少了磁盘读写次数。

综合来看，不考虑范围查询与遍历操作，在追求最快速度的条件下，哈希表是最好的选择。

然而，在生产环境用哈希表管理如此多的数据，必然面临以下问题：

首先，面对上亿条数据，为了保证可靠性，需要做灾备恢复，我们可以结合快照 +oplog 方式恢复数据，但内存中的哈希表如何快速地序列化为快照文件？

其次，简单的使用标准库提供的哈希表处理如此规模的数据，会导致内存消耗过大，因为每多使用一个 8 字节的指针（或者叫引用）都会被放大亿万倍，此时该如何实现更节约内存的个性化哈希表？

再次，哈希表频繁发生冲突时，速度会急剧降低，我们该通过哪些手段减少冲突概率？

接下来，我们就来看看，如何解决以上问题，用哈希表有效地管理亿级数据。

内存结构与序列化方案

事实上对于动态（元素是变化的）哈希表，我们无法避免哈希冲突。比如上例中，“abc”与“cba”这两个字符串哈希后都会落到下标 39 中，这就产生了冲突。有两种方法解决哈希冲突：

链接法，落到数组同一个位置中的多个数据，通过链表串在一起。使用哈希函数查找到这个位置后，再使用链表遍历的方式查找数据。Java 标准库中的哈希表就使用链接法解决冲突。

开放寻址法，插入时若发现对应的位置已经占用，或者查询时发现该位置上的数据与查询关键字不同，开放寻址法会按既定规则变换哈希函数（例如哈希函数设为 H(key,i)，顺序地把参数 i 加 1），计算出下一个数组下标，继续在哈希表中探查正确的位置。

我们该选择哪种方法呢？

由于生产级存放大量对象的哈希表是需要容灾的，比如每隔一天把哈希表数据定期备份到另一台服务器上。当服务器宕机而启动备用服务器时，首先可以用备份数据把哈希表恢复到 1 天前的状态，再通过操作日志 oplog 把 1 天内的数据载入哈希表，这样就可以最快速的恢复哈希表。所以，为了能够传输，首先必须把哈希表序列化。

链接法虽然实现简单，还允许存放元素个数大于数组的大小（也叫装载因子大于 1），但链接法序列化数据的代价很大，因为使用了指针后，内存是不连续的。

开放寻址法确保所有对象都在数组里，就可以把数组用到的这段连续内存原地映射到文件中（参考 Linux 中的 mmap，Java 等语言都有类似的封装），再通过备份文件的方式备份哈希表。虽然操作系统会自动同步内存中变更的数据至文件，但备份前还是需要主动刷新内存（参考 Linux 中的 msync，它可以按地址及长度来分段刷新，以减少 msync 的耗时），以确定备份数据的精确时间点。而新的进程启动时，可以通过映射磁盘中的文件到内存，快速重建哈希表提供服务。

如果能将数据完整的放进数组，那么开放寻址法已经解决了序列化问题，所以我们应该选择开放寻址法。

但是，有两个因素使得我们必须把数据放在哈希桶之外：

每条数据有上百字节；

哈希表中一定会有很多空桶（没有存放数据）。空桶的比例越高（装载因子越小），冲突概率也会越低，但如果每个空桶都占用上百字节，亿级规模会轻松把浪费的内存放大许多倍。

所以，我们要把数据从哈希表中分离出来，提升哈希表的灵活性（灵活调整装载因子）。此时，该如何序列化哈希表以外的数据呢？最快速的序列化方案，还是像开放寻址法的散列表一样，使用定长数组存放对象，通过原地映射文件的方式序列化数据。由于数据未必是定长的，所以又分为两种情况。

一、数据的长度是固定的。可以用另一个数组 D 存放数据，其中 D 的大小是待存放元素的最大数量，注意，D 可以远小于哈希数组的大小。如果哈希表是动态的，支持新建与删除元素操作，还需要把数组 D 中空闲的位置构建一个单链表，新建时从链表头取元素，删除时将元素归还至链表头部。

二、数据的长度并不固定。此时，可以采用有限个定长数组存放数据，用以空间换时间的思想，加快访问速度。如下图中，D1 数组存放长度小于 L1 的数据，D2 数组存放长度在 L1 和 L2 之间的数据，以此类推。而哈希表数组 H 中，每个桶用 i 位存放该数据在哪个数组中，用 j 位存放数组下标。查找数据时，前 i 位寻找数组，后 j 位作为数组下标直接访问数据。

在这两种情况里，哈希桶不需要存放 8 字节 64 位的地址。因为，或许数组 D 的大小不到 1 亿，也就是说，你最多只需要寻址 1 亿条数据，这样 30 位足够使用。要知道，减少哈希桶的尺寸，就意味着同等内存下可以扩大哈希数组，从而降低装载因子。

降低哈希表的冲突概率

虽然哈希冲突有解决方案，但若是所有元素都发生了冲突，哈希表的时间复杂度就退化成了 O(N)，即每查找一次都要遍历所有数据。所以，为了获得与数据规模无关的常量级时间，我们必须减少冲突的概率，而减少冲突概率有两个办法，第一个办法是调优哈希函数，第二个办法就是扩容。

我们先看调优哈希函数。什么是好的哈希函数呢？首先它的计算量不能大，其次应尽量降低冲突概率。回到开头的那个哈希函数：

这个哈希函数使得“abc”和“cba”两个关键字都落在了下标 39 上，造成了哈希冲突，是因为它丢失了字母的位置信息。BKDR 是优秀的哈希算法，但它不能以 28作为基数，这会导致字符串分布不均匀。事实上，我们应当找一个合适的素数作为基数，比如 31，Java 标准库的 BKDR 哈希算法就以它为基数，它的计算量也很小：n*31 可以通过先把 n 左移 5 位，再减去 n 的方式替换（n*31 == n<<5 - n）。

一次位移加一次减法，要比一次乘法快得多。当然，图中的哈希函数之所以会丢失位置信息，是因为以 28作为基数的同时，又把 28-1 作为除数所致，数学较好的同学可以试着推导证明，这里只需要记住，基数必须是素数就可以了。

当哈希函数把高信息量的关键字压缩成更小的数组下标时，一定会丢失信息。我们希望只丢失一些无关紧要的信息，尽量多地保留区分度高的信息。这需要分析关键字的特点、分布规律。比如，对于 11 位手机号，前 3 位接入号区分度最差，中间 4 位表示地域的数字信息量有所增强，最后 4 位个人号信息量最高。如果哈希桶只有 1 万个，那么通过 phonenum%10000，最大化保留后 4 位信息就是个不错的选择。

再比如，QQ 号似乎不像手机号的数字分布那么有特点，然而，如果静态的统计存量 QQ 号，就会发现最后 1 位为 0 的号码特别多（数字更讨人欢喜），区分度很低。这样，哈希函数应当主动降低最后 1 位的信息量，减少它对哈希表位置的影响。比如，QQ 号 %100 就放大了最后 1 位的信息，增大了哈希冲突，而用 QQ 号 %101（101 是素数，效果更好）作为哈希函数，就降低了最后 1 位的影响。

接下来我们看看减少哈希冲突概率的第二个办法，扩容。装载因子越接近于 1，冲突概率就会越大。我们不能改变元素的数量，只能通过扩容提升哈希桶的数量，减少冲突。

由于哈希函数必须确保计算出的下标落在数组范围中，而扩容会增加数组的大小，进而影响哈希函数，因此，扩容前存放在哈希表中的所有元素，它们在扩容后的数组中位置都发生了变化。所以，扩容需要新老哈希表同时存在，通过遍历全部数据，用新的哈希函数把关键字放到合适的新哈希桶中。可见，扩容是一个极其耗时的操作，尤其在元素以亿计的情况下。

那么，在耗时以小时计的扩容过程中，如何持续提供正常服务呢？其实，只要把一次性的迁移过程，分为多次后台迁移，且提供服务时能够根据迁移情况选择新老哈希表即可。如果单机内存可以存放下新老两张哈希表，那么动态扩容不需要跨主机。反之，扩容过程将涉及新老哈希表所在的两台服务器，实现更为复杂，但原理是相同的。

小结

今天我们介绍了如何用哈希表管理上亿条数据。为什么选择哈希表？因为哈希表的运行时间不随着业务规模增长而变化。位图本质上是哈希表的变种，不过它常用于配合主索引，快速判断数据的状态。因为哈希表本身没办法找到关键字相邻的下一个元素，所以哈希表不支持范围查询与遍历。如果业务需要支持范围查询时，我们需要考虑红黑树、B 树等索引，它们其实并不慢。当索引太大，必须将一部分从内存中移到硬盘时，B 树就是一个很好的选择。

使用哈希表，你要注意几个关键问题。

生产环境一定要考虑容灾，而把哈希表原地序列化为文件是一个解决方案，它能保证新进程快速恢复哈希表。解决哈希冲突有链接法和开放寻址法，而后者更擅长序列化数据，因此成为我们的首选 。

亿级数据下，我们必须注重内存的节约使用。数亿条数据会放大节约下的点滴内存，再把它们用于提升哈希数组的大小，就可以通过降低装载因子来减少哈希冲突，提升速度。

优化哈希函数也是降低哈希冲突的重要手段，我们需要研究关键字的特征与分布，设计出快速、使关键字均匀分布的哈希函数。在课程的第四部分，集群的负载均衡也用到了哈希函数及其设计思想，只不过，哈希桶从一段内存变成了一台服务器。

再延伸说一点，哈希表、红黑树等这些索引都使用了以空间换时间的思想。判断它们的时间消耗，我们都需要依赖时间复杂度这个工具。当然，索引在某些场景下也会降低性能。例如添加、删除元素时，更新索引消耗的时间就是新增的。但相对于整体的收益，这些消耗是微不足道的。

思考题

最后留给大家一个思考题，你用过哪些其他类型的索引？基于怎样的应用场景和约束，才选择使用这些索引的？欢迎你在留言区与大家一起探讨。

感谢阅读，如果你觉得这篇文章对你有一些启发，也欢迎把它分享给你的朋友。

04 | 零拷贝：如何高效地传输文件？

你好，我是陶辉。

上一讲我们谈到，当索引的大小超过内存时，就会用磁盘存放索引。磁盘的读写速度远慢于内存，所以才针对磁盘设计了减少读写次数的 B 树索引。

磁盘是主机中最慢的硬件之一，常常是性能瓶颈，所以优化它能获得立竿见影的效果。

因此，针对磁盘的优化技术层出不穷，比如零拷贝、直接 IO、异步 IO 等等。这些优化技术为了降低操作时延、提升系统的吞吐量，围绕着内核中的磁盘高速缓存（也叫 PageCache），去减少 CPU 和磁盘设备的工作量。

这些磁盘优化技术和策略虽然很有效，但是理解它们并不容易。只有搞懂内核操作磁盘的流程，灵活正确地使用，才能有效地优化磁盘性能。

这一讲，我们就通过解决“如何高效地传输文件”这个问题，来分析下磁盘是如何工作的，并且通过优化传输文件的性能，带你学习现在热门的零拷贝、异步 IO 与直接 IO 这些磁盘优化技术。

你会如何实现文件传输？

服务器提供文件传输功能，需要将磁盘上的文件读取出来，通过网络协议发送到客户端。如果需要你自己编码实现这个文件传输功能，你会怎么实现呢？

通常，你会选择最直接的方法：从网络请求中找出文件在磁盘中的路径后，如果这个文件比较大，假设有 320MB，可以在内存中分配 32KB 的缓冲区，再把文件分成一万份，每份只有 32KB，这样，从文件的起始位置读入 32KB 到缓冲区，再通过网络 API 把这 32KB 发送到客户端。接着重复一万次，直到把完整的文件都发送完毕。如下图所示：

不过这个方案性能并不好，主要有两个原因。

首先，它至少经历了 4 万次用户态与内核态的上下文切换。因为每处理 32KB 的消息，就需要一次 read 调用和一次 write 调用，每次系统调用都得先从用户态切换到内核态，等内核完成任务后，再从内核态切换回用户态。可见，每处理 32KB，就有 4 次上下文切换，重复 1 万次后就有 4 万次切换。

上下文切换的成本并不小，虽然一次切换仅消耗几十纳秒到几微秒，但高并发服务会放大这类时间的消耗。

其次，这个方案做了 4 万次内存拷贝，对 320MB 文件拷贝的字节数也翻了 4 倍，到了 1280MB。很显然，过多的内存拷贝无谓地消耗了 CPU 资源，降低了系统的并发处理能力。

所以要想提升传输文件的性能，需要从降低上下文切换的频率和内存拷贝次数两个方向入手。

零拷贝如何提升文件传输性能？

首先，我们来看如何降低上下文切换的频率。

为什么读取磁盘文件时，一定要做上下文切换呢？这是因为，读取磁盘或者操作网卡都由操作系统内核完成。内核负责管理系统上的所有进程，它的权限最高，工作环境与用户进程完全不同。只要我们的代码执行 read 或者 write 这样的系统调用，一定会发生 2 次上下文切换：首先从用户态切换到内核态，当内核执行完任务后，再切换回用户态交由进程代码执行。

因此，如果想减少上下文切换次数，就一定要减少系统调用的次数。解决方案就是把 read、write 两次系统调用合并成一次，在内核中完成磁盘与网卡的数据交换。

其次，我们应该考虑如何减少内存拷贝次数。

每周期中的 4 次内存拷贝，其中与物理设备相关的 2 次拷贝是必不可少的，包括：把磁盘内容拷贝到内存，以及把内存拷贝到网卡。但另外 2 次与用户缓冲区相关的拷贝动作都不是必需的，因为在把磁盘文件发到网络的场景中，用户缓冲区没有必须存在的理由。

如果内核在读取文件后，直接把 PageCache 中的内容拷贝到 Socket 缓冲区，待到网卡发送完毕后，再通知进程，这样就只有 2 次上下文切换，和 3 次内存拷贝。

如果网卡支持 SG-DMA（The Scatter-Gather Direct Memory Access）技术，还可以再去除 Socket 缓冲区的拷贝，这样一共只有 2 次内存拷贝。

实际上，这就是零拷贝技术。

它是操作系统提供的新函数，同时接收文件描述符和 TCP socket 作为输入参数，这样执行时就可以完全在内核态完成内存拷贝，既减少了内存拷贝次数，也降低了上下文切换次数。

而且，零拷贝取消了用户缓冲区后，不只降低了用户内存的消耗，还通过最大化利用 socket 缓冲区中的内存，间接地再一次减少了系统调用的次数，从而带来了大幅减少上下文切换次数的机会！

你可以回忆下，没用零拷贝时，为了传输 320MB 的文件，在用户缓冲区分配了 32KB 的内存，把文件分成 1 万份传送，然而，这 32KB 是怎么来的？为什么不是 32MB 或者 32 字节呢？这是因为，在没有零拷贝的情况下，我们希望内存的利用率最高。如果用户缓冲区过大，它就无法一次性把消息全拷贝给 socket 缓冲区；如果用户缓冲区过小，则会导致过多的 read/write 系统调用。

那用户缓冲区为什么不与 socket 缓冲区大小一致呢？这是因为，socket 缓冲区的可用空间是动态变化的，它既用于 TCP 滑动窗口，也用于应用缓冲区，还受到整个系统内存的影响（我在《Web 协议详解与抓包实战》第 5 部分课程对此有详细介绍，这里不再赘述）。尤其在长肥网络中，它的变化范围特别大。

零拷贝使我们不必关心 socket 缓冲区的大小。比如，调用零拷贝发送方法时，尽可以把发送字节数设为文件的所有未发送字节数，例如 320MB，也许此时 socket 缓冲区大小为 1.4MB，那么一次性就会发送 1.4MB 到客户端，而不是只有 32KB。这意味着对于 1.4MB 的 1 次零拷贝，仅带来 2 次上下文切换，而不使用零拷贝且用户缓冲区为 32KB 时，经历了 176 次（4 * 1.4MB/32KB）上下文切换。

综合上述各种优点，零拷贝可以把性能提升至少一倍以上！对文章开头提到的 320MB 文件的传输，当 socket 缓冲区在 1.4MB 左右时，只需要 4 百多次上下文切换，以及 4 百多次内存拷贝，拷贝的数据量也仅有 640MB，这样，不只请求时延会降低，处理每个请求消耗的 CPU 资源也会更少，从而支持更多的并发请求。

此外，零拷贝还使用了 PageCache 技术，通过它，零拷贝可以进一步提升性能，我们接下来看看 PageCache 是如何做到这一点的。

PageCache，磁盘高速缓存

回顾上文中的几张图，你会发现，读取文件时，是先把磁盘文件拷贝到 PageCache 上，再拷贝到进程中。为什么这样做呢？有两个原因所致。

第一，由于磁盘比内存的速度慢许多，所以我们应该想办法把读写磁盘替换成读写内存，比如把磁盘中的数据复制到内存中，就可以用读内存替换读磁盘。但是，内存空间远比磁盘要小，内存中注定只能复制一小部分磁盘中的数据。

选择哪些数据复制到内存呢？通常，刚被访问的数据在短时间内再次被访问的概率很高（这也叫“时间局部性”原理），用 PageCache 缓存最近访问的数据，当空间不足时淘汰最久未被访问的缓存（即 LRU 算法）。读磁盘时优先到 PageCache 中找一找，如果数据存在便直接返回，这便大大提升了读磁盘的性能。

第二，读取磁盘数据时，需要先找到数据所在的位置，对于机械磁盘来说，就是旋转磁头到数据所在的扇区，再开始顺序读取数据。其中，旋转磁头耗时很长，为了降低它的影响，PageCache 使用了预读功能。

也就是说，虽然 read 方法只读取了 0-32KB 的字节，但内核会把其后的 32-64KB 也读取到 PageCache，这后 32KB 读取的成本很低。如果在 32-64KB 淘汰出 PageCache 前，进程读取到它了，收益就非常大。这一讲的传输文件场景中这是必然发生的。

从这两点可以看到 PageCache 的优点，它在 90% 以上场景下都会提升磁盘性能，但在某些情况下，PageCache 会不起作用，甚至由于多做了一次内存拷贝，造成性能的降低。在这些场景中，使用了 PageCache 的零拷贝也会损失性能。

具体是什么场景呢？就是在传输大文件的时候。比如，你有很多 GB 级的文件需要传输，每当用户访问这些大文件时，内核就会把它们载入到 PageCache 中，这些大文件很快会把有限的 PageCache 占满。

然而，由于文件太大，文件中某一部分内容被再次访问到的概率其实非常低。这带来了 2 个问题：首先，由于 PageCache 长期被大文件占据，热点小文件就无法充分使用 PageCache，它们读起来变慢了；其次，PageCache 中的大文件没有享受到缓存的好处，但却耗费 CPU 多拷贝到 PageCache 一次。

所以，高并发场景下，为了防止 PageCache 被大文件占满后不再对小文件产生作用，大文件不应使用 PageCache，进而也不应使用零拷贝技术处理。

异步 IO + 直接 IO

高并发场景处理大文件时，应当使用异步 IO 和直接 IO 来替换零拷贝技术。

仍然回到本讲开头的例子，当调用 read 方法读取文件时，实际上 read 方法会在磁盘寻址过程中阻塞等待，导致进程无法并发地处理其他任务，如下图所示：

异步 IO（异步 IO 既可以处理网络 IO，也可以处理磁盘 IO，这里我们只关注磁盘 IO）可以解决阻塞问题。它把读操作分为两部分，前半部分向内核发起读请求，但不等待数据就位就立刻返回，此时进程可以并发地处理其他任务。当内核将磁盘中的数据拷贝到进程缓冲区后，进程将接收到内核的通知，再去处理数据，这是异步 IO 的后半部分。如下图所示：

从图中可以看到，异步 IO 并没有拷贝到 PageCache 中，这其实是异步 IO 实现上的缺陷。经过 PageCache 的 IO 我们称为缓存 IO，它与虚拟内存系统耦合太紧，导致异步 IO 从诞生起到现在都不支持缓存 IO。

绕过 PageCache 的 IO 是个新物种，我们把它称为直接 IO。对于磁盘，异步 IO 只支持直接 IO。

直接 IO 的应用场景并不多，主要有两种：第一，应用程序已经实现了磁盘文件的缓存，不需要 PageCache 再次缓存，引发额外的性能消耗。比如 MySQL 等数据库就使用直接 IO；第二，高并发下传输大文件，我们上文提到过，大文件难以命中 PageCache 缓存，又带来额外的内存拷贝，同时还挤占了小文件使用 PageCache 时需要的内存，因此，这时应该使用直接 IO。

当然，直接 IO 也有一定的缺点。除了缓存外，内核（IO 调度算法）会试图缓存尽量多的连续 IO 在 PageCache 中，最后合并成一个更大的 IO 再发给磁盘，这样可以减少磁盘的寻址操作；另外，内核也会预读后续的 IO 放在 PageCache 中，减少磁盘操作。直接 IO 绕过了 PageCache，所以无法享受这些性能提升。

有了直接 IO 后，异步 IO 就可以无阻塞地读取文件了。现在，大文件由异步 IO 和直接 IO 处理，小文件则交由零拷贝处理，至于判断文件大小的阈值可以灵活配置（参见 Nginx 的 directio 指令）。

小结

基于用户缓冲区传输文件时，过多的内存拷贝与上下文切换次数会降低性能。零拷贝技术在内核中完成内存拷贝，天然降低了内存拷贝次数。它通过一次系统调用合并了磁盘读取与网络发送两个操作，降低了上下文切换次数。尤其是，由于拷贝在内核中完成，它可以最大化使用 socket 缓冲区的可用空间，从而提高了一次系统调用中处理的数据量，进一步降低了上下文切换次数。

零拷贝技术基于 PageCache，而 PageCache 缓存了最近访问过的数据，提升了访问缓存数据的性能，同时，为了解决机械磁盘寻址慢的问题，它还协助 IO 调度算法实现了 IO 合并与预读（这也是顺序读比随机读性能好的原因），这进一步提升了零拷贝的性能。几乎所有操作系统都支持零拷贝，如果应用场景就是把文件发送到网络中，那么我们应当选择使用了零拷贝的解决方案。

不过，零拷贝有一个缺点，就是不允许进程对文件内容作一些加工再发送，比如数据压缩后再发送。另外，当 PageCache 引发负作用时，也不能使用零拷贝，此时可以用异步 IO+ 直接 IO 替换。我们通常会设定一个文件大小阈值，针对大文件使用异步 IO 和直接 IO，而对小文件使用零拷贝。

事实上 PageCache 对写操作也有很大的性能提升，因为 write 方法在写入内存中的 PageCache 后就会返回，速度非常快，由内核负责异步地把 PageCache 刷新到磁盘中，这里不再展开。

这一讲我们从零拷贝出发，看到了文件传输场景中内核在幕后所做的工作。这里面的性能优化技术，要么减少了磁盘的工作量（比如 PageCache 缓存），要么减少了 CPU 的工作量（比如直接 IO），要么提高了内存的利用率（比如零拷贝）。你在学习其他磁盘 IO 优化技术时，可以延着这三个优化方向前进，看看究竟如何降低时延、提高并发能力。

思考题

最后，留给你一个思考题，异步 IO 一定不会阻塞进程吗？如果阻塞了进程，该如何解决呢？欢迎你在留言区与大家一起探讨。

感谢阅读，如果你觉得这节课对你有一些启发，也欢迎把它分享给你的朋友。

05 | 协程：如何快速地实现高并发服务？

你好，我是陶辉。

上一讲谈到，零拷贝通过减少上下文切换次数，提升了文件传输的性能。事实上高并发服务也是通过降低切换成本实现的，这一讲我们来看看它是如何做到的。

如果你需要访问多个服务来完成一个请求的处理，比如实现文件上传功能时，首先访问 Redis 缓存，验证用户是否登陆，再接收 HTTP 消息中的 body 并保存在磁盘上，最后把文件路径等信息写入 MySQL 数据库中，你会怎么做？

用阻塞 API 写同步代码最简单，但一个线程同一时间只能处理一个请求，有限的线程数导致无法实现万级别的并发连接，过多的线程切换也抢走了 CPU 的时间，从而降低了每秒能够处理的请求数量。

为了达到高并发，你可能会选择一个异步框架，用非阻塞 API 把业务逻辑打乱到多个回调函数，通过多路复用实现高并发，然而，由于业务代码过度关注并发细节，需要维护很多中间状态，不但 Bug 率会很高，项目的开发速度也上不去，产品及时上线存在风险。

如果想兼顾开发效率，又能保证高并发，协程就是最好的选择。它可以在保持异步化运行机制的同时，用同步方式写代码，这在实现高并发的同时，缩短了开发周期，是高性能服务未来的发展方向。

你会发现，解决高并发问题的技术一直在变化，从多进程、多线程，到异步化、协程，面对不同的场景，它们都在用各自不同的方式解决问题。我们就来看看，高并发的解决方案是怎么演进的，协程到底解决了什么问题，它又该如何应用。

如何通过切换请求实现高并发？

我们知道，主机上资源有限，一颗 CPU、一块磁盘、一张网卡，如何同时服务上百个请求呢？多进程模式是最初的解决方案。内核把 CPU 的执行时间切分成许多时间片（timeslice），比如 1 秒钟可以切分为 100 个 10 毫秒的时间片，每个时间片再分发给不同的进程，通常，每个进程需要多个时间片才能完成一个请求。

这样，虽然微观上，比如说就这 10 毫秒时间 CPU 只能执行一个进程，但宏观上 1 秒钟执行了 100 个时间片，于是每个时间片所属进程中的请求也得到了执行，这就实现了请求的并发执行。

不过，每个进程的内存空间都是独立的，这样用多进程实现并发就有两个缺点：一是内核的管理成本高，二是无法简单地通过内存同步数据，很不方便。于是，多线程模式就出现了，多线程模式通过共享内存地址空间，解决了这两个问题。

然而，共享地址空间虽然可以方便地共享对象，但这也导致一个问题，那就是任何一个线程出错时，进程中的所有线程会跟着一起崩溃。这也是如 Nginx 等强调稳定性的服务坚持使用多进程模式的原因。

事实上，无论基于多进程还是多线程，都难以实现高并发，这由两个原因所致。

首先，单个线程消耗的内存过多，比如，64 位的 Linux 为每个线程的栈分配了 8MB 的内存，还预分配了 64MB 的内存作为堆内存池（你可以从[第 2 讲] 中找到 Linux 系统为什么这么做）。所以，我们没有足够的内存去开启几万个线程实现并发。

其次，切换请求是内核通过切换线程实现的，什么时候会切换线程呢？不只时间片用尽，当调用阻塞方法时，内核为了让 CPU 充分工作，也会切换到其他线程执行。一次上下文切换的成本在几十纳秒到几微秒间，当线程繁忙且数量众多时，这些切换会消耗绝大部分的 CPU 运算能力。

下图以上一讲介绍过的磁盘 IO 为例，描述了多线程中使用阻塞方法读磁盘，2 个线程间的切换方式。

那么，怎么才能实现高并发呢？把上图中本来由内核实现的请求切换工作，交由用户态的代码来完成就可以了，异步化编程通过应用层代码实现了请求切换，降低了切换成本和内存占用空间。异步化依赖于 IO 多路复用机制，比如 Linux 的 epoll 或者 Windows 上的 iocp，同时，必须把阻塞方法更改为非阻塞方法，才能避免内核切换带来的巨大消耗。Nginx、Redis 等高性能服务都依赖异步化实现了百万量级的并发。

下图描述了异步 IO 的非阻塞读和异步框架结合后，是如何切换请求的。

然而，写异步化代码很容易出错。因为所有阻塞函数，都需要通过非阻塞的系统调用拆分成两个函数。虽然这两个函数共同完成一个功能，但调用方式却不同。第一个函数由你显式调用，第二个函数则由多路复用机制调用。这种方式违反了软件工程的内聚性原则，函数间同步数据也更复杂。特别是条件分支众多、涉及大量系统调用时，异步化的改造工作会非常困难。

有没有办法既享受到异步化带来的高并发，又可以使用阻塞函数写同步化代码呢？

协程可以做到，它在异步化之上包了一层外衣，兼顾了开发效率与运行效率。

协程是如何实现高并发的？

协程与异步编程相似的地方在于，它们必须使用非阻塞的系统调用与内核交互，把切换请求的权力牢牢掌握在用户态的代码中。但不同的地方在于，协程把异步化中的两段函数，封装为一个阻塞的协程函数。这个函数执行时，会使调用它的协程无感知地放弃执行权，由协程框架切换到其他就绪的协程继续执行。当这个函数的结果满足后，协程框架再选择合适的时机，切换回它所在的协程继续执行。如下图所示：

看起来非常棒，然而，异步化是通过回调函数来完成请求切换的，业务逻辑与并发实现关联在一起，很容易出错。协程不需要什么“回调函数”，它允许用户调用“阻塞的”协程方法，用同步编程方式写业务逻辑。

那协程的切换是如何完成的呢？

实际上，用户态的代码切换协程，与内核切换线程的原理是一样的。内核通过管理 CPU 的寄存器来切换线程，我们以最重要的栈寄存器和指令寄存器为例，看看协程切换时如何切换程序指令与内存。

每个线程有独立的栈，而栈既保留了变量的值，也保留了函数的调用关系、参数和返回值，CPU 中的栈寄存器 SP 指向了当前线程的栈，而指令寄存器 IP 保存着下一条要执行的指令地址。因此，从线程 1 切换到线程 2 时，首先要把 SP、IP 寄存器的值为线程 1 保存下来，再从内存中找出线程 2 上一次切换前保存好的寄存器值，写入 CPU 的寄存器，这样就完成了线程切换。（其他寄存器也需要管理、替换，原理与此相同，不再赘述。）

协程的切换与此相同，只是把内核的工作转移到协程框架实现而已，下图是协程切换前的状态：

从协程 1 切换到协程 2 后的状态如下图所示：

创建协程时，会从进程的堆中（参见[第 2 讲]）分配一段内存作为协程的栈。线程的栈有 8MB，而协程栈的大小通常只有几十 KB。而且，C 库内存池也不会为协程预分配内存，它感知不到协程的存在。这样，更低的内存占用空间为高并发提供了保证，毕竟十万并发请求，就意味着 10 万个协程。当然，栈缩小后，就尽量不要使用递归函数，也不能在栈中申请过多的内存，这是实现高并发必须付出的代价。

由此可见，协程就是用户态的线程。然而，为了保证所有切换都在用户态进行，协程必须重新封装所有的阻塞系统调用，否则，一旦协程触发了线程切换，会导致这个线程进入休眠状态，进而其上的所有协程都得不到执行。比如，普通的 sleep 函数会让当前线程休眠，由内核来唤醒线程，而协程化改造后，sleep 只会让当前协程休眠，由协程框架在指定时间后唤醒协程。再比如，线程间的互斥锁是使用信号量实现的，而信号量也会导致线程休眠，协程化改造互斥锁后，同样由框架来协调、同步各协程的执行。

所以，协程的高性能，建立在切换必须由用户态代码完成之上，这要求协程生态是完整的，要尽量覆盖常见的组件。比如 MySQL 官方提供的客户端 SDK，它使用了阻塞 socket 做网络访问，会导致线程休眠，必须用非阻塞 socket 把 SDK 改造为协程函数后，才能在协程中使用。

当然，并不是所有的函数都能用协程改造。比如[第 4 讲] 提到的异步 IO，它虽然是非阻塞的，但无法使用 PageCache，降低了系统吞吐量。如果使用缓存 IO 读文件，在没有命中 PageCache 时是可能发生阻塞的。

这种时候，如果对性能有更高的要求，就需要把线程与协程结合起来用，把可能阻塞的操作放在线程中执行，通过生产者 / 消费者模型与协程配合工作。

实际上，面对多核系统，也需要协程与线程配合工作。因为协程的载体是线程，而一个线程同一时间只能使用一颗 CPU，所以通过开启更多的线程，将所有协程分布在这些线程中，就能充分使用 CPU 资源。

除此之外，为了让协程获得更多的 CPU 时间，还可以设置所在线程的优先级，比如 Linux 下把线程的优先级设置到 -20，就可以每次获得更长的时间片。另外，[第 1 讲] 曾谈到 CPU 缓存对程序性能的影响，为了减少 CPU 缓存失效的比例，还可以把线程绑定到某个 CPU 上，增加协程执行时命中 CPU 缓存的机率。

虽然这一讲中谈到协程框架在调度协程，然而，你会发现，很多协程库只提供了创建、挂起、恢复执行等基本方法，并没有协程框架的存在，需要业务代码自行调度协程。这是因为，这些通用的协程库并不是专为服务器设计的。服务器中可以由客户端网络连接的建立，驱动着创建出协程，同时伴随着请求的结束而终止。在协程的运行条件不满足时，多路复用框架会将它挂起，并根据优先级策略选择另一个协程执行。

因此，使用协程实现服务器端的高并发服务时，并不只是选择协程库，还要从其生态中找到结合 IO 多路复用的协程框架，这样可以加快开发速度。

小结

这一讲，我们从高并发的应用场景入手，分析了协程出现的背景和实现原理，以及它的应用范围。你会发现，协程融合了多线程与异步化编程的优点，既保证了开发效率，也提升了运行效率。

有限的硬件资源下，多线程通过微观上时间片的切换，实现了同时服务上百个用户的能力。多线程的开发成本虽然低，但内存消耗大，切换次数过多，无法实现高并发。

异步编程方式通过非阻塞系统调用和多路复用，把原本属于内核的请求切换能力，放在用户态的代码中执行。这样，不仅减少了每个请求的内存消耗，也降低了切换请求的成本，最终实现了高并发。然而，异步编程违反了代码的内聚性，还需要业务代码关注并发细节，开发成本很高。

协程参考内核通过 CPU 寄存器切换线程的方法，在用户态代码中实现了协程的切换，既降低了切换请求的成本，也使得协程中的业务代码不用关注自己何时被挂起，何时被执行。相比异步编程中要维护一堆数据结构表示中间状态，协程直接用代码表示状态，大大提升了开发效率。

在协程中调用的所有 API，都需要做非阻塞的协程化改造。优秀的协程生态下，常用服务都有对应的协程 SDK，方便业务代码使用。开发高并发服务时，与 IO 多路复用结合的协程框架可以与这些 SDK 配合，自动挂起、切换协程，进一步提升开发效率。

协程并不是完全与线程无关，首先线程可以帮助协程充分使用多核 CPU 的计算力，其次，遇到无法协程化、会导致内核切换的阻塞函数，或者计算太密集从而长时间占用 CPU 的任务，还是要放在独立的线程中执行，以防止它影响所有协程的执行。

思考题

最后，留给你一个思考题，你用过协程吗？觉得它还有什么优点？如果没有在生产环境中使用协程，原因是什么？欢迎你在留言区与我一起探讨。

感谢阅读，如果你觉得这节课有所收获，也欢迎把它分享给你的朋友。

06 | 锁：如何根据业务场景选择合适的锁？

你好，我是陶辉。

上一讲我们谈到了实现高并发的不同方案，这一讲我们来谈谈如何根据业务场景选择合适的锁。

我们知道，多线程下为了确保数据不会出错，必须加锁后才能访问共享资源。我们最常用的是互斥锁，然而，还有很多种不同的锁，比如自旋锁、读写锁等等，它们分别适用于不同的场景。

比如高并发场景下，要求每个函数的执行时间必须都足够得短，这样所有请求才能及时得到响应，如果你选择了错误的锁，数万请求同时争抢下，很容易导致大量请求长期取不到锁而处理超时，系统吞吐量始终维持在很低的水平，用户体验非常差，最终“高并发”成了一句空谈。

怎样选择最合适的锁呢？首先我们必须清楚加锁的成本究竟有多大，其次我们要分析业务场景中访问共享资源的方式，最后则要预估并发访问时发生锁冲突的概率。这样，我们才能选对锁，同时实现高并发和高吞吐量这两个目标。

今天，我们就针对不同的应用场景，了解下锁的选择和使用，从而减少锁对高并发性能的影响。

互斥锁与自旋锁：休眠还是“忙等待”？

我们常见的各种锁是有层级的，最底层的两种锁就是互斥锁和自旋锁，其他锁都是基于它们实现的。互斥锁的加锁成本更高，但它在加锁失败时会释放 CPU 给其他线程；自旋锁则刚好相反。

当你无法判断锁住的代码会执行多久时，应该首选互斥锁，互斥锁是一种独占锁。什么意思呢？当 A 线程取到锁后，互斥锁将被 A 线程独自占有，当 A 没有释放这把锁时，其他线程的取锁代码都会被阻塞。

阻塞是怎样进行的呢？对于 99% 的线程级互斥锁而言，阻塞都是由操作系统内核实现的（比如 Linux 下它通常由内核提供的信号量实现）。当获取锁失败时，内核会将线程置为休眠状态，等到锁被释放后，内核会在合适的时机唤醒线程，而这个线程成功拿到锁后才能继续执行。如下图所示：

互斥锁通过内核帮忙切换线程，简化了业务代码使用锁的难度。

但是，线程获取锁失败时，增加了两次上下文切换的成本：从运行中切换为休眠，以及锁释放时从休眠状态切换为运行中。上下文切换耗时在几十纳秒到几微秒之间，或许这段时间比锁住的代码段执行时间还长。而且，线程主动进入休眠是高并发服务无法容忍的行为，这让其他异步请求都无法执行。

如果你能确定被锁住的代码执行时间很短，就应该用自旋锁取代互斥锁。

自旋锁比互斥锁快得多，因为它通过 CPU 提供的 CAS 函数（全称 Compare And Swap），在用户态代码中完成加锁与解锁操作。

我们知道，加锁流程包括 2 个步骤：第 1 步查看锁的状态，如果锁是空闲的，第 2 步将锁设置为当前线程持有。

在没有 CAS 操作前，多个线程同时执行这 2 个步骤是会出错的。比如线程 A 执行第 1 步发现锁是空闲的，但它在执行第 2 步前，线程 B 也执行了第 1 步，B 也发现锁是空闲的，于是线程 A、B 会同时认为它们获得了锁。

CAS 函数把这 2 个步骤合并为一条硬件级指令。这样，第 1 步比较锁状态和第 2 步锁变量赋值，将变为不可分割的原子指令。于是，设锁为变量 lock，整数 0 表示锁是空闲状态，整数 pid 表示线程 ID，那么 CAS(lock, 0, pid) 就表示自旋锁的加锁操作，CAS(lock, pid, 0) 则表示解锁操作。

多线程竞争锁的时候，加锁失败的线程会“忙等待”，直到它拿到锁。什么叫“忙等待”呢？它并不意味着一直执行 CAS 函数，生产级的自旋锁在“忙等待”时，会与 CPU 紧密配合 ，它通过 CPU 提供的 PAUSE 指令，减少循环等待时的耗电量；对于单核 CPU，忙等待并没有意义，此时它会主动把线程休眠。

如果你对此感兴趣，可以阅读下面这段生产级的自旋锁，看看它是怎么执行“忙等待”的：

while (true) {
  //因为判断lock变量的值比CAS操作更快，所以先判断lock再调用CAS效率更高
  if (lock == 0 &&  CAS(lock, 0, pid) == 1) return;

  if (CPU_count > 1 ) { //如果是多核CPU，“忙等待”才有意义
      for (n = 1; n < 2048; n <<= 1) {//pause的时间，应当越来越长
        for (i = 0; i < n; i++) pause();//CPU专为自旋锁设计了pause指令
        if (lock == 0 && CAS(lock, 0, pid)) return;//pause后再尝试获取锁
      }
  }
  sched_yield();//单核CPU，或者长时间不能获取到锁，应主动休眠，让出CPU
}

在使用层面上，自旋锁与互斥锁很相似，实现层面上它们又完全不同。自旋锁开销少，在多核系统下一般不会主动产生线程切换，很适合异步、协程等在用户态切换请求的编程方式，有助于高并发服务充分利用多颗 CPU。但如果被锁住的代码执行时间过长，CPU 资源将被其他线程在“忙等待”中长时间占用。

当取不到锁时，互斥锁用“线程切换”来面对，自旋锁则用“忙等待”来面对。这是两种最基本的处理方式，更高级别的锁都会选择其中一种来实现，比如读写锁就既可以基于互斥锁实现，也可以基于自旋锁实现。

下面我们来看一看读写锁能带来怎样的性能提升。

允许并发持有的读写锁

如果你能够明确区分出读和写两种场景，可以选择读写锁。

读写锁由读锁和写锁两部分构成，仅读取共享资源的代码段用读锁来加锁，会修改资源的代码段则用写锁来加锁。

读写锁的优势在于，当写锁未被持有时，多个线程能够并发地持有读锁，这提高了共享资源的使用率。多个读锁被同时持有时，读线程并不会修改共享资源，所以它们的并发执行不会产生数据错误。

而一旦写锁被持有后，不只读线程必须阻塞在获取读锁的环节，其他获取写锁的写线程也要被阻塞。写锁就像互斥锁和自旋锁一样，是一种独占锁；而读锁允许并发持有，则是一种共享锁。

因此，读写锁真正发挥优势的场景，必然是读多写少的场景，否则读锁将很难并发持有。

实际上，读写锁既可以倾向于读线程，又可以倾向于写线程。前者我们称为读优先锁，后者称为写优先锁。

读优先锁更强调效率，它期待锁能被更多的线程持有。简单看下它的工作特点：当线程 A 先持有读锁后，即使线程 B 在等待写锁，后续前来获取读锁的线程 C 仍然可以立刻加锁成功，因为这样就有 A、C 这 2 个读线程在并发持有锁，效率更高。

我们再来看写优先的读写锁。同样的情况下，线程 C 获取读锁会失败，它将被阻塞在获取锁的代码中，这样，只要线程 A 释放读锁后，线程 B 马上就可以获取到写锁。如下图所示：

读优先锁并发性更好，但问题也很明显。如果读线程源源不断地获取读锁，写线程将永远获取不到写锁。写优先锁可以保证写线程不会饿死，但如果新的写线程源源不断地到来，读线程也可能被饿死。

那么，能否兼顾二者，避免读、写线程饿死呢？

用队列把请求锁的线程排队，按照先来后到的顺序加锁即可，当然读线程仍然可以并发，只不过不能插队到写线程之前。Java 中的 ReentrantReadWriteLock 读写锁，就支持这种排队的公平读写锁。

如果不希望取锁时线程主动休眠，还可以用自旋锁实现读写锁。到底应该选择“线程切换”还是“忙等待”方式实现读写锁呢？除去读写场景外，这与选择互斥锁和自旋锁的方法相同，就是根据加锁代码执行时间的长短来选择，这里就不再赘述了。

乐观锁：不使用锁也能同步

事实上，无论互斥锁、自旋锁还是读写锁，都属于悲观锁。

什么叫悲观锁呢？它认为同时修改资源的概率很高，很容易出现冲突，所以访问共享资源前，先加上锁，总体效率会更优。然而，如果并发产生冲突的概率很低，就不必使用悲观锁，而是使用乐观锁。

所谓“乐观”，就是假定冲突的概率很低，所以它采用的“加锁”方式是，先修改完共享资源，再验证这段时间内有没有发生冲突。如果没有其他线程在修改资源，那么操作完成。如果发现其他线程已经修改了这个资源，就放弃本次操作。

至于放弃后如何重试，则与业务场景相关，虽然重试的成本很高，但出现冲突的概率足够低的话，还是可以接受的。可见，乐观锁全程并没有加锁，所以它也叫无锁编程。

无锁编程中，验证是否发生了冲突是关键。该怎么验证呢？这与具体的场景有关。

比如说在线文档。Web 中的在线文档是怎么实现多人编辑的？用户 A 先在浏览器中编辑某个文档，之后用户 B 也打开了相同的页面开始编辑，可是，用户 B 最先编辑完成提交，这一过程用户 A 却不知道。当 A 提交他改完的内容时，A、B 之间的并行修改引发了冲突。

Web 服务是怎么解决这种冲突的呢？它并没有限制用户先拿到锁后才能编辑文档，这既因为冲突的概率非常低，也因为加解锁的代价很高。Web 中的方案是这样的：让用户先改着，但需要浏览器记录下修改前的文档版本号，这通过下载文档时，返回的 HTTP ETag 头部实现。

当用户提交修改时，浏览器在请求中通过 HTTP If-Match 头部携带原版本号，服务器将它与文档的当前版本号比较，一致后新的修改才能生效，否则提交失败。如下图所示（如果你想了解这一过程的细节，可以阅读 《Web 协议详解与抓包实战》第 28 课）：

乐观锁除了应用在 Web 分布式场景，在数据库等单机上也有广泛的应用。只是面向多线程时，最后的验证步骤是通过 CPU 提供的 CAS 操作完成的。

乐观锁虽然去除了锁操作，但是一旦发生冲突，重试的成本非常高。所以，只有在冲突概率非常低，且加锁成本较高时，才考虑使用乐观锁。

小结

这一讲我们介绍了高并发下同步资源时，如何根据应用场景选择合适的锁，来优化服务的性能。

互斥锁能够满足各类功能性要求，特别是被锁住的代码执行时间不可控时，它通过内核执行线程切换及时释放了资源，但它的性能消耗最大。需要注意的是，协程的互斥锁实现原理完全不同，它并不与内核打交道，虽然不能跨线程工作，但效率很高。（如果你希望进一步了解协程，可以阅读[第 5 讲]。）

如果能够确定被锁住的代码取到锁后很快就能释放，应该使用更高效的自旋锁，它特别适合基于异步编程实现的高并发服务。

如果能区分出读写操作，读写锁就是第一选择，它允许多个读线程同时持有读锁，提高了并发性。读写锁是有倾向性的，读优先锁很高效，但容易让写线程饿死，而写优先锁会优先服务写线程，但对读线程亲和性差一些。还有一种公平读写锁，它通过把等待锁的线程排队，以略微牺牲性能的方式，保证了某种线程不会饿死，通用性更佳。

另外，读写锁既可以使用互斥锁实现，也可以使用自旋锁实现，我们应根据场景来选择合适的实现。

当并发访问共享资源，冲突概率非常低的时候，可以选择无锁编程。它在 Web 和数据库中有广泛的应用。然而，一旦冲突概率上升，就不适合使用它，因为它解决冲突的重试成本非常高。

总之，不管使用哪种锁，锁范围内的代码都应尽量的少，执行速度要快。在此之上，选择更合适的锁能够大幅提升高并发服务的性能！

思考题

最后，留给你一道思考题，上一讲我们提到协程中也有各种锁，你觉得协程中可以用自旋锁或者互斥锁吗？如果不可以，那协程中的锁是怎么实现的？欢迎你在留言区与我探讨。

感谢阅读，如果你觉得这节课对你有一些启发，也欢迎把它分享给你的朋友。

07 | 性能好，效率高的一对多通讯该如何实现？

你好，我是陶辉。从这一讲开始，我们将从单机进入网络层面的性能优化。

我们接触过的绝大多数通讯方式，无论是面向连接的 HTTP 协议，还是无连接的 DNS 协议，都是一对一收发消息的。其实，除了一对一，还有一对多的通讯方式，它在网络资源的利用上效率要比一对一高得多。这种一对多的通讯方式，在局域网中有很广泛的应用，常见的 ARP 欺骗、泛洪攻击等，都是通过一对多通讯进行的。

当应用场景中用一对多代替一对一通讯时，发送方性能会获得很大的提升，整个局域网的效率也会提高。比如，源主机的带宽只有 1Gbps，如果采用一对一的方式向 100 个客户端发送流媒体，这 100 台主机的带宽之和不会超过 1Gbps。但采用一对多传输时，总带宽就可以达到 100Gbps。

除了能提升性能以外，由于一对多通讯可同时向所有主机发送消息，这就在功能层面上可以替换许多人工操作。比如分布式系统的服务发现，使用人工配置既容易出错，速度也慢，而用广播就可以轻松实现自动化服务发现。

一对多通讯协议一直在发展，在运营商的 IPTV 网络的视频直播中，它就得到了广泛的应用。即使你暂时不会用到一对多这种方式，也应当了解下它是怎么工作的，熟悉它的工作原理后，还能更深入地理解一对一通讯协议。

这一讲，我们就来学习如何实现一对多通讯。

广播是怎么实现的？

一对多通讯分为两种：对局域网内所有主机发送消息的叫做广播，而对部分主机发送消息的，则叫做组播。我们先来看一下广播是怎么实现的。

使用广播要改用 UDP 协议。可能你会问，为什么不能使用最熟悉的 TCP 协议呢？这要从 TCP 协议的分层说起。

1978 年在 TCP 协议迭代了 3 个版本后，才被 Jon Postel（IANA 创始人）提出违反了网络分层原则，网络层和传输层耦合在一起很难扩展。于是在 TCP 的第 4 个迭代版本中把协议一分为二，包括网络层 IP 协议和传输层 TCP 协议（这也是今天的 IP 协议被称为 IPv4 的原因）。

当你访问 Internet 站点时，IP 协议会将数据通过网络设备穿越多个卫星、光纤等网络，才能送到服务器。而网络设备天然就拥有广播能力，当它在一个网络端口上收到主机发来的报文时，可以向其他端口上的所有主机重发一遍，这就是广播，如下图所示：

虽然 IP 协议已经具有广播功能，但实际编程中并不会直接使用 IP 协议发送广播，因为它很难与进程关联起来。

根据网络分层模型，上层协议可以使用下层协议的功能，所以传输层协议拥有 IP 协议的广播能力。同时，传输层通过端口号把网络报文和进程关联在了一起，就像 TCP 的 80 端口，把 HTTP 消息与 Nginx 等 Web Server 关联在一起。

然而，传输层的 TCP 协议为了保证可靠性，建立了逻辑上的连接概念，由于一个连接上只能有两方，所以 TCP 无法进行一对多通讯。而传输层的 UDP 协议无需建立连接，所以我们常用 UDP 协议发送广播。

广播的性能高有两个原因：首先，交换机直接转发给接收方，要比从发送方到接收方的传输路径更短。其次，原本需要发送方复制多份报文再逐一发送至各个接受者的工作，被交换机完成了，这既分担了发送方的负载，也充分使用了整个网络的带宽。

那么，交换机收到消息后，怎么知道这是广播报文并转发给整个网络呢？我们知道，以太网中的数据链路层，通过硬件的 MAC 地址来传播消息，交换机就通过报文的 MAC 地址来确定是否需要广播。当交换机收到目标 MAC 地址是 ff:ff:ff:ff:ff:ff 的报文时，便知道这是一个广播报文，才会将它转发给局域网中的所有主机，否则只会转发给 MAC 地址对应端口上的主机。

不过，我们写代码时无法控制底层的 MAC 地址，只能填写目标 IP 地址。什么样的目标 IP 地址，会生成广播 MAC 地址呢？如果只是对所在子网进行广播，那么使用受限广播地址 255.255.255.255 就可以了；如果局域网划分了多个子网，主机需要向其他子网广播，则需要正确地设置直接广播地址（路由器需要打开直接广播功能）。

如何正确地设置直接广播 IP 地址？

怎么设置直接广播的 IP 地址呢？我们首先得了解 IP 地址的构成。

由于 IP 协议需要跨越多个网络工作，所以 IP 地址被一分为二，包括前边的网络 ID 和后边的主机 ID，其中，网络 ID 用于不同网络间的寻址，而主机 ID 则用于在本地局域网内通讯。

举个例子，如果你的局域网 IP 地址是 192.168.0.101，那么网络 ID 就是 192.168.0，而主机 ID 则是 101（这里假定网络管理员没有继续划分 C 类子网）。

这是因为，以 192.168 打头的 IP 地址，被称为 C 类地址，而 C 类地址中，最后 1 个十进制数字代表主机 ID。如果 IP 地址是 172.16.20.227，这就是 B 类地址，此时，172.16 是网络 ID，而 20.227 才是主机 ID。

所以，IP 地址的前缀数字不同，主机 ID 的划分位置也不同。事实上，IP 地址一共被划分为 A、B、C、D、E，5 个类别，它的划分依据正是 IP 地址转换为二进制后，用前 4 个比特位作为依据的。如果第 1 个比特位为 0，这就是 A 类地址，它的网络 ID 是 IP 地址的第 1 到第 8 比特位，而主机 ID 则是随后的 24 个比特位。如果局域网 IP 地址第 1 个数字是 10，这就是 A 类私有地址（局域网中的地址不能在公网中使用，统称为私有地址或者内网地址）。

类似的，若前 2 个比特位是 10，则是 B 类地址，它的主机 ID 是后 16 位；如果前 3 个比特位为 110，这就是 C 类地址，它的主机 ID 是后 8 位。显然，以 192 打头的地址，前 3 位正是 110，所以这是 C 类地址。

此外还有 D 类组播地址和 E 类预留实验地址。

清楚了划分方法后，再来看如何通过修改主机 ID 将 IP 地址改为直接广播地址。如果你留心观察 IP 地址，会发现主机 ID 不会出现全 0 和全 1 这两种情况，这是因为全 0 和全 1 有特殊用途，其中全 0 特指它自己（所以 0.0.0.0 可以指代本机 IP），而全 1 表示全部主机。

所以，主机 ID 的比特位全部设为 1 后就是广播地址。比如，192.168.0.101 是 C 类地址，把主机 ID 从 101 改为 255 后，就可以用 192.168.0.255 发送广播了。

然而，事情到这并没有完。一个 A 类网络可以容纳千万台主机，B 类网络则只能容纳 6 万多台主机，C 类网络则最多容纳 254 台主机。仅有的三类网络中主机数量差距太大，而世界上存在各种规模的企业，它们所需网络中的主机规模千差万别，上述划分方式太过单一，无法满足各类企业的需求，于是诞生了 CIDR 这种新的划分方式，它通过子网掩码（或者叫 Netmask），可以在任意的位置将 IP 地址拆分为网络 ID 和主机 ID，扩展了 A、B、C 三类网络的用法。

当你查看主机的 IP 地址时，就会看到其后跟着一个类似 IP 地址的子网掩码。子网掩码必须把它展开成二进制才能使用，这样，掩码前 N 位为 1 时，就表示 IP 地址的前 N 位是网络 ID，而掩码后面剩余的位全是 0，表示 IP 地址对应的位是主机 ID。

比如，若 192.168.0.101 的子网掩码是 255.255.255.192，就表示 IP 地址的前 26 位是网络 ID，后 6 位是主机 ID，将主机 ID 置为全 1 后，就得到了它的广播地址 192.168.0.127，如下图所示：

到这里，我们设置好 IP 地址后，再把 socket 句柄设置 SO_BROADCAST 属性，就可以发送广播了。广播虽然有很多优点，可是一旦被滥用，很容易产生网络风暴，所以路由器默认是不转发广播报文的。

用更精准的组播来做服务发现

当你用 UDP 广播来做分布式系统的服务发现，会遇到这样一个问题：若并非网络内的所有主机都属于分布式系统，那么，当指定了端口的 UDP 广播报文到达其他主机时，会怎么样呢？这些广播报文在这 3 个步骤后会被丢弃：

第 1 步，网卡设备收到报文后，查看报文中的目标 MAC 地址是否与本机的 MAC 地址匹配，如果不匹配就会丢弃。广播 MAC 地址默认匹配，继续交由上层的 IP 协议栈处理；

第 2 步，IP 协议栈查看目标 IP 地址是否为本机 IP 地址，不匹配也会丢弃报文。上文介绍过的广播 IP 地址同样默认匹配，交由传输层协议继续处理。

第 3 步，传输层检查目标端口是否有进程在监听，如果没有则丢弃报文，反之则交付给进程处理。不属于集群的主机自然不会启动服务监听端口，在这一步才会丢弃广播报文。

可见，对于不属于分布式集群的主机而言，广播报文既占用了它们的带宽，这 3 步协议栈的操作也消耗了 CPU 的计算力。有什么办法能缩小广播的范围，消除它加在无关主机上的负载呢？

组播可以做到。组播是一种“定向广播”，它设定了一个虚拟组，用组播 IP 来标识。这个虚拟组中可以包含多个主机的 IP，当向对应的组播 IP 发送消息时，仅在这个组内的主机才能收到消息。

组播 IP 与常见的单播 IP 不同，它是前文介绍过 5 类 IP 地址中的 D 类地址，32 位 IP 地址的前 4 位必须是 1110，因此组播 IP 地址的范围是从 224.0.0.0 到 239.255.255.255。

当设置好组播 IP 地址后，还要通过管理组播地址的 IGMP 协议（Internet Group Management Protocol），将主机 IP 地址添加进虚拟组中。编程语言提供的 setsockopt 函数，就可以操作 IGMP 协议管理组播地址。比如，使用参数 IP_ADD_MEMBERSHIP 就能够向虚拟组中增加 IP，而 IP_DROP_MEMBERSHIP 则可以从组中去除某个主机的 IP。

这里有一个可运行的 python 测试代码，供你参考。如果你想进一步了解组播的细节，可以观看《Web 协议详解与抓包实战》第 117 课。

组播相对于广播而言 ，除了能够更精准的管理组播范围，还能够跨越多个网络工作。当然，如果将多个网络中的 IP 加入同一虚拟组时，需要涉及到的路由器都可以正确地处理这些 IP 地址，且都能支持 IGMP 协议。

小结

最后我们对这一讲做一个总结。

由于一对多通讯能够充分利用整体网络的性能，而且通过交换机能够同时向许多主机发送消息，所以在局域网内有广泛的应用。

在 TCP 协议分层后，IP 协议天然就支持一对多通讯方式。TCP 协议面向连接的特性使它放弃了一对多的通讯方式，而 UDP 协议则继承了 IP 协议的这一功能。所以，在一对多通讯场景中，我们会选择 UDP 协议。

正确输入广播地址的前提，是理解 IP 地址如何划分为网络 ID 和主机 ID。当主机 ID 所有的比特位改为全 1 时，IP 地址就表示该网络下的所有主机，这就是广播地址。当向广播地址发送 UDP 消息时，网络中的所有主机都会收到。广播在局域网中有广泛的应用，转换 IP 地址与 MAC 地址的 ARP 协议就是用广播实现的。

广播对无关的主机增加了不必要的负担，而组播可以更精准地“定向”广播。组播地址也被称为 D 类地址，它描述的虚拟组要通过 IGMP 协议管理。网络 API 中的 setsockopt 函数可以通过 IGMP 协议，向虚拟组中添加或者删除 IP 地址。当路由器支持 IGMP 协议时，组播就可以跨越多个网络实现更广泛的一对多通讯。

广播和组播能够充分地使用全网带宽，也通过交换机等网络设备分散了发送主机的负载。但它很难对每台接收主机提供定制化服务，这样可靠传输就很难实现。这使得它们在更关注及时性、对丢包不敏感的流媒体直播中更有应用前景。

这一讲我们介绍了许多网络概念，这些也是理解后续内容的基础。从下一讲开始，我们将进入更复杂的一对一通讯协议。

思考题

最后，请你思考下，你使用或者了解过哪些一对多的通讯协议？它们的优缺点，以及未来的发展方向又是什么？欢迎你留言与我探讨。

感谢阅读，如果你觉得今天学习的内容对你有帮助，也欢迎把它分享给你的朋友。

08 | 事件驱动：C10M是如何实现的？

你好，我是陶辉。

上一讲介绍了广播与组播这种一对多通讯方式，从这一讲开始，我们回到主流的一对一通讯方式。

早些年我们谈到高并发，总是会提到 C10K，这是指服务器同时处理 1 万个 TCP 连接。随着服务器性能的提升，近来我们更希望单台服务器的并发能力可以达到 C10M，也就是同时可以处理 1 千万个 TCP 连接。从 C10K 到 C10M，实现技术并没有本质变化，都是用事件驱动和异步开发实现的。[第 5 讲] 介绍过的协程，也是依赖这二者实现高并发的。

做过异步开发的同学都知道，处理基于 TCP 的应用层协议时，一个请求的处理代码必须被拆分到多个回调函数中，由异步框架在相应的事件生成时调用它们。这就是事件驱动方式，它通过减少上下文切换次数，实现了 C10M 级别的高并发。

不过，做应用开发的同学往往不清楚什么叫做“事件”，不了解处理 HTTP 请求的回调函数与事件间的关系。这样，在高并发下，当多个 HTTP 请求争抢执行时，涉及资源分配、释放等重要工作的回调函数，就可能在错误的时间被调用，进而引发一系列问题。比如，不同的回调函数对应不同的事件，如果某个函数执行时间过长，就会影响其他请求，可能导致大量请求出现超时而处理失败。

这一讲我们就来介绍一下，事件是怎样产生的？它是如何驱动请求执行的？多路复用技术是怎样协助实现异步开发的？理解了这些，你也就明白了这种事件驱动的解决方案，知道了怎么样实现 C10M。

事件是怎么产生的？

要了解“事件驱动”的运作机制，首先就要搞清楚到底什么是事件。这就需要你对网络原理有深入的理解了。

简单来说，从网络中接收到一个报文，就可能产生一个事件。如上一讲介绍过的 UDP 请求就是最简单的例子，一个 UDP 请求通常仅由一个网络报文组成，所以，当收到一个 UDP 报文，就意味着收到一个请求，它会生成一个事件，进而触发回调函数执行。

不过，常见的 HTTP 等协议都是基于 TCP 实现的。由于 TCP 是一种面向字节流的协议，HTTP 请求的大小并不受限制，当一个 HTTP 请求的大小超过 TCP 报文的最大长度时，请求会被拆分到多个报文中运输，在接收端的缓冲区中重组、排序。因此，并不是每个到达的 TCP 报文都能生成事件的。

如果不理解事件和 TCP 报文的关系，就没法准确地掌握处理 HTTP 请求的函数何时被调用。当然，作为应用开发工程师，我们无须在意实现细节，只要了解 TCP 连接建立、关闭，以及消息的发送和接收这四个场景中，报文与事件间的关系就可以了。

事件并没有你想象中那么复杂，它只有两种类型：读事件与写事件，其中，读事件表示有到达的消息需要处理，而写事件表示可以发送消息（TCP 连接的写缓冲区中有可用空间）。我们先从三次握手建立连接说起，这一过程会产生一读、一写两个事件。

由于 TCP 允许双向传输，所以建立连接时，会依次在连接的两个方向上建立通道。主动发起连接的一方叫做客户端，被动监听端口等待连接的一方叫做服务器。

客户端首先发送 SYN 报文给服务器，而服务器收到后回复 ACK 和 SYN（这里我们只需要知道产生事件的过程即可，下一讲会详细介绍这两个报文的含义），当它们到达客户端时，双向连接中由客户端到服务器的通道就建立好了，此时客户端就已经可以发送请求了，因此客户端会产生写事件。接着，客户端发送 ACK 报文，到达服务器后，服务器上会产生读事件，因为进程原本在监听 80 等端口，此时有新连接建立成功，应当调用 accept 函数读取这个连接，所以这是一个读事件。

在建立好的 TCP 连接上收发消息时，读事件对应着接收到对方的消息，这很好理解。写事件则稍微复杂些，我们举个例子加以说明。假设要发送一个 2MB 的请求，当调用 write 函数发送时，会先把内存中的数据拷贝到写缓冲区中后，再发送到网卡上。

为何要多此一举呢？这是因为在对方没有明确表示收到前，TCP 会通过定时器重发写缓冲区中的数据，保证消息能够到达对方。写缓冲区是有大小限制的，我在[第 10 讲]中会详细介绍。这里假设写缓冲区只有 1MB，所以调用 write 发送 2MB 数据时，write 函数的返回值只有 1MB，表示写缓冲区已用尽。当收到对方发来的 ACK 报文后，缓冲区中的数据才能释放，就会产生写事件通知进程发送剩余的那 1MB 数据。

如同建立连接需要双向建立一样，关闭连接也需要双方各自关闭每个方向的通道。主动关闭的一方发送 FIN 报文，到达被动方后，内核自动回复 ACK 报文，这表示从主动方到被动方的通道已经关闭。但被动方到主动方的通道也需要关闭，所以此时被动方会产生读事件，提醒被动方调用 close 函数关闭连接。

这样，我们就清楚了 TCP 报文如何产生事件，也明白回调函数何时执行了。然而，同步代码拆分成多个异步函数成本并不低，咱们手里拿着事件驱动这个锤子，可不能看到什么都像是钉子。

什么样的代码值得基于事件来做拆分呢？还得回到高性能这个最终目标上来。我们知道，做性能优化一定要找出性能瓶颈，针对瓶颈做优化性价比才最高。对于服务器来说，对最慢的操作做异步化改造，才能值回开发效率的损失。而服务里对资源的操作速度由快到慢，依次是 CPU、内存、磁盘和网络。CPU 和内存的执行速度都是纳秒级的，无须考虑事件驱动，而磁盘和网络都可以采用事件驱动的异步方式处理。

相对而言，网络不只速度慢，而且波动很大，既受制于连接对端的性能，也受制于网络传输路径。把操作网络的同步 API 改为事件驱动的异步 API 收益最大。而磁盘（特别是机械硬盘）访问速度虽然不快，但它最慢时也不过几十毫秒，是可控的。而且目前磁盘异步 IO 技术（参见[第 4 讲]）还不成熟，它绕过了 PageCache 性能损失很大。所以当下的事件驱动，主要就是指网络事件。

该怎样处理网络事件？

有了网络事件的概念后，我们再来看用户态代码如何处理事件。

网络事件是由内核产生的，进程该怎样获取到它们呢？如 epoll 这样的多路复用技术可以帮我们做到。多路复用是通讯领域的词汇，有些抽象但原理确很简单。

比如，一条高速的光纤上，允许多个用户用较低的网速同时通讯，这就是多路复用。同样道理，一个进程虽然任一时刻只能处理一个请求，但处理每个请求产生的事件时，若耗时控制在 1 毫秒以内，这样 1 秒钟就可以处理数千个请求，从更长的时间维度上看，多个请求复用了一个进程，也叫做多路复用（或者叫做时分多路复用）。我们熟知的 epoll，就是内核提供给用户态的多路复用接口，进程可以通过它从内核中获取事件。

epoll 是如何获取网络事件的呢？最简单的方法，就是在获取事件时，把所有并发连接传给内核，再由内核返回产生了事件的连接，再处理这些连接对应的请求即可。epoll 前的 select 等多路复用函数就是这么干的。

然而，C10M 意味着有一千万个连接，若每个 socket 是 4 字节，那么 1 千万连接就是 40M 字节。这样，每收集一次事件，就需要从用户态复制 40M 字节到内核态。而且，高性能 Server 必须及时地处理网络事件，所以每隔几十毫秒就要收集一次事件，性能消耗巨大。

epoll 为了降低性能消耗，把获取事件拆分成两步。

第一步把需要监控的 socket 传给内核（epoll_ctl 函数），它仅在连接建立等有限的时机调用；

第二步收集事件（epoll_wait 函数）便不用传递 socket 了，这样就把 socket 的重复传递改为了一次传递，降低了性能损耗。

由于网卡的处理能力有限，千兆网卡下，每秒只能接收 100MB 左右的数据，如果每个请求约 10KB，那么每秒大概有 1 万个请求到达、10 万个事件需要处理。这样，即使每隔 100 毫秒收集一次事件（调用 epoll_wait），每次也不过只有 1 万个事件（100000 Event/s * 0.1s = 10000 Event/s）需要处理，只要保证处理一个事件的平均时间小于 10 微秒（多核处理器可以做到），100 毫秒内就可以处理完这些事件（100ms = 10us * 10000）。 因此，哪怕有 1 千万并发连接，也能保证 1 万 RPS 的处理能力，这就是 epoll 能在 C10M 下实现高吞吐量的原因。

进程获取到产生事件的 socket 后，又该如何处理它呢？这里的核心约束是，处理任何一个事件的耗时都应该是微秒级或者毫秒级，否则就会延误其他事件的处理，不只降低了用户的体验，而且会形成恶性循环。

我们知道，为了应对网络的不确定性，每个参与网络通讯的进程都会为请求设置超时时间。一旦某个 socket 上的事件迟迟不被处理，当客户端的超时定时器触发时，客户端往往会关闭连接并重发请求，这会让服务器雪上加霜。

怎样保证处理一个事件的时间不会太长呢？ 我们把处理事件的代码分为三类来看。

第一类是计算任务，虽然内存、CPU 的速度很快，然而循环执行也可能耗时达到秒级。所以，如果一定要引入需要密集计算才能完成的请求，为了不阻碍其他事件的处理，要么把这样的请求放在独立的线程中完成，要么把请求的处理过程拆分成多段，确保每段能够快速执行完，同时每段执行完都要均等地处理其他事件，这样通过放慢该请求的处理时间，就保障了其他请求的及时处理。

第二类会读写磁盘，由于磁盘的写入操作使用了 PageCache 的延迟写特性，当 write 函数返回时只是复制到了内存中，所以写入操作很快。磁盘的读取操作就比较慢了，这时，通常要把大文件的读取，拆分成许多份，每份仅有几十 KB，降低单次操作的耗时。

第三类是通过网络访问上游服务。与处理客户端请求相似，我们必须使用非阻塞 socket，用事件驱动方式处理请求。需要注意的是，许多网络服务提供的 SDK，都是基于阻塞 socket 实现的，使用前必须先做完非阻塞改造。比如 Memcached 的官方 SDK 是用阻塞 socket 实现的，Nginx 如果直接使用该 SDK 访问它，性能就会一落千丈。正确的访问方式，是使用第三方提供的 ngx_http_memcached_module 模块，它用非阻塞 socket 重新封装了 SDK。

总之，网络报文到达后，内核就产生了读、写事件，而 epoll 函数使得进程可以高效地收集到这些事件。接下来，要确保在进程中处理每个事件的时间足够短，才能及时地处理所有请求，这个过程中既要避免阻塞 socket 的使用，也要把耗时过长的操作拆成多份执行。最终，通过快速、及时、均等地执行所有事件，异步 Server 实现了高并发。

小结

最后我们对这一讲做个小结。异步服务改为从事件层面处理请求，在 epoll 这样的多路复用机制协助下，最终实现了 C10M 级别的高并发服务。

事件有很多种，网络消息的传输既慢又不可控，所以用网络事件驱动请求的性价比最高。这样，就需要你了解 TCP 报文是如何产生事件的。

TCP 连接建立时，会在客户端产生写事件，在服务器端产生读事件。连接关闭时，则会在被动关闭端产生读事件。在连接上收发消息时，也会产生事件，其中发送消息前的写事件与内核分配的缓冲区有关。

清楚了事件与 TCP 报文的关系后，可以用多路复用技术获取事件，其中 epoll 是佼佼者，它取消了收集事件时重复传递的大量 socket 参数，给 C10M 的实现提供了基础。

你需要注意的是，处理 epoll 收集到的事件时，必须保证处理一个事件的平均时间在毫秒级以内。传统的阻塞 socket 是做不到的，所以必须用非阻塞 socket 替换阻塞 socket。如果事件的回调函数耗时过长，也得拆分为多个耗时短的函数，用多次事件（比如定时器事件）的触发来替代。

虽然我们有了上述的事件驱动方案，但实现 C10M 还需要更谨慎地使用不过数百 GB 的服务器内存。关于如何降低内存的消耗，可以关注[第 2 讲] 提到的内存池，[第 11 讲] 还会介绍如何减少连接缓冲区的空间占用。

这一讲我们介绍了事件驱动的总体方案，但 C10M 需要高效的用心几乎所有服务器资源，所以，我们还得通过 Linux 更精细地控制 TCP 的行为，接下来的 3 讲我们将深入 Linux，讨论如何优化 TCP 的性能。

思考题

最后，留给你一个思考题，需要 CPU 做密集计算的请求，该如何拆分到事件驱动框架中呢？欢迎你在留言区留言，与大家一起探讨。

感谢阅读，如果你觉得这节课对你有一些启发，也欢迎把它分享给你的朋友。

09 | 如何提升TCP三次握手的性能？

你好，我是陶辉。

上一讲我们提到 TCP 在三次握手建立连接、四次握手关闭连接时是怎样产生事件的，这两个过程中 TCP 连接经历了复杂的状态变化，既容易导致编程出错，也有很大的优化空间。这一讲我们看看在 Linux 操作系统下，如何优化 TCP 的三次握手流程，提升握手速度。

TCP 是一个可以双向传输的全双工协议，所以需要经过三次握手才能建立连接。三次握手在一个 HTTP 请求中的平均时间占比在 10% 以上，在网络状况不佳、高并发或者遭遇 SYN 泛洪攻击等场景中，如果不能正确地调整三次握手中的参数，就会对性能有很大的影响。

TCP 协议是由操作系统实现的，调整 TCP 必须通过操作系统提供的接口和工具，这就需要理解 Linux 是怎样把三次握手中的状态暴露给我们，以及通过哪些工具可以找到优化依据，并通过哪些接口修改参数。

因此，这一讲我们将介绍 TCP 握手过程中各状态的意义，并以状态变化作为主线，看看如何调整 Linux 参数才能提升握手的性能。

客户端的优化

客户端和服务器都可以针对三次握手优化性能。相对而言，主动发起连接的客户端优化相对简单一些，而服务器需要在监听端口上被动等待连接，并保存许多握手的中间状态，优化方法更为复杂一些。我们首先来看如何优化客户端。

三次握手建立连接的首要目的是同步序列号。只有同步了序列号才有可靠的传输，TCP 协议的许多特性都是依赖序列号实现的，比如流量控制、消息丢失后的重发等等，这也是三次握手中的报文被称为 SYN 的原因，因为 SYN 的全称就叫做 Synchronize Sequence Numbers。

三次握手虽然由操作系统实现，但它通过连接状态把这一过程暴露给了我们，我们来细看下过程中出现的 3 种状态的意义。客户端发送 SYN 开启了三次握手，此时在客户端上用 netstat 命令（后续查看连接状态都使用该命令）可以看到连接的状态是 SYN_SENT（顾名思义，就是把刚 SYN 发送出去）。

tcp    0   1 172.16.20.227:39198     129.28.56.36:81         SYN_SENT

客户端在等待服务器回复的 ACK 报文。正常情况下，服务器会在几毫秒内返回 ACK，但如果客户端迟迟没有收到 ACK 会怎么样呢？客户端会重发 SYN，重试的次数由 tcp_syn_retries 参数控制，默认是 6 次：

net.ipv4.tcp_syn_retries = 6

第 1 次重试发生在 1 秒钟后，接着会以翻倍的方式在第 2、4、8、16、32 秒共做 6 次重试，最后一次重试会等待 64 秒，如果仍然没有返回 ACK，才会终止三次握手。所以，总耗时是 1+2+4+8+16+32+64=127 秒，超过 2 分钟。

如果这是一台有明确任务的服务器，你可以根据网络的稳定性和目标服务器的繁忙程度修改重试次数，调整客户端的三次握手时间上限。比如内网中通讯时，就可以适当调低重试次数，尽快把错误暴露给应用程序。

服务器端的优化

当服务器收到 SYN 报文后，服务器会立刻回复 SYN+ACK 报文，既确认了客户端的序列号，也把自己的序列号发给了对方。此时，服务器端出现了新连接，状态是 SYN_RCV（RCV 是 received 的缩写）。这个状态下，服务器必须建立一个 SYN 半连接队列来维护未完成的握手信息，当这个队列溢出后，服务器将无法再建立新连接。

新连接建立失败的原因有很多，怎样获得由于队列已满而引发的失败次数呢？netstat -s 命令给出的统计结果中可以得到。

# netstat -s | grep "SYNs to LISTEN"
    1192450 SYNs to LISTEN sockets dropped

这里给出的是队列溢出导致 SYN 被丢弃的个数。注意这是一个累计值，如果数值在持续增加，则应该调大 SYN 半连接队列。修改队列大小的方法，是设置 Linux 的 tcp_max_syn_backlog 参数：

net.ipv4.tcp_max_syn_backlog = 1024

如果 SYN 半连接队列已满，只能丢弃连接吗？并不是这样，开启 syncookies 功能就可以在不使用 SYN 队列的情况下成功建立连接。syncookies 是这么做的：服务器根据当前状态计算出一个值，放在己方发出的 SYN+ACK 报文中发出，当客户端返回 ACK 报文时，取出该值验证，如果合法，就认为连接建立成功，如下图所示。

Linux 下怎样开启 syncookies 功能呢？修改 tcp_syncookies 参数即可，其中值为 0 时表示关闭该功能，2 表示无条件开启功能，而 1 则表示仅当 SYN 半连接队列放不下时，再启用它。由于 syncookie 仅用于应对 SYN 泛洪攻击（攻击者恶意构造大量的 SYN 报文发送给服务器，造成 SYN 半连接队列溢出，导致正常客户端的连接无法建立），这种方式建立的连接，许多 TCP 特性都无法使用。所以，应当把 tcp_syncookies 设置为 1，仅在队列满时再启用。

net.ipv4.tcp_syncookies = 1

当客户端接收到服务器发来的 SYN+ACK 报文后，就会回复 ACK 去通知服务器，同时己方连接状态从 SYN_SENT 转换为 ESTABLISHED，表示连接建立成功。服务器端连接成功建立的时间还要再往后，到它收到 ACK 后状态才变为 ESTABLISHED。

如果服务器没有收到 ACK，就会一直重发 SYN+ACK 报文。当网络繁忙、不稳定时，报文丢失就会变严重，此时应该调大重发次数。反之则可以调小重发次数。修改重发次数的方法是，调整 tcp_synack_retries 参数：

net.ipv4.tcp_synack_retries = 5

tcp_synack_retries 的默认重试次数是 5 次，与客户端重发 SYN 类似，它的重试会经历 1、2、4、8、16 秒，最后一次重试后等待 32 秒，若仍然没有收到 ACK，才会关闭连接，故共需要等待 63 秒。

服务器收到 ACK 后连接建立成功，此时，内核会把连接从 SYN 半连接队列中移出，再移入 accept 队列，等待进程调用 accept 函数时把连接取出来。如果进程不能及时地调用 accept 函数，就会造成 accept 队列溢出，最终导致建立好的 TCP 连接被丢弃。

实际上，丢弃连接只是 Linux 的默认行为，我们还可以选择向客户端发送 RST 复位报文，告诉客户端连接已经建立失败。打开这一功能需要将 tcp_abort_on_overflow 参数设置为 1。

net.ipv4.tcp_abort_on_overflow = 0

通常情况下，应当把 tcp_abort_on_overflow 设置为 0，因为这样更有利于应对突发流量。举个例子，当 accept 队列满导致服务器丢掉了 ACK，与此同时，客户端的连接状态却是 ESTABLISHED，进程就在建立好的连接上发送请求。只要服务器没有为请求回复 ACK，请求就会被多次重发。如果服务器上的进程只是短暂的繁忙造成 accept 队列满，那么当 accept 队列有空位时，再次接收到的请求报文由于含有 ACK，仍然会触发服务器端成功建立连接。所以，tcp_abort_on_overflow 设为 0 可以提高连接建立的成功率，只有你非常肯定 accept 队列会长期溢出时，才能设置为 1 以尽快通知客户端。

那么，怎样调整 accept 队列的长度呢？listen 函数的 backlog 参数就可以设置 accept 队列的大小。事实上，backlog 参数还受限于 Linux 系统级的队列长度上限，当然这个上限阈值也可以通过 somaxconn 参数修改。

net.core.somaxconn = 128

当下各监听端口上的 accept 队列长度可以通过 ss -ltn 命令查看，但 accept 队列长度是否需要调整该怎么判断呢？还是通过 netstat -s 命令给出的统计结果，可以看到究竟有多少个连接因为队列溢出而被丢弃。

# netstat -s | grep "listen queue"
    14 times the listen queue of a socket overflowed

如果持续不断地有连接因为 accept 队列溢出被丢弃，就应该调大 backlog 以及 somaxconn 参数。

TFO 技术如何绕过三次握手？

以上我们只是在对三次握手的过程进行优化。接下来我们看看如何绕过三次握手发送数据。

三次握手建立连接造成的后果就是，HTTP 请求必须在一次 RTT（Round Trip Time，从客户端到服务器一个往返的时间）后才能发送，Google 对此做的统计显示，三次握手消耗的时间，在 HTTP 请求完成的时间占比在 10% 到 30% 之间。

因此，Google 提出了 TCP fast open 方案（简称TFO），客户端可以在首个 SYN 报文中就携带请求，这节省了 1 个 RTT 的时间。

接下来我们就来看看，TFO 具体是怎么实现的。

为了让客户端在 SYN 报文中携带请求数据，必须解决服务器的信任问题。因为此时服务器的 SYN 报文还没有发给客户端，客户端是否能够正常建立连接还未可知，但此时服务器需要假定连接已经建立成功，并把请求交付给进程去处理，所以服务器必须能够信任这个客户端。

TFO 到底怎样达成这一目的呢？它把通讯分为两个阶段，第一阶段为首次建立连接，这时走正常的三次握手，但在客户端的 SYN 报文会明确地告诉服务器它想使用 TFO 功能，这样服务器会把客户端 IP 地址用只有自己知道的密钥加密（比如 AES 加密算法），作为 Cookie 携带在返回的 SYN+ACK 报文中，客户端收到后会将 Cookie 缓存在本地。

之后，如果客户端再次向服务器建立连接，就可以在第一个 SYN 报文中携带请求数据，同时还要附带缓存的 Cookie。很显然，这种通讯方式下不能再采用经典的“先 connect 再 write 请求”这种编程方法，而要改用 sendto 或者 sendmsg 函数才能实现。

服务器收到后，会用自己的密钥验证 Cookie 是否合法，验证通过后连接才算建立成功，再把请求交给进程处理，同时给客户端返回 SYN+ACK。虽然客户端收到后还会返回 ACK，但服务器不等收到 ACK 就可以发送 HTTP 响应了，这就减少了握手带来的 1 个 RTT 的时间消耗。

当然，为了防止 SYN 泛洪攻击，服务器的 TFO 实现必须能够自动化地定时更新密钥。

Linux 下怎么打开 TFO 功能呢？这要通过 tcp_fastopen 参数。由于只有客户端和服务器同时支持时，TFO 功能才能使用，所以 tcp_fastopen 参数是按比特位控制的。其中，第 1 个比特位为 1 时，表示作为客户端时支持 TFO；第 2 个比特位为 1 时，表示作为服务器时支持 TFO，所以当 tcp_fastopen 的值为 3 时（比特为 0x11）就表示完全支持 TFO 功能。

net.ipv4.tcp_fastopen = 3

小结

这一讲，我们沿着三次握手的流程，介绍了 Linux 系统的优化方法。

当客户端通过发送 SYN 发起握手时，可以通过 tcp_syn_retries 控制重发次数。当服务器的 SYN 半连接队列溢出后，SYN 报文会丢失从而导致连接建立失败。我们可以通过 netstat -s 给出的统计结果判断队列长度是否合适，进而通过 tcp_max_syn_backlog 参数调整队列的长度。服务器回复 SYN+ACK 报文的重试次数由 tcp_synack_retries 参数控制，网络稳定时可以调小它。为了应对 SYN 泛洪攻击，应将 tcp_syncookies 参数设置为 1，它仅在 SYN 队列满后开启 syncookie 功能，保证连接成功建立。

服务器收到客户端返回的 ACK 后，会把连接移入 accept 队列，等待进程调用 accept 函数取出连接。如果 accept 队列溢出，默认系统会丢弃 ACK，也可以通过 tcp_abort_on_overflow 参数用 RST 通知客户端连接建立失败。如果 netstat 统计信息显示，大量的 ACK 被丢弃后，可以通过 listen 函数的 backlog 参数和 somaxconn 系统参数提高队列上限。

TFO 技术绕过三次握手，使得 HTTP 请求减少了 1 个 RTT 的时间。Linux 下可以通过 tcp_fastopen 参数开启该功能。

从这一讲可以看出，虽然 TCP 是由操作系统实现的，但 Linux 通过多种方式提供了修改 TCP 功能的接口，供我们优化 TCP 的性能。下一讲我们再来探讨四次握手关闭连接时，Linux 怎样帮助我们优化其性能。

思考题

最后，留给你一个思考题，关于三次握手建立连接，你做过哪些优化？效果如何？欢迎你在留言区与大家一起探讨。

感谢阅读，如果你觉得这节课对你有一些启发，也欢迎把它分享给你的朋友。

10 | 如何提升TCP四次挥手的性能？

你好，我是陶辉。

上一节课，我们介绍了建立连接时的优化方法，这一节课再来看四次挥手关闭连接时，如何优化性能。

close 和 shutdown 函数都可以关闭连接，但这两种方式关闭的连接，不只功能上有差异，控制它们的 Linux 参数也不相同。close 函数会让连接变为孤儿连接，shutdown 函数则允许在半关闭的连接上长时间传输数据。TCP 之所以具备这个功能，是因为它是全双工协议，但这也造成四次挥手非常复杂。

四次挥手中你可以用 netstat 命令观察到 6 种状态。其中，你多半看到过 TIME_WAIT 状态。网上有许多文章介绍怎样减少 TIME_WAIT 状态连接的数量，也有文章说 TIME_WAIT 状态是必不可少、不能优化掉的。这两种看似自相矛盾的观点之所以存在，就在于优化连接关闭时，不能仅基于主机端的视角，还必须站在整个网络的层次上，才能给出正确的解决方案。

Linux 为四次挥手提供了很多控制参数，有些参数的名称与含义并不相符。例如 tcp_orphan_retries 参数中有 orphan 孤儿，却同时对非孤儿连接也生效。而且，错误地配置这些参数，不只无法针对高并发场景提升性能，还会降低资源的使用效率，甚至引发数据错误。

这一讲，我们将基于四次挥手的流程，介绍 Linux 下的优化方法。

四次挥手的流程

你想没想过，为什么建立连接是三次握手，而关闭连接需要四次挥手呢？

这是因为 TCP 不允许连接处于半打开状态时就单向传输数据，所以在三次握手建立连接时，服务器会把 ACK 和 SYN 放在一起发给客户端，其中，ACK 用来打开客户端的发送通道，SYN 用来打开服务器的发送通道。这样，原本的四次握手就降为三次握手了。

但是当连接处于半关闭状态时，TCP 是允许单向传输数据的。为便于下文描述，接下来我们把先关闭连接的一方叫做主动方，后关闭连接的一方叫做被动方。当主动方关闭连接时，被动方仍然可以在不调用 close 函数的状态下，长时间发送数据，此时连接处于半关闭状态。这一特性是 TCP 的双向通道互相独立所致，却也使得关闭连接必须通过四次挥手才能做到。

互联网中往往服务器才是主动关闭连接的一方。这是因为，HTTP 消息是单向传输协议，服务器接收完请求才能生成响应，发送完响应后就会立刻关闭 TCP 连接，这样及时释放了资源，能够为更多的用户服务。

这就使得服务器的优化策略变得复杂起来。一方面，由于被动方有多种应对策略，从而增加了主动方的处理分支。另一方面，服务器同时为成千上万个用户服务，任何错误都会被庞大的用户数放大。所以对主动方的关闭连接参数调整时，需要格外小心。

了解了这一点之后，我们再来看四次挥手的流程。

其实四次挥手只涉及两种报文：FIN 和 ACK。FIN 就是 Finish 结束连接的意思，谁发出 FIN 报文，就表示它将不再发送任何数据，关闭这一方向的传输通道。ACK 是 Acknowledge 确认的意思，它用来通知对方：你方的发送通道已经关闭。

当主动方关闭连接时，会发送 FIN 报文，此时主动方的连接状态由 ESTABLISHED 变为 FIN_WAIT1。当被动方收到 FIN 报文后，内核自动回复 ACK 报文，连接状态由 ESTABLISHED 变为 CLOSE_WAIT，顾名思义，它在等待进程调用 close 函数关闭连接。当主动方接收到这个 ACK 报文后，连接状态由 FIN_WAIT1 变为 FIN_WAIT2，主动方的发送通道就关闭了。

再来看被动方的发送通道是如何关闭的。当被动方进入 CLOSE_WAIT 状态时，进程的 read 函数会返回 0，这样开发人员就会有针对性地调用 close 函数，进而触发内核发送 FIN 报文，此时被动方连接的状态变为 LAST_ACK。当主动方收到这个 FIN 报文时，内核会自动回复 ACK，同时连接的状态由 FIN_WAIT2 变为 TIME_WAIT，Linux 系统下大约 1 分钟后 TIME_WAIT 状态的连接才会彻底关闭。而被动方收到 ACK 报文后，连接就会关闭。

主动方的优化

关闭连接有多种方式，比如进程异常退出时，针对它打开的连接，内核就会发送 RST 报文来关闭。RST 的全称是 Reset 复位的意思，它可以不走四次挥手强行关闭连接，但当报文延迟或者重复传输时，这种方式会导致数据错乱，所以这是不得已而为之的关闭连接方案。

安全关闭连接的方式必须通过四次挥手，它由进程调用 close 或者 shutdown 函数发起，这二者都会向对方发送 FIN 报文（shutdown 参数须传入 SHUT_WR 或者 SHUT_RDWR 才会发送 FIN），区别在于 close 调用后，哪怕对方在半关闭状态下发送的数据到达主动方，进程也无法接收。

此时，这个连接叫做孤儿连接，如果你用 netstat -p 命令，会发现连接对应的进程名为空。而 shutdown 函数调用后，即使连接进入了 FIN_WAIT1 或者 FIN_WAIT2 状态，它也不是孤儿连接，进程仍然可以继续接收数据。关于孤儿连接的概念，下文调优参数时还会用到。

主动方发送 FIN 报文后，连接就处于 FIN_WAIT1 状态下，该状态通常应在数十毫秒内转为 FIN_WAIT2。只有迟迟收不到对方返回的 ACK 时，才能用 netstat 命令观察到 FIN_WAIT1 状态。此时，内核会定时重发 FIN 报文，其中重发次数由 tcp_orphan_retries 参数控制（注意，orphan 虽然是孤儿的意思，该参数却不只对孤儿连接有效，事实上，它对所有 FIN_WAIT1 状态下的连接都有效），默认值是 0，特指 8 次：

net.ipv4.tcp_orphan_retries = 0

如果 FIN_WAIT1 状态连接有很多，你就需要考虑降低 tcp_orphan_retries 的值。当重试次数达到 tcp_orphan_retries 时，连接就会直接关闭掉。

对于正常情况来说，调低 tcp_orphan_retries 已经够用，但如果遇到恶意攻击，FIN 报文根本无法发送出去。这是由 TCP 的 2 个特性导致的。

首先，TCP 必须保证报文是有序发送的，FIN 报文也不例外，当发送缓冲区还有数据没发送时，FIN 报文也不能提前发送。

其次，TCP 有流控功能，当接收方将接收窗口设为 0 时，发送方就不能再发送数据。所以，当攻击者下载大文件时，就可以通过将接收窗口设为 0，导致 FIN 报文无法发送，进而导致连接一直处于 FIN_WAIT1 状态。

解决这种问题的方案是调整 tcp_max_orphans 参数：

net.ipv4.tcp_max_orphans = 16384

顾名思义，tcp_max_orphans 定义了孤儿连接的最大数量。当进程调用 close 函数关闭连接后，无论该连接是在 FIN_WAIT1 状态，还是确实关闭了，这个连接都与该进程无关了，它变成了孤儿连接。Linux 系统为防止孤儿连接过多，导致系统资源长期被占用，就提供了 tcp_max_orphans 参数。如果孤儿连接数量大于它，新增的孤儿连接将不再走四次挥手，而是直接发送 RST 复位报文强制关闭。

当连接收到 ACK 进入 FIN_WAIT2 状态后，就表示主动方的发送通道已经关闭，接下来将等待对方发送 FIN 报文，关闭对方的发送通道。这时，如果连接是用 shutdown 函数关闭的，连接可以一直处于 FIN_WAIT2 状态。但对于 close 函数关闭的孤儿连接，这个状态不可以持续太久，而 tcp_fin_timeout 控制了这个状态下连接的持续时长。

net.ipv4.tcp_fin_timeout = 60

它的默认值是 60 秒。这意味着对于孤儿连接，如果 60 秒后还没有收到 FIN 报文，连接就会直接关闭。这个 60 秒并不是拍脑袋决定的，它与接下来介绍的 TIME_WAIT 状态的持续时间是相同的，我们稍后再来回答 60 秒的由来。

TIME_WAIT 是主动方四次挥手的最后一个状态。当收到被动方发来的 FIN 报文时，主动方回复 ACK，表示确认对方的发送通道已经关闭，连接随之进入 TIME_WAIT 状态，等待 60 秒后关闭，为什么呢？我们必须站在整个网络的角度上，才能回答这个问题。

TIME_WAIT 状态的连接，在主动方看来确实已经关闭了。然而，被动方没有收到 ACK 报文前，连接还处于 LAST_ACK 状态。如果这个 ACK 报文没有到达被动方，被动方就会重发 FIN 报文。重发次数仍然由前面介绍过的 tcp_orphan_retries 参数控制。

如果主动方不保留 TIME_WAIT 状态，会发生什么呢？此时连接的端口恢复了自由身，可以复用于新连接了。然而，被动方的 FIN 报文可能再次到达，这既可能是网络中的路由器重复发送，也有可能是被动方没收到 ACK 时基于 tcp_orphan_retries 参数重发。这样，正常通讯的新连接就可能被重复发送的 FIN 报文误关闭。保留 TIME_WAIT 状态，就可以应付重发的 FIN 报文，当然，其他数据报文也有可能重发，所以 TIME_WAIT 状态还能避免数据错乱。

我们再回过头来看看，为什么 TIME_WAIT 状态要保持 60 秒呢？这与孤儿连接 FIN_WAIT2 状态默认保留 60 秒的原理是一样的，因为这两个状态都需要保持 2MSL 时长。MSL 全称是 Maximum Segment Lifetime，它定义了一个报文在网络中的最长生存时间（报文每经过一次路由器的转发，IP 头部的 TTL 字段就会减 1，减到 0 时报文就被丢弃，这就限制了报文的最长存活时间）。

为什么是 2 MSL 的时长呢？这其实是相当于至少允许报文丢失一次。比如，若 ACK 在一个 MSL 内丢失，这样被动方重发的 FIN 会在第 2 个 MSL 内到达，TIME_WAIT 状态的连接可以应对。为什么不是 4 或者 8 MSL 的时长呢？你可以想象一个丢包率达到百分之一的糟糕网络，连续两次丢包的概率只有万分之一，这个概率实在是太小了，忽略它比解决它更具性价比。

因此，TIME_WAIT 和 FIN_WAIT2 状态的最大时长都是 2 MSL，由于在 Linux 系统中，MSL 的值固定为 30 秒，所以它们都是 60 秒。

虽然 TIME_WAIT 状态的存在是有必要的，但它毕竟在消耗系统资源，比如 TIME_WAIT 状态的端口就无法供新连接使用。怎样解决这个问题呢？

Linux 提供了 tcp_max_tw_buckets 参数，当 TIME_WAIT 的连接数量超过该参数时，新关闭的连接就不再经历 TIME_WAIT 而直接关闭。

net.ipv4.tcp_max_tw_buckets = 5000

当服务器的并发连接增多时，相应地，同时处于 TIME_WAIT 状态的连接数量也会变多，此时就应当调大 tcp_max_tw_buckets 参数，减少不同连接间数据错乱的概率。

当然，tcp_max_tw_buckets 也不是越大越好，毕竟内存和端口号都是有限的。有没有办法让新连接复用 TIME_WAIT 状态的端口呢？如果服务器会主动向上游服务器发起连接的话，就可以把 tcp_tw_reuse 参数设置为 1，它允许作为客户端的新连接，在安全条件下使用 TIME_WAIT 状态下的端口。

net.ipv4.tcp_tw_reuse = 1

当然，要想使 tcp_tw_reuse 生效，还得把 timestamps 参数设置为 1，它满足安全复用的先决条件（对方也要打开 tcp_timestamps ）：

net.ipv4.tcp_timestamps = 1

老版本的 Linux 还提供了 tcp_tw_recycle 参数，它并不要求 TIME_WAIT 状态存在 60 秒，很容易导致数据错乱，不建议设置为 1。

net.ipv4.tcp_tw_recycle = 0

所以在 Linux 4.12 版本后，直接取消了这一参数。

被动方的优化

当被动方收到 FIN 报文时，就开启了被动方的四次挥手流程。内核自动回复 ACK 报文后，连接就进入 CLOSE_WAIT 状态，顾名思义，它表示等待进程调用 close 函数关闭连接。

内核没有权力替代进程去关闭连接，因为若主动方是通过 shutdown 关闭连接，那么它就是想在半关闭连接上接收数据。因此，Linux 并没有限制 CLOSE_WAIT 状态的持续时间。

当然，大多数应用程序并不使用 shutdown 函数关闭连接，所以，当你用 netstat 命令发现大量 CLOSE_WAIT 状态时，要么是程序出现了 Bug，read 函数返回 0 时忘记调用 close 函数关闭连接，要么就是程序负载太高，close 函数所在的回调函数被延迟执行了。此时，我们应当在应用代码层面解决问题。

由于 CLOSE_WAIT 状态下，连接已经处于半关闭状态，所以此时进程若要关闭连接，只能调用 close 函数（再调用 shutdown 关闭单向通道就没有意义了），内核就会发出 FIN 报文关闭发送通道，同时连接进入 LAST_ACK 状态，等待主动方返回 ACK 来确认连接关闭。

如果迟迟等不到 ACK，内核就会重发 FIN 报文，重发次数仍然由 tcp_orphan_retries 参数控制，这与主动方重发 FIN 报文的优化策略一致。

至此，由一方主动发起四次挥手的流程就介绍完了。需要你注意的是，如果被动方迅速调用 close 函数，那么被动方的 ACK 和 FIN 有可能在一个报文中发送，这样看起来，四次挥手会变成三次挥手，这只是一种特殊情况，不用在意。

我们再来看一种特例，如果连接双方同时关闭连接，会怎么样？

此时，上面介绍过的优化策略仍然适用。两方发送 FIN 报文时，都认为自己是主动方，所以都进入了 FIN_WAIT1 状态，FIN 报文的重发次数仍由 tcp_orphan_retries 参数控制。

接下来，双方在等待 ACK 报文的过程中，都等来了 FIN 报文。这是一种新情况，所以连接会进入一种叫做 CLOSING 的新状态，它替代了 FIN_WAIT2 状态。此时，内核回复 ACK 确认对方发送通道的关闭，仅己方的 FIN 报文对应的 ACK 还没有收到。所以，CLOSING 状态与 LAST_ACK 状态下的连接很相似，它会在适时重发 FIN 报文的情况下最终关闭。

小结

我们对这一讲的内容做个小结。

今天我们讲了四次挥手的流程，你需要根据主动方与被动方的连接状态变化来调整系统参数，使它在特定网络条件下更及时地释放资源。

四次挥手的主动方，为了应对丢包，允许在 tcp_orphan_retries 次数内重发 FIN 报文。当收到 ACK 报文，连接就进入了 FIN_WAIT2 状态，此时系统的行为依赖这是否为孤儿连接。

如果这是 close 函数关闭的孤儿连接，那么在 tcp_fin_timeout 秒内没有收到对方的 FIN 报文，连接就直接关闭，反之 shutdown 函数关闭的连接则不受此限制。毕竟孤儿连接可能在重发次数内存在数分钟之久，为了应对孤儿连接占用太多的资源，tcp_max_orphans 定义了最大孤儿连接的数量，超过时连接就会直接释放。

当接收到 FIN 报文，并返回 ACK 后，主动方的连接进入 TIME_WAIT 状态。这一状态会持续 1 分钟，为了防止 TIME_WAIT 状态占用太多的资源，tcp_max_tw_buckets 定义了最大数量，超过时连接也会直接释放。当 TIME_WAIT 状态过多时，还可以通过设置 tcp_tw_reuse 和 tcp_timestamps 为 1 ，将 TIME_WAIT 状态的端口复用于作为客户端的新连接。

被动关闭的连接方应对非常简单，它在回复 ACK 后就进入了 CLOSE_WAIT 状态，等待进程调用 close 函数关闭连接。因此，出现大量 CLOSE_WAIT 状态的连接时，应当从应用程序中找问题。当被动方发送 FIN 报文后，连接就进入 LAST_ACK 状态，在未等来 ACK 时，会在 tcp_orphan_retries 参数的控制下重发 FIN 报文。

至此，TCP 连接建立、关闭时的性能优化就介绍完了。下一讲，我们将专注在 TCP 上传输数据时，如何优化内存的使用效率。

思考题

最后，给你留一个思考题。你知道关闭连接时的 SO_LINGER 选项吗？它希望用四次挥手替代 RST 关闭连接的方式，防止浏览器没有接收到完整的 HTTP 响应。请你思考一下，SO_LINGER 会怎么影响主动方连接的状态变化？SO_LINGER 上的超时时间，是怎样与系统配置参数协作的？欢迎你在留言区与我一起探讨。

感谢阅读，如果你觉得这节课对你有一些启发，也欢迎把它分享给你的朋友。

11 | 如何修改TCP缓冲区才能兼顾并发数量与传输速度？

你好，我是陶辉。

我们在[第 8 课] 中讲了如何从 C10K 进一步到 C10M，不过，这也意味着 TCP 占用的内存翻了一千倍，服务器的内存资源会非常紧张。

如果你在 Linux 系统中用 free 命令查看内存占用情况，会发现一栏叫做 buff/cache，它是系统内存，似乎与应用进程无关。但每当进程新建一个 TCP 连接，buff/cache 中的内存都会上升 4K 左右。而且，当连接传输数据时，就远不止增加 4K 内存了。这样，几十万并发连接，就在进程内存外又增加了 GB 级别的系统内存消耗。

这是因为 TCP 连接是由内核维护的，内核为每个连接建立的内存缓冲区，既要为网络传输服务，也要充当进程与网络间的缓冲桥梁。如果连接的内存配置过小，就无法充分使用网络带宽，TCP 传输速度就会很慢；如果连接的内存配置过大，那么服务器内存会很快用尽，新连接就无法建立成功。因此，只有深入理解 Linux 下 TCP 内存的用途，才能正确地配置内存大小。

这一讲，我们就来看看，Linux 下的 TCP 缓冲区该如何修改，才能在高并发下维持 TCP 的高速传输。

滑动窗口是怎样影响传输速度的？

我们知道，TCP 必须保证每一个报文都能够到达对方，它采用的机制就是：报文发出后，必须收到接收方返回的 ACK 确认报文（Acknowledge 确认的意思）。如果在一段时间内（称为 RTO，retransmission timeout）没有收到，这个报文还得重新发送，直到收到 ACK 为止。

可见，TCP 报文发出去后，并不能立刻从内存中删除，因为重发时还需要用到它。由于 TCP 是由内核实现的，所以报文存放在内核缓冲区中，这也是高并发下 buff/cache 内存增加很多的原因。

事实上，确认报文被收到的机制非常复杂，它受制于很多因素。我们先来看第一个因素，速度。

如果我们发送一个报文，收到 ACK 确认后，再发送下一个报文，会有什么问题？显然，发送每个报文都需要经历一个 RTT 时延（RTT 的值可以用 ping 命令得到）。要知道，因为网络设备限制了报文的字节数，所以每个报文的体积有限。

比如，以太网报文最大只有 1500 字节，而发送主机到接收主机间，要经历多个广域网、局域网，其中最小的设备决定了网络报文的最大字节数，在 TCP 中，这个值叫做 MSS（Maximum Segment Size），它通常在 1KB 左右。如果 RTT 时延是 10ms，那么它们的传送速度最多只有 1KB/10ms=100KB/s！可见，这种确认报文方式太影响传输速度了。

提速的方式很简单，并行地批量发送报文，再批量确认报文即可。比如，发送一个 100MB 的文件，如果 MSS 值为 1KB，那么需要发送约 10 万个报文。发送方大可以同时发送这 10 万个报文，再等待它们的 ACK 确认。这样，发送速度瞬间就达到 100MB/10ms=10GB/s。

然而，这引出了另一个问题，接收方有那么强的处理能力吗？接收方的处理能力，这是影响确认机制的第二个因素（网络也没有这么强的处理能力，下一讲会介绍应对网络瓶颈的拥塞控制技术）。

当接收方硬件不如发送方，或者系统繁忙、资源紧张时，是无法瞬间处理这么多报文的。于是，这些报文只能被丢掉，网络效率非常低。怎么限制发送方的速度呢？

接收方把它的处理能力告诉发送方，使其限制发送速度即可，这就是滑动窗口的由来。接收方根据它的缓冲区，可以计算出后续能够接收多少字节的报文，这个数字叫做接收窗口。当内核接收到报文时，必须用缓冲区存放它们，这样剩余缓冲区空间变小，接收窗口也就变小了；当进程调用 read 函数后，数据被读入了用户空间，内核缓冲区就被清空，这意味着主机可以接收更多的报文，接收窗口就会变大。

因此，接收窗口并不是恒定不变的，那么怎么把时刻变化的窗口通知给发送方呢？TCP 报文头部中的窗口字段，就可以起到通知的作用。

当发送方从报文中得到接收方的窗口大小时，就明白了最多能发送多少字节的报文，这个数字被称为发送方的发送窗口。如果不考虑下一讲将要介绍的拥塞控制，发送方的发送窗口就是接收方的接收窗口（由于报文有传输时延，t1 时刻的接收窗口在 t2 时刻才能到达发送端，因此这两个窗口并不完全等价）。

从上图中可以看到，窗口字段只有 2 个字节，因此它最多能表达 216 即 65535 字节大小的窗口（之所以不是 65536，是因为窗口可以为 0，此时叫做窗口关闭，上一讲提到的关闭连接时让 FIN 报文发不出去，以致于服务器的连接都处于 FIN_WAIT1 状态，就是通过窗口关闭技术实现的），这在 RTT 为 10ms 的网络中也只能到达 6MB/s 的最大速度，在当今的高速网络中显然并不够用。

RFC1323 定义了扩充窗口的方法，但 Linux 中打开这一功能，需要把 tcp_window_scaling 配置设为 1，此时窗口的最大值可以达到 1GB（230）。

net.ipv4.tcp_window_scaling = 1

这样看来，只要进程能及时地调用 read 函数读取数据，并且接收缓冲区配置得足够大，那么接收窗口就可以无限地放大，发送方也就无限地提升发送速度。很显然，这是不可能的，因为网络的传输能力是有限的，当发送方依据发送窗口，发送超过网络处理能力的报文时，路由器会直接丢弃这些报文。因此，缓冲区的内存并不是越大越好。

带宽时延积如何确定最大传输速度？

缓冲区到底该设置为多大呢？我们知道，TCP 的传输速度，受制于发送窗口与接收窗口，以及网络传输能力。其中，两个窗口由缓冲区大小决定（进程调用 read 函数是否及时也会影响它）。如果缓冲区大小与网络传输能力匹配，那么缓冲区的利用率就达到了最大值。

怎样计算出网络传输能力呢？带宽描述了网络传输能力，但它不能直接使用，因为它与窗口或者说缓冲区的计量单位不同。带宽是单位时间内的流量 ，它表达的是速度，比如你家里的宽带 100MB/s，而窗口和缓冲区的单位是字节。当网络速度乘以时间才能得到字节数，差的这个时间，这就是网络时延。

当最大带宽是 100MB/s、网络时延是 10ms 时，这意味着客户端到服务器间的网络一共可以存放 100MB/s * 0.01s = 1MB 的字节。这个 1MB 是带宽与时延的乘积，所以它就叫做带宽时延积（缩写为 BDP，Bandwidth Delay Product）。这 1MB 字节存在于飞行中的 TCP 报文，它们就在网络线路、路由器等网络设备上。如果飞行报文超过了 1MB，就一定会让网络过载，最终导致丢包。

由于发送缓冲区决定了发送窗口的上限，而发送窗口又决定了已发送但未确认的飞行报文的上限，因此，发送缓冲区不能超过带宽时延积，因为超出的部分没有办法用于有效的网络传输，且飞行字节大于带宽时延积还会导致丢包；而且，缓冲区也不能小于带宽时延积，否则无法发挥出高速网络的价值。

怎样调整缓冲区去适配滑动窗口？

这么看来，我们只要把缓冲区设置为带宽时延积不就行了吗？比如，当我们做 socket 网络编程时，通过设置 socket 的 SO_SNDBUF 属性，就可以设定缓冲区的大小。

然而，这并不是个好主意，因为不是每一个请求都能够达到最大传输速度，比如请求的体积太小时，在慢启动（下一讲会谈到）的影响下，未达到最大速度时请求就处理完了。再比如网络本身也会有波动，未必可以一直保持最大速度。

因此，时刻让缓冲区保持最大，太过浪费内存了。

到底该如何设置缓冲区呢？

我们可以使用 Linux 的缓冲区动态调节功能解决上述问题。其中，缓冲区的调节范围是可以设置的。先来看发送缓冲区，它的范围通过 tcp_wmem 配置：

net.ipv4.tcp_wmem = 4096        16384   4194304

其中，第 1 个数值是动态范围的下限，第 3 个数值是动态范围的上限。而中间第 2 个数值，则是初始默认值。

发送缓冲区完全根据需求自行调整。比如，一旦发送出的数据被确认，而且没有新的数据要发送，就可以把发送缓冲区的内存释放掉。而接收缓冲区的调整就要复杂一些，先来看设置接收缓冲区范围的 tcp_rmem：

net.ipv4.tcp_rmem = 4096        87380   6291456

它的数值与 tcp_wmem 类似，第 1、3 个值是范围的下限和上限，第 2 个值是初始默认值。发送缓冲区自动调节的依据是待发送的数据，接收缓冲区由于只能被动地等待接收数据，它该如何自动调整呢？

可以依据空闲系统内存的数量来调节接收窗口。如果系统的空闲内存很多，就可以把缓冲区增大一些，这样传给对方的接收窗口也会变大，因而对方的发送速度就会通过增加飞行报文来提升。反之，内存紧张时就会缩小缓冲区，这虽然会减慢速度，但可以保证更多的并发连接正常工作。

发送缓冲区的调节功能是自动开启的，而接收缓冲区则需要配置 tcp_moderate_rcvbuf 为 1 来开启调节功能：

net.ipv4.tcp_moderate_rcvbuf = 1

接收缓冲区调节时，怎么判断空闲内存的多少呢？这是通过 tcp_mem 配置完成的：

net.ipv4.tcp_mem = 88560        118080  177120

tcp_mem 的 3 个值，是 Linux 判断系统内存是否紧张的依据。当 TCP 内存小于第 1 个值时，不需要进行自动调节；在第 1 和第 2 个值之间时，内核开始调节接收缓冲区的大小；大于第 3 个值时，内核不再为 TCP 分配新内存，此时新连接是无法建立的。

在高并发服务器中，为了兼顾网速与大量的并发连接，我们应当保证缓冲区的动态调整上限达到带宽时延积，而下限保持默认的 4K 不变即可。而对于内存紧张的服务而言，调低默认值是提高并发的有效手段。

同时，如果这是网络 IO 型服务器，那么，调大 tcp_mem 的上限可以让 TCP 连接使用更多的系统内存，这有利于提升并发能力。需要注意的是，tcp_wmem 和 tcp_rmem 的单位是字节，而 tcp_mem 的单位是页面大小。而且，千万不要在 socket 上直接设置 SO_SNDBUF 或者 SO_RCVBUF，这样会关闭缓冲区的动态调整功能。

小结

我们对这一讲的内容做个小结。

实现高并发服务时，由于必须把大部分内存用在网络传输上，所以除了关注应用内存的使用，还必须关注 TCP 内核缓冲区的内存使用情况。

TCP 使用 ACK 确认报文实现了可靠性，又依赖滑动窗口既提升了发送速度也兼顾了接收方的处理能力。然而，默认的滑动窗口最大只能到 65KB，要想提升发送速度必须提升滑动窗口的上限，在 Linux 下是通过设置 tcp_window_scaling 为 1 做到的。

滑动窗口定义了飞行报文的最大字节数，当它超过带宽时延积时，就会发生丢包。而当它小于带宽时延积时，就无法让 TCP 的传输速度达到网络允许的最大值。因此，滑动窗口的设计，必须参考带宽时延积。

内核缓冲区决定了滑动窗口的上限，但我们不能通过 socket 的 SO_SNFBUF 等选项直接把缓冲区大小设置为带宽时延积，因为 TCP 不会一直维持在最高速上，过大的缓冲区会减少并发连接数。Linux 带来的缓冲区自动调节功能非常有效，我们应当把缓冲区的上限设置为带宽时延积。其中，发送缓冲区的调节功能是自动打开的，而接收缓冲区需要把 tcp_moderate_rcvbuf 设置为 1 来开启，其中调节的依据根据 tcp_mem 而定。

这样高效地配置内存后，既能够最大程度地保持并发性，也能让资源充裕时连接传输速度达到最大值。这一讲我们谈了内核缓冲区对传输速度的影响，下一讲我们再来看如何调节发送速度以匹配不同的网络能力。

思考题

最后，请你观察下 Linux 系统下，连接建立时、发送接收数据时，buff/cache 内存的变动情况。用我们这一讲介绍的原理，解释系统内存的变化现象。欢迎你在留言区与我沟通互动。

感谢阅读，如果你觉得这节课对你有一些启发，也欢迎把它分享给你的朋友。

12 | 如何调整TCP拥塞控制的性能？

你好，我是陶辉。

上一讲我们谈到接收主机的处理能力不足时，是通过滑动窗口来减缓对方的发送速度。这一讲我们来看看，当网络处理能力不足时又该如何优化 TCP 的性能。

如果你阅读过 TCP 协议相关的书籍，一定看到过慢启动、拥塞控制等名词。这些概念似乎离应用开发者很远，然而，如果没有拥塞控制，整个网络将会锁死，所有消息都无法传输。

而且，如果你在开发分布式集群中的高并发服务，理解拥塞控制的工作原理，就可以在内核的 TCP 层，提升所有进程的网络性能。比如，你可能听过，2013 年谷歌把初始拥塞窗口从 3 个 MSS（最大报文长度）左右提升到 10 个 MSS，将 Web 站点的网络性能提升了 10% 以上，而有些高速 CDN 站点，甚至把初始拥塞窗口提升到 70 个 MSS。

特别是，近年来谷歌提出的 BBR 拥塞控制算法已经应用在高版本的 Linux 内核中，从它在 YouTube 上的应用可以看到，在高性能站点上网络时延有 20% 以上的降低，传输带宽也有提高。

Linux 允许我们调整拥塞控制算法，但是，正确地设置参数，还需要深入理解拥塞控制对 TCP 连接的影响。这一讲我们将沿着网络如何影响发送速度这条线，看看如何调整 Linux 下的拥塞控制参数。

慢启动阶段如何调整初始拥塞窗口？

上一讲谈到，只要接收方的读缓冲区足够大，就可以通过报文中的接收窗口，要求对方更快地发送数据。然而，网络的传输速度是有限的，它会直接丢弃超过其处理能力的报文。而发送方只有在重传定时器超时后，才能发现超发的报文被网络丢弃了，发送速度提不上去。更为糟糕的是，如果网络中的每个连接都按照接收窗口尽可能地发送更多的报文时，就会形成恶性循环，最终超高的网络丢包率会使得每个连接都无法发送数据。

解决这一问题的方案叫做拥塞控制，它包括 4 个阶段，我们首先来看 TCP 连接刚建立时的慢启动阶段。由于 TCP 连接会穿越许多网络，所以最初并不知道网络的传输能力，为了避免发送超过网络负载的报文，TCP 只能先调低发送窗口（关于发送窗口，你可以参考[第 11 讲]），减少飞行中的报文来让发送速度变慢，这也是“慢启动”名字的由来。

让发送速度变慢是通过引入拥塞窗口（全称为 congestion window，缩写为 CWnd，类似地，接收窗口叫做 rwnd，发送窗口叫做 swnd）实现的，它用于避免网络出现拥塞。上一讲我们说过，如果不考虑网络拥塞，发送窗口就等于对方的接收窗口，而考虑了网络拥塞后，发送窗口则应当是拥塞窗口与对方接收窗口的最小值：

swnd = min(cwnd, rwnd)

这样，发送速度就综合考虑了接收方和网络的处理能力。

虽然窗口的计量单位是字节，但为了方便理解，通常我们用 MSS 作为描述窗口大小的单位，其中 MSS 是 TCP 报文的最大长度。

如果初始拥塞窗口只有 1 个 MSS，当 MSS 是 1KB，而 RTT 时延是 100ms 时，发送速度只有 10KB/s。所以，当没有发生拥塞时，拥塞窗口必须快速扩大，才能提高互联网的传输速度。因此，慢启动阶段会以指数级扩大拥塞窗口（扩大规则是这样的：发送方每收到一个 ACK 确认报文，拥塞窗口就增加 1 个 MSS），比如最初的初始拥塞窗口（也称为 initcwnd）是 1 个 MSS，经过 4 个 RTT 就会变成 16 个 MSS。

虽然指数级提升发送速度很快，但互联网中的很多资源体积并不大，多数场景下，在传输速度没有达到最大时，资源就已经下载完了。下图是 2010 年 Google 对 Web 对象大小的 CDF 累积分布统计，大多数对象在 10KB 左右。

图片来源：《An Argument for Increasing TCP's Initial Contestion Window》

这样，当 MSS 是 1KB 时，多数 HTTP 请求至少包含 10 个报文，即使以指数级增加拥塞窗口，也需要至少 4 个 RTT 才能传输完，参见下图：

因此，2013 年 TCP 的初始拥塞窗口调整到了 10 个 MSS（参见RFC6928），这样 1 个 RTT 内就可以传输 10KB 的请求。然而，如果你需要传输的对象体积更大，BDP 带宽时延积很大时，完全可以继续提高初始拥塞窗口的大小。下图是 2014 年、2017 年全球主要 CDN 厂商初始拥塞窗口的变化，可见，随着网速的增加，初始拥塞窗口也变得更大了。

图片来源：https://blog.imaginea.com/look-at-tcp-initcwnd-cdns/

因此，你可以根据网络状况和传输对象的大小，调整初始拥塞窗口的大小。调整前，先要清楚你的服务器现在的初始拥塞窗口是多大。你可以通过 ss 命令查看当前拥塞窗口：

# ss -nli|fgrep cwnd
         cubic rto:1000 mss:536 cwnd:10 segs_in:10621866 lastsnd:1716864402 lastrcv:1716864402 lastack:1716864402

再通过 ip route change 命令修改初始拥塞窗口：

# ip route | while read r; do
           ip route change $r initcwnd 10;
       done

当然，更大的初始拥塞窗口以及指数级的提速，连接很快就会遭遇网络拥塞，从而导致慢启动阶段的结束。

出现网络拥塞时该怎么办？

以下 3 种场景都会导致慢启动阶段结束：

通过定时器明确探测到了丢包；

拥塞窗口的增长到达了慢启动阈值 ssthresh（全称为 slow start threshold），也就是之前发现网络拥塞时的窗口大小；

接收到重复的 ACK 报文，可能存在丢包。

我们先来看第 1 种场景，在规定时间内没有收到 ACK 报文，这说明报文丢失了，网络出现了严重的拥塞，必须先降低发送速度，再进入拥塞避免阶段。不同的拥塞控制算法降低速度的幅度并不相同，比如 CUBIC 算法会把拥塞窗口降为原先的 0.8 倍（也就是发送速度降到 0.8 倍）。此时，我们知道了多大的窗口会导致拥塞，因此可以把慢启动阈值设为发生拥塞前的窗口大小。

再看第 2 种场景，虽然还没有发生丢包，但发送方已经达到了曾经发生网络拥塞的速度（拥塞窗口达到了慢启动阈值），接下来发生拥塞的概率很高，所以进入拥塞避免阶段，此时拥塞窗口不能再以指数方式增长，而是要以线性方式增长。接下来，拥塞窗口会以每个 RTT 增加 1 个 MSS 的方式，代替慢启动阶段每收到 1 个 ACK 就增加 1 个 MSS 的方式。这里可能有同学会有疑问，在第 1 种场景发生前，慢启动阈值是多大呢？事实上，RFC5681 建议最初的慢启动阈值尽可能的大，这样才能在第 1、3 种场景里快速发现网络瓶颈。

第 3 种场景最为复杂。我们知道，TCP 传输的是字节流，而“流”是天然有序的。因此，当接收方收到不连续的报文时，就可能发生报文丢失或者延迟，等待发送方超时重发太花时间了，为了缩短重发时间，快速重传算法便应运而生。

当连续收到 3 个重复 ACK 时，发送方便得到了网络发生拥塞的明确信号，通过重复 ACK 报文的序号，我们知道丢失了哪个报文，这样，不等待定时器的触发，立刻重发丢失的报文，可以让发送速度下降得慢一些，这就是快速重传算法。

出现拥塞后，发送方会缩小拥塞窗口，再进入前面提到的拥塞避免阶段，用线性速度慢慢增加拥塞窗口。然而，为了平滑地降低速度，发送方应当先进入快速恢复阶段，在失序报文到达接收方后，再进入拥塞避免阶段。

那什么是快速恢复呢？我们不妨把网络看成一个容器（上一讲中说过它可以容纳 BDP 字节的报文），每当接收方从网络中取出一个报文，发送方就可以增加一个报文。当发送方接收到重复 ACK 时，可以推断有失序报文离开了网络，到达了接收方的缓冲区，因此可以再多发送一个报文。如下图所示：

这里你要注意：第 6 个报文在慢启动阶段丢失，接收方收到失序的第 7 个报文会触发快速重传算法，它必须立刻返回 ACK6。而发送方接收到第 1 个重复 ACK6 报文时，就从慢启动进入了快速重传阶段，此刻的重复 ACK 不会扩大拥塞窗口。当连续收到 3 个 ACK6 时，发送方会重发报文 6，并把慢启动阈值和拥塞窗口都降到之前的一半：3 个 MSS，再进入快速恢复阶段。按照规则，由于收到 3 个重复 ACK，所以拥塞窗口会增加 3 个 MSS。之后收到的 2 个 ACK，让拥塞窗口增加到了 8 个 MSS，直到收到期待的 ACK12，发送方才会进入拥塞避免阶段。

慢启动、拥塞避免、快速重传、快速恢复，共同构成了拥塞控制算法。Linux 上提供了更改拥塞控制算法的配置，你可以通过 tcp_available_congestion_control 配置查看内核支持的算法列表：

net.ipv4.tcp_available_congestion_control = cubic reno

再通过 tcp_congestion_control 配置选择一个具体的拥塞控制算法：

net.ipv4.tcp_congestion_control = cubic

但有件事你得清楚，拥塞控制是控制网络流量的算法，主机间会互相影响，在生产环境更改之前必须经过完善的测试。

基于测量的拥塞控制算法

上文介绍的是传统拥塞控制算法，它是以丢包作为判断拥塞的依据。然而，网络刚出现拥塞时并不会丢包，而真的出现丢包时，拥塞已经非常严重了。如下图所示，像路由器这样的网络设备，都会有缓冲队列应对突发的、超越处理能力的流量：

当缓冲队列为空时，传输速度最快。一旦队列开始积压，每个报文的传输时间需要增加排队时间，网速就变慢了。而当队列溢出时，才会出现丢包，基于丢包的拥塞控制算法在这个时间点进入拥塞避免阶段，显然太晚了。因为升高的网络时延降低了用户体验，而且从丢包到重发这段时间，带宽也会出现下降。

进行拥塞控制的最佳时间点，是缓冲队列刚出现积压的时刻，此时，网络时延会增高，但带宽维持不变，这两个数值的变化可以给出明确的拥塞信号，如下图所示：

图片来源网络：传输速度_RTT与飞行报文的关系

这种以测量带宽、时延来确定拥塞的方法，在丢包率较高的网络中应用效果尤其好。2016 年 Google 推出的 BBR 算法（全称 Bottleneck Bandwidth and Round-trip propagation time），就是测量驱动的拥塞控制算法，它在 YouTube 站点上应用后使得网络时延下降了 20% 以上，传输带宽也有 5% 左右的提升。

当然，测量驱动的拥塞算法并没有那么简单，因为网络会波动，线路也会变化，算法必须及时地响应网络变化，这里不再展开算法细节，你可以在我的这篇博客中找到 BBR 算法更详细的介绍。

Linux 4.9 版本之后都支持 BBR 算法，开启 BBR 算法仍然使用 tcp_congestion_control 配置：

net.ipv4.tcp_congestion_control=bbr

小结

我们对这一讲的内容做个小结。

当 TCP 连接建立成功后，拥塞控制算法就会发生作用，首先进入慢启动阶段。决定连接此时网速的是初始拥塞窗口，Linux 上可以通过 route ip change 命令修改它。通常，在带宽时延积较大的网络中，应当调高初始拥塞窗口。

丢包以及重复的 ACK 都是明确的拥塞信号，此时，发送方就会调低拥塞窗口减速，同时修正慢启动阈值。这样，将来再次到达这个速度时，就会自动进入拥塞避免阶段，用线性速度代替慢启动阶段的指数速度提升窗口大小。

当然，重复 ACK 意味着发送方可以提前重发丢失报文，快速重传算法定义了这一行为。同时，为了使得重发报文的过程中，发送速度不至于出现断崖式下降，TCP 又定义了快速恢复算法，发送方在报文重新变得有序后，结束快速恢复进入拥塞避免阶段。

但以丢包作为网络拥塞的信号往往为时已晚，于是以 BBR 算法为代表的测量型拥塞控制算法应运而生。当飞行中报文数量不变，而网络时延升高时，就说明网络中的缓冲队列出现了积压，这是进行拥塞控制的最好时机。Linux 高版本支持 BBR 算法，你可以通过 tcp_congestion_control 配置更改拥塞控制算法。

思考题

最后，请你思考下，快速恢复阶段的拥塞窗口，在报文变得有序后反而会缩小，这是为什么？欢迎你在留言区与大家一起探讨。

感谢阅读，如果你觉得这节课对你有一些启发，也欢迎把它分享给你的朋友。

13 | 实战：单机如何实现管理百万主机的心跳服务？

你好，我是陶辉。

这一讲我们将结合前 12 讲，以一个可管理百万主机集群的心跳服务作为实战案例，看看所有高性能服务的设计思路。

首先解释下什么是心跳服务。集群中的主机如果宕机，那么管理服务必须及时发现，并做相应的容灾处理，比如将宕机主机的业务迁移到新的虚拟机上等等。怎么做到及时发现呢？可以要求每台主机定时上报心跳包，考虑到网络报文的延迟，如果管理服务在几个上报周期内未收到心跳，则认为主机宕机。当新主机加入集群后，心跳服务也可以及时识别并告知管理服务。

这就是心跳服务要解决的核心问题，虽然很简单，可是如果集群规模达到百万台虚拟机或者微服务进程，这就不再简单了。多核 CPU、内存使用效率、网络带宽时延积等都必须纳入你的考虑，因为此时心跳包占用的网络带宽已经接近网卡上限，仅调动一颗 CPU 的计算力去处理就会大量丢包，百万级的对象、网络连接也很容易造成内存 OOM。甚至判断宕机的算法都要重新设计，降低时间复杂度后才能够应对超大集群的心跳管理。

解决这种集群规模下的性能问题，需要深入掌握底层基础知识，用系统化的全局思维去解决问题，这也是程序员薪资差异的重要分水岭。接下来，我们先实现更高效的核心算法，再设计高并发服务的架构，最后再来看传输层协议的选择。

如何设计更快的宕机判断算法？

通过心跳包找到宕机的主机需要一套算法，比如用 for 循环做一次遍历，找到停止上报心跳的主机就可以实现，然而正如[第 3 讲] 所说，当管理的对象数量级很大时，算法复杂度会严重影响程序性能，遍历算法此时并不可取。我们先分析下这个算法的时间复杂度。

如果用红黑树（这里用红黑树是因为它既支持遍历，也可以实现对数时间复杂度的查询操作）存放主机及其最近一次上报时间，那么，新主机上报心跳被发现的流程，时间复杂度仅为 O(logN)，这是查询红黑树的成本。寻找宕机服务的流程，需要对红黑树做全量遍历，用当前时间去比较每个主机的上次心跳时间，时间复杂度就是 O(N)！

如果业务对时间灵敏度要求很高，就意味着需要频繁地执行 O(N) 级的遍历，当 N 也就是主机数量很大时，耗时就很可观了。而且寻找宕机服务和接收心跳包是两个流程，如果它们都在单线程中执行，那么寻找宕机服务的那段时间就不能接收心跳包，会导致丢包！如果使用多线程并发执行，因为两个流程都需要操作红黑树，所以要使用到互斥锁，而当这两个流程争抢锁的频率很高时，性能也会急剧下降。

其实这个算法的根本问题在于，判断宕机的流程做了大量的重复工作。比如，主机每隔 1 秒上报一次心跳，而考虑到网络可能丢包，故 5 秒内失去心跳就认为宕机，这种情况下，如果主机 A 在第 10 秒时失去心跳，那么第 11、12、13、14 这 4 秒对主机 A 的遍历，都是多余的，只有第 15 秒对主机 A 的遍历才有意义。于是，每次遍历平均浪费了 4/5 的计算量。

如何设计快速的宕机判断算法呢？其实，这是一个从一堆主机中寻找宕机服务的信息题。根据香农的理论，引入更多的信息，才能减少不确定性降低信息熵，从而减少计算量。就像心跳包间是有时间顺序的，上面的宕机判断算法显然忽略了接收到它们的顺序。比如主机 A 的上次心跳包距现在 4 秒了，而主机 B 距现在只有 1 秒，显然不应同等对待。

于是，我们引入存放心跳包的先入先出队列，这就保存了心跳包的时序关系。新的心跳包进入队列尾部，而老的心跳包则从队列首部退出，这样，寻找宕机服务时，只要看队列首部最老的心跳包，距现在是否超过 5 秒，如果超过 5 秒就认定宕机，同时把它取出队列，否则证明队列中不存在宕机服务，维持队列不变。

当然，这里并没有解决如何发现新主机的问题。我们还需要一个能够执行高效查询的容器，存放所有主机及其状态。红黑树虽然不慢，但我们不再需要遍历容器，所以可以选择更快的、查询时间复杂度为 O(1) 的哈希表存放主机信息（非标哈希表的实现参见[第 3 讲]）。如下图所示。

当然，队列中的心跳包并不是只能从队首删除，否则判断宕机流程的时间复杂度仍然是 O(N)。实际上，每当收到心跳包时，如果对应主机的上一个心跳包还在队列中，那么可以直接把它从队列中删除。显然，计算在线主机何时宕机，只需要最新的心跳包，老的心跳包没有必要存在。因此，这个队列为每个主机仅保留最新的那个心跳包。如下图所示：

这样，判断宕机的速度会非常快，它的计算量等于实际发生宕机的主机数量。同时，接收心跳包并发现新主机的流程，因为只需要做一次哈希表查询，时间复杂度也只有 O(1)。

这样，新算法通过以空间换时间的思想，虽然使用了更加占用空间的哈希表，并新增了有序队列容器，但将宕机和新主机发现这两个流程都优化到了常量级的时间复杂度。尤其是宕机流程的计算量非常小，它仅与实际宕机服务的数量有关，这就允许我们将宕机判断流程插入到心跳包的处理流程中，以微观上的分时任务实现宏观上的并发，同时也避免了对哈希表的加锁。

如何设计高并发架构？

有了核心算法，还需要充分利用服务器资源的架构，才能实现高并发。

一颗 1GHZ 主频的 CPU，意味着一秒钟只有 10 亿个时钟周期可以工作，如果心跳服务每秒接收到 100 万心跳包，就要求它必须在 1000 个时钟周期内处理完一个心跳包。这无法做到，因为每一个汇编指令的执行需要多个时钟周期（参见CPI），一条高级语言的语句又由多条汇编指令构成，而中间件提供的反序列化等函数又需要很多条语句才能完成。另外，内核从网卡上读取报文，执行协议分析需要的时钟周期也要算到这 1000 个时钟周期里。

因此，选择只用一颗 CPU 为核心的单线程开发模式，一定会出现计算力不足，不能及时接收报文从而使得缓冲区溢出的问题，最终导致大量丢包。所以，我们必须选择多线程或者多进程开发模式。多进程之间干扰更小，但内存不是共享的，数据同步较为困难，因此案例中我们还是选择多线程开发模式。

使用多线程后我们需要解决 3 个问题。

第一是负载均衡，我们应当把心跳包尽量均匀分配到不同的工作线程上处理。比如，接收网络报文的线程基于主机名或者 IP 地址，用哈希算法将心跳包分发给工作线程处理，这样每个工作线程只处理特定主机的心跳，相互间不会互相干扰，从而可以无锁编程。

第二是多线程同步。分发线程与工作线程间可以采用生产者 - 消费者模型传递心跳包，然而多线程间传递数据要加锁，为了减少争抢锁对系统资源的消耗，需要做到以下两点：

由于工作线程多过分发线程（接收心跳包消耗的资源更少），所以每个工作线程都配发独立的缓冲队列及操作队列的互斥锁；

为避免线程执行主动切换，必须使用自旋锁，关于锁的选择你可以看[第 6 讲]。如下图所示：

第三要解决 CPU 亲和性问题。从[第 1 讲] 我们可以看到，CPU 缓存对计算速度的影响很大，如果线程频繁地切换 CPU 会导致缓存命中率下降，降低性能，此时将线程绑定到特定的 CPU 就是一个解决方案（NUMA 架构也会对 CPU 亲和性产生影响，这里略过）。

这样，通过上述的多线程架构就可以有效地使用 CPU。当然，除了 CPU，内存的使用效率也很重要。[第２讲] 中我们提到，TCMalloc 相比 Linux 默认的 PtMalloc2 内存池，在多线程下分配小块内存的速度要快得多，所以对于心跳服务应当改用 TCMalloc 申请内存。而且，如果心跳包对象的格式已经固定，你还可以建立一个心跳包资源池，循环往复的使用，这进一步减少了构造、销毁心跳包对象所消耗的计算力。

由于服务重启后一个心跳周期内就可以获得所有心跳包，所以并不需要将数据持久化到磁盘上。如果你想进一步了解磁盘优化，可以再看下[第 4 讲]。

如何选择心跳包网络协议？

最后我们再来看看心跳包的协议该选择 TCP 还是 UDP 实现。

网络报文的长度是受限的，MTU（Maximum Transmission Unit）定义了最大值。比如以太网中 MTU 是 1500 字节，如果 TCP 或者 UDP 试图传送大于 1500 字节的报文，IP 协议就会把报文拆分后再发到网络中，并在接收方组装回原来的报文。然而，IP 协议并不擅长做这件事，拆包组包的效率很低，因此 TCP 协议宁愿自己拆包（详见[第 11 讲]）。

所以，如果心跳包长度小于 MTU，那么 UDP 协议是最佳选择。如果心跳包长度大于 MTU，那么最好选择 TCP 协议，面对复杂的 TCP 协议，还需要解决以下问题。

首先，一台服务器到底能同时建立多少 TCP 连接？要回答这个问题，得先从 TCP 四元组谈起，它唯一确定一个 TCP 连接。TCP 四元组分别是 < 源 IP、目的 IP、源端口、目的端口 >，其中前两者在 IP 头部中，后两者在 TCP 头部中。

由于 IPv4 地址为 4 个字节（参见[第 7 讲]）、端口为 2 个字节，所以当服务器 IP 地址和监听端口固定时，并发连接数的上限则是 2(32+16)。

当然，这么高的并发连接需要很多条件，其中之一就是增加单个进程允许打开的最大句柄数（包括操作系统允许的最大句柄数 /proc/sys/fs/file-nr），因为 Linux 下每个连接都要用掉一个文件句柄。当然，作为客户端的主机如果想用足 216  端口，还得修改 ip_local_port_range 配置，扩大客户端的端口范围：

net.ipv4.ip_local_port_range = 32768    60999

其次，基于 TCP 协议实现百万级别的高并发，必须使用基于事件驱动的全异步开发模式（参见[第 8 讲]）。而且，TCP 协议的默认配置并没有考虑高并发场景，所以我们还得在以下 4 个方面优化 TCP 协议：

三次握手建立连接的过程需要优化，详见[第 9 讲]；

四次挥手关闭连接的过程也需要优化，详见[第 10 讲]；

依据网络带宽时延积重新设置 TCP 缓冲区，详见[第 11 讲]；

优化拥塞控制算法，详见[第 12 讲]。

最后还有一个问题需要我们考虑。网络中断时并没有任何信息通知服务器，此时该如何发现并清理服务器上的这些僵死连接呢？KeepAlive 机制允许服务器定时向客户端探测连接是否存活。其中，每隔 tcp_keepalive_time 秒执行一次探测。

net.ipv4.tcp_keepalive_time = 7200

每次探测的最大等待时间是 tcp_keepalive_intvl 秒。

net.ipv4.tcp_keepalive_intvl = 75

超时后，内核最多尝试 tcp_keepalive_probes 次，仍然没有反应就会及时关闭连接。

net.ipv4.tcp_keepalive_probes = 9

当然，如果在应用层通过心跳能及时清理僵死 TCP 连接，效果会更好。

从上述优化方案可见，TCP 协议的高并发优化方案还是比较复杂的，这也是享受 TCP 优势时我们必须要付出的代价。

小结

这一讲以我实践过的项目为案例，介绍了高并发服务的设计思路。

核心算法对性能的影响最大，为了设计出高效的算法，我们必须分析出时间复杂度，充分寻找、利用已知信息，减少算法的计算量。在心跳服务这个案例中，利用好心跳包的时序，就可以把计算宕机的时间复杂度从 O(N) 降为 O(1)。

有了好的算法，还需要好的架构，才能高效地调动系统资源。当摩尔定律在 CPU 频率上失效后，CPU 都在向多核发展，所以高性能必须充分使用多核的计算力。此时，我们需要谨慎设计多线程间的负载均衡和数据同步，尽量减少访问共享资源带来的损耗。选择与业务场景匹配的内存池也很重要，对于 RPS 上百万的服务来说，申请内存的时间不再是一个忽略项。

选择网络协议时，如果消息长度大于 MTU，那么选择 TCP 更有利，但 TCP 解决了流控、可靠性等很多问题，优化起来较为困难。对于不要求可靠传输，长度通常不大的心跳包来说，UDP 协议通常是更好的选择。

思考题

最后，还是留给你点思考题。你遇到过心跳服务吗？它是怎么设计的？还有哪些优化空间？欢迎你在留言区与我探讨。

感谢阅读，如果你觉得这节课对你有一些启发，也欢迎把它分享给你的朋友。

14 | 优化TLS/SSL性能该从何下手？

你好，我是陶辉。

从这一讲开始，我们进入应用层协议的处理。

信息安全在当下越来越重要，绝大多数站点访问时都使用 https:// 替代了 http://，这就是在用 TLS/SSL 协议（下文简称为 TLS 协议）来保障应用层消息的安全。但另一方面，你会发现很多图片类门户网站，还在使用 http://，这是因为 TLS 协议在对信息加解密的同时，必然会降低性能和用户体验，这些站点在权衡后选择了性能优先。

实际上，TLS 协议由一系列加密算法及规范组成，这些算法的安全性和性能各不相同，甚至与你的系统硬件相关。比如当主机的 CPU 支持 AES-NI 指令集时，选择 AES 对称加密算法便可以大幅提升性能。然而，要想选择合适的算法，需要了解算法所用到的一些数学知识，而很多同学由于忽视了数学原理便难以正确地配置 TLS 算法。

同时，TLS 协议优化时也需要了解网络和软件工程知识，比如我们可以在网络的不同位置缓存密钥来优化性能。而且，TLS 协议还可以优化其他应用层协议的性能，比如从 HTTP/1 升级到 HTTP/2 协议便可以通过 TLS 协议减少 1 个 RTT 的时间。

优化 TLS 性能究竟该从何下手呢？在我看来主要有两个方向，一是对称加密算法的性能优化，二是如何高效地协商密钥。下面我们来详细看看优化细节。

如何提升对称加密算法的性能？

如果你用 Wireshark 等工具对 HTTPS 请求抓包分析，会发现在 TCP 传输层之上的消息全是乱码，这是因为 TCP 之上的 TLS 层，把 HTTP 请求用对称加密算法重新进行了编码。当然，用 Chrome 浏览器配合 Wireshark 可以解密消息，帮助你分析 TLS 协议的细节（具体操作方法可参考《Web 协议详解与抓包实战》第 51 课）。

现代对称加密算法的特点是，即使把加密流程向全社会公开，攻击者也从公网上截获到密文，但只要他没有拿到密钥，就无法从密文中反推出原始明文。如何同步密钥我们稍后在谈，先来看如何优化对称加密算法。

目前主流的对称加密算法叫做 AES（Advanced Encryption Standard），它在性能和安全上表现都很优秀。而且，它不只在访问网站时最为常用，甚至你日常使用的 WINRAR 等压缩软件也在使用 AES 算法（见官方 FAQ）。因此，AES 是我们的首选对称加密算法，下面来看看 AES 算法该如何优化。

AES 只支持 3 种不同的密钥长度，分别是 128 位、192 位和 256 位，它们的安全性依次升高，运算时间也更长。比如，当密钥为 128 比特位时，需要经过十轮操作，其中每轮要用移位法、替换法、异或操作等对明文做 4 次变换。而当密钥是 192 位时，则要经过 12 轮操作，密钥为 256 比特位时，则要经过 14 轮操作，如下图所示。

AES128的10轮加密流程
此图由Ahmed Ghanim Wadday上传于www.researchgate.net

密钥越长，虽然性能略有下降，但安全性提升很多。比如早先的 DES 算法只有 56 位密钥，在 1999 年便被破解。在 TLS1.2 及更早的版本中，仍然允许通讯双方使用 DES 算法，这是非常不安全的行为，你应该在服务器上限制 DES 算法套件的使用（Nginx 上限制加密套件的方法，参见《Nginx 核心知识 100 讲》第 96 课 和第 131 课）。也正因为密钥长度对安全性的巨大影响，美国政府才不允许出口 256 位密钥的 AES 算法。

只有数百比特的密钥，到底该如何对任意长度的明文加密呢？主流对称算法会将原始明文分成等长的多组明文，再分别用密钥生成密文，最后把它们拼接在一起形成最终密文。而 AES 算法是按照 128 比特（16 字节）对明文进行分组的（最后一组不足 128 位时会填充 0 或者随机数）。为了防止分组后密文出现明显的规律，造成攻击者容易根据概率破解出原文，我们就需要对每组的密钥做一些变换，这种分组后变换密钥的算法就叫做分组密码工作模式（下文简称为分组模式），它是影响 AES 性能的另一个因素。

优秀的分组密码工作模式
更难以从密文中发现规律，图参见wiki

比如，CBC 分组模式中，只有第 1 组明文加密完成后，才能对第 2 组加密，因为第 2 组加密时会用到第 1 组生成的密文。因此，CBC 必然无法并行计算。在材料科学出现瓶颈、单核频率不再提升的当下，CPU 都在向多核方向发展，而 CBC 分组模式无法使用多核的并行计算能力，性能受到很大影响。所以，通常我们应选择可以并行计算的 GCM 分组模式，这也是当下互联网中最常见的 AES 分组算法。

由于 AES 算法中的替换法、行移位等流程对 CPU 指令并不友好，所以 Intel 在 2008 年推出了支持AES-NI 指令集的 CPU，能够将 AES 算法的执行速度从每字节消耗 28 个时钟周期（参见这里），降低至 3.5 个时钟周期（参见这里）。在 Linux 上你可以用下面这行命令查看 CPU 是否支持 AES-NI 指令集：

# sort -u /proc/crypto | grep module |grep aes
module       : aesni_intel

因此，如果 CPU 支持 AES-NI 特性，那么应选择 AES 算法，否则可以选择CHACHA20 对称加密算法，它主要使用 ARX 操作（add-rotate-xor），CPU 执行起来更快。

说完对称加密算法的优化，我们再来看加密时的密钥是如何传递的。

如何更快地协商出密钥？

无论对称加密算法有多么安全，一旦密钥被泄露，信息安全就是一纸空谈。所以，TLS 建立会话的第 1 个步骤是在握手阶段协商出密钥。

早期解决密钥传递的是RSA 密钥协商算法。当你部署 TLS 证书到服务器上时，证书文件中包含一对公私钥（参见非对称加密），其中，公钥会在握手阶段传递给客户端。在 RSA 密钥协商算法中，客户端会生成随机密钥（事实上是生成密钥的种子参数），并使用服务器的公钥加密后再传给服务器。根据非对称加密算法，公钥加密的消息仅能通过私钥解密，这样服务器解密后，双方就得到了相同的密钥，再用它加密应用消息。

RSA 密钥协商算法的最大问题是不支持前向保密（Forward Secrecy），一旦服务器的私钥泄露，过去被攻击者截获的所有 TLS 通讯密文都会被破解。解决前向保密的是DH（Diffie–Hellman）密钥协商算法。

我们简单看下 DH 算法的工作流程。通讯双方各自独立生成随机的数字作为私钥，而后依据公开的算法计算出各自的公钥，并通过未加密的 TLS 握手发给对方。接着，根据对方的公钥和自己的私钥，双方各自独立运算后能够获得相同的数字，这就可以作为后续对称加密时使用的密钥。即使攻击者截获到明文传递的公钥，查询到公开的 DH 计算公式后，在不知道私钥的情况下，也是无法计算出密钥的。这样，DH 算法就可以在握手阶段生成随机的新密钥，实现前向保密。

DH 算法的计算速度很慢，如上图所示，计算公钥以及最终的密钥时，需要做大量的乘法运算，而且为了保障安全性，这些数字的位数都很长。为了提升 DH 密钥交换算法的性能，诞生了当下广为使用的ECDH 密钥交换算法，ECDH 在 DH 算法的基础上利用ECC 椭圆曲线特性，可以用更少的计算量计算出公钥以及最终的密钥。

依据解析几何，椭圆曲线实际对应一个函数，而不同的曲线便有不同的函数表达式，目前不被任何已知专利覆盖的最快椭圆曲线是X25519 曲线，它的表达式是 y2 = x3 + 486662x2 + x。因此，当通讯双方协商使用 X25519 曲线用于 ECDH 算法时，只需要传递 X25519 这个字符串即可。在 Nginx 上，你可以使用 ssl_ecdh_curve 指令配置想使用的曲线：

ssl_ecdh_curve X25519:secp384r1;

选择密钥协商算法是通过 ssl_ciphers 指令完成的：

ssl_ciphers 'EECDH+ECDSA+AES128+SHA:RSA+AES128+SHA';

可见，ssl_ciphers 可以同时配置对称加密算法及密钥强度等信息。注意，当 ssl_prefer_server_ciphers 设置为 on 时，ssl_ciphers 指定的多个算法是有优先顺序的，我们应当把性能最快且最安全的算法放在最前面。

提升密钥协商速度的另一个思路，是减少密钥协商的次数，主要包括以下 3 种方式。

首先，最为简单有效的方式是在一个 TLS 会话中传输多组请求，对于 HTTP 协议而言就是使用长连接，在请求中加入 Connection: keep-alive 头部便可以做到。

其次，客户端与服务器在首次会话结束后缓存下 session 密钥，并用唯一的 session ID 作为标识。这样，下一次握手时，客户端只要把 session ID 传给服务器，且服务器在缓存中找到密钥后（为了提升安全性，缓存会定期失效），双方就可以加密通讯了。这种方式的问题在于，当 N 台服务器通过负载均衡提供 TLS 服务时，客户端命中上次访问过的服务器的概率只有 1/N，所以大概率它们还得再次协商密钥。

session ticket 方案可以解决上述问题，它把服务器缓存密钥，改为由服务器把密钥加密后作为 ticket 票据发给客户端，由客户端缓存密文。其中，集群中每台服务器对 session 加密的密钥必须相同，这样，客户端携带 ticket 密文访问任意一台服务器时，都能通过解密 ticket，获取到密钥。

当然，使用 session 缓存或者 session ticket 既没有前向安全性，应对重放攻击也更加困难。提升 TLS 握手性能的更好方式，是把 TLS 协议升级到 1.3 版本。

为什么应当尽快升级到 TLS1.3？

TLS1.3（参见RFC8446）对性能的最大提升，在于它把 TLS 握手时间从 2 个 RTT 降为 1 个 RTT。

在 TLS1.2 的握手中，先要通过 Client Hello 和 Server Hello 消息协商出后续使用的加密算法，再互相交换公钥并计算出最终密钥。TLS1.3 中把 Hello 消息和公钥交换合并为一步，这就减少了一半的握手时间，如下图所示：

TLS1.3相对TLS1.2，减少了1个RTT的握手时间
图片来自www.ssl2buy.com

那 TLS1.3 握手为什么只需要 1 个 RTT 就可以完成呢？因为 TLS1.3 支持的密钥协商算法大幅度减少了，这样，客户端尽可以把常用 DH 算法的公钥计算出来，并与协商加密算法的 HELLO 消息一起发送给服务器，服务器也作同样处理，这样仅用 1 个 RTT 就可以协商出密钥。

而且，TLS1.3 仅支持目前最安全的几个算法，比如 openssl 中仅支持下面 5 种安全套件：

TLS_AES_256_GCM_SHA384

TLS_CHACHA20_POLY1305_SHA256

TLS_AES_128_GCM_SHA256

TLS_AES_128_CCM_8_SHA256

TLS_AES_128_CCM_SHA256

相较起来，TLS1.2 支持各种古老的算法，中间人可以利用降级攻击，在握手阶段把加密算法替换为不安全的算法，从而轻松地破解密文。如前文提到过的 DES 算法，由于密钥位数只有 56 位，很容易破解。

因此，无论从性能还是安全角度上，你都应该尽快把 TLS 版本升级到 1.3。你可以用这个网址测试当前站点是否支持 TLS1.3。

如果不支持，还可以参见每日一课《TLS1.3 原理及在 Nginx 上的应用》，升级 Nginx 到 TLS1.3 版本。

小结

这一讲，我们介绍了 TLS 协议的优化方法。

应用消息是通过对称加密算法编码的，而目前 AES 还是最安全的对称加密算法。不同的分组模式也会影响 AES 算法的性能，而 GCM 模式能够充分利用多核 CPU 的并行计算能力，所以 AES_GCM 是我们的首选。当你的 CPU 支持 AES-NI 指令集时，AES 算法的执行会非常快，否则，可以考虑对 CPU 更友好的 CHACHA20 算法。

再来看对称加密算法的密钥是如何传递的，它决定着 TLS 系统的安全，也对 HTTP 小对象的传输速度有很大影响。DH 密钥协商算法速度并不快，因此目前主要使用基于椭圆曲线的 ECDH 密钥协商算法，其中，不被任何专利覆盖的 X25519 椭圆曲线速度最快。为了减少密钥协商次数，我们应当尽量通过长连接来复用会话。在 TLS1.2 及早期版本中，session 缓存和 session ticket 也能减少密钥协商时的计算量，但它们既没有前向安全性，也更难防御重放攻击，所以为了进一步提升性能，应当尽快升级到 TLS1.3。

TLS1.3 将握手时间从 2 个 RTT 降为 1 个 RTT，而且它限制了目前已经不再安全的算法，这样中间人就难以用降级攻击来破解密钥。

密码学的演进越来越快，加密与破解总是在道高一尺、魔高一丈的交替循环中发展，当下安全的算法未必在一年后仍然安全。而且，当量子计算机真正诞生后，它强大的并行计算能力可以轻松地暴力破解当下还算安全的算法。然而，这种划时代的新技术出现时总会有一个时间窗口，而在窗口内也会涌现出能够防御住量子破解的新算法。所以，我们应时常关注密码学的进展，更换更安全、性能也更优秀的新算法。

思考题

最后，留给你一道思考题，TLS 体系中还有许多性能优化点，比如在服务器上部署OSCP Stapling（用于更快地发现过期证书）也可以提升网站的访问性能，你还用过哪些方式优化 TLS 的性能呢？欢迎你在留言区与我探讨。

感谢阅读，如果你觉得这节课对你有一些启发，也欢迎把它分享给你的朋友。

15 | 如何提升HTTP/1.1性能？

你好，我是陶辉。

上一讲介绍了为应用层信息安全保驾护航的 TLS/SSL 协议，这一讲我们来看看最常用的应用层协议 HTTP/1.1 该如何优化。

由于门槛低、易监控、自表达等特点，HTTP/1.1 在互联网诞生之初就成为最广泛使用的应用层协议。然而它的性能却很差，最为人诟病的是 HTTP 头部的传输占用了大量带宽。由于 HTTP 头部使用 ASCII 编码方式，这造成它往往达到几 KB，而且滥用的 Cookie 头部进一步增大了体积。与此同时，REST 架构的无状态特性还要求每个请求都得重传 HTTP 头部，这就使消息的有效信息比重难以提高。

你可能听说过诸如缓存、长连接、图片拼接、资源压缩等优化 HTTP 协议性能的方式，这些优化方案有些从底层的传输层优化入手，有些从用户使用浏览器的体验入手，有些则从服务器资源的编码入手，五花八门，导致我们没有系统化地优化思路，往往在性能提升上难尽全功。

那么，如何全面地提升 HTTP/1.1 协议的性能呢？我认为在不升级协议的情况下，有 3 种优化思路：首先是通过缓存避免发送 HTTP 请求；其次，如果不得不发起请求，那么就得思考如何才能减少请求的个数；最后则是减少服务器响应的体积。

接下来，我们就沿着这 3 个思路看看具体的优化方法。

通过缓存避免发送 HTTP 请求

如果不走网络就能获得 HTTP 响应，这样性能肯定最高。HTTP 协议设计之初就考虑到了这一点，缓存能够让客户端在免于发送 HTTP 请求的情况下获得服务器的资源。

缓存到底是如何做到的呢？其实很简单，它从时间维度上做文章，把第 1 份请求及其响应保存在客户端的本地磁盘上，其中请求的 URL 作为关键字（部分 HTTP 头部也会加入关键字，例如确定服务器域名的 Host 头部），而响应就是值。这样，后续发起相同的请求时，就可以先在本地磁盘上通过关键字查找，如果找到了，就直接将缓存作为服务器响应使用。读取本地磁盘耗时不过几十毫秒，这远比慢了上百倍且不稳定的网络请求快得多。

你可能会问，服务器上的资源更新后，客户端并不知道，它若再使用过期的缓存就会出错，这该如何解决？因此，服务器会在发送响应时预估一个过期时间并在响应头部中告诉客户端，而客户端一旦发现缓存过期则重新发起网络请求。HTTP 协议控制缓存过期的头部非常多，而且通常这是在服务器端设置的，我会在本专栏的第 4 部分“分布式系统优化”结合服务器操作再来介绍，这里暂时略过。

当然，过期的缓存也仍然可以提升性能，如下图所示，当客户端发现缓存过期后，会取出缓存的摘要（摘要是从第 1 次请求的响应中拿到的），把它放在请求的 Etag 头部中再发给服务器。而服务器获取到请求后，会将本地资源的摘要与请求中的 Etag 相比较，如果不同，那么缓存没有价值，重新发送最新资源即可；如果摘要与 Etag 相同，那么仅返回不含有包体的 304 Not Modified 响应，告知客户端缓存仍然有效即可，这就省去传递可能高达千百兆的文件资源。

至于 Etag 摘要究竟是怎样生成的，各类 Web 服务器的处理方式不尽相同，比如 Nginx 会将文件大小和修改时间拼接为一个字符串作为文件摘要（详见《Nginx 核心知识 100 讲》第 97 课）。过期缓存在分布式系统中可以有效提升可用性，[第 25 课] 还会站在反向代理的角度介绍过期缓存的用法。

浏览器上的缓存只能为一个用户使用，故称为私有缓存。代理服务器上的缓存可以被许多用户使用，所以称为共享缓存。可见，共享缓存带来的性能收益被庞大的客户端群体放大了。你可以看到在下面的 REST 架构图中，表示缓存的 $ 符号（缓存的英文名称是 cache，由于它的发音与 cash 很像，所以许多英文文档中用美元符号来表示缓存）既存在于 User Agent 浏览器中，也存在于 Proxy 正向代理服务器和 Gateway 反向代理上。

图片来自网络

可见，缓存与互联网世界的网络效率密切相关，用好缓存是提升 HTTP 性能最重要的手段。

如何降低 HTTP 请求的次数？

如果不得不发起请求，就应该尽量减少 HTTP 请求的次数，这可以从减少重定向次数、合并请求、延迟发送请求等 3 个方面入手。

首先来看看什么是重定向请求，一个资源由于迁移、维护等原因从 url1 移至 url2 后，在客户端访问原先的 url1 时，服务器不能简单粗暴地返回错误，而是通过 302 响应码及 Location 头部，告诉客户端资源已经改到 url2 了，而客户端再用 url2 发起新的 HTTP 请求。

可见，重定向增加了请求的数量。尤其客户端在公网中时，由于公网速度慢、成本高、路径长、不稳定，而且为了信息安全性还要用 TLS 协议加密，这些都降低了网络性能。从上面的 REST 架构图可以看到，HTTP 请求会经过多个代理服务器，如果将重定向工作交由代理服务器完成，就能减少网络消耗，如下图所示：

更进一步，客户端还可以缓存重定向响应。RFC 规范定义了 5 个重定向响应码（如下表所示），其中客户端接收到 301 和 308 后都可以将重定向响应缓存至本地，之后客户端会自动用 url2 替代 url1 访问网络资源。

其次我们来看如何合并请求。当多个访问小文件的请求被合并为一个访问大文件的请求时，这样虽然传输的总资源体积未变，但减少请求就意味着减少了重复发送的 HTTP 头部，同时也减少了 TCP 连接的数量，因而省去了 TCP 握手和慢启动过程消耗的时间（参见第 12 课）。我们具体看几种合并请求的方式。

一些 WEB 页面往往含有几十、上百个小图片，用CSS Image Sprites 技术可以将它们合成一张大图片，而浏览器获得后可以根据 CSS 数据把它切割还原为多张小图片，这可以大幅减少网络消耗。

图片来自[黑染枫林的CSDN博客]

类似地，在服务器端用 webpack 等打包工具将 Javascript、CSS 等资源合并为大文件，也能起到同样的效果。

除此以外，还可以将多媒体资源用 base64 编码后，以 URL 的方式嵌入 HTML 文件中，以减少小请求的个数（参见RFC2397）。

用 Chrome 浏览器开发者工具的 Network 面板可以轻松地判断各站点是否使用了这种技术。关于 Network 面板的用法，我们在此就不赘述了（可参考《Web 协议详解与抓包实战》第 9 课，那里有详细的介绍）。

当然，这种合并请求的方式也会带来一个新问题，即，当其中一个资源发生变化后，客户端必须重新下载完整的大文件，这显然会带来额外的网络消耗。在落后的 HTTP/1.1 协议中，合并请求还算一个不错的解决方案，在下一讲将介绍的 HTTP/2 出现后，这种技术就没有用武之地了。

最后，我们还可以从浏览页面的体验角度上，减少 HTTP 请求的次数。比如，有些 HTML 页面上引用的资源，其实在当前页面上用不上，而是供后续页面使用的，这就可以使用懒加载（lazy loading） 技术延迟发起请求。

当不得不发起请求时，还可以从服务器的角度通过减少响应包体的体积来提升性能。

如何重新编码以减少响应的大小？

减少资源体积的唯一方式是对资源重新编码压缩，其中又分为无损压缩与有损压缩两种。

先来看无损压缩，这是指压缩后不会损失任何信息，可以完全恢复到压缩前的原样。因此，文本文件、二进制可执行文件都会使用这类压缩方法。

源代码也是文本文件，但它有自身的语法规则，所以可以依据语法先做一轮压缩。比如jQuery 是用 javascript 语言编写的库，而标准版的 jQuery.js 为了帮助程序员阅读含有许多空格、回车等符号，但机器解释执行时并不需要这些符号。因此，根据语法规则将这些多余的符号去除掉，就可以将 jQuery 文件的体积压缩到原先的三分之一。

接着可以基于信息熵原理进行通用的无损压缩，这需要对原文建立统计模型，将出现频率高的数据用较短的二进制比特序列表示，而将出现频率低的数据用较长的比特序列表示。我们最常见的 Huffman 算法就是一种执行速度较快的实践，在下一讲的 HTTP/2 协议中还会用到它。在上图中可以看到，最小版的 jQuery 文件经过 Huffman 等算法压缩后，体积还会再缩小三分之二。

支持无损压缩算法的客户端会在请求中通过 Accept-Encoding 头部明确地告诉服务器：

Accept-Encoding: gzip, deflate, br

而服务器也会在响应的头部中，告诉客户端包体中的资源使用了何种压缩算法（Nginx 开启资源压缩的方式参见《Nginx 核心知识 100 讲》第 131 课和第 134 课）：

content-encoding: gzip

虽然目前 gzip 使用最为广泛，但它的压缩效率以及执行速度其实都很一般，Google 于 2015 年推出的Brotli 算法在这两方面表现都更优秀（也就是上文中的 br），其对比数据如下：

图片来源：[https://quixdb.github.io/squash-benchmark/]

再来看有损压缩，它通过牺牲质量来提高压缩比，主要针对的是图片和音视频。HTTP 请求可以通过 Accept 头部中的 q 质量因子（参见RFC7231），告诉服务器期望的资源质量：

Accept: audio/*; q=0.2, audio/basic

先来看图片的压缩。目前压缩比较高的开源算法是 Google 在 2010 年推出的WebP 格式，你可以在这个页面看到它与 png 格式图片的对比图。对于大量使用图片的网站，使用它代替传统格式会有显著的性能提升。

动态的音视频压缩比要比表态的图片高很多！由于音视频数据有时序关系，且时间连续的帧之间变化很小，因此可以在静态的关键帧之后，使用增量数据表达后续的帧，因此在质量略有损失的情况下，音频体积可以压缩到原先的几十分之一，视频体积则可以压缩到几百分之一，比图片的压缩比要高得多。因此，对音视频做有损压缩，能够大幅提升网络传输的性能。

对响应资源做压缩不只用于 HTTP/1.1 协议，事实上它对任何信息传输场景都有效，消耗一些 CPU 计算力在事前或者事中做压缩，通常会给性能带来不错的提升。

小结

这一讲我们从三个方面介绍了 HTTP/1.1 协议的优化策略。

首先，客户端缓存响应，可以在有效期内避免发起 HTTP 请求。即使缓存过期后，如果服务器端资源未改变，仍然可以通过 304 响应避免发送包体资源。浏览器上的私有缓存、服务器上的共享缓存，都对 HTTP 协议的性能提升有很大意义。

其次是降低请求的数量，如将原本由客户端处理的重定向请求，移至代理服务器处理可以减少重定向请求的数量。或者从体验角度，使用懒加载技术延迟加载部分资源，也可以减少请求数量。再比如，将多个文件合并后再传输，能够少传许多 HTTP 头部，而且减少 TCP 连接数量后也省去握手和慢启动的消耗。当然合并文件的副作用是小文件的更新，会导致整个合并后的大文件重传。

最后可以通过压缩响应来降低传输的字节数，选择更优秀的压缩算法能够有效降低传输量，比如用 Brotli 无损压缩算法替换 gzip，或者用 WebP 格式替换 png 等格式图片等。

但其实在 HTTP/1.1 协议上做优化效果总是有限的，下一讲我们还将介绍在 URL、头部等高层语法上向前兼容的 HTTP/2 协议，它在性能上有大幅度提升，是如 gRPC 等应用层协议的基础。

思考题

除了我今天介绍的方法以外，使用 KeepAlive 长连接替换短连接也能提升性能，你还知道有哪些提升 HTTP/1.1 性能的方法吗？欢迎你在留言区与我一起探讨。

感谢阅读，如果你觉得这节课对你有一些启发，也欢迎把它分享给你的朋友。

16 | HTTP/2是怎样提升性能的？

你好，我是陶辉。

上一讲我们从多个角度优化 HTTP/1 的性能，但获得的收益都较为有限，而直接将其升级到兼容 HTTP/1 的 HTTP/2 协议，性能会获得非常大的提升。

HTTP/2 协议既降低了传输时延也提升了并发性，已经被主流站点广泛使用。多数 HTTP 头部都可以被压缩 90% 以上的体积，这节约了带宽也提升了用户体验，像 Google 的高性能协议 gRPC 也是基于 HTTP/2 协议实现的。

目前常用的 Web 中间件都已支持 HTTP/2 协议，然而如果你不清楚它的原理，对于 Nginx、Tomcat 等中间件新增的流、推送、消息优先级等 HTTP/2 配置项，你就不知是否需要调整。

同时，许多新协议都会参考 HTTP/2 优秀的设计，如果你不清楚 HTTP/2 的性能究竟高在哪里，也就很难对当下其他应用层协议触类旁通。而且，HTTP/2 协议也并不是毫无缺点，到 2020 年 3 月时它的替代协议HTTP/3 已经经历了27 个草案，推出在即。HTTP/3 的目标是优化传输层协议，它会保留 HTTP/2 协议在应用层上的优秀设计。如果你不懂 HTTP/2，也就很难学会未来的 HTTP/3 协议。

所以，这一讲我们就将介绍 HTTP/2 对 HTTP/1.1 协议都做了哪些改进，从消息的编码、传输等角度说清楚性能提升点，这样，你就能理解支持 HTTP/2 的中间件为什么会提供那些参数，以及如何权衡 HTTP/2 带来的收益与付出的升级成本。

静态表编码能节约多少带宽？

HTTP/1.1 协议最为人诟病的是 ASCII 头部编码效率太低，浪费了大量带宽。HTTP/2 使用了静态表、动态表两种编码技术（合称为 HPACK），极大地降低了 HTTP 头部的体积，搞清楚编码流程，你自然就会清楚服务器提供的 http2_max_requests 等配置参数的意义。

我们以一个具体的例子来观察编码流程。每一个 HTTP/1.1 请求都会有 Host 头部，它指示了站点的域名，比如：

Host: test.taohui.tech\r\n

算上冒号空格以及结尾的\r\n，它占用了 24 字节。使用静态表及 Huffman 编码，可以将它压缩为 13 字节，也就是节约了 46% 的带宽！这是如何做到的呢？

我用 Chrome 访问站点 test.taohui.tech，并用 Wireshark 工具抓包（关于如何用 Wireshark 抓 HTTP/2 协议的报文，如果你还不太清楚，可参见《Web 协议详解与抓包实战》第 51 课）后，下图高亮的头部就是第 1 个请求的 Host 头部，其中每 8 个蓝色的二进制位是 1 个字节，报文中用了 13 个字节表示 Host 头部。

HTTP/2 能够用 13 个字节编码原先的 24 个字节，是依赖下面这 3 个技术。

首先基于二进制编码，就不需要冒号、空格和\r\n 作为分隔符，转而用表示长度的 1 个字节来分隔即可。比如，上图中的 01000001 就表示 Host，而 10001011 及随后的 11 个字节表示域名。

其次，使用静态表来描述 Host 头部。什么是静态表呢？HTTP/2 将 61 个高频出现的头部，比如描述浏览器的 User-Agent、GET 或 POST 方法、返回的 200 SUCCESS 响应等，分别对应 1 个数字再构造出 1 张表，并写入 HTTP/2 客户端与服务器的代码中。由于它不会变化，所以也称为静态表。

这样收到 01000001 时，根据RFC7541 规范，前 2 位为 01 时，表示这是不包含 Value 的静态表头部：

再根据索引 000001 查到 authority 头部（Host 头部在 HTTP/2 协议中被改名为 authority）。紧跟的字节表示域名，其中首个比特位表示域名是否经过 Huffman 编码，而后 7 位表示了域名的长度。在本例中，10001011 表示域名共有 11 个字节（8+2+1=11），且使用了 Huffman 编码。

最后，使用静态 Huffman 编码，可以将 16 个字节的 test.taohui.tech 压缩为 11 个字节，这是怎么做到的呢？根据信息论，高频出现的信息用较短的编码表示后，可以压缩体积。因此，在统计互联网上传输的大量 HTTP 头部后，HTTP/2 依据统计频率将 ASCII 码重新编码为一张表，参见这里。test.taohui.tech 域名用到了 10 个字符，我把这 10 个字符的编码列在下表中。

这样，接收端在收到下面这串比特位（最后 3 位填 1 补位）后，通过查表（请注意每个字符的颜色与比特位是一一对应的）就可以快速解码为：

由于 8 位的 ASCII 码最小压缩为 5 位，所以静态 Huffman 的最大压缩比只有 5/8。关于 Huffman 编码是如何构造的，你可以参见每日一课《HTTP/2 能带来哪些性能提升？》。

动态表编码能节约多少带宽？

虽然静态表已经将 24 字节的 Host 头部压缩到 13 字节，但动态表可以将它压缩到仅 1 字节，这就能节省 96% 的带宽！那动态表是怎么做到的呢？

你可能注意到，当下许多页面含有上百个对象，而 REST 架构的无状态特性，要求下载每个对象时都得携带完整的 HTTP 头部。如果 HTTP/2 能在一个连接上传输所有对象，那么只要客户端与服务器按照同样的规则，对首次出现的 HTTP 头部用一个数字标识，随后再传输它时只传递数字即可，这就可以实现几十倍的压缩率。所有被缓存的头部及其标识数字会构成一张表，它与已经传输过的请求有关，是动态变化的，因此被称为动态表。

静态表有 61 项，所以动态表的索引会从 62 起步。比如下图中的报文中，访问 test.taohui.tech 的第 1 个请求有 13 个头部需要加入动态表。其中，Host: test.taohui.tech 被分配到的动态表索引是 74（索引号是倒着分配的）。

这样，后续请求使用到 Host 头部时，只需传输 1 个字节 11001010 即可。其中，首位 1 表示它在动态表中，而后 7 位 1001010 值为 64+8+2=74，指向服务器缓存的动态表第 74 项：

静态表、Huffman 编码、动态表共同完成了 HTTP/2 头部的编码，其中，前两者可以将体积压缩近一半，而后者可以将反复传输的头部压缩 95% 以上的体积！

那么，是否要让一条连接传输尽量多的请求呢？并不是这样。动态表会占用很多内存，影响进程的并发能力，所以服务器都会提供类似 http2_max_requests 这样的配置，限制一个连接上能够传输的请求数量，通过关闭 HTTP/2 连接来释放内存。因此，http2_max_requests 并不是越大越好，通常我们应当根据用户浏览页面时访问的对象数量来设定这个值。

如何并发传输请求？

HTTP/1.1 中的 KeepAlive 长连接虽然可以传输很多请求，但它的吞吐量很低，因为在发出请求等待响应的那段时间里，这个长连接不能做任何事！而 HTTP/2 通过 Stream 这一设计，允许请求并发传输。因此，HTTP/1.1 时代 Chrome 通过 6 个连接访问页面的速度，远远比不上 HTTP/2 单连接的速度，具体测试结果你可以参考这个页面。

为了理解 HTTP/2 的并发是怎样实现的，你需要了解 Stream、Message、Frame 这 3 个概念。HTTP 请求和响应都被称为 Message 消息，它由 HTTP 头部和包体构成，承载这二者的叫做 Frame 帧，它是 HTTP/2 中的最小实体。Frame 的长度是受限的，比如 Nginx 中默认限制为 8K（http2_chunk_size 配置），因此我们可以得出 2 个结论：HTTP 消息可以由多个 Frame 构成，以及 1 个 Frame 可以由多个 TCP 报文构成（TCP MSS 通常小于 1.5K）。

再来看 Stream 流，它与 HTTP/1.1 中的 TCP 连接非常相似，当 Stream 作为短连接时，传输完一个请求和响应后就会关闭；当它作为长连接存在时，多个请求之间必须串行传输。在 HTTP/2 连接上，理论上可以同时运行无数个 Stream，这就是 HTTP/2 的多路复用能力，它通过 Stream 实现了请求的并发传输。

图片来源：https://developers.google.com/web/fundamentals/performance/http2

虽然 RFC 规范并没有限制并发 Stream 的数量，但服务器通常都会作出限制，比如 Nginx 就默认限制并发 Stream 为 128 个（http2_max_concurrent_streams 配置），以防止并发 Stream 消耗过多的内存，影响了服务器处理其他连接的能力。

HTTP/2 的并发性能比 HTTP/1.1 通过 TCP 连接实现并发要高。这是因为，当 HTTP/2 实现 100 个并发 Stream 时，只经历 1 次 TCP 握手、1 次 TCP 慢启动以及 1 次 TLS 握手，但 100 个 TCP 连接会把上述 3 个过程都放大 100 倍！

HTTP/2 还可以为每个 Stream 配置 1 到 256 的权重，权重越高服务器就会为 Stream 分配更多的内存、流量，这样按照资源渲染的优先级为并发 Stream 设置权重后，就可以让用户获得更好的体验。而且，Stream 间还可以有依赖关系，比如若资源 A、B 依赖资源 C，那么设置传输 A、B 的 Stream 依赖传输 C 的 Stream 即可，如下图所示：

图片来源：https://developers.google.com/web/fundamentals/performance/http2

服务器如何主动推送资源？

HTTP/1.1 不支持服务器主动推送消息，因此当客户端需要获取通知时，只能通过定时器不断地拉取消息。HTTP/2 的消息推送结束了无效率的定时拉取，节约了大量带宽和服务器资源。

HTTP/2 的推送是这么实现的。首先，所有客户端发起的请求，必须使用单号 Stream 承载；其次，所有服务器进行的推送，必须使用双号 Stream 承载；最后，服务器推送消息时，会通过 PUSH_PROMISE 帧传输 HTTP 头部，并通过 Promised Stream ID 告知客户端，接下来会在哪个双号 Stream 中发送包体。

在 SDK 中调用相应的 API 即可推送消息，而在 Web 资源服务器中可以通过配置文件做简单的资源推送。比如在 Nginx 中，如果你希望客户端访问 /a.js 时，服务器直接推送 /b.js，那么可以这么配置：

location /a.js {
  http2_push /b.js;
}

服务器同样也会控制并发推送的 Stream 数量（如 http2_max_concurrent_pushes 配置），以减少动态表对内存的占用。

小结

这一讲我们介绍了 HTTP/2 的高性能是如何实现的。

静态表和 Huffman 编码可以将 HTTP 头部压缩近一半的体积，但这只是连接上第 1 个请求的压缩比。后续请求头部通过动态表可以压缩 90% 以上，这大大提升了编码效率。当然，动态表也会导致内存占用过大，影响服务器的总体并发能力，因此服务器会限制 HTTP/2 连接的使用时长。

HTTP/2 的另一个优势是实现了 Stream 并发，这节约了 TCP 和 TLS 协议的握手时间，并减少了 TCP 的慢启动阶段对流量的影响。同时，Stream 之间可以用 Weight 权重调节优先级，还可以直接设置 Stream 间的依赖关系，这样接收端就可以获得更优秀的体验。

HTTP/2 支持消息推送，从 HTTP/1.1 的拉模式到推模式，信息传输效率有了巨大的提升。HTTP/2 推消息时，会使用 PUSH_PROMISE 帧传输头部，并用双号的 Stream 来传递包体，了解这一点对定位复杂的网络问题很有帮助。

HTTP/2 的最大问题来自于它下层的 TCP 协议。由于 TCP 是字符流协议，在前 1 字符未到达时，后接收到的字符只能存放在内核的缓冲区里，即使它们是并发的 Stream，应用层的 HTTP/2 协议也无法收到失序的报文，这就叫做队头阻塞问题。解决方案是放弃 TCP 协议，转而使用 UDP 协议作为传输层协议，这就是 HTTP/3 协议的由来。

思考题

最后，留给你一道思考题。为什么 HTTP/2 要用静态 Huffman 查表法对字符串编码，基于连接上的历史数据统计信息做动态 Huffman 编码不是更有效率吗？欢迎你在留言区与我一起探讨。

感谢阅读，如果你觉得这节课对你有一些启发，也欢迎把它分享给你的朋友。

17 | Protobuf是如何进一步提高编码效率的？

你好，我是陶辉。

上一讲介绍的 HTTP/2 协议在编码上拥有非常高的空间利用率，这一讲我们看看，相比其中的 HPACK 编码技术，Protobuf 又是通过哪些新招式进一步提升编码效率的。

Google 在 2008 年推出的 Protobuf，是一个针对具体编程语言的编解码工具。它面向 Windows、Linux 等多种平台，也支持 Java、Python、Golang、C++、Javascript 等多种面向对象编程语言。使用 Protobuf 编码消息速度很快，消耗的 CPU 计算力也不多，而且编码后的字符流体积远远小于 JSON 等格式，能够大量节约昂贵的带宽，因此 gRPC 也把 Protobuf 作为底层的编解码协议。

然而，很多同学并不清楚 Protobuf 到底是怎样做到这一点的。这样，当你希望通过更换通讯协议这个高成本手段，提升整个分布式系统的性能时，面对可供选择的众多通讯协议，仅凭第三方的性能测试报告，你仍将难以作出抉择。

而且，面对分布式系统中的疑难杂症，往往需要通过分析抓取到的网络报文，确定到底是哪个组件出现了问题。可是由于 Protobuf 编码太过紧凑，即使对照着 Proto 消息格式文件，在不清楚编码逻辑时，你也很难解析出消息内容。

下面，我们将基于上一讲介绍过的 HPACK 编码技术，看看 Protobuf 是怎样进一步缩减编码体积的。

怎样用最少的空间编码字段名？

消息由多个名、值对组成，比如 HTTP 请求中，头部 Host: www.taohui.pub 就是一个名值对，其中，Host 是字段名称，而 www.taohui.pub 是字段值。我们先来看 Protobuf 如何编码字段名。

对于多达几十字节的 HTTP 头部，HTTP/2 静态表仅用一个数字来表示，其中，映射数字与字符串对应关系的表格，被写死在 HTTP/2 实现框架中。这样的编码效率非常高，但通用的 HTTP/2 框架只能将 61 个最常用的 HTTP 头部映射为数字，它能发挥出的作用很有限。

动态表可以让更多的 HTTP 头部编码为数字，在上一讲的例子中，动态表将 Host 头部减少了 96% 的体积，效果惊人。但动态表生效得有一个前提：必须在一个会话连接上反复传输完全相同的 HTTP 头部。如果消息字段在 1 个连接上只发送了 1 次，或者反复传输时字段总是略有变动，动态表就无能为力了。

有没有办法既使用静态表的预定义映射关系，又享受到动态表的灵活多变呢？其实只要把由 HTTP/2 框架实现的字段名映射关系，交由应用程序自行完成即可。而 Protobuf 就是这么做的。比如下面这段 39 字节的 JSON 消息，虽然一目了然，但字段名 name、id、sex 其实都是多余的，因为客户端与服务器的处理代码都清楚字段的含义。

{"name":"John","id":1234,"sex":"MALE"}

Protobuf 将这 3 个字段名预分配了 3 个数字，定义在 proto 文件中：

message Person {
  string name = 1;
  uint32 id = 2;

  enum SexType {
    MALE = 0;
    FEMALE = 1;
  }
  SexType sex = 3;
}

接着，通过 protoc 程序便可以针对不同平台、编程语言，将它生成编解码类，最后通过类中自动生成的 SerializeToString 方法将消息序列化，编码后的信息仅有 11 个字节。其中，报文与字段的对应关系我放在下面这张图中。

从图中可以看出，Protobuf 是按照字段名、值类型、字段值的顺序来编码的，由于编码极为紧凑，所以分析时必须基于二进制比特位进行。比如红色的 00001、00010、00011 等前 5 个比特位，就分别代表着 name、id、sex 字段。

图中字段值的编码方式我们后面再解释，这里想必大家会有疑问，如果只有 5 个比特位表示字段名的值，那不是限制消息最多只有 31 个（25 - 1）字段吗？当然不是，字段名的序号可以从 1 到 536870911（即 229 - 1），可是，多数消息不过只有几个字段，这意味着可以用很小的序号表示它们。因此，对于小于 16 的序号，Protobuf 仅有 5 个比特位表示，这样加上 3 位值类型，只需要 1 个字节表示字段名。对于大于 16 小于 2027 的序号，也只需要 2 个字节表示。

Protobuf 可以用 1 到 5 个字节来表示一个字段名，因此，每个字节的第 1 个比特位保留，它为 0 时表示这是字段名的最后一个字节。下表列出了几种典型序号的编码值（请把黑色的二进制位，从右至左排列，比如 2049 应为 000100000000001，即 2048+1）。

说完字段名，我们再来看字段值是如何编码的。

怎样高效地编码字段值？

Protobuf 对不同类型的值，采用 6 种不同的编码方式，如下表所示：

字符串用 Length-delimited 方式编码，顾名思义，在值长度后顺序添加 ASCII 字节码即可。比如上文例子中的 John，对应的 ASCII 码如下表所示：

这样，"John"需要 5 个字节进行编码，如下图所示（绿色表示长度，紫色表示 ASCII 码）：

这里需要注意，字符串长度的编码逻辑与字段名相同，当长度小于 128（27）时，1 个字节就可以表示长度。若长度从 128 到 16384（214），则需要 2 个字节，以此类推。

由于字符串编码时未做压缩，所以并不会节约空间，但胜在速度快。如果你的消息中含有大量字符串，那么使用 Huffman 等算法压缩后再编码效果更好。

我们再来看 id：1234 这个数字是如何编码的。其实 Protobuf 中所有数字的编码规则是一致的，字节中第 1 个比特位仅用于指示由哪些字节编码 1 个数字。例如图中的 1234，将由 14 个比特位 00010011010010 表示（1024+128+64+16+2，正好是 1234）。

由于消息中的大量数字都很小，这种编码方式可以带来很高的空间利用率！当然，如果你确定数字很大，这种编码方式不但不能节约空间，而且会导致原先 4 个字节的大整数需要用 5 个字节来表示时，你也可以使用 fixed32、fixed64 等类型定义数字。

Protobuf 还可以通过 enum 枚举类型压缩空间。回到第 1 幅图，sex: FEMALE 仅用 2 个字节就编码完成，正是枚举值 FEMALE 使用数字 1 表示所达到的效果。

而且，由于 Protobuf 定义了每个字段的默认值，因此，当消息使用字段的默认值时，Protobuf 编码时会略过该字段。以 sex: MALE 为例，由于 MALE=0 是 sex 的默认值，因此在第 2 幅示例图中，这 2 个字节都省去了。

另外，当使用 repeated 语法将多个数字组成列表时，还可以通过打包功能提升编码效率。比如下图中，对 numbers 字段添加 101、102、103、104 这 4 个值后，如果不使用打包功能，共需要 8 个字节编码，其中每个数字前都需要添加字段名。而使用打包功能后，仅用 6 个字节就能完成编码，显然列表越庞大，节约的空间越多。

在 Protobuf2 版本中，需要显式设置 [packed=True] 才能使用打包功能，而在 Protobuf3 版本中这是默认功能。

最后，从这里可以查看 Protobuf 的编解码性能测试报告，你能看到，在保持高空间利用率的前提下，Protobuf 仍然拥有飞快的速度！

小结

这一讲我们介绍了 Protobuf 的编码原理。

通过在 proto 文件中为每个字段预分配 1 个数字，编码时就省去了完整字段名占用的空间。而且，数字越小编码时用掉的空间也越小，实际网络中大量传输的是小数字，这带来了很高的空间利用率。Protobuf 的枚举类型也通过类似的原理，用数字代替字符串，可以节约许多空间。

对于字符串 Protobuf 没有做压缩，因此如果消息中的字符串比重很大时，建议你先压缩后再使用 Protobuf 编码。对于拥有默认值的字段，Protobuf 编码时会略过它。对于 repeated 列表，使用打包功能可以仅用 1 个字段前缀描述所有数值，它在列表较大时能带来可观的空间收益。

思考题

下一讲我将介绍 gRPC 协议，它结合了 HTTP/2 与 Protobuf 的优点，在应用层提供方便而高效的 RPC 远程调用协议。你也可以提前思考下，既然 Protobuf 的空间效率远甚过 HPACK 技术，为什么 gRPC 还要使用 HTTP/2 协议呢？

在 Protobuf 的性能测试报告中，C++ 语言还拥有 arenas 功能，你可以通过 option cc_enable_arenas = true 语句打开它。请结合[第 2 讲] 的内容，谈谈 arenas 为什么能提升消息的解码性能？欢迎你在留言区与我一起探讨。

感谢阅读，如果你觉得这节课对你有一些启发，也欢迎把它分享给你的朋友。

18 | 如何通过gRPC实现高效远程过程调用？

你好，我是陶辉。

这一讲我们将以一个实战案例，基于前两讲提到的 HTTP/2 和 ProtoBuf 协议，看看 gRPC 如何将结构化消息编码为网络报文。

直接操作网络协议编程，容易让业务开发过程陷入复杂的网络处理细节。RPC 框架以编程语言中的本地函数调用形式，向应用开发者提供网络访问能力，这既封装了消息的编解码，也通过线程模型封装了多路复用，对业务开发很友好。

其中，Google 推出的 gRPC 是性能最好的 RPC 框架之一，它支持 Java、Javascript、Python、GoLang、C++、Object-C、Android、Ruby 等多种编程语言，还支持安全验证等特性，得到了广泛的应用，比如微服务中的 Envoy、分布式机器学习中的 Tensorflow，甚至华为去年推出重构互联网的 New IP 技术，都使用了 gRPC 框架。

然而，网络上教你使用 gRPC 框架的教程很多，却很少去谈 gRPC 是如何编码消息的。这样，一旦在大型分布式系统中出现疑难杂症，需要通过网络报文去定位问题发生在哪个系统、主机、进程中时，你就会毫无头绪。即使我们掌握了 HTTP/2 和 Protobuf 协议，但若不清楚 gRPC 的编码规则，还是无法分析抓取到的 gRPC 报文。而且，gRPC 支持单向、双向的流式 RPC 调用，编程相对复杂一些，定位流式 RPC 调用引发的 bug 时，更需要我们掌握 gRPC 的编码原理。

这一讲，我就将以 gRPC 官方提供的 example：data_transmisstion 为例，介绍 gRPC 的编码流程。在这一过程中，会顺带回顾 HTTP/2 和 Protobuf 协议，加深你对它们的理解。虽然这个示例使用的是 Python 语言，但基于 gRPC 框架，你可以轻松地将它们转换为其他编程语言。

如何使用 gRPC 框架实现远程调用？

我们先来简单地看下 gRPC 框架到底是什么。RPC 的全称是 Remote Procedure Call，即远程过程调用，它通过本地函数调用，封装了跨网络、跨平台、跨语言的服务访问，大大简化了应用层编程。其中，函数的入参是请求，而函数的返回值则是响应。

gRPC 就是一种 RPC 框架，在你定义好消息格式后，针对你选择的编程语言，gRPC 为客户端生成发起 RPC 请求的 Stub 类，以及为服务器生成处理 RPC 请求的 Service 类（服务器只需要继承、实现类中处理请求的函数即可）。如下图所示，很明显，gRPC 主要服务于面向对象的编程语言。

图片来源：https://grpc.io/

gRPC 支持 QUIC、HTTP/1 等多种协议，但鉴于 HTTP/2 协议性能好，应用场景又广泛，因此 HTTP/2 是 gRPC 的默认传输协议。gRPC 也支持 JSON 编码格式，但在忽略编码细节的 RPC 调用中，高效的 Protobuf 才是最佳选择！因此，这一讲仅基于 HTTP/2 和 Protobuf，介绍 gRPC 的用法。

gRPC 可以简单地分为三层，包括底层的数据传输层，中间的框架层（框架层又包括 C 语言实现的核心功能，以及上层的编程语言框架），以及最上层由框架层自动生成的 Stub 和 Service 类，如下图所示：

图片来源：https://platformlab.stanford.edu/Seminar%20Talks/gRPC.pdf

接下来我们以官网上的data_transmisstion 为例，先看看如何使用 gRPC。

构建 Python 语言的 gRPC 环境很简单，你可以参考官网上的QuickStart。

使用 gRPC 前，先要根据 Protobuf 语法，编写定义消息格式的 proto 文件。在这个例子中只有 1 种请求和 1 种响应，且它们很相似，各含有 1 个整型数字和 1 个字符串，如下所示：

package demo;

message Request {
    int64 client_id = 1;
    string request_data = 2;
}

message Response {
    int64 server_id = 1;
    string response_data = 2;
}

请注意，这里的包名 demo 以及字段序号 1、2，都与后续的 gRPC 报文分析相关。

接着定义 service，所有的 RPC 方法都要放置在 service 中，这里将它取名为 GRPCDemo。GRPCDemo 中有 4 个方法，后面 3 个流式访问的例子我们呆会再谈，先来看简单的一元访问模式 SimpleMethod 方法，它定义了 1 个请求对应 1 个响应的访问形式。其中，SimpleMethod 的参数 Request 是请求，返回值 Response 是响应。注意，分析报文时会用到这里的类名 GRPCDemo 以及方法名 SimpleMethod。

service GRPCDemo {
    rpc SimpleMethod (Request) returns (Response);
}

用 grpc_tools 中的 protoc 命令，就可以针对刚刚定义的 service，生成含有 GRPCDemoStub 类和 GRPCDemoServicer 类的 demo_pb2_grpc.py 文件（实际上还包括完成 Protobuf 编解码的 demo_pb2.py），应用层将使用这两个类完成 RPC 访问。我简化了官网上的 Python 客户端代码，如下所示：

with grpc.insecure_channel("localhost:23333") as channel:
    stub = demo_pb2_grpc.GRPCDemoStub(channel)
    request = demo_pb2.Request(client_id=1,
            request_data="called by Python client")
    response = stub.SimpleMethod(request)

示例中客户端与服务器都在同一台机器上，通过 23333 端口访问。客户端通过 stub 对象的 SimpleMethod 方法完成了 RPC 访问。而服务器端的实现也很简单，只需要实现 GRPCDemoServicer 父类的 SimpleMethod 方法，返回 response 响应即可：

class DemoServer(demo_pb2_grpc.GRPCDemoServicer):
    def SimpleMethod(self, request, context):
        response = demo_pb2.Response(
            server_id=1,
            response_data="Python server SimpleMethod Ok!!!!")
        return response

可见，gRPC 的开发效率非常高！接下来我们分析这次 RPC 调用中，消息是怎样编码的。

gRPC 消息是如何编码的？

定位复杂的网络问题，都需要抓取、分析网络报文。如果你在 Windows 上抓取网络报文，可以使用 Wireshark 工具（可参考《Web 协议详解与抓包实战》第 37 课），如果在 Linux 上抓包可以使用 tcpdump 工具（可参考第 87 课）。当然，你也可以从这里下载我抓取好的网络报文，用 Wireshark 打开它。需要注意，23333 不是 HTTP 常用的 80 或者 443 端口，所以 Wireshark 默认不会把它解析为 HTTP/2 协议。你需要鼠标右键点击报文，选择“解码为”（Decode as），将 23333 端口的报文设置为 HTTP/2 解码器，如下图所示：

图中蓝色方框中，TCP 连接的建立过程请参见[第 9 讲]，而 HTTP/2 会话的建立可参见《Web 协议详解与抓包实战》第 52 课（还是比较简单的，如果你都清楚就可以直接略过）。我们重点看红色方框中的 gRPC 请求与响应，点开请求，可以看到下图中的信息：

先来分析蓝色方框中的 HTTP/2 头部。请求中有 2 个关键的 HTTP 头部，path 和 content-type，它们决定了 RPC 方法和具体的消息编码格式。path 的值为“/demo.GRPCDemo/SimpleMethod”，通过“/ 包名. 服务名 / 方法名”的形式确定了 RPC 方法。content-type 的值为“application/grpc”，确定消息编码使用 Protobuf 格式。如果你对其他头部的含义感兴趣，可以看下这个文档，注意这里使用了 ABNF 元数据定义语言（如果你还不了解 ABNF，可以看下《Web 协议详解与抓包实战》第 4 课）。

HTTP/2 包体并不会直接存放 Protobuf 消息，而是先要添加 5 个字节的 Length-Prefixed Message 头部，其中用 4 个字节明确 Protobuf 消息的长度（1 个字节表示消息是否做过压缩），即上图中的桔色方框。为什么要多此一举呢？这是因为，gRPC 支持流式消息，即在 HTTP/2 的 1 条 Stream 中，通过 DATA 帧发送多个 gRPC 消息，而 Length-Prefixed Message 就可以将不同的消息分离开。关于流式消息，我们在介绍完一元模式后，再加以分析。

最后分析 Protobuf 消息，这里仅以 client_id 字段为例，对上一讲的内容做个回顾。在 proto 文件中 client_id 字段的序号为 1，因此首字节 00001000 中前 5 位表示序号为 1 的 client_id 字段，后 3 位表示字段的值类型是 varint 格式的数字，因此随后的字节 00000001 表示字段值为 1。序号为 2 的 request_data 字段请你结合上一讲的内容，试着做一下解析，看看字符串“called by Python client”是怎样编码的。

再来看服务器发回的响应，点开 Wireshark 中的响应报文后如下图所示：

其中 DATA 帧同样包括 Length-Prefixed Message 和 Protobuf，与 RPC 请求如出一辙，这里就不再赘述了，我们重点看下 HTTP/2 头部。你可能留意到，响应头部被拆成了 2 个部分，其中 grpc-status 和 grpc-message 是在 DATA 帧后发送的，这样就允许服务器在发送完消息后再给出错误码。关于 gRPC 的官方错误码以及 message 描述信息是如何取值的，你可以参考这个文档。

这种将部分 HTTP 头部放在包体后发送的技术叫做 Trailer，RFC7230 文档对此有详细的介绍。其中，RPC 请求中的 TE: trailers 头部，就说明客户端支持 Trailer 头部。在 RPC 响应中，grpc-status 头部都会放在最后发送，因此它的帧 flags 的 EndStream 标志位为 1。

可以看到，gRPC 中的 HTTP 头部与普通的 HTTP 请求完全一致，因此，它兼容当下互联网中各种七层负载均衡，这使得 gRPC 可以轻松地跨越公网使用。

gRPC 流模式的协议编码

说完一元模式，我们再来看流模式 RPC 调用的编码方式。

所谓流模式，是指 RPC 通讯的一方可以在 1 次 RPC 调用中，持续不断地发送消息，这对订阅、推送等场景很有用。流模式共有 3 种类型，包括客户端流模式、服务器端流模式，以及两端双向流模式。在data_transmisstion 官方示例中，对这 3 种流模式都定义了 RPC 方法，如下所示：

service GRPCDemo {
    rpc ClientStreamingMethod (stream Request) returns Response);

    rpc ServerStreamingMethod (Request) returns (stream Response);

    rpc BidirectionalStreamingMethod (stream Request) returns (stream Response);
}

不同的编程语言处理流模式的代码很不一样，这里就不一一列举了，但通讯层的流模式消息编码是一样的，而且很简单。这是因为，HTTP/2 协议中每个 Stream 就是天然的 1 次 RPC 请求，每个 RPC 消息又已经通过 Length-Prefixed Message 头部确立了边界，这样，在 Stream 中连续地发送多个 DATA 帧，就可以实现流模式 RPC。我画了一张示意图，你可以对照它理解抓取到的流模式报文。

小结

这一讲介绍了 gRPC 怎样使用 HTTP/2 和 Protobuf 协议编码消息。

在定义好消息格式，以及 service 类中的 RPC 方法后，gRPC 框架可以为编程语言生成 Stub 和 Service 类，而类中的方法就封装了网络调用，其中方法的参数是请求，而方法的返回值则是响应。

发起 RPC 调用后，我们可以这么分析抓取到的网络报文。首先，分析应用层最外层的 HTTP/2 帧，根据 Stream ID 找出一次 RPC 调用。客户端 HTTP 头部的 path 字段指明了 service 和 RPC 方法名，而 content-type 则指明了消息的编码格式。服务器端的 HTTP 头部被分成 2 次发送，其中 DATA 帧发送完毕后，才会发送 grpc-status 头部，这样可以明确最终的错误码。

其次，分析包体时，可以通过 Stream 中 Length-Prefixed Message 头部，确认 DATA 帧中含有多少个消息，因此可以确定这是一元模式还是流式调用。在 Length-Prefixed Message 头部后，则是 Protobuf 消息，按照上一讲的内容进行分析即可。

思考题

最后，留给你一道练习题。gRPC 默认并不会压缩字符串，你可以通过在获取 channel 对象时加入 grpc.default_compression_algorithm 参数的形式，要求 gRPC 压缩消息，此时 Length-Prefixed Message 中 1 个字节的压缩位将会由 0 变为 1。你可以观察下执行压缩后的 gRPC 消息有何不同，欢迎你在留言区与大家一起探讨。

感谢阅读，如果你觉得这节课对你有一些启发，也欢迎把它分享给你的朋友。

加餐1｜特别福利：陶辉视频课精选

你好，我是陶辉。

不知道你的期中考试成绩如何呢？是否发现了一些新的问题？成绩好的同学请继续努力了，咱们还有一半的课程未学，希望你能一如既往地跟着我深挖系统性能，这门课的内容设计是由浅入深的，第三、四模块会重点往集群方向转了，内容相对会更加烧脑。成绩略差的同学也不要气馁，就像我上节课说的，问题驱动是一种很好的学习方式，查漏补缺才是这场期中考试的目的。

聊完考试，我就来兑现承诺了，精选 3 节我的视频课作为福利送给你，它们来自《Web 协议详解与抓包实战》，这 3 节课都是与咱们专栏有强关联的。

我还记得在开篇词中有位同学问我，学习咱们专栏需要哪些基础？我是这么回复的：

对操作系统的原理有简单的了解，比如，文件是怎么读取的，HTTP 请求是怎么发送和接收的；

对网络协议要有所了解，毕竟分布式系统就是靠网络将操作系统上的进程连接在一起。这块《Web 协议详解与抓包实战》可以先学下。

大致知道什么是分布式系统，这块网上的文章有很多。

其中第 1 点和第 3 点，我想还是很容易的，所以我就在第 2 点上为你加码了。以下就是 3 节视频课，你可以直接点击观看。

第 1 节

与第 7 课有联系的《Web 协议详解与抓包实战》第 117 课。我在第 7 课讲解组播时，有推荐你如果想要进一步了解组播的细节，可以观看以下视频课。

Sorry, your browser doesn't support embedded videos.



00:00 / 00:00



第 2 节

与第 14 课有联系的《Web 协议详解与抓包实战》第 51 课。我在第 14 课讲解用 Chrome 浏览器配合 Wireshark 解密消息，可以帮助你分析 TLS 协议的细节时，具体的操作方法有推荐你观看以下视频课。

Sorry, your browser doesn't support embedded videos.



00:00 / 00:00



第 3 节

与第 15 课有联系的《Web 协议详解与抓包实战》第 9 课。第 15 课我们介绍了 Chrome 浏览器开发者工具的 Network 面板，通过它可以快速判断各站点使用了哪些 HTTP 性能优化技术，有关 Network 面板的详细用法，我推荐你观看以下视频课。

Sorry, your browser doesn't support embedded videos.



00:00 / 20:52



今天的特别福利就到这里，这 3 节视频课对于你学习咱们专栏有一定的辅助作用，希望你能把它作为拓展认真学完。如果你有更多想要了解的内容和期待的学习资料，欢迎在留言区中提出。

加餐2 ｜答疑精选：这些问题你都清楚吗？

你好，我是陶辉。今天是期中周的第 2 篇加餐，按照约定，这节课我从 1～15 课的留言区精选出了 15 个问题，这里一部分是与内容强相关的，还有一部分是属于拓展型的问题，选择标准就是是否存在增量信息以及问题价值，希望你能从别人的疑问中进行一次自检，引发更多的思考。

第 1 课

鲤鲤鱼：我们集群有一个问题，某一台物理机的 CPU 会被 Hadoop yarn 的查询任务打满，并且占用最多的 pid 在不停的变化，我查看了 TIME_WAIT 的个数好像也不是很多，在顶峰的时候还没达到一万，能够持续一两个小时。这个问题您有没有什么思路呢？

作者：解决性能问题，一般有两种方法：经验派和“理论”派。前者就是基于自己的经验概率，将能想到的优化方法都试一遍，这种方式通常又有效又快速，但无法解决复杂的问题。而所谓理论派，就是沿着固定的思路，使用二分法，从高至低慢慢下沉到细节。

具体到你的问题，我建议你先看看，CPU 占用的是用户态还是系统态，用户态的话就要分析代码了，系统态还要进一步分析。火焰图通常是个很好的办法，虽然搭能画火焰图的环境很麻烦，但这种底层方法很有效（第 19 课会具体讲到火焰图的用法）。

第 2 课

alan：老师好，这节课真好，第一次了解到内存池也是有层次的。我遇到一个问题想请教一下：我有一个和数据库交互的 Groovy 程序，运行起来后会占用很大内存，启动时，将 Xmx 设置为多少，该程序的内存占用就不会超过 Xmx 指定的上限。比如，Xmx=10g，程序就稳定占 10g 内存，但如果不限制的话，最高见过占用 30G 左右。这个您觉得有什么可能的原因吗？

作者：每种 Java 虚拟机都有自己独特的垃圾回收机制，有时为了时间更快就会牺牲更多的内存空间，这是正常的。我建议在服务器上长时间运行的 Java 进程，一定要通过 Xmx 去明确内存占用，否则内存不可控会很麻烦。

第 3 课

杨文宇：链表的内存地址不连续是如何影响序列化的？老师能具体说一下吗？

作者：当数组外还有链表中的元素时，序列化就必须遍历所有元素，比如，至少要做 1 次循环，把每 1 个遍历到的元素的值，序列化写入至另一段内存中。而使用闭散列时，可以直接将这个数组占用的内存作为序列化后的数据。

第 4 课

helloworld：“第二，读取磁盘数据时，需要先找到数据所在的位置，对于机械磁盘来说，就是旋转磁头到数据所在的扇区，再开始顺序读取数据。其中，旋转磁头耗时很长，为了降低它的影响，PageCache 使用了预读功能。”那是不是使用 SSD 这类固态硬盘（不用旋转磁头），PageCache 就没有很大的影响？

作者：对的！其实，当下的操作系统对 SSD 磁盘的支持还不够，当 SSD 广泛应用时，文件系统还需要跟上，得获得很大的性能提升才可以。

第 5 课

Robust：“然而，共享地址空间虽然可以方便地共享对象，但这也导致一个问题，那就是任何一个线程出错时，进程中的所有线程会跟着一起崩溃。”这里的出错应该表示一些特殊的错误吧，或者是说和共享内存有关的错误，比如申请不到内存等。老师，我理解得没错吧？

作者：这里指无法恢复的错误，不仅是内存申请错误，比如访问已经释放的资源，且没有捕获异常或者无法捕获异常，进而操作系统只能杀死线程时，进程里的其它线程也会被杀死。

第 6 课

范闲：用户态的协程不能用互斥或者自旋，会进入内核态与其设计初衷相悖，Python 里面用的 yield。

作者：是的，用户态协程需要用户态的代码将锁重新实现一遍，其中实现时不能用到内核提供的系统调用。

第 7 课

重返归途：广播功能属于双工吗？但多个客户机向主机响应时，会有性能瓶颈吗？

作者：广播不是双工，因为广播是由网络设备实现的，所以服务器无法感知到每个客户端的响应，因此客户端对服务器的响应，与本次广播消息链路无关，它必须是另一个通道。

第 8 课

龙龙：“因此，哪怕有 1 千万并发连接，也能保证 1 万 RPS 的处理能力，这就是 epoll 能在 C10M 下实现高吞吐量的原因。”老师，这段话我不太理解，1 千万的并发连接，只有 1 万的 RPS 这能算高吞吐量吗？相当于每秒只有 1000 个人中的 1 个人得到响应。还是我理解错了，您表述的是另一层意思？

作者：这里有 2 层意思，都是服务于 epoll 的设计思想这一个目的。

这段话的上下文，是指单次获取网络事件时，你可以理解为调用 epoll_wait 系统调用，它的速度与并发连接总数无关，相对于之前的 select/poll 系统调用（它们都与并发连接总数相关），因此 epoll_wait 速度很快，这是实现高吞吐量的关键。

有些应用会长时间保持 TCP 长连接，但并没有消息通讯（比如 GPS 等 IoT 设备与服务器之间的通讯），此时 1 千万并发连接下，如果能够维持 1 万 RPS，这也是只有 epoll 才能做到的，poll/select 是不可能做到的。

第 9 课

Geek_007：看评论区，很多同学都说是长连接，普通的 HTTP keep-alive 会不会有坑，三大运营商或者中间网络设备都会将超过一定时间的链接 drop 掉。如果没有 H2 这种 ping 保活的机制，有可能客户端长链接莫名其妙的就被 drop 掉，客户端只能依赖超时来感知异常，反倒是影响性能了。

作者：是的，不只网络设备，一些代理服务器为了减轻自己的负担，也会把长连接断掉，比如 Nginx 默认关闭 75 秒没有数据交互的 keep-alive 长连接。

第 10 课

安排：TTL 每一跳减少 1，这些怎么和 MSL 对应起来呢？每一跳减少的 1 相当于 1 秒？

作者：不是，这是一个预估值，所谓每一跳，是指每经过一个路由器网络设备，将 IP 头部中的 TTL 字段减少 1，并不等于 1 秒，通常推荐的 TTL 的初始值是 64。

第 11 课

Trident：带宽时延积如何衡量呢？网络时延不是固定的，是要多次取样计算平均网络时延，然后估算出这个时延积吗？

作者：是的，需要多次取样做估算，再乘以带宽。

第 12 课

妥协：为什么报文 5 之后的 ack 都是 ack6 呀？

作者：TCP 是有序的字符流，因此接收方收完报文 5 后，只能接收报文 6，但现在却接收到了报文 7、8、9、10，此时接收方该怎么办呢？

当然，它可以当做不知道，什么也不做，坐等报文 6 的到来。报文 6 什么时候会到呢？RTO 时间超时后，发送方会重发报文 6，因为发送方一直没收到 ACK7！但是，RTO 是很长的时间，接收方直接反复地传递 ACK6，这样发送方就能明白，报文 6 丢了，它可以提前重发报文 6。这叫做快速重传！

第 13 课

有铭：“寻找宕机服务时，只要看队列首部最老的心跳包，距现在是否超过 5 秒，如果超过 5 秒就认定宕机。”这里的逻辑无法理解。如果要用这种方式检测心跳，那么肯定要不停地把队列首部的心跳包移除，让新的心跳包从尾部加入，那么如果这个加入的过程卡一点，岂不是就会误判？

作者：这种设计下，还必须限制每次移除心跳包的数量（分多次执行），以防止加入过程长时间得不到执行。而且，在这种极限场景下，必须监控 CPU 的使用率，如果长期维持在高占用率（可能你的集群规模已经超大，要每秒处理数百万心跳包），那么应当通过扩容更多的 CPU 核、分布式系统等其它方案来解决，这已经不是单台机器能解决的了。

第 14 课

东郭：请问老师，我在 Nginx 配置中，不管 ssl_certificate 和 ssl_certificate_key 是否配置 ecc 证书，抓包查看服务器的 server hello 响应中的 Cipher Suite 字段都是 TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256，这是正常的吗？

作者：这是正常的，TLS 握手阶段 Nginx 的 Cipher Suite，要通过 ssl_ciphers 指令来配合支持的 suites，并可通过 ssl_prefer_server_ciphers 指定优先选用的算法。证书只是包含了密钥、身份等信息，它们与密钥协商方式、对称加密算法并无关系。

第 15 课

Geek_007：FB 也搞了一套压缩算法 ZSTD，对比起来也比 gzip 性能强很多，不清楚这些压缩算法的原理是啥？怎么对比？另外普通的 JSON 和 PB 有不同适合的压缩算法吗？怎么比较呢？

作者：

压缩算法的原理都是基于香农的信息论，将高频出现的信息用更少的比特编码。虽然原理是一致的，但实现上却有很大的差别，比如 Huffman 通过建立 Huffman 树来生成编码，而 LZ77 却是通过滑动窗口，这就造成了压缩比、压缩速度都很不相同。

比较它们的优劣，主要看 3 个指标：

压缩比，比如 Brotli 的压缩比好于 ZSTD；

压缩与解压的速度，比如 ZSTD 比 gzip 速度快；

浏览器等中间件的支持程度，现在几乎所有浏览器都支持 Brotli（即 br），但 ZSTD 少有支持。

普通的 JSON 和 PB 没有最适合的算法，还是要针对具体场景，比较方法参见我刚刚说的那 3 个指标。

今天的答疑精选就到这里，期待大家能一如既往的在留言区进行交流，如果有更多问题，也欢迎一并提出。

期中考试｜行至半程，你的收获如何呢？

你好，我是陶辉。

时间过得真快，从 4 月 27 日课程上线，转眼已经一月有余了，不知道你的收获如何呢？在这期间，我收到了很多同学的反馈，很感谢大家的认可，也非常开心能与你一起交流技术。

那从我个人而言呢，这已经是我在极客时间开的第三门课了，前两门都是视频课。那切换到文字专栏的话，其实是完全不同的感受，视频课可以通过演示把问题讲清楚；而文字专栏则要反复打磨内容，并且和写书的方式还不一样，从每节课的内容设计、讲述方式到大纲以及具体细节的编辑，真真是掉了不少头发。

目前专栏已经更新一半了，前两个模块我们已经学完了，包括单主机如何提升性能，以及到了瓶颈后开始使用网络编程会出现的一些问题，第三模块我们也接触了一点点。学到这里，相信你应该感觉到了，系统性能问题的涉及面就是很广很深，往往需要在多个环境中反复验证分析才可以。那每清楚一个问题，你的实力其实就会有一定的沉淀，直到跳跃式的进步。

这其实也是我本人在学习性能优化过程中一个很深的体会，即问题驱动。特别是我在腾讯、阿里时，不断增长的业务流量导致系统需要持续地进行优化和扩容，再然后就自然地进行系统化总结，我那时候就发现大学里的《数据结构》课白学了，所以我又重头开始学习《算法导论》，然后发现《网络原理》课白学了，又开始学习《TCP/IP 协议详解》，等等（当然，这里不是为了吐槽，而是想告诉你，学习时选对教材真的很重要）。这就是一个螺旋上升的过程。

所以，阶段性的验证和总结其实还是非常重要的，这里我特别设置了这场期中考试，精选了 20 个问题，知识范围就是咱们专栏的 1～15 课，希望你能认真完成，发现问题及时解决。

这里特别说明一下：我们的期中考试为期一周，从 6 月 3 日开始到 6 月 9 日结束，这期间我们会暂停更新正文内容，也就是说我们会从 6 月 10 日开始更新第 16 课。你可以好好利用这一周的时间，去回顾一下前 15 课的知识，做一个巩固。另外，在这一周中，我还会提供两篇加餐给你，一篇是福利加餐，我会从我的视频课中精选出 3 节和本专栏具有强关联的课程送给你；另一篇是答疑精选，期待你能借由大家的问题和增量信息，去引发更多的思考。

有标准才有追求，有追求才有动力，有动力才有进步，真心希望性能优化能成为你职业生涯的重武器。最后，来挑战一下吧，开启你的期中考试之旅。

19 | 如何通过监控找到性能瓶颈？

你好，我是陶辉。

从这一讲开始，我们将进入分布式系统层面，站在更宏观的角度去探讨系统性能的优化。

如果优化系统性能时，只是依据自己的经验，对感觉存在性能提升空间的代码，无一例外地做一遍优化，这既是一件事倍功半的事，也很容易遗漏下关键的优化点，无法大幅提升系统的性能。根据帕累托法则（也叫二八定律），只有优化处于性能瓶颈的那些少量代码，才能用最小的成本获得最大的收益。

然而，找到性能瓶颈却不是一件容易的事。我们通常会采用各种监控手段来发现性能瓶颈，但如果监控动作自身的开发成本过高，或者施行监控时显著降低了业务请求的性能，或者无法全面覆盖潜在的问题，都会影响性能优化目标的实现。

这一讲，我将介绍 2 个简单而又行之有效的方案，分别从微观上快速地找出进程内的瓶颈函数，以及从宏观上找出整个分布式系统中的瓶颈组件。这样，我们就可以事半功倍地去优化系统性能。

单机：如何通过火焰图找到性能瓶颈？

对于工作在一台主机上的进程而言，有许多监控方案可用于寻找性能瓶颈。比如在 Linux 下，你可以通过 iostat 命令监控磁盘状态，也可以通过 top 命令监控 CPU、内存的使用。这些方案都是在旁敲侧击着寻找性能瓶颈，然而，有一种最直接有效的方式，就是从代码层面直接寻找调用次数最频繁、耗时最长的函数，通常它就是性能瓶颈。

要完成这样的目标，通常还有 3 个约束条件。

没有代码侵入性。比如，在函数执行的前后，分别打印一行日志记录时间，这当然能获取到函数的调用频次和执行时长，但并不可取，它的开发、维护成本太高了。

覆盖到代码中的全部函数。如果只是监控事先猜测的、有限的几个函数，往往会因为思维死角，遗漏掉真正的瓶颈函数。因此，只有监控所有函数的执行情况，并以一种全局的、直观的方式分析聚合后的监控结果，才能快速、准确地找到性能瓶颈。

搭建环境的成本足够低。我曾经使用过 systemtap 来监控函数的执行状况，搭建这样的监控环境需要很多步骤，比如要重新编译 Linux 内核，这些过于繁琐的成本会阻碍很多应用开发者的性能优化脚步。

带着这 3 个约束条件，最合适的方案已经呼之欲出了：那就是Brendan Gregg 发明的火焰图，以及为火焰图提供监控数据的工具。我们先来直观感受下火焰图到底是什么样子：

火焰图可以将监控期间涉及到的所有函数调用栈都列出来，就像上图对 Nginx worker 进程的监控一样，函数非常密集，因此通常采用可缩放、可互动的SVG 矢量格式图片来展示。由于极客时间暂时还没法直接展示矢量图片，所以我把矢量图片放在了我的个人网站上，你可以点击这个链接跳转到矢量图片页面。在 SVG 图片上你可以用 Ctrl+F，通过名称搜索函数，而且这是支持正则表达式匹配的。你还可以点击函数方块，只查看它相关的函数调用栈。

火焰图中最重要的信息，就是表示函数执行时间的 X 轴，以及表示函数调用栈的 Y 轴。

先来看 X 轴。X 轴由多个方块组成，每个方块表示一个函数，其长度是定时采样得到的函数调用频率，因此你可以简单粗暴地把它近似为执行时间。需要注意的是，如果函数 A 中调用了函数 B、C、D，监控采样时会形成 A->B、A->C、A->D 这 3 个函数调用栈，但火焰图会将 3 个调用栈中表示 A 函数的方块合并，并将 B、C、D 放在上一层中，并以字母表顺序排列它们。这样更有助于你找到耗时最长的函数。

再来看 Y 轴，它表示函数的调用栈。这里，我们既可以看到内核 API 的函数调用栈，也可以看到用户态函数的调用栈，非常强大。如果你正在学习开源组件的源码，推荐你先生成火焰图，再对照图中的 Y 轴调用栈，理解源码中函数的调用关系。注意，函数方块的颜色是随机的，并没有特别的意义，只是为了在视觉上更容易区分开。

结合 X 轴和 Y 轴，我们再来看如何使用火焰图找到性能瓶颈。

首先，火焰图很容易从全局视角，通过寻找长方块，找到调用频率最高的几个函数。

其次，函数方块长，有些是因为函数自身执行了很消耗 CPU 的代码，而有些则是调用的子函数耗时长造成的。怎么区分这两种场景呢？很简单，如果函数方块的长度，远大于调用栈中子函数方块的长度之和，那么这个函数就执行了比较耗费 CPU 的计算。比如，下图中执行 TLS 握手的 ngx_ssl_handshake_handler 函数，自身并没有消耗 CPU 多少计算力。

而更新 Nginx 时间的 ngx_time_update 函数就不同，它在做时间格式转换时消耗了许多 CPU，如下图所示：

这样，我们可以直观地找到最耗时的函数，再有针对性地优化它的性能。

怎么生成火焰图呢？在 Linux 上这非常简单，因为 Linux 内核默认支持 perf 工具，你可以用 perf 生成函数的采样数据，再用 FlameGraph 脚本生成火焰图。当然，如果你在使用 Java、GoLang、Python 等高级语言，也可以使用各自语言生态中封装过的 Profile 类工具，生成采样数据。这里我以最基本的 perf、FlameGraph 为例，介绍下生成火焰图的 5 步流程。

首先，你可以通过 yum 或者 apt-get 安装 perf 工具，再通过 git 来下载 FlameGraph：

yum install perf
git clone --depth 1 https://github.com/brendangregg/FlameGraph.git

接着，针对运行中的进程 PID，使用 perf 采样函数的调用频率（对于 C/C++ 语言，为了能够显示完整的函数栈，你需要在编译时加入 -g 选项），如下所示：

perf record -F 99 -p 进程PID -g --call-graph dwarf

上述命令行中各参数的含义，可以参见这里。

再将二进制信息转换为 ASCII 格式的文件，方便 FlameGraph 处理：

perf script > out.perf

再然后，需要汇聚函数调用栈，转化为 FlameGraph 生成火焰图的数据格式：

FlameGraph/stackcollapse-perf.pl out.perf > out.folded

最后一步，生成 SVG 格式的矢量图片：

FlameGraph/flamegraph.pl out.folded > out.svg

需要注意，上面的火焰图只能找到消耗 CPU 计算力最多的函数，因此它也叫做 On CPU 火焰图，由于 CPU 工作时会发热，所以色块都是暖色调。有些使用了阻塞 API 的代码，则会导致进程休眠，On CPU 火焰图无法找到休眠时间最长的函数，此时可以使用 Off CPU 火焰图，它按照函数引发进程的休眠时间的长短，确定函数方块的长度。由于进程休眠时不使用 CPU，所以色块会使用冷色调。如下图所示：

图片来源：http://www.brendangregg.com/FlameGraphs/offcpuflamegraphs.html，你可以点击这个链接查看 SVG 图片。

生成 Off CPU 火焰图的步骤稍微繁琐点，你可以参照这个页面。关于火焰图的设计思路和详细使用方式，推荐你参考Brendan Gregg 的这篇论文。

分布式系统：如何通过全链路监控找到性能瓶颈？

说完微观上性能瓶颈的定位，我们再来看宏观上的分布式系统，它通过网络把不同类型的硬件、操作系统、编程语言组合在一起，结构很复杂，因此，现在我们的目标变成寻找更大尺度的瓶颈组件。

当然，与单机不同的是，找到分布式系统的性能瓶颈组件后，除了可以继续通过火焰图优化组件中某个进程的性能外，还有下面这样 3 个新收益。

首先，可以通过简单的扩容动作，利用负载均衡机制提升性能。

其次，在云计算和微服务时代，扩容可以在分钟级时间内完成。因此，如果性能瓶颈可以实时检测到，就可以自动化地完成扩容和流量迁移。这可以提升服务器资源的利用率。

最后，既然能够找到瓶颈组件，同样也能找出资源富裕的组件，这样就可以通过反向的缩容，减少服务器资源的浪费。

接下来，我将从 4 个角度介绍分布式系统中的性能监控体系。

首先，监控基于日志来做，对系统的侵入性最低，因此，你要把格式多样化的文本日志，进行结构化管理。我们知道，1 个分布式系统会涉及到多种编程语言，这样各组件基于完全异构的中间件，产生的日志格式也是五花八门！如果定义一个统一的日志格式标准，再要求每个组件遵照它重新开发一个版本，这样代价太高，基本无法实现！

比较幸运的是，几乎每个组件都会输出文本类型的访问日志，而且它们通常包括访问对象（例如 URL）、错误码、处理时间等共性信息。因此，可以针对每个组件特定格式的文本日志，写一个正则表达式，将我们需要的结构化信息提取出来。这样，结构化数据的提取是在组件之外的，对于组件中的代码也没有影响。

其次，同时搜集系统中每个组件的日志并汇总在一起，这样日志数据的规模就非常可观，每天可能会产生 TB 级别的数据，因此关系型数据库并不可取，因为它们为了照顾事务、关联查询等操作，不具备很好的可伸缩性。同时，结构化的日志源于我们必须针对某类请求、某类组件做聚合分析，因此纯粹的 Key/Value 数据库也无法实现这类功能。因此，像 HBase 这样的半结构化列式数据库往往是监控数据的首选落地形式。

再次，我们分析日志时，首先会从横向上，聚合分析成百上千个组件中时延、吞吐量、错误码等数据项的统计值。这样，计算力就是一个很大的问题，因此通常选择支持 Map Reduce 算法的系统（比如 Hadoop）进行计算，并将结果以可视化的方式展现出来，协助性能分析。由于计算时间比较久，所以这也称为离线计算。其次会在纵向上，监控一个请求在整个生命周期内，各个参与组件的性能状态。因此必须设计一个请求 ID，能够贯穿在各个组件内使用，由于分布式系统中的请求数量非常大，这个 ID 的碰撞概率必须足够低。所以，请求 ID 通常会同时包含时间和空间元素（比如 IP 地址）。这个方案还有个很威武的名字，叫做全链路监控（可以参考 Google 的这篇论文），它还可以用于组件依赖关系的优化。

最后，你还得对性能瓶颈做实时监控，这是实现分布式系统自动化扩容、缩容的前提。由于监控数据规模庞大，所以我们通常要把流量在时间维度上分片，仅对每个时间小窗口中有限的数据，做快速的增量计算。像 Storm、Spark、Flink 都是这样的实时流计算中间件，你可以基于它们完成实时数据的汇聚分析。

以上是我对分布式系统性能监控方案的一些思考。

一提到分布式系统，涉及到的知识点就非常多。好在我们后续的课程对 MapReduce、实时流计算等还有进一步的分析，因此监控方案中对它们就一带而过了。

小结

这一讲，我们介绍了单机上如何通过火焰图找到性能瓶颈函数，以及在分布式系统中，如何通过全链路监控找到性能瓶颈组件。

在 Linux 系统中，你可以用内核支持的 perf 工具，快速地生成火焰图。其他高级编程语言生态中，都有类似的 Profiler 工具，可生成火焰图。

火焰图中可以看到函数调用栈，它对你分析源码很有帮助。图中方块的长度表示函数的调用频率，当采样很密集时，你可以把它近似为函数的执行时长。父方块长度减去所有子方块长度的和，就表示了父函数自身代码对 CPU 计算力的消耗。因此，火焰图可以直观地找到调用次数最频繁且最耗时的函数。

除了上面介绍的 On CPU 火焰图，你也可以用同样的工具生成 Off CPU 火焰图，它用于找到频率引发进程休眠，进而降低了性能的瓶颈函数。

对于分布式系统，性能监控还有利于系统的扩容和缩容运维。搭建性能监控体系包括以下四个关键点：首先要把五花八门的日志用正则表达式提取为结构化的监控数据；其次用半结构化的列式数据库存放集群中的所有日志，便于后续的汇聚分析；第三，使用统一的请求 ID 将各组件串联在一起，并使用 MapReduce 算法对大量的监控数据做离线分析；最后，通过实时流计算框架，对监控数据做实时汇聚分析。

性能监控是一个很大的话题，这节课我只希望能从不同的角度带给你思考，课后还需要你进行更深入的探索。

思考题

最后给你留一道练习题，请你参考 On CPU 火焰图的制作，对你熟悉的程序做一张 Off CPU 火焰图，看看能带给你哪些新的启发？期待你的总结。

感谢阅读，如果你觉得这节课对你有一些启发，也欢迎把它分享给你的朋友。

20 | CAP理论：怎样舍弃一致性去换取性能？

你好，我是陶辉。

上一讲我们介绍了如何通过监控找到性能瓶颈，从这一讲开始，我们将具体讨论如何通过分布式系统来提升性能。

在第一部分课程中，我介绍了多种提升单机处理性能的途径，然而，进程的性能必然受制于一台服务器上各硬件的处理能力上限。如果需要进一步地提升服务性能，那只有整合多台主机组成分布式系统才能办到。

然而，当多台主机通过网络协同处理用户请求时，如果主机上的进程含有数据状态，那么必然需要在多台主机之间跨网络同步数据，由于网络存在时延，也并不稳定，因此不可靠的数据同步操作将会增加请求的处理时延。

CAP 理论指出，当数据同时存放在多个主机上时，可用性与一致性是不可兼得的。根据 CAP 的指导性思想，我们可以通过牺牲一致性，来提升可用性中的核心因素：性能。当然，在实践中对一致性与性能并不是非黑即白的选择，而是从概率上进行不同程度的取舍。

这一讲，我们将基于分布式系统中的经典理论，从总体上看看如何设计一致性模型，通过牺牲部分数据的一致性来提升性能。

如何权衡性能与一致性？

首先，这节课针对的是有状态服务的性能优化。所谓有状态服务，是指进程会在处理完请求后，仍然保存着影响下次请求结果的用户数据，而无状态服务则只从每个请求的输入参数中获取数据，在请求处理完成后并不保存任何会话信息。因此，无状态服务拥有最好的可伸缩性，但涉及数据持久化时，则必须由有状态服务处理，这是 CAP 理论所要解决的问题。

什么是 CAP 理论呢？这是 2000 年University of California, Berkeley 的计算机教授Eric Brewer（也是谷歌基础设施 VP）提出的理论。所谓 CAP，是以下 3 个单词的首字母缩写，它们都是分布式系统最核心的特性：

Consistency 一致性

Availability 可用性

Partition tolerance 分区容错性

我们通过以下 3 张示意图，快速理解下这 3 个词的意义。下图中 N1、N2 两台主机上运行着 A 进程和 B 进程，它们操作着同一个用户的数据（数据的初始值是 V0），这里 N1 和 N2 主机就处于不同的 Partition 分区中，如下所示：

正常情况下，当用户请求到达 N1 主机上的 A 进程，并将数据 V0 修改为 V1 后，A 进程将会把这一修改行为同步到 N2 主机上的 B 进程，最终 N1、N2 上的数据都是 V1，这就保持了系统的 Consistency 一致性。

然而，一旦 N1 和 N2 之间网络异常，数据同步行为就会失败。这时，N1 和 N2 之间数据不一致，如果我们希望在分区间网络不通的情况下，N2 能够继续为用户提供服务，就必须容忍数据的不一致，此时系统的 Availability 可用性更高，系统的并发处理能力更强，比如 Cassandra 数据库。

反之，如果 A、B 进程一旦发现数据同步失败，那么 B 进程自动拒绝新请求，仅由 A 进程独立提供服务，那么虽然降低了系统的可用性，但保证了更强的一致性，比如 MySQL 的主备同步模式。

这就是 CAP 中三者只能取其二的简要示意，对于这一理论，2002 年 MIT 的Seth Gilbert 、 Nancy Lynch 在这篇论文中，证明了这一理论。当然，可用性是一个很大的概念，它描述了分布式系统的持续服务能力，如下表所示：

当用户、流量不断增长时，系统的性能将变成衡量可用性的关键因素。当我们希望拥有更强的性能时，就不得不牺牲数据的一致性。当然，一致性并不是只有是和否这两种属性，我们既可以从时间维度上设计短暂不一致的同步模型，也可以从空间维度上为不含有因果、时序关系的用户数据设计并发模型。

在学术上，通常会按照 2 个维度对一致性模型分类。首先是从数据出发设计出的一致性模型，比如顺序一致性必须遵照读写操作的次序来保持一致性，而弱一些的因果一致性则允许不具备因果关系的读写操作并发执行。其次是从用户出发设计出的一致性模型，比如单调读一致性保证客户端不会读取到旧值，而单调写一致性则保证写操作是串行的。

实际工程中一致性与可用性的边界会模糊很多，因此又有了最终一致性这样一个概念，这个“最终”究竟是多久，将由业务特性、网络故障率等因素综合决定。伴随最终一致性的是 BASE 理论：

Basically Available 基本可用性

Soft state 软状态

Eventually consistent 最终一致性

BASE 与 CAP 一样并没有很精确的定义，它们最主要的用途是从大方向上给你指导性的思想。接下来，我们看看如何通过最终一致性来提升性能。

怎样舍弃一致性提升性能？

服务的性能，主要体现在请求的时延和系统的并发性这两个方面，而它们都与上面提到的最终一致性有关。我通常会把分布式系统分为纵向、横向两个维度，其中纵向是请求的处理路径，横向则是同类服务之间的数据同步路径。这样，在纵向上在离客户端更近的位置增加数据的副本，并把它存放在处理速度更快的物理介质上，就可以作为缓存降低请求的时延；而在横向上对数据增加副本，并在这些主机间同步数据，这样工作在数据副本上的进程也可以同时对客户端提供服务，这就增加了系统的并发性，如下图所示：

我们先来看纵向上的缓存，是如何在降低请求处理时延时，保持最终一致性的。

缓存可以由读、写两个操作触发更新，[第 15 课] 介绍过的 HTTP 私有缓存，就是工作在浏览器上基于读操作触发的缓存。

工作在代理服务器上的 HTTP 共享缓存，也是由读操作触发的。这些缓存与源服务器上的数据最终是一致的，但在 CacheControl 等 HTTP 头部指定的时间内，缓存完全有可能与源数据不同，这就是牺牲一致性来提升性能的典型例子。

事实上，也有很多以写操作触发缓存更新的设计，它们通常又分为 write back 和 write through 两种模式。其中，write back 牺牲了更多的一致性，但带来了更低的请求时延。比如[第 4 课] 介绍过的 Linux 磁盘高速缓存就采用了 write back 这种设计，它虽然是单机内的一种缓存设计，但在分布式系统中缓存的设计方式也是一样的。而write through 会在更新数据成功后再更新缓存，虽然带来了很好的一致性，但写操作的时延会更久，如下图所示：

write through 的一致性非常好，它常常是我们的首选设计。然而，一旦缓存到源数据的路径很长、延时很高的时候，就值得考虑 write back 模式，此时一致性模型虽然复杂了许多，但可以带来显著的性能提升。比如机械磁盘相对内存延时高了很多，因此磁盘高速缓存会采用 write back 模式。

虽然缓存也可以在一定程度上增加系统的并发处理连接，但这更多是缘于缓存采用了更快的算法以及存储介质带来的收益。从水平方向上，在更多的主机上添加数据副本，并在其上用新的进程提供服务，这才是提升系统并发处理能力最有效的手段。此时，进程间同步数据主要包含两种方式：同步方式以及异步方式，前者会在每个更新请求完成前，将数据成功同步到每个副本后，请求才会处理完成，而后者则会在处理完请求后，才开始异步地同步数据到副本中，如下图所示：

同步方式下系统的一致性最好，但由于请求的处理时延包含了副本间的通讯时长，所以性能并不好。而异步方式下系统的一致性要差，特别是在主进程宕机后，副本上的进程很容易出现数据丢失，但异步方式的性能要好得多，这不只由于更新请求的返回更快，而且异步化后主进程可以基于合并、批量操作等技巧，进行效率更高的数据同步。比如 MySQL 的主备模式下，默认就使用异步方式同步数据，当然你也可以改为同步方式，包括 Full-synchronous Replication（同步所有数据副本后请求才能返回）以及 Semi-synchronous Replication（仅成功同步 1 个副本后请求就可以返回）两种方式。

当然，在扩容、宕机恢复等场景下，副本之间的数据严重不一致，如果仍然基于单个操作同步数据，时间会很久，性能很差。此时应结合定期更新的 Snapshot 快照，以及实时的 Oplog 操作日志，协作同步数据，这样效率会高很多。其中，快照是截止时间 T0 时所有数据的状态，而操作日志则是 T0 时间到当下 T1 之间的所有更新操作，这样，副本载入快照恢复至 T0 时刻的数据后，再通过有限的操作日志与主进程保持一致。

小结

这一讲我们介绍了分布式系统中的 CAP 理论，以及根据 CAP 如何通过一致性来换取性能。

CAP 理论指出，可用性、分区容错性、一致性三者只能取其二，因此当分布式系统需要服务更多的用户时，只能舍弃一致性，换取可用性中的性能因子。当然，性能与一致性并不是简单的二选一，而是需要你根据网络时延、故障概率设计出一致性模型，在提供高性能的同时，保持时间、空间上可接受的最终一致性。

具体的设计方法，可以分为纵向上添加缓存，横向上添加副本进程两种做法。对于缓存的更新，write through 模式保持一致性更容易，但写请求的时延偏高，而一致性模型更复杂的 write back 模式时延则更低，适用于性能要求很高的场景。

提升系统并发性可以通过添加数据副本，并让工作在副本上的进程同时对用户提供服务。副本间的数据同步是由写请求触发的，其中包括同步、异步两种同步方式。异步方式的最终一致性要差一些，但写请求的处理时延更快。在宕机恢复、系统扩容时，采用快照加操作日志的方式，系统的性能会好很多。

思考题

最后留给你一道讨论题，当数据副本都在同一个 IDC 内部时，网络时延很低，带宽大而且近乎免费使用。然而，一旦数据副本跨 IDC 后，特别是 IDC 之间物理距离很遥远，那么网络同步就变得很昂贵。这两种不同场景下，你是如何考虑一致性模型的？它与性能之间有多大的影响？期待你的分享。

感谢阅读，如果你觉得这节课对你有一些启发，也欢迎把它分享给你的朋友。

21 | AKF立方体：怎样通过可扩展性来提高性能？

你好，我是陶辉。

上一讲我们谈到，调低一致性可以提升有状态服务的性能。这一讲我们扩大范围，结合无状态服务，看看怎样提高分布式系统的整体性能。

当你接收到运维系统的短信告警，得知系统性能即将达到瓶颈，或者会议上收到老板兴奋的通知，接下来市场开缰拓土，业务访问量将要上一个大台阶时，一定会马上拿起计算器，算算要加多少台机器，系统才能扛得住新增的流量。

然而，有些服务虽然可以通过加机器提升性能，但可能你加了一倍的服务器，却发现系统的吞吐量没有翻一倍。甚至有些服务无论你如何扩容，性能都没有半点提升。这缘于我们扩展分布式系统的方向发生了错误。

当我们需要分布式系统提供更强的性能时，该怎样扩展系统呢？什么时候该加机器？什么时候该重构代码？扩容时，究竟该选择哈希算法还是最小连接数算法，才能有效提升性能？

在面对 Scalability 可伸缩性问题时，我们必须有一个系统的方法论，才能应对日益复杂的分布式系统。这一讲我将介绍 AKF 立方体理论，它定义了扩展系统的 3 个维度，我们可以综合使用它们来优化性能。

如何基于 AKF X 轴扩展系统？

AKF 立方体也叫做scala cube，它在《The Art of Scalability》一书中被首次提出，旨在提供一个系统化的扩展思路。AKF 把系统扩展分为以下三个维度：

X 轴：直接水平复制应用进程来扩展系统。

Y 轴：将功能拆分出来扩展系统。

Z 轴：基于用户信息扩展系统。

如下图所示：

我们日常见到的各种系统扩展方案，都可以归结到 AKF 立方体的这三个维度上。而且，我们可以同时组合这 3 个方向上的扩展动作，使得系统可以近乎无限地提升性能。为了避免对 AKF 的介绍过于抽象，下面我用一个实际的例子，带你看看这 3 个方向的扩展到底该如何应用。

假定我们开发一个博客平台，用户可以申请自己的博客帐号，并在其上发布文章。最初的系统考虑了 MVC 架构，将数据状态及关系模型交给数据库实现，应用进程通过 SQL 语言操作数据模型，经由 HTTP 协议对浏览器客户端提供服务，如下图所示：

在这个架构中，处理业务的应用进程属于无状态服务，用户数据全部放在了关系数据库中。因此，当我们在应用进程前加 1 个负载均衡服务后，就可以通过部署更多的应用进程，提供更大的吞吐量。而且，初期增加应用进程，RPS 可以获得线性增长，很实用。

这就叫做沿 AKF X 轴扩展系统。这种扩展方式最大的优点，就是开发成本近乎为零，而且实施起来速度快！在搭建好负载均衡后，只需要在新的物理机、虚拟机或者微服务上复制程序，就可以让新进程分担请求流量，而且不会影响事务 Transaction 的处理。

当然，AKF X 轴扩展最大的问题是只能扩展无状态服务，当有状态的数据库出现性能瓶颈时，X 轴是无能为力的。例如，当用户数据量持续增长，关系数据库中的表就会达到百万、千万行数据，SQL 语句会越来越慢，这时可以沿着 AKF Z 轴去分库分表提升性能。又比如，当请求用户频率越来越高，那么可以把单实例数据库扩展为主备多实例，沿 Y 轴把读写功能分离提升性能。下面我们先来看 AKF Y 轴如何扩展系统。

如何基于 AKF Y 轴扩展系统？

当数据库的 CPU、网络带宽、内存、磁盘 IO 等某个指标率先达到上限后，系统的吞吐量就达到了瓶颈，此时沿着 AKF X 轴扩展系统，是没有办法提升性能的。

在现代经济中，更细分、更专业的产业化、供应链分工，可以给社会带来更高的效率，而 AKF Y 轴与之相似，当遇到上述性能瓶颈后，拆分系统功能，使得各组件的职责、分工更细，也可以提升系统的效率。比如，当我们将应用进程对数据库的读写操作拆分后，就可以扩展单机数据库为主备分布式系统，使得主库支持读写两种 SQL，而备库只支持读 SQL。这样，主库可以轻松地支持事务操作，且它将数据同步到备库中也并不复杂，如下图所示：

当然，上图中如果读性能达到了瓶颈，我们可以继续沿着 AKF X 轴，用复制的方式扩展多个备库，提升读 SQL 的性能，可见，AKF 多个轴完全可以搭配着协同使用。

拆分功能是需要重构代码的，它的实施成本比沿 X 轴简单复制扩展要高得多。在上图中，通常关系数据库的客户端 SDK 已经支持读写分离，所以实施成本由中间件承担了，这对我们理解 Y 轴的实施代价意义不大，所以我们再来看从业务上拆分功能的例子。

当这个博客平台访问量越来越大时，一台主库是无法扛住所有写流量的。因此，基于业务特性拆分功能，就是必须要做的工作。比如，把用户的个人信息、身份验证等功能拆分出一个子系统，再把文章、留言发布等功能拆分到另一个子系统，由无状态的业务层代码分开调用，并通过事务组合在一起，如下图所示：

这样，每个后端的子应用更加聚焦于细分的功能，它的数据库规模会变小，也更容易优化性能。比如，针对用户登录功能，你可以再次基于 Y 轴将身份验证功能拆分，用 Redis 等服务搭建一个基于 LRU 算法淘汰的缓存系统，快速验证用户身份。

然而，沿 Y 轴做功能拆分，实施成本非常高，需要重构代码并做大量测试工作，上线部署也很复杂。比如上例中要对数据模型做拆分（如同一个库中的表拆分到多个库中，或者表中的字段拆到多张表中），设计组件之间的 API 交互协议，重构无状态应用进程中的代码，为了完成升级还要做数据迁移，等等。

解决数据增长引发的性能下降问题，除了成本较高的 AKF Y 轴扩展方式外，沿 Z 轴扩展系统也很有效，它的实施成本更低一些，下面我们具体看一下。

如何基于 AKF Z 轴扩展系统？

不同于站在服务角度扩展系统的 X 轴和 Y 轴，AKF Z 轴则从用户维度拆分系统，它不仅可以提升数据持续增长降低的性能，还能基于用户的地理位置获得额外收益。

仍然以上面虚拟的博客平台为例，当注册用户数量上亿后，无论你如何基于 Y 轴的功能去拆分表（即“垂直”地拆分表中的字段），都无法使得关系数据库单个表的行数在千万级以下，这样表字段的 B 树索引非常庞大，难以完全放在内存中，最后大量的磁盘 IO 操作会拖慢 SQL 语句的执行。

这个时候，关系数据库最常用的分库分表操作就登场了，它正是 AKF 沿 Z 轴拆分系统的实践。比如已经含有上亿行数据的 User 用户信息表，可以分成 10 个库，每个库再分成 10 张表，利用固定的哈希函数，就可以把每个用户的数据映射到某个库的某张表中。这样，单张表的数据量就可以降低到 1 百万行左右，如果每个库部署在不同的服务器上（具体的部署方式视访问吞吐量以及服务器的配置而定），它们处理的数据量减少了很多，却可以独占服务器的硬件资源，性能自然就有了提升。如下图所示：

分库分表是关系数据库中解决数据增长压力的最有效办法，但分库分表同时也导致跨表的查询语句复杂许多，而跨库的事务几乎难以实现，因此这种扩展的代价非常高。当然，如果你使用的是类似 MySQL 这些成熟的关系数据库，整个生态中会有厂商提供相应的中间件层，使用它们可以降低 Z 轴扩展的代价。

再比如，最开始我们采用 X 轴复制扩展的服务，它们的负载均衡策略很简单，只需要选择负载最小的上游服务器即可，比如 RoundRobin 或者最小连接算法都可以达到目的。但若上游服务器通过 Y 轴扩展，开启了缓存功能，那么考虑到缓存的命中率，就必须改用 Z 轴扩展的方式，基于用户信息做哈希规则下的新路由，尽量将同一个用户的请求命中相同的上游服务器，才能充分提高缓存命中率。

Z 轴扩展还有一个好处，就是可以充分利用 IDC 与用户间的网速差，选择更快的 IDC 为用户提供高性能服务。网络是基于光速传播的，当 IDC 跨城市、国家甚至大洲时，用户访问不同 IDC 的网速就会有很大差异。当然，同一地域内不同的网络运营商之间，也会有很大的网速差。

例如你在全球都有 IDC 或者公有云服务器时，就可以通过域名为当地用户就近提供服务，这样性能会高很多。事实上，CDN 技术就基于 IP 地址的位置信息，就近为用户提供静态资源的高速访问。

下图中，我使用了 2 种 Z 轴扩展系统的方式。首先是基于客户端的地理位置，选择不同的 IDC 就近提供服务。其次是将不同的用户分组，比如免费用户组与付费用户组，这样在业务上分离用户群体后，还可以有针对性地提供不同水准的服务。

沿 AKF Z 轴扩展系统可以解决数据增长带来的性能瓶颈，也可以基于数据的空间位置提升系统性能，然而它的实施成本比较高，尤其是在系统宕机、扩容时，一旦路由规则发生变化，会带来很大的数据迁移成本，[第 24 讲] 我将要介绍的一致性哈希算法，其实就是用来解决这一问题的。

小结

这一讲我们介绍了如何基于 AKF 立方体的 X、Y、Z 三个轴扩展系统提升性能。

X 轴扩展系统时实施成本最低，只需要将程序复制到不同的服务器上运行，再用下游的负载均衡分配流量即可。X 轴只能应用在无状态进程上，故无法解决数据增长引入的性能瓶颈。

Y 轴扩展系统时实施成本最高，通常涉及到部分代码的重构，但它通过拆分功能，使系统中的组件分工更细，因此可以解决数据增长带来的性能压力，也可以提升系统的总体效率。比如关系数据库的读写分离、表字段的垂直拆分，或者引入缓存，都属于沿 Y 轴扩展系统。

Z 轴扩展系统时实施成本也比较高，但它基于用户信息拆分数据后，可以在解决数据增长问题的同时，基于地理位置就近提供服务，进而大幅度降低请求的时延，比如常见的 CDN 就是这么提升用户体验的。但 Z 轴扩展系统后，一旦发生路由规则的变动导致数据迁移时，运维成本就会比较高。

当然，X、Y、Z 轴的扩展并不是孤立的，我们可以同时应用这 3 个维度扩展系统。分布式系统非常复杂，AKF 给我们提供了一种自上而下的方法论，让我们能够针对不同场景下的性能瓶颈，以最低的成本提升性能。

思考题

最后给你留一道思考题，我们在谈到 Z 轴扩展时（比如关系数据库的分库分表），提到了基于哈希函数来设置路由规则，请结合[第 3 讲] 的内容，谈谈你认为应该如何设计哈希函数，才能使它满足符合 Z 轴扩展的预期？期待你的总结。

感谢阅读，如果你觉得这节课对你有一些启发，也欢迎把它分享给你的朋友。

22 | NWR算法：如何修改读写模型以提升性能？

你好，我是陶辉。

前两讲我们介绍数据库的扩展时，写请求仍然在操作中心化的 Master 单点，这在很多业务场景下都是不可接受的。这一讲我将介绍对于无单点的去中心化系统非常有用的 NWR 算法，它可以灵活地平衡一致性与性能。

最初我们仅在单机上部署数据库，一旦性能到达瓶颈，我们可以基于 AKF Y 轴将读写分离，这样多个 Slave 从库将读操作分流后，写操作就可以独享 Master 主库的全部性能。然而主库作为中心化的单点，一旦宕机，未及时同步到从库的数据就有可能丢失。而且，这一架构下，主库的故障还会导致整个系统瘫痪。

去中心化系统中没有“Master 主库”这一概念，数据存放在多个 Replication 冗余节点上，且这些节点间地位均等，所以没有单点问题。为了保持强一致性，系统可以要求修改数据时，必须同时写入所有冗余节点，才能向客户端返回成功。但这样系统的可用性一定很成问题，毕竟大规模分布式系统中，出现故障是常态，写入全部节点的操作根本无法容错，任何 1 个节点宕机都会造成写操作失败。而且，同步节点过多也会导致写操作性能低下。

NWR 算法提供了一个很棒的读写模型，可以解决上述问题。这里的“NWR”，是指在去中心化系统中将 1 份数据存放在 N 个节点上，每次操作时，写 W 个节点、读 R 个节点，只要调整 W、R 与 N 的关系，就能动态地平衡一致性与性能。NWR 在 NoSQL 数据库中有很广泛的应用，比如 Amazon 的 Dynamo 和开源的 Cassandra，这些数据库往往跨越多个 IDC 数据中心，包含成千上万个物理机节点，适用于海量数据的存储与处理。

这一讲，我们将介绍 NWR 算法的原理，包括它是怎样调整读写模型来提升性能的，以及 Cassandra 数据库是如何使用 NWR 算法的。

从鸽巢原理到 NWR 算法

NWR 算法是由鸽巢原理得来的：如果 10 只鸽子放入 9 个鸽巢，那么有 1 个鸽巢内至少有 2 只鸽子，这就是鸽巢原理，如下图所示：

图片来源：https://zh.wikipedia.org/wiki/%E9%B4%BF%E5%B7%A2%E5%8E%9F%E7%90%86

你可以用反证法证明它。鸽巢原理虽然简单，但它有许多很有用的推论。比如[第 3 课] 介绍了很多解决哈希表冲突的方案，那么，哈希表有没有可能完全不出现冲突呢？鸽巢原理告诉我们，只要哈希函数输入主键的值范围大于输出索引，出现冲突的概率就一定大于 0；只要存放元素的数量超过哈希桶的数量，就必然会发生冲突。

基于鸽巢原理，David K. Gifford 在 1979 年首次提出了Quorum 算法（参见《Weighted Voting for Replicated Data》论文），解决去中心化系统冗余数据的一致性问题。而 Quorum 算法提出，如果冗余数据存放在 N 个节点上，且每次写操作成功写入 W 个节点（其他 N - W 个节点将异步地同步数据），而读操作则从 R 个节点中选择并读出正确的数据，只要确保 W + R > N，同 1 条数据的读、写操作就不能并发执行，这样客户端就总能读到最新写入的数据。特别是当 W > N/2 时，同 1 条数据的修改必然是顺序执行的。这样，分布式系统就具备了强一致性，这也是 NWR 算法的由来。

比如，若 N 为 3，那么设置 W 和 R 为 2 时，在保障系统强一致性的同时，还允许 3 个节点中 1 个节点宕机后，系统仍然可以提供读、写服务，这样的系统具备了很高的可用性。当然，R 和 W 的数值并不需要一致，如何调整它们，取决于读、写请求数量的比例。比如当 N 为 5 时，如果系统读多写少时，可以将 W 设为 4，而 R 设为 2，这样读操作的性能会更好。

NWR 算法最早应用在 Amazon 推出的Dynamo 数据库中，你可以参见 2007 年 Amazon 发表的《Dynamo: Amazon’s Highly Available Key-value Store》论文。2008 年 Dynamo 的作者 Avinash Lakshman 跳槽到 FaceBook，开发了 Dynamo 的开源版数据库Cassandra，它是目前最流行的 NoSQL 数据库之一，在 Apple、Netflix、360 等公司得到了广泛的应用。想必你对 NWR 算法的很多细节并不清楚，那么接下来我们以 Cassandra 为例，看看 NWR 是如何应用在实际工程中的。

Cassandra 数据库是如何使用 NWR 算法的？

1 个 Cassandra 分布式系统可以由多个 IDC 数据中心、数万个服务器节点构成，这些节点间使用 RPC 框架通信，由于 Cassandra 推出时 gRPC（参见[第 18 课]）还没有诞生，因此它使用的是性能相对较低的 Thrift RPC 框架（Thrift 的优点是支持的开发语言更多）。同时，Cassandra 虽然使用宽列存储模型（每行最多可以包含20 亿列数据），但数据的分布是基于行 Key 进行的，它和 Dynamo 一样使用了一致性哈希算法，将 Key 对应的数据存储在多个节点中。关于一致性哈希算法，我们会在 [第 24 课] 再详细介绍。

Cassandra 对客户端提供一种类 SQL 的CQL 语言，你可以使用下面这行 CQL 语句设定数据存储的冗余节点个数，也就是 NWR 算法中的 N（也称为 Replication Factor）：

CREATE KEYSPACE excalibur
  WITH REPLICATION = {'class' : 'NetworkTopologyStrategy', 'dc1' : 3};

上面这行 CQL 语句设置了每行数据在数据中心 DC1 中存储 3 份冗余，即 N = 3，接下来我们通过下面的 CQL 语句，将读 R、写 W 的节点数都设置为 1：

cqlsh> CONSISTENCY ONE
Consistency level set to ONE.
cqlsh> CONSISTENCY
Current consistency level is ONE.

此时，Cassandra 的性能最高，但达成最终一致性的耗时最长，丢数据风险也最大。如果业务上对丢失少量数据不太在意，可以采用这种模型。此时修改数据时，客户端会并发地向 3 个存储节点写入数据，但只要 1 个节点返回成功，Cassandra 就会向客户端返回写入成功，如下图所示：

该图片及以下图片来源：https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/dml

上图中的系统由 12 个主机节点构成，由于数据采用一致性哈希算法分片，故构成了一个节点环。其中，本次写入的数据被分布到 1、3、6 这 3 个节点中存储。客户端可以随机连接到系统中的任何一个节点访问 Cassandra，此时该节点被称为 Coordinator Node，由它根据 NWR 的值来选择一致性模型，访问存储节点。

再来看读取数据的流程。下图中，作为 Coordinator Node 的节点 10 首先试图读取节点 1 中的数据，但发现节点 1 已经宕机，于是改选节点 6 并获取到数据，由于 R = 1 于是立刻向客户端返回成功。

如果我们将 R、W 都设置成 2，这就满足了 R + W > N(3) 的场景，此时系统具备了强一致性。客户端读写数据时，必须有 2 个节点返回，才算操作成功。比如下图中读取数据时，只有接收到节点 1、节点 6 的返回，操作才算成功。

上图中的蓝色线叫做 Read repair，如果节点 3 上的数据不一致，那么本次读操作可以将它修复为正确的数据。说完正常场景，我们再来看当一个节点出现异常时，NWR 是如何保持强一致性的。

下图中，客户端 1 在第 2 步，同时向 3 个存储节点写入了数据，由于节点 1、3 返回成功，所以写入操作实际已经完成了，但是节点 6 由于网络故障，却一直没有收到 Coordinator Node 发来的写入操作。在强一致性的约束下，客户端 2 在第 5 步发起的读请求，必须能获取到第 2 步写入的数据。然而，客户端 2 连接的 Coordinator Node 与客户端 1 不同，它选择了节点 3 和节点 6，这两个节点上的数据并不一致。根据不同的 timestamp 时间戳，Coordinator Node 发现节点 3 上的数据才是最后写入的数据，因此选择其上的数据返回客户端。这也叫 Last-Write-Win 策略。

图片来源：https://blog.scottlogic.com/2017/10/06/cassandra-eventual-consistency.html

Cassandra 提供了一个简单的方法，用于设置读写节点数量都过半，满足强一致性的要求，如下所示：

cqlsh> CONSISTENCY QUORUM
Consistency level set to QUORUM.
cqlsh> CONSISTENCY
Current consistency level is QUORUM.

最后我们再来看看多数据中心的部署方式。下图中 2 个数据中心各设置 N = 3，其中 R、W 则采用 QUORUM 一致性模型。当客户端发起写请求到达节点 10 这个 Coordinator Node 后，它选择本 IDC Alpha 的 1、3、6 节点存储数据，其中节点 3、6 返回成功后，IDC Alpha 便更新成功。同时找到另一 IDC Beta 的节点 11 存储数据，并由节点 11 将数据同步给节点 4 和节点 8。其中，只要节点 4 返回成功，IDC Beta 也就成功更新了数据，此时 Coordinator Node 会向客户端返回写入成功。

读取数据时，这 2 个 IDC 内必须由 4 个存储节点返回数据，才满足 QUORUM 一致性的要求。下图中，Coordinator Node 获取到了 IDC Alpha 中节点 1、3、6 的返回，以及 IDC Beta 中节点 11 的返回，就可以基于 timestamp 时间戳选择最新的数据返回客户端。而且 Coordinator Node 会并发地发起 Read repair，试图修复 IDC Beta 中可能存在不一致的节点 4 和 8。

Cassandra 还有许多一致性模型，比如 LOCAL_QUORUM 只要求本地 IDC 内有多数节点响应即可，而 EACH_QUORUM 则要求每个 IDC 内都必须有多数节点返回成功（注意，这与上图中 IDC Alpha 中有 3 个节点返回，而 IDC Beta 则只有 1 个节点返回的 QUORUM 是不同的）。你可以从这个页面找到 Cassandra 支持的所有一致性模型，但无论如何变化，都只是在引入数据中心、机架等概念后，局部性地调节 NWR 而已。

小结

这一讲我们介绍了鸽巢原理，以及由此推导出的 NWR 算法，并以流行的 NoSQL 数据库 Cassandra 为例，介绍了 NWR 在分布式系统中的实践。

当鸽子的数量超过了鸽巢后，就要注定某一个鸽巢内一定含有两只以上的鸽子，同样的道理，只要读、写操作涉及的节点超过半数，就注定读写操作总包含一个含有正确数据的节点。NWR 算法将这一原理一般化为：只要读节点数 R + 写节点数 W > 存储节点数 N，特别是 W > N/2 时，就能使去中心的分布式系统获得强一致性。

支持上万节点的 Cassandra 数据库，就使用了 NWR 算法来保持一致性。当然，Cassandra 支持多种一致性模型，当你需要更强劲的性能时，你可以令 R + W < N，当业务变化导致需要增强系统的一致性时，你可以实时地修改 R、W。Cassandra 也支持跨数据中心部署，此时的一致性模型更为复杂，但仍然将 NWR 算法作为实现基础。

思考题

最后给你留一道讨论题。你还知道哪些有状态服务使用了 NWR 算法吗？它与 NWR 在 Cassandra 中的应用有何不同？欢迎你在留言区中分享，也期待你能从大家的留言中总结出更一般化的规律。

感谢你的阅读，如果你觉得这节课对你有一些启发，也欢迎把它分享给你的朋友。

23 | 负载均衡：选择Nginx还是OpenResty？

你好，我是陶辉。

在[第 21 讲] 介绍 AKF 立方体时，我们讲过只有在下游添加负载均衡后，才能沿着 X、Y、Z 三个轴提升性能。这一讲，我们将介绍最流行的负载均衡 Nginx、OpenResty，看看它们是如何支持 AKF 扩展体系的。

负载均衡通过将流量分发给新增的服务器，提升了系统的性能。因此，我们对负载均衡最基本的要求，就是它的吞吐量要远大于上游的应用服务器，否则扩展能力会极为有限。因此，目前性能最好的 Nginx，以及在 Nginx 之上构建的 OpenResty，通常是第一选择。

系统接入层的负载均衡，常通过 Waf 防火墙承担着网络安全职责，系统内部的负载均衡则通过权限、流量控制等功能承担着 API 网关的职责，CDN 等边缘节点上的负载均衡还会承担边缘计算的任务。如果负载均衡不具备高度开放的设计，或者推出时间短、社区不活跃，我们就无法像搭积木一样，从整个生态中低成本地构建出符合需求的负载均衡。

很幸运的是，Nginx 完全符合上述要求，它性能一流而且非常稳定。从 2004 年诞生之初，Nginx 的模块化设计就未改变过，这样 16 年来累积下的各种 Nginx 模块都可以复用。它的2-clause BSD-like license 源码许可协议极其开放，即使修改源码后仍然可作商业用途，因此 Nginx 之上延伸出了 TEngine、OpenResty、Kong 等生态，这大大扩展了 Nginx 的能力边界。

接下来，我们就以 Nginx 以及建立了 Lua 语言生态的 OpenResty 为例，看看负载均衡是怎样扩展系统的，以及 Nginx 和同源的 OpenResty 有何不同。

负载均衡是如何扩展系统提升性能的？

通过 AKF 立方体 X 轴扩展系统时，负载均衡只需要能够透传协议，并选择负载最低的上游应用作为流量分发对象即可。这样，三层（网络层）、四层（传输层）负载均衡都可用于扩展系统，甚至在单个局域网内你还可以使用二层（数据链路层）负载均衡。其中，分发流量的路由算法既可以使用 RoundRobin 轮转算法，也可以基于 TCP 连接或者 UDP Session 使用最少连接数算法，如下图所示：

然而，基于 AKF Y 轴扩展系统时，负载均衡必须根据功能来分发请求，也就是说它必须解析完应用层协议，才能明白这是什么请求。因此，如 LVS 这样工作在三层和四层的负载均衡就无法满足需求了，我们需要 Nginx 这样的七层（应用层）负载均衡，它能够从请求中获取到描述功能的关键信息，并以此为依据路由请求。比如当 HTTP 请求中的 URL 描述功能时，Nginx 就可以用 location 匹配 URL，再基于 location 来路由请求，如下图所示：

基于 AKF Z 轴扩展时，如果只是使用了网络报文中的源 IP 地址，那么三层、四层负载均衡都能胜任。然而如果需要帐号、访问对象等用户信息扩展系统，仍然只能使用七层负载均衡从请求中获得。比如，Nginx 可以通过 $ 变量获取到 URL 参数或者 HEADER 头部的值，再以此作为路由算法的输入参数。

因此，七层负载均衡是分布式系统提升性能的必备工具。除了基于各种路由策略分发流量，提高性能及可用性（如宕机迁移）外，负载均衡还需要完成上、下游协议间的适配、转换。例如考虑到信息安全，跑在公网上的外部协议常基于 TLS/SSL 协议，而在效率优先的企业内网中，一般不会使用大幅降低性能的 TLS 协议，因此负载均衡需要拥有卸载或者装载 TLS 层的能力。

再比如，下游客户端多样且难以保持一致（比如 IE6 这个古董浏览器仍然存在于当下的互联网中），因此常使用 HTTP 协议与服务器通讯，而上游组件则依据开发团队或者系统架构的特点，会选择 CGI、uWSGI、gRPC 等协议，这样负载均衡还得拥有转换各种协议的功能。Nginx 可以通过反向代理模块，轻松适配各类协议，如下所示（通过 stream 模块，Nginx 也支持四层负载均衡）：

从性能角度，Nginx 支持 C10M 级别的并发连接，其原因你可以参考本专栏第 1、2 部分的内容。从功能角度，良好的模块化设计，使得 Nginx 可以完成各类协议的适配，不只包括第 3 部分课程介绍过的通用协议，甚至支持 Redis、MySQL 等专有协议。因此，Nginx 是目前最好用的负载均衡。

Nginx 上为什么可以执行 Lua 代码？

OpenResty 也非常流行，其实它就是 Nginx，只是通过扩展的 C 模块支持在 Nginx 中嵌入 Lua 语言，这样 Lua 模块构建出的生态就可以与 C 模块协作使用，大幅度提升了开发效率。我们先来看下 OpenResty 与 Nginx 之间的关系。

OpenResty 源代码由官方 Nginx、第三方 C 模块、Lua 语言模块以及一些工具脚本构成。编译 Nginx 时，OpenResty 将自己的第三方 C 模块按照 Nginx 的规则添加到可执行文件中，包括 ngx_http_lua_module 和 ngx_stream_lua_module 这两个 C 模块，它们允许 Lua 语言运行在 Nginx 进程中，如下图所示：

Lua 模块既能够享受 Nginx 的高性能，又通过“协程”（参见[第 5 讲]）、Lua 脚本语言提高了开发效率，这也是 OpenResty 最大的优点。我们先来看看 Lua 语言是怎么嵌入到 Nginx 中的。

Nginx 在进程启动、处理请求时提供了许多钩子函数，允许第三方 C 模块将其代码放在这些钩子函数中执行。同时，Nginx 还允许 C 模块自行解析 nginx.conf 中出现的配置项。这种架构允许 OpenResty 将 Lua 代码写进 nginx.conf 文件，再基于LuaJIT 即时编译到 Nginx 中执行。

ngx_http_lua_module 模块也正是通过 OpenResty 提供的以下 11 个指令嵌入 Lua 代码的：

在 Nginx 启动时嵌入 Lua 代码，包括 master 进程启动时执行的 init_by_lua 指令，以及每个 worker 进程启动时执行的 init_worker_by_lua 指令。

在重写 URL、访问权限控制等预处理阶段嵌入 Lua 代码，包括解析 TLS 协议后的 ssl_certificate_by_lua 指令（基于 openssl 的回调函数实现），设置动态变量的 set_by_lua 指令，重写 URL 阶段的 rewrite_by_lua 指令，以及控制访问权限的 access_by_lua 指令。

生成 HTTP 响应时嵌入 Lua 代码，包括直接生成响应的 content_by_lua 指令，连接上游服务前的 balancer_by_lua 指令，处理响应头部的 header_filter_by_lua 指令，以及处理响应包体的 body_filter_by_lua 指令。

记录 access.log 日志时嵌入 Lua 代码，通过 log_by_lua 指令实现。

如下图所示：

ngx_stream_lua_module 模块与之类似，这里不再赘述。

当然，如果 Lua 代码只是可以在 Nginx 进程中执行，它是无法处理用户请求的。我们还需要让 Lua 代码与 Nginx 中的 C 代码互相调用，去获取、改变 HTTP 请求、响应的内容。因此，ngx_http_lua_module 和 ngx_stream_lua_module 这两个模块通过FFI 技术，将 C 函数通过 Ngx 库中的 Lua API，暴露给纯 Lua 代码，如下图所示：

这样，通过 nginx.conf 文件中的 11 个指令，以及 FFI 技术提供的 SDK，Lua 代码才真正可以处理请求，Lua 生态从这里开始延伸，因此，OpenResty 上还提供了进一步提高开发效率的 Lua 模块库（参见 /usr/local/openresty/lualib/ 目录）。关于 FFI 技术及 OpenResty 提供的 HTTP SDK，你可以参考 《Nginx 核心知识 100 讲》中的第 147-154 课。

Nginx 与 OpenResty 的差别在哪里？

清楚了 OpenResty 与 Nginx 间的相同之处，我们再来看，二者默认编译出的 Nginx 可执行程序有何不同之处。

首先看版本差异。当你在官网下载 Nginx 时，会发现有 3 类版本：Mainline、Stable 和 Legacy。其中，Mainline 是单号版本，它是含有最新功能的主线版本，迭代速度最快。Stable 是 mainline 版本稳定运行一段时间后，将单号大版本转换为双号的稳定版本，比如 1.18.0 就是由 1.17.10 转换而来。Legacy 则是曾经的稳定版本，如下图所示：

你可以通过源代码中的 CHANGES 文件，通过 4 种不同类型的变更查看版本间的差异，包括：

表示新功能的 Feature，比如下图中 HTTP 服务新增的 auth_delay 指令。

表示已修复问题的 Bugfix。

表示已知特性变更的 Change，比如 Nginx 曾经允许 HTTP 请求头部中出现多个 Host 头部，但在 1.17.9 这个 Change 之后，这类 HTTP 请求将作为非法请求处理。

表示安全升级的 Security，比如 1.15.6 版本就修复了 CVE-2018-16843 等 3 个安全问题。

当你安装好了 OpenResty 或者 Nginx 后，你可以通过 nginx -v 命令查看它们的版本。你会发现，2014 年以后发布的 OpenResty 都是运行在单号 Mainline 版本上的：

# /usr/local/nginx/sbin/nginx -v
nginx version: nginx/1.18.0
# /usr/local/openresty/nginx/sbin/nginx -v
nginx version: openresty/1.15.8.3

通常我们会选择稳定的 Stable 版本，OpenResty 为什么会选择单号的 Mainline 版本呢？这是因为，Nginx 与 OpenResty 的版本发布频率完全不同，2012 年后 Nginx 每年大约发布 10 多个版本，如下图所示（versions 统计了每年发布的版本数，图中其他 3 条拆线统计了每年 feature、bugfix、change 的变更次数）：

OpenResty 每年的版本更新频率是个位数，特别是从 2018 年到现在，OpenResty 只发布了 4 个版本。所以为了使用尽量运行在最新版本上，OpenResty 选择了 Mainline 单号版本。

你可能会想，OpenResty 并没有修改 Nginx 的源代码，为什么不能由用户在官方 Nginx 上自行添加 C 模块，实现 OpenResty 的安装呢？这源于部分 OpenResty C 模块，没有按照 Nginx 架构指定的顺序添加到模块列表中，而且它们的编译环境也过于复杂。因此，OpenResty 放弃了 Nginx 官方的 configure 文件，用户需要使用 OpenResty 改造过的 configure 脚本编译 Nginx。

再来看模块间的差异。如果你留意 OpenResty 与 Nginx 间二进制文件的体积，会发现使用默认配置时，OpenResty 的可执行文件大了 5 倍，如下所示：

# ls -s --block-size=1 /usr/local/openresty/nginx/sbin/nginx
16437248 /usr/local/openresty/nginx/sbin/nginx
# ls -s --block-size=1 /usr/local/nginx/sbin/nginx
3851568 /usr/local/openresty/nginx/sbin/nginx

这由 2 个原因所致。

首先，官方 Nginx 提供的四层负载均衡功能（由 18 个 STREAM 模块实现）、TLS 协议处理功能，默认都是不添加到 Nginx 中的，而 OpenResty 的 configure 脚本将其改为了默认模块。当然，如果你在编译官方 Nginx 时，加入以下选项：

./configure --with-stream --with-stream_ssl_module --with-stream_ssl_preread_module --with-http_ssl_module

那么从官方模块上，Nginx 就与 OpenResty 完全一致了，此时再观察二进制文件的体积，会发现它翻了一倍：

# ls -s --block-size=1 /usr/local/openresty/nginx/sbin/nginx
6999144 /usr/local/openresty/nginx/sbin/nginx

其次，OpenResty 添加了近 20 个第三方 C 模块，除了前文介绍过支持 Lua 语言的 2 个模块外，还有支持 Redis、Memcached、MySQL 等服务的模块。这些模块编译时，还需要链接依赖的软件库，因此它们又将 Nginx 可执行文件的体积增加了 1 倍多。

除版本、模块外，OpenResty 与 Nginx 间还有一些小的差异，比如 Nginx 使用了 GCC 编译器的 -O1 优化参数，而 OpenResty 则使用了 -O2 优化参数。再比如，官方 Nginx 最新版本的 Makefile 支持 upgrade 参数，简化了热升级操作。当然，这些小改动并不重要，只要修改 configure 脚本就能做到。

我们到底该如何在二者中选择呢？我认为，如果不使用 Lua 语言，那么我建议使用 Nginx。官方 Nginx 的 Stable 版本更稳定，可执行文件的体积也更小。如果你需要使用 OpenResty、TEngine 中的部分 C 模块，可以通过–add-module 选项将其加入到官方 Nginx 中。

如果所有 C 模块都无法满足业务需求，你就应该选择 OpenResty。注意，Lua 语言给你带来极大灵活性的同时，也会引入许多不确定性。比如，如果你调用了会导致进程休眠的 Lua 阻塞函数（比如封装了系统调用的原生 Lua 库，或者第三方服务提供的同步 SDK），将会导致 Nginx 正在处理数万并发请求的 C 模块同时进入休眠，从而让服务的性能大幅度下降。

小结

这一讲，我们介绍了负载均衡的工作方式，以及 Nginx、OpenResty 这两个最流行的负载均衡之间的异同。

任何负载均衡都能从 AKF X 轴水平扩展系统，但只有能够解析应用层协议，获取到请求的功能、用户身份、访问对象等信息，才能够沿 AKF Y 轴、Z 轴全方位扩展系统。因此，七层负载均衡是分布式系统提升性能的利器。

Nginx 的开放式架构允许第三方模块通过 10 多个钩子函数，在不同的生命周期中处理请求。同时，还允许 C 模块自行解析 nginx.conf 配置文件。这样，OpenResty 就通过 2 个 C 模块，将 Lua 代码用 LuaJIT 编译到 Nginx 中执行，并通过 FFI 技术将 C 函数暴露给 Lua 代码。这就是 OpenResty 允许 Lua 语言与 C 模块协同处理请求的原因。

OpenResty 虽然就是 Nginx，但由于版本发布频率低于官方 Nginx，因此使用了单号的 Mainline 版本以获得 Nginx 的最新特性。由于 OpenResty 默认加入了四层负载均衡和 TLS 协议处理功能，还新增了近 20 个第三方 C 模块，这造成它编译出的 Nginx 体积大了 5 倍。如果无须使用 Lua 语言就能够满足业务需求，我推荐你使用 Nginx。

思考题

最后，留给你一道思考题。Nginx、OpenResty 都有着丰富、活跃的生态，这也是我们选择开源软件的一个前提。这节课我们提到开放的设计是形成生态的一个必要因素，然而这二者都有许多没有形成生态的竞争对手，你认为开源软件构建出庞大的生态，还需要哪些充分条件呢？欢迎你在留言区与大家一起探讨。

感谢阅读，如果你觉得这节课让你对负载均衡有了更深的了解，搞清楚了 OpenResty 与 Nginx 间的差别，也欢迎把它分享给你的朋友。

24 | 一致性哈希：如何高效地均衡负载？

你好，我是陶辉。

还记得我们在[第 22 讲] 谈到的 Cassandra 数据库吗？它将服务器节点组成一个环来存储数据，所使用的就是一致性哈希算法。那这一讲，我们就来看看一致性哈希算法是怎样工作的。

使用哈希算法扩展系统时，最大的问题在于代表哈希桶的服务器节点数发生变化时，哈希函数就改变了，数据与节点间的映射关系自然发生了变化，结果大量数据就得在服务器间迁移。特别是含有多份冗余数据的系统，迁移工作量更是会成倍提高。

同时，为了在整体上更加充分地使用 IT 资源，我们必须解决分布式系统扩展时可能出现的两个问题：数据分布不均衡和访问量不均衡。比如，对于包含 10 个服务器节点、持久化 1 亿条用户数据的有状态服务，如果采用关键字模 10（key%10）的哈希算法作为路由策略，就很难保证每个节点处理 1 千万条数据，那些可能还未达到一半设计容量的节点会浪费大量磁盘空间。

即使节点间存储的数据非常均匀，但这些数据间的活跃程度也不相同，存放热点数据较多的节点访问量非常大，很容易率先达到 CPU 瓶颈，在许多主机节点还很空闲时，我们就得扩容系统。

特别是我们很难保证集群内的所有节点都是同构的，如果哈希算法不能区别对待不同配置的服务器，也会抬高 IT 成本。

一致性哈希算法可以解决上述问题，它在许多流行的开源软件上都有很广泛的应用。这一讲我们将介绍一致性哈希算法的工作原理，以及如何通过虚拟节点提升算法的均衡性。

如何减少扩容、缩容时迁移的数据量？

在主机硬件达到性能瓶颈后，有状态服务可以沿 AKF 立方体 Z 轴（参见[第 21 讲]），基于哈希算法扩展为分布式系统。下图系统中拥有 5 个节点，哈希算法将每条数据的关键字模 5 得出的数字作为哈希桶序号，从而将数据映射到节点上（如果关键字是字符串或者其他结构化数据，可以先通过其他哈希算法转换为整数，再进行模运算）：

这个方案实现简单，运算速度也快，但它最大的问题是在系统扩容或者缩容时，必须迁移改变了映射关系的数据。然而，取模哈希函数中基数的变化，往往会导致绝大部分映射关系改变，比如上例中的 5 个关键字，在下图中集群节点数（即基数）从 5 降为 4 时，原映射关系全部失效，这 5 条数据都得迁移到其他节点：

1997 年发布的《Consistent Hashing and Random Trees》论文提出了一致性哈希算法，可以大幅度减少数据迁移量。一致性哈希算法是通过以下 2 个步骤来建立数据与主机节点间映射关系的：

首先，将关键字经由通用的哈希函数映射为 32 位整型哈希值。这些哈希值会形成 1 个环，最大的数字 232 相当于 0。

其次，设集群节点数为 N，将哈希环由小至大分成 N 段，每个主机节点处理哈希值落在该段内的数据。比如下图中，当节点数 N 等于 3 且均匀地分段时，节点 0 处理哈希值在 [0, 31​∗232] 范围内的关键字，节点 1 处理 [31​∗232, 32​∗232] 范围内的关键字，而节点 2 则处理的范围是 [32​∗232, 232]：

当然，在生产环境中主机节点很可能是异构的，所以我们要给高规格的服务器节点赋予更高的权重。一致性哈希算法改变节点的权重非常简单，只需要给每个节点分配更大的弧长即可。例如，如果上图中的节点 0 拥有更高的硬件配置，那么可以将原本均匀分布的 3 个节点调整为 2:1:1 的权重，这样节点 0 处理的哈希值范围调整为 [0, 231]，节点 1 的处理范围调整为 [231, 3∗230]，节点 2 的处理范围调整为 [3∗230, 232]，如下图所示：

而扩容、缩容时，虽然节点数发生了变化，但只要小幅度调整环上各节点的位置，就不会导致大量数据的迁移。比如下图中我们将 3 个节点的集群扩容为 4 个节点，只需要将节点 0 上一半的数据迁移至节点 3 即可，其他节点不受影响：

接下来我们从成本上分析下一致性哈希算法的优劣。假设总数据条数为 M，而节点个数为 N，先来看映射函数的时间复杂度。传统的哈希算法以 N 为基数执行取模运算，时间复杂度为 O(1)（参见[第 3 讲]）；一致性哈希算法需要将关键字先转换为 32 位整型（这 1 步的时间复杂度也是 O(1)），再根据哈希环中各节点的处理范围，找到所属的节点。由于所有节点是有序排列的，所以采用二分法，可以在 O(logN) 时间复杂度内，完成关键字到节点位置的映射。

再来评估下数据的迁移规模。节点变化会导致传统哈希算法的映射结果不可控，最坏情况下所有数据都需要迁移，所以它的数据迁移规模是 O(M)；对于一致性哈希算法，我们可以通过调整节点位置，任意设定迁移规模。在环中各节点均匀分布的情况下，数据迁移规模是 O(M/N)。

因此，一致性哈希算法的缺点是将映射函数的时间复杂度从 O(1) 提高到了 O(logN)，它的优点是将数据迁移规模从 O(M) 降低至 O(M/N)。由于数据条数 M 远大于主机节点数 N，而且数据迁移的成本很大，所以一致性哈希算法更划算，它的适用场景也更广！

如何通过虚拟节点提高均衡度？

一致性哈希算法虽然降低了数据的迁移量，但却遗留了两个问题没有解决。

首先，如果映射后哈希环中的数字分布不均匀，就会导致各节点处理的数据不均衡，从而降低了系统的运行效率与性能。在无法找出分布规律时，我们也无法通过调整环中节点的权重，平衡各节点处理的数据量。

其次，容灾与扩容时，哈希环上的相邻节点容易受到过大影响。比如下图中，当节点 0 宕机后，根据一致性哈希算法的规则，其上数据应该全部迁移到相邻的节点 1 上，这样，节点 1 的数据量、访问量都会迅速增加 1 倍，一旦新增的压力超过了节点 1 的处理能力上限，就会导致节点 1 崩溃，进而形成雪崩式的连锁反应：

系统扩容时也面临着同样的问题，除非同时调整环中各节点的位置，否则扩容节点也只会减轻相邻节点的负载。

当数据存在多份冗余时，这两类问题会被进一步放大。

那如何提高均衡性呢？在真实的数据节点与哈希环之间引入一个虚拟节点层，就可以解决上述问题。例如下图中的集群含有 4 个节点，但我们并不直接将哈希环分为 4 份，而是将它均匀地分为 32 份并赋予 32 个虚拟节点，因此每个虚拟节点会处理 227 个哈希值，再将 32 个虚拟节点通过某个哈希函数（比如 CRC32）映射到 4 个真实节点上（比如图中 8 个绿色虚拟节点皆由同色的主机节点 0 处理）：

这样，如果图中绿色的节点 0 宕机，按照哈希环上数据的迁移规则，8 个绿色虚拟节点上的数据就会沿着顺时针方向，分别迁移至相邻的虚拟节点上，最终会迁移到真实节点 1（橙色）、节点 2（蓝色）、节点 3（水红色）上。所以，宕机节点上的数据会迁移到其他所有节点上。

扩容时也是一样的，通过虚拟节点环，新增节点可以分担现有全部节点的压力。至于虚拟节点为什么可以让数据的分布更均衡，这是因为在虚拟节点与真实节点间，又增加了一层哈希映射，哈希函数会将原本不均匀的数字进一步打散。上图为了方便你理解，每个真实节点仅包含 8 个虚拟节点，这样能起到的均衡效果其实很有限。而在实际的工程中，虚拟节点的数量会大很多，比如 Nginx 的一致性哈希算法，每个权重为 1 的真实节点就含有160 个虚拟节点。

当然，有了虚拟节点后，为异构的服务器节点设置权重也更方便。只需要为权重高的真实节点，赋予更多的虚拟节点即可。注意，虚拟节点增多虽然会提升均衡性，但也会消耗更多的内存与计算力。

上面我们仅讨论了数据分布的均衡性，当热点数据导致访问量不均衡时，因为这一新维度的信息还没有反馈在系统中，所以你需要搜集各节点的访问量信息，基于它来动态地调整真实节点的权重，进而从热点数据更多的节点中迁移出部分数据，以此提高均衡性。

小结

这一讲我们介绍了一致性哈希算法的工作原理。

传统哈希函数中，主机节点的变化会导致大量数据发生迁移。一致性哈希算法将 32 位哈希值构成环，并将它分段赋予各节点，这样，扩容、缩容动作就只影响相邻节点，大幅度减少了数据迁移量。一致性哈希算法虽然将数据的迁移量从 O(M) 降为 O(M/N)，却也将映射函数的时间复杂度从 O(1) 提高到 O(logN)，但由于节点数量 N 并不会很大，所以一致性哈希算法的性价比还是很高的。

当哈希值分布不均匀时，数据分布也不会均衡。在哈希环与真实节点间，添加虚拟节点层，可以通过新的哈希函数，分散不均匀的数据。每个真实节点含有的虚拟节点数越多，数据分布便会越均衡，但同时也会消耗更多的内存与计算力。

虚拟节点带来的最大优点，是宕机时由所有节点共同分担流量缺口，这避免了可能产生的雪崩效应。同时，扩容的新节点也会分流所有节点的压力，这也提升了系统整体资源的利用率。

思考题

最后，留给你一道思考题。提升数据分布、访问的平衡性，并不是只有一致性哈希这一个方案。比如，我们将数据与节点的映射关系放在另一个服务中持久化存储，通过反向代理或者客户端 SDK，在访问数据节点前，先从元数据服务中获取到数据的映射关系，再访问对应的节点，也是可以的，如下图所示：

你觉得上述方案与一致性哈希相比，有何优劣？各自适用的场景又是什么？欢迎你在留言区与大家一起探讨。

感谢阅读，如果你觉得这节课让你掌握了一致性哈希算法这个新工具，并能够用它提升分布式系统的运行效率，也欢迎把今天的内容分享给你的朋友。

25 | 过期缓存：如何防止缓存被流量打穿？

你好，我是陶辉。

这一讲我们将对一直零散介绍的缓存做个全面的总结，同时讨论如何解决缓存被流量打穿的场景。

在分布式系统中，缓存无处不在。比如，浏览器会缓存用户 Cookie，CDN 会缓存图片，负载均衡会缓存 TLS 的握手信息，Redis 会缓存用户的 session，MySQL 会缓存 select 查询出的行数据，HTTP/2 会用动态表缓存传输过的 HTTP 头部，TCP Socket Buffer 会缓存 TCP 报文，Page Cache 会缓存磁盘 IO，CPU 会缓存主存上的数据，等等。

只要系统间的访问速度有较大差异，缓存就能提升性能。如果你不清楚缓存的存在，两个组件间重合的缓存就会带来不必要的复杂性，同时还增大了数据不一致引发错误的概率。比如，MySQL 为避免自身缓存与 Page Cache 的重合，就使用直接 IO 绕过了磁盘高速缓存。

缓存提升性能的幅度，不只取决于存储介质的速度，还取决于缓存命中率。为了提高命中率，缓存会基于时间、空间两个维度更新数据。在时间上可以采用 LRU、FIFO 等算法淘汰数据，而在空间上则可以预读、合并连续的数据。如果只是简单地选择最流行的缓存管理算法，就很容易忽略业务特性，从而导致缓存性能的下降。

在分布式系统中，缓存服务会为上游应用挡住许多流量。如果只是简单的基于定时器淘汰缓存，一旦热点数据在缓存中失效，超载的流量会立刻打垮上游应用，导致系统不可用。

这一讲我会系统地介绍缓存及其数据变更策略，同时会以 Nginx 为例介绍过期缓存的用法。

缓存是最有效的性能提升工具

在计算机体系中，各类硬件的访问速度天差地别。比如：

CPU 访问缓存的耗时在 10 纳秒左右，访问内存的时延则翻了 10 倍；

如果访问 SSD 固态磁盘，时间还要再翻个 1000 倍，达到 100 微秒；

如果访问机械硬盘，对随机小 IO 的访问要再翻个 100 倍，时延接近 10 毫秒；

如果跨越网络，访问时延更要受制于主机之间的物理距离。比如杭州到伦敦相距 9200 公里，ping 时延接近 200 毫秒。当然，网络传输的可靠性低很多，一旦报文丢失，TCP 还需要至少 1 秒钟才能完成报文重传。

可见，最快的 CPU 缓存与最慢的网络传输，有 1 亿倍的速度差距！一旦高速、低速硬件直接互相访问，前者就会被拖慢运行速度。因此，我们会使用高速的存储介质创建缓冲区，通过预处理、批处理以及缓冲数据的反复命中，提升系统的整体性能。

不只是硬件层面，软件设计对访问速度的影响更大。比如，对关系数据库的非索引列做条件查询，时间复杂度是 O(N)，而对 Memcached 做 Key/Value 查询，时间复杂度则是 O(1)，所以在海量数据下，两者的性能差距远高于硬件。因此，RabbitMQ、Kafka 这样的消息服务也会充当高速、低速应用间的缓存。

如果两个实体之间的访问时延差距过大，还可以通过多级缓存，逐级降低访问速度差，提升整体性能。比如[第 1 讲] 我们介绍过 CPU 三级缓存，每级缓存越靠近 CPU 速度越快，容量也越小，以此缓解 CPU 频率与主存的速度差，提升 CPU 的运行效率。

再比如下图的 Web 场景中，浏览器的本地缓存、操作系统内核中的 TCP 缓冲区（参见[第 11 讲]）、负载均衡中的 TLS 握手缓存、应用服务中的 HTTP 响应缓存、MySQL 中的查询缓存等，每一级缓存都缓解了上下游间不均衡的访问速度，通过缩短访问路径降低了请求时延，通过异步访问、批量处理提升了系统效率。当然，缓存使用了简单的 Key/Value 结构，因此可以用哈希表、查找树等容器做索引，这也提升了访问速度。

从系统底层到业务高层，缓存都大有用武之地。比如在 Django 这个 Python Web Server 中，既可以使用视图缓存，将动态的 HTTP 响应缓存下来：

from django.views.decorators.cache import cache_page

@cache_page(60 * 15)
def my_view(request):
    ...

也可以使用django-cachealot 这样的中间件，将所有 SQL 查询结果缓存起来:

INSTALLED_APPS = [
  ...
  'cachalot',
  ...
]

还可以在更细的粒度上，使用 Cache API 中的 get、set 等函数，将较为耗时的运算结果存放在缓存中：

cache.set('online_user_count', counts, 3600)
user_count = cache.get('online_user_count')

这些缓存的应用场景大相径庭，但数据的更新方式却很相似，下面我们来看看缓存是基于哪些原理来更新数据的。

缓存数据的更新方式

缓存的存储容量往往小于原始数据集，这有许多原因，比如：

缓存使用了速度更快的存储介质，而这类硬件的单位容量更昂贵，因此从经济原因上只能选择更小的存储容量；

负载均衡可以将上游服务的动态响应转换为静态缓存，从时间维度上看，上游响应是无限的，这样负载均衡的缓存容量就一定会不足；

即使桌面主机的磁盘容量达到了 TB 级，但浏览器要对用户访问的所有站点做缓存，就不可能缓存一个站点上的全部资源，在一对多的空间维度下，缓存一样是稀缺资源。

因此，我们必须保证在有限的缓存空间内，只存放会被多次访问的热点数据，通过提高命中率来提升系统性能。要完成这个目标，必须精心设计向缓存中添加哪些数据，缓存溢出时淘汰出哪些冷数据。我们先来看前者。

通常，缓存数据的添加或者更新，都是由用户请求触发的，这往往可以带来更高的命中率。比如，当读请求完成后，将读出的内容放入缓存，基于时间局部性原理，它有很高的概率被后续的读请求命中。[第 15 讲] 介绍过的 HTTP 缓存就采用了这种机制。

对于磁盘操作，还可以基于空间局部性原理，采用预读算法添加缓存数据（参考[第 4 讲] 介绍的 PageCache）。比如当我们统计出连续两次读 IO 的操作范围也是连续的，就可以判断这是一个顺序读 IO，如果这个读 IO 获取 32KB 的数据，就可以在这次磁盘中，多读出 128KB 的数据放在缓存，这会带来 2 个收益：

首先，通过减少定位时间提高了磁盘工作效率。机械磁盘容量大价格低，它的顺序读写速度由磁盘旋转速度与存储密度决定，通常可以达到 100MB/s 左右。然而，由于机械转速难以提高（服务器磁盘的转速也只有 10000 转 /s），磁头定位与旋转延迟大约消耗了 8 毫秒，因此对于绝大部分时间花在磁头定位上的随机小 IO（比如 4KB），读写吞吐量只有几 MB。

其次，当后续的读请求命中提前读入缓存的数据时，请求时延会大幅度降低，这提升了用户体验。

而且，并不是只有单机进程才能使用预读算法。比如公有云中的云磁盘，之所以可以实时地挂载到任意虚拟机上，就是因为它实际存放在类似 HDFS 这样的分布式文件系统中。因此，云服务会在宿主物理机的内存中缓存虚拟机发出的读写 IO，由于网络传输的成本更高，所以预读效果也更好。

写请求也可以更新缓存，你可以参考[第 20 讲] 我们介绍过 write through 和 write back 方式。其中，write back 采用异步调用回写数据，能通过批量处理提升性能。比如 Linux 在合并 IO 的同时，也会像电梯运行一样，每次使磁头仅向一个方向旋转写入数据，提升机械磁盘的工作效率，因此得名为电梯调度算法。

说完数据的添加，我们再来看 2 种最常见的缓存淘汰算法。

首先来看FIFO(First In, First Out) 先入先出淘汰算法。[第 16 讲] 介绍的 HTTP/2 动态表，会将 HTTP/2 连接上首次出现的 HTTP 头部，缓存在客户端、服务器的内存中。由于它们基于相同的规则生成，所以拥有相同的动态表序号。这样，传输 1-2 个字节的表序号，要比传输几十个字节的头部划算得多。当内存容量超过 SETTINGS_HEADER_TABLE_SIZE 阈值时，会基于 FIFO 算法将最早缓存的 HTTP 头部淘汰出动态表。

图片参见wiki：https://en.wikipedia.org/wiki/FIFO_(computing_and_electronics)

再比如[第 14 讲] 介绍的 TLS 握手很耗时，所以我们可以将密钥缓存在客户端、服务器中，等再次建立连接时，通过 session ID 迅速恢复 TLS 会话。由于内存有限，服务器必须及时淘汰过期的密钥，其中，Nginx 也是采用 FIFO 队列淘汰 TLS 缓存的。

其次，LRU(Less Recently Used) 也是最常用的淘汰算法，比如 Redis 服务就通过它来淘汰数据，OpenResty 在进程间共享数据的 shared_dict 在达到共享内存最大值后，也会通过 LRU 算法淘汰数据。LRU 通常使用双向队列实现（时间复杂度为 O(1)），队首是最近访问的元素，队尾就是最少访问、即将淘汰的元素。当访问了队列中某个元素时，可以将其移动到队首。当缓存溢出需要淘汰元素时，直接删除队尾元素，如下所示：

以上我只谈了缓存容量到达上限后的淘汰策略，为了避免缓存与源数据不一致，在传输成本高昂的分布式系统中，通常会基于过期时间来淘汰缓存。比如 HTTP 响应中的 Cache-Control、Expires 或者 Last-Modified 头部，都会用来设置定时器，响应过期后会被淘汰出缓存。然而，一旦热点数据被淘汰出缓存，那么来自用户的流量就会穿透缓存到达应用服务。由于缓存服务性能远大于应用服务，过大的流量很可能会将应用压垮。因此，过期缓存并不能简单地淘汰，下面我们以 Nginx 为例，看看如何利用过期缓存提升系统的可用性。

Nginx 是如何防止流量打穿缓存的？

当热点缓存淘汰后，大量的并发请求会同时回源上游应用，其实这是不必要的。比如下图中 Nginx 的合并回源功能开启后，Nginx 会将多个并发请求合并为 1 条回源请求，并锁住所有的客户端请求，直到回源请求返回后，才会更新缓存，同时向所有客户端返回响应。由于 Nginx 可以支持 C10M 级别的并发连接，因此可以很轻松地锁住这些并发请求，降低应用服务的负载。

启用合并回源功能很简单，只需要在 nginx.conf 中添加下面这条指令即可：

proxy_cache_lock on;

如果上游服务确实不可用，那有没有办法可以通过 Nginx 提供降级服务呢？所谓“服务降级”，是指部分服务出现故障后，通过有策略地放弃一些可用性，来保障核心服务的运行，这也是[第 20 讲] BASE 理论中 Basically Available 的实践。如果 Nginx 上持有着过期的缓存，那就可以通过牺牲一致性，向用户返回过期缓存，以保障基本的可用性。比如下图中，Nginx 会直接将过期缓存返回给客户端，同时也会一直试图更新缓存（proxy_cache_background_update 指令决定是由用户请求还是异步请求回源上游）。

开启过期缓存功能也很简单，添加下面这行指令即可：

proxy_cache_use_stale updating;

当然，上面两条 Nginx 指令只是开启了最基本的功能，如果你想进一步了解它们的用法，可以观看《Nginx 核心知识 100 讲》第 102 课。

小结

这一讲我们系统地总结了缓存的工作原理，以及 Nginx 解决缓存穿透问题的方案。

当组件间的访问速度差距很大时，直接访问会降低整体性能，在二者之间添加更快的缓存是常用的解决方案。根据时间局部性原理，将请求结果放入缓存，会有很大概率被再次命中，而根据空间局部性原理，可以将相邻的内容预取至缓存中，这样既能通过批处理提升效率，也能降低后续请求的时延。

由于缓存容量小于原始数据集，因此需要将命中概率较低的数据及时淘汰出去。其中最常用的淘汰算法是 FIFO 与 LRU，它们执行的时间复杂度都是 O(1)，效率很高。

由于缓存服务的性能远大于上游应用，一旦大流量穿透失效的缓存到达上游后，就可能压垮应用。Nginx 作为 HTTP 缓存使用时，可以打开合并回源功能，减轻上游压力。在上游应用宕机后，还可以使用过期缓存为用户提供降级服务。

思考题

最后，留给你一道讨论题。缓存并不总是在提高性能，回想一下，在你的实践中，有哪些情况是增加了缓存服务，但并没有提高系统性能的？原因又是什么？欢迎你在留言区与大家一起分享。

感谢阅读，如果你觉得这节课让你加深了对缓存的理解，也欢迎把它分享给你的朋友。

26 | 应用层多播：如何快速地分发内容？

你好，我是陶辉。

[第 7 讲] 我们曾介绍了网络层的 IP 协议是如何支持多播的，这节课我们再来从应用层看看如何实现多播功能。

当你的分布式集群只有十多个节点时，每次发布版本时，尽可以从发布服务器，将新版本的安装包通过 ftp、scp、wget 等工具分发到各个节点中。可是，一旦集群规模达到成千上万个节点时，再这么做就会带来很大的问题，文件分发的时长高达几个小时，甚至会打挂文件源终止分发流程。在微服务环境中这点尤为明显， 毕竟每个 Docker 镜象的体积动辄就在数百兆字节以上。

虽然网络层的 IP 协议允许通过路由器、交换机实现高效的多播，但 IP 层很难实现文件的可靠传输，而且跨越多个局域网时路由器等网络设备对 IP 多播的支持也不好。此时，通过应用层的接力传播，就能通过多播思想大幅提升系统的传输效率，解决上述问题。

除了分发文件场景外，应用层多播协议也常用于完全去中心化的分布式系统，特别是在管理成千上万个节点的状态时非常有用。比如 Gossip 就是这样一个多播协议，[第 22 讲] 介绍过的 Cassandra 数据库使用它来同步节点间的状态，比特币通过它传输账本数据，Redis 集群也使用它同步 Redis 进程间的状态。

那么这节课我们就重点介绍应用层中的多播协议，并以阿里的蜻蜓、Cassandra 中的 Gossip 协议为例，看看它们的工作原理。

认识应用层的多播协议

之所以需要应用层的多播协议，是因为网络层的 IP 多播功能（参见[第 7 讲] 的 IP 广播与组播）有以下 4 个方面的问题：

从功能上看，IP 多播缺失了质量控制、可靠性传输等特性，无法满足绝大部分场景中的要求；

从管理上看，监控跨网络的多播报文并不容易，多播地址的管理也很复杂，而且多播很容易造成网络洪峰及安全问题；

从市场上看，单播报文在终端上的计费很成熟，相反，运营商对多播报文的计费要困难许多，相对缺乏推进动力；

从产业协作上看，IP 多播必须由各设备厂商的路由器、交换机配合，由于网络层是由内核实现的，所以还要同步升级操作系统。

这些因素都使得网络层的多播功能难以推广，因此，各类基于 IP 单播功能的应用层多播协议应运而生。可能有些同学对于单播、多播这些网络知识还不熟悉，下图我将传统单播、网络层多播、应用层多播放在一起对比着看，你可以很容易看到它们的区别。

这里仍然以版本发布场景为例，主机 1 是发布节点，它需要将文件分发到其余 3 台主机上，其中传统单播协议的玩法，是在主机 2、3、4 上分别向主机 1 发起下载文件命令，因此主机 1 上有 3 条 TCP 下载链路，图中我用 3 种不同的颜色表示。很明显，此时主机 1 的下行流量一定会成为瓶颈，而且随着集群规模的扩大，网络链路中的路由器也会很快达到流量瓶颈。

再来看效率最高的网络层多播，主机 1 可以仅通过 1 次报文传输，将文件分发到所有主机上。在整个流程中，仅由路由器将网络报文扩散到相邻的主机、路由器上，没有任何多余的动作。如同上述所说，网络层多播的使用有很多困难，这里列出它是为了方便与应用层多播作对比。

在上图的应用层多播中，文件传输分为两个阶段。首先，主机 2、3 直接从主机 1 中下载文件（参见绿色与红色线）。其次，当主机 3 完成下载后，主机 4 再从主机 3 上下载文件（参见蓝色线）。可见，相对于 IP 多播，应用层多播有以下 3 个缺点：

首先网络效率下降了不少，这由 2 个原因所致：

数据在网络中有冗余，比如主机 1 将数据重复发送了 2 次（参见红色、绿色线），主机 3 既接收了一次数据，也发送了一次数据；

虽然图中主机 4 从同一局域网中的主机 3 下载数据，但在复杂的互联网环境中，应用层很难掌握完整的路由器组网信息，主机 4 一旦跨网络从主机 2 上下载数据，这就增加了路由器 A 及网络链路的负载，效率进一步下降。

其次，由于传输路径变长了，文件传输的总完成时间也变长了。比如，主机 4 的总传输路径是：主机 1 -> 路由器 A -> 路由器 B -> 主机 3 -> 路由器 B -> 主机 4，既多出了主机 3、路由器 B 这两个传输环节，而且进入主机 3 后，协议栈的处理深度也增加了。

最后，单次传输路径中引入了功能复杂的主机，相比仅由网络设备参与的 IP 多播，可靠性、稳定性也降低了。

说完缺点，我们再来看应用层多播的优点。

首先，它回避了 IP 多播的问题，无须改变现有组网环境，也不需要管理组播 IP 地址，立刻就可以应用在当下的生产环境中；

其次，在数以万计的大规模集群下，单一发布源很容易被流量打爆，进而导致分发流程停止，应用层多播可以避免这一问题；

再次，通过应用层节点的接力分发，整个传输带宽被大幅度提高了，分发速度有了数量级上的飞跃；

最后，如果分发集群跨越不同传输成本的网络（比如多个区域 IDC 构成的集群），在应用层也很容易控制分发策略，进而减少高成本网络的数据传输量，提升经济性。

所以，综合来说，集群规模越大，应用层多播的优势也越大。实际上十多年前，我们在使用 BT、迅雷下载时，就已经接触到应用层多播协议了，接下来我们结合 2 个服务器端的案例，看看多播协议的实现与应用。

应用层多播协议是如何工作的？

其实，应用层多播主要是指一种P2P（Peer to Peer）网络传输思想，任何基于 IP 单播的通讯协议都可以拿来使用。比如针对于在分布式集群中分发软件安装包的场景，完全可以使用 HTTP 协议实现应用层的分发，阿里巴巴开源的Dragonfly 蜻蜓就是这么做的。

蜻蜓拥有 1 个中心化的集群服务节点：SuperNode，其中既可以直接保存着源文件，也可以通过 HTTP 缓存加速第三方文件源（比如 Docker 仓库）。集群中的每个节点都要启动 dfget 进程（替代了传统的 wget），它就像我们平时使用的迅雷，在下载文件的同时，也会将自己下载完成的文件传输给其他节点。其中，通过 HTTP 的 Range 文件分段下载规范，dfget 就可以实现断点续传、多线程下载等功能，如下图所示：

图片及下图来源：https://github.com/DarLiner/Dragonfly/blob/master/docs/zh/architecture.md

各个 dfget 程序间通过 SuperNode 协调传输关系，这样绝大部分 dfget 程序并不需要从 SuperNode 节点下载文件。比如上图中的节点 C，通过 HTTP Range 协议从节点 A 中下载了文件的第 1 块，同时并发地从节点 B 中下载了文件的第 2 块，最后再把这些 Block 块拼接为完整的文件。

这里你可能会想，这不就是一个 P2P 下载工具么？是的，但你站在集群运维的角度，这就是基于应用层多播协议的文件分发工具。当每个节点部署 dfget 服务后，新版本安装包发布时，就可以由 SuperNode 节点推送，经由各个 dfget 进程以多播的形式分发下去，此时性能会获得大幅度的提升。

上图是传统的 wget 单播与蜻蜓多播分发文件的性能对比图。我们可以看到，传统方式下，分发客户端越多（Y 轴）总分发时长（X 轴）就越大，特别是 1200 个以上的并发节点下载文件时，会直接将文件源打爆。而采用应用层多播方式后，下载时长要低得多，而且伴随着节点数的增加，下载时长也不会增长。

蜻蜓虽然传输效率很高，但 SuperNode 却是保存着全局信息的中心节点，一旦宕机就会造成系统不可用。我们再来看去中心化的Gossip 流言协议是如何实现应用层多播的。

Gossip 协议也叫 epidemic 传染病协议，工作原理如下图所示。在这个分布式网络中，并没有任何中心化节点，但只要第 1 个种子节点被感染为红色后，每个节点只需要感染其相邻的、有限的几个节点，最终就能快速感染网络中的所有节点（即仅保证最终一致性）。

当然，所谓的“感染”就是数据的传输，这一算法由 1987 年发布的《Epidemic algorithms for replicated database maintenance》论文提出，同时证明了算法的收敛概率。Cassandra 数据库、Fabric 区块链、Consul 系统等许多去中心化的分布式系统，都使用 Gossip 协议管理集群中的节点状态。以 Cassandra 为例，每秒钟每个节点都会随机选择 1 到 3 个相邻节点，通过默认的 7000 端口传输包含节点状态的心跳信息，这样集群就可以快速发现宕机或者新增的节点。

图片来源：https://www.linkedin.com/pulse/gossip-protocol-inside-apache-cassandra-soham-saha

小结

这一讲我们介绍了应用层的多播协议。

网络层的 IP 多播功能有限，对网络环境也有过多的要求，所以很难通过多播协议提升传输效率。基于 IP 单播协议（如 TCP 或者 UDP），在应用代码层面实现分布式节点间的接力转发，就可以实现应用层的多播功能。

在分布式集群的文件分发场景中，阿里开源的Dragonfly 蜻蜓可以将发布节点上的源文件，通过 HTTP 协议推送到集群中的每个节点上，其中每个节点在应用层都参与了多播流量分发的实现。当节点数到达千、万级时，蜻蜓仍然能保持较低的分发时延，避免发布节点被下行流量打爆。

在完全去中心化的分布式集群中，每个节点都没有准确的全局信息，此时可以使用 Gossip 流言协议，通过仅向有限的相邻节点发送消息，完成整个集群的数据同步，实现最终一致性。因此，Gossip 协议常用于大规模分布集群中的节点状态同步。

思考题

最后，留给你一道讨论题。在 5G 完成设备层的组网后，类似华为 NewIP 这样的基础协议层也在做相应的重构，其中Multicast VPN 协议就将现有 IPv4 无法在公网中推广的多播功能，在 VPN 逻辑链路层实现了。你对未来多播协议的发展又是如何看的？欢迎你在留言区与大家一起探讨。

感谢阅读，如果你觉得这节课让你了解到应用层的多播协议，而通过它可以大幅度提升分布式集群的网络传输效率的话，也欢迎你把今天的内容分享给你的朋友。

27 | 消息队列：如何基于异步消息提升性能？

你好，我是陶辉。

在前 26 讲中我们介绍了许多异步实现机制，这节课我们来看看如何通过消息队列提升分布式系统的性能。

异步通讯是最常用的性能提升方式，比如 gRPC 提供的异步 API，或者基于 write-back 模式向缓存写入数据时，系统性能都可以提高。然而，对于复杂的大规模分布式系统，这些分散、孤立的异步实现机制，无法解决以下问题：

组件间耦合在一起，不只迭代变更时更为困难，而且当它们之间的性能有差异时，吞吐量较低的组件就会成为系统瓶颈；

当业务在时间上具有明显的峰谷访问差异时，实现削峰填谷需要一定的开发成本；

实现 BASE 理论中的 Basically Available 并不容易；

每个组件都要自行维护负载均衡组件，以此提供可伸缩性；

每个组件的请求格式、日志都不尽相同，因此系统总体的监控成本相对较高；

批量处理请求、异步化都可以提升性能，但每个组件独立实现这些基础功能付出的成本并非完全必要。

想必你肯定听过 Kafka、RabbitMQ、RocketMQ 这些流行的消息队列吧？通过消息队列实现组件间的异步交互方式，上述问题就会迎刃而解。这一讲我们就来看看如何在分布式系统中使用消息队列，以及高可用性又是如何保证的。

消息队列解决了哪些问题？

当进程中需要交互的两个模块性能差距很大时，我们会基于 FIFO 先入先出队列实现生产者消费者模型，通过调整生产者、消费者的数量，实现线程间的负载均衡。而且，生产者仅将任务添加至队列首部就可以返回，这种异步操作释放了它的性能。比如[第 13 课] 中接收心跳包的分发线程性能要比处理心跳包的工作线程性能高得多，两者间就通过单机上的消息队列提高了整体性能。

把单机中的 FIFO 队列放大到分布式系统中，就形成了独立的消息队列服务。此时，生产者、消费者的角色从线程变成了网络中的独立服务，如下图所示，生产者可以向消息队列发布多种消息，多个消费者也可以订阅同一种消息，如下图所示：

总结一下的话，消息队列就具备了以下 7 个优点：

降低了系统的耦合性。比如上图中，组件 2 发布了一条用户注册成功消息，原本只有负责通知用户注册结果的组件 3 在处理，如果组件 4 需要立刻开启新用户的营销工作，只需要同时向消息队列订阅即可；再比如，组件 2、组件 3、组件 4 通讯时并不需要统一应用层协议或者 RPC 接口，所有参与方只需要与消息队列服务的 SDK 打交道。

可伸缩性很容易实现。比如，当组件 3 的性能不足时，添加订阅消息的新实例，就可以通过水平扩展提升消费能力。反之，也可以扩展组件 1，提升消息的生产能力。

天然实现“削峰填谷”功能。消息队列服务会将消息持久化存储在磁盘中，在高峰期来不及处理的消息，会在低谷期被消费者服务处理完。通常，消息队列会使用廉价、高容量的机械磁盘存放消息，可以轻松缓存住高峰期超载的全部请求。

提高了系统可用性。首先，持久化到磁盘中的消息，在宕机故障时比内存中的请求有更高的可用性；其次，消息队列可以隔离故障，比如，消费者服务宕机后，生产者服务短期内不会受到影响；再次，当总吞吐量超过性能上限时，还可以设置不同的消息优先级，通过服务降级保障系统的基本可用性。

消息队列的生产者天然具备异步功能，这降低了生产者的请求处理时延，提升了用户体验。

[第 21 课] 介绍过，基于 AKF Y 轴拆分功能可以降低数据规模，而且组件间分工更细也会带来更深入的性能优化。当消息队列作为通讯方式时，这种“事件驱动”的分布式系统很容易通过消息实现服务拆分，成本会低很多。

消息队列服务对于各种消息的发布、消费情况都有统计，因此，从消息中就能获得业务的实时运行状态，以极低的成本实现系统的监控。

正是因为这么多的优点，所以消息队列成为了多数分布式系统必备的基础设施。而且，消息队列自身也拥有很高的性能，比如 RabbitMQ 单机每秒可以处理 10 万条消息，而 Kafka 单机每秒甚至可以处理百万条消息。消息队列的性能为什么如此夸张呢？除了消息队列处理逻辑简单外，还有一个重要原因，就是消息的产生、消费在时间上是连续的，这让消息队列在以下优化点上能获得很高的收益：

首先，在网络通讯中，很容易通过批量处理提高网络效率。比如生产者快速发布消息时，Kafka 的客户端 SDK 会自动聚集完一批消息，再一次性发送给 Broker，这样网络报文的有效载荷比会很高。

其次，在数据写入磁盘的过程中，由于时序性特征，存放消息的文件仅以追加形式变更，这样多数情况下机械硬盘的磁头仅朝一个方向转动，这让磁盘写入速度可以轻松达到 100MB/s。

最后，由于消费者也是按照 FIFO 规则有序接收消息的，这样消息队列的缓存就可以通过批量预读等优化方式，大幅提高读操作的缓存命中率。

而且，目前主流消息队列都支持集群架构，因此消息队列自身一般不会是性能瓶颈。

消息队列的服务质量是如何保证的？

为了提升整个分布式系统的性能，我们在处理消息时，还需要在生产端、消费端以及消息队列的监控上，做到以下 3 件事：

首先，虽然生产者会异步地发布消息，但毕竟需要接收到消息队列的确认，才构成完整的发布流程。网络传输是相对漫长、不可控的，所以在高性能场景中，生产者应基于多线程或者非阻塞 Socket 发布消息，以提高并发能力。

其次，当消费端性能不足需要扩容时，必须同步增加消息队列服务中的队列（在 Kafka 中叫做分区），才能允许新增的消费节点并行接收消息，提高消息的处理能力。否则，当多个消费者消费同一消息队列时，消息的有序性会导致多个消费节点串行处理消息，无法发挥出它们的全部性能，如下图所示：

最后，如果通过监控发现消息的消费能力小于生产能力，那就必须及时扩容消费端，或者降低消息的发布速度，否则消息就会积压，最终导致系统不可用。

接下来，我们再来看消息队列的 QoS（Quality of Service）是如何保证的，即：消息在传递过程中会不会丢失，以及接收方会不会重复消费消息。在MQTT 协议中，给消息队列定义了三种 QoS 级别：

at most once，每条消息最多只被传送一次，这意味着消息有可能丢失；

at least once，每条消息至少会传送一次，这意味着消息可能被重复消费；

exactly once，每条消息恰好只传送一次，这是最完美的状态。

需要 at most once 约束的场景较罕见，因此目前绝大部分消息队列服务提供的 QoS 约束都是 at least once，它是通过以下 3 点做到的：

生产端发布消息时，只有消息队列确定写入磁盘后，才会返回成功；

为防止消息队列服务出现故障后丢消息，我们也需要将数据存放在多个副本节点中。第 4 部分课程介绍的许多高可用策略，消息队列都会采用，比如 Kafka 就是使用[第 22 课] 介绍过的 NWR 算法来选出副本中的 Leader 节点，再经由它同步数据副本。

消费端必须在消费完消息（而不是收到消息）后，才能向消息队列服务返回成功。

这样，消息队列就能以很高的可用性提供 at least once 级别的 QoS。而 exactly once 是在 at least once 的基础上，通过幂等性 idempotency 实现的。对于一条“幂等性消息”，无论消费 1 次还是多次，结果都是一样的。因此，Kafka 通过消息事务和幂等性约束实现了exactly once 语义，其中，发布消息时 Kafka 会创建全局唯一的递增 ID，这样传输消息时它就能低成本地去除重复的消息，通过幂等性为单队列实现 exactly once 语义；针对生产者向多个分区发布同一条消息的场景，消息事务通过“要么全部成功要么全部失败”，也实现了 exactly once 语义。

小结

这一讲我们介绍了消息队列及其用法。

消息队列可以解耦分布式系统，其缓存的消息提供了削峰填谷功能，将消息持久化则提高了系统可用性，共享队列则为系统提供了可伸缩性，而且统计消息就可以监控整个系统，因此消息队列已成为当下分布式系统的必备基础设施。

虽然消息队列自身拥有优秀的性能，但若想提高使用效率，我们就需要确保在生产端实现网络传输上的并发，在消费端扩容时同步增加队列或者分区，并且需要持续监控系统，确保消息的生产能力小于消费能力，防止消息积压。

消息队列的 Qos 提供三种语义，其中 at most once 很少使用，而主流的 at least once 由消息持久化时的冗余，以及生产端、消息端使用消息的方式共同保障。Kafka 通过幂等性、事务消息这两个特性，在 at least once 的基础上提供了 exactly once 语义。

思考题

最后，留给你一道讨论题。你在实践中使用过消息队列吗？它主要帮你解决了哪些问题？欢迎你在留言区与大家一起探讨。

感谢阅读，如果你觉得这节课对你有所帮助，也欢迎把今天的内容分享给你的朋友。

28 | MapReduce：如何通过集群实现离线计算？

你好，我是陶辉。

接下来的 2 节课我将介绍如何通过分布式集群优化计算任务。这一讲我们首先来看对于有边界静态数据的离线计算，下一讲再来看对无边界数据流的实时计算。

对大量数据做计算时，我们通常会采用分而治之的策略提升计算速度。比如单机上基于递归、分治思想实现的快速排序、堆排序，时间复杂度只有 O(N*logN)，这比在原始数据集上工作的插入排序、冒泡排序要快得多（O(N2)）。然而，当单机磁盘容量无法存放全部数据，或者受限于 CPU 频率、核心数量，单机的计算时间远大于可接受范围时，我们就需要在分布式集群上使用分治策略。

比如，大规模集群每天产生的日志量是以 TB 为单位计算的，这种日志分析任务单台服务器的处理能力是远远不够的。我们需要将计算任务分解成单机可以完成的小任务，由分布式集群并行处理后，再从中间结果中归并得到最终的运算结果。这一过程由 Google 抽象为MapReduce 模式，实现在 Hadoop 等分布式系统中。

虽然 MapReduce 已经有十多个年头的历史了，但它仍是分布式计算的基石，这种编程思想在新出现的各种技术中都有广泛的应用。比如当在单机上使用 TensorFlow 完成一轮深度学习的时间过久，或者单颗 GPU 显存无法存放完整的神经网络模型时，就可以通过 Map 思想把数据或者模型分解给多个 TensorFlow 实例，并行计算后再根据 Reduce 思想合并得到最终结果。再比如知识图谱也是通过 MapReduce 思想并行完成图计算任务的。

接下来我们就具体看看如何在分布式集群中实现离线计算，以及 MapReduce 是怎样提供 SQL 语言接口的。

分而治之：如何实现集群中的批量计算？

分而治之的思想在分布式系统中广为使用，比如[第 21 讲] 介绍过的 AKF 立方体 Z 轴扩展，就是基于用户的请求，缩小集群中单个节点待处理的数据量，比如下图中当关系数据库中单表行数达到千万行以上时，此时不得不存放在磁盘中的索引将会严重降低 SQL 语句的查询速度。而执行分库分表后，由应用或者中间层的代理分解查询语句，待多个不足百万行的表快速返回查询结果后，再归并为最终的结果集。

与上述的 IO 类任务不同，并非所有的计算任务都可以基于分治策略，分解为可以并发执行的子任务。比如[第 14 讲] 介绍过的基于CBC 分组模式的 AES 加密算法就无法分解执行，如下图所示，每 16 个字节的块在加密时，都依赖前 1 个块的加密结果，这样的计算过程既无法利用多核 CPU，也无法基于 MapReduce 思想放在多主机上并发执行。

图片源自：https://zh.wikipedia.org/zh-hans/%E5%88%86%E7%BB%84%E5%AF%86%E7%A0%81%E5%B7%A5%E4%BD%9C%E6%A8%A1%E5%BC%8F

我们再来看可以使用 MapReduce 的计算任务，其中最经典的例子是排序（Google 在构建倒排索引时要为大量网页排序）。当使用插入排序（不熟悉插入排序的同学，可以想象自己拿了一手乱牌，然后在手中一张张重新插入将其整理有序）在整个数据集上操作时，计算的时间复杂度是 O(N2)，但快排、堆排序、归并排序等算法的时间复杂度只有 O(N*logN)，这就是通过分治策略，缩小子问题数据规模实现的。

比如下图是在 8 个数字上使用归并排序算法进行排序的流程。我们将数组递归地进行 3（log8）轮对半拆分后，每个子数组就只有 2 个元素。对 2 个元素排序只需要进行 1 次比较就能完成。接着，再将有序的子数组不断地合并，就可以得到完整的有序数组。

其中，将两个含有 N/2 个元素的有序子数组（比如 1、3、7、19 和 4、8、11、25），合并为一个有序数组时只需要做 N/2 到 N-1 次比较（图中只做了 5 次比较），速度非常快。因此，比较次数乘以迭代轮数就可以得出时间复杂度为 O(N*logN)。

同样的道理引申到分布式系统中，就成为了 MapReduce 模式。其中，原始数据集要通过 SPLIT 步骤拆分到分布式系统中的多个节点中，而每个节点并发执行用户预定义的 MAP 函数，最后将 MAP 运算出的结果通过用户预定义的 REDUCE 函数，归并为最终的结果。比如上例中我们可以将 8 个元素拆分到 2 个节点中并行计算，其中每个节点究竟是继续采用归并排序，还是使用其他排序算法，这由预定义的 MAP 函数决定。当 MAP 函数生成有序的子数组后，REDUCE 函数再将它们归并为完整的有序数组，具体如下图所示：

当面对 TB、PB 级别的数据时，MapReduce 思想就成了唯一的解决方案。当然，在实际软件工程中实现 MapReduce 的框架要比上面的示意图复杂许多，毕竟在大规模分布式系统中，故障每时每刻都会发生，如何分发数据、调度节点执行 MAP 映射、监控计算节点等，都需要精心的设计。特别是，当单个节点的磁盘无法存放下全部数据时，常常使用类似 HDFS 的分布式文件系统存放数据，所以 MapReduce 框架往往还需要对接这样的系统来获取数据，具体如下图所示：

图片来源：http://a4academics.com/tutorials/83-hadoop/840-map-reduce-architecture

而且，生产环境中的任务远比整数排序复杂得多，所以写对 Map、Reduce 函数并不容易。另一方面，大部分数据分析任务又是高度相似的，所以我们没有必要总是直接编写 Map、Reduce 函数，实现发布式系统的离线计算。由于 SQL 语言支持聚合分析、表关联，还内置了许多统计函数，很适合用来做数据分析，它的学习成本又非常低，所以大部分 MapReduce 框架都提供了类 SQL 语言的接口，可以替代自行编写 Map、Reduce 函数。接下来我们看看，SQL 语言统计数据时，Map、Reduce 函数是怎样工作的。

SQL 是如何简化 MapReduce 模式的？

我们以最常见的 Web 日志分析为例，观察用 SQL 语言做统计时，MapReduce 流程是怎样执行的。举个例子，Nginx 的 access.log 访问日志是这样的（基于默认的 combined 格式）：

127.0.0.1 - - [18/Jul/2020:10:16:15 +0800] "GET /login？userid=101 HTTP/1.1" 200 56 "-" "curl/7.29.0"

你可以通过正则表达式取出客户端 IP 地址、用户名、HTTP 响应码，这样就可以生成结构化的数据表格：

如果我们想按照客户端 IP、HTTP 响应码聚合统计访问次数，基于通用的 SQL 规则，就可以写出下面这行 SQL 语句：

select ClientIp, StatusCode, count(*) from access_log group by ClientIp, StatusCode

而建立在 MapReduce 之上的框架（比如 Hive）会将它翻译成如下图所示的 MapReduce 流程：

其中，我们假定 5 行数据被拆分到 2 个节点中执行 Map 函数，其中它们分别基于 2 行、3 行这样小规模的数据集，生成了正确的聚合统计结果。接着，在 Shuffle 步骤基于 key 关键字排序后，再交由 Reduce 函数归并出正确的结果。

除了这个例子中的 count 函数，像 max（求最大值）、min（求最小值）、distinct（去重）、sum（求和）、avg（求平均数）、median（求中位数）、stddev（求标准差）等函数，都很容易分解为子任务并发执行，最后归并出最终结果。

当多个数据集之间需要做交叉统计时，SQL 中的 join 功能（包括内连接、左外连接、右外连接、全连接四种模式）也很容易做关联查询。此时，我们可以在并行计算的 Map 函数中，把 where 条件中的关联字段作为 key 关键字，经由 Reduce 阶段实现结果的关联。

由于 MapReduce 操作的数据集非常庞大，还需要经由网络调度多台服务器才能完成计算，因此任务的执行时延至少在分钟级，所以通常不会服务于用户的实时请求，而只是作为离线的异步任务将运算结果写入数据库。

小结

这一讲我们介绍了在集群中使用分治算法统计大规模数据的 MapReduce 模式。

当数据量很大，或者计算时间过长时，如果计算过程可以被分解为并发执行的子任务，就可以基于 MapReduce 思想，利用分布式集群的计算力完成任务。其中，用户可以预定义在节点中并发执行的 Map 函数，以及将 Map 输出的列表合并为最终结果的 Reduce 函数。

虽然 MapReduce 将并行计算抽象为统一的模型，但开发 Map、Reduce 函数的成本还是太高了，于是针对高频场景，许多 MapReduce 之上的框架提供了类 SQL 语言接口，通过 group by 的聚合、join 连接以及各种统计函数，我们就可以利用整个集群完成数据分析。

MapReduce 模式针对的是静态数据，也叫有边界数据，它更多用于业务的事前或者事后处理流程中，而做事中处理时必须面对实时、不断增长的无边界数据流，此时 MapReduce 就无能为力了。下一讲我们将介绍处理无边界数据的流式计算框架。

思考题

最后，留给你一道思考题。你遇到过哪些计算任务是无法使用 MapReduce 模式完成的？欢迎你在留言区与大家一起探讨。

感谢阅读，如果你觉得这节课让你有所收获，也欢迎你把今天的内容分享给你的朋友。

29 | 流式计算：如何通过集群实现实时计算？

你好，我是陶辉。

上节课我们介绍了在有边界的存量数据上进行的 MapReduce 离线计算，这节课我们来看看对于无边界数据，怎样实时地完成流式计算。

对于不再变化的存量数据，可以通过分而治之的 MapReduce 技术将数据划分到多台主机上并行计算，由于待处理的数据量很大，我们只能获得分钟级以上的时延。当面对持续实时产生动态数据的场景时，业务上通常需要在秒级时延中及时地拿到运算结果。

比如，商家为了拉新促活，会为特定的用户群体（比如新用户或者不活跃用户）推出优惠活动，为了防止“羊毛党”通过大量主机并行地“薅羊毛”，系统要能实时地聚合分析所有优惠券的使用者特点，再基于业务规则及时地封掉“羊毛党”帐号或者 IP 地址，才可以控制住风险范围，提高营销活动的收益。那对于整个系统持续生成的大量订单数据，怎样才能提供秒级的聚合分析结果呢？

最初的流式计算方案，是在时间维度上定期地将数据分片，再基于 MapReduce 思想在空间维度的多台主机上实现并行计算，这样也能获得实时计算结果。然而，对每片数据执行批量计算，想要在秒级甚至毫秒级拿到计算结果并不容易。当网络不稳定时，数据会因为报文延误而乱序，简单的基于时序分片会导致计算结果失真。当数据之间具有明显的业务关系时，固定的时间窗口更是难以得到预期的分析结果。

接下来我们就深入学习一下流式计算的工作原理，以及流式计算常用的数据分片窗口。

流式计算是如何实现的？

在数据库、HDFS 等分布式系统中存放的静态数据，由于拥有清晰的边界，所以被称为 InBound Data 有边界数据。然而，线上运行中的互联网产品生命周期并不确定，它产生的数据有明确的开始，却没有截止时间点。对于这样有始无终的实时数据流，我们把它称为 OutBound Data 无边界数据，如下图所示：

从业务需求上看，有边界数据与无边界数据的计算目的是完全不同的。比如对于分布式监控系统，我们需要基于 IP 地址、用户帐号、请求类型等许多特征进行定时的聚合统计，例如获取每分钟内所有请求处理时延的平均值、中位数、最大值等，监控系统性能。此时，可以根据请求执行结果的产生时间对数据进行分片计算。比如，下表中有 7 条监控数据，需要求出每分钟请求时延的平均值。

如果我们按照分钟整点对数据进行分片，就可以在 02:00 时对蓝色的消息 1、2 求出窗口内的平均时延 192 毫秒，并立刻返回结果。之后当接收完红色的消息 3、4、5 后，在第 2 分钟结束时再对 3 个数字求出平均值。以此类推。

这种设计思想就是基于固定时间窗口的批处理解决方案。当然，并不是一定要等到时间窗口结束时，才对这一批次的所有数据统一计算。我们完全可以在每个消息到来时，就计算出中间状态，当所在的时间窗口结束时，再将中间状态转换为最终结果。仍然以上表为例，我们可以在每个监控事件到达时，计算出请求时延和以及当前窗口内的事件个数，这样，在窗口结束时我们只需要将时延和除以事件个数，就能得到平均值。

因此，中间状态可以更均衡地使用计算资源，提高流式计算的整体性能。我们既可以把中间状态放在内存中，也可以把它持久化到本地磁盘中获取更高的可用性，为了方便计算节点的调度，我们通常还会将备份状态存放至远端的数据库。

图片来源：https://flink.apache.org/

当然，流式计算最主要的性能提升思路，还是基于 MapReduce 思想，将同一窗口的数据从空间维度中分发到不同的计算节点进行并行的 Map 计算，再将 Map 映射出的结果 Reduce 为最终结果。由于流式计算天然是基于消息事件驱动的，因此它往往直接从 Kafka 等消息队列中获取输入数据，如同[第 27 讲] 的介绍，消息队列很容易协助流式计算实现数据拆分。

到这里，我们已经看到了实现流式计算的基本思路，其中基于固定时间窗口的数据划分方式还有很大的改进空间，目前它还无法解决较为复杂的有状态计算。所谓有状态计算，是指在时间窗口内，不同的消息之间会互相作用并影响最终的计算结果，比如求平均值就是这样一个例子，每个新到达的数据都会影响中间状态值。

相反，无状态计算处理到达的数据时，并不涉及窗口内的其他数据，处理流程要简单的多。例如，当监控到请求时延超过 3 秒时，就产生一条告警。此时，只需要单独地判断每个消息中的时延数据，就能够得到计算结果。

在真实的业务场景中，有状态计算还要更复杂。比如，对两个不同的数据源（可以理解为数据库中的表）做 join 连接时，采用内连接、外连接这两种不同的连接方式，就会影响到我们的时间窗口长度。再比如，当不同的事件具有逻辑关系时，窗口长度则应该由业务规则确定，不同的请求可能拥有不等的窗口大小。接下来，我们再来看看流式计算中的几种常见窗口。

如何通过窗口确定待计算的数据？

首先来看滑动窗口，它是从固定窗口衍生出的一种窗口。我们继续延续求每分钟平均值的例子，当业务上需要更平滑的曲线时，可以通过每 20 秒求最近 1 分钟请求时延的平均值实现，这就是滑动窗口，其中窗口长度则是 1 分钟，但每次计算完并不会淘汰窗口中的全部数据，而只是将步长向后移动 20 秒，即只淘汰最早 20 秒中的数据。当窗口长度与步长一致时，滑动窗口就退化成了固定窗口。

当然，我们还可以把窗口的计量单位从时间改为事件个数，此时可以称为计数窗口。仍然延续上面的例子，固定计数窗口可以改为求每 100 个访问记录的平均时延，滑动计数窗口可以改为每 10 条记录中求最近 100 个记录的平均时延。由于消息本身是有时序的，所以这些都可以称为时间驱动的窗口。事实上，还有另外一种事件驱动的窗口与此完全不同，如下图所示：

固定窗口、滑动窗口并不会解析业务字段，区别对待图中不同的 Key 关键字，这就很难解决以下这类场景：当需要统计用户在一个店铺内浏览的商品数量时，就需要针对用户的店铺停留时长来设计动态的窗口大小。毕竟不同的用户在不同的店铺内停留时长不可能相同，此时，动态的窗口大小可以通过事件来驱动，我们称为会话窗口。

事实上，我们还面临着信息统计准确性上的问题。在基于时间驱动的窗口中，这里的时间其实是事件到达流式系统时产生的系统处理时间，而不是事件发生的时间。仍然以访问日志为例，每条日志都有明确的请求访问时间，但在分布式系统传输时，由于网络波动的传输时延，以及各主机节点应用层的处理时延，这些事件到达流式计算框架的顺序已经发生了变化。如果仍然以固定的时间窗口来处理，就会得到错误的统计结果。

为了避免乱序事件扰乱统计结果，我们可以使用水位线 Watermark 减少乱序概率。比如下图中，消息队列中的数字表示事件时间，其中事件 7 先于事件 3、5 到达了流式计算系统：

如果设置了水位 4，窗口就不再以事件顺序严格划分，而是通过水位上的时间来划分窗口，这样事件 7 就会放在第 2 个窗口中处理：

当然，并不是有了水位线，第 1 个窗口就会无限制的等下去。在经历一个时间段后，第 1 个窗口会认定窗口关闭（这未必准确），它会处理 3、1、3、2 这 4 个事件。基于业务规则，下一个水位被设置为 9：

这样第 2 个窗口会处理 6、5、7 事件，而事件 9 就放在了第 3 个窗口中处理：

以此类推。根据业务特性和经验值，找到最大乱序时间差，再基于此设置合适的水位线，就能减轻乱序事件的影响。

小结

这一讲我们介绍了流式计算的实现原理，以及常用的几种分片窗口。

对于无边界的实时数据流，我们可以在时间维度上将其切分到不同的窗口中，再将每个窗口内的数据从空间维度上分发到不同的节点并行计算，在窗口结束时汇总结果，这就实现了流式计算。Apache Flink、Spark、Storm 等开源产品都是这样的流式计算框架。

通过不同的窗口划分规则，可以实现不同的计算目的，包括以时间驱动的固定窗口、滑动窗口和计数窗口，以及以事件驱动的会话窗口。为了避免乱序事件的影响，还可以通过携带超时时间的 Watermark 水位，基于事件发生时间更精准地划分窗口。

思考题

最后，留给你一道讨论题。你知道 Lambda 架构吗？它通过分开部署的 MapReduce、流式计算系统，分别完成离线计算与实时流计算，如下图所示：

图片来源：https://www.oreilly.com/radar/questioning-the-lambda-architecture/

这套系统的 IT 成本很高，因此大家致力于使用一套系统同时解决这两个问题。你认为这种解决方案是如何实现的？你又是如何看待流式计算发展方向的？欢迎你在留言区与大家一起探讨。

感谢阅读，如果你觉得这节课让你有所收获，也欢迎你把今天的内容分享给你的朋友。

30 | 如何权衡关系数据库与NoSQL数据库？

你好，我是陶辉。

到了第 4 部分课程的最后一讲，我们来结合前面介绍过的知识点，看看面对 NoSQL、关系数据库时该如何选择。

在分布式系统中，我们会同时使用多种数据库。比如，你可能会在 Redis 中存放用户 Session 会话，将业务数据拆解为由行、列构成的二维表存储在 MySQL 中，将需要全文检索的数据放在 ElasticSearch 中，将知识图谱放在 Neo4j 图数据库中，将数据量、访问量很大的数据放在 Cassandra 列式数据库或者 MongoDB 文档型数据库中，等等。

选择数据库时，我们的依据可能是访问速度，比如基于哈希表的 Redis 查询复杂度只有 O(1)，也可能从事务的支持程度上选择了关系数据库，甚至从应用层的开发效率上还给它添加了 Hibernate 等 ORM 框架，也可能从处理数据的体量上选择了 NoSQL 数据库。可是，除了各种实现层面上的差异外，各类 NoSQL 与关系数据库之间，有没有最本质的区别？在实际工程中，我们可否从此入手确定大方向，再从细微处选择不同的实现？

在我看来，答案就在于“关系”这两个字，这也是我权衡数据库时最先考虑的前提。接下来我们就沿着关系数据库的特性，看看 NoSQL 数据库究竟做了哪些改变，我们又该如何选择它们。

关系数据库的优点

关系数据库对业务层开发效率的提升有很大帮助。下面我们先基于一个简单的例子，看看关系数据库有何优点。疫情期间新增了一批能够测量体温的考勤机，通过关系数据库我们新建了用户、考勤机、考勤记录三张表，如下图所示：

在关系数据库中，表中的每行数据由多个从属于列的单一值（比如数字、字符串）构成。虽然表中可以存放任意行数据，但列却是预先定义且不变的，因此我们很容易通过行、列交汇处的单一值进行关联操作，进而完成各类业务目的不同的查询。比如，业务开发者可以通过下面这行 SQL 语句，找到体温超过 37 度的员工，上报其姓名、测量时间以及所在地理位置：

select user.name, record.time, machine.location from user, record, machine where user.id = record.user_id and machine.id = record.machine_id and record.temporature > 37;

运营人员则可以通过下面这行 SQL 语句，找出各类考勤机的使用频率：

select count(*), machine.id from machine, record where machine.id = record.machine_id group py machine.id;

因此，关系数据库可以通过预定义的关系，由数据库自身完成复杂的逻辑计算，为不同的场景提供数据服务。由于不同的数据间具有了关系，关系数据库还提供了“Transaction 事务”，用于保证相关数据间的一致性，这大大释放了应用开发者的生产力。所谓“事务”，会同时具有 ACID4 个特性：

Atomicity 原子性，指多个 SQL 语句组成了一个逻辑单位，执行时要么全部成功，要么全部失败。

Consistency 一致性，指数据库只能从一个一致性状态转换到另一个一致性状态。即使数据库发生了重启，仍然得维持一致性。

Isolation 隔离性，由于数据库可以支持多个连接并发操作，因此并发的事务间必须互相隔离才有意义。SQL 标准定义了以下 4 种隔离级别：

READ UNCOMMITTED 未提交读，它表示在事务 A 还未提交时，并发执行的事务 B 已经可以看到事务 A 改变的数据。这种隔离级别会带来很多问题，因此很少使用。

READ COMMITTED 提交读，它表示当事务 A 未提交时，事务 B 看不到事务 A 改变的任何数据，这是 PostgreSQL 数据库的默认隔离级别。

REPEATABLE READ 可重复读，指在 READ COMMITTED 的基础上解决了脏读问题。所谓脏读，是指在一个事务内，多次读取到同一数据时，结果可能不一致。这是 MySQL 数据库的默认隔离级别。

SERIALIZABLE 可串行化，它通过对每一行数据加锁，使得所有事务串行执行，虽然隔离性最好，但也大大降低了数据库的并发性，所以很少使用。

Durability 持久性，指一旦事务提交，事务所做的修改必须永久性地保存到数据库中。

可见，事务的 ACID 特性简化了本应由应用层完成的流程！这也是关系数据库与 NoSQL 数据库之间最大的差别。除事务外，关系数据库还在以下 4 点上降低了应用层的开发成本：

无论是商业版的 Oracle，还是开源的 MySQL、PostgreSQL，只要是关系数据库就拥有同样的数据模型，因此它们可以通过SQL 语言为应用层提供标准化、几乎没有差异的访问接口；

生产级的数据库对持久化都有良好的支持，全面的冷备、热备方案提供了很高的可用性；

通过索引、缓存等特性，当行数在亿级以下时，关系数据库的性能并不低；

关系数据库支持还不错的并发度，一般可以服务于上千个并发连接。

所以应用层会将许多计算任务放在关系数据库中，在此基础上还诞生了 MVC 等将数据层从业务中剥离、以关系数据库为中心的架构。

关系数据库的问题

虽然基于单一值的关系映射提供了事务等许多功能，但同时也引入了 3 个问题。

首先，内存中的数据结构非常多样，难以直接映射到行列交汇处的单一值上。不过，这个问题可以通过ORM（Object-relational mapping）框架解决。比如，Python 中的 Django ORM 框架，可以将上述 3 张表映射为内存中的 3 个类：

from django.db import models

class User(models.Model):
    name = models.CharField(max_length=20)

class Machine(models.Model):
    location = models.CharField(max_length=100)

class Record(models.Model):
    time = models.DateTimeField()
    temporature = models.FloatField()
    user = models.ForeignKey(User)
    machine= models.ForeignKey(Machine)

ORM 框架会为每张表生成 id 字段，而 Record 表将 User 和 Machine 表中的 id 字段作为外键（ForeignKey）互相关联在一起。于是，这 3 个类就映射了数据库中的那 3 张表，而内存中的对象（即类的实例）则映射为每张表中的一行数据。在 ORM 框架下，找到体温大于 37 度员工的那串长 SQL，可以转化为 OOP 中的函数调用，如下所示：

#gte表示大于等于
records = Record.objects.filter(temporature__gte = 37)
for r in records:
  print(r.user.name, r.machine.location, r.time)

相比起 SQL 语句，映射后的 OO 编程要简单许多。

其次，为了实现关系映射，每张表中的字段都得预先定义好，一旦在产品迭代过程中数据模型发生了变化，便需要同步完成以下 3 件事：

修改表结构；

修改应用层操作数据的代码；

根据新的规则转换、迁移已有数据。

在 ORM 中我们可以把这 3 步放在一个 migration 迁移脚本中完成。当然，如果数据迁移成本高、时间长，可以设计更复杂的灰度迁移方案。

最后是关系数据库固有的可伸缩性问题，这是各类 NoSQL 数据库不断诞生的主要原因。在[第 21 讲] 中，我们介绍过沿 AKF X 轴扩展的复制型主从结构，然而单点主库无法解决数据持续增长引入的性能问题。

沿 AKF Z 轴扩展数据库虽然能够降低数据规模，但分库分表后，单一值关系引申出的 ACID 事务不能基于高时延、会抖动的网络传输强行实现，否则会导致性能大幅下降，这样的可用性是分布式系统无法接受的。

因此，在单机上设计出的关系数据库，难以处理 PB 级的大数据。而 NoSQL 数据库放弃了单一值数据模型，非常适合部署在成千上万个节点的分布式环境中。

NoSQL 数据库是如何解决上述问题的？

虽然所有的 NoSQL 数据库都无法实现标准的 SQL 语言接口，但 NoSQL 绝不是“No SQL：拒绝 SQL 语言”的意思。当然，NoSQL 也不是“Not Only SQL：不只是 SQL 语言”的意思，否则 Oracle 也能算 NoSQL 数据库了。实际上，没有必要纠结 NoSQL 的字面含义，NoSQL 数据库只是放弃了与分布式环境相悖的 ACID 事务，提供了另一种聚合数据模型，从而拥有可伸缩性的非关系数据库。

NoSQL 数据库可以分为以下 4 类：

Key/Value 数据库，通常基于哈希表实现（参见[第 3 讲]），性能非常好。其中 Value 的类型通常由应用层代码决定，当然，Redis 这样的 Key/Value 数据库还可以将 Value 定义为列表、哈希等复合结构。

文档型数据库，在 Key/Value 数据库中，由于没有预定义的值结构，所以只能针对 Key 执行查询，这大大限制了使用场景。文档型数据库将 Value 扩展为 XML、JSON（比如 MongoDB）等数据结构，于是允许使用者在文档型数据库的内部解析复合型的 Value 结构，再通过其中的单一值进行查询，这就兼具了部分关系数据库的功能。

列式数据库，比如[第 22 讲] 介绍过的 Cassandra。列式数据库基于 Key 来映射行，再通过列名进行二级映射，同时它基于列来安排存储的拓扑结构，这样当仅读写大量行中某个列时，操作的数据节点、磁盘非常集中，磁盘 IO、网络 IO 都会少很多。列式数据库的应用场景非常有针对性，比如博客文章标签的行数很多，但在做数据分析时往往只读取标签列，这就很适合使用列式数据库。再比如，通过倒排索引实现了全文检索的 ElasticSearch，就适合使用列式存储存放 Doc Values，这样做排序、聚合时非常高效。

图数据库，在社交关系、知识图谱等场景中，携带各种属性的边可以表示节点间的关系，由于节点的关系数量多，而且非常容易变化，所以关系数据库的实现成本很高，而图数据库既没有固定的数据模型，遍历关系的速度也非常快，很适合处理这类问题。当然，我们日常见到的主要是前 3 类 NoSQL 数据库。

相对于关系数据库，NoSQL 在性能和易用性上都有明显的优点。

首先我们来看可用性及性能，这是 NoSQL 数据库快速发展的核心原因：

NoSQL 数据库的可伸缩性都非常好。虽然许多文档型、列式数据库都提供了类 SQL 语言接口，但这只是为了降低用户的学习成本，它们对跨节点事务的支持极其有限。因此，这些 NoSQL 数据库可以放开手脚，基于 Key/Value 模型沿 AKF Z 轴将系统扩展到上万个节点。

在数据基于 Key 分片后，很容易通过[第 28 讲] 介绍过的 MapReduce 思想，提高系统的计算能力。比如，MongoDB 很自然的就在查询接口中，提供了MapReduce 函数。

通过冗余备份，NoSQL 可以提供优秀的容灾能力。比如，Redis、Cassandra 等数据库，都可以基于[第 22 讲] 介绍过的 NWR 算法，灵活地调整 CAP 权重。

如果每个 Key 中 Value 存放的复合数据已经能满足全部业务需求，那么 NoSQL 的单机查询速度也会优于关系数据库。

其次再来看易用性，这主要体现在我们可以低成本地变更 Value 结构。虽然 NoSQL 数据库支持复合型 Value 结构，但并不限定结构类型。比如，文档型数据库中，同一个表中的两行数据，其值可以是完全不同的 JSON 结构；同样的，列式数据库中两行数据也可以拥有不同的列数。因此，当数据结构改变时，只需要修改应用层操作数据的代码，并不需要像关系数据库那样同时修改表结构以及迁移数据。

那么，到底该如何选择关系数据库与 NoSQL 数据库呢？其实，沿着“单一值关系”这一线索，我们已经找到了各自适用的场景。

如果多个业务数据间互相关联，我们需要从多个不同的角度分析、计算，并保持住相关数据的一致性，那么关系数据库最为适合。一旦数据行数到了亿级别以上，就需要放弃单一值结构，将单行数据聚合为复合结构，放在可以自由伸缩的 NoSQL 数据库中。此时，我们无法寄希望于 NoSQL 数据库提供 ACID 事务，只能基于二段式提交等算法在应用层代码中自行实现事务。

小结

这一讲我们介绍了关系数据库与 NoSQL 数据库各自的特点及其适用场景。

关系数据库通过行、列交汇处的单一值，实现了多种数据间的关联。通过统一的 SQL 接口，用户可以在数据库中实现复杂的计算任务。为了维持关联数据间的一致性，关系数据库提供了拥有 ACID 特性的事务，提升了应用层的开发效率。

虽然单一值无法映射内存中的复合数据结构，但通过 ORM 框架，关系数据库可以将表映射为面向对象编程中的类，将每行数据映射为对象，继续降低开发成本。然而，关系数据库是为单机设计的，一旦将事务延伸到分布式系统中，执行成本就会高到影响基本的可用性。因此，关系数据库的可伸缩性是很差的。

NoSQL 数据库基于 Key/Value 数据模型，可以提供几乎无限的可伸缩性。同时，将 Value 值进一步设计为复合结构后，既可以增加查询方式的多样性，也可以通过 MapReduce 提升系统的计算能力。实际上，关系数据库与每一类 NoSQL 数据库都有明显的优缺点，我们可以从数据模型、访问方式、数据容量上观察它们，结合具体的应用场景权衡取舍。

思考题

最后，留给你一道讨论题。你在选择 NoSQL 与关系数据库时是如何考虑的？欢迎你在留言区与大家一起探讨。

感谢阅读，如果你觉得这节课让你有所收获，也欢迎你把今天的内容分享给身边的朋友。

加餐3｜百万并发下Nginx的优化之道

你好，我是专栏编辑冬青。今天的课程有点特别，作为一期加餐，我为你带来了陶辉老师在 GOPS 2018 · 上海站的分享，以文字讲解 + PPT 的形式向你呈现。今天的内容主要集中在 Nginx 的性能方面，希望能给你带来一些系统化的思考，帮助你更有效地去做 Nginx。

优化方法论

今天的分享重点会看这样两个问题：

第一，如何有效使用每个连接分配的内存，以此实现高并发。

第二，在高并发的同时，怎样提高 QPS。

当然，实现这两个目标，既可以从单机中的应用、框架、内核优化入手，也可以使用类似 F5 这样的硬件设备，或者通过 DNS 等方案实现分布式集群。

而 Nginx 最大的限制是网络，所以将网卡升级到万兆，比如 10G 或者 40G 吞吐量就会有很大提升。作为静态资源、缓存服务时，磁盘也是重点关注对象，比如固态硬盘的 IOPS 或者 BPS，要比不超过 1 万转每秒的机械磁盘高出许多。

这里我们重点看下 CPU，如果由操作系统切换进程实现并发，代价太大，毕竟每次都有 5 微秒左右的切换成本。Nginx 将其改到进程内部，由 epoll 切换 ngx_connection_t 连接的处理，成本会非常低。OpenResty 切换 Lua 协程，也是基于同样的方式。这样，CPU 的计算力会更多地用在业务处理上。

从整体上看，只有充分、高效地使用各类 IT 资源，才能减少 RTT 时延、提升并发连接。

请求的“一生”

只有熟悉 Nginx 处理 HTTP 请求的流程，优化时才能做到有的放矢。

首先，我们要搞清楚 Nginx 的模块架构。Nginx 是一个极其开放的生态，它允许第三方编写的 C 模块与框架协作，共同处理 1 个 HTTP 请求。比如，所有的请求处理模块会构成一个链表，以 PipeAndFilter 这种架构依次处理请求。再比如，生成 HTTP 响应后，所有过滤模块也会依次加工。

1. 请求到来

试想一下，当用户请求到来时，服务器到底会做哪些事呢？首先，操作系统内核会将完成三次握手的连接 socket，放入 1 个 ACCEPT 队列（如果打开了 reuseport，内核会选择某个 worker 进程对应的队列），某个 Nginx Worker 进程事件模块中的代码，需要调用 accept 函数取出 socket。

建立好连接并分配 ngx_connection_t 对象后，Nginx 会为它分配 1 个内存池，它的默认大小是 512 字节（可以由 connection_pool_size 指令修改），只有这个连接关闭的时候才会去释放。

接下来 Nginx 会为这个连接添加一个默认 60 秒（client_header_timeout 指令可以配置）的定时器，其中，需要将内核的 socket 读缓冲区里的 TCP 报文，拷贝到用户态内存中。所以，此时会将连接内存池扩展到 1KB（client_header_buffer_size 指令可以配置）来拷贝消息内容，如果在这段时间之内没有接收完请求，则返回失败并关闭连接。

2. 处理请求

当接收完 HTTP 请求行和 HEADER 后，就清楚了这是一个什么样的请求，此时会再分配另一个默认为 4KB（request_pool_size 指令可以修改，这里请你思考为什么这个请求内存池比连接内存池的初始字节数多了 8 倍？）的内存池。

Nginx 会通过协议状态机解析接收到的字符流，如果 1KB 内存还没有接收到完整的 HTTP 头部，就会再从请求内存池上分配出 32KB，继续接收字符流。其中，这 32KB 默认是分成 4 次分配，每次分配 8KB（可以通过 large_client_header_buffers 指令修改），这样可以避免为少量的请求浪费过大的内存。

接下来，各类 HTTP 处理模块登场。当然，它们并不是简单构成 1 个链表，而是通过 11 个阶段构成了一个二维链表。其中，第 1 维长度是与 Web 业务场景相关的 11 个阶段，第 2 维的长度与每个阶段中注册的 HTTP 模块有关。

这 11 个阶段不用刻意死记，你只要掌握 3 个关键词，就能够轻松地把他们分解开。首先是 5 个阶段的预处理，包括 post_read，以及与 rewrite 重写 URL 相关的 3 个阶段，以及 URL 与 location 相匹配的 find_config 阶段。

其次是访问控制，包括限流限速的 preaccess 阶段、控制 IP 访问范围的 access 阶段和做完访问控制后的 post_access 阶段。

最后则是内容处理，比如执行镜象分流的 precontent 阶段、生成响应的 content 阶段、记录处理结果的 log 阶段。

每个阶段中的 HTTP 模块，会在 configure 脚本执行时就构成链表，顺序地处理 HTTP 请求。其中，HTTP 框架允许某个模块跳过其后链接的本阶段模块，直接进入下一个阶段的第 1 个模块。

content 阶段会生成 HTTP 响应。当然，其他阶段也有可能生成 HTTP 响应返回给客户端，它们通常都是非 200 的错误响应。接下来，会由 HTTP 过滤模块加工这些响应的内容，并由 write_filter 过滤模块最终发送到网络中。

3. 请求的反向代理

Nginx 由于性能高，常用来做分布式集群的负载均衡服务。由于 Nginx 下游通常是公网，网络带宽小、延迟大、抖动大，而上游的企业内网则带宽大、延迟小、非常稳定，因此 Nginx 需要区别对待这两端的网络，以求尽可能地减轻上游应用的负载。

比如，当你配置 proxy_request_buffering on 指令（默认就是打开的）后，Nginx 会先试图将完整的 HTTP BODY 接收完，当内存不够（默认是 16KB，你可以通过 client_body_buffer_size 指令修改）时还会保存到磁盘中。这样，在公网上漫长的接收 BODY 流程中，上游应用都不会有任何流量压力。

接收完请求后，会向上游应用建立连接。当然，Nginx 也会通过定时器来保护自己，比如建立连接的最长超时时间是 60 秒（可以通过 proxy_connect_timeout 指令修改）。

当上游生成 HTTP 响应后，考虑到不同的网络特点，如果你打开了 proxy_buffering on（该功能也是默认打开的）功能，Nginx 会优先将内网传来的上游响应接收完毕（包括存储到磁盘上），这样就可以关闭与上游之间的 TCP 连接，减轻上游应用的并发压力。最后再通过缓慢的公网将响应发送给客户端。当然，针对下游客户端与上游应用，还可以通过 proxy_limit_rate 与 limit_rate 指令限制传输速度。如果设置 proxy_buffering off，Nginx 会从上游接收到一点响应，就立刻往下游发一些。

4. 返回响应

当生成 HTTP 响应后，会由注册为 HTTP 响应的模块依次加工响应。同样，这些模块的顺序也是由 configure 脚本决定的。由于 HTTP 响应分为 HEADER（包括响应行和头部两部分）、BODY，所以每个过滤模块也可以决定是仅处理 HEADER，还是同时处理 HEADER 和 BODY。

因此，OpenResty 中会提供有 header_filter_by_lua 和 body_filter_by_lua 这两个指令。

应用层优化

1. 协议

应用层协议的优化可以带来非常大的收益。比如 HTTP/1 HEADER 的编码方式低效，REST 架构又放大了这一点，改为 HTTP/2 协议后就大有改善。Nginx 对 HTTP/2 有良好的支持，包括上游、下游，以及基于 HTTP/2 的 gRPC 协议。

2. 压缩

对于无损压缩，信息熵越大，压缩效果就越好。对于文本文件的压缩来说，Google 的 Brotli 就比 Gzip 效果好，你可以通过https://github.com/google/ngx_brotli 模块，让 Nginx 支持 Brotli 压缩算法。

对于静态图片通常会采用有损压缩，这里不同压缩算法的效果差距更大。目前 Webp 的压缩效果要比 jpeg 好不少。对于音频、视频则可以基于关键帧，做动态增量压缩。当然，只要是在 Nginx 中做实时压缩，就会大幅降低性能。除了每次压缩对 CPU 的消耗外，也不能使用 sendfile 零拷贝技术，因为从磁盘中读出资源后，copy_filter 过滤模块必须将其拷贝到内存中做压缩，这增加了上下文切换的次数。更好的做法是提前在磁盘中压缩好，然后通过 add_header 等指令在响应头部中告诉客户端该如何解压。

3. 提高内存使用率

只在需要时分配恰当的内存，可以提高内存效率。所以下图中 Nginx 提供的这些内存相关的指令，需要我们根据业务场景谨慎配置。当然，Nginx 的内存池已经将内存碎片、小内存分配次数过多等问题解决了。必要时，通过 TcMalloc 可以进一步提升 Nginx 申请系统内存的效率。

同样，提升 CPU 缓存命中率，也可以提升内存的读取速度。基于 cpu cache line 来设置哈希表的桶大小，就可以提高多核 CPU 下的缓存命中率。

4. 限速

作为负载均衡，Nginx 可以通过各类模块提供丰富的限速功能。比如 limit_conn 可以限制并发连接，而 limit_req 可以基于 leacky bucket 漏斗原理限速。对于向客户端发送 HTTP 响应，可以通过 limit_rate 指令限速，而对于 HTTP 上游应用，可以使用 proxy_limit_rate 限制发送响应的速度，对于 TCP 上游应用，则可以分别使用 proxy_upload_rate 和 proxy_download_rate 指令限制上行、下行速度。

5. Worker 间负载均衡

当 Worker 进程通过 epoll_wait 的读事件获取新连接时，就由内核挑选 1 个 Worker 进程处理新连接。早期 Linux 内核的挑选算法很糟糕，特别是 1 个新连接建立完成时，内核会唤醒所有阻塞在 epoll_wait 函数上的 Worker 进程，然而，只有 1 个 Worker 进程，可以通过 accept 函数获取到新连接，其他进程获取失败后重新休眠，这就是曾经广为人知的“惊群”现象。同时，这也很容易造成 Worker 进程间负载不均衡，由于每个 Worker 进程绑定 1 个 CPU 核心，当部分 Worker 进程中的并发 TCP 连接过少时，意味着 CPU 的计算力被闲置了，所以这也降低了系统的吞吐量。

Nginx 早期解决这一问题，是通过应用层 accept_mutex 锁完成的，在 1.11.3 版本前它是默认开启的：accept_mutex on;

其中负载均衡功能，是在连接数达到 worker_connections 的八分之七后，进行次数限制实现的。

我们还可以通过 accept_mutex_delay 配置控制负载均衡的执行频率，它的默认值是 500 毫秒，也就是最多 500 毫秒后，并发连接数较少的 Worker 进程会尝试处理新连接：accept_mutex_delay 500ms;

当然，在 1.11.3 版本后，Nginx 默认关闭了 accept_mutex 锁，这是因为操作系统提供了 reuseport（Linux3.9 版本后才提供这一功能）这个更好的解决方案。

图中，横轴中的 default 项开启了 accept_mutex 锁。我们可以看到，使用 reuseport 后，QPS 吞吐量有了 3 倍的提高，同时处理时延有明显的下降，特别是时延的波动（蓝色的标准差线）有大幅度的下降。

6. 超时

Nginx 通过红黑树高效地管理着定时器，这里既有面对 TCP 报文层面的配置指令，比如面对下游的 send_timeout 指令，也有面对 UDP 报文层面的配置指令，比如 proxy_responses，还有面对业务层面的配置指令，比如面对下游 HTTP 协议的 client_header_timeout。

7. 缓存

只要想提升性能必须要在缓存上下工夫。Nginx 对于七层负载均衡，提供各种 HTTP 缓存，比如 http_proxy 模块、uwsgi_proxy 模块、fastcgi_proxy 模块、scgi_proxy 模块等等。由于 Nginx 中可以通过变量来命名日志文件，因此 Nginx 很有可能会并行打开上百个文件，此时通过 open_file_cache，Nginx 可以将文件句柄、统计信息等写入缓存中，提升性能。

8. 减少磁盘 IO

Nginx 虽然读写磁盘远没有数据库等服务要多，但由于它对性能的极致追求，仍然提供了许多优化策略。比如为方便统计和定位错误，每条 HTTP 请求的执行结果都会写入 access.log 日志文件。为了减少 access.log 日志对写磁盘造成的压力，Nginx 提供了批量写入、实时压缩后写入等功能，甚至你可以在另一个服务器上搭建 rsyslog 服务，然后配置 Nginx 通过 UDP 协议，将 access.log 日志文件从网络写入到 rsyslog 中，这完全移除了日志磁盘 IO。

系统优化

最后，我们来看看针对操作系统内核的优化。

首先是为由内核实现的 OSI 网络层（IP 协议）、传输层（TCP 与 UDP 协议）修改影响并发性的配置。毕竟操作系统并不知道自己会作为高并发服务，所以很多配置都需要进一步调整。

其次，优化 CPU 缓存的亲和性，对于 Numa 架构的服务器，如果 Nginx 只使用一半以下的 CPU 核心，那么就让 Worker 进程只绑定一颗 CPU 上的核心。

再次，调整默认的 TCP 网络选项，更快速地发现错误、重试、释放资源。

还可以减少 TCP 报文的往返次数。比如 FastOpen 技术可以减少三次握手中 1 个 RTT 的时延，而增大初始拥塞窗口可以更快地达到带宽峰值。

还可以提高硬件资源的利用效率，比如当你在 listen 指令后加入 defer 选项后，就使用了 TCP_DEFER_ACCEPT 功能，这样 epoll_wait 并不会返回仅完成三次握手的连接，只有连接上接收到的 TCP 数据报文后，它才会返回 socket，这样 Worker 进程就将原本 2 次切换就降为 1 次了，虽然会牺牲一些即时性，但提高了 CPU 的效率。

Linux 为 TCP 内存提供了动态调整功能，这样高负载下我们更强调并发性，而低负载下则可以更强调高传输速度。

我们还可以将小报文合并后批量发送，通过减少 IP 与 TCP 头部的占比，提高网络效率。在 nginx.conf 文件中打开 tcp_nopush、tcp_nodelay 功能后，都可以实现这些目的。

为了防止处理系统层网络栈的 CPU 过载，还可以通过多队列网卡，将负载分担到多个 CPU 中。

为了提高内存、带宽的利用率，我们必须更精确地计算出 BDP，也就是通过带宽与 ping 时延算出的带宽时延积，决定 socket 读写缓冲区（影响滑动窗口大小）。

Nginx 上多使用小于 256KB 的小内存，而且我们通常会按照 CPU 核数开启 Worker 进程，这样一种场景下，TCMalloc 的性能要远高于 Linux 默认的 PTMalloc2 内存池。

作为 Web 服务器，Nginx 必须重写 URL 以应对网址变化，或者应用的维护，这需要正则表达式的支持。做复杂的 URL 或者域名匹配时，也会用到正则表达式。优秀的正则表达式库，可以提供更好的执行性能。

以上就是今天的加餐分享，有任何问题欢迎在留言区中提出。

加餐4 | 如何理解分布式系统？

你好，我是陶辉。课程到现在也已经接近尾声，我看到有的同学已经开始在掉队了。所以今天这讲，我准备来回答大家的一些高频问题。

咱们目前正在学习的这一模块叫“分布式系统优化”，我给你讲了监控、CAP、负载均衡、一致性哈希，说实话，这些知识都不简单，你如果觉得有点难，那也别气馁，因为它确实得多琢磨，我自己一开始学习的时候也是这样。

不过，我发现，在这个模块中，很多同学似乎对分布式有什么误解，有的人说分布式就是多台机器，有的人说分布式就是微服务，总之，大家各有自己的理解。于是，我就想着给你写篇加餐，来系统聊聊这个话题。

不过，在查资料的过程中，我发现 InfoQ 上已经有一篇文章很好地回答了这个问题。于是，经过编辑冬青的努力，我们找到了作者张帆，申请到了那篇文章的授权，在这里交付给你。

如果现在让你阐述一下什么是“分布式系统”，你脑子里第一下跳出来的是什么？我想，此时可以用苏东坡先生的一句诗，来形象地描述大家对分布式系统的认识：

横看成岭侧成峰，远近高低各不同。

“分布式系统”等于 SOA、ESB、微服务这些东西吗？

我觉得每个人脑子里一下子涌现出来的肯定是非常具象的东西，就像下面这些：

“分布式系统”等于 SOA、ESB、微服务这些东西吗？

如果你一下子想到的是 XX 中心、XX 服务，意味着你把服务化的模式（SOA、ESB、微服务）和分布式系统错误地划上了等号。

那么，什么是“服务化”呢？服务化就像企业当中将相同岗位的人员划分到同一个部门管理，以此来收敛特定的工作入口，再进行二次分配，以提高人员利用率和劳动成果的复用度。服务化的本质是“分治”，而“分治”的前提是先要拆，然后才谈得上如何治。这时，高内聚、低耦合的思想在拆分过程中起到了一个非常重要的作用，因为这可以尽可能地降低拆分后不同组件间进行协作的复杂度。所以重要的是“怎么拆”，还有如何循序渐进地拆，而这个过程中你究竟是采用了何种服务化模式（比如 SOA、ESB、微服务等）并不是关键。

为什么说“怎么拆”最重要呢？我来举个例子，企业的组织架构包括三种模型：职能型、项目型、矩阵型。你可以把这里的企业理解为一个“分布式系统”，把后面的 3 种模型理解为这个分布式系统的 3 种形态。作为这个“系统”的所有人，你需要考虑如何拆分它，才能使得各功能组件相互之间可以更好地协作。假设，你要将一个总计 10000 名员工的企业按“职能型”拆分成 20 个部门，得到的结果是每个部门 500 人。

这时，如果工作是流水线式的上下游关系。一个部门完工了再交给下一个部门。

那么这时候是高内聚、低耦合的。因为一个工种只与另一个工种产生了关联，并且仅有一次。

但如果工作需要频繁的由不同职能的人员同时进行，就会导致同一个部门可能与多个部门产生联系。

那么，这时是低内聚、高耦合的。因为一个工种需要和其他多个工种产生关联并且远不止一次。

可以看到服务化体现了“分治”的效果，这也是分布式系统的核心思想，因此从“分治”这个本质上来看，服务化的确是分布式系统，但分布式系统不仅仅停留在那些服务化的模式上。

我相信，你在工作中参与开发的任何软件系统，到处都存在着需要拆分的地方，除非它的功能极简到只需要计算一个 1+1。比如，当我们在电商平台点击“提交订单”的时候，会涉及生成订单、扣除积分、扣除库存等等动作。电商系统初期所有的功能可能都在一个系统里面，那么这些操作可以写在一个方法体里吗？我想只要代码能够成功运行，大部分人是不会管你怎么写的。但是如果这时需要增加一个红包功能呢？相信你或多或少遇到过在几百上千行代码中去增改功能的事情，其中的痛苦应该深有体会。

要解决这个问题就是要做拆分，通过梳理、归类，将不同的紧密相关的部分收敛到一个独立的逻辑体中，这个逻辑体可以是函数、类以及命名空间，等等。所以，从这个角度来说“分治”的问题其实早就存在我们的工作中，就看我们是否有去关注它了。因此，这并不只是我们在进行服务化时才需要考虑的问题。

那么如何才能做好这个事情，更好的拆分能力正是我们需要掌握的。如果只是因为看到其他人这么拆，我也这么拆，根据“二八原则”，或许“依样画葫芦”可以达到 80% 的契合度，但是往往那剩下的 20% 会是耗费我们 80% 精力的“大麻烦”。要知道，只有掌握了核心主旨，才能更快地找到最理想的高内聚、低耦合方案。

“分布式系统”是各种中间件吗？

又或许，听到分布式系统，你想到了某某 MQ 框架、某某 RPC 框架、某某 DAL 框架，把运用中间件和分布式系统错误地划上了等号。

这里需要你搞清楚的是，中间件起到的是标准化的作用。中间件只是承载这些标准化想法的介质、工具，可以起到引导和约束的效果，以此大大降低系统的复杂度和协作成本。我们来分别看一下：

MQ 框架标准化了不同应用程序间非实时异步通信的方式。

RPC 框架标准化了不同应用程序间实时通讯的方式。

DAL（Data Access Layer，数据访问层）框架标准化了应用程序和数据库之间通讯的方式。

所以，虽然分布式系统中会运用中间件，但分布式系统却不仅仅停留在用了什么中间件上。你需要清楚每一类中间件背后是对什么进行了标准化，它的目的是什么，带来了哪些副作用，等等。只有如此，你才能真正识别不同技术框架之间的区别，找到真正适合当前系统的技术框架。

那么标准是拍脑袋决定的吗？肯定不是，正如前面所说每一次标准化都是有目的的，需要产生价值。比如，大部分中间件都具备这样一个价值：

为了在软件系统的迭代过程中，避免将精力过多地花费在某个子功能下众多差异不大的选项中。

在现实中，这点更多时候出现在技术层面的中间件里，比如，数据库访问框架的作用是为了标准化操作不同数据库的差异，使得上层应用程序不用纠结于该怎么与 MySQL 交互或者该怎么与 SQL SERVER 交互。因为与业务相比，技术层面“稳定”多了，所以做标准化更有价值，更能获得长期收益。但“稳定”是相对的，哪怕单纯在业务层面也存在相对稳定的部分。

比如，你可以想象一下“盛饭”的场景，在大多数情况下其中相对稳定的是什么，不稳定的是什么。想完之后看下面的示例：

...
基类：人
继承基类的子类：男人、女人

基类：碗
继承基类的子类：大碗、小碗、汤碗

基类：勺子
继承基类的子类：铁勺、陶瓷勺、塑料勺

function 盛饭（参数 人，参数 碗，参数 勺子）{
    do 人拿起碗
    do 人拿起勺子
    do 人用勺子舀起饭
    do 人把勺子放到碗的上方并倒下

}
...

从这个示例里我们发现，不稳定的部分都已经成为变量了，那么剩下的这个方法体起到的作用和前面提到的中间件是一样的，它标准化了盛饭的过程。所以识别相对稳定的部分是什么，如何把它们提炼出来，并且围绕这些点进行标准化，才是我们需要掌握的能力。而锻炼这个能力和需要这个能力的地方同样并不局限于分布式系统。

列举这些现象只是想说，我们在认知一个分布式系统的时候，内在胜于表象，掌握一个扎实的理论基本功更为重要。而且，这些训练场无处不在。

海市蜃楼般的“分布式系统”

我相信，自从进入移动时代以来，各种高大上的系统架构图越来越频繁地出现，你的眼前充斥着各种主流、非主流的眼花缭乱的技术框架。你不由得肃然起敬一番，心中呐喊着：“对，这就是我想去的地方，我想参与甚至实现一个这样牛逼的分布式系统，再也不想每天只是增删改查了。”

得不到的事物总是美好的，但往往我们也会过度地高估它的美好。与此类似，高大上的架构图背后呈现的系统的确也是一个成熟分布式系统的样貌，但我们要清楚一点：罗马不是一日建成的。

而且，“分布式”这个词只是意味着形态上是散列状的，而“一分为二”和“一分为 N”本质上并没有区别。所以，很多小项目或者大型项目的初期所搭配的基础套餐“单程序 + 单数据库”，同样可以理解为分布式系统，其中遇到的问题很多同样也存在于成熟的分布式系统中。

想象一下，下面的场景是否在“单程序 + 单数据库”项目中出现过？

log 记录执行成功，但是数据库的数据没发生变化；

进程内的缓存数据更新了，但是数据库更新失败了。

这里我们停顿 30 秒，思考一下为什么会出现这些问题？

这里需要我们先思考一下“软件”是什么。 软件的本质是一套代码，而代码只是一段文字，除了提供文字所表述的信息之外，本身无法“动”起来。但是，想让它“动”起来，使其能够完成一件我们指定的事情，前提是需要一个宿主来给予它生命。这个宿主就是计算机，它可以让代码变成一连串可执行的“动作”，然后通过数据这个“燃料”的触发，“动”起来。这个持续的活动过程，又被描述为一个运行中的“进程”。

那么除了我们开发的系统是软件，数据库也是软件，前者负责运算，后者负责存储运算后的结果（也可称为“状态”），分工协作。

所以，“单程序 + 单数据库”为什么也是分布式系统这个问题就很明白了。因为我们所编写的程序运行时所在的进程，和程序中使用到的数据库所在的进程，并不是同一个。也因此导致了，让这两个进程（系统）完成各自的部分，而后最终完成一件完整的事，变得不再像由单个个体独自完成这件事那么简单。这就如“两人三足”游戏一样，如何尽可能地让外部看起来像是一个整体、自然地前进。

所以，我们可以这么理解，涉及多个进程协作才能提供一个完整功能的系统就是“分布式系统”。

那么再回到上面举例的两个场景，我们在思考“单程序 + 单数据库”项目中遇到的这些问题背后的原因和解决它的过程时，与我们在一个成熟的分布式系统中的遭遇是一样的，例如数据一致性。当然，这只是分布式系统核心概念的冰山一角。

维基百科对“分布式系统”的宏观定义是这样的：

分布式系统是一种其组件位于不同的联网计算机上的系统，然后通过互相传递消息来进行通信和协调。为了达到共同的目标，这些组件会相互作用。

我们可以再用大小关系来解释它：把需要进行大量计算的工程数据分割成小块，由多台计算机分别计算，然后将结果统一合并得出数据结论的科学。这本质上就是“分治”。而“单程序 + 单数据库”组合的系统也包含了至少两个进程，“麻雀虽小五脏俱全”，这也是“分布式系统”。

小结

现在，我们搞清楚了，看待一个“分布式系统”的时候，内在胜于表象。以及，只要涉及多个进程协作才能提供一个完整功能的系统，就是“分布式系统”。

我相信还有很多其他景象出现你的脑海中，但这大多数都是分布式系统的本质产生的“化学反应”，进而形成的结果。如果停留在这些表象上，那么我们最终将无法寻找到“分布式系统”的本质，也就无法得到真正的“道”，更不会真正具备驾驭这些形态各异的“分布式系统”的能力。

所以，希望你在学习分布式系统的时候，不要因追逐“术”而丢了“道”。没有“道”只有“术”是空壳，最终会走火入魔，学得越多，会越混乱，到处都是矛盾和疑惑。

以上就是张帆老师的分享，他的观点与本专栏也是不谋而合的。他认为：我们不仅要清楚具体场景下的最佳实践，还要明白为什么这样做，以及该如何去权衡不同方案。我们务必要修炼好自己的内功，形成一套完整的知识体系，完成核心“骨架”的塑造。而在此之后，你自己在课外学习时，就可以去填充“血肉”部分，逐渐丰满自己。未来，大家的区别就在于胖一点和瘦一点，但只要能很好地完成工作，胖瘦又有何影响呢？

最后，有关“分布式系统优化”你还有什么问题吗？欢迎在留言区中一起讨论。

加餐5 | 如何理解分布式系统？

你好，我是陶辉。课程到现在也已经接近尾声，我看到有的同学已经开始在掉队了。所以今天这讲，我准备来回答大家的一些高频问题。

咱们目前正在学习的这一模块叫“分布式系统优化”，我给你讲了监控、CAP、负载均衡、一致性哈希，说实话，这些知识都不简单，你如果觉得有点难，那也别气馁，因为它确实得多琢磨，我自己一开始学习的时候也是这样。

不过，我发现，在这个模块中，很多同学似乎对分布式有什么误解，有的人说分布式就是多台机器，有的人说分布式就是微服务，总之，大家各有自己的理解。于是，我就想着给你写篇加餐，来系统聊聊这个话题。

不过，在查资料的过程中，我发现 InfoQ 上已经有一篇文章很好地回答了这个问题。于是，经过编辑冬青的努力，我们找到了作者张帆，申请到了那篇文章的授权，在这里交付给你。

如果现在让你阐述一下什么是“分布式系统”，你脑子里第一下跳出来的是什么？我想，此时可以用苏东坡先生的一句诗，来形象地描述大家对分布式系统的认识：

横看成岭侧成峰，远近高低各不同。

“分布式系统”等于 SOA、ESB、微服务这些东西吗？

我觉得每个人脑子里一下子涌现出来的肯定是非常具象的东西，就像下面这些：

“分布式系统”等于 SOA、ESB、微服务这些东西吗？

如果你一下子想到的是 XX 中心、XX 服务，意味着你把服务化的模式（SOA、ESB、微服务）和分布式系统错误地划上了等号。

那么，什么是“服务化”呢？服务化就像企业当中将相同岗位的人员划分到同一个部门管理，以此来收敛特定的工作入口，再进行二次分配，以提高人员利用率和劳动成果的复用度。服务化的本质是“分治”，而“分治”的前提是先要拆，然后才谈得上如何治。这时，高内聚、低耦合的思想在拆分过程中起到了一个非常重要的作用，因为这可以尽可能地降低拆分后不同组件间进行协作的复杂度。所以重要的是“怎么拆”，还有如何循序渐进地拆，而这个过程中你究竟是采用了何种服务化模式（比如 SOA、ESB、微服务等）并不是关键。

为什么说“怎么拆”最重要呢？我来举个例子，企业的组织架构包括三种模型：职能型、项目型、矩阵型。你可以把这里的企业理解为一个“分布式系统”，把后面的 3 种模型理解为这个分布式系统的 3 种形态。作为这个“系统”的所有人，你需要考虑如何拆分它，才能使得各功能组件相互之间可以更好地协作。假设，你要将一个总计 10000 名员工的企业按“职能型”拆分成 20 个部门，得到的结果是每个部门 500 人。

这时，如果工作是流水线式的上下游关系。一个部门完工了再交给下一个部门。

那么这时候是高内聚、低耦合的。因为一个工种只与另一个工种产生了关联，并且仅有一次。

但如果工作需要频繁的由不同职能的人员同时进行，就会导致同一个部门可能与多个部门产生联系。

那么，这时是低内聚、高耦合的。因为一个工种需要和其他多个工种产生关联并且远不止一次。

可以看到服务化体现了“分治”的效果，这也是分布式系统的核心思想，因此从“分治”这个本质上来看，服务化的确是分布式系统，但分布式系统不仅仅停留在那些服务化的模式上。

我相信，你在工作中参与开发的任何软件系统，到处都存在着需要拆分的地方，除非它的功能极简到只需要计算一个 1+1。比如，当我们在电商平台点击“提交订单”的时候，会涉及生成订单、扣除积分、扣除库存等等动作。电商系统初期所有的功能可能都在一个系统里面，那么这些操作可以写在一个方法体里吗？我想只要代码能够成功运行，大部分人是不会管你怎么写的。但是如果这时需要增加一个红包功能呢？相信你或多或少遇到过在几百上千行代码中去增改功能的事情，其中的痛苦应该深有体会。

要解决这个问题就是要做拆分，通过梳理、归类，将不同的紧密相关的部分收敛到一个独立的逻辑体中，这个逻辑体可以是函数、类以及命名空间，等等。所以，从这个角度来说“分治”的问题其实早就存在我们的工作中，就看我们是否有去关注它了。因此，这并不只是我们在进行服务化时才需要考虑的问题。

那么如何才能做好这个事情，更好的拆分能力正是我们需要掌握的。如果只是因为看到其他人这么拆，我也这么拆，根据“二八原则”，或许“依样画葫芦”可以达到 80% 的契合度，但是往往那剩下的 20% 会是耗费我们 80% 精力的“大麻烦”。要知道，只有掌握了核心主旨，才能更快地找到最理想的高内聚、低耦合方案。

“分布式系统”是各种中间件吗？

又或许，听到分布式系统，你想到了某某 MQ 框架、某某 RPC 框架、某某 DAL 框架，把运用中间件和分布式系统错误地划上了等号。

这里需要你搞清楚的是，中间件起到的是标准化的作用。中间件只是承载这些标准化想法的介质、工具，可以起到引导和约束的效果，以此大大降低系统的复杂度和协作成本。我们来分别看一下：

MQ 框架标准化了不同应用程序间非实时异步通信的方式。

RPC 框架标准化了不同应用程序间实时通讯的方式。

DAL（Data Access Layer，数据访问层）框架标准化了应用程序和数据库之间通讯的方式。

所以，虽然分布式系统中会运用中间件，但分布式系统却不仅仅停留在用了什么中间件上。你需要清楚每一类中间件背后是对什么进行了标准化，它的目的是什么，带来了哪些副作用，等等。只有如此，你才能真正识别不同技术框架之间的区别，找到真正适合当前系统的技术框架。

那么标准是拍脑袋决定的吗？肯定不是，正如前面所说每一次标准化都是有目的的，需要产生价值。比如，大部分中间件都具备这样一个价值：

为了在软件系统的迭代过程中，避免将精力过多地花费在某个子功能下众多差异不大的选项中。

在现实中，这点更多时候出现在技术层面的中间件里，比如，数据库访问框架的作用是为了标准化操作不同数据库的差异，使得上层应用程序不用纠结于该怎么与 MySQL 交互或者该怎么与 SQL SERVER 交互。因为与业务相比，技术层面“稳定”多了，所以做标准化更有价值，更能获得长期收益。但“稳定”是相对的，哪怕单纯在业务层面也存在相对稳定的部分。

比如，你可以想象一下“盛饭”的场景，在大多数情况下其中相对稳定的是什么，不稳定的是什么。想完之后看下面的示例：

...
基类：人
继承基类的子类：男人、女人

基类：碗
继承基类的子类：大碗、小碗、汤碗

基类：勺子
继承基类的子类：铁勺、陶瓷勺、塑料勺

function 盛饭（参数 人，参数 碗，参数 勺子）{
    do 人拿起碗
    do 人拿起勺子
    do 人用勺子舀起饭
    do 人把勺子放到碗的上方并倒下

}
...

从这个示例里我们发现，不稳定的部分都已经成为变量了，那么剩下的这个方法体起到的作用和前面提到的中间件是一样的，它标准化了盛饭的过程。所以识别相对稳定的部分是什么，如何把它们提炼出来，并且围绕这些点进行标准化，才是我们需要掌握的能力。而锻炼这个能力和需要这个能力的地方同样并不局限于分布式系统。

列举这些现象只是想说，我们在认知一个分布式系统的时候，内在胜于表象，掌握一个扎实的理论基本功更为重要。而且，这些训练场无处不在。

海市蜃楼般的“分布式系统”

我相信，自从进入移动时代以来，各种高大上的系统架构图越来越频繁地出现，你的眼前充斥着各种主流、非主流的眼花缭乱的技术框架。你不由得肃然起敬一番，心中呐喊着：“对，这就是我想去的地方，我想参与甚至实现一个这样牛逼的分布式系统，再也不想每天只是增删改查了。”

得不到的事物总是美好的，但往往我们也会过度地高估它的美好。与此类似，高大上的架构图背后呈现的系统的确也是一个成熟分布式系统的样貌，但我们要清楚一点：罗马不是一日建成的。

而且，“分布式”这个词只是意味着形态上是散列状的，而“一分为二”和“一分为 N”本质上并没有区别。所以，很多小项目或者大型项目的初期所搭配的基础套餐“单程序 + 单数据库”，同样可以理解为分布式系统，其中遇到的问题很多同样也存在于成熟的分布式系统中。

想象一下，下面的场景是否在“单程序 + 单数据库”项目中出现过？

log 记录执行成功，但是数据库的数据没发生变化；

进程内的缓存数据更新了，但是数据库更新失败了。

这里我们停顿 30 秒，思考一下为什么会出现这些问题？

这里需要我们先思考一下“软件”是什么。 软件的本质是一套代码，而代码只是一段文字，除了提供文字所表述的信息之外，本身无法“动”起来。但是，想让它“动”起来，使其能够完成一件我们指定的事情，前提是需要一个宿主来给予它生命。这个宿主就是计算机，它可以让代码变成一连串可执行的“动作”，然后通过数据这个“燃料”的触发，“动”起来。这个持续的活动过程，又被描述为一个运行中的“进程”。

那么除了我们开发的系统是软件，数据库也是软件，前者负责运算，后者负责存储运算后的结果（也可称为“状态”），分工协作。

所以，“单程序 + 单数据库”为什么也是分布式系统这个问题就很明白了。因为我们所编写的程序运行时所在的进程，和程序中使用到的数据库所在的进程，并不是同一个。也因此导致了，让这两个进程（系统）完成各自的部分，而后最终完成一件完整的事，变得不再像由单个个体独自完成这件事那么简单。这就如“两人三足”游戏一样，如何尽可能地让外部看起来像是一个整体、自然地前进。

所以，我们可以这么理解，涉及多个进程协作才能提供一个完整功能的系统就是“分布式系统”。

那么再回到上面举例的两个场景，我们在思考“单程序 + 单数据库”项目中遇到的这些问题背后的原因和解决它的过程时，与我们在一个成熟的分布式系统中的遭遇是一样的，例如数据一致性。当然，这只是分布式系统核心概念的冰山一角。

维基百科对“分布式系统”的宏观定义是这样的：

分布式系统是一种其组件位于不同的联网计算机上的系统，然后通过互相传递消息来进行通信和协调。为了达到共同的目标，这些组件会相互作用。

我们可以再用大小关系来解释它：把需要进行大量计算的工程数据分割成小块，由多台计算机分别计算，然后将结果统一合并得出数据结论的科学。这本质上就是“分治”。而“单程序 + 单数据库”组合的系统也包含了至少两个进程，“麻雀虽小五脏俱全”，这也是“分布式系统”。

小结

现在，我们搞清楚了，看待一个“分布式系统”的时候，内在胜于表象。以及，只要涉及多个进程协作才能提供一个完整功能的系统，就是“分布式系统”。

我相信还有很多其他景象出现你的脑海中，但这大多数都是分布式系统的本质产生的“化学反应”，进而形成的结果。如果停留在这些表象上，那么我们最终将无法寻找到“分布式系统”的本质，也就无法得到真正的“道”，更不会真正具备驾驭这些形态各异的“分布式系统”的能力。

所以，希望你在学习分布式系统的时候，不要因追逐“术”而丢了“道”。没有“道”只有“术”是空壳，最终会走火入魔，学得越多，会越混乱，到处都是矛盾和疑惑。

以上就是张帆老师的分享，他的观点与本专栏也是不谋而合的。他认为：我们不仅要清楚具体场景下的最佳实践，还要明白为什么这样做，以及该如何去权衡不同方案。我们务必要修炼好自己的内功，形成一套完整的知识体系，完成核心“骨架”的塑造。而在此之后，你自己在课外学习时，就可以去填充“血肉”部分，逐渐丰满自己。未来，大家的区别就在于胖一点和瘦一点，但只要能很好地完成工作，胖瘦又有何影响呢？

最后，有关“分布式系统优化”你还有什么问题吗？欢迎在留言区中一起讨论。

加餐6｜分布式系统的本质是什么？

你好，我是编辑冬青。上一期加餐我们分享了张帆老师的一篇文章，从总体上聊了聊分布式系统，那作为系列分享，这期加餐我还为你带来了张帆老师的另一篇文章，进一步聊聊分布式系统的本质。这里交付给你，期待能给你带来更多的收获！

分布式系统的价值

谈到分布式系统的价值，可能就得从 1953 年说起了。在这一年，埃布·格罗希（Herb Grosch）提出了一个他观察得出的规律——Grosch 定律。维基百科中是这样描述的：

计算机性能随着成本的平方而增加。如果计算机 A 的成本是计算机 B 的两倍，那么计算机 A 的速度应该是计算机 B 的四倍。

这一论断与当时的大型机技术非常吻合，因而使得许多机构都尽其所能购买最大的单个大型机。其实，这也非常符合惯性思维，简单粗暴。

然而，1965 年高登·摩尔（Gordon Moore）提出了摩尔定律。经过几年的发展，人们发现摩尔定律的预测是符合现实的。这就意味着，集中式系统的运算能力每隔一段时间才能提升一倍。

那么，到底要隔多久呢？这个“时间”有很多版本，比如广为流传的 18 个月版本，以及 Gordon Moore 本人坚持的 2 年版本。这里我们不用太过纠结于实际情况到底是哪个“时间”版本，因为这其中隐含的意思更重要，即：如果你的系统需承载的计算量的增长速度大于摩尔定律的预测，那么在未来的某一个时间点，集中式系统将无法承载你所需的计算量。

而这只是一个内在因素，真正推动分布式系统发展的催化剂是“经济”因素。

人们发现，用廉价机器的集合组成的分布式系统，除了可以获得超过 CPU 发展速度的性能外，花费更低，具有更好的性价比，并且还可以根据需要增加或者减少所需机器的数量。

所以，我们得到一个新结论：无论是要以低价格获得普通的性能，还是要以较高的价格获得极高的性能，分布式系统都能够满足。并且受规模效应的影响，系统越大，性价比带来的收益越高。

之后，进入到互联网快速发展的时期，我们看到了分布式系统相比集中式系统的另一个更明显的优势：更高的可用性。例如，有 10 个能够承载 10000 流量的相同的节点，如果其中的 2 个挂了，只要实际流量不超过 8000，系统依然能够正常运转。

而这一切的价值，都是建立在分布式系统的“分治”和“冗余”之上的。从全局角度来看，这其实就是分布式系统的本质。

分治

分治，字面意思是“分而治之”，和我们的大脑在解决问题时的思考方式是一样的。我们可以将整个过程分为 3 步：分解 -> 治理 -> 归并。而分治思想的表现形式多样，分层、分块都是它的体现。

这么做的好处是：问题越小越容易被解决，并且，只要解决了所有子问题，父问题就都可以被解决了。但是，这么做的时候，需要满足一个最重要的条件：不同分支上的子问题，不能相互依赖，需要各自独立。因为一旦包含了依赖关系，子问题和父问题之间就失去了可以被“归并”的意义。在软件开发领域，我们把这个概念称为“耦合度”和“内聚度”，这两个度量概念非常重要。

耦合度，指的是软件模块之间相互依赖的程度。比如，每次调用方法 A 之后都需要同步调用方法 B，那么此时方法 A 和 B 间的耦合度是高的。

内聚度，指的是模块内的元素具有的共同点的相似程度。比如，一个类中的多个方法有很多的共同之处，都是做支付相关的处理，那么这个类的内聚度是高的。

内聚度通常与耦合度形成对比。低耦合通常与高内聚相关，反之亦然。

所以，当你打算进行分治的时候，耦合度和内聚度就是需要考虑的重点。

下面我们来看个例子，体会一下耦合度和内聚度的含义（图仅用于表达含义，切勿作其他参考）。假设一个电商平台，为了应对更大的访问量，需要拆分一个同时包含商品、促销的系统。如果垂直拆分，是这样：

而如果水平拆分，则是这样的：

假如我们面对的场景仅仅是具体的商品详情展示页面，很显然，用水平拆分的效果会更好。因为传统的商品展示必然会同时展示促销，所以，如果用水平拆分，一次请求即可获取所有数据，内聚度非常高，并且此时模块间完全没有耦合。而如果是垂直拆分的话，就需要同时请求 2 个节点的数据并进行组合，因此耦合度更高、内聚度更差。

但是，这样的假设在真实的电商场景中是不存在的。从全局来看，订单、购物车、商品列表等许多其他场景也需要促销信息。并且这个时候我们发现引入了一些新的主体，诸如订单、购物车、商品分类等等。这个时候，水平拆分带来的好处越来越小，因为这样只解决了多个耦合中的一个，低耦合丧失了。并且随着商品和促销与外界的关联越来越多，必然有些场景仅仅涉及到商品和促销的其中一个，但是处理的时候，我们还需要避免受到另一个的影响。如此，高内聚也丧失了。

这个时候，反而通过垂直拆分可以获得更优的耦合度和内聚度，如下图。

最高的耦合关系从原先的 6 降到了 4，并且商品和促销各自的处理相互不受影响。

所以，你会发现随着业务的变化，耦合度与内聚度也会发生变化。因此，及时地进行梳理和调整，可以避免系统的复杂度快速增长，这样才能最大程度地发挥“分治”带来的好处。

综上，分治可以简化解题的难度，通过高内聚、低耦合的协作关系达到更好的“性能与经济比”，来承载更大的流量。而“冗余”则带来了系统可以 7*24 小时不间断运作的希望。

冗余

这里的冗余并不等同于代码的冗余、无意义的重复劳动，而是我们有意去做的、人为增加的重复部分。其目的是容许在一定范围内出现故障，而系统不受影响，如下图。

此时，我们可以将冗余的节点部署在一个独立的环境中。这个独立的环境，可能是处于同一个局域网内的不同主机，也可能是在不同的局域网，还可能是在不同的机房。很显然，它们能够应对的故障范围是逐步递增的。

但是，像这种单纯为了备用而做的冗余，最大的弊端是，如果没有出现故障，那么冗余的这部分资源就白白浪费了，不能发挥任何作用。所以，我们才提出了诸如双主多活、读写分离之类的概念，以提高资源利用率。

当然，除了软件层面，硬件层面的冗余也是同样的道理。比如，磁盘阵列可以容忍几块之内磁盘损坏，而不会影响整体。

不过也很显然，当故障影响范围大于你冗余的容量时，系统依然会挂。所以，既然你无法预知故障的发生情况，那么做冗余的时候需要平衡的另一端就是成本。相比更多的冗余，追求更好的性价比更合理一些。

在我们生活中的冗余也到处存在。比如，大部分的飞机和直升机的发动机都是偶数的，汽车中的电子控制系统的冗余机制等。就好比替身与真身的关系，冗余的就是替身。它可以和真身同时活动，也可以代替真身活动。

分治和冗余讲究的都是分散化，最终形成一个完整的系统还需要将它们“连接”起来。天下没有免费的午餐，获得分布式系统价值的同时，这个“再连接”的过程就是我们相比集中式系统要做的额外工作。

再连接

如何将拆分后的各个节点再次连接起来，从模式上来说，主要是去中心化与中心化之分。

前者完全消除了中心节点故障带来的全盘出错的风险，却带来了更高的节点间协作成本。后者通过中心节点的集中式管理大大降低了协作成本，但是一旦中心节点故障则全盘出错。

另外，从技术角度来说，如何选择通信协议和序列化机制，也是非常重要的。

虽然很多通讯协议和序列化机制完全可以承担任何场景的连接责任，但是不同的协议和序列化机制在适合的场景下才能发挥它最大的优势。比如，需要更高性能的场景运用 TCP 协议优于 HTTP 协议；需要更高吞吐量的场景运用 UDP 协议优于 TCP 协议，等等。

小结

不管系统的规模发展到多大，合理的拆分，加上合适的连接方式，那么至少会是一个运转顺畅、协作舒服的系统，至少能够正常发挥分布式系统应有的价值。

如今，我们发现分布式系统还可以发挥更多的作用。比如，只要基于一个统一的上层通信协议，其下层的不同节点可以运用不同的技术栈来发挥不同技术各自的优势，比如用 Go 来应对高并发场景，用 Python 来做数据分析等。再比如，提高交付的速度，如下图。

通过分配不同的团队、人员同时进行多个模块的开发，虽然总的耗时增加了，但是整体的交付速度加快了。

事物最本质的东西是恒定的、不变的，可以指引我们的工作方向。分布式系统的本质也是这样。例如，这样的“分治”方案耦合度和内聚度是否最优，这样做“冗余”带来的收益是否成本能够接受。只要持续带着这些思考，我们就好像拿着一杆秤，基于它，我们就可以去衡量各种变量影响，然后作权衡。比如成本、时间、人员、性能、易维护等等。也可以基于它去判断什么样的框架、组件、协议更适合当前的环境。

需要不断的权衡，也意味着分布式系统的设计工作一定不是一步到位，而是循序渐进的。因为过分为未知的未来做更多的考量，最终可能都会打水漂。所以，建议以多考虑 1~2 步为宜。假如以你所在的团队中对重大技术升级的频率来作为参考的话，做可供 2 个升级周期的设计，花一个升级周期的时间先实现第一阶段，下个阶段可以选择直接实现剩下的部分，也可继续进行 2 个升级周期设计，开启一个循环，持续迭代，并且不断修正方向以更贴近现实的发展，就如下图这样。

以上就是今天的全部内容。最后，互动一下，在你的工作或者学习中，你觉得分布式系统还具备哪些价值呢？欢迎留言！

大咖助场 | 李玥：高并发场景下如何优化微服务的性能?

你好，我是李玥。相信这里有部分同学对我是比较熟悉的，我在极客时间开了两门课，分别是《消息队列高手课》和《后端存储实战课》。今天很荣幸受邀来到陶辉老师的专栏做一期分享。

陶辉老师的这门课程，其中的知识点都是非常“硬核”，因为涉及到计算机操作系统底层的这些运行机制，确实非常抽象。我也看到有些同学在留言区提到，希望能通过一些例子来帮助大家更好地消化一下这些知识。那么这期分享呢，我就来帮陶辉老师做一次科普，帮助同学们把“基础设施优化”这一部分中讲到的一些抽象的概念和方法，用举例子的方式来梳理一遍。总结下的话，就是帮你理清这些问题：

线程到底是如何在 CPU 中执行的？

线程上下文切换为什么会影响性能？

为什么说异步比同步的性能好？

BIO、NIO、AIO 到底有什么区别？

为什么线程数越多反而性能越差？

今天的课程，从一个选择题开始。假设我们有一个服务，服务的业务逻辑和我们每天在做的业务都差不多，根据传入的参数去数据库查询数据，然后执行一些简单的业务逻辑后返回。我们的服务需要能支撑 10,000TPS 的请求数量，那么数据库的连接池设置成多大合适呢？

我给你二个选项：

A. 32

B. 2048

我们直接公布答案，选项 A 是性能更好的选择。连接池的大小直接影响的是，同时请求到数据库服务器的并发数量。那我们直觉的第一印象可能是，并发越多总体性能应该越好才对，事实真的是这样吗？下面我们通过一个例子来探究一下这个问题的答案。

说有一个工厂，要新上一个车间，车间里面设置了 8 条流水生产线，每个流水线设置 1 个工位，那需要安排多少个工人才能达到最佳的效率呢？显然是需要 8 个工人是吧？工人少了生产线闲置，工人多了也没有工位让他们去工作，工人闲置，8 个工人对 8 条流水线是效率最优解。这里面的车间，就可以类比为一台计算机，工位就是线程，工人就是 CPU 的核心。通过这个类比，我们就可以得出这样一个结论：一个 8 核的 CPU，8 个线程的情况下效率是最高的。 这时，每个 CPU 核心正好对应一个线程。

这是一个非常理想的情况，它有一个前提就是，流水线上的工人（CPU 核心）一直有事情做，没有任何等待。而现实情况下，我们绝大部分的计算程序都做不到像工厂流水线那么高效。我们开发的程序几乎是请求 / 响应的模型，对应到车间的例子，生产模式不太像流水线，更像是来料加工。工人在工位上等着，来了一件原料，工人开始加工，加工完成后，成品被送走，然后再等待下一件，周而复始。对应到计算机程序中，原料就是请求，工人在工位上加工原料的过程，相当于 CPU 在线程上执行业务逻辑的过程，成品就是响应，或者说是请求的返回值。你可以对照下面这个图来理解上面我们讲的这个例子，以及对应到计算机程序中的概念。

来料加工这种情况下，只有 8 个工位并不能保证 8 个工人一直满负荷的工作。因为，工人每加工完成一件产品之后，需要等待成品被送出去，下一件原料被送进来，才能开始继续工作。在同一个工位上加工每件产品之间的等待是不可避免的，那怎么才能最大化工人的效率，尽量减少工人等待呢？很简单，增加一些工位就可以了。工人在 A 工位加工完成一件产品之后，不在 A 工位等着，马上去另外一个原料已经就绪的 B 工位继续工作，这样只要工位设置得足够多，就可以保证 8 个工人一直满负荷工作。

那同样是 8 个工人满负荷工作，多工位来料加工这种方式，和上面提到的 8 条流水线作业的方式，哪种效率更高呢？还是流水线的效率高，是不是？原因是，虽然在这两种方式下，工人们都在满负荷工作，但是，来料加工这种方式下，工人在不同的工位之间切换，也是需要一点点时间的，相比于流水线作业，这部分工时相当于被浪费掉了。

工人在工位间切换，对应到计算机执行程序的过程，就是 CPU 在不同的线程之间切换，称为线程上下文切换。一次线程上下文切换的时间耗时非常短，大约只有几百个纳秒（ns）。一般来说我们并不需要太关注这个短到不可感知的切换时间，但是，在多线程高并发的场景下，如果没有很好的优化，就有可能出现，CPU 在大量线程间频繁地发生切换，累积下来，这个切换时间就很可观了，严重的话就会拖慢服务的总体性能。

我们再来思考另外一个问题：设置多少个工位最合适呢？工位数量不足时，工人不能满负荷工作，工位数量太多了也不行，工人需要频繁地切换工位，浪费时间。这里面一定存在一个最优工位数，可以让所有工人正好不需要等待且满负荷工作。最优工位数取决于工人的加工速度、等待原料的时长等因素。如果这些参数是确定的，那我们确定这个最佳工位数就不太难了。一般来说，工位的数量设置成工人数量的两三倍就差不多了，如果等待的时间比较长，可能需要五六倍，大致是这样一个数量级。把这个结论对应到计算机系统中就是，对于一个请求 / 响应模型的服务，并发线程数设置为 CPU 核数 N 倍时性能最佳，N 的大致的经验值范围是[2, 10]。

有了这个结论，再回过头来看我们课程开始提到的那个数据库连接池问题。数据库服务符合“请求 / 响应模型”，所以它的并发数量并不是越多越好，根据我们上面得出的结论，大约是 CPU 核数的几倍时达到最佳性能。这个问题来自于数据库连接池 HikariCP 的一篇 Wiki: About Pool Sizing，里面有详细的性能测试数据和计算最佳连接池数量的公式，强烈推荐你课后去看一下。

为什么说异步比同步的性能好？

然后我们再来思考这样一个问题。我们开发的很多业务服务实际的情况是，并发线程数越多总体性能越好，几百甚至上千个线程才达到最佳性能。这并不符合我们上面说的那个结论啊？什么原因？

原因是这样的，我们上面这个结论它有一个适用范围，它的适用范围是，像数据库服务这样，只依赖于本地计算资源的服务。

如果说，我们的业务服务，它在处理请求过程中，还需要去调用其他服务，这种情况就不适用于我们上面所说的结论。这里面的其它服务包括数据库服务或者是下游的业务服务等等。不适用的原因是，我们线程在执行业务逻辑过程中，很大一部分时间都花在等待外部服务上了，在这个等待的过程中，几乎不需要 CPU 参与。换句话说，每个线程需要的 CPU 时间是非常少的，这样的情况下，一个 CPU 核心需要非常多的线程才能把它“喂饱”，这就是为什么这些业务服务需要非常多的线程数，才能达到最佳性能的原因。

我们刚刚讲过，线程数过多很容易导致 CPU 频繁的在这些线程之间切换，虽然 CPU 看起来已经在满负荷运行了，但 CPU 并没有把所有的时间都用在执行我们的业务逻辑上，其中一部分 CPU 时间浪费在线程上下文切换上了。怎么来优化这种情况呢？要想让 CPU 高效地执行业务逻辑，最佳方式就是我们开头提到的流水线，用和 CPU 核数相同的线程数，通过源源不断地供给请求，让 CPU 一直不停地执行业务逻辑。所以优化的关键点是，减少线程的数量，把线程数量控制在和 CPU 核数相同的数量级这样一个范围。

要减少线程数量，有这样两个问题需要解决。

第一个问题是，如何用少量的线程来处理大量并发请求呢？我们可以用一个请求队列，和一组数量固定的执行线程，来解决这个问题。线程的数量就等于 CPU 的核数。接收到的请求先放入请求队列，然后分配给执行线程去处理。这样基本上能达到，让每个 CPU 的核心相对固定到一个线程上，不停地执行业务逻辑这样一个效果。

第二个问题是，执行线程在需要调用外部服务的时候，如何避免线程等待外部服务，同时还要保证及时处理返回的响应呢？我们希望的情况是，执行线程需要调用外部服务的时候，把请求发送出去之后，不要去等待响应，而是去继续处理下一个请求。等外部请求的响应回来之后，能有一个通知，来触发执行线程再执行后续的业务逻辑，直到给客户端返回响应。这其实就是我们通常所说的异步 IO 模型（AIO，Asynchronous I/O），这个模型的关键就是，线程不去等待 Socket 通道上的数据，而是待数据到达时，由操作系统来发起一个通知，触发业务线程来处理。Linux 内核从 2.6 开始才加入了 AIO 的支持，到目前为止 AIO 还没有被广泛使用。

使用更广泛的是 IO 多路复用模型（IO Multiplexing），IO 多路复用本质上还是一种同步 IO 模型。但是，它允许一个线程同时等待多个 Socket 通道，任意一个通道上有数据到来，就解除等待去处理。IO 多路复用没有 AIO 那么理想化，但也只是多了一个线程用于等待响应，相比 AIO 来说，效果也差不了多少，在内核 AIO 支持还不完善的时代，是一个非常务实且高效的网络 IO 模型。

很多编程语言中，都有一些网络 IO 框架，封装了这些 IO 模型，来帮我们解决这个问题，比如 Java 语言中的 BIO、NIO、AIO 分别对应了同步 IO 模型、IO 多路复用模型和异步 IO 模型。

解决了上面这两个问题之后，我们用很少量的线程就可以处理大量的并发请求。这种情况下，负责返回响应的线程和接收请求的线程，不再是同一个线程，这其实就是我们所说的异步模型。你可以看到，异步模型并不会让程序的业务逻辑执行得更快，但是它可以非常有效地避免线程等待，大幅减少 CPU 在线程上下文切换上浪费的时间。这样，在同样的计算机配置下，异步模型相比同步模型，可以更高效地利用计算机资源，从而拥有更好的总体的吞吐能力。

小结

以上就是本节课的全部内容了，我们来简单地做个小结。

理论上，线程数量设置为 CPU 核数，并且线程没有等待的情况下，CPU 几乎不会发生线程上下文切换，这个时候程序的执行效率是最高的。实际情况下，对于一个请求 / 响应模型的服务，并发线程数设置为 CPU 核数 N 倍时性能最佳。这个 N 取决于业务逻辑的执行时间、线程等待时间等因素，N 的大致的经验值范围是[2, 10]。

使用异步模型编写微服务，配合异步 IO 或者 IO 多路复用，可以有效地避免线程等待，用少量的线程处理大量并发请求，大幅减少线程上下文切换的开销，从而达到提升服务总体性能的效果。

思考题

最后留给你一道思考题。IO 多路复用，它只是一种 IO 模型，实际上有多种实现。在 Linux 中，有 select、poll、epoll 三种实现方式，课后请你去查阅一下资料，看看这三种实现方式有什么区别？

感谢阅读，如果今天的内容让你有所收获，欢迎把它分享给你的朋友。

大咖助场｜傅健：那些年，影响我们达到性能巅峰的常见绊脚石（上）

你好，我是傅健。这里有部分同学可能认识我，我在极客时间开设了一门视频课《Netty 源码剖析与实战》，很荣幸受邀来到陶辉老师的专栏做一些分享。今天我会围绕这门课程的主题——系统性能调优，结合我自身的工作经历补充一些内容，期待能给你一些新思路。

其实说起性能调优，我们往往有很多理论依据可以参考，例如针对分布式系统的 NWR、CAP 等，也有很多实践的“套路”，如绘制火焰图、监控调用链等。当然，这些内容多多少少陶辉老师都有讲到。实际上，不管方式、方法有多少，我们的终极目标都是一致的，那就是在固定的资源条件下，将系统的响应速度调整到极致。

但是在严谨地评价一个系统性能时，我们很少粗略地使用这种表述：在压力 A（如 1000 TPS）下，基于软硬件配置 B，我们应用的 C 操作响应时间是 D 毫秒。而是加上一个百分位，例如：在压力 A（如 1000 TPS）下，基于软硬件配置 B，我们应用的 C 操作响应时间 99% 已经达到 D 毫秒。或者当需要提供更为详细的性能报告时，我们提供的往往是类似于下面表格形式的数据来反映性能：不仅包括常见的百分比（95%、99% 等或者常见的四分位），也包括平均数、中位数、最大值等。

那为什么要如此“严谨”？不可以直接说达到了某某水平吗？究其原因，还是我们的系统很难达到一个完美的极致，总有一些请求的处理时间超出了我们的“预期”，让我们的系统不够平滑（即常说的系统有“毛刺”）。所以在追求极致的路上，我们的工作重心已经不再是“大刀阔斧”地进行主动性能调优以满足 99% 的需求，而是着重观察、分析那掉队的 1% 请求，找出这些“绊脚石”，再各个击破，从而提高我们系统性能的“百分比”。例如，从 2 个 9（99%）再进一步提高到 3 个 9（99.9%）。而实际上，我们不难发现，这些所谓的绊脚石其实都是类似的，所以这期分享我就带你看看究竟有哪些绊脚石，我们结合具体场景总结应对策略。

场景 1：重试、重定向

案例

当我们使用下游服务的 API 接口时，偶尔会出现延时较大的情况，而这些延时较大的调用最后也能成功，且没有任何明显的时间规律。例如响应延时正常时，API 调用性能度量数据如下：

{
 "stepName": "CallRemoteService"
 "values": {
    "componentType": "RemoteService",
    "startTime": "2020-07-06T10:50:41.102Z",
    "totalDurationInMS": 2,
    "success": true
 }
}

而响应延时超出预期时，度量数据如下：

{
 "stepName": "CallRemoteService"
 "values": {
    "componentType": "RemoteService",
    "startTime": "2020-07-06T04:31:55.805Z",
    "totalDurationInMS": 2005,
    "success": true
 }
}

解析

这种情况可以说非常典型了，单从以上度量数据来看，没有什么参考意义，因为所有的性能问题都是这样的特征，即延时增大了。这里面可能的原因也有许多，例如 GC 影响、网络抖动等等，但是除了这些原因之外，其实最常见、最简单的原因往往都是“重试”。重试成功前的访问往往都是很慢的，因为可能遇到了各种需要重试的错误，同时重试本身也会增加响应时间。那作为第一个绊脚石，我们现在就对它进行一个简单的分析。

以这个案例为例，经过查询后你会发现：虽然最终是成功的，但是我们中途进行了重试，具体而言就是我们在使用 HttpClient 访问下游服务时，自定义了重试的策略：当遇到 ConnectTimeoutException、SocketTimeoutException 等错误时直接进行重试。

//构建http client
CloseableHttpClient httpClient = HttpClients.custom().
      setConnectionTimeToLive(3, TimeUnit.MINUTES).
      //省略其它非关键代码
      setServiceUnavailableRetryStrategy(new DefaultServiceUnavailableRetryStrategy(1, 50)).
      //设置了一个自定义的重试规则
      setRetryHandler(new CustomizedHttpRequestRetryHandler()).
            build();

//自定义的重试规则
@Immutable
public class CustomizedHttpRequestRetryHandler implements HttpRequestRetryHandler {

   private final static int RETRY_COUNT = 1;

   CustomizedHttpRequestRetryHandler() {}

   @Override
   public boolean retryRequest(final IOException exception, final int executionCount, final HttpContext context) {
      //控制重试次数
      if (executionCount > RETRY_COUNT) {
         return false;
      }
      //遇到下面这些异常情况时，进行重试
      if (exception instanceof ConnectTimeoutException || exception instanceof NoHttpResponseException || exception instanceof SocketTimeoutException) {
        return true;
      }

      //省略其它非关键代码
      return false;
   }
}

如果查询日志的话（由 org.apache.http.impl.execchain.RetryExec#execute 输出），我们确实发现了重试的痕迹，且可以完全匹配上我们的请求和时间：

[07/06/2020 04:31:57.808][threadpool-0]INFO  RetryExec-I/O exception (org.apache.http.conn.ConnectTimeoutException) caught when processing request to {}->http://10.224.86.130:8080: Connect to 10.224.86.130:8080 [/10.224.86.130] failed: connect timed out

[07/06/2020 04:31:57.808][threadpool-0]INFO  RetryExec-Retrying request to {}->http://10.224.86.130:8080

另外除了针对异常的重试外，我们有时候也需要对于服务的短暂不可用（返回 503：SC_SERVICE_UNAVAILABLE）进行重试，正如上文贴出的代码中我们设置了 DefaultServiceUnavailableRetryStrategy。

小结

这个案例极其简单且常见，但是这里我需要额外补充下：假设遇到这种问题，又没有明确给出重试的痕迹（日志等）时，我们应该怎么去判断是不是重试“捣鬼”的呢？

一般而言，我们可以直接通过下游服务的调用次数数据来核对是否发生了重试。但是如果下游也没有记录，在排除完其它可能原因后，我们仍然不能排除掉重试的原因，因为重试的痕迹可能由于某种原因被隐藏起来了，例如使用的开源组件压根没有打印日志，或者是打印了但是我们应用层的日志框架没有输出。这个时候，我们也没有更好的方法，只能翻阅源码查找线索。

另外除了直接的重试导致延时增加外，还有一种类似情况也经常发生，即“重定向”，而比较坑的是，对于重定向行为，很多都被“内置”起来了：即不输出 INFO 日志。例如 Apache HttpClient 对响应码 3XX 的处理（参考 org.apache.http.impl.client.DefaultRedirectStrategy）只打印了 Debug 日志：

final String location = locationHeader.getValue();
if (this.log.isDebugEnabled()) {
    this.log.debug("Redirect requested to location '" + location + "'");
}

再如，当我们使用 Jedis 访问 Redis 集群中的结点时，如果数据不在当前的节点了，也会发生“重定向”，而它并没有打印出日志让我们知道这件事的发生（参考 redis.clients.jedis.JedisClusterCommand）：

  private T runWithRetries(final int slot, int attempts, boolean tryRandomNode, JedisRedirectionException redirect) {
     //省略非关键代码
    } catch (JedisRedirectionException jre) {
      // if MOVED redirection occurred,
      if (jre instanceof JedisMovedDataException) {
        //此处没有输入任何日志指明接下来的执行会“跳转”了。
        // it rebuilds cluster's slot cache recommended by Redis cluster specification
        this.connectionHandler.renewSlotCache(connection);
      }

      //省略非关键代码
      return runWithRetries(slot, attempts - 1, false, jre);
    } finally {
      releaseConnection(connection);
    }

综上，重试是最普通的，也是最简单的导致延时增加的“绊脚石”，而重试问题的界定难度取决于自身和下游服务是否有明显的痕迹指明。而对于这种绊脚石的消除，一方面我们应该主动出击，尽量减少引发重试的因素。另一方面，我们一定要控制好重试，例如：

控制好重试的次数；

错峰重试的时间；

尽可能准确识别出重试的成功率，以减少不必要的重试，例如我们可以通过“熔断”“快速失败”等机制来实现。

场景 2：失败引发轮询

案例

在使用 Apache HttpClient 发送 HTTP 请求时，稍有经验的程序员都知道去控制下超时时间，这样，在连接不上服务器或者服务器无响应时，响应延时都会得到有效的控制，例如我们会使用下面的代码来配置 HttpClient：

RequestConfig requestConfig = RequestConfig.custom().
            setConnectTimeout(2 * 1000). //控制连接建立时间
            setConnectionRequestTimeout(1 * 1000).//控制获取连接时间
            setSocketTimeout(3 * 1000).//控制“读取”数据等待时间
            build();

以上面的代码为例，你能估算出响应时间最大是多少么？上面的代码实际设置了三个参数，是否直接相加就能计算出最大延时时间？即所有请求 100% 控制在 6 秒。

先不说结论，通过实际的生产线观察，我们确实发现大多符合我们的预期：可以说 99.9% 的响应都控制在 6 秒以内，但是总有一些“某年某月某天”，发现有一些零星的请求甚至超过了 10 秒，这又是为什么？

解析

经过问题跟踪，我们发现我们访问的 URL 是一个下游服务的域名（大多如此，并不稀奇），而这个域名本身有点特殊，由于负载均衡等因素的考虑，我们将它绑定到了多个 IP 地址。所以假设这些 IP 地址中，一些 IP 地址指向的服务临时不服务时，则会引发轮询，即轮询其它 IP 地址直到最终成功或失败，而每一次轮询中的失败都会额外增加一倍 ConnectTimeout，所以我们发现超过 6 秒甚至 10 秒的请求也不稀奇了。我们可以通过 HttpClient 的源码来验证下这个逻辑（参考 org.apache.http.impl.conn.DefaultHttpClientConnectionOperator.connect 方法）：

public void connect(
        final ManagedHttpClientConnection conn,
        final HttpHost host,
        final InetSocketAddress localAddress,
        final int connectTimeout,
        final SocketConfig socketConfig,
        final HttpContext context) throws IOException {
    final Lookup<ConnectionSocketFactory> registry = getSocketFactoryRegistry(context);
    final ConnectionSocketFactory sf = registry.lookup(host.getSchemeName());
    //域名解析，可能解析出多个地址
    final InetAddress[] addresses = host.getAddress() != null ?
            new InetAddress[] { host.getAddress() } : this.dnsResolver.resolve(host.getHostName());
    final int port = this.schemePortResolver.resolve(host);

    //对于解析出的地址，进行连接，如果中途有失败，尝试下一个
    for (int i = 0; i < addresses.length; i++) {
        final InetAddress address = addresses[i];
        final boolean last = i == addresses.length - 1;

        Socket sock = sf.createSocket(context);
        conn.bind(sock);

        final InetSocketAddress remoteAddress = new InetSocketAddress(address, port);
        if (this.log.isDebugEnabled()) {
            this.log.debug("Connecting to " + remoteAddress);
        }
        try {
            //使用解析出的地址执行连接
            sock = sf.connectSocket(
                    connectTimeout, sock, host, remoteAddress, localAddress, context);
            conn.bind(sock);
            if (this.log.isDebugEnabled()) {
                this.log.debug("Connection established " + conn);
            }
            //如果连接成功，则直接退出，不继续尝试其它地址
            return;
        } catch (final SocketTimeoutException ex) {
            if (last) {
                throw new ConnectTimeoutException(ex, host, addresses);
            }
        } catch (final ConnectException ex) {
            if (last) { //如果连接到最后一个地址，还是失败，则抛出异常。如果不是最后一个，则轮询下一个地址进行连接。
                final String msg = ex.getMessage();
                if ("Connection timed out".equals(msg)) {
                    throw new ConnectTimeoutException(ex, host, addresses);
                } else {
                    throw new HttpHostConnectException(ex, host, addresses);
                }
            }
        }
        if (this.log.isDebugEnabled()) {
            this.log.debug("Connect to " + remoteAddress + " timed out. " +
                    "Connection will be retried using another IP address");
        }
    }
}

通过以上代码，我们可以清晰地看出：在一个域名能解析出多个 IP 地址的场景下，如果其中部分 IP 指向的服务不可达时，延时就可能会增加。这里不妨再举个例子，对于 Redis 集群，我们会在客户端配置多个连接节点（例如在 SpringBoot 中配置 spring.redis.cluster.nodes=10.224.56.101:8001,10.224.56.102:8001），通过连接节点来获取整个集群的信息（其它所有节点）。正常情况下，我们都会连接成功，所以我们没有看到长延时情况，但是假设刚初始化时，连接的部分节点不服务了，那这个时候就会连接其它配置的节点，从而导致延时倍增。

小结

这部分我们主要看了失败引发轮询造成的长延时案例，细心的同学可能会觉得这不就是上一部分介绍的重试么？但是实际上，你仔细体会，两者虽然都旨在提供系统可靠性，但却略有区别：重试一般指的是针对同一个目标进行的再次尝试，而轮询则更侧重对同类目标的遍历。

另外，除了以上介绍的开源组件（Apache HttpClient 和 RedisClient）案例外，还有其它一些常见组件都可能因为轮询而造成延时超过预期。例如对于 Oracle 连接，我们采用下面的这种配置时，也可能会出现轮询的情况：

DBURL=jdbc:oracle:thin:@(DESCRIPTION=(load_balance=off)(failover=on)(ADDRESS=(PROTOCOL=TCP)(HOST=10.224.11.91)(PORT=1804))(ADDRESS=(PROTOCOL=TCP)(HOST=10.224.11.92)(PORT=1804))(ADDRESS=(PROTOCOL=TCP)(HOST= 10.224.11.93)(PORT=1804))(CONNECT_DATA=(SERVICE_NAME=xx.yy.com)(FAILOVER_MODE=(TYPE=SELECT)(METHOD=BASIC)(RETRIES=6)(DELAY=5))))
DBUserName=admin
DBPassword=password

其实通过对以上三个案例的观察，我们不难得出一个小规律：假设我们配置了多个同种资源，那么就很有可能存在轮询情况，这种轮询会让延时远超出我们的预期。只是幸运的是，在大多情况下，轮询第一次就成功了，所以我们很难观察到长延时的情况。针对这种情况造成的延时，我们除了在根源上消除外因，还要特别注意控制好超时时间，假设我们不知道这种情况，我们乐观地设置了一个很大的时间，则实际发生轮询时，这个时间会被放大很多倍。

这里再回到最开始的案例啰嗦几句，对于 HttpClient 的访问，是否加上最大轮询时间就是最大的延时时间呢？其实仍然不是，至少我们还忽略了一个时间，即 DNS 解析花费的时间。这里就不再展开讲了。

场景 3：GC 的“STW”停顿

案例

系统之间调用是服务最常见的方式，但这也是最容易发生“掐架”的斗争之地。例如对于组件 A，运维或者测试工程师反映某接口偶然性能稍差，而对于这个接口而言，实际逻辑“简单至极”，直接调用组件 B 的接口，而对于这个接口的调用，平时确实都是接近 1ms 的：

[07/04/2020 07:18:16.495 pid=3160 tid=3078502112] Info:[ComponentA] Send to Component B:
[07/04/2020 07:18:16.496 pid=3160 tid=3078502112] Info:[ComponentA] Receive response from B

而反映的性能掉队不经常发生，而且发生时，也没有没有特别的信息，例如下面这段日志，延时达到了 400ms：

[07/04/2020 07:16:27.222 pid=4725 tid=3078407904] Info: [ComponentA]  Send to Component B:
[07/04/2020 07:16:27.669 pid=4725 tid=3078407904] Info: [ComponentA]  Receive response from B

同时，对比下，我们也发现这 2 次请求其实很近，只有 2 分钟的差距。那么这又是什么导致的呢？

解析

对于这种情况，很明显，A 组件往往会直接“甩锅”给 B 组件。于是，B 组件工程师查询了日志：

 [07/04/2020 07:16:27.669][nioEventLoopGroup-4-1]INFO [ComponentB] Received request from Component A
 [07/04/2020 07:16:27.669][nioEventLoopGroup-4-1]INFO [ComponentB] Response to Component B

然后 B 组件也开始甩锅：鉴于我们双方组件传输层都是可靠的，且 N 年没有改动，那这次的慢肯定是网络抖动造成的了。貌似双方也都得到了理想的理由，但是问题还是没有解决，这种类似的情况还是时而发生，那问题到底还有别的什么原因么？

鉴于我之前的经验，其实我们早先就知道 Java 的 STW（Stop The World）现象，可以说 Java 最省心的地方也是最容易让人诟病的地方。即不管怎么优化，或采用更先进的垃圾回收算法，都避免不了 GC，而 GC 会引发停顿。其实上面这个案例，真实的原因确实就是 B 组件的 GC 导致了它的处理停顿，从而没有及时接受到 A 发出的信息，何以见得呢？

早先在设计 B 组件时，我们就考虑到未来某天可能会发生类似的事情，所以加了一个 GC 的跟踪日志，我们先来看看日志：

{
  "metricName": "java_gc",
  "componentType": "B",
  "componentAddress": "10.224.3.10",
  "componentVer": "1.0",
  "poolName": "test001",
  "trackingID": "269",
  "metricType": "innerApi",
  "timestamp": "2020-07-04T07:16:27.219Z",
  "values": {
    "steps": [
    ],
    "totalDurationInMS": 428
  }
}

在 07:16:27.219 时，发生了 GC，且一直持续了 428ms，所以最悲观的停顿时间是从 219ms 到 647ms，而我们观察下 A 组件的请求发送时间 222 是落在这个区域的，再核对 B 组件接收这个请求的时间是 669，是 GC 结束后的时间。所以很明显，排除其它原因以后，这明显是受了 GC 的影响。

小结

假设某天我们看到零星请求有“掉队”，且没有什么规律，但是又持续发生，我们往往都会怀疑是网络抖动，但是假设我们的组件是部署在同一个网络内，实际上，不大可能是网络原因导致的，而更可能是 GC 的原因。当然，跟踪 GC 有 N 多方法，这里我只是额外贴上了组件 B 使用的跟踪代码：

List<GarbageCollectorMXBean> gcbeans = ManagementFactory.getGarbageCollectorMXBeans();
for (GarbageCollectorMXBean gcbean : gcbeans) {
      LOGGER.info("GC bean: " + gcbean);
   if (!(gcbean instanceof NotificationEmitter))
      continue;

   NotificationEmitter emitter = (NotificationEmitter) gcbean;

   //注册一个GC(垃圾回收)的通知回调
   emitter.addNotificationListener(new NotificationListenerImplementation(), notification -> {
            return GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION
         .equals(notification.getType());
      }, null);

}

public final static class NotificationListenerImplementation implements NotificationListener {

   @Override
   public void handleNotification(Notification notification, Object handback) {
      GarbageCollectionNotificationInfo info = GarbageCollectionNotificationInfo
               .from((CompositeData) notification.getUserData());
      String gctype = info.getGcAction().replace("end of ", "");
      //此处只获取major gc的相关信息
      if(gctype.toLowerCase().contains("major")){
          long id = info.getGcInfo().getId();
          long startTime = info.getGcInfo().getStartTime();
          long duration = info.getGcInfo().getDuration();
          //省略非关键代码，记录GC相关信息，如耗费多久、开始时间点等。
      }
   }
}

另外同样是停顿，发生的时机不同，呈现的效果也不完全相同，具体问题还得具体分析。至于应对这个问题的策略，就是我们写 Java 程序一直努力的方向：减少 GC 引发的 STW 时间。

以上是我总结的 3 种常见“绊脚石”，那其实类似这样的问题还有很多，下一期分享我会再总结出 4 个场景化的问题，和你一起探讨应对策略。

大咖助场｜傅健：那些年，影响我们达到性能巅峰的常见绊脚石（下）

你好，我是傅健，又见面了。上一期分享我们总结了 3 个场景化的问题以及应对策略，这一期我们就接着“系统性能优化”这个主题继续总结。

场景 1：资源争用

案例

一段时间，我们总是监控到一些性能“掉队”的请求，例如平时我们访问 Cassandra 数据库都在 10ms 以内，但是偶尔能达到 3s，你可以参考下面这个度量数据：

{
   "stepName": "QueryInformation",
   "values": {
      "componentType": "Cassandra",
      "totalDurationInMS": 3548,
      "startTime": "2018-05-11T08:20:28.889Z",
      "success": true
    }
}

持续观察后，我们发现这些掉队的请求都集中在每天 8 点 20 分，话说“百果必有因”，这又是什么情况呢？

解析

这种问题，其实相对好查，因为它们有其发生的规律，这也是我们定位性能问题最基本的手段，即找规律：发生在某一套环境？某一套机器？某个时间点？等等，这些都是非常有用的线索。而这个案例就是固定发生在某个时间点。既然是固定时间点，说明肯定有某件事固定发生在这个点，所以查找问题的方向自然就明了了。

首先，我们上来排除了应用程序及其下游应用程序定时去做任务的情况。那么除了应用程序自身做事情外，还能是什么？可能我们会想到：运行应用程序的机器在定时做事情。果然，我们查询了机器的 CronJob，发现服务器在每天的 8 点 20 分（业务低峰期）都会去归档业务的日志，而这波集中的日志归档操作，又带来了什么影响呢？

日志备份明显是磁盘操作，所以我们要查看的是磁盘的性能影响，如果磁盘都转不动了，可想而知会引发什么。我们一般都会有图形化的界面去监控磁盘性能，但是这里为了更为通用的演示问题，所以我使用了 SAR 的结果来展示当时的大致情况（说是大致，是因为 SAR 的历史记录结果默认是以 10 分钟为间隔，所以只显示 8：20 分的数据，可能 8：21 分才是尖峰，但是却不能准确反映）：

从上面的结果我们可以看出，平时磁盘 await 只要 2ms，而 8：20 分的磁盘操作达到了几百毫秒。磁盘性能的急剧下降影响了应用程序的性能。

小结

在这个案例中，服务器上的定时日志归档抢占了我们的资源，导致应用程序速度临时下降，即资源争用导致了性能掉队。我们再延伸一点来看，除了这种情况外，还有许多类似的资源争用都有可能引起这类问题，例如我们有时候会在机器上装一些 logstash、collectd 等监控软件，而这些监控软件如果不限制它们对资源的使用，同样也可能会争用我们很多的资源。诸如此类，不一一枚举，而针对这种问题，很明显有好几种方法可以供我们去尝试解决，以当前的案例为例：

降低资源争用，例如让备份慢一点；

错峰，错开备份时间，例如不让每个机器都是 8 点 20 分去备份，而是在大致一个时间范围内随机时间进行；

避免资源共享，避免多个机器 / 虚拟机使用同一块磁盘。

上面讨论的争用都发生在一个机器内部，实际上，资源争用也常发生在同一资源（宿主、NFS 磁盘等）的不同虚拟机之间，这也是值得注意的一个点。

场景 2：延时加载

案例

某日，某测试工程师胸有成竹地抱怨：“你这个 API 接口性能不行啊，我们每次执行自动化测试时，总有响应很慢的请求。”于是一个常见的争执场景出现了：当面演示调用某个 API 若干次，结果都是响应极快，测试工程师坚持他真的看到了请求很慢的时候，但是开发又坚定说在我的机器上它就是可以啊。于是互掐不停。

解析

开发者后来苦思冥想很久，和测试工程师一起比较了下到底他们的测试有什么不同，唯一的区别在于测试的时机不同：自动化测试需要做的是回归测试，所以每次都会部署新的包并执行重启，而开发者复现问题用的系统已经运行若干天，那这有什么区别呢？这就引入了我们这部分要讨论的内容——延时加载。

我们知道当系统去执行某个操作时，例如访问某个服务，往往都是“按需执行”，这非常符合我们的行为习惯。例如，需要外卖点餐时，我们才打开某 APP，我们一般不会在还没有点餐的时候，就把 APP 打开“守株待兔”某次点餐的发生。这种思路写出来的系统，会让我们的系统在上线之时，可以轻装上阵。例如，非常类似下面的 Java“延时初始化”伪代码：

public class AppFactory{
  private static App app;
  synchronized App getApp() {
    if (App == null)
        app= openAppAndCompleteInit();
    return app;
  }
  //省略其它非关键代码
}

App app = AppFactory.getApp();
app.order("青椒土豆丝");

但这里的问题是什么呢？假设打开 APP 的操作非常慢，那等我们需要点餐的时候，就会额外耗费更长的时间（而一旦打开运行在后台，后面不管怎么点都会很快）。这点和我们的案例其实是非常类似的。

我们现在回到案例，持续观察发现，这个有性能问题的 API 只出现在第一次访问，或者说只出现在系统重启后的第一次访问。当它的 API 被触发时，它会看看本地有没有授权相关的信息，如果没有则远程访问一个授权服务拿到相关的认证信息，然后缓存认证信息，最后再使用这个认证信息去访问其它组件。而问题恰巧就出现在访问授权服务器完成初始化操作耗时比较久，所以呈现出第一次访问较慢，后续由于已缓存消息而变快的现象。

那这个问题怎么解决呢？我们可以写一个“加速器”，说白了，就是把“延时加载”变为“主动加载”。在系统启动之后、正常提供服务之前，就提前访问授权服务器拿到授权信息，这样等系统提供服务之后，我们的第一个请求就很快了。类似使用 Java 伪代码，对上述的延时加载修改如下：

public class AppFactory{
  private static App app = openAppAndCompleteInit();
  synchronized App getApp() {
      return app;
  }
  //省略其它非关键代码
}

小结

延时加载固然很好，可以让我们的系统轻装上阵，写出的程序也符合我们的行为习惯，但是它常常带来的问题是，在第一次访问时可能性能不符合预期。当遇到这种问题时，我们也可以根据它的出现特征来分析是不是属于这类问题，即是否是启动完成后的第一次请求。如果是此类问题，我们可以通过变“被动加载”为“主动加载”的方式来加速访问，从而解决问题。

但是这里我不得不补充一点，是否在所有场景下，我们都需要化被动为主动呢？实际上，还得具体情况具体分析，例如我们打开一个网页，里面内嵌了很多小图片链接，但我们是否会为了响应速度，提前将这些图片进行加载呢？一般我们都不会这么做。所以具体问题具体分析永远是真理。针对我们刚刚讨论的案例，这种加速只是一次性的而已，而且资源数量和大小都是可控的，所以这种修改是值得，也是可行的。

场景 3：网络抖动

案例

我们来看一则新闻：

类似的新闻还有许多，你可以去搜一搜，然后你就会发现：它们都包含一个关键词——网络抖动。

解析

那什么是网络抖动呢？网络抖动是衡量网络服务质量的一个指标。假设我们的网络最大延迟为 100ms，最小延迟为 10ms，那么网络抖动就是 90ms，即网络延时的最大值与最小值的差值。差值越小，意味着抖动越小，网络越稳定。反之，当网络不稳定时，抖动就会越大，网络延时差距也就越大，反映到上层应用自然是响应速度的“掉队”。

为什么网络抖动如此难以避免？这是因为网络的延迟包括两个方面：传输延时和处理延时。忽略处理延时这个因素，假设我们的一个主机进行一次服务调用，需要跨越千山万水才能到达服务器，我们中间出“岔子”的情况就会越多。我们在 Linux 下面可以使用 traceroute 命令来查看我们跋山涉水的情况，例如从我的 Linux 机器到百度的情况是这样的：

[root@linux~]# traceroute -n -T www.baidu.com
traceroute to www.baidu.com (119.63.197.139), 30 hops max, 60 byte packets
1  10.224.2.1  0.452 ms  0.864 ms  0.914 ms
2  1.1.1.1  0.733 ms  0.774 ms  0.817 ms
3  10.224.16.193  0.361 ms  0.369 ms  0.362 ms
4  10.224.32.9  0.355 ms  0.414 ms  0.478 ms
5  10.140.199.77  0.400 ms  0.396 ms  0.544 ms
6  10.124.104.244  12.937 ms  12.655 ms  12.706 ms
7  10.124.104.195  12.736 ms  12.628 ms  12.851 ms
8  10.124.104.217  13.093 ms  12.857 ms  12.954 ms
9  10.112.4.65  12.586 ms  12.510 ms  12.609 ms
10  10.112.8.222  44.250 ms  44.183 ms  44.174 ms
11  10.112.0.122  44.926 ms  44.360 ms  44.479 ms
12  10.112.0.78  44.433 ms  44.320 ms  44.508 ms
13  10.75.216.50  44.295 ms  44.194 ms  44.386 ms
14  10.75.224.202  46.191 ms  46.135 ms  46.042 ms
15  119.63.197.139  44.095 ms  43.999 ms  43.927 ms

通过上面的命令结果我们可以看出，我的机器到百度需要很多“路”。当然大多数人并不喜欢使用 traceroute 来评估这段路的艰辛程度，而是直接使用 ping 来简单看看“路”的远近。例如通过以下结果，我们就可以看出，我们的网络延时达到了 40ms，这时网络延时就可能是一个问题了。

[root@linux~]# ping www.baidu.com
PING www.wshifen.com (103.235.46.39) 56(84) bytes of data.
64 bytes from 103.235.46.39: icmp_seq=1 ttl=41 time=46.2 ms
64 bytes from 103.235.46.39: icmp_seq=2 ttl=41 time=46.3 ms

其实上面这两个工具的使用只是直观反映网络延时，它们都默认了一个潜规则：网络延时越大，网络越抖动。且不说这个规则是否完全正确，至少从结果来看，评估网络抖动并不够直观。

所以我们可以再寻求一些其它的工具。例如可以使用 MTR 工具，它集合了 tractroute 和 ping。我们可以看下执行结果：下图中的 best 和 wrst 字段，即为最好的情况与最坏的情况，两者的差值也能在一定程度上反映出抖动情况，其中不同的 host 相当于 traceroute 经过的“路”。

小结

对于网络延时造成的抖动，特别是传输延迟造成的抖动，我们一般都是“有心无力”的。只能说，我们需要做好网络延时的抖动监测，从而能真正定位到这个问题，避免直接无证据就“甩锅”于网络。

另外，在做设计和部署时，我们除了容灾和真正的业务需求，应尽量避免或者说减少“太远”的访问，尽量将服务就近到一个机房甚至一个机柜，这样就能大幅度降低网络延迟，抖动情况也会降低许多，这也是为什么 CDN 等技术兴起的原因。

那同一网络的网络延时可以有多好呢？我们可以自己测试下。例如，我的 Linux 机器如果 ping 同一个网段的机器，是连 1ms 都不到的：

[root@linux~]# ping 10.224.2.146
PING 10.224.2.146 (10.224.2.146) 56(84) bytes of data.
64 bytes from 10.224.2.146: icmp_seq=1 ttl=64 time=0.212 ms
64 bytes from 10.224.2.146: icmp_seq=2 ttl=64 time=0.219 ms
64 bytes from 10.224.2.146: icmp_seq=3 ttl=64 time=0.154 ms

场景 4：缓存失效

案例

在产线上，我们会经常观察到一些 API 接口调用周期性（固定时间间隔）地出现“长延时”，而另外一些接口调用也会偶尔如此，只是时间不固定。继续持续观察，你会发现，虽然后者时间不够规律，但是它们的出现时间间隔都是大于一定时间间隔的。那这种情况是什么原因导致的呢？

解析

当我们遇到上述现象，很有可能是因为“遭遇”了缓存失效。缓存的定义与作用这里不多赘述，你应该非常熟悉了。同时我们也都知道，缓存是以空间换时间的方式来提高性能，讲究均衡。在待缓存内容很多的情况下，不管我们使用本地缓存，还是 Redis、Memcached 等缓存方案，我们经常都会受限于“空间”或缓存条目本身的时效性，而给缓存设置一个失效时间。而当失效时间到来时，我们就需要访问数据的源头从而增加延时。这里以下面的缓存构建代码作为示例：

CacheBuilder.newBuilder().maximumSize(10_000).expireAfterWrite(10, TimeUnit.MINUTES).build();

我们可以看到：一旦缓存后，10 分钟就会失效，但是失效后，我们不见得刚好有请求过来。如果这个接口是频繁调用的，则长延时请求的出现频率会呈现出周期性的 10 分钟间隔规律，即案例描述的前者情况。而如果这个接口调用很不频繁，则我们只能保证在 10 分钟内的延时是平滑的。当然，这里讨论的场景都是在缓存够用的情况下，如果缓存的条目超过了设定的最大允许值，这可能会提前淘汰一些缓存内容，这个时候长延时请求出现的规律就无章可循了。

小结

缓存失效是导致偶发性延时增加的常见情况，也是相对来说比较好定位的问题，因为接口的执行路径肯定是不同的，只要我们有充足的日志，即可找出问题所在。另外，针对这种情况，一方面我们可以增加缓存时间来尽力减少长延时请求，但这个时候要求的空间也会增大，同时也可能违反了缓存内容的时效性要求；另一方面，在一些情况下（比如缓存条目较少、缓存的内容可靠性要求不高），我们可以取消缓存的 TTL，更新数据库时实时更新缓存，这样读取数据就可以一直通过缓存进行。总之，应情况调整才是王道。

总结

结合我上一期的分享，我们一共总结了 7 种常见“绊脚石”及其应对策略，那通过了解它们，我们是否就有十足的信心能达到性能巅峰了呢？

其实在实践中，我们往往还是很难的，特别是当前微服务大行其道，决定我们系统性能的因素往往是下游微服务，而下游微服务可能来源于不同的团队或组织。这时，已经不再单纯是技术本身的问题了，而是沟通、协调甚至是制度等问题。但是好在对于下游微服务，我们依然可以使用上面的分析思路来找出问题所在，不过通过上面的各种分析你也可以知道，让性能做到极致还是很难的，总有一些情况超出我们的预期，例如我们使用的磁盘发生损害，彻底崩溃前也会引起性能下降。

另外一个值得思考的问题是，是否有划算的成本收益比去做无穷无尽的优化，当然对于技术极客来说，能不能、让不让解决问题也许不是最重要的，剥丝抽茧、了解真相才是最有成就感的事儿。

感谢阅读，希望今天的分享能让你有所收获！如果你发现除了上述我介绍的那些“绊脚石”外，还有其它一些典型情况存在，也欢迎你在留言区中分享出来作为补充。

大咖助场｜庄振运：与程序员相关的SSD性能知识

你好，我是庄振运。我是《性能工程高手课》的专栏作者，很荣幸受邀来到陶辉老师的专栏做一期分享。今天我们来讲一点 SSD 相关的性能知识。SSD（Solid State Drive）是硬盘的一种，有时候也叫 Flash 或者固态硬盘。

最近几年，SSD 的发展和演化非常迅速。随着市场规模的增大和技术的进步，SSD 的价格也大幅度降低了。在很多实时的后台系统中，SSD 几乎已经成了标准配置了。所以了解它的机制和性能，对你的工作会很有益处的。

相对于传统硬盘 HDD（Hard Disk Drive），SSD 有完全不同的内部工作原理和全新的特性。有些机制不太容易理解，而且根据你工作的领域，需要理解的深度也不一样。所以，我把这节课的内容按照由浅入深的原则分成了三个层次。

第一个层次是关注 SSD 的外部性能指标；第二个层次是了解它的内部工作机制；第三个层次是设计对 SSD 友好的应用程序。

比 HDD 更快的硬盘

很多人对传统硬盘了解较多，毕竟这种硬盘在业界用了好几十年了，很多教科书里面都讲述过。所以，对 SSD 的性能，我先用对比的方式带你看看它们的外部性能指标和特性。

一个硬盘的性能最主要体现在这三个指标：IOPS，带宽 / 吞吐率和访问延迟。IOPS (Input/Output Per Second) ，即每秒钟系统能处理的读写请求数量。访问延迟，指的是从发起 IO 请求到存储系统把 IO 处理完成的时间间隔。吞吐率（Throughput）或者带宽（Bandwidth），衡量的是实际数据传输速率。

对于传统硬盘我们应该比较熟悉它的内部是如何操作的，简单来说，当应用程序发出硬盘 IO 请求后，这个请求会进入硬盘的 IO 队列。当轮到这个 IO 来存取数据时，磁头需要机械运动到数据存放的位置，这就需要磁头寻址到相应的磁道和旋转到相应的扇区，然后才是数据的传输。对于一块普通硬盘而言，随机 IO 读写延迟就是 8 毫秒左右，IO 带宽大约每秒 100MB，而随机 IOPS 一般是 100 左右。

SSD 的种类很多，按照技术来说有单层和多层。按照质量和性能来分，有企业级和普通级。根据安装的接口和协议来分，有 SAS, SATA, PCIe 和 NVMe 等。

我用一张表格来对比一下 HDD 和 SSD 的三大性能指标的差异。这里考虑比较流行的 NVMe 协议的 SSD。你可以看到，SSD 的随机 IO 延迟比传统硬盘快百倍以上，一般在微妙级别；IO 带宽也高很多倍，可以达到每秒几个 GB；随机 IOPS 更是快了上千倍，可以达到几十万。

SSD 的性能特性和机制

SSD 的内部工作方式和 HDD 大相径庭，我们先了解几个概念。

单元（Cell）、页面（Page）、块（Block）。当今的主流 SSD 是基于 NAND 的，它将数字位存储在单元中。每个 SSD 单元可以存储一位或多位。对单元的每次擦除都会降低单元的寿命，所以单元只能承受一定数量的擦除。单元存储的位数越多，制造成本就越少，SSD 的容量也就越大，但是耐久性（擦除次数）也会降低。

一个页面包括很多单元，典型的页面大小是 4KB，页面也是要读写的最小存储单元。SSD 上没有“重写”操作，不像 HDD 可以直接对任何字节重写覆盖。一个页面一旦写入内容后就不能进行部分重写，必须和其它相邻页面一起被整体擦除重置。

多个页面组合成块。一个块的典型大小为 512KB 或 1MB，也就是大约 128 或 256 页。块是擦除的基本单位，每次擦除都是整个块内的所有页面都被重置。

了解完以上几个基础概念，我们重点看看 IO 和垃圾回收 (Garbage Collection) 。对 SSD 的 IO 共有三种类型：读取、写入和擦除。读取和写入以页为单位。IO 写入的延迟具体取决于磁盘的历史状态，因为如果 SSD 已经存储了许多数据，那么对页的写入就经常需要移动已有的数据。一般的读写延迟都很低，在微秒级别，远远低于 HDD。擦除是以块为单位的。擦除速度相对很慢，通常为几毫秒。所以对同步的 IO，发出 IO 的应用程序可能会因为块的擦除，而经历很大的写入延迟。为了尽量地减少这样的场景，保持空闲块的阈值对于快速的写响应是很有必要的。SSD 的垃圾回收（GC）的目的就在于此。GC 可以回收用过的块，这样可以确保以后的页写入可以快速分配到一个全新的页。

写入放大（Write Amplification, or WA)。 这是 SSD 相对于 HDD 的一个缺点，即实际写入 SSD 的物理数据量，有可能是应用层写入数据量的多倍。一方面，页级别的写入需要移动已有的数据来腾空页面。另一方面，GC 的操作也会移动用户数据来进行块级别的擦除。所以对 SSD 真正的写操作的数据可能比实际写的数据量大，这就是写入放大。一块 SSD 只能进行有限的擦除次数，也称为编程 / 擦除（P/E）周期，所以写入放大效用会缩短 SSD 的寿命。

耗损平衡 (Wear Leveling) 。对每一个块而言，一旦达到最大数量，该块就会死亡。对于 SLC 块，P/E 周期的典型数目是十万次；对于 MLC 块，P/E 周期的数目是一万；而对于 TLC 块，则可能是几千。为了确保 SSD 的容量和性能，我们需要在擦除次数上保持平衡，SSD 控制器具有这种“耗损平衡”机制可以实现这一目标。在损耗平衡期间，数据在各个块之间移动，以实现均衡的损耗，这种机制也会对前面讲的写入放大推波助澜。

设计对 SSD 友好的程序

SSD 的 IO 性能相对于 HDD 来说，IOPS 和访问延迟提升了上千倍，吞吐率也是几十倍，但是 SSD 的缺点也很明显，有三个：贵、容量小、易损耗。随着技术的发展，这三个缺点近几年在弱化。

现在越来越多的系统采用 SSD 来减轻应用程序的 IO 性能瓶颈。许多部署的结果显示，与 HDD 相比，SSD 带来了极大的应用程序的性能提升。但是，在大多数部署方案中，SSD 仅被视为“更快的 HDD”，SSD 的潜力并未得到充分利用。尽管使用 SSD 作为存储时应用程序可以获得更好的性能，但是这些收益主要归因于 SSD 提供的更高的 IOPS 和带宽。

进一步讲，如果应用程序的设计充分考虑了 SSD 的内部机制，从而设计为对 SSD 友好，则可以更大程度地优化 SSD，从而进一步提高应用程序性能，也可以延长 SSD 的寿命而降低运用成本。接下来我们就看看如何在应用程序层进行一系列 SSD 友好的设计更改。

为什么要设计 SSD 友好的软件和应用程序？

SSD 友好的程序可以获得三种好处：

提升应用程序性能；

提高 SSD 的 IO 效率；

延长 SSD 的寿命。

我分别说明一下。

更好的应用程序性能。尽管从 HDD 迁移到 SSD 通常意味着更好的应用程序性能，这主要是得益于 SSD 的 IO 性能更好，但在不更改应用程序设计的情况下简单地采用 SSD 可能无法获得最佳性能。我们曾经有一个应用程序就是如此。该应用程序需要不断写入文件以保存数据，主要性能瓶颈就是硬盘 IO。使用 HDD 时，最大应用程序吞吐量为每秒 142 个查询（QPS）。无论对应用程序设计进行各种更改还是调优，这都是可以获得的最好性能。

当迁移到具有相同应用程序的 SSD 时，吞吐量提高到 2 万 QPS，速度提高了 140 倍。这主要来自 SSD 提供的更高 IOPS。在对应用程序设计进行进一步优化使其对 SSD 友好之后，吞吐量提高到 10 万 QPS，与原来的简单设计相比，提高了 4 倍。

这其中的秘密就是使用多个并发线程来执行 IO，这就利用了 SSD 的内部并行性。记住，多个 IO 线程对 HDD 毫无益处，因为 HDD 只有一个磁头。

更高效的存储 IO。 SSD 上的最小内部 IO 单元是一页，比如 4KB 大小。因此对 SSD 的单字节读 / 写必须在页面级进行。应用程序对 SSD 的写操作可能会导致对 SSD 上的物理写操作变大，这就是“写放大（WA）”。因为有这个特性，如果应用程序的数据结构或 IO 对 SSD 不友好，就会让写放大效果无谓的更大，导致 SSD 的 IO 不能被充分利用。

更长的使用寿命。SSD 会磨损，因为每个存储单元只能维持有限数量的写入擦除周期。实际上，SSD 的寿命取决于四个因素：SSD 大小、最大擦除周期数、写入放大系数和应用程序写入速率。例如，假设有一个 1TB 大小的 SSD，一个写入速度为 100MB 每秒的应用程序和一个擦除周期数为 1 万的 SSD。当写入放大倍数为 4 时，SSD 仅可持续 10 个月。具有 3 千个擦除周期和写入放大系数为 10 的 SSD 只能使用一个月。鉴于 SSD 相对较高的成本，我们很希望这些应用程序对 SSD 友好，从而延长 SSD 的使用寿命。

SSD 友好的设计原则

在设计程序的时候，我们可以把程序设计成对 SSD 友好，以获得前面提到的三种好处。那有哪些对 SSD 友好的程序设计呢？我这里总结了四个原则，大体上分为两类：数据结构和 IO 处理。

1. 数据结构：避免就地更新的优化

传统 HDD 的寻址延迟很大，因此，使用 HDD 的应用程序通常经过优化，以执行不需要寻址的就地更新（比如只在一个文件后面写入）。如下图所示，执行随机更新时，吞吐量一般只能达到约 170QPS；而对于同一个 HDD，就地更新可以达到 280QPS，远高于随机更新（如下左图所示）。

在设计与 SSD 配合使用的应用程序时，这些考虑就不再有效了。对 SSD 而言，随机读写和顺序读写性能类似，就地更新不会获得任何 IOPS 优势。此外，就地更新实际上会导致 SSD 性能下降。原因是包含数据的 SSD 页面无法直接重写，因此在更新存储的数据时，必须先将相应的 SSD 页面读入 SSD 缓冲区，然后将数据写入干净页面。 SSD 中的“读取 - 修改 - 写入”过程与 HDD 上的直接“仅写入”行为形成鲜明对比。相比之下，SSD 上的随机更新就不会引起读取和修改步骤（即仅仅“写入”），因此速度更快。使用 SSD，以上相同的应用程序可以通过随机更新或就地更新来达到大约 2 万 QPS（如上右图所示）。

2. 数据结构：将热数据与冷数据分开

对于几乎所有处理存储的应用程序，磁盘上存储的数据的访问概率均不相同。我们考虑这样一个需要跟踪用户活动的社交网络应用程序，对于用户数据存储，简单的解决方案是基于用户属性（例如注册时间）将所有用户压缩在同一位置（例如某个 SSD 上的文件），以后需要更新热门用户的活动时，SSD 需要在页面级别进行访问（即读取 / 修改 / 写入）。因此，如果用户的数据大小小于一页，则附近的用户数据也将一起访问。如果应用程序其实并不需要附近用户的数据，则额外的数据不仅会浪费 IO 带宽，而且会不必要地磨损 SSD。

为了缓解这种性能问题，在将 SSD 用作存储设备时，应将热数据与冷数据分开。以不同级别或不同方式来进行分隔，例如，存到不同的文件，文件的不同部分或不同的表。

3. IO 处理：避免长而繁重的写入

SSD 通常具有 GC 机制，不断地回收存储块以供以后使用。 GC 可以后台或前台方式工作。

SSD 控制器通常保持一个空闲块的阈值。每当可用块数下降到阈值以下时，后台 GC 就会启动。由于后台 GC 是异步发生的（即非阻塞），因此它不会影响应用程序的 IO 延迟，但是，如果块的请求速率超过了 GC 速率，并且后台 GC 无法跟上，则将触发前台 GC。

在前台 GC 期间，必须即时擦除（即阻塞）每个块以供应用程序使用，这时发出写操作的应用程序所经历的写延迟会受到影响。具体来说，释放块的前台 GC 操作可能会花费数毫秒以上的时间，从而导致较大的应用程序 IO 延迟。出于这个原因，最好避免进行长时间的大量写入操作，以免永远不使用前台 GC。

4. IO 处理：避免 SSD 存储太满

SSD 磁盘存储太满会影响写入放大系数和 GC 导致的写入性能。在 GC 期间，需要擦除块以创建空闲块。擦除块前需要移动并保留有效数据才能获得空闲块。有时为了获得一个空闲块，我们需要压缩好几个存储块。每个空闲块的生产需要压缩的块数取决于磁盘的空间使用率。

假设磁盘满百分比平均为 A％，要释放一块，则需要压缩 1 /（1-A％）块。显然，SSD 的空间使用率越高，将需要移动更多的块以释放一个块，这将占用更多的资源并导致更长的 IO 等待时间。例如，如果 A=80％，则大约移动五个数据块以释放一个块；当 A=95％时，将移动约 20 个块。

总结

各种存储系统的基础是传统硬盘或者固态硬盘，固态硬盘 SSD 的 IO 性能比传统硬盘高很多。如果系统对 IOPS 或者延迟要求很高，一般都采用 SSD。

现在已经有很多专门针对 SSD 来设计的文件系统、数据库系统和数据基础架构，它们的性能比使用 HDD 的系统都有了很大的提升。

SSD 有不同于 HDD 工作原理，所以在进行应用程序设计的时候，如果可以做到对 SSD 友好，那么就可以充分发挥 SSD 的全部性能潜能，应用程序的性能会进一步提高。你可以参考我们今天总结的四个设计原则进行实践。

思考题

最后给你留几道思考题。你们公司里面，有哪些后台服务和应用是使用 SSD 作为存储的？而对这些使用 SSD 的系统，有没有充分考虑 SSD 的特性，做深层的优化呢（比如降低损耗）？

感谢阅读，如果今天的内容让你有所收获，欢迎把它分享给你的朋友。

特别放送 | 大厂面试到底在考些什么？

你好，我是陶辉。这节课我们换换脑子，聊一个相对轻松点的话题——大厂面试。

2004 年我毕业于西安交通大学计算机科学与技术专业，此后 16 年来既在华为、腾讯、思科、阿里巴巴这样的大厂工作过，也在两家几十人的创业公司工作过，在这种对比下，我对大厂面试的考核点很有心得体会。

作为候选人我拿到过很多大厂 offer，作为面试官也考核过数百位同学的技术水平，因此，今天这节课我会兼顾面试官与候选人的视角，分享如何拿下一线大厂的技术面试。

大厂面试到底在考些什么？

相信绝大多数同学都经历过技术面试，你肯定发现，小厂与大厂的面试题差距很大，其中，大厂特别关注程序性能，为什么呢？在我看来有这样 3 个原因：

首先，大厂产品的用户基数大，任何微小的性能提升都会被庞大的用户数放大。因此，员工具备性能优先的思维，有利于提升产品竞争力。

其次，大厂经常会重新造轮子，不管原因是什么，造轮子都需要深厚的底层知识，而性能是其中的核心要素。而且，愿意花时间去掌握底层知识的候选人，学习动力更强，潜力也会更好。

最后，大厂待遇好，成长空间大，是典型的稀缺资源，大家都打破了头往里挤。在这样优中选优的情况下，有区分度的性能题就是最好的面试题，通过快速筛选不同档次的候选人，可以节约招聘成本。

那么，对于候选人来说，到底怎样才能答好性能面试题呢？首先，背网上流传的大厂面试题，绝对不是个好主意，这是因为大厂的面试题并不是固定的，往往都是考官自备的面试题，这与每位考官的个人经历有关，所以你押中面试题的概率非常低。

而且，面试并不是为了找出最优秀的那位候选人（这样的候选人手里往往拿着许多优厚的 offer，签下他并不容易），而是将大量的候选人分出层次，再按照团队的业务发展、技术方向、薪资规划来发放 offer。这样的话，面试题就必须是开放的，在各个层次上都有考核点，有内涵更有外延，任何一个点考官都可以展开了聊上个把小时。你背的知识点，未必是考官感兴趣会展开了问的点。

所以，大厂面试考核的是技能、潜力，而不是知识，面试前的刷题不是为了背答案，而是通过练习来提升技能！

下面我就以 1 道算法题为例，带你看看大厂面试中都在考哪些技能点。

举例：1 道算法题可以考核多少知识点？

题目：请用你熟悉的一门编程语言实现 Fibnacci 函数。

Fibnacci 是中学代数提过的一个函数，在自然界中广泛存在，美学中的黄金分割点也与它相关。可能有些同学还不熟悉 Fibnacci 函数，它的定义如下：

比如 Fib(6)=Fib(5)+Fib(4)=5+3=8。

我个人非常喜欢用这道面试题，因为它有很好的区分度，至少能考核候选人 6 个方面的能力。

首先它像所有编码题一样，可以判断候选人是否至少熟练使用一门编程语言，特别是在不依赖编辑器错误提示的情况下，能不能在白板上手写出高质量的代码。这通常是大厂的基本要求。

其次，Fibnacci 函数很显然非常适合用递归函数实现，大多数候选人都可以写出递归函数，比如：

Fib(n) {
  if (n <= 0) return 0;
  if (n == 1) return 1;
  return Fib(n-1)+Fib(n-2);
}

我们可以看到递归函数其实并不难，然而面试官会很自然地追问 2 个问题，这才是考核点：

首先，递归函数在系统层面有什么问题？

这其实是在考你是否知道栈溢出问题。每调用一次函数，需要将函数参数、返回值压栈，而操作系统为每个线程分配的栈空间是有限的，比如 Linux 通常只有 8MB。因此，当数字 n 过大时，很容易导致 StackOverFlow 错误。

其次，这段递归代码的时间复杂度是多少？

如果你还没有时间复杂度的概念，请再次阅读[第 3 讲]。《算法导论》中的递归树很适合用于猜测算法的时间复杂度，下图是 Fibnacci(6) 展开的计算量，可见，递归树中所有节点的数量，就是递归函数的时间复杂度：

图片来源：https://medium.com/launch-school/recursive-fibonnaci-method-explained-d82215c5498e

如果你仔细数一数，会发现随着 n 的增大，节点数大致接近 2n 个，因此其时间复杂度为 O(2n)。当然，树中的节点数与 Fibnacci 数列相关，大致为 O(1.618n) 个（书中还介绍了 2 种求解递归函数时间复杂度的方法：代入法和主定理，请参考书中第 4 章）。

最后，这道题还可以考察候选人能否运用逆向思维，通过一个循环实现递归函数的效果。递归法的时间复杂度之所以达到了 O(2n)，是因为做了大量的重复运算。比如求 Fibnacci(6) 时，Fibnacci(2) 重复执行了 4 次。如果对 n 从小向大做递推运算，重复使用已经计算完成的数字，就能大大减少计算量，如下所示：

Fib(n) {
  if (n <= 0) return 0;
  if (n == 1) return 1;
  prev = 0;
  cur = 1;
  for (i = 2; i <= n; i = i + 1) {
    next = cur + prev;
    prev = cur;
    cur = next;
  }
  return next;
}

这样，只通过 1 次循环，就能以 O(n) 的时间复杂度完成 Fibnacci 数列的计算。这与动态规划的实现思路是一致的。注意，使用递推法时如果不小心，会采用数组存放计算出的每个结果，这样空间复杂度就从 O(1) 到了 O(n)，这也是一个考核点。

当然，我们还可以通过公式直接计算出数列的值：

但纯粹背下这个公式并没有意义，因为你写出来后，会有 2 个问题等着你：

首先，如何使用高等数学推导出这个公式？如果你能够答出来，那么证明你的数学功底很好，在数据挖掘、人工智能人才短缺的当下，这对你在大厂内部的职业发展很有好处！

其次，上面这个公式有大量的浮点运算，在数学中数字可以是无限长的，但在计算机工程体系中，任何类型都有最大长度（比如浮点类型通常是 64 个比特位），所以对于根号 5 这样的无理数，小数点后的数字会出现四舍五入而不精确，而且当 n 非常大时，有限的内存还会导致数据溢出。因此上述的公式法并不能直接使用，如果你能回答出适合计算机使用的矩阵解法（请参考wiki，这里不再列出矩阵法的细节），那就更完美了。

可见，这么一道简单的题目，就可以考察递归编码能力、递推解法、公式解法、矩阵解法、时间复杂度的推算、计算机浮点运算特性等许多知识点。而且，随着你回答时涉及到更多的知识点，面试官会基于自己的经验进一步延伸提问。所以我不推荐你押题，把基础技能掌握好才是最有效的面试备战法。

这道题目只是入门的算法题，如果你应聘的是 Google、头条之类非常重视算法的公司，那么你还必须掌握动态规划、贪心算法、图算法等高级算法等等。

小结

今天我们只是详细讲解了算法题，其实从系统工程、网络协议上也可以从性能优化这个方向快速区分出候选人的能力水平。比如：

我在开课直播时提出的关于并发的问题，多进程和多线程、协程实现的并发编程，各自的优势和劣势是什么（你可以参考[第 5 讲]）？

或者 TCP 连接的 close_wait 状态出现时应当如何解决（你可以参考[第 10 讲]）？

这些都在考察你如何通过操作系统，协调使用系统资源的能力。

另外，除了硬核的知识技能外，你也不要忽略软技能，这也能在面试中加分。比如，任何大厂都非常强调团队协作，如果员工遇到难题时，只会闷头冥想，这样的时间成本太高，既有可能延迟项目进度，也不利于充分发挥大厂高手如林、资源丰富的优势。所以，如果你在面试中，表现出善于沟通、乐于求助的特性，都是加分项。

以上就是我对大厂面试的一些沉淀和思考，不知道你有没有感同身受呢？如果你在面试中也遇到过一些特别开放、有区分度的面试题，或者作为面试官你有哪些喜欢用的面试题，欢迎分享出来，我们一起探讨。

感谢阅读，如果你觉得这节课有所收获，也欢迎把它分享给你的朋友。

结束语 | 从业 IT 20年后，我将最看重什么？

你好，我是陶辉。

经过 3 个多月的更新，咱们这门课今天就正式结课了！很感谢你的一路相伴，我有收获很多的分享和故事，这份信任弥足珍贵。

走到今天，我入行已经有 20 年了，那作为结束语，最后我特别想和你聊聊我的一些心得体会，就说说我最看重的两大能力吧，希望能给你的未来发展带来一些正反馈！

每次聊天提到程序员，大部分人的脑海中总会跳出两个词：996 与 35 岁危机，它们都反应了 1 个现实：程序员的竞争压力太大了。其实，老板是无法逼迫你 996 的，只有大量虎视眈眈盯着你职位、薪酬包的竞争者，才能让你心甘情愿的加班，让你为了度过 35 岁危机而不停地试图突破天花板！为什么程序员的供给量这么大呢？这既来源于中国庞大的人口基数，也因为资本、人才密集的互联网行业，为年轻人提供了罕见的高薪资、高成长机会。

程序员的平均薪资远高于其他职业，这造成大量职场新人转行入互联网，从 Javascript、Android、Python 等工程师做起（这些语言形成闭环的路径更短，入门更快），寻求更好的个人发展。我有一些半路转行的朋友，经常见到他们凌晨还在提交代码，痛苦的转行过程让他们的自我驱动力无比强大。

各类院校也在为这个职业输送大量人才。在我上大学那会，只有计算机科学与技术这一个编程专业。现在，软件工程、网页设计各类专业层出不穷，而且，计算机课程已经成为各理工专业必修的基础课。

所以程序员普遍年轻，35+ 的我已经是我们团队年纪最大的程序员了，这在其他行业实在是不可思议的事！另一方面，互联网仍然是一个朝阳行业，变化是永恒的主题。在开源盛行的当下，注定会有层出不穷却又不太稳定的新框架，只有深入的学习才能用好、改进它。所以，我们常常会让自己“忙”起来、“学”起来，这样似乎可以忘却焦虑。

那焦虑是什么呢？在我看来，就是对未来的自己是否仍具备竞争力的不确定感。如果只是在不停地学习新语言、新框架，那么 35 岁的你，竞争力一定不如 25 岁的你，因为对框架的熟练度是不值几倍薪资差距的。我认为，有两个能力可以消除这种不确定性，它们不会因为新技术迭代，每次都让你数据清零后重新开始。

构建知识体系最需要什么？

首先，是不局限于一招一式、构建知识体系的能力。当你还未对某个领域形成知识体系时，只能解决曾经遇到过的相似问题，或者仅涉及单一知识点、能够从网上查询到答案的简单问题。而基于新技术做架构设计，或者定位涉及多个系统的复杂问题时，知识体系是最值得依赖的灯塔，它能指引你前进的方向。

知识体系可以将散落在脑海中的知识，通过逻辑联系在一起，形成庞大的网络。知识点之间的联系线条越多，网络就越健壮，越能应对不确定性；网络越大，覆盖的领域越广，你能解决问题的价值就越高！所以，我们需要结网的能力！

比如，你学习了某个人脸识别框架的用法，通过 API 把它集成到系统中，此时该技术与已有知识体系是割裂的。在你了解 CNN 网络，知道它只不过是一种聚类函数后，就能通过数学知识把它联结到知识网络中；在了解到密集浮点运算下保持精度的方案，与主流 IEEE-754 方案间的差别后，你又可以从数值计算维度上与常用的编程语言关联起来；从 GPU、CPU 对并行计算的设计差别上，你还可以在计算体系架构这条线上增强知识体系；从分布式模型训练系统中找到分而治之的思想，你就可以从算法上连接网络，等等。

概括下的话，我认为构建知识体系最需要的其实是底层知识！两个看似无关的技术，可以再往下看一层，找找它们共同的理论基础，建立逻辑关联。我认为，下面 3 个底层知识对结网的帮助最大：

数据结构与算法，我推荐你精读《算法导论》这本书，程序是由数据与算法构成的，这条线几乎可以连接所有技术点。

计算机网络知识，它可以连接所有涉及互联网的技术，这也是我推出《Web 协议详解与抓包实战》这门课的初衷。

最后是操作系统知识，毕竟所有的软件都需要通过操作系统才能操作硬件。

另外，咱们在给知识体系添砖加瓦时，一定要注意知识的正确性，否则网络越密，后续修复成本就越高。我一般建议你尽量去源头寻找第一手知识，虽然有时这并不容易。比如学习 HTTP/3 协议时，我们只能去看那 5 份 RFC 文档，但 RFC 文档的结构设计是作为参考手册使用的，它并不适合首次学习，此时我们还可以借鉴一些权威高手基于一手知识分享的“二手”文章，这能让你更快地看懂 RFC。

好的“表达力”能为你插上翅膀

第 2 个最能对抗“焦虑”的是表达力。做技术的同学通常对跟自己技术水平差不多，但因为表达力更好、从而拥有更高职位的同学不屑一顾，我曾经也是这样，总觉得咱靠的是“真才实料”，不是嘴皮子。这是一个很大的误区，它与你的职业发展密切相关。

如果团队里有 3 位同学：1 位技术最好却不擅言辞，1 位擅长管理、调动团队氛围，1 位擅长表达，你觉得谁的职业发展最好呢？其实是最后那位，因为当团队向上级汇报工作时，他最容易让团队的工作得到认可，从而获取更多的资源把事情做成，使人人都有好处。久而久之，他就会获得更多的提拔机会。

我想你多半遇到过那种沟通起来特“费劲”的人，说话总是找不到重点，这样他就只能做一些简单的、不需要协作的工作。现代社会是需要高度协作的，如果没办法说清楚你的工作成果，你的价值就会大大缩水。当然，好的表达力不是无中生有，也不是能说会道，毕竟你面对的都是专业人士。

在职场中，由于沟通对象时间有限，所以采用金字塔方式表达效果最好。比如，当我向老板汇报工作时，我会在第一时间讲结论，然后再按照重要性顺序讲论据。否则，他同时要处理的事务比我广得多，如果我不能快速让他抓住重点，就很容易失去这次沟通机会。再比如我写这个专栏时，大家同时订阅了那么多课程，时间非常珍贵，我必须在每节课起始就开宗明义地给出场景，把各种对立面引发的冲突列出来，如果它能聚焦你的视线，我就会在正文中层层递进地讲下去，每一段总是为了引出下一段，防止在碎片化阅读时代里丢掉你的注意力。

那如果你想学习金字塔表达方式的话，我十分建议你精读《金字塔原理》这本书，这也是写作本专栏时，我的编辑推荐给我的，对我的帮助非常大，现学现卖推荐给你。

总结而言，我希望你拥有“结网能力”。构建广泛、结实的知识网络，可以帮助你提升竞争力、减轻“焦虑”，面对新技术时不用清零重来；但当你无法将新学的知识纳入已有知识体系时，不妨把眼光放低点，从底层技术中找找关联。 我还希望你拥有“表达能力”。再硬核的知识体系也需要通过优秀的表达力，转换为动听的语言和精练的文字，再通过互联网跨越时空，让你跳出公司，在整个行业中提升影响力、竞争力。

希望这 2 种能力能帮助你在大的技术洪流中站稳脚跟，从无数的竞争对手中脱颖而出。最后，祝你工作顺利，也希望这门课能成为你抵抗焦虑的武器之一。

最后的最后，我还为你准备了一份毕业问卷，希望你能用 2 分钟的时间填写一下。大胆表达自己，期待听到你的声音！