191-性能优化高手课

开篇词 | 与我一起修炼，成为一名软件性能大师吧！

你好，我是尉刚强，一名软件设计咨询顾问。从今天开始，我会带你深入软件性能优化的探索之旅。

从业十多年来，我先后在通信领域、大数据领域、人工智能对话领域、SaaS 服务等领域的一线工作，主要解决软件架构设计与性能优化上的问题。同时，我也在为国内一些知名企业提供软件设计咨询服务。

正因为我跨领域的项目性能优化经历，在寻找性能优化思路时，我的视角往往是多维度的，并由此总结出了一套从设计到交付、兼具多个领域优势的全流程性能优化方案。比如，之前我在 SaaS 服务性能优化项目中，所使用的编解码、实时处理任务等高性能实现方案，就是借鉴了嵌入式系统上的优化经验。

你可能会说，老师，我觉得快速编码交付业务的功能，才是最有意思的，为啥要学习一整套优化方案呢？

说实话，我刚开始工作的时候，也是一个只关注软件设计与开发的工程师，和你想的一样。不过后来的一次项目开发经历，让我发现，原来软件的性能也可以从设计和开发的视角去实现和交付，而且从软件设计与架构维度出发的性能优化，不仅性能收益更大，而且还会让软件架构设计更加合理和简洁。

支撑软件性能长期保持竞争力的道理其实很简单

事情其实是这样的：那时我还在一家通信企业工作，带领功能交付团队，与另一个专门负责性能攻关和优化的团队一起协作，来支撑子系统版本的交付。不过，在协作的过程中，因为我们两个团队互相不理解对方的工作，产生了不少矛盾和冲突。所以，后来，为了缓解双方的矛盾，领导让我去负责性能优化攻关团队的工作。

可是，由于前期性能优化团队主要是基于编译优化、测量分析手段来进行性能攻关和优化的，已经将代码实现修改的性能优化空间挖掘殆尽了，所以，我在负责性能优化工作时，就只能另辟蹊径。

而我们应该都知道，软件架构设计通常来说对性能的影响很大，况且当时我们面临的困境确实是无法再从代码实现层来进行调优了。

由于我之前一直负责软件设计与开发，非常清楚业务的软件设计架构，所以，我就选择从软件设计与架构的视角出发，通过不断调整设计架构，来尝试优化性能。比如说，根据业务使用内存的特性，实现定制化内存分配器，以提升内存的申请和释放效率；优化业务代码逻辑，实现核心流程中的内存零拷贝机制，等等。

由此，在这个性能优化的过程之中，我真的就找到了破局之道。最终，这个性能优化项目在实现代码规模量减少、设计更加优雅的同时，性能上也有较大幅度的提升。

再后来，我参与的架构设计与实现的软件越来越多，在不同领域下解决的性能优化问题也越来越多，而我的这些经历也在不断印证着一个道理，就是虽然不同领域的业务知识差异比较大，但其背后支撑性能设计与优化的原理与方法论，却是统一的。

就比如说，SaaS 服务领域的性能优化中使用延迟计算，与嵌入式领域的延迟计算优化的思想是一致的；智能对话引擎基于消息回复时延的设计方法，也跟实时性系统时延的设计方法是统一的；类似的还有消息队列设计原理、选型设计，等等。

所以到了这里，我才真正理解和摸清了软件性能优化的关键命脉，那就是要从软件设计阶段对性能进行建模设计，再在后续的编码实现、测试、维护等阶段进行层层控制，只有这样，才能真正做好性能优化工作。

为什么你学习了很多性能优化知识，却依然解决不了性能问题？

现在，我作为一名软件技术咨询顾问，有机会接触到更多的项目和团队。可遗憾的是，我在跟不同领域的研发团队配合的过程中，发现他们处理、解决性能问题的能力差距比较大。

很多团队还在使用低效的方式去处理解决各种性能问题，甚至还有很多团队没有系统化的性能设计与调优的能力，当碰到比较简单的性能问题时还能处理，可一旦碰到一些复杂、深层次的软件性能问题，就很容易陷入到僵局之中。

这是为什么呢？实际上，传统的性能优化视角，更多的是从问题与测量数据的角度出发，是被动式地解决处理性能问题，所以依据这样的视角驱动的性能优化工作，就会存在很多的局限性，比如说：

很容易造成代码可读性差，而且只是做到局部性的优化；

这种工作方式通常是在软件工程的后期才启动，发现问题会比较滞后；

一次性优化合入的代码量很大，导致软件版本发生质量问题的概率会比较大；

由于没有设计与数学理论的支撑，不知道性能优化目标在哪个范围内才是合理的。

而在这门课程中，我要给你介绍的性能优化，是从整个软件生命周期的视角去关注与审视软件性能，通过主动式地设计与实现来主导软件性能，从而支撑软件性能长期保持竞争力。

另外，这里我想告诉你的是，这门课程并不是一个工具技术类课程，你可以等有需要或是遇到具体问题时再去翻找查看。这门课更像是一个思维训练类课程，它会站在系统端到端的角度，分析从软件设计到工程发布上线这一整个过程中，如何有效保证软件性能一直处于可控状态。

所以在学习的过程中，你需要和我一起去思考分析这样做的出发点，以及背后支撑的理论是什么，从而深入挖掘出全局的性能设计与优化的方法论，并提升软件性能工程的管理能力。

当然，也许有些内容与方法你暂时还用不到，但是当你在实际场景中遇到不同的性能问题时，它也可以启发你寻找到更系统的解决思路和方法。

好了，那么具体我是怎么设计这门课的呢？下面我就来给你介绍下课程的内容框架吧。

这门课是怎么设计的？

我把课程主体划分为了五大模块，分别针对基于性能的建模与设计、高性能的编码实现、性能看护和性能持续调优四个部分进行方法与实践的讲解介绍，让你能够明确地理解和掌握系统级的性能优化的理念和方法，最后还会通过一个案例模块，来帮助你提升具体实施性能优化方法的能力。

性能设计篇

这一模块，我会给你介绍几种在性能优化设计中非常关键、且经常用到的设计方法，包括并发架构设计、内存模型设计、IO 通信设计等。另外，我还会从性能扩展和调优的角度，给你讲解在软件设计阶段需要考虑的可监控设计、可扩展性设计、可移植性设计、软硬件选型设计，以此帮助你在实际的建模和设计过程中，支撑产品更好地实现性能需求。

性能实现篇

我会从编码实现的阶段入手，结合场景案例，带你了解和学习高性能编码的实现模式，让你能够在一些特定场景下选择合适的实现，来大幅度提升性能。另外，我还会带你深入理解各种数据结构与算法，并从使用最为广泛的 Java 语言着手，给你讲解在不同的场景下，使用不同的算法所带来的性能差异，这样你在面临不同的业务问题时，就知道如何选择合适的算法来提升性能了。

性能看护篇

软件是一个需求在不断变化的产品，只有持续地看护性能，才能保证软件性能一直保持在可控的状态。所以，我会从性能测试前置这一核心的性能看护出发点着手，带你深入产品的组件级、系统级的多层级性能测试，让你在理解性能看护核心理念的基础上，清楚地知道该如何实现性能测试用例自动化，并能够集成到流水线中，这样当你在提交代码时，就可以第一时间发现性能腐化问题。

性能调优篇

软件产品在交付运行后，仍需要持续监控系统的运行状态，支撑持续的性能调优。因此，在这一模块，我会给你分享一套性能调优的通用方法论，包括性能问题分析与定位的方法技巧，帮助你在实际的业务场景中，快速找到受限于性能瓶颈的资源，持续地监控分析目标。

案例篇

在实际的业务领域中，你可能会面对形形色色的性能问题，而要想快速解决它们，你需要考虑两方面的问题：如何选择合适的性能优化方法？怎么才能更好地实施性能优化方法？所以，在案例篇中，我会为你讲解多个在真实项目中，实施性能优化的操作过程，帮助你提升落地性能优化方法的能力和技巧。

结语

最后，我希望你在学习的过程中，能够具备批判性的思维，结合自己的知识积累和实践经验，来理解和吸收课程内容，并且你也可以给我多多留言，阐释久悬不决的疑问、分享见解独到的观点，我都会给予解答和回复。

这样，当你学完了课程之后，通过积极地思考学习和输出，你也可以建立起来一套关于软件性能的全局系统认识。今后，当你碰到各种性能问题时，你都可以从软件设计、软件实现、性能看护、性能调优等多个维度，进行系统性的思考，找到合适的解决方案。

好了，接下来我们就开始这段软件性能优化的探索之旅吧！

01 | 性能建模设计：如何满足软件设计中的性能需求？

你好，我是尉刚强。今天是课程的第一讲，我想先和你一起来学习下基于性能的建模设计方法。

基于性能对软件进行建模和设计的目的呢，其实是为了保证软件产品最终交付的性能，跟一开始的设计预期相匹配。然而，在实际的软件建模和设计过程中，很多人其实都忽视了性能的评估分析，导致生成的软件性能差，被客户频繁投诉，甚至有可能导致产品失败，给公司带来严重的后果。

所以这节课，我们就来看看如何在软件设计阶段做好性能的评估分析，通过一定的方法提前识别出软件设计中潜在的性能问题，并指导优化设计，从而更好地满足软件设计中的性能需求。

学会了这个方法之后，你不仅可以提前获取产品的性能预估表现，还可以用它来指导软硬件资源的选型设计，甚至在一些场景下，如果客户对产品要求的性能目标不合理，你也可以利用这个方法来推动他调整性能目标。

那么具体是什么方法呢？答案就是软件执行模型和系统执行模型这两种对系统建模的方法思路。

软件执行模型是一种静态分析模型，一般不需要考虑多用户和资源竞争等动态情况，我们可以用它来分析评估系统的理想响应时间；而系统执行模型则是需要重点考虑多用户、资源竞争等情况的动态分析模型，我们可以利用它来分析和评估系统吞吐量。虽然这两个模型的关注点不同，但我们可以借此识别出软件设计中存在的一些性能问题。

所以接下来，我们就从软件执行模型开始，来看看如何在软件建模与设计的过程中，最大化地满足性能需求吧。

软件执行模型

前面我说过，软件执行模型是一种静态分析模型，我们在分析过程中主要关注执行步骤和流程即可。而传统的 UML 时序图承载的与性能不相关的额外信息比较多，所以这里呢，你可以选择使用执行图来表示软件执行模型。

执行图是一种对软件执行过程、步骤的可视化描述手段，你可以通过推理来计算这些步骤流程的开销，从而帮助你预估软件的性能表象。

不过在使用执行图评估和分析性能之前，我们还需要知道执行图的大概结构。它主要是由节点和箭头组成的，这里我列举了一些常见的节点类型，你可以先熟悉下：

其中，扩展节点代表的是还需要进一步被细化的节点，它可以用另外单独的执行子图做进一步的描述；并行节点代表着多个并行执行的节点单元，只有当所有的节点都执行完毕之后，才能执行后续的操作；分割节点代表着有多个异步执行的节点单元，它并不需要等待所有节点执行结束，就可以开始后续的操作。

好了，理解这些节点类型的语义之后，我们接下来看看如何用这些节点类型来表示一个执行图。

简单的软件执行模型是什么样子的？

下面给出的是一个简单的执行图，节点中的数字代表的是一个权重，你可以用它来表示 CPU 执行时长、数据库操作次数、磁盘操作次数等，这里我们先假设它代表的是 CPU 执行时长，以便于理解接下来的计算过程。

现在，我们具体来看看这个执行图的操作步骤：

第一步是基本节点，执行开销为 t1。

第二步是循环节点，其中 n 代表的是循环次数，同时你可以看到这里的循环体是一个扩展节点。扩展节点可以使用另外一个执行子图来表示，上图中的扩展执行子图是由一个分支选择节点组成的，其中包含的两个分支节点开销分别为 t3 和 t4，而这个分支选择节点本身开销为 t2。

第三步是并行节点，它包含了三个基本节点，执行开销分别为 t5, t6, t7。

最后一步也是基本节点，开销为 t8。

然后我们就可以根据这个执行图，估算出采用这种软件设计后的平均处理时延为：t1+t8+max(t5,t6,t7)+n((p1*t3)+(p2*t4)+t2)。同样的，你还可以估算出最短和最长的处理时延，分别如下：

最短处理时延：t1+t8+max(t5,t6,t7)+n*(min(t3,t4)+t2)。

最长处理时延：t1+t8+max(t5,t6,t7)+n*(max(t3,t4)+t2)。

这里你可能有一个疑问，在软件设计阶段怎么能估算出这些值呢？

是这样的，首先你不能期望获取准确的评估值，因为在开始软件设计的阶段，你能获取的前期测量信息不仅有限，而且可能不是非常准确的。

但是，你可以根据软件设计去估算出一些值，比如根据业务逻辑分析数据库的操作次数，再结合数据库性能指标，来估算开销等。通过这样的估算，你就已经能够提前识别出一些性能设计上的问题了。

如何利用软件执行模型分析评估系统性能？

OK，现在我们已经了解了对于软件设计来说，执行图是一种能够有效且方便地分析评估处理时延的手段。那么接下来，我就通过一个真实的人工智能对话引擎的软件设计案例，来带你详细了解下，使用执行图分析性能并引导软件设计的过程。

注意：这是一个被大幅简化后的真实案例，其中介绍的相关测量评估数据并不是真实的，只是为了阐述问题而已。

下图是这个对话业务的语义树模型，这个语义树模型代表的是人类对话场景的一个模拟过程，就像两个人在聊天过程中，一方可能会接着对方最近几句中的其中一句进行回复。语义树中的每个节点代表着一个语义，然后智能对话引擎就可以通过一个智能计算模型，来判别这个语义是否匹配。

其中，黄色节点代表着已经识别的用户对话上下文语义，红色虚线箭头代表用户对话发生的顺序，而绿色节点则是接下来用户对话可能发生的语义。当然了，在一个实际的对话执行引擎中，包含的功能其实有很多，这里我们先就一个简化后的智能对话引擎，来查看下它的执行图：

从图上你可以看到，对话引擎在执行过程中，首先要进行语音识别，将用户的语音转换为文字，然后转换为词向量。紧接着就需要遍历执行所有智能语义模型，来计算匹配度，然后根据一定的算法选择出最佳匹配的语义。最后还需要查询相关的数据库，构造用户的响应文字信息，并转换成语音发送给用户。

到这里，你就可以使用前面介绍的模型求解的方法，来预估下该智能对话引擎的响应反馈时延。

在这个对话执行引擎中，语音识别、词向量转换、构造回复（多次查询或修改数据库）、文字转语音都是相对比较确定的，粗略估算分析可以控制在 50ms 以内。而中间循环使用各个节点的语义计算模型，需要的时间开销会比较大。

假设你经过初步的测量，单个的语义分析模型的计算，需要的时间开销约为 10ms，而针对一些复杂的对话业务场景，系统中待识别的语义计算模型数（假设为 n），可能有几百个。所以，这部分处理时间可能已经超过了 1 秒钟，无法满足用户的响应时延性能要求。

那么，为了更好地解决这个问题，你就可以在设计时采用并发架构，将语义计算模型任务拆分到多个核上来运行。这样，调整后的软件执行图就如下图所示：

这里我们可以看到，调整后系统会同时启动 6 个并发任务来执行语义计算模型，由于每个语义计算模型的执行时间比较接近，所以在静态分析的过程中，理论上可以将中间循环的处理时间提升近 6 倍，从而就很好地满足了软件的性能需求。

但是，在系统真实的运行过程中，真的可以达到理论上的性能提升效果吗？

其实不一定，如果这个软件系统是运行在一个仅有两个 CPU 硬件核的机器上，那么程序中虽然启动了 6 个并发任务，其最大的加速比也只能到 2，所以不可能达到理论上 6 倍处理时延的提升。

由此你肯定也就发现了，不考虑软硬件资源使用状况的软件执行模型，是存在一定局限性的，所以我们还需要通过一定的手段，来减少性能评估与真实运行时的偏差，而这就是我接下来要给你介绍系统执行模型的原因。

好，下面我们就具体来看看吧。

系统执行模型

我们开发的所有软件都是运行在一系列硬件资源上的，比如 CPU、内存、磁盘、网络等，而系统执行模型作为一种动态分析模型，实际上就是针对多用户和硬件资源竞争场景下的动态建模过程。

这也就是说，我们在使用系统执行模型对软件运行态进行建模的过程中，其实可以将系统中的关键资源抽象成一个队列服务器模型。其中，服务器代表具体的硬件资源，而队列则代表处于排队状态的用户作业。这样，针对存在排队处理的业务逻辑，我们在数据中就可以使用 QNM（Queuing Network Model，排队网络模型）进行分析。

QNM 是针对排队问题的一种数学建模分析方法，我们可以借助这个模型，来帮助模拟与分析系统的运行态性能，从而可以有针对性地对软件设计进行调整和优化。下面展示的就是一个比较简单的 QNM 拓扑图：

在这个拓扑图中，描述的过程是当用户作业到达后，首先排队访问 CPU，紧接着的流程是一个选择逻辑（图上的黑点），这里有两个分支，一个是直接退出，另一个是接着排队访问磁盘，然后再继续访问 CPU 资源。QNM 模型支持的元素类型比较多，比如还有延迟等待、普通队列等，不过在互联网服务场景下，一般不需要构造特别复杂的 QNM 模型，所以这里我就不去深入介绍了。

这里你需要注意的是，在进行系统执行建模时，除了处理 CPU、磁盘等硬件资源可以使用排队服务来建模外，一些外部依赖的数据库、第三方服务等，也满足排队与服务器模型的特性，因此同样可以使用这种方式来建模。

那么现在，我们回到前面介绍的智能对话引擎案例当中。因为这个系统的核心关键资源是 CPU，所以针对这个 CPU 资源，我们建立的 QNM 模型如下：

可以发现，这是一个最简单的、只考虑 CPU 资源排队竞争的 QNM 模型，当系统接收到业务请求后，经过 CPU 的排队并处理之后，就结束退出了。

接下来，我们就可以基于之前的执行图，然后基于测量或估算的方法，来计算获取的单个对话任务的服务时间，如下图所示：

那么，在这个对话引擎的执行过程中，我们就可以基于一些前期测量数据，预估出每个节点的执行时间。这里假设用户平均需要识别的智能语义模型数目 n 为 10，然后我们可以根据图中的语义逻辑，来推算出单个用户回复需要占用的 CPU 服务时间为 0.125s。

不过这里还有一个问题：在真实的业务负载场景下，该引擎的对话响应时延，是否也可以达到理论值 0.125s 呢？其实很多时候都是不能的，这是因为当系统中同时存在很多个对话请求时，会因为竞争使用 CPU 资源而存在排队等待的情况，从而就会增加对话响应时延。

所以接下来，我就借助系统执行模型，来带你分析评估下这个智能引擎的动态运行性能，这样你就会更清晰地了解到，考虑资源竞争的系统执行建模分析，与软件执行静态建模之间存在的差异。另外，我还会对比该系统在不同 CPU 核数的场景下，其平均的响应时间表现是怎样的，这样你就会明白，不同软件与硬件选型也可以直接反映在性能评估的结果值当中。

这里首先你要知道的是，系统执行模型所做的性能评估分析，通常只能分析系统处于稳态情况下的性能表现，毕竟在非稳态的场景下，我们很难可以准确地评估性能，而且分析的意义也不大。所以，针对这个对话执行引擎来说，我们可以先假设用户对话到达速率在某个恒定速率，然后再来分析对话响应时延。

补充：在该对话引擎中，我们设定所有的用户对话请求处理都是可并行的，所以可以使用 QNM 来进行分析。

好，下面我们就来看看这个具体的计算过程，如下图所示，其中系统到达速率 5 作业 /s，代表的含义是每秒钟会有 5 个用户对话请求到达：

图中左边的数学公式，是 QNM 模型的通用数学分析公式（具体的公式证明你可以参考下QNM 的相关资料或论文，这里我就不展开介绍公式的原理了），这样接下来，你就可以计算出不同 CPU 核数下的平均响应时延值。

不过，查看上面的计算结果，你可能会发现两个比较奇怪的现象：

单核场景下，系统响应时间 0.454 远大于 CPU 执行时间 0.125。这其实是因为当系统处于动态运行过程中，有可能会由于多个任务竞争使用 CPU 资源，从而引发了排队的时延问题。

在增加 CPU 核数后，响应时间的提升速度明显大于并发提升的速度。这其实是因为缓解了排队现象，从而导致响应时延变少。

所以到这里，我们应该能够发现，相比软件执行模型，使用系统执行模型来分析和评估系统的响应时间，才能够帮助我们更准确地分析动态负载场景下的性能表现，从而支持在软件设计的调整和优化。

小结

今天这一讲，我重点给你介绍了软件执行模型和系统执行模型两种对系统建模的方法思路。这里你需要明确一点，就是在使用软件执行模型来对系统性能进行静态分析时，会相对容易一些，但它只能分析单个用户场景下的理论性能表现，而使用系统执行模型则可以帮你动态分析多用户和资源竞争场景下的性能表现。

而且在课程中，我并没有非常深入地去讲解这两种模型的所有建模细节，这是因为在软件设计阶段，你首先应该有基于性能进行建模与分析的意识，然后才是去学习如何正确使用这些性能建模的方法。

所以，我希望你在学习了今天的课程之后，能够在实际的软件设计过程中，提前识别出性能关键点，并寻找到合适的性能建模方法来实现性能评估。另外，你还可以在软件生命周期中，去持续矫正这个系统性能模型，从而达成持续地支撑后续各种设计与实现优化的目标。

思考题

在互联网云服务场景中，当应用服务器实例 CPU 负荷，超过一定门限（如 80%）就会触发弹性扩容，而不会等到满负荷时候才触发，这是为什么呢？

欢迎在留言区分享你的答案和思考。如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

02 | 并行设计（上）：如何利用并行设计挖掘性能极限？

你好，我是尉刚强。

在计算机领域，由于 CPU 单核性能的增⻓逐渐停滞，而我们面临的业务问题复杂度却在不断地上升，为了更好地解决这个冲突，在 CPU 中增加核数就成为了一种默认的应对方案。而通常来说，我们会借助并行设计来充分发挥硬件多核上的运行性能。

不过，在 CPU 多核的场景下，要想通过并行设计将计算负载均衡到每个 CPU 核上，以此减少业务处理的时延，将软件性能提升至最大化，依然存在着很大的挑战。

为什么这么说呢？不知道你在实际的业务场景中有没有发现，由于并行拆分不合理，而导致产品性能不可控，甚至是恶化的现象非常普遍。另外，由于程序员普遍会存在串行编程的惯性思维，在并发同步互斥实现中引入的故障难以定位，也很容易导致产品在较长时间里处于不可用状态。而这些问题，都会对我们的软件性能产生直接影响。

所以这节课，我就来给你介绍 6 种针对不同业务问题的典型并行设计架构模式，以此让你在面对实际的业务问题时，能快速准确地挖掘业务中的并发性，找到适合产品的并行设计架构。而同步互斥作为并行设计中的一个难点，如果你希望能高效解决，需要对其有很深入的理解认识，我将在下一节课单独介绍。

并行计算模型

在开始讲解具体的并行设计架构模式之前，我想先带你了解一下并行计算模型。

因为当面对具体的业务问题时，如何将复杂的领域问题拆分成可并行的逻辑单元，并实现同步交互，是并行架构设计的关键。而并行计算模型，可以帮助我们建立起对并发系统抽象模型，以及各种基本概念的认识，从而更容易去理解后续的并行设计架构模式。

我们知道，在 CPU 多核运行的场景下，不同的并行计算单元如果共享相同的内存地址单元，就可能会导致各种同步互斥的问题，比如脏数据、死锁等。所以，在并行计算模型中，我们就需要隔离不同并发计算单元的内存数据，以尽量减少引入同步互斥的问题。

那么具体要怎么做呢？我们可以把并行计算模型抽象为两个层次：

由结构数据和相应的计算逻辑组成并发执⾏单元，这样可以通过组合实现更复杂的业务；

基于各种手段（如内存、互斥量、消息队列、数据库等），对并发执⾏单元计算的结果进行交互同步，保证业务计算结果的确定性。

你可以参考一下这个模型的抽象视图：

这里你要注意的是，图中的两个并行执行单元代表了抽象的逻辑单元，并不是特指线程。并行执行单元的粒度可大可小，像函数、routine（协程）、actor、线程、进程、作业等，都可以作为并行执行单元。

那么现在，我们来思考一个问题：在调用软件并发调度框架，如 java.util.concurrent.Executors 的 submit 接口时，提交的 Thread 是一个真正创建的线程吗？

首先我们要知道，这个 Thread 是一个抽象的并行执行单元，实际上并不是真正的线程。而 java.util.concurrent.Executors 是 Java 语言基于线程封装的调度框架底座，它支持将 Callable 和 Runner 接口实现并映射到具体的线程运行单元上。所以说，在设计并发架构时，我们不应该将并行执行单元片面地理解为线程。

不过在 Java 语言中，也不是只能使用这种抽象粒度的并行执行单元来实现并行设计。Java 中还有诸如 Akka、Reactor 等并发调度框架底座，来支撑更加轻量级的并行执行单元。比如，你可以使用 Akka 中的 Actor 进行并行系统设计，也可以基于 Reactor 库设计和实现并发程序，你甚至可以为特定应用场景专门定制并发调度底座。

也就是说，在并行设计的过程中，我们不应该将并行执行单元限定在线程粒度上，而是应该根据处理的特定领域问题，选择合适的并行执行单元粒度，并选择或定制实现相应的并发调度框架。

另外，在使用 Java 设计实现并发程序的过程中，我们可以使用 Java 语言内置的并发库，如各种锁、并发集合等来实现同步与信息交互。但在多机分布式系统中，还需要依赖数据库、消息队列、网络传输等技术来实现信息交互。

所以，接下来我介绍的 6 种并行设计架构模式，就是基于上述的并行计算模型来描述的。

并行设计架构模式

这里我想先说明一点，在软件领域中，我们⾯对的业务场景一定是纷繁多样的，只花一节课的时间我们不可能面面俱到、了解所有的业务场景。所以今天，我只想带你重点思考一个问题：如何根据业务场景进⾏并行设计，从⽽在最⼤程度上发挥硬件并⾏的能⼒。下面我介绍的 6 种并行设计架构模式，也都是基于这个问题而展开的。

那么，我是如何划分这 6 种并行架构⽅案的呢？答案是根据计算逻辑、结构数据、信息交互这三个维度的不同的规则性，拆分后得出的。要知道，这些架构之间并不是孤⽴的。对于特定领域的业务场景来说，很多时候需要组合⼏种架构模式来实现业务逻辑。

所以，你在学习这 6 种并⾏架构模式时，就需要了解这种架构的核⼼关注点是什么，以及它重点解决了什么问题。为了便于你理解后面会用到的几种并行架构设计的图例，这里我先说明一下图中各种元素的含义：

计算逻辑：业务中的计算逻辑，可以在 CPU 上执行的代码段。

结构数据：拆分到并行执行单元中的独立数据，可以记录在内存中，也可以在数据库中。

并行执行单元：抽象并行执行实体，使用软件并发调度框架映射到底层 CPU 硬件线程上；并行计算单元中的数字，表示计算单元的工作量。

并行执行单元输出：并行执行单元的执行结果，需要使用同步与互斥手段实现并行执行单元的业务功能组合。

保存并行执行单元队列：不少软件并发调度框架，如 java.util.concurrent.Executors 内部已提供了待调度并行执行单元队列，但也有不少场景需要自己设计维护并行执行单元队列。

CPU 芯片硬件线程： CPU 多核场景下，支撑并行执行的 CPU 硬件线程。软件并行执行单元最终会映射到不同 CPU 硬件线程上才能实现真正并行。

除此之外，在介绍 6 种并行设计架构模式的过程中，我还会给你重点强调下该架构模式中的隐式约束条件。只有充分理解了这些约束条件，你才能在并行设计的过程中，避免引入一些故障，也能够降低代码开发的实现复杂度，并最大化地挖掘这种并行设计架构模式的性能。

好了，下面我们就开始吧。

1. 任务线性分解架构

第一种架构模式是任务线性分解架构，它是一种按照计算逻辑维度进行确定性拆分的并行架构设计模式。其大致的实现过程是这样的：

首先可以看到，在图中的左侧，三个计算逻辑 A、B、C 是在相同的⼀个数据块上进行操作的。通过依赖分析，我们会发现 A、B、C 三个计算逻辑相对独⽴。因此，当单核处理性能存在瓶颈时，按照计算逻辑维度进⾏并⾏拆分，就能够进⼀步提升性能。

所以上图的右侧，就是按照计算逻辑拆分成的三个独⽴的并⾏执⾏单元，这样就可以映射到两个硬件线程较少的处理时延上。

补充：作为一名 Java 工程师，需要你显式地映射绑定到硬件线程的场景可能比较少；但对于嵌入式工程师而言，映射绑定也是并行设计中非常关键的一个环节。

实际上在很多的业务领域中，都存在需要根据同一个事件或数据，并行触发很多任务的场景。比如在电商购物场景下，当发生了一笔交易且交易成功后，就会同时触发填充邮件内容并通知责任人、按照多种维度统计数据及更新等多项任务。

通常，当这些触发的业务计算逻辑之间相互独立时，我们就可以通过创建多个并行执行单元，分别处理拆分后的不同子问题，并根据不同单元业务工作量的大小，建立与具体硬件线程的映射绑定关系。

这种并行设计架构模式相对比较简单，潜在的业务场景也比较多。比如，在 Observer 模式中处理的类似问题、在消息队列中一对多通信解决的业务问题等，通常都隐含着任务线性并发的可能性。

总而言之，任务线性分解架构比较适用于业务逻辑确定性的场景，你在实际应用时要注意以下几点：

在并⾏执⾏单元间，数据依赖可以通过⼀些⼿段进行消除或隔离，比如利用 Thread Local 变量，通过数据冗余来消除依赖；

执⾏单元的⼯作量⽐较确定，容易与硬件线程建⽴绑定和映射关系；

一般来说，做并⾏拆分我们需要先了解全局的业务功能，同时任务线性拆分的扩展性会差⼀些。

2. 任务分治架构

第二种架构模式是任务分治架构，它是一种按照计算逻辑进行动态拆分的并行架构设计模式。我们来看下它的设计特点：

通过图中的左侧，我们能够发现，在很多的业务场景下，计算逻辑并不是全局确定的。有些业务在计算过程中，还需要根据场景来判断是否拆分成更⼩的⼦问题进⾏求解。比如说，A 计算过程中，会拆分出 2 个 B⼦问题，⽽在这 2 个⼦问题的计算过程中，需要进⼀步拆分为 3 个 C⼦问题来求解。

那么，针对这种场景进⾏并行设计时，就不能在系统运⾏前完成任务的拆分，而是需要动态创建任务，并借助任务队列来管理执⾏任务。这里的执⾏线程可以从队列中拉取任务，映射到硬件线程上执⾏。

这种并行设计架构模式的使用场景相对少一些。

我之前曾基于 Akka 框架，设计开发了一款智能对话引擎。在这个对话引擎系统中，用户对话的所有语义信息是有限的，当收到某个用户对话数据时，在特定上下文中，可能语义是全局语义中一个较小的子集。所以我需要在这个子集内选择语义匹配率最高的一个，然后进行回复。

另外，每个语义计算匹配率的计算逻辑与对话数据是独立的，所以为了实现用户对话消息的急速回复，我需要在该上下文下，动态创建出多个并行执行单元，分别计算语义匹配度，再汇总选择出匹配率最高的一个。而这个实现框架，就是基于任务分治架构进行设计的。

事实上，在 Java 的 java.util.concurrent.Executors 以及 Akka 等框架中，已经内置实现了并发任务队列，并支持与 CPU 等硬件线程映射，从而满足了大部分场景下的业务需求。但在一些实时性要求比较高、性能要求非常苛刻的场景下，比如股票交易等，任务队列以及硬件资源绑定关系，通常是需要单独设计实现的。

同样，这里我们也来了解下这种架构模式的隐式约束条件：

通常动态拆分的并⾏任务间，通信开销会比较⼤，你需要额外分析通信对性能的影响；

动态拆分的并⾏任务间通常存在控制依赖，需要利用 Fork-Join 机制协调任务间同步；

受制于计算路径跨度对并发性能的影响，最⼤化发挥并⾏性能⽐较困难。

3. 数据⼏何分解架构

第三种架构模式是数据几何分解架构，它是一种根据待处理的业务数据进行线性拆分的并行架构设计模式。同样，我们先来看下它的设计特点：

数据⼏何分解与任务线性分解架构的⻛格⽐较接近，但⼏何分解架构的主要特点是相同计算逻辑需要在不同的数据上进⾏运算。如图中右侧所示，拆分成不同的并⾏计算单元后，计算逻辑是相同的（颜⾊相同），但是数据是不同的（颜⾊不同）。

在互联网微服务场景中，业务关键数据会记录到数据库表中。当数据规模比较大，需要对数据库表使用分表策略保存，这就是一种典型数据几何分解方式。针对这种场景，当接收到业务数据库表查询分析请求，需要基于同一个计算逻辑与不同数据库分表组合，创建出多个执行单元并行计算提升性能。

通常在业务发展中，待处理数据规模增加是一个非常重要的变化方向，通过弹性计算资源提升业务处理能力是核心关注点之一。而数据几何分解架构是解决这类问题的一种典型方法，有很多优点，应用非常广泛。

好，最后我们来看看数据几何分解架构的隐式约束条件：

一般来说，采⽤数据⼏何分解架构，其可⽀持的扩展性会⽐较强；

这种性能架构模式⽐较适合于 SPMD（Single Program Multi Data）架构，SPMD 架构会使用一套相同的代码实体并行运行在多个硬件线程上，这样用户只需要管理一套代码实体即可，成本比较低。

数据几何分解架构中，不同并⾏计算单元的更新数据间是独⽴的。

4. 递归数据架构

第四种架构模式是递归数据架构，它是一种在处理过程中对业务数据进行动态拆分的并行架构设计模式，其架构设计特点如下：

从图中我们可以看到，业务处理的数据是树状或者图状组织的，而这就表明了线性⼏何拆分数据会比较困难。

因此，我们在实际应用时，就需要在遍历的过程中动态创建任务，然后对每个中间计算单元的运算结果逐步合并，计算得到最终的结果，如图中右侧所示。

MongoDB 是目前应用非常广泛的开源文档性数据库，它支持将灵活的 JSON 格式业务数据保存到数据库中。在对业务记录 JSON 格式内的多个字段进行数据分析时，代码需要递归遍历 JSON 中所有嵌套字段并进行分析计算。为了最大化并发执行，减少处理时延，可以采用递归数据架构模式，在递归遍历字段过程中动态创建对应字段分析的并行执行单元。

这种架构应用场景也相对较少，主要针对非规则结构数据进行计算分析时使用，比如树状、有向图等数据结构。

同样，最后我们来看看这种架构的隐式约束条件：

这种架构模式下，计算任务单元需要动态创建，⽽且⼯作量不确定；

一般来说，递归数据架构对应的算法是递归算法。

5. 数据流交互架构

第五种架构模式是数据流交互架构，它是从信息交互的维度出发，是一种在并行执行单元间单向交互的并行架构设计模式。我们来看下它的设计特点：

从上图中我们可以发现，这种业务场景的典型特⾊是：

⼀个计算单元的输出刚好是另外⼀个计算单元的输⼊，并且消息交互是单向确定性的；

业务场景中还会源源不断接收到新的输⼊，需要使⽤相似的计算策略进行处理。

也就是说，针对这种场景，计算单元的确定性会⽐较强，我们可以静态规划与硬件线程的映射关系，⽽设计的核⼼就是如何⾼效实现并发计算单元间的信息交互。

具体怎么做呢？我给你举个例子。

在大数据领域中，ETL（Extract-Transform-Load）是一个非常典型的场景，它是用来描述将数据从来源端经过抽取（extract）、转换（transform）、加载（load）至目的端的过程。但业务数据处理需求通常是由多个 ETL 阶段组合完成的，因此针对这类场景，使用数据流交互架构会比较适合。

此外，在嵌入式领域，⽹络协议栈的报⽂处理、不同协议栈解析特定头部字节、完成业务处理后透传给下⼀层，也是使用数据流交互架构的一类典型场景。

这里你要知道，在数据流交互架构中，不同并行执行单元的处理消息速率通常是不一致的，你需要借助消息队列缓存来协调。而在 Java 中，并发的各种 BlockingQueue 就是前面这个问题中，消息队列的一种实现方式，也就是典型的生产者消费者模型处理的问题。

好，现在我们来看下这种架构的隐式约束条件：

通常，数据流交互架构中的计算业务是线性可拆分的，数据在时间线上是均匀、批量地向前推进且相互独⽴；

在该架构下，计算任务的⼯作量确定性比较强，⽐较适合静态规划；

当消息通信满⾜单向⽣产者消费者模式时，数据流交互架构可以避免使⽤互斥锁，达到消息的⾼效率交互。

6. 异步交互架构

最后一种架构模式是异步交互架构，它是一种并行执行单元间，交互关系比较复杂的并行架构设计模式，其设计特点如下图所示：

从图上我们可以发现，该业务场景的典型特⾊是这样的：

同⼀个任务需要与多个任务进⾏消息交互；

同⼀个消息需要多个任务进⾏处理。

这种系统的计算逻辑可能需要进行全局拆分，也可能不能拆分，我们要根据实际情况进⾏处理。

我给你举个例子。在微服务架构中，微服务在完成一个 REST 请求业务功能的过程中，可能需要进行多次数据库操作，还可能需要多次调用其他微服务提供的 REST 接口。为了充分发挥性能，我们在将业务逻辑拆分为多个并行执行单元后，并行执行单元间的运行开销差异较大，就可以使用异步交互来实现业务功能。

这里请注意，要想最大化地发挥这种架构的性能，还需要实现一点：并行执行单元能够动态灵活地映射到特定的硬件 CPU 核上。比如说，Node.js 后端业务 async 和 Java 语言中的 Future 并发机制，都是比较好的支撑异步交互架构的语言机制。

最后我们来看下它的隐式约束条件：

计算任务的⼯作量不确定性强，任务通常需要动态调整映射到对应硬件线程；

消息交互需要使⽤异步机制提升性能；

小结

并⾏设计解决的是将复杂业务领域的问题拆分为多个相对较⼩的串⾏⼦问题，每个串行子问题对应一个并行执行单元，并通过⾼效解决⼦问题间的信息交互与同步，从而减少业务整体处理时延，最终满足业务的性能需求。

今天我以最大化地挖掘并行性能为出发点，给你介绍了 6 种⽐较典型的并行架构解决思路。不过在实际的业务场景中，当性能并不是系统关键因素时，如果你使用串行化代码实现就已经满足了性能要求，而且开发成本更低，那么这时你就需要权衡一下，是否还需要进行并行设计。

思考题

我们知道，对 Java 而言，有 java.util.concurrent.Executors、Akka、Reactor 等并发调度框架底座，那么这些框架之间有什么差异呢？在开发一个核心业务只有对数据库增删查改的微服务时，你会如何选择呢？

欢迎在留言区分享你的答案和思考。如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

03 | 并行设计（下）：如何高效解决同步互斥问题？

你好，我是尉刚强。

我曾经主导过一个性能优化的项目，该项目的主要业务逻辑是在线抢货并购买。在原来的设计方案中，我们为了保证库存数据的一致性，后端服务在请求处理中使用了 Redis 互斥锁，而这就导致系统的吞吐量受限于 30TPS，不能通过弹性扩展来提高性能。

那我们是怎么解决这个问题的呢？后来我们使用无锁化来实现性能的拓展，系统吞吐量一下就提升至 1000TPS，相比原来提升了 30 倍之多。

所以你看，同步互斥是影响并发系统性能的关键因素之一，一旦处理不当，甚至可能会引起死锁或者系统崩溃的危险。

这节课，我就会带你去发现并发系统中存在的同步互斥问题，一起思考、分析引起这些问题的根源是什么，然后我 会介绍各种同步互斥手段的内部实现细节，帮助你理解利用同步互斥的具体原理及解决思路。这样，你在深入理解同步互斥问题的本质模型后，就能够更加精准地设计并发系统中的同步互斥策略，从而帮助提升系统的关键性能。

好，接下来，我们就从并发系统中存在的同步互斥问题开始，一起来看看引起同步互斥问题的内在根源是什么吧。

并行执行的核心问题

从计算机早期的图灵机模型，到面向过程、面向对象的软件编程模型，软件工程师其实早已习惯于运用串行思维去思考和解决问题。而随着多核时代的来临，受制于硬件层面的并发技术的发展，为了更大地发挥 CPU 价值，就需要通过软件层的并行设计来进一步提升系统性能。

但是，现在大多数的软件工程师还习惯于用串行思维去解决问题，这就会导致设计实现的软件系统不仅性能非常差，还容易出故障。

比如说，我们可以来看下这个并发程序，找找它在执行期间都可能会存在什么问题：

int number_1 = 0;
int number_2 = 0;
void atom_increase_call()
{
     for (int i = 0; i < 10000; i++)
     {
         number_1++;
         number_2++;
     }
}
void atom_read_call()
{
     int inorder_count = 0;
     for (int i = 0; i < 10000; i++)
     {
      if (number_2 > number_1)
         {
             inorder_count++;
         }
     }
     std::cout << "thread:3 read inorder_number is " << inorder_count
             << std::endl;
}
int main()
{
     std::thread threadA(atom_increase_call);
     std::thread threadB(atom_increase_call);
     std::thread threadC(atom_read_call);
     threadA.join();
     threadB.join();
     threadC.join();
     std::cout << "thread:main read number is " << number_1 << std::endl;
     return 0;
}

运行之后你会发现，由于代码在三个线程上并行执行，导致这个程序每次的运行结果可能都不相同，这种现象就被叫做程序运行结果不确定性，而这通常是业务所不能接受的。

这里我列举了其中两次执⾏结果，如下：

| 第⼀次：
thread:3 read inorder_number is 1
thread:main read number_1 is 15379
thread:main read number_2 is 15378

| 第⼆次：
thread:3 read inorder_number is 13
thread:main read number_1 is 15822
thread:main read number_2 is 15821

通过分析这段代码的两次执行结果，我们可以看到该并发程序出现了两种现象：

线程 A 和线程 B 中，number_1++、number_2++ 累计执行了 20000 次，那么结果应该为 20000 才对，但实际运行的结果却与 20000 的差距比较大。

线程 A 和线程 B 中，都是先执行 number_1++，再执行 number_2++，因此 inorder_number 的统计应该是 0 才合理，但最后的结果却不是 0。这就说明了，number_1++ 与 number_2++ 执行结果的生效，在跨线程下的顺序是不一致的。

那么现在，我们可以先来思考一下：为什么现象 1 中，number_1 的值不是 20000 呢？我认为可能有两个原因：

number_1 在不同线程间的缓存失效了，导致大量写入操作与预期不一致，这就导致与实际值的偏差较大；

number_1++ 的操作执行包括了读取、修改两个阶段，中间有可能被中断，所以不满足原子特性，这样两个线程中 number_1++ 操作互相干扰，从而就无法保证结果的正确性。

而导致 inorder_number 值不为 0 的原因比较多，比如说：

变量 number_1 和 number_2 在线程间的缓存不一致；

由于编译器指令重排序优化，导致 number_1++ 和 number_2++生成指令的顺序被打乱；

由于 CPU 级指令级并发技术，造成 number_1++ 和 number_2++ 并发执行，因而无法保证执行顺序。

如此一来，我们将以上所有问题进行汇总整理之后，其实可以发现引起并发系统执行结果不确定性的根源问题主要有三个，分别是原⼦性破坏问题、缓存一致性问题、顺序一致性问题。

那么我们该怎么去解决并发系统中存在的这三个根源问题呢？你肯定会想到，使用互斥锁呀！的确，互斥锁能够很好地解决上述三个问题。

下面，我们就一起来了解下互斥锁是如何解决上面描述的三个问题的，同时在此过程中，我们也来看看由于使用了互斥锁，都会引入什么样的性能开销。

互斥锁的原理与性能

首先，我们来理解下互斥锁的实现原理，下图就展示了一个互斥锁的处理过程：

如图中所示，在 Lock 加锁后进入临界区前、退出临界区后并执行 Unlock 之前，这两处都增加了内存屏障指令（不同 CPU 架构与 OS 上的实现存在一些差异，但其基本原理是类似的）。这样，编译期间通过这两个内存屏障，就实现了以下功能：

限制了临界区与非临界区之间的指令重排序；

保证在释放锁之前，临界区中的共享数据已经写入到了内存中，以此确保多线程间的缓存一致性。

由于临界区是互斥访问的，因此你可以认为临界区的业务逻辑在整体上是原子性且缓存一致的，而且跨线程间数据顺序的一致性约束，也被统一放到了临界区内来实现。虽然临界区间内的代码是乱序优化执行的，还存在非原子性操作等实现，不过这都不会影响到程序执行最终结果的不确定性。

另外，从图上你还可以看到，当互斥锁加锁失败后，执行线程会进入休眠态，直到互斥锁资源释放之后，才会被动地等待内核态重新调度去激活。

显而易见，线程长时间休眠会导致业务阻塞，从而就会影响到软件系统的性能。所以，在并发程序中使用互斥锁时，一个重要的性能优化手段就是减少临界区的大小，以此减少线程可能的阻塞时间。比如说，通过删除一些非冲突的业务逻辑，来减少临界区的执行代码时间。

不过这里请你再来思考一个问题：在通过减少临界区代码来优化性能的过程中，如果你发现临界区的执行时间，已经小于线程休眠切换的时间开销（通常线程休眠切换的开销大约在 2us 左右，不同机器在性能上会有一定差异，需要以实际机器的测试为准），那你还会选择互斥锁这种方式吗？

其实，这时候你应该考虑更换一种锁，来减少线程休眠切换消耗的时间。接下来我要带你了解的自旋锁（SpinLock），就可以帮助实现这个目的。自旋锁在 Linux 源码中使用很多，我来给你介绍一下它的基本原理与性能表现吧。

自旋锁的原理与性能

首先，我们还是来了解下自旋锁的实现原理，看看它的处理逻辑是怎么样的，如下图所示：

对比前面互斥锁的工作过程示意图，你可以发现，自旋锁与互斥锁的逻辑差异主要体现在：当加锁失败时，当前线程并不会进入休眠态。所以如果你使用自旋锁这种实现方式，如果临界区执行开销比较小，就可以赚取等待时间开销小于线程休眠切换开销的额外收益了。

在自旋锁中，临界区的实现机制与互斥锁基本是一致的，因此它也能解决前面提到的并发系统中的三个根源问题。

另外，与互斥锁一样，为了进一步提升软件性能，你也需要进一步减少线程间的数据依赖。这样，你通过设计优化之后，将线程之间的依赖数据减少到仅剩几个变量时，执行开销可能只需要几个指令周期就可以完成了。

不过这时使用锁机制，你还需要在每次数据操作的过程中进行加锁与解锁，这样额外开销的占比就会过大，其实就不太划算了。

那么既然如此，还有其他更加高效的解决方案吗？

当然有！请记住，锁只是我们解决问题的手段，而不是我们需要解决的问题。现在让我们再次回到问题本身，再来强化记忆一下并发系统内的三个本质问题：原⼦性破坏问题、缓存一致性问题、顺序一致性问题。

这里你需要意识到，在具体的并发业务场景中，可能并不需要你同时去解决这三个问题。比如多线程场景下的统计变量，两个线程会同时更新一个变量，那这里压根就不存在顺序一致性的问题。

因此，你首先需要学会的是识别并发系统中待解决的问题，然后再去精准地寻找解决方案，这才是进一步提升系统性能的关键。

那么，在实际的业务场景中，最常见的引发并发系统执行结果不确定性的问题，其实是缓存一致性问题，比如典型的生产者消费者问题。不过在嵌入式系统的业务场景中，C 语言已经通过引入 volatile 变量解决了这个问题。

接下来，我们就通过使用 volatile 来解决问题的工作流程，来分析、了解下 volatile 是如何解决同步互斥中存在的问题的。

volatile 的原理与性能

volatile 是一种特殊变量类型，它主要是为了解决并发系统中的缓存一致性问题。定义为 volatile 类型的变量，会被默认为是缓存失效状态，针对这个变量的读取、设置操作，都可以通过直接操作内存来实现，从而就规避了缓存一致性问题。

在 C/C++ 语言中，volatile 一直在沿用这种方式，但这种实现机制并没有完全解决并发系统中的原子性破坏和顺序一致性的问题。

而在 Java 语言中，JVM 会在 volatile 变量的过程中添加内存屏障机制，从而可以部分解决顺序一致性的问题。其具体机制如下图所示：

图中，变量 x、y 是 volatile 类型变量，初始值分别为 1 和 2，Load 代表的是对内存直接进行读取操作，而 Store 代表了对内存直接进行写入操作。在线程 1 内，volatile 变量 y 执行写入操作时，会在生成的操作指令前添加写屏障指令；而线程 2 是在执行 volatile 变量 y 读取操作时，在生成的代码指令后添加了读屏障指令。

如此一来，通过写屏障就限制了线程 1 在执行过程中，Store x 与 Store y 的写操作不能乱序；而读屏障就限制了线程 2 在执行过程中，Load y 和 Load x 不能乱序。

因此，对于线程 2 来说，就只可能看到线程 1 执行过程中 3 个时间点的状态，分别为：

State A ：初始化状态，y=2，x =1。

State B ：x 刚设置完的中间状态，y=2，x =5。

State C ：x, y 都设置完的状态，y=8，x=5。

而如果线程 1 和线程 2 的其中任何一方没有使用内存屏障指令，就有可能导致线程 2 读到的数据顺序不一致，比如说获取到乱取的状态，y=8，x=2。实际上，这也是无锁编程（即不使用操作系统中锁资源的程序，而互斥锁需要使用操作系统的锁资源）中的一个典型问题解决方式。

但这里，你还需要注意的是：volatile 并没有完全实现原子性。比如说，如果出现以下两种情况，就不满足原子性：

类似 i++ 这种对数据的更新操作，CPU 层面无法通过一条指令就更新完成，因此使用 volatile 也不能保证原子性；

对 32 位的 CPU 架构而言，64 位的长整型变量的读取和写入操作就无法在一条指令内完成，因此也无法保证原子性。

对于 32 位与 64 位 CPU 架构之间的差异而导致的原子性问题，我们就只能在使用过程中尽量去规避；而针对 i++ 这种更新操作，大部分 CPU 架构都实现了一条特殊的 CPU 指令，来单独解决这个问题。

这个特殊指令就是 CAS 指令，它的实现语义如下：

 bool CAS(T* addr, T expected, T newValue)
 {
      if( *addr == expected )
     {
          *addr =  newValue;
           return true;
     }
     else
           return false;
 }

该函数实现的功能是：如果当前值等于 expect, 则更新值为 newValue，否则不更新；如果更新成功就返回 true，否则返回 false。这条指令就是满足原子性的。

好了，现在我给你总结下前面的分析过程：在并发系统的同步互斥中，使用 volatile 可以实现读取和写入操作的原子性，使用 CAS 指令能够实现更新操作的原子性，然后再借助内存屏障实现跨线程的顺序一致性。

在 Java 语言中，正是基于 volatile + CAS + 内存屏障的组合，实现了 Atomic 类型（如果想更深入理解 Java 的 Atomic 类型的原理与机制，可以参考阅读这个文档），从而支撑解决了并发中的三个本质问题。

C++ 在 Atmoic 实现的原理与 Java Atomic 是类似的，但在 C++ 语言中，它定义了更加丰富的一致性内存模型，可以供我们灵活选择。

小结

这节课，我带你学习了并发系统中，解决同步互斥问题的多种手段与原理，以此帮助你更好地优化同步互斥性能。不过，我并不希望你在实际的业务场景中，也直接去对比选择这节课所讲的解决方案，因为脱离了上下文场景下的优劣分析是没有实际意义的。

相反，我希望你通过今天的学习，能够更加深入地理解并发系统同步互斥问题本身，这样当面临具体问题时，你可以准确地抓住问题本质，找到最佳性能的解决方案。

思考题

思考一下，Redis 上的变量 set 和 get 操作也是原子操作，也提供 CAS 指令，那么在跨机器的分布式系统设计中，是否也可以使用 Redis 进行无锁编程呢？

04 | 缓存设计：做好缓存设计的关键是什么？

你好，我是尉刚强，今天我们来聊聊基于性能的缓存设计。

缓存就是一个临时储存数据的地方。当用户查询数据时，首先会在缓存中查找，如果找到了就直接使用；如果找不到，就再到数据的原始位置去寻找。所以，缓存本质上是一种用空间换时间的技术，通过数据在空间上的重复，来提升数据的访问速度。

不过，随着分布式和云计算技术的发展，数据存储技术也发生了翻天覆地的变化，而且不同存储技术在价格和性能上都存在很大的差异，所以在针对性能进行软件设计时，如果我们没有做好多层级的缓存设计，不仅可能浪费钱，而且获取的性能收益可能也不够理想。

所以在今天的课程上，我会结合互联网应用与服务场景，给你讲解如何做好缓存设计，并会剖析典型的缓存使用案例，帮助你建立起缓存技术原理的系统认识，以此指导你在产品的软件设计中，可以正确使用缓存来提升系统的性能。

缓存设计的通关之路

那么首先，我想从两个问题开始，来带你了解下缓存设计要在什么时候做，以及通过不同数据类型的特性对比分析，来跟你一起探讨如何才能做好缓存设计。

好，第一个问题：在互联网应用服务中，使用缓存技术的目的就只是为了提升访问速度吗？

其实我认为，并不是所有的缓存都只是为了提升速度，因为在分布式系统中，缓存机制实际上是系统级性能设计的一个重要权衡手段。比如当某个数据库的负载比较高，接近系统瓶颈时，我们就可以使用缓存技术，把负荷分担到其他数据库中，那么这里使用缓存的目的，主要就是负载均衡，而不是提升访问速度。

第二个问题：一个大型系统中的数据种类会非常多，那么需要为每种数据都设计缓存机制吗？

其实完全没有必要。在实际的业务场景下，系统包含的业务数据太多了，你不可能针对每种数据都设计和实现缓存机制，因为一方面是投入的软件成本太高，另一方面也很可能无法带来比较高的性能收益。所以，在进行缓存设计之前，你首先需要识别出哪些数据访问对性能的影响比较大。

那么我们该怎么去识别哪些数据是需要缓存机制呢？在第 1 讲中，我已经给你介绍了性能建模设计方法，也就是通过分析和评估手段，来识别出哪个数据访问操作对性能的影响比较关键，然后再进行缓存设计。

不过接下来，在识别出需要使用缓存机制的数据之后，你可能会发现，这些数据种类之间的特性差异非常大，如果使用同一种缓存设计的话，其实是很难发挥出软件性能的最佳状态的。

所以在这里，我给你总结了三种需要缓存机制的数据种类，分别是不变性数据、弱一致性数据、强一致性数据。了解这三种缓存数据种类的差异，以及对应的设计缓存机制的方法，你就掌握了缓存设计的精髓。

好，下面我们就来具体了解下吧。

不变性数据

首先是不变性数据，它代表数据永远不发生变化，或者是在较长一个时间段内不会发生变化，因此我们也可以认为这部分数据是不变的。

这类数据就是可以优先考虑使用缓存技术的一种数据类型，在实际的业务场景中也非常多。比如，Web 服务中的静态网页、静态资源，或者数据库表中列数据与 key 的映射关系、业务的启动配置，等等，这些都可以认为是不变性数据。

而且，不变性数据也意味着实现分布式一致性会非常容易，我们可以为这些数据选择任意的数据存储方式，也可以选择任意的存储节点位置。因此，我们实现缓存机制的方式就可以很灵活，也会比较简单，比如说在 Java 语言中，你可以直接使用内存 Caffeine，或者内置的结构体来作为缓存都可以。

另外这里你要注意，当你针对不变性数据进行缓存设计时，其中的缓存失效机制可以采用永久不失效，或者基于时间的失效方式。而在采用基于时间的失效方式的时候，你还需要根据具体的业务需求，在缓存容量和访问速度之间做好设计实现上的权衡。

弱一致性数据

第二种是弱一致性数据，它代表数据会经常发生变化，但是业务对数据的一致性要求不高，也就是说，不同用户在同一时间点上看到不完全一致的数据，都是可以接受的。

由于这类数据对一致性的要求比较低，所以在设计缓存机制时，你只需要实现最终一致性就可以了。这类数据在实际业务中也比较多，比如业务的历史分析数据、一些搜索查找返回数据等，即使最近的一些数据没有记录进去，关系也不大。

另外，快速识别这类数据还有一个方法，那就是使用数据库 Replica（复制）节点中读取的数据，大部分都是这种类型的数据（很多数据库 Replica 节点的数据因为数据同步时延，是不满足强一致性要求的）。

针对弱一致性的数据，我们通常使用的缓存失效机制是基于时间的失效方式，同时因为弱一致性的特性，你可以比较灵活地选择数据存储技术，比如内存 Cache，或者是分布式数据库 Cache。你甚至可以基于负载均衡的调度，来设计多层级缓存机制。

强一致性数据

第三种缓存数据类型是强一致性数据，它是指代码数据会经常发生变化，而且业务对数据库的一致性要求非常高，也就是说当数据发生变更后，其他用户在系统中的任何地方，都应该看到的是更新后的数据。

那么，针对这种类型的数据，我一般是不推荐你去使用缓存机制，因为这类数据在使用缓存时会比较复杂，而且很容易会引入新的问题。比如说，用户可以直接提交和修改的各种数据内容，如果没有同步修改缓存中的数据，就会引发数据不一致性的问题，导致比较严重的业务故障。

不过在一些特殊的业务场景中，比如，在针对个别的数据访问频率非常高的情况下，我们还是需要通过设计缓存机制，来进一步提升性能。因此针对这类强一致性数据，在设计缓存机制时，你需要特别注意两点：

这种数据的缓存一定要采用修改同步的实现方式。也就是说，所有的数据修改都必须确保可以同步修改缓存与数据库中的数据。

准确识别特定业务流程中，可以使用缓存获取数据的时间有多长。因为有些缓存数据（比如一次 REST 请求中，多个流程都需要使用的数据）只可以在单次业务流程中使用，不能跨业务流程使用。

好了，以上就是三种典型的数据种类的缓存设计思路了。这里你需要注意的是，使用缓存一定是以性能优化为目的，因此，你还需要使用评估模型来分析缓存是否达到了性能优化的目标。

那么具体是什么评估模型呢？我们来看一下这个性能评估模型的公式：AMAT = Thit + MR * MP。其中：

AMAT（Average Memory Access Time），代表的是平均内存访问时间；

Thit，是指命中缓存之后的数据访问时间；

MR，是指访问缓存的失效率；

MP，是指缓存失效后，系统访问缓存的时间与访问原始数据请求的时间之和。

另外这里你可能会注意到，AMAT 与原始数据访问之间的差值，代表的就是使用缓存所带来的访问速度的提升。而在一些缓存使用不当的场景下，增加的缓存机制很可能会造成数据访问速度下降的情况。所以接下来，我就通过真实的缓存设计案例，来带你理解如何正确地使用缓存，以此帮助你更好地提升系统性能。

缓存设计的典型使用场景

好，在开始介绍之前呢，我还想给你说明一下，在真实的业务中，缓存设计的场景其实有很多，这里我的目的主要是让你明确缓存设计的方法。因此，我会从两个比较典型的案例场景入手，来带你理解缓存的使用。

如何做好静态页面的缓存设计？

在 Web 应用服务中，一个重要的应用场景就是静态页面的缓存使用。这里的静态页面是指一个网站内，所有用户看到的都是一样的页面，除非重新部署否则一般不发生变更，比如大部分公司官网的首页封面等。

通常静态页面的访问并发量是比较大的，如果你不使用缓存技术，不仅会造成用户响应时延比较长，而且会对后端服务造成很大的负载压力。

那么针对静态页面，我们在使用缓存技术时，可以通过将静态缓存放到距离用户近的地方，来减少页面数据在网络上的传输时延。现在，我们来看一个针对静态页面使用缓存设计的示意图：

如图上所示，针对静态网页，首先你就可以在软件后端服务的实例中使用缓存技术，从而避免每次都要重新生成页面信息。然后，由于静态网页属于不变性数据，所以你可以使用内存或文件级缓存。另外，针对访问量非常大的静态页面，为了进一步减少对后端服务的压力，你还可以将静态页面放在网关处，然后利用 OpenResty 等第三方框架增加缓存机制，来保存静态页面。

除此之外，在网页中很多的静态页面或静态资源文件，还需要使用浏览器的缓存，来进一步提升性能。

注意，这里我并不是建议你针对所有的静态页面，你都需要设计三层的缓存机制，而是你要知道，在软件设计阶段，一般就需要考虑如何做静态页面的缓存设计了。

后端服务如何设计数据库的多级缓存机制？

还有一个典型的缓存场景是针对数据库的缓存。现在的数据库通常都是分布式存储的，而且规模都比较大，在针对大规模数据进行查询与分析计算时，都需要花费一定的时间周期。

因此，我们可以先识别出这些计算结果中可以使用缓存机制的数据，然后就可以使用缓存来提升访问速度了。下面是一张针对数据库缓存机制的原理图：

从图上我们可以看到，内存级 Cache、分布式 Cache 都可以作为数据计算分析结果的缓存。而且，不同级的缓存访问速度是不一样的，内存级的 Cache 访问速度可以到微秒级别，甚至更好；分布式 Cache 访问速度通常可以小于毫秒级别；而针对原生数据库的查询与分析，通常是大于毫秒级别的。

因此，在具体设计缓存机制的时候，你就需要依据前面我介绍的缓存使用原理，识别出数据类型，然后选择并设计缓存实现机制。

另外，在使用缓存机制实现访问速度优化的过程中，我们的主要关注点是不同层级缓存所带来的访问速度提升，而在这里，不同层级缓存也是可以在一个数据库中的。比如，在我参与设计的一个性能优化项目中，其 Cache 策略就是，使用 MongoDB 中的另外一个 Collection（集合），来作为缓存查询分析，以此优化性能。

所以，你在做缓存设计时，关注点应该放到不同的数据种类，以及不同层级缓存的性能评估模型上，而不是只关注数据库。只有这样，你才能设计出更好、更优的性能缓存方案。

小结

今天，我重点介绍了缓存技术的使用原理和典型应用场景，当你在进行软件业务系统性能设计时，可以结合今天学习的内容，识别出系统中各种可缓存的数据类型，然后有针对性地设计缓存方案，并且还可以根据评估模型，来进行前期的性能验证分析。

另外，在具体的缓存技术实现中，比如缓存替换算法、缓存失效策略等，通常这部分能力是内置于缓存库的配置选项当中的，或者选用第三方库即可，需要自定义设计算法的实现场景很少。所以这里你需要重点做的，就是选择合适的配置和策略即可。

思考题

对于一致性要求比较高的数据信息，在微服务多实例架构中，我们是否可以选择使用内存 Cache 呢？

欢迎在留言区分享你的答案和思考。如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

05 | IO设计：如何设计IO交互来提升系统性能？

你好，我是尉刚强。今天这节课，我想从性能的角度，来跟你聊聊 IO 交互设计。

对于一个软件系统来说，影响其性能的因素有很多，与 IO 之间的交互就是其中很关键的一个。不过可能有不少的程序员会觉得，IO 交互是操作系统底层干的事情，好像跟上层的业务关系不太大，所以很少会关注 IO 交互设计。其实，这是一种不太科学的认识。

事实上，在测试软件性能的时候，如果你发现了这样一种很奇怪的现象：虽然 CPU 使用率还没有到 100%，但是系统吞吐量却无法再提升了。那么这个时候，就很有可能是因为 IO 交互设计没有做好，导致软件的很多业务处理线程都被阻塞了，所以性能提不上去。可见，在软件设计当中，良好的 IO 交互设计对于系统性能的提升非常重要。

因此在这节课中，我想先帮你打开一下思路，了解下在软件设计中可能会碰到的各种 IO 场景，从而树立起对 IO 交互设计的正确认知。然后，我会给你介绍下针对不同的 IO 场景，应该怎样进行 IO 交互设计，才能在软件实现复杂度与性能之间实现平衡，从而帮助你提升在 IO 交互设计方面的能力。

那么下面，我们就一起来了解下，在软件设计中都有哪些 IO 场景吧。

突破对 IO 的片面认识

提到 IO，你首先想到的会是什么呢？键盘、鼠标、打印机吗？实际上，现在的软件系统中很少会用到这些东西了。一般来说，大部分程序员所理解的 IO 交互，是文件读取操作、底层网络通信，等等。

那么这里我想问你一个问题：是不是当系统中没有这些操作的时候，就不用进行 IO 交互设计了？

其实并不是的。对一个软件系统而言，除了 CPU 和内存外，其他资源或者服务的访问也可以认为是 IO 交互。比如针对数据库的访问、REST 请求，还有消息队列的使用，你都可以认为是 IO 交互问题，因为这些软件服务都在不同的服务器之上，直接信息交互也是通过底层的 IO 设备来实现的。

下面，我就带你来看一段使用 Java 语言访问 MongoDB 的代码实现，你会发现在软件开发中，有很多与 IO 相关的代码实现其实是比较隐蔽的，不太容易被发现。所以，你应该对这些 IO 相关的问题时刻保持警觉，不要让它们拖垮了软件的业务性能。

这段代码的业务逻辑是在数据库中查询一条数据并返回，具体代码如下：

// 从数据库查询一条数据。
MongoClient client = new MongoClient("*.*.*.*");
DBCollection collection = mClient.getDB("testDB").getCollection("firstCollection");
BasicDBObject queryObject = new BasicDBObject("name","999");
DBObject obj = collection.findOne(queryObject);  // 查询操作

其中我们可以发现，代码中的最后一行是采用了同步阻塞的交互方式。也就是说，这段代码在执行过程中，是会把当前线程阻塞起来的，这个过程与读取一个文件的代码原理是一样的。所以，它也是一种很典型的 IO 业务问题。

可见，我们一定要突破对传统 IO 的那种片面理解和认识，用更加全局性、系统性的视角，来认识系统中的各种 IO 场景，这才是做好基于 IO 交互设计，提升软件性能的先决条件。

那么说到这里，我们具体要如何针对系统中不同的 IO 场景，进行交互设计并提升系统性能呢？接下来，我就给你详细介绍下在软件设计中，IO 交互设计的不同实现模式，进而帮助你理解不同 IO 交互对软件设计与实现以及在性能上的影响。

IO 交互设计与软件设计

我们知道，在 Linux 操作系统内核中，内置了 5 种不同的 IO 交互模式，分别是阻塞 IO、非阻塞 IO、多路复用 IO、信号驱动 IO、异步 IO。但是，不同的编程语言和代码库，都基于底层 IO 接口重新封装了一层接口，而且这些接口在使用上也存在不少的差异。所以，这就导致很多程序员对 IO 交互模型的理解和认识不能统一，进而就对做好 IO 的交互设计与实现造成了比较大的障碍。

所以接下来，我就会站在业务使用的视角，将 IO 交互设计分为三种方式，分别是同步阻塞交互方式、同步非阻塞交互方式和异步回调交互方式，给你一一介绍它们的设计原理。我认为，只要你搞清楚这些 IO 交互设计的原理，以及理解它们在不同的 IO 场景下，如何在软件实现复杂度与性能之间做好权衡，你就离设计出高性能的软件不远了。

另外这里你要知道的是，这三种交互设计方式之间是层层递进的关系，越是靠后的方式，在 IO 交互过程中，CPU 介入开销的可能就会越少。当然 CPU 介入越少，也就意味着在相同 CPU 硬件资源上，潜在可以支撑更多的业务处理流程，因而性能就有可能会更高。

同步阻塞交互方式

首先，我们来看看第一种 IO 交互方式：同步阻塞交互方式。

什么是同步阻塞交互方式呢？在 Java 语言中，传统的基于流的读写操作方式，其实就是采用的同步阻塞方式，前面我介绍的那个 MongoDB 的查询请求，也是同步阻塞的交互方式。也就是说，虽然从开发人员的视角来看，采用同步阻塞交互方式的程序是同步调用的，但在实际的执行过程中，程序会被操作系统挂起阻塞。我们来看看采用了同步阻塞交互方式的原理示意图：

从图上你可以看到，业务代码中发送了读写请求之后，当前的线程或进程会被阻塞，只有等 IO 处理结束之后才会被唤醒。

所以这里你可能会产生一个疑问：是不是使用同步阻塞交互方式，性能就一定会非常差呢？实际上，并没有那么绝对，因为并不是所有的 IO 访问场景都是性能关键的场景。

我给你举个例子，针对在程序启动过程中加载配置文件的场景，因为软件在运行过程中只会加载配置文件一次，所以这次的读取操作并不会对软件的业务性能产生影响，这样我们就应该选择最简单的实现方式，也就是同步阻塞交互方式。

既然如此，你可能又要问了：如果系统中有很多这样的 IO 请求操作时，那么软件系统架构会是怎样的呢？

实际上，早期的 Java 服务器端经常使用 Socket 通信，也是采用的同步阻塞交互方式，它对应的架构图是这样的：

可以看到，每个 Socket 会单独使用一个线程，当使用 Socket 接口写入或读取数据的时候，这个对应的线程就会被阻塞。那么对于这样的架构来说，如果系统中的连接数比较少，即使某一个线程发生了阻塞，也还有其他的业务线程可以正常处理请求，所以它的系统性能实际上并不会非常差。

不过，现在很多基于 Java 开发的后端服务，在访问数据库的时候其实也是使用同步阻塞的方式，所以就只能采用很多个线程，来分别处理不同的数据库操作请求。而如果针对系统中线程数很多的场景，每次访问数据库时都会引起阻塞，那么就很容易导致系统的性能受限。

由此，我们就需要考虑采用其他类型的 IO 交互方式，避免因频繁地进行线程间切换而造成 CPU 资源浪费，以此进一步提升软件的性能。所以，同步非阻塞交互模式就被提出来，目的就是为了解决这个问题，下面我们具体来看看它的设计原理。

同步非阻塞交互方式

这里，我们先来了解下同步非阻塞交互方式的设计特点：在请求 IO 交互的过程中，如果 IO 交互没有结束的话，当前线程或者进程并不会被阻塞，而是会去执行其他的业务代码，然后等过段时间再来查询 IO 交互是否完成。Java 语言在 1.4 版本之后引入的 NIO 交互模式，其实就属于同步非阻塞的模式。

注意：实际上，Java NIO 使用的并不是完全的同步非阻塞交互方式，比如 FileChannel 就不支持非阻塞模式。另外，Java NIO 具备高性能的其中一个重要原因，是因为它增加了缓冲机制，通过引入 Buffer 来支持数据的批量处理。

那么接下来，我们就通过一个 SocketChannel 在非阻塞模式中读取数据的代码片段，来具体看看同步非阻塞交互方式的工作原理：

while(selector.select()>0){   //不断循环选择可操作的通道。
      for(SelectionKey sk:selector.selectedKeys()){
        selector.selectedKeys().remove(sk);
        if(sk.isReadable()){ //是一个可读的通道
            SocketChannel sc=(SocketChannel)sk.channel();
            String content="";
            ByteBuffer buff=ByteBuffer.allocate(1024);
            while(sc.read(buff)>0){
                sc.read(buff);
                buff.flip();
                content+=charset.decode(bff);
                }
            System.out.println(content);
            sk.interestOps(SelectionKey.OP_READ);
        }
    }
}

你能看到，业务代码中会不断地循环执行 selector.select() 操作，选择出可读就绪的 SocketChannel，然后再调用 channel.read，把通道数据读取到 Buffer 中。

也就是说，在这个代码执行过程中，SocketChannel 从网口设备接收数据期间，并不会长时间地阻塞当前业务线程的执行，所以就可以进一步提升性能。这个 IO 交互方式对应的原理图如下：

从图中你能看到，当前的业务线程虽然避免了长时间被阻塞挂起，但是在业务线程中，会频繁地调用 selector.select 接口来查询状态。这也就是说，在单 IO 通道的场景下，使用这种同步非阻塞交互方式，性能提升其实是非常有限的。

不过，与同步阻塞交互方式刚好相反，当业务系统中同时存在很多的 IO 交互通道时，使用同步非阻塞交互方式，我们就可以复用一个线程，来查询可读就绪的通道，这样就可以大大减少 IO 交互引起的频繁切换线程的开销。

因此，在软件设计的过程中，如果你发现核心业务逻辑也是多 IO 交互的问题，你就可以基于这种 IO 同步非阻塞交互方式，来支撑产品的软件架构设计。在采用这种 IO 交互设计方式实现多个 IO 交互时，它的软件架构如下图所示：

如果你详细阅读了前面 SocketChannel 在非阻塞模式中读取数据的代码片段，你就会发现在这个图中包含了三个很熟悉的概念，分别是 Buffer、Channel、Selector，它们正是 Java NIO 的核心。这里我也给你简单介绍下：Buffer 是一个缓冲区，用来缓存读取和写入的数据；Channel 是一个通道，负责后台对接 IO 数据；而 Selector 实现的主要功能，就是主动查询哪些通道是处于就绪状态。

所以，Java NIO 正是基于这个 IO 交互模型，来支撑业务代码实现针对 IO 进行同步非阻塞的设计，从而降低了原来传统的同步阻塞 IO 交互过程中，线程被频繁阻塞和切换的开销。

补充：但 Java 的 NIO 接口设计得并不是非常友好，代码中需要关注 Channel 的选择细节，而且还需要不断关注 Buffer 的状态切换过程。因此，基于这套接口的代码实现起来会比较复杂。

那么有没有什么办法可以帮助降低代码实现的复杂度呢？我们可以基于 Java NIO 设计的 Netty 框架来帮助屏蔽这些细节问题。Netty 框架是一个开源异步事件编程框架，它的系统性能非常高，同时在接口使用上也非常友好，所以目前使用也很广泛。如果你在开发网络通信的高性能服务器产品，那么你也可以考虑使用这种框架（Elasticsearch 底层实际上就是采用的这种机制）。

不过，基于同步非阻塞方式的 IO 交互设计，如果在并发设计中，没有平衡好 IO 状态查询与业务处理 CPU 执行开销管理，就很容易导致软件执行期间存在大量的 IO 状态的冗余查询，从而造成对 CPU 资源的浪费。

因此，我们还需要从业务角度的 IO 交互设计出发，来进一步减少 IO 对 CPU 带来的额外开销，而这就是我接下来要给你介绍的异步回调交互方式的重要优势。

异步回调交互方式

所谓异步回调的意思就是，当业务代码触发 IO 接口调用之后，当前的线程会接着执行后续处理流程，然后等 IO 处理结束之后，再通过回调函数来执行 IO 结束后的代码逻辑。

这里我们同样来看一段代码示例，这是 Java 语言针对 MongoDB 的插入操作，它采用的就是异步回调的实现方式：

Document doc = new Document("name", "Geek")
               .append("info", new Document("age", 203).append("sex", "male"));

collection.insertOne(doc, new SingleResultCallback<Void>() {
    @Override
    public void onResult(final Void result, final Throwable t) {
        System.out.println("Inserted success");
    }
});

我们可以发现，在这段代码中，调用 collection.insertOne 在插入数据时，同时还传入了回调函数。

实际上，这个 MongoDB 访问接口在底层使用是 Netty 框架，只是重新封装了接口使用方法而已。

由此，我们就可以最大化地减少 IO 交互过程中 CPU 参与的开销。这种 IO 交互方式的原理图如下所示：

从这个图中可以看到，在使用异步回调这种处理方式时，回调函数经常会被挂载到另外一个线程中去执行。所以使用这种方式会有一个好处，就是业务逻辑不需要频繁地查询数据，但同时，它也会引入一个新问题，那就是回调处理函数与正常的业务代码被割裂开了，这会给代码实现增加不少的复杂度。

我给你举个例子，如果代码中的回调函数在处理过程中，还需要进一步执行其他 IO 请求时，如果再使用回调机制，那么就会出现万恶的回调嵌套问题，也就是回调函数中再嵌套一个回调函数，这样一直嵌套下去，代码就会很难阅读和维护。

所以后来，在 Node.js 中就引入了 async 和 await 机制（在 C++、Rust 中，也都引入了类似的机制），比较好地解决了这个问题。我们使用这个机制，可以将背后的回调函数机制封装到语言内部底层实现中，这样我们就依旧可以使用串行思维模式来处理 IO 交互。

而且，当有了这种机制之后，IO 交互方式对软件设计架构的影响就比较少了，所以像 Node.js 这样的单进程模型也可以处理非常多的 IO 请求。

另外，使用异步回调交互方式还有一个好处，因为现在的互联网场景中，对数据库、消息队列、REST 请求都是非常频繁的，所以如果你采用异步回调方式，比较有可能将 IO 阻塞引起的线程切换开销，还有频繁查询 IO 状态的时间开销，都降低到比较低的状态。

最后我还想告诉你的是，实际上，IO 交互设计不仅与语言系统的并发设计相关性很大，而且与缓冲区（Buffer）的设计和实现关系也很紧密，我们在进行 IO 交互设计时，其实需要权衡很多因素，这是一个挺复杂的工作，我们一定不能小看它。

小结

今天这节课，我通过一些常见的业务代码逻辑，带你突破了之前对 IO 的片面认识，帮助你更加清楚地识别出系统中的各种 IO 交互场景。另外，我也给你重点介绍了在应用软件设计过程中，三种常用的 IO 同步交互设计，帮助你去理解不同 IO 交互对软件设计与实现，以及在性能上的影响。你可以基于这些理解和认识，来指导软件架构设计，避免因为 IO 交互问题而引起比较严重的性能问题。

其实，还有一种在软件设计中使用的比较少的 IO 交互同步方式，我并没有给你介绍，这种 IO 交互方式叫做零协调交互方式，它在高性能嵌入式系统设备或高性能服务器中使用会比较多。比如，你可以使用 DPDK 技术，来减少网络数据接收期间操作系统内核的参与，或者使用链式 DMA 拷贝，来减少内存拷贝中的 CPU 介入等。这里你简单了解下即可，如果还想要深入学习的话，你可以参考这个文档。

思考题

今天课程中介绍的异步回调交互方式，操作系统底层是不是采用的 Linux 内核的异步 IO 交互方式呢？

欢迎在留言区分享你的答案和思考。如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

06 | 通信设计：请不要让消息通信拖垮了系统的整体性能

你好，我是尉刚强。随着业务规模的不断扩大，分布式的系统架构越来越流行，而基于消息队列的通信设计，则是分布式系统性能设计中非常关键的一环，今天我们就来聊一聊这个话题。

首先，为什么基于消息队列的通信设计如此重要呢？其实简单来说，它在软件系统中的地位和作用，类似于接力赛中的交棒环节，一旦某个选手在交接棒期间失误，那么整个团队的成绩就会被拖垮。所以，如果没有做好基于消息队列的通信设计，那么系统的整体性能就一定不会很理想。

之前我做过一个项目，在业务系统中大量使用了 Redis 来作为消息队列进行传输，而业务开发人员经常就会因为接口使用不当，导致消息丢失；另外，由于队列容量也不加以控制，并会出现消息队列的数据堆积，使得整个 Redis 的速度都很慢，进而就导致了业务整体的性能非常不稳定。

另外，还有一个很重要的原因，那就是基于消息队列的通信设计，与软件系统的很多设计都有关系，比如并发设计、IO 设计等，它对整个软件架构的影响比较大。所以在软件的设计阶段，我们就应该开始关注它。

今天这节课上，我会给你详细介绍基于消息队列选型和设计过程中的一些核心要点，以及它们对软件性能的影响。我会带你理解正确开展通信队列的设计思路，从而让你真正掌握分布式系统中进行通信设计的方法，避免因消息通信的时延与吞吐量制约你的软件性能的提升。

那么具体是什么思路呢？在对一个高性能分布式系统进行通信队列设计时，我一般会按照这几个步骤开展设计工作，分别是消息队列选型设计、消息体设计、消息通信过程设计。因为消息队列选型是最基础的，它会直接影响后续的通信过程设计。

所以接下来，我们就从通信队列的选型设计开始学习吧。

消息队列选型设计

首先，在软件设计阶段，消息队列选型设计的核心目的，其实就是根据软件架构选择合适的消息队列底层实现、对应的框架或服务等，从而将底层的传输成本降低到最小化。而且现在是一个富技术的时代，碰到问题时我们可以选择的框架和服务特别多，而前面我也说过，具体的消息队列的实现原理及性能，会制约软件的架构设计。

所以，如果说消息队列选型失败，那么后续即使你花费再大的代价，可能都无法弥补性能上的不足。我曾经就经历过一个项目，该项目对时延要求非常高，可是因为选用了 RabbitMQ 这款消息队列系统，它在传输消息时，都需要多经过一个代理服务器，比在实例间直接点对点通信要慢很多，所以最后就无法满足系统延迟的极致需求。

可见，我们在进行消息队列选型设计之前，一定要确认好需要进行通信的软件实例的运行位置，避免由于搞不清楚该选择什么样的底层消息传输机制，影响到最后的软件性能。

那接下来你可能要问：具体该怎么根据不同软件实例的位置分布，来选择不同的底层消息传输机制呢？

现在我们来看一张图片，这是一张不同层级的消息队列底层原理图，图中按照底层实现机制，将消息队列分为了四个层级，分别是线程内队列、线程间队列、进程间队列、服务器间队列。这四个层级从上到下，其传输时延会越来越大。

这也就是说，如果我们在线程内通信选择了线程间队列，虽然它也可以满足功能需求，但是由于需要解决同步互斥的问题，其性能往往不会是最佳的。所以，在选型设计阶段，我们要优先选择能够满足通信要求的同时，底层实现也是最快的消息队列类型。

不过，在实际的系统设计和实现过程中，很多人其实都忽视了不同底层实现的消息队列之间，所具备的性能差异。所以接下来，我就给你一一介绍下前面这四种消息队列类型的实现原理，以及在做选型设计时的核心关注点。

不同类型的消息队列都有啥特点？

首先是线程内队列。现在我们已经知道，线程内队列的传输速度最快、时延最低，它可以作为同一个线程的各个模块单元间通信，因为不用考虑并发问题，所以性能会比较高。

其次是线程间队列。在一个进程中，我们可以基于内存创建队列来进行通信，但是由于跨线程内存访问数据一致性的问题，很容易引起执行结果的不确定性，所以这时我们就需要考虑前面第 3 讲介绍过的同步互斥问题。也就是说，在使用线程间队列的过程中，可能会引入加锁或内存屏障机制，因而就会导致性能下降。

第三种是进程间队列。我们可以利用 IPC（Inter-Process Communication，进程间通信）队列，或者利用进程间共享的一块内存建立通信队列，因为这种通信队列可以完全在内存中实现，所以几乎可以达到与线程间队列比较接近的性能。比如，Java 语言的 traffic-shm(Anna) 库提供的消息队列，由于不需要跨服务器的消息队列，从而可以避免引入额外的网络传输的开销。

最后是服务器间队列。它的实现原理是基于物理网络设备，建立传输网络来进行通信，所以这样可能就会受到传输带宽、网络拥塞等各种问题的影响，传输时延相对会比较长，而且有可能不稳定。

实际上，线程内、线程间、进程间通信队列设计在嵌入式分布式系统中使用得很频繁，而对于网络服务化的分布式系统而言，这些通信队列机制通常已经内置到了特定语言或者框架库中，比如 Go 语言的 Channel 队列、Java 的 CurrentQueue 等。

不过，对于互联网的分布式系统来说，消息队列选型设计的核心关注点，主要在于服务器间的消息队列选型。因为大部分的软件 / 服务实例都部署在不同的机器之上，所以我们只能使用服务器间消息队列。

那么针对服务器间的消息队列，我们可以选择采用的消息队列实现非常多，比如有 RabbitMQ、ActiveMQ、Kafka、RocketMQ、ZeroMQ，等等。当然，还有一些基于特定数据库封装实现的消息队列，如基于 Redis 实现的消息队列等。

既然如此，在这些消息队列之间，我们又应该怎么选择呢，它们对性能都有什么影响呢？接下来，我就给你分享一下选择满足业务性能需求的消息队列的方法。

如何选择满足业务性能需求的消息队列？

首先你要知道的是，虽然前面我列举的这些消息队列都可以实现不同服务器间的消息通信，但因为它们在底层实现上存在差异，会直接影响软件在性能上的表现，我给你举几个例子。

比如，对于 ZeroMQ 来说，它是基于 C 语言开发的，并没有中间代理服务器来缓存消息，会直接基于服务器中间的网络链路进行通信，所以它的时延速度是最高的。

而 Kafka 是一款多分区、多副本，且基于 ZooKeeper 协调的分布式消息系统，理论上可以支撑非常大的集群规模，所以它可以支撑的业务吞吐量会非常高。我之前在开发大数据平台时，利用 Kafka，使得消息吞吐量可以支撑到几百 MB/s 的速度。

那么，相比 Kafka 而言，RabbitMQ 的吞吐量会差一些，但是它在时延性能和功能上并不逊色，可以满足大部分业务场景的性能需求。

再举个例子，我看到有很多的软件系统，其实都是使用数据库来构造消息队列的，比如基于 Redis 开发的消息队列等。在理论上，你确实是可以基于各种分布式数据库来开发消息队列的。但是这样的做法，一方面会受到不同数据库实现架构的影响，另一方面它也不具备很多针对消息通信的优化设计与开发配置策略，所以往往在性能上不会是最佳的。

所以综合以上不同消息队列的特性和使用场景，我想告诉你的是，当你在面对一些消息通信很关键的性能场景时，针对消息队列的选型，需要从时延、吞吐量等多个维度进行性能评估与分析，而在评估分析前，基于底层实现机制进行选型是你要做的第一步。

好了，在确认了消息队列选型之后，为了支撑分布式系统通信的高性能，你还需要做好消息体设计，下面我们就具体来看看它的核心关注点。

消息体设计

实际上，消息体设计的核心有两点，消息内容设计和消息编码设计。

首先，什么是消息内容设计呢？是这样的，同样一个信息，通常会有很多种表示方法。比如“北京”和 “中国的首都城市”，它们都表示相同的地方，但是二者所需要的数据量（字数）却是不一样的。

也就是说，不同的消息内容呈现方式会直接影响传输的消息体大小，这对通信性能很关键，但是这部分却经常被我们所忽略。

而除了消息内容设计之外，不同的消息编码格式设计也会影响传输的消息体大小，从而直接影响到消息传输的时延和吞吐量。现在比较常见的消息编码格式主要有几种：TLV 格式（Type/Tag、Length、Value）、ProtoBuffer 格式、JSON 格式、XML 格式。

其中，TLV 格式和 ProtoBuffer 格式并不是自描述的，因此就需要生产者和消费者之间提前约定好消息格式，或者基于特定的格式描述文件进行解释；而使用 JSON 和 XML 这类消息格式，通常消息内容是自描述的，所以消息体中会携带额外的描述消息，进而就造成消息体比较大，因此我不太建议在一些高性能的通信业务场景中使用。

所以说，如果消息通信性能对你的软件系统性能很关键的话，你就应该优先考虑前两种消息编码格式。但是采用这两种方式，你还需要在软件实现中处理接口的兼容性，因而会花费更多的精力。

事实上，针对关系型数据库，表信息就是对字段内容格式的描述，所以在一些特殊场景下，我们基于数据库也可以构造出一些高性能的通信队列。比如，在 Mongo 的主节点和其他 Replica 节点之间，传输信息所使用的 Change Stream 就是使用数据库内一个 Collection（集合）来实现的，因此你也可以构造出一些高性能通信队列的场景。

而当你使用后面两种消息格式时，那么在发送和接收消息的过程中，你可以使用一些编解码库来压缩消息体的内容，从而减少传输的信息大小。比如针对 JSON，你可以使用 cJSON、HPACK 这类的压缩算法来压缩传输数据，通常压缩效率还是比较高的。

OK，在选择完合适的通信队列实现，并完成了消息的高效编码之后，是不是就可以保证通信性能卓越，不会影响系统业务的性能呢？

当然不是的，配置和使用消息队列以及匹配软件的设计，对性能的影响同样也非常大。所以接下来，我们就一起看看，如何在通信设计的过程中保证系统的高性能。

消息通信过程设计

首先，我们来看一张图片，它展示的就是一个基于消息队列通信的过程，图上包含了三个核心概念，分别是生产者、消费者和消息队列：

其中，生产者和消费者都可以包含多个运行实例，而消息队列则是消息传输通道的一个抽象载体，在有些消息队列服务框架或服务中，如 Kafka、RabbitMQ 等，会有独立的运行实体去处理它。但是在一些场景中，消息队列并没有独立的运行实体可被处理，而是依托在生产者或消费者进程中进行处理的。

另外在图上，我还重点标注了对性能影响比较关键的几个决策点。理解了这些核心决策点和背后的原理后，你就可以针对分布式架构的通信部分，设计出高性能方案了：

批量模式：即从消息队列读取和写入消息的模式，我们应该优先选择批量或 Batch 模式。

队列个数：即基于性能设计去选择消息队列的个数。

队列容量：即消息队列中可以保存消息的个数或是字节的长度限制。

并发映射：即在消息队列与业务架构中，生产者和消费者对于运行实例之间的映射关系设计。

速度优势：即消费者处理消息的速度应大于生产者生产消息的速度。

那么我们该怎么理解以上这 5 个决策点呢？下面我就一一来给你详细介绍下。

首先是批量模式，通常对于消息队列而言，单个消息与批量消息的读取与写入的性能差异非常大，所以很多消息队列在这里都提供了很灵活的配置能力，比如 Kafka 客户端的 batch 大小和最大时延，或者 RabbitMQ 中接收端的 prefetch 等配置。也就是说，我们在业务代码中设计消息队列通信之前，一定要针对业务模型，调整和分析批处理模式中的相关配置。

同时，你还需要明白的一点是，即使业务中按照单条消息进行处理，而发送消息和接收消息也采用批量消息模式，这两个其实并不冲突。在上节课我也介绍过，从业务使用的视角来看，消息队列也属于 IO 交互，所以你还需要依据 IO 异步交互设计来进一步提升性能。

然后是消息队列的个数，对于拥有独立的 Broker 节点的消息队列服务来说，其实也是比较重要的。因为在消息队列服务的设计实现中，通常每个队列是由独立的线程来处理的。所以，提升消息队列个数，也就是提升消息通信中可以使用的 CPU 资源。比如，在 RabbitMQ 中，一个队列对应一个线程，它的上限吞吐量是确定的，所以你就可以通过提升消息队列个数，来提升消息传输的性能。

此外，消息队列的容量设置也是对性能影响比较关键的一个因素，但是却经常被我们所忽视。要知道，消息队列通常可以作为生产者与消费者中间的流控手段，当消费者处理能力下降时，我们可以通过消息队列的容量来限制生产者继续添加消息，从而限制生产者接收处理消息。

而如果这个容量设置过大的话，首先就会导致流控机制丧失，其次还有可能引起消息队列占用资源过多，影响整个系统的性能。其实，在一些高性能的嵌入式分布式系统中，消息队列长度都是需要进行严格设计的。

那并发映射主要解决的是什么问题呢？其实，它主要解决的是同步互斥的问题。

并发映射的关键，就是避免消费者或生产者的运行实例与消息队列之间的映射关系，出现同一个消息队列被多个运行实例并发访问，导致引入同步互斥的问题，尤其是对进程间的消息队列设计来说最为关键。

另外，针对跨服务间存在 Broker 节点的消息队列来说，我们也可以使用并发映射，同样可以减少 Broker 节点中处理消息队列的复杂度。

最后我要说的一点就是速度优势，在设计系统时，我们要尽量保证消费者的处理速度大于生成者，这是一个很重要的原则，因为它可以避免出现消息队列被阻塞的场景。但这里需要注意的是，处理速度大并不一定就是要求进程实体多，因为不同的业务逻辑，使用一个进程中的处理速度都是不一样的。

好了，除此之外，在消息通信过程设计中，其实还有一些对性能设计比较关键的点，比如持久化等，但这些点通常是我们已经能够关注到的点，所以这里我就不展开讲解了。

小结

今天这节课，我主要是从消息队列选型设计、消息格式编码设计、通信过程设计三个阶段，给你展开讲解了针对通信性能的关键技术手段。

当你面对一个高性能软件系统的通信设计时，你也可以按照今天我介绍的步骤，首先根据业务中并发实例的位置布局，选择和底层实现相匹配的消息队列；然后再根据业务逻辑特点，设计消息内容表示和编解码方式；最后，针对通信过程的 5 个核心关注点，有针对性地调整软件的设计与实现。这样，你就可以在使用同样的软硬件资源情况下，实现出更好的性能通信设计了。

思考题

我们在选用 RabbitMQ 进行软件通信设计的过程中，应该怎样在安全可靠性和性能之间做好平衡呢？

欢迎在留言区分享你的答案和思考。如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

07 | 数据库选型：如何基于性能需求选择合适的数据库？

你好，我是尉刚强。

我们知道，在软件系统的性能建模分析设计中，并行架构设计、IO 模型设计、内存模型设计是最核心的三个维度，它们决定了最终产品的性能底座。而在互联网应用服务产品中，内存模型设计与 IO 模型设计的大部分职责，其实在很大程度上都沉淀到了数据库服务与消息中间件中来实现。

所以，这节课我会聚焦在数据库选型上，以一个真实的性能需求案例为引导，来带你了解在设计或性能优化阶段，如何寻找备选数据库并进行初步地分析、筛选，然后基于性能评估与软件设计的权衡，来进行数据库选型与方案设计的过程方法。

不过我们也要知道，数据库的种类非常繁多，要了解每一款数据库的功能与性能其实是不现实的，所以这节课我主要的目的就是帮你建立基于性能的数据库选型思路。这样，当你面对不熟悉的性能需求或问题时，也可以做到有的放矢，并能够借助这套数据库选型的过程方法，找到合适的数据库与设计方案。

现在，我先来给你介绍下这个性能需求案例。这是一个 SaaS 服务产品中的分析服务，主要功能是针对客户提交的数据进行查询搜索并生成报表数据。通过对产品线上的数据规模和特征分析，以及对系统的核心业务流程分析，我们可以挖掘出其最核心的性能需求：

实际上，数据查询与搜索、报表生成都是互联网产品的核心业务场景，也是性能问题频发的重灾区。而在软件设计阶段从性能的角度出发，选择合适的数据库与架构设计，就可以在很大程度上避免出现这样的问题。

OK，在识别出典型的性能需求之后，我们接下来的工作就是寻找到所有满足这些条件的备选数据库列表了。

基于性能寻找备选数据库列表

在寻找备选数据库的过程中，我们首先应该关注的是广度。不过由于数据库的种类太多，而且人都存在认知的局限性，会很容易漏掉一些非常有价值的数据库产品，从而导致无法寻找到一个最佳的方案。所以这里，我给你推荐一个网站，这是一个专门收集和呈现数据库管理系统信息的数据库引擎排名，里面列举了超过 300 多种数据库产品，大部分的开源和商业数据库都在列。

可是，直接在这个数据库列表中进行大范围筛选，工作量还是比较巨大的，所以我们可以通过认识和学习数据库的大体分类，来提升筛选的效率。下面我就给你简单介绍下。

在业内，对数据库的通用分类主要包括 5 个大类：

关系数据库：以 MySQL、Oracle、PostgreSQL 为代表，这些是结构化的关系型数据库，主要基于 SQL 进行操作；

文档数据库：以 MongoDB、Cassandra、Elasticsearch 为代表，它支持灵活的半结构数据存储，如 JSON 等；

时序数据库：主要服务于监控、日志类的数据存储，它支持按照时间维度进行存储与分析；

Key-Value 数据库：以 Aerospike、Redis 等为代表，它支持典型数据结构的快速存储访问；

图数据库：支持图的存储，典型应用场景包括知识图谱、关键路径搜索等。

关于数据库更详细的种类及功能介绍，可以参考我之前写的文章资料。

那么，针对我们前面列举出的核心性能需求，可以发现其数据是标准结构化的，因此选择关系数据库就是一种让性能更加优越的实现方式。而对于关系数据库，按照典型应用场景我们可以分为 OLTP（On-Line Transaction Processing，联机事务处理）和 OLAP（On-Line Analytical Processing，联机分析处理）两种。

其中，OLTP 的事务能力更强，OLAP 则是分析性能更佳，但是事务能力偏弱。由于我们的业务性能需求需要对大规模的数据进行分析，而且时延要求极高，因此综合评定后，OLAP 的数据库性能会更加适合。

这样我们再通过前面的数据库引擎排名，可以搜索到的备选数据库列表如下（实际列表比较庞大，我没有全部列出，这里仅作示意）：

ClickHouse

Redshift

Greenplum

阿里云 AnalyticDB

MemSQL

Vertica

Hlive

SparkSQL

……

不过到这里，如果我们要对每一个备选数据库都进行系统的性能评估分析的话，那么成本还是会非常大。所以接下来，我们需要在这些备选数据库列表中进一步筛选一个子集，然后再进行深入的性能评估和分析。

理论评估筛选出待分析数据库

筛选待分析数据库的过程实际上会比较复杂，有些数据库可能你已经深入使用过，对它的功能与性能有深入的理解，那么是比较幸运的。不过如果你遇到的并不是非常熟悉的数据库的话，那你可以基于以下手段来进行初步的理论评估，识别或过滤掉一部分数据库产品：

数据库官网提供的性能指标；

数据库官网的存储模型与架构视图；

第三方给出的性能测试分析报告；

网上部分用户的性能测试分析结论。

相比较而言，官网提供的性能指标、存储模型及架构视图是比较权威的，你在选择数据库之前需要去深入了解学习。就拿我们前面提到的性能需求案例来说，基于官方文档的分析中，你会发现 Hlive 和 SparkSQL 数据库依赖 Map-Reduce，要动态创建分析请求的任务并上传时，准备时间会有些长，而当在业务中实时动态创建性能分析的请求时，性能很可能是不太理想的；而 MemSQL 开源版本有容量规模的限制，也很可能与产品业务的数据规模需求不一致，因此同样需要排除在外。

除此之外，进一步理解和学习一些典型的数据库相关实现架构，比如 MPP（Massively Parallel Processing）架构、Hadoop 生态架构等，也可以帮助你更好地识别数据库。当然，仅从数据库的视角进行理论分析评估是不够的，你可能还需要分析、评估潜在的外部性能影响因素，比如说，网络传输带宽的影响、跨地区或者跨云之间的传输时延开销，等等。

这里我给你举个例子，Redshift 和阿里云 ADB 都是云服务提供的数据库，如果你的产品业务是部署在阿里云上，那么使用 AWS 云上的 Redshift 就会引起额外的传输延迟抖动，甚至可能会影响产品的稳定性。

这样，当你的产品并没有极致的、严格的性能需求时，基于上述的搜索与理论评估，你就可以锁定到具体的数据库产品中，接下来重点工作就只需要进行原型验证，测试性能满足需求即可。

不过在不少的业务场景下，很多产品其实是有极致的性能要求的，而且这通常也是产品在行业内的核心竞争力体现。那么这时候，你就需要对备选数据库进行更深入的性能评估分析，甚至还需要调整软件设计与优化来应对。

好，针对前面我介绍的典型性能需求的案例，假设基于理论分析之后，我们筛选出了四个待分析的数据库，分别为 ClickHouse、 阿里云 ADB、 Redshift、 Greenplum（详细的筛选过程与业务相关性比较大，就不做深入介绍了，但使用的方法就是基于上述的手段），那我们接下来就可以针对这四个数据库做更深度的性能评估分析。

挖掘全量的性能需求点

对数据库的操作除了查询、分析操作外，还有插入、删除、更新等。因此在对数据库进行性能评估分析时，我们还需要考虑到各个性能维度，避免因为漏掉了个别的性能需求造成最终的评估结果无效。

可遗憾的是，在很多的产品业务中，因为性能需求考虑不完整，造成的性能评估结果不可靠的问题经常发生。比如，我前面介绍的性能优化案例中，原来的解决方案是引入 Elasticsearch 来优化数据库的查询性能，但这种方案会导致很多业务的复杂度增加，而且它只能解决一小部分现网的性能问题。

所以，为了避免因性能需求考虑不完整，造成性能评估结果不可靠的事情发生，在进行深度性能评估前，挖掘出全量的性能需求点就显得尤为重要了。下面我就根据前面的性能案例，给你介绍下挖掘全量的性能需求点的过程。

首先针对案例中的性能需求，我们可以做进一步细化，会扩展出很多的性能场景，如下面的思维脑图所示：

从图上我们可以发现，各个树干上的叶子节点之间其实是可以组合的，比如说：

分库、1000 万条、20 列字段、插入、单条数据、时延；

分库、1000 万条、20 列字段、插入、单条数据、TPS；

分表、1000 万条、20 列字段、插入、单条数据、生效时延；

……

这样组合之后能生成很多种性能场景，因此你需要针对实际业务的性能需求进行一些等价类划分，分析出一些比较关键的场景，再进行接下来的性能评估分析。

那么在这个性能案例场景中，我们刚开始仅挖掘出了最核心且具有挑战性的性能需求，但产品对数据库的性能需求是包含很多维度的，所以我们可以进一步完善对插入、更新、删除的性能需求之后，再额外扩充一些具体的性能需求，如下表所示：

此外，我们还可以根据这里扩展后的具体性能需求，基于理论评估分析再进一步地筛选待分析数据库，那么最终需要基于深度性能评估分析的数据库还能够再减少。

深度性能评估分析并发掘潜在性能冲突

到这里，我们已经完成了备选数据库的理论筛选过程，接下来就需要进行深度性能评估分析，也就是性能测试分析的过程。具体的性能测试理论与方法，我会在“性能看护”这个模块给你详细讲解，这里我们只需要关注下性能测试中比较重要的几点：

测试数据要尽可能接近真实数据；

尽量自动化执行；

尽量代码脚本化执行。

在明确了性能测试的重点之后，还需要尽量获取真实的被测数据集。假设我们通过在现网中采集或构造仿真数据，创建了一个待评测数据集：

基于这个待评测数据集和一致化的业务分析请求，我们对上述四个数据库进行性能测试的对比分析，获取真实的性能评测数据如下：

从结果可以看出，在真实领域的业务场景下，ClickHouse 的时延仅为 50~100ms，有着非常卓越的性能。这样，在基于真实数据的性能评测之后，你会发现获取的性能对比结果与在网上获取的信息是不完全一致的，这是因为你的性能评估数据已经携带了领域数据特征，其分析请求也是与领域特性相关的。

此外，在这个阶段所做的性能评估分析，还有一个重要的目标是发掘潜在的性能冲突点。针对案例扩展出来的性能需求点，经过评估测试后我们会发现部分数据库产品在一些性能场景下，并不能满足需求，比如说：

如果你在数据库选型阶段忽略了这些性能冲突点，带来的后果往往是致命的，因为这可能会导致产品上线交付后，浪费很大的修改解决成本，甚至可能导致产品最终失败。

所以，如果没有百分百满足你所有性能需求的数据库产品，可以考虑通过软件设计方案去权衡解决发掘出的性能冲突点，不过这个方法只是在数据库选型不理想的场景下的一种弥补方法，希望你不会用到。

基于性能的软件设计权衡

幸运的话，你可以找到各个方面都能满足性能需求的数据库产品，然而实际中面对的业务通常都是复杂多变的，而且如今在数百种数据库不断迭代更新的场景下，确实存在找不到那个性能完美匹配的数据库的情况。

那么面对这个问题，有没有什么好的解决办法呢？就我的经验来看，当出现性能冲突点时，我们可以在软件设计层面，考虑一些折中的手段来规避和解决问题，这里我给你举几个例子。

假设受制于数据库的单条数据插入 TPS 的上限为 10 条，而产品业务每秒插入的数据是 500 条，那我们是不是就没有办法选择这款数据库产品了？

并不是，针对这个场景，我们可以通过批处理模式（即将很多个单次处理操作整合起来，一次性执行处理的性能优化模式，我在第 11 讲中会给你详细介绍），将每秒内的 500 条数据整合到一起插入（注意，在使用这个性能模式时，我们还需要构建单独的业务代码逻辑来实现这个功能）。

不过，这种实现方法也可能会带来一些潜在的问题，比如说：

插入数据的潜在生效时延被人为拉长了；

插入数据在整合批量插入的间隔时间内，又发生了变更，需要开发复杂的逻辑来处理这种场景。

所以你需要明白的是，软件设计上的权衡是有利弊的，在通过提升某些操作上的性能来解决性能冲突的同时，势必会在某些点上引起性能的下降。这个时候，你还需要评估设计权衡导致的这些性能下降点是否可以被产品业务所接受，才能确定该设计权衡的可行性。

我再举个例子，有些 OLAP 分析数据库不提供删除操作，但当产品业务需求有删除操作时，是否有其他变通实现的手段呢？答案也是有的，我们可以将删除操作转换为对数据条目的状态位更新操作，然后通过状态位标记软删除来实现业务中删除能力。

再比如说，MongoDB 数据库不提供跨文档操作的事务一致性机制，但在产品业务中存在这样的事务性要求时，要怎么解决呢？如果业务中存在的事务逻辑的切换频率非常低，其实有些逻辑是可以通过加锁来实现的，或者是可以通过实现事务机制来支撑。

以上我举的这几个例子，其实并不能完全列举出，各种软件设计与实现手法来规避数据库性能冲突的权衡手段，因为针对每个数据库产品或多或少都能找到这样一些规避手段。

总而言之，你在实际的业务场景下，如果碰到了数据库的性能冲突时，要知道还可以通过软件设计实现权衡来解决的思路与方法，以此帮助你更好地实现系统极限的性能需求。

小结

互联网时代是一个不断制造数据的时代，各行各业的产品都具有特色的数据分析需求，而最终产品的性能表现都会承载在数据库上来实现。然而，在对数据库选型时，我们其实很难完全剥离功能来谈性能，而是需要基于功能与性能一起综合决定。

完整且系统地介绍所有数据库的功能与性能，是一个非常庞大的工程，所以今天我是站在性能的立场去给你讲解数据库选型的过程和方法，以及过程中需要注意的一些事项，希望你能够掌握基于性能的数据库选型中的理论筛选、深度性能评估、软件设计权衡的思路与方法，从而能够在产品性能设计与优化中选择一款合适的数据库。

思考题

在你的业务场景中，有没有哪个性能瓶颈是受制于数据库且还没有很好的解决办法的？学完今天的内容，你觉得可以怎样解决呢？欢迎给我留言，分享你的思考和看法。如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

08 | 可监控设计：如何利用eBPF来指导可监控设计？

你好，我是尉刚强。今天这节课，我们会从系统监控的角度，来聊聊如何有效提升软件性能。

在前面的课程中，我介绍的并行设计、缓存设计、IO 设计等设计方法，实际上都只是从软件设计架构的维度去优化软件性能。但软件的生命周期一般是比较长的，伴随着新业务需求的不断演进，你还需要持续不断地对软件系统进行调优，才能保证软件性能长期具备竞争力。而要对一个软件系统做持续的性能调优，则是需要建立在对它进行观测数据的基础上。

那么今天我们要学习的系统可监控设计，就是有针对性地设计系统应该对外提供哪些观测数据，以及如何实现这些测量数据的获取过程。

我们都知道，在软件系统中，打印日志、统计信息等都属于观测数据，但是对于一个高性能的软件系统而言，需要提供的观测手段可远远不止这些。事实上，对于高性能软件系统的可监控设计来说，我们面临的最大挑战就是，如何能够在获取最有效的观测数据的前提下，又能尽量减少对正常业务流程所产生的额外开销。

既然如此，有没有什么有效的手段可以帮助我们解决这个难题呢？这就是我今天要给你介绍的 eBPF 技术了。我认为，它是一项能够为软件系统的可监控设计与实现，带来巨大改变的技术。至于原因，我在课程中就会给你揭晓答案。

这节课呢，我会从一个对高性能软件进行监控和观测实现时，经常需要面对的问题出发，与你探讨如果使用 eBPF 会带来什么样的改变，在此过程中，你就能够明白为什么在做可监控设计时需要使用 eBPF。然后，我会带你理解 eBPF 的核心架构原理，帮助你更加准确地找到使用场景，以此更好地支撑对系统的可监控设计。

好，下面我们就先来看下 eBPF 技术所带来的改变是什么吧。

eBPF 对系统观测分析的改变

不过在开始介绍 eBPF 之前，我想先带你来看一下，在一个高性能的软件系统内，现在常用的一些监控与观测手段在实现过程中都会面临什么样的困境。这样你就可以更直观地发现，为什么使用 eBPF 技术之后能够更好地解决这些难题。

目前常用的监控与观测手段都存在哪些不足？

首先，在一众的监控与观测手段中，成本最低的应该是各种计数器、统计变量等。

在嵌入式系统中，这些计数器或统计变量会记录到内存当中；而对分布式服务架构来说，则是更习惯记录到一些 Key-Value 数据库中，如 Redis 等。但是使用计数器存在的典型问题就是，我们只能看到一个个孤立的数字，所以采用这种手段来支撑软件系统的观测分析能力，实际上是比较有限的。

然后，日志打印也是比较常用的一个手段。不过，这种观测手段对于软件运行所产生的开销要比计数器大，所以在高性能系统中使用就会受控，比如我曾经参与的嵌入式实时性系统项目，在分析性能时一般都无法使用。另外，它对于分布式高性能的场景来说，也会因为 IO 开销比较大，所以使用效果不是太好。

日志打印还有一个比较明显的局限性，就是在高性能软件系统中使用日志时，一般都默认只打开错误日志，当系统出现问题需要进行分析时，才可以短暂地打开 INFO 级别日志进行查看。但是，如果系统的业务吞吐量很高，打开 INFO 日志直接就会导致系统崩溃，所以它对性能分析的帮助其实不大。不仅如此，大量的日志打印逻辑与业务代码通常都会搅和在一起，也会导致我们阅读代码的体验非常糟糕。

其实，动态跟踪和监控才是高性能软件系统中比较重要的分析手段，可是这些机制需要与业务代码一起实现，只有当接收到某个监控任务时才会触发执行，所以有不少高性能软件系统并没有认真去设计实现这里的功能。

而且它还存在一个问题，就是这种监控机制经常会和业务逻辑强耦合，实现起来会很复杂，同时我们在前期软件设计阶段，很难能考虑到所有的监控与观测场景，因而当后面出现性能问题时，只能靠不断定做特殊补丁软件版本来解决。

所以，根据以上对目前常用的监控与观测手段的介绍来看，我们其实能发现，对一个高性能的软件系统来说，设计和实现高效率的监控观测系统，确实是困难重重！

那么在使用 eBPF 之后，又会发生什么改变呢？

接下来，我们就通过一个具体的例子来了解一下，eBPF 为什么能够成为目前 Linux 生态中的热门技术，并真正改变和颠覆原有的软件系统监控和观测手段。

为什么 eBPF 可以帮助实现可监控设计？

这段代码是一个非常简单的 C 代码实现，函数在执行时可以不断输入数字，同时在函数中，还使用 DTRACE_PROBE1 定义了静态探针：

#include <sys/sdt.h>
#include <unistd.h>

int main(int argc, char **argv)
{
    while(1) {
        int age;
        scanf("%d", &age);
        DTRACE_PROBE1(sdt_group,sdt_age, age);  //定义一个静态探针。
        sleep(1);
    }
    return 0;
}

然后，在程序运行期间，你就可以使用下面这段 bpftrace 脚本，来跟踪打印上面程序中输入的值。

sudo bpftrace \
    -e 'usdt:/home/**/**/sdt_group::sdt_age \
        { printf("%d\n", arg0); }' \
    -p $(pidof test-main)
12
32
44

看到这里，你是不是会觉得有些繁琐：我直接在 main 函数中增加一个打印不香吗，为什么要搞得这么复杂呢？

先别着急，下面我就带你来看看这样做都能带来哪些好处，然后你再去评判这样的做法是否值得。

第一个好处，就是使用这个 DTRACE_PROBE1 插入的静态跟踪点，在生成的二进制指令中只对应了一个 nop 指令。而这个 nop 指令，只需要消耗一个 CPU 指令周期，尤其在今天 CPU 超强的指令发射技术背景下，这个开销几乎可以忽略不计。所以，它就可以把这个监控实现对正常业务流程的开销，降低到了接近零开销。

第二个好处，就是你可以从应用程序的外部去选择开启或者关闭跟踪，完全不用修改业务程序。同时，你还可以选择性地去跟踪分析具体的某一个跟踪点，这样就避免了软件系统中，很多监控日志只能整体开启和关闭的尴尬。

第三个好处，就是在这种场景下，你可以使用 eBPF 在软件系统的外部，去注入和修改打印与跟踪逻辑，而不用担心影响到业务运行逻辑，同时还能让业务代码更加清爽！

所以不知你发现了没，把这些好处总结到一起，其实就是使用 eBPF 这个技术手段的优势之一。它不仅可以提升系统的性能，同时还会让软件设计更加灵活。

那么 eBPF 还有什么其他的使用优势呢？

从前面的代码示例中，你可能会观察到在 bpftrace 脚本里，包含了一个 USDT（User Statically Defined Tracing）命令，这是一个接入到用户业务代码中进行静态定义的跟踪点，然后会获取跟踪信息并打印，这里使用的 USDT 技术其实只是 eBPF 中的一个技术而已。

实际上，现在不同的编程语言都在尝试着引入 USDT 技术，以此支持对开发软件的监控分析工作。比如说，Rust、Go、Python 使用的USDT，以及 Node.js、JVM 使用的USDT。

而除了 USDT 技术，在 eBPF 中还可以针对内核动态插桩（基于 kprobes 探针），针对用户态程序动态插桩（基于 uprobes 探针），以及针对内核和应用程序的静态插桩等各种能力。所以，基于 eBPF 技术，你就可以对监控、观测应用程序和系统的运行状态，具有了前所未有的可见性和灵活性。

正是因为 eBPF 技术这种强大的监控和观测能力，你在对高性能软件系统进行可监控设计时，就可以把监控测量作为一个独立的侧面来设计。

这又是什么意思呢？下面，为了让你更好地理解这个要点，我们一起来看一张示意图，这是一张软件业务与软件监控的设计解耦示意图：

可以看到，在这个图中主要分为三个大块，其中 Service system 代表了我们的业务系统，Analysis tool box 代表对业务系统的监控与分析工具箱，它们之间可以基于 eBPF 技术这个桥梁，实现高效互通。

那么这也就是说，当我们在对一个高性能系统进行监控设计时，就可以将监控观测设计与实现，从原来的业务逻辑中单独剥离出来进行。

好了，前面我只是给你介绍了 eBPF 都有什么功能，以及它对可监控设计都会带来哪些帮助，现在你可能还是不清楚，为什么 eBPF 可以具备如此强大的能力。

所以接下来，我就给你详细介绍下 eBPF 的核心架构，这样当你拥有了对 eBPF 的全局认识之后，就可以更好地支撑你的系统可监控设计了。

eBPF 的实现原理和使用场景

首先我们来看一张示意图，这是 eBPF 的核心架构原理图，它可以帮助你更好地理解 eBPF 的机制和性能优势：

你可以发现，这张图被中间的一条线给分开了，左半部分代表的就是应用态软件，右边则是代表操作系统内核。其中，内核是一个操作系统中最核心的模块，我们经常说的进程调度、设备管理、文件系统等，都属于内核，它是计算机上运行软件的大脑，控制着操作系统中软件的资源分配和调度。

现在，eBPF 的核心能力都已经集成到了内核中，而 eBPF 的功能则主要是由处于应用态的前端和内核态的执行引擎部分协同完成的。

其中，BBC 和 bpftrace 是 eBPF 的前端，你可以基于这两款工具来开发各种监控分析程序，这样开发出来的监控程序会编译成标准的 BPF 字节码，然后内核态的校验器执行完安全校验后，再交给内核态 eBPF 执行引擎来处理。

如此一来，通过这种方式，内核态的 eBPF 引擎提供的动态探知、静态探针、采样等丰富的观测监控手段，就可以很方便地被业务软件触发执行和控制。

另外，在软件系统运行过程中，我们还可以不断地通过动态配置的方式来修改观测行为。同时，内核态的 eBPF 引擎在获取到各种跟踪观测数据后，可以直接对数据进行加工处理，然后通过 perf 缓冲区和映射表数据结构，将处理后的数据再传输给应用态的监控工具。

也正是因为 eBPF 内核态的数据加工能力，就有效减少了内核态与应用态程序之间的内存拷贝数据量，进一步提升了监控性能。

而且，为了支持 eBPF 的处理性能，现在很多通用的 CPU 硬件上，都已经集成了专门用于 eBPF 程序的寄存器、函数栈等，所以说 eBPF 的运行性能优势会更大。

实际上，目前 eBPF 技术发展得非常快，不过在我们的课程设计中并不会系统讲解 eBPF，所以如果你还想更深入地了解它，我推荐你可以参考这个学习地址，其中包含了 eBPF 的理论、生态工具、丰富的开放样例等。

但是在今天的课程中，你需要明确理解和掌握 eBPF 这项技术是什么，以及它为高性能软件监控设计的解决思路所带来的变化是什么，然后你也可以根据这项技术的实现原理继续去思考，如何才能更好地实现高性能系统的可监控设计。

小结

其实，eBPF 对性能的改变并不限于监控观测分析，因为 eBPF 是标准化的，同时它还可以实现动态地改变内核态在一些场景下的行为，从而可以支撑 Linux 协议栈层上的性能优化，比如 bypass 优化等。

但这些已经都属于专有领域的范畴了，今天的课程上，我是站在一个高性能软件系统的可监控设计的视角，分析了 eBPF 技术可以帮助你解决哪些问题，并带你理解了 eBPF 的原理架构。学完今天的课程后，你就会更加清楚 eBPF 在性能上的优势，以及它应该在什么样的业务场景下被使用，这样你在自己的软件系统监控设计中，才会取得更好的效果。

思考题

使用 eBPF 技术开发出来的一般是比较小的工具，是否可以将这些工具和监控运维系统集成到一起呢？

欢迎给我留言，分享你的思考和看法。如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

09 | 性能模式（上）：如何有效提升性能指标？

你好，我是尉刚强。

构建高性能的软件，可以说是所有程序员的共同追求。不过，当我们碰到性能问题时，一般都只会想到数据结构和算法，而忘记系统性能是由运行态的各个硬件所承载的。比如说，当你的代码中遇到一个复杂数学计算的时候，你可能只会想到通过修改优化算法的方式来提升性能。可实际上，一个潜在更优的解决方案是提前计算好放到内存中，使用的时候直接取用，这时候具体的算法性能已经不是最重要的了。

所以，当我们从硬件运行态的视角去思考问题的时候，其实就会很容易地找到一些之前没发现，但性能收益比较大的解决方法，这就是我接下来的两节课要给你介绍的性能模式。

所谓的性能模式，就是在软件设计的过程中针对一些特定的上下文场景，以性能提升作为出发点的通用解决方案。下面我所讲解的各种性能模式，在很多场景下都已经被实际验证过了，你可以在特定的性能优化场景下去套用和实现，从而少走一些性能优化上的弯路。

另外，与软件设计模式不同，一般来说这些性能模式都是比较独立的，你可以将其看作是在时间、空间等不同维度去解决问题的参考思路，在很多的场景下都可以同时使用。所以我希望，你也不要局限于这几种性能模式，而是可以借鉴这些性能模式的解题思路，来掌握这种全局性地、软硬协同地、动态地思考和解决问题的思路。

好了，接下来，我就根据我的实践经验，按照常用度从高到低的顺序，来分别给你介绍下快速通道模式、并行分解模式、批处理模式、弹性时间模式、预计算模式、耦合模式、搬移计算模式、丢弃模式这八种性能模式的优化实现原理。这节课我们先来了解下前四种性能模式。

另外，在开始介绍之前我还要说明一点，通常情况下性能模式的抽象级别会比较高，不太适合直接使用举例。为了更清楚地给你展示性能模式的核心思想，我在课程里单独增加了代码示例，这些代码示例很多时候并没有实际意义，你千万不要抄作业且不改名字。

快速通道模式

好，我们先来学习下第一种性能模式：快速通道模式。

不知道你平常在逛超市的时候有没有发现这种现象：人们经常购买的商品可能只是整个超市商品的一小部分，大部分商品实际上很少有人购买。

基于这个现象，二十世纪初意大利的经济学家维弗雷提出了著名的二八效应，即 80/20 法则（The 80/20 Rule）：在通常情况下，事物的发展都是由少数决定多数的，体现了不公平的特点。

这个法则在计算机领域中也非常适用：系统中大部分用户使用的通常只是少数的一些业务场景；系统中的大部分性能负载是由少量的代码决定的。二八效应在软件系统中的各个维度不断重复着，所以我们能找到那 20% 的决定性场景，寻找定制化方案，就能在很大程度上提升系统的性能。

而这正是快速通道模式的核心思想，下面我们就来看看它的具体实现流程：

图上矩形块中的数字代表的是执行开销，我们可以看到在优化前，系统的总执行开销为 10。而通过分析业务流程和度量数据，我们会发现绝大部分用户使用的少量典型场景，其实可以找到简化处理的方案。

因此，如图上的右半部分所示，针对少数典型场景定制化实现的方案，其执行开销从原来的 3+5+2 变成了 4，系统的总执行开销变为了 6.2，可以说性能提升非常明显。

不过，从解决方案上我们也可以看出，系统在优化过程中增加了额外的业务流程，所以它的业务复杂度就提升了。这也是性能优化实现中经常需要面对的问题，为了追求极致的性能，而不得已舍弃了软件实现的部分简洁性。

这里我给你举个例子。下面是一个数学阶乘的递归实现，我们能看出程序在运行过程中会触发多次函数的调用，这就导致执行时间会比较长（注意，该实现在 num 数字较大的时候会计算越界，不过这不是我们的关注点，可以先忽略）。

long factBeforeOptimize(int num)
{
    if ( num == 1)
    {
        return 1;
    }
    else
    {
        return num * fact(num - 1);
    }
}

在对这个函数使用统计数据分析后，可以发现 80% 以上的接口调用传入的参数都是 8，所以我们就可以针对这个典型场景实现快速处理，以此来提升性能。修改后的代码实现如下：

long factAfterOptimize(int num)
{
    if (num == 8)
    {
        return 40320;
    }
    else if (ip == 1)
    {
        return 1;
    }
    else
    {
        return ip * fact(ip - 1);
    }
}

通过对比前面的代码，我们能发现改写后的代码实现只是增加了很小一段代码，但系统处理性能却有了很大程度的提升。所以，快速通道模式的核心思想，就是找到系统中频繁使用的典型场景，然后针对性地提供定制化方案来优化性能。

不过实际上，针对 CPU 资源紧张、内存资源相对充足的场景，前面所讲的阶乘计算实现是非常糟糕的。因为在该场景下，首先递归运算本身是非常低效的；其次，如果阶乘运算输入参数比较少的话，我们其实可以通过查表来减少执行的运算量。

因此在实际的场景中，快速通道模式的典型应用主要有两类：

一类是数据库 Cache 场景。业务代码经常访问的数据往往是全量数据中很少的一部分，所以在内存中对这部分数据进行 Cache，就可以获得比较明显的性能改善。

另一类针对的是 Web 业务用户经常访问的页面。在前端交互设计中，你可以通过给用户提供快速入口来直接跳转至常用页面，减少很多中间过程页面的访问，从而能够减少系统整体的业务负载。

但是，快速通道模式也存在一定的局限性，也就是如果对典型场景分支的预测有错误，就可能会导致系统性能更加恶化。比如，有些数据库 Cache 场景由于 Cache 的命中失败率太高，如果继续引入 Cache，反而会恶化性能表现。

看到这里，你可能还存在一个疑问，那就是快速通道模式只能用于优化时延吗？

显然不是的！我一直强调，我们不应该只看到性能模式的外部表现形式，而是应该理解性能模式背后的思维逻辑。

我曾经设计过一款内容资源受限的系统应用，该系统中包含了很多用户实例，而且每个用户的实例结构体比较大，内存总开销超过了设备的内存上限。而通过分析发现，大部分的用户实例其实只使用了结构体内的少部分字段，因此针对用户实例，我设计了精简版结构体和复杂版结构体，由于大部分用户实例使用了精简版结构体，所以系统总内存开销就被控制在了规定的范围内。

所以你看，这就说明了快速通道模式其实也可以用于内存的优化设计。

并行分解模式

OK，我们接着来看第二种性能模式：并行分解模式。并行其实是一种常态化的解决问题的模式，比如在工作当中，一件任务会被拆分成很多份，交付给不同的人，通过并行来加速任务的完成。

随着计算机技术的不断发展，并行计算也越来越方便可得，因此我们可以在业务处理的过程中，去挖掘并行执行的部分，借助并行计算来优化性能。

这种并行分解的性能解决方案的主要目标，是减少业务的处理时延，虽然在调整优化后，系统的执行开销可能会增加，但是从用户的角度来看，处理时延却降低了。下面我们就具体来看看：

如上图所示，矩形方块中的数字表示执行开销，在正常业务流程中可以分为两个大的代码块。在原来的业务流程中，虽然业务是串行执行的，但通过分析业务流程和度量数据后，我们会发现这两个代码块之间是独立可并行的。

所以，图上左侧优化前的总执行开销为 13，借助并行分解模式，将两个代码块并行执行，优化后的总开销降为 11。

不过从图上我们也能看出，右侧优化后的应用占用的 CPU 资源为 1+2+4+2+5=14，总执行消耗资源变多了。但从用户的角度来看，其实是可以感知到处理时延变低了。

下面我们来看一个具体的例子，这是一个使用并发性能模式前后的实现代码对比样例（为了简化代码内容，我隐藏了 loadStudents() 和 loadTeachers() 的具体代码）：

void loadStudentsAndTeachersBeforeOptimize()
{
    loadStudents(); //加载学生信息列表
    loadTeachers(); //加载老师信息列表
}

void loadStudentsAndTeachersAfterOptimize()
{
    std::thread first(loadStudents); //创建独立线程加载学生信息列表
    std::thread second(loadTeachers); //创建独立线程加载老师信息列表
    first.join();
    second.join();
}

如上述代码所示，loadStudentsAndTeachersBeforeOptimize 中，两个代码块 loadStudents 和 loadTeachers 之间是可以并行执行的。loadStudentsAndTeachersAfterOptimize 中使用了两个独立线程，分别加载学生和老师信息，从而加速了系统的处理时延。

注意，这里我使用线程来举例，只是为了给你说明并行模式的核心价值，并不是只有线程可以提升并发。要知道，并行任务分解模式通常是站在系统运行的视角来审视系统，寻找并发增益的。在第 2 讲中我已经给你介绍过并行系统设计的相关话题，你可以去回顾复习下。

我给你举两个典型的场景案例吧：

很多大型数据库的计算引擎就是典型的应用案例。在大数据处理场景中，根据数据拆分、并行计算再规约的典型框架，本质上就是通过增加计算资源实现处理时延降低的目标。

基于控制流的并发处理也很有价值。这里我以 Web 后端服务的异步机制为例，很多后端服务支持系统同一时刻触发多个 REST 请求，通过并行异步回调处理，从而就在很大程度上降低了后端的处理时延。

不过，并行分解模式使用会引入一些额外的挑战。一般来说，我们习惯使用串行编程思维来解决性能问题，这时心智模型会比较简单，而修改为并行方式后不仅程序的复杂度会提升，还很容易引起很多并发互斥问题。

而且并不是并发度越高，系统的性能就越好，人月神话就是一个很好的反例。一个项目的实现可能需要 10 人 / 月，但这并不意味着让 300 人来做就可以在一天内完成该项工作。

OK，接着这个思路，我们再来思考一个问题：在单核场景下，有必要创建线程吗？

我认为实际上，在单核场景下你可能也需要创建线程。早期的操作系统在创建线程时，主要想解决的是 CPU 资源共享的问题。当时线程工作的特点是：少许 CPU 操作和较多异步 IO 操作，在这种场景下创建多个线程时，能够实现当某个线程阻塞时，CPU 资源可以分配给其他线程，从而提升系统的整体性能。

但随着计算机 CPU 硬件多核技术的普及，在很多场景下，线程的工作目标主要在于充分发挥 CPU 硬件多核的价值，线程的工作特点也向不间断执行 CPU 指令的方向演进，因此并行设计的架构与模式也发生了很大的变化。

批处理模式

好，我们接着来学习第三种性能模式：批处理模式。

这里我们先来思考一个问题：当我们去超市购买商品时，每次只买一件商品，然后分别购买 10 次所用的时间，跟去超市一次性买足 10 件商品使用的时间，是不是肯定不一样？。

在计算机领域中也是同样的道理，同样的计算任务，分别执行 10 次和一次批处理 10 个，它们需要的时间也是不一样的。所以找出这样的典型业务场景，通过采用批处理方式，就可以很大程度地提升系统性能。

接下来我们就具体看看这种性能模式的优化特点：

如上图所示，左侧优化前循环中第一个矩形代码块执行开销为 2。这个操作在循环体执行了 10 次，需要消耗的计算资源为 20，当修改成批量计算后，执行开销从 20 降低到了 5，从而极大地提升了性能。

另外，从图上我们也能看到，左侧优化前的执行开销为 60，而优化后开销为 45，总的性能提升也比较明显。

同样地，我们还是来看一个具体的例子。这是一个用 C 语言实现的案例，案例中对比了循环遍历处理数据和批处理两种不同的代码实现：

typedef struct Student
{
    int age;
    char name[20];
} Student;

Student students[10];

void init(Student *student)
{
    student->age = 0;
    memset((void*) student->name, (int) 0, sizeof(student->name));
}

void initAllStudentsBeforeOptimize()
{
    for (int i = 0; i < 10; i++)  // 循环遍历10次初始化操作
    {
        init(&students[i])
    }
}

void initAllStudentAfterOptimize()
{
    memset((void*) students, 0, sizeof(students)) //memset一次性批处理
}

可以看到，优化前 initAllStudentsBeforeOptimize 会遍历初始化所有的学生信息，而优化的代码后变成了一次性初始化所有的学生信息。

当然，这个代码只是为了让你更好地理解 1+1 不等于 2 的背后逻辑（请忽略代码中的魔术数字等代码坏味道，这里仅作演示）。同样地，我们也不能只从 CPU 执行时间的视角来看待批处理模式，还可以在内存使用中去整合批量数据的保存，然后通过编码带来很大的内存使用增益。

这里我给你举两个典型的使用场景的例子：

针对分析数据库来插入批量数据。对于一些分析性数据库来说，插入一条数据与批量插入上千条数据所使用时间很接近，因此使用批处理模式会带来非常大的优化杠杆。

针对网络传输、消息队列传输场景，批量处理模式效果也很明显。发送消息准备时间（如建立连接时间）、结束时间等，都可以通过批量数据发送平均后优化到很小的占比。

当然，批处理模式也存在一些限制。当修改为批量模式之后，会导致部分业务数据的处理延迟增加；同时，批处理模式处理失败所造成的影响通常也会比较大。比如说，你在修改文件的过程中，如果积攒了很多次修改还没有保存，万一突然关机，那么你丢失的信息也就比较多。

最后我们再来思考一个问题：修改为批处理模式后导致系统的可靠性变差了，怎么办？

我是这样看待这个问题的，首先，并不是所有的批处理模式都会造成软件复杂度上升，有些甚至会简化软件的实现；其次，当某些批处理模式导致软件复杂度上升时，我们就需要权衡软件复杂度上升所带来性能收益的大小，判断下是否值得这样做。

但最后，我还想特别说明一点，并不是复杂的软件可靠性就差，比如大型嵌入式通信系统、银行交易系统等，它们的业务逻辑都非常复杂，但依旧有非常高的可靠性。

弹性时间模式

在介绍这种性能模式之前，我们还是来思考这样一个场景：对职场人士来说，从家到工作单位的距离是不变的，车辆动力也是不变的，但在不同的时间段开到公司的时间却是不一样的。因为上下班时段高峰期，交通拥塞会导致车辆的行驶速度变慢，从而花费的时间就会变长。

回到计算机领域，系统的性能也是由软件和硬件的性能整体决定的，软件服务和硬件也都具有相同的运行特征。在相同的网络带宽下，当链路消息堆积拥塞时，处理时延就会变长。

而弹性时间模式，正是通过离散化业务的请求时间，从而避免系统中的单个软件服务和硬件服务出现拥塞的情况。我们来看下它的具体工作流程：

你会发现，上图中诡异的地方是，优化前第一个矩形代码块的执行开销为 3，只是调整了开始执行时间，优化后的执行开销就降成了 2。

其实这并不神奇，弹性时间模式的原理只是避免了同一时间段上，系统中某个节点发生拥塞而已。

现在我给你举个具体的例子。在下面的代码示例中，我对比展示了确定时间顺序执行和使用弹性时间执行的两种代码实现的差异。

void secondLevelScheduleTaskBeforeOptimize(int second)
{
    syncSendMsgToA();
    normalwork();
    syncSendMsgToB();
}

void secondLevelScheduleTaskAfterOptimize(int second)
{
    if ((second % 2) == 1)
    {
        syncSendMsgToB();
        normalwork();
        syncSendMsgToA();
    }
    else
    {
        syncSendMsgToA();
        normalwork();
        syncSendMsgToB();

    }
}

我们知道，系统中同时存在多个进程，且每个进程内都有一个相同的时间点定时处理任务，这种现象是比较普遍的。在函数 syncSendMsgToB() 中，使用消息队列或者网络是被很多进程所共享的，如果在同一时间段内，多个进程同步发送消息，就会因为堵塞导致处理时延增大。

而这里我们可以发现，优化后的代码只是随机调整了 syncSendMsgToB() 和 syncSendMsgToA() 的位置，就可以很大程度上缓解消息拥塞造成的额外时延开销。

一个典型的使用场景，就是系统中针对网络带宽、数据库等平台基础服务，在访问各种定时任务时，通常需要在时间上离散分布来避免拥塞。

而弹性时间模式存在的局限性就在于，在时间、空间上离散分布来提升性能，都需要建立在数据度量的基础上。而且在时间、空间上的离散分布也有可能是不稳定的，随着新业务功能的引入，就可能会导致原来的离散分布调整失效，从而需要进一步地调整。

看到这里，你可能会想到一个问题：使用任何软硬件资源都要考虑弹性时间分布吗，太麻烦了吧？

其实也不是，一般来说，当这种资源的使用直接影响到了业务的核心逻辑，并对性能影响较大的时候，我们才需要考虑使用弹性时间的处理方式。

以前我参与设计实现的某通信协议媒体面中，需要与周边三个子系统进行大量信息传递，要共享消息总线。该系统对实时性要求非常高，必须要求与不同子系统在总线上消息交互在时间上错开，才能满足实时性，因此需要定义很严格的消息总线使用时序。但该系统通过网络端口发送的消息，对实时性要求不高，所以就不需要进行弹性时间分布。

小结

性能模式名字并没有像软件设计模式，成为开发人员的通用沟通语言，所以你要重点关注性能模式背后所代表的解决思路与原理，而不是名字。另外，性能模式的抽象粒度可大可小，你可以在不同抽象层级去使用这些模式，但我更推荐在更大的抽象级别去使用性能模式。

性能模式是建立在系统运行视图的基础上，因此你需要对软件执行模型有深入的理解，同时还要借助数据度量来辅助分析，否则很容易引起适得其反的效果。

思考题

面向对象设计模式基于 SOLID 原则选择，SOLID 原则基于正交设计，而正交设计的底层原理又是高内聚、低耦合的，那么你认为性能模式底层的基础逻辑是什么呢？

欢迎给我留言，分享你的思考和看法。如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

10 | 性能模式（下）：如何解决核心的性能问题？

你好，我是尉刚强。今天我们接着上节课的话题，继续来学习另外 4 种性能模式，分别是预计算模式、耦合模式、搬移计算模式以及丢弃模式。

现在我们已经知道，性能模式是为了提升性能指标，针对软件设计与实现的一种调整方法和手段。理解了这些性能模式，我们就能够在优化系统性能的过程中，快速找到调整设计实现的出发点与思路。

在开始这节课的学习之前，我还想给你强调两点：

首先，与设计模式一样，每种性能模式都只是解决特定业务场景下的性能问题，如果你使用不当，很有可能会取得反效果。所以你一定不要局限于这几种性能模式，而是要掌握这种解决性能问题的思路。

其次，基于性能模式对软件设计实现的调整，它带来的性能收益其实并不是确定的。因此，在做调整优化前，你需要通过测试获取性能提升收益的准确数据后，再去权衡考虑是否真的需要修改，这样也有利于节省成本。

好了，接下来我就从预计算模式开始，来带你了解下它的设计原理和工作机制。

预计算模式

我们可以先来试想下这样的场景：小明喜欢在家吃早餐，但又不想太早起床，所以他选择了前天晚上就把菜洗好切好，这样早上起来直接炒一下就可以吃了，节省了早上要花费的时间，可以多睡一会儿懒觉。

那么回到软件实现的业务逻辑中，是否也有一些计算逻辑可以提前执行呢？

当然有，下面我要介绍的预计算模式，就是通过挖掘出提前计算的业务逻辑，并在程序启动前执行完毕，从而有效地提升了业务的处理速度。

好，现在我们来看下预计算性能模式的具体工作流程：

在图上左侧优化前的代码中，第一个矩形代码块的执行开销为 3。通过分析重复的计算逻辑，发现其中一部分的计算逻辑可以提前执行。针对这种场景，我们就可以将这部分计算从业务中剥离掉，使用预计算模式提前到程序启动前来执行。

现在我们来看一个具体的例子。如下所示的代码示例中，实现的功能是根据员工请假天数来计算当月薪水，其中 calcSalaryBeforeOptimize 代表优化前的实现，calcSalaryAfterOptimize 代表优化后的实现：

public class ClacOptimze {
    public static int calcSalaryBeforeOptimize(int leaveDay) {
        int fullSalary = 12000;
        if (leaveDay <= 1) {            //使用条件判断，潜在分支预测失败
            return fullSalary;
        }
        if (leaveDay < 5) {
            return fullSalary - leaveDay * 400; //使用了乘法运算逻辑
        }

        return fullSalary - leaveDay * 800;

    }

    final static int[] salarys = { 12000, 12000, 11800, 11600, 11400, 11200, 10800 };
    public static int calcSalaryAfterOptimize(int leaveDay) {
        return salarys[leaveDay];  //这里使用查表
    }
}

可以看到，在优化前的代码实现中，需要进行多次 if 判断，还需要进行乘法运算；而优化后的代码中，每次运行只需要查表就可以返回，执行速度会快很多。

实际上，以上代码示例所使用的预计算模式，采用的策略是通过空间换时间，这是预计算性能模式实现过程中比较常见的一种方式。

在通常情况下，针对预计算工作量比较小的方式，我们完全可以手工计算，但当计算量比较大时，我们可能还需要开发单独的针对预计算的程序。当然，预计算模式并不是只有空间换时间的实现方式，还有很多种实现并不会带来额外的内存开销，比如业务中内存的预申请、业务数据的预初始化，等等。

另外，还有一些编程语言提供了编译期计算的能力，针对这种场景，我们也可以将计算逻辑提前到编译期执行，来减少运行期的时间开销。比如 C++ 的常量表达式、模板泛型编程等，都提供了比较强大的编译期计算能力。

这里我给你举个真实的例子。我曾经参与过一个 SaaS 服务时延的优化项目，就使用过多次在数据库中添加冗余数据来记录预计算结果，从而减少了业务处理运行期开销，达到降低时延的效果；此外，在嵌入式实时性的优化中，我们还通过挖掘业务中所有预计算逻辑，多次帮我们大幅度提升了产品性能。

不过在使用预计算模式时，你还需要注意一点，就是当需要对计算逻辑进行比较大的调整时，你需要进行完备的测试，以免引入新的故障。

耦合模式

好，我们接着来看看耦合模式。

这种性能模式的原理其实非常简单，我就拿出行服务来给你举个例子。我们知道，出租车司机在开车运营期间，喜欢选择拼车模式同时接送多位乘客，因为当乘客路线重合比较多的时候，他们就可以获得更大的现金收益，而这就是使用了耦合模式的解决思路。

所以，耦合模式的意思就是当你做一件事情的时候，不要把目光单独停留在这一件事情上，你还可以思考下是不是可以顺带把其他事情也一并处理掉。

这里你可能马上就会想到，这与面向对象设计原则中的“单一职责原则”有冲突啊？的确，耦合模式在一些场景下会与单一职责存在冲突（单一职责推荐一个方法只实现一个功能，而耦合模式需要一个方法内同时实现多个功能），所以我更推荐你只在性能影响权重比较大的关键场景中使用它。

现在，我们先来了解下耦合性能模式的优化过程：

可以看到，图中左侧粉色的两个代码块是相对独立的，执行开销分别为 2，在优化过程中将两个代码块逻辑合并到一起后，执行总开销变为了 3。这样在使用这种方式优化后，系统的总执行开销就从原来的 12 降低到了 11，处理时延也就降低了。

这里我们来看一个具体的例子。下面是一个 Java 使用 MyBatis 访问数据库场景的代码片段，其中 UserMapperBeforeOptimize 代表的是优化前访问数据库的接口，UserMapperAfterOptimize 则代表优化后访问数据库的接口。

注：代码中我省略了很多关于数据库的相关配置与代码，因为这对理解耦合模式并无太大帮助。

public class User {
        private String name;
        private Integer age;

        public String getName() {
        return name;
        }

        public void setName(String name) {
        this.name = name;
        }

        public Integer getAge() {
        return age;
        }

        public void setAge(Integer age) {
        this.age = age;
        }

}

public interface UserMapperBeforeOptimize {
    public String findNameById(String Id);  //单一职责接口
    public String findAgeById(String Id);   //单一职责接口
}

public interface UserMapperAfterOptimize {
    public String findNameById(String Id);  //单一职责接口
    public String findAgeById(String Id);  //单一职责接口
    public User FindUserById(String Id);  //新增的获取多个字段的耦合接口
}

可以发现，优化之前的接口中包含了两个方法：根据 ID 获取名字、根据 ID 获取年龄。当很多的客户代码同时需要获取名字和年龄时，就可以通过在接口中增加一次性返回姓名和年龄信息的方法，来减少业务两次访问数据库带来的网络和查询的额外开销。

耦合模式的应用场景比较多，比如说：

在数据库设计的过程中，基于性能考虑，我们可以将多个表中的字段信息融合记录到一个大表内，从而实现原来需要多次查询操作，变成一次查询就全部获取。

在微服务接口设计中，通常 REST 接口并不是正交的，其中会包含一些基本功能接口和一些复合功能接口。而在一些典型的性能优化场景下，使用复合接口就可以一次实现原来多个基本功能接口请求的功能，从而就能通过减少 REST 接口调用次数来优化性能。

在嵌入式场景中，子系统间交互使用的 TLV（Tag、Length、Value）数据结构类型，也是典型的耦合模式的应用。

另外，耦合模式也并不局限在接口层面，你也可以在计算逻辑中去使用。同时你需要注意，在实现包含复合功能的接口与业务逻辑时，不建议删除掉原来的单一功能实现，这是为了防止对只使用简单接口与功能的客户带来额外的开销。

搬移计算模式

不论在工作还是生活中，你可能很擅长基于时间来统筹安排每个时间段中要做的事情。那么在软件的业务计算过程当中，你同样可以基于性能和效率考量，去调整安排计算逻辑在运行时间与物理位置上的分布。

在实时高性能的系统中，软件工程师在设计时通常会把业务逻辑划分为关键路径和非关键路径。而我们知道，系统时延在更大程度上取决于关键业务逻辑的处理时延。

所以，如果你可以把计算逻辑从关键路径搬移到非关键路径，就可以提升产品的性能。

在使用搬移计算模式来调整计算业务逻辑的过程中，你要重点关注的是处理时延的性能提升。但你也要注意，在这样优化处理之后，其实系统的总负荷通常并没有减少。所以，你可以在系统核心目标是追求用户侧的时延最小化的业务场景中，选择使用这种性能模式。

下面我们就来看一下搬移计算模式的具体实现流程：

在图中左侧第一个矩形执行代码块中，我们通过分析业务流程和度量数据，发现部分业务逻辑可以推后计算，于是把这部分业务拆分到另外一个任务中去执行，从而就减少了客户关注的处理时延。

这里我给你举一个真实的使用案例。在互联网 SaaS 服务中，对我们这样的普通用户而言，会对请求的响应时延十分感兴趣，而并不关注服务器处理业务的全部处理时间，所以这时候，我们就可以使用搬移计算模式来进行优化。因此，我曾经就在这类项目的优化过程中，引入了延迟计算服务，并通过对业务逻辑优化，剥离了非关键业务，交给延迟计算任务进行处理，从而实现了在短时间内，将时延性能指标提升到 40% 以上的目标。

而除了与 SaaS 服务相关的业务场景，在嵌入式场景和云服务场景中，使用搬移计算模式优化性能也都能取得很好的效果。但是你需要认识到，搬移计算的设计与实现其实容易引起整个系统的复杂度提升，进而也容易引入额外的故障，所以你在引用这种性能模式时，一定要注意进行充分的功能验证与性能优化的提升分析。

丢弃模式

好，现在我们来学习下最后一种性能模式，也就是丢弃模式。

你可以先来试想这样一种场景：在正常情况下，你每天早上起来上班前，都会洗脸、刷牙，然后再带上电脑出门；而当某天起来晚了，你可能就会把洗脸、刷牙这步省掉了，直接背着电脑出门。

所以发现没有，我们做的很多事情在特定场景下其实都是可以丢弃的。

那么回到软件业务处理的过程中，也是一样的道理。在软件系统中，我们在一些特殊的场景下使用丢弃模式，就可以达到非常好的性能优化效果，这里我们先来了解下它的具体工作流程：

在上图的左侧部分，可以看到第一个矩形代码块的执行开销为 3，而我们通过分析业务流程和度量数据，发现这个功能的优先级比较低。因此可以使用的优化策略就是：把优先级比较低的代码块放到业务最后，在极端场景下，也可以通过直接丢弃不处理来保证系统性能不恶化。

丢弃模式，在实时嵌入式的场景中使用的比较多，这种模式非常好理解和操作。你在实际的业务场景下，要注意先识别出业务中的非关键部分逻辑，确认其支持可关闭，这样当系统处于超负荷运行时，就可以直接将这部分业务停掉。

小结

通过这两节课的介绍和讲解，现在你应该就能理解这八种性能模式的核心优化思想了。这里我想提醒你一点，在前面的代码示例中，我使用的是非常小的代码来进行演示的，因此并不建议你在实际代码实现的细粒度级别中去使用这些代码例子。

最后我想说，性能模式是软件优化中非常重要的手段之一，但是很多的性能优化工程师，目光只停留在编译层面的优化性能，而忽略了最直接且高效的性能模式。所以我希望，你在碰到具体的性能问题与挑战时，可以运用今天学到的性能模式，并可以在较大的业务粒度上使用，来帮助产品提升性能竞争力。

思考题

在你以往参与的性能优化项目中，有没有也使用过一些巧妙的处理模式，却带来了很明显的性能提升呢？

欢迎在留言区中分享你的答案，我们一起交流讨论。如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

11 | 如何针对特定业务场景设计数据结构和高性能算法？

你好，我是尉刚强。今天这节课，我们来聊聊数据结构与算法。

可能在看到这节课的标题后，你会觉得有点儿奇怪：好像在平时的编码过程中，已经不太需要单独去关注数据结构和算法了，为什么还需要再根据场景设计数据结构和算法呢？

有这样的想法也无可厚非，因为我们确实会发现，在实际的业务领域内，需要我们开发人员直接设计数据结构与算法的机会越来越少。比如说：

在互联网服务场景中，性能开销主要集中在数据库 CRUD 操作上，所以很少会关注业务内数据结构与算法设计的性能；

随着更多的核心业务算法内置到了芯片当中，对于从事嵌入式研发的工程师来说，主要工作就聚焦在了管理配置各种硬件资源上，因而并不会经常设计和使用数据结构与算法；

很多语言与标准库中已经内置了丰富的数据结构与算法，并不太需要开发人员手动去设计和开发；

……

但事实上，我们以往所采用的性能优化手段（如热点代码分析优化、编译器优化等），对于系统性能的提升其实是按照百分制计算的，这是一种线性粒度的性能提升。我举个简单的例子，如果你在代码 Profiling 分析后，识别出了一个频繁调用的热点函数，将它内联或者优化后性能提升能够达到 3%~5%，就已经属于非常明显的优化提升了。

而通过数据结构与算法的设计来改进的系统性能，其获得的性能收益很有可能是非线性的，甚至可能是指数级的。就拿典型的查找问题来说，使用链表的遍历查找算法和数组向量的二分查找算法，在查找速度上性能可能会相差好多倍呢！

所以，合理设计数据结构与算法，对于软件系统的性能提升来说至关重要。

当然，可能你已经系统学习过了数据结构与算法，对相关知识原理都有比较深入的理解，也可能你对现有数据结构与算法的了解比较有限，但这都不会影响到你学习这节课的内容。另外，数据结构与算法包含的内容非常多，我不可能在一节课里介绍完整，市面上也已经有不少相关的课程书籍，我也没有必要再重复讲解。

这节课，我只聚焦于一个视角，那就是根据业务开发中数据特征和计算逻辑的典型差异，从性能维度出发，系统性地选择和设计数据结构与算法，以此帮助你在软件编码的过程中，更容易开发出高性能的软件。

那么接下来，我就从分析计算机软件执行原理开始，带你去了解选择不同的数据结构与算法，都会给系统性能带来什么影响。

数据结构与算法选择对性能的影响

谈起数据结构与算法的开销，可能第一时间你会想到大 O 标记表示法。这的确是一个非常重要的算法复杂度表示方法，但这并不是衡量数据结构与算法性能的全部。

事实上，衡量数据结构与算法的实现复杂度，有几类比较常用的指标，包括最优时间开销、最差时间开销、平均时间开销、空间使用开销、摊销时间开销。

这里你可能要问了：平均时间开销是决定系统负载的一个关键指标，所以是不是只要重点关注平均时间开销就可以了？

实际上并不是，不同业务场景关注的指标都是不同的。我给你举几个例子，你就明白了：

针对内存资源极度受限的业务场景，对空间使用开销的关注度更高；

针对实时性要求非常高的场景，通常重点关注的是最差时间开销，而平均时间开销的意义并不大；

针对关注最大吞吐量的业务系统，这时的平均时间开销就变成最重要的指标了。

所以说，我们不要只关注平均时间开销，而是要关注对业务更有价值的指标。

那么接下来，你或许还会产生这样的疑问：是不是只根据算法复杂度去选择算法就可以了?

答案是不可以，相同的算法复杂度并不代表相同的性能。你要知道，性能还会受到软件编码实现方式、数据结构存储特性等多方面的影响。比如对于二分查找算法而言，基于循环遍历的实现与基于递归调用的实现，二者在性能上就会存在很大差异。

这里我给你举一个具体的例子。

注：虽然该示例中使用的是 C++ 语言和 STL 库，但解释的原理与具体语言无关。

首先我们来看一个类定义，其中包含了一个构造函数和比较运算符，代码如下：

struct Kv
{
    char const *key;
    unsigned int value;
    Kv(const char *key, unsigned int value) : //构造函数
            key(key), value(value)
    {
    }
    bool operator==(Kv const &rht)  // 比较运算符，当两个对象实例比较时使用
    {
        return (strcmp(key, rht.key) == 0) && (value == rht.value);
    }
};

那么针对这个类，我选择了两种数据结构进行记录，然后使用相同的查询算法来对比性能。

第一种数据结构类型为数组：

Kv arrayKvs[] = {...}

然后，使用 STD 标准库中的线性查找算法，算法复杂度为 O(n)，如下所示：

Kv *result = std::find(std::begin(arrayKvs), std::end(arrayKvs),  Kv("bbb", 2));

第二种数据结构类型为链表：

std::list<Kv> listKvs;

然后，这里我使用的也是标准库中的线性查找算法，算法复杂度为 O(n)，如下所示：

std::list<Kv>::iterator result = std::find(listKvs.begin(),listKvs.end(), Kv("bbb", 2));

到这里，你可以先思考一下，以上两种实现选择了相同的算法，实现复杂度一样，那么其性能表现是一致的吗？

显然是不一致的。当使用数组时，顺序访问数据的局部性高（数据内存地址是连续的）；而使用链表时，由于链表中的元素位置不相邻，而且数据不连续，就潜在导致了内存 Cache Miss（缓存未命中）的概率显著增大，从而造成性能开销变大。

所以说，单纯的算法复杂度实际并不能准确地反映性能，数据结构对性能的影响也很大，而这部分并没有很好地在算法复杂度上体现出来。

OK，最后我们再来思考一个问题：选择数据结构与算法之后，软件性能就决定了吗？

答案也是否定的，因为数据结构和算法转换成的二级制代码执行是否高效，会受到很多因素的影响，比如编码实现、编译优化等。这里咱们再来分析一下上述业务场景中的比较逻辑：

bool Kv::operator==(Kv const &rht)
{
    return (strcmp(key, rht.key) == 0) && (value == rht.value);
    /*先比较字符串key, 再比较数字value */
}

如果这个类的所有节点数据中，几乎所有的 value 值都不相同，而且 key 长度比较大，那么我们可以调整下代码中的比较顺序，因为整数比较的效率更高，还可以进一步提升性能。

总而言之，数据结构与算法的不同编码实现过程和方法，对软件的性能来说很重要，你在软件实现过程中，不仅要关注数据结构和算法的选择，还需要关注它的具体编码实现过程，这样才能真正开发出高性能的软件。

好了，在理解了数据结构和算法如何影响软件性能之后，下面咱们就进一步来探讨，如何根据领域数据的特征来选择对应的算法。

根据领域数据特征去选择算法

可能你之前已经发现了，我们从教科书上学习的数据结构与算法，通常都是标准的，但是在解决具体的业务问题时，我们需要处理的数据与算法却经常不是标准的。

怎么个不标准法儿呢？我认为主要体现在以下两个方面。

一方面，很多场景的领域数据是不标准的。

在大 O 标记法中，有一个假设是任意数据集上，通过软件所实现算法的运行时间基本相同的，但其实不少算法对数据的特性是非常敏感的。比如针对排序算法，如果待排序的数据集已经很接近有序状态，那么相比快速排序，选择直接插入排序算法的优势会更大。

我们来看一个例子。假设有一个数据集，它的特点如下：

数据集规模为 10 万条；

数据集完全乱序；

这 10 万条数据中，有 1/3 数值小于 1000，另外 1/3 数值在 1000 到 2000 之间，还有 1/3 的数值是大于 2000 的。

那么现在，你需要对这个数据集进行完整排序，应该如何选择算法呢？

如果你没有关注到第 3 点特征，选择一个非常高效的排序算法后，其实也可以将算法复杂度降低到O(N*log2 N)。

但是当你意识到了第 3 点特征时，以上的排序过程就可以拆分为 3 个子数据集排序，然后再将排序结果合并到一起。而基于这种方式实现后，算法复杂度就可以降低到O(N/3*log2(N/3)) * 3 = O(N*log2(N/3))，从而就可以进一步提升性能了。

除此之外，针对上面这个业务场景，我们也很容易能想到，采用并发模式将数据集中的 3 个子数据集的排序过程，通过子任务并发起来，从而就能进一步降低业务的处理时延。

所以说，我们一定要认真挖掘领域数据的各种特性，只要挖掘的领域数据中的特性越多，其潜在的优化数据结构与算法的性能空间也就越大。

另一方面，业务算法通常是不标准的。

要知道，除了领域数据不标准之外，业务场景中的算法通常也不是标准的，所以我们就要根据具体的业务逻辑设计算法，才能最大化地提升性能，而不是仅仅照搬现成的标准算法实现。

我给你举个真实的例子，这是我曾经参与设计的一个资源调度子系统中的算法案例。不过为了方便理解，我把问题做了简化抽象，也就是如何在 1000 个用户中，根据优先级选择前 10 位用户进行资源分配。

那么碰到这个问题，你选择的算法方案会是什么呢？比如，是否会是以下两种方案：

方案 1：根据 1000 个用户的优先级进行全排序，然后选择前 10 个；

方案 2：使用冒泡排序算法，对 1000 个用户全遍历 10 次，选择前 10 个用户。

如果你选择方案 1，那么你将会浪费很多无谓的计算机资源，性能注定会非常差。而这个时候，你可能就很容易地想到了方案 2，觉得这个方案效率很高。那么方案 2 会是最佳的解决方案吗？

显然也不是，我们再来看看另外一个方案：

方案 3：首先选择前 10 个用户作为优先级最高的 10 个，然后对 1000 个用户全遍历一次，当某个用户的优先级超过这 10 个用户时，就更新至前 10 个用户中。

现在你可以来想想看，方案 3 在性能上是否会优于方案 2 呢？或者还有其他的算法实现吗？相信在认真思考了这些问题之后，你就迈出了基于业务选择和优化算法的第一步。

而实际上，对于这个案例来说，因为它的业务计算逻辑是比较特殊的，所以我们就需要针对典型计算逻辑，来单独设计算法实现逻辑。因此，最后我们选择了方案 3，使用针对前 10 位用户的资源分配，取得了比较好的性能效果。

OK，在根据业务逻辑定制化设计算法和实现之后，我们还需要综合权衡各种典型操作，才能选择出最符合业务逻辑的数据结构与算法，所以下面我们就具体来看看吧。

权衡综合各种操作选择数据结构与算法

我们知道，数据结构和算法之间通常是一对多的关系，在业务中，针对同一个数据结构可能会有排序、搜索等不同的算法业务逻辑。但是，同一个数据结构在不同的算法上性能差异是比较大的，所以这时候，我们就需要去综合各种功能操作，再选择数据结构和算法。

举个简单的例子，对于数据结构，很典型的方法就包括了删除、增加、查找元素等。当然数据结构还可以有很多其他方法，但是每种方法的操作频率都不一样，优先级也不同，比如说：

有些业务场景，插入和删除操作非常频繁，而查询操作很少，选择链表类数据结构保存会比较适合；

有些业务场景，插入和删除操作非常少，而查询操作很频繁，因此考虑选择数组类数据结构，系统的性能会比较好；

另外，当查询操作非常频繁时，可能还需要考虑对数据保持实时排序，从而进一步提升性能。

所以，为了更好地权衡，我们在设计数据结构与算法时，有时候甚至需要同时选择多种数据结构来记录数据。比如，把绝大部分的稳定数据保存在序列数组中，针对偶尔变更的数据记录保存在链表中，毕竟业务中并没有限定必须要使用相同的结构类型，保存相同类型的数据。

那么为了更直观地说明从业务操作的不同频率出发，选择数据结构与算法的意义，这里我就通过两种比较典型的数据库类型的设计原理，来给你举例说明下。

第一种是分析数据库，比如 ClickHouse。它绝大部分的操作请求都会集中在批量数据分析上，所以在设计时，就必须保证批量数据分析的性能，而这样就会造成数据的修改性能开销比较大。

第二种是文档数据库，比如 MongoDB 等。不过很多时候，我们为了追求单文档级别的 CRUD 性能，就不得已在批量数据分析计算性能上做出让步。

实际上，针对大型业务系统，我们通常需要选择多种数据存储方案进行数据冗余，从而综合满足各种业务场景下的性能需求。同样，我们在业务内部设计数据结构与算法时，不能既要、又要，必须要作出取舍，只有在综合各种操作之后，才能权衡利弊进行选择。

学会降低算法精确度提升性能

好了，最后我要带你掌握的知识点，就是要学会降低算法精确度，以此来进一步提升系统性能。

我们都知道，算法通常都是精确的、严格的，但在很多业务场景下，我们并不需要那么高的精确性。就拿我自己来说，我过去参与的诸多项目中，有过太多次降低算法精度与性能之间的权衡，所以接下来，我也用一个简单的例子来给你说明下原因。

假设现在有一个已经排序后的链表：

std::list<Kv> SortedKvs;

然后，它在每个周期内都会有新数据输入，而在正常情况下，每插入一条数据都需要遍历寻找插入点，从而确保整个链表中的数据都是有序的。

这样通过分析业务发现，排序的正确性其实并不需要非常高，并且通过认真评估分析和验证后，我们发现其实可以把待插入数据首先放入链表的尾部，这样当积攒了 5 到 10 条待插入数据之后，再遍历一遍链表插入所有数据，通过这种实现方式，就可以将插入数据的运行开销降低数倍。

可见，在实际的业务场景下，我们一定要根据性能要求标准来选择合适的算法精确度。

OK，我们再来看看前面我介绍的那个资源调度例子，想一想，从 1000 个用户选择 10 个高优先级用户进行资源调度，还有没有其他降低算法精确度来提升系统性能的方案呢？

其实，我们可以将 1000 个用户拆分成 2 个组，每个组包含 500 个用户，然后交替在 2 个组内选择 10 个高优先级用户进行资源分配。这样通过在代码实现上的较少改动，就可以在性能上提升接近一倍。

但这里你要注意一点，就是你还需要验证调整后的算法实现是否满足了业务需求。有很多种降低算法精确度的实现方式，你需要准确分析并验证，选择背后的业务逻辑是否还能满足业务需求。

小结

在我的认知里，现成的数据结构和算法更像是一个工具库。相较于熟悉所有的数据结构与算法而言，我认为更重要的是如何理解业务，这样在权衡利弊之下，选择并优化的数据结构与算法才会更加合适。

而且我在从事人工智能算法设计的工作期间，也更加深刻地印证了这一认识，因为深入理解所有人工智能算法的价值是相对有限的，也不现实。

所以在最后，我想告诉你的是，我并不是要反对你系统学习各种数据结构和算法，而是我希望你能够懂得如何理解业务，然后从业务出发，主动选择与优化数据结构和算法。

思考题

选择不同的数据结构和算法，它们在并发模式下的性能和串行模型的性能差别大吗？

欢迎给我留言，分享你的思考和看法。如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

12 | 我们要先实现业务功能，还是先优化代码？

你好，我是尉刚强，今天我们一起来聊一聊高性能编码技术。

在做软件设计咨询工作的时候，我经常发现有很多高性能软件产品的研发团队，在软件开发阶段只关注和实现业务的特性功能，然后等功能交付之后，再开始花费很长的时间，对软件代码进行调整优化。

而且我在跟这些程序员接触的过程中，还观察到了一个比较有趣的现象，就是大家普遍认为在软件编码实现阶段，过早地考虑代码优化意义不大，而是应该等到功能开发完成后，再基于打点 Profiling（数据分析）去优化代码实现。

其实这个想法是否可取，曾经也困扰过我，但当我经历了很多低级编码所导致的性能问题之后，我发现原来高性能编码实现是有很大价值的，而且这能让我更好地处理编码实现优化与 Profiling 优化之间的关系。

所以今天这节课，我会和你一起探讨下应该如何去看待高性能编码这件事，然后我会给你具体讲讲，实现高性能编码的出发点和典型的最佳实践。通过今天课程的学习，你就可以建立起一套高性能编码实现的价值观，同时也会掌握实现高性能编码的思路和方法，从而支撑你开发出高性能的软件代码。

建立正确的高性能编码价值观

首先，提到高性能编码，你肯定听说过现代计算机科学的鼻祖高德纳（Donald Knuth）的那句名言：

We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%.

我们应该忘掉那些效率低下的事情，告诫自己在 97% 的情况下：过早优化是万恶之源。但是，我们也不应该在关键的 3% 上错过优化机会。

——Computer Programming as an Art (1974) P671.

不过我想，可能很多程序员都只记住了这句话的前半部分，“97% 的情况下，过早优化是万恶之源”，而没有注意到这句话还有后半句：我们不应该放弃掉那关键的 3% 的优化机会。

所以这样造成的后果就是：过度推崇不要对代码进行提前优化，并以此来作为编写低性能软件代码的借口。也就是说，现在我们在软件编码的过程中碰到的大多数问题，并不是由于过早优化导致的，而是因为在编写代码时对执行效率不关注所导致的。

其实，在编写代码阶段去追求高性能实现的意识非常重要，主要有两个原因。

第一个原因，就是可能原本只是一个很小的编码问题，却有可能会引起软件比较大的性能问题。

就比如说，我曾经参与的一个 C++ 高性能软件开发项目，因为一位研发人员在编码中不小心将函数行参的引用符号写丢了，导致函数调用开销增大，软件版本性能明显下降。而且这个问题还比较隐蔽，我们后面花了很大力气，通过在代码中增加了很多定位手段后，才发现问题。

所以高性能实现的第二个原因，就是一旦你错过了高性能编码这个窗口，将性能问题遗漏到软件生命周期的后期，很有可能会因为错过了当时编写代码的上下文，后面就很难再发现这个问题。这里，我也再给你举个例子来说明下。

可以看到，在如下所示的代码片段中，这个类的实例在接收到数据之后，会更新各个 Channel 中的数据量大小，然后对外提供了一个方法，来判断所有通道中是否存在数据：

public class ChannelGroup {
    class Channel{
        public String channelname;
        public int dataSize;
    }

    Channel[] channels;

    public Channels() {
        channels = new Channel[10];
    }

    public receiveData(...)  {....}  // 收到数据更新Channel中信息，省略
    public boolean hasData() {   // 判断所有通道是否有数据。
        for (Channel Channel : Channels) {
            if (Channel.dataSize > 0){
                return true;
            }
        }
        return false;
    }
}

那么在看完代码之后，你觉得这段代码实现中的方法 hasData，可以算是高性能实现吗？如果你只是根据这段代码实现来进行分析，会发现它好像没有啥性能问题。毕竟，针对一个只有 10 个元素的数组来说，使用二分法查找来提升查找速度的必要性不太大。

好，我们可以先暂且这样认为，然后再接着来做个假设：在一个真实的编写代码过程中，有这样一个潜在的上下文信息，那就是绝大多数的业务场景下是第三个通道中收到数据。

那么针对这种情况，如果你再使用从前向后的顺序遍历，肯定就不是性能最佳的实现方式了，而是应该先判断第三个通道中的数据。

所以通过这两个例子，你应该就明白了，如果在编码实现阶段并没有从高性能实现的角度出发，而是意图在后续通过打点数据分析来优化解决问题，几乎是不太可能的。

其实，就我的思考和实践经验来说，在开发一个高性能软件系统的时候，在编码阶段考虑高性能的实现方法，与完成业务功能后再进行代码调优之间并不矛盾，这二者应该要被同等地重视起来。因为前期的高性能编码实现过程，很多都是由人来主观控制的，所以可能会由于判断不准确或者实现过程不小心，引入一些低效率的代码实现。这样一来，后期通过热点代码分析以及代码调优的过程，就是不能省略的。

而且说实话，我心目中优秀的软件代码，应该是集代码简洁与性能于一身，而如果是对编码性能不屑一顾的话，我想通常这样的程序员也写不出非常高质量的代码。

好了，在理解了应该如何去看待高性能编码之后，接下来的问题就是，如何才能掌握实现高性能编码的方法，下面我们就具体来看看。

高性能编码实现方法

其实在软件开发的过程中，高性能编码实现的方法和技术非常多，不同的编程语言之间也会存在一些差异，很难在一节课中介绍完整。

所以，今天我主要是从编写的代码映射到执行过程的角度，来给你介绍四种高性能编码实现方法，以及对应的实现原则和手段，分别是循环实现、函数方法实现、表达式实现以及控制流程实现。在实际的软件编码过程中，你也可以根据这样的角度和思路，尝试去理解和分析软件代码的运行态过程，逐步积累和完善高性能编码的实现手法。

好，接下来我们就从循环实现开始，来看看这种高性能实现的原则和方法。

高性能循环实现

我们都知道，在编写代码的时候，循环体内的代码会被执行很多遍，所以它的代码开销会被放大，经常会出现在热点代码中。也就是说，如何实现高效循环是实现高性能编码最重要的一步。

那么编写高效循环代码的重要参考原则都有哪些呢？我认为主要有两个，下面我们就具体了解下。

第一点，尽量避免对循环起始条件和终止条件的重复计算。

为了让你更容易理解这个原则，我先带你来看一个高效循环的反例。在下面这个代码示例中，实现的功能是循环遍历并更新字符串中的值，你会发现在循环执行的过程中，strlen 被调用了很多次，所以性能比较低。

void updateStr(char* str)
{
    for(int i = 0; i<strlen(str); i++)
    {
        str[i]= '*';
    }
}

那么，针对这种情况，我们就应该在循环开始时，将字符串长度值保存在一个变量中，从而避免重复计算。修改好的代码如下：

void updateStr(char* str)
{
    int length = strlen(str);
    for(int i = 0; i< length; i++)
    {
        str[i]= '*';
    }
}

第二点，尽量避免循环体中存在重复的计算逻辑。

我们同样也来看一个反模式的代码示例。在下面这段代码的实现过程中，x*y的值并没有发生变化，但是在循环体中被执行了很多遍。

void initData(int[] data, int length, int x, int y){
    for(int i = 0; i < length; i++)
    {
        data[i] = x * y + 100;
    }
}

因此，站在高性能编码实现的角度，我们可以把x*y的值的计算过程搬移到循环体外部，从而减少这部分的冗余计算开销。

其实到这里，你可以记住一句话：编写高效循环代码的本质，就是尽量让循环体中执行的代码越少越好，剥离掉所有可以冗余的重复计算。

那么在具体的代码实现中，需要检查的循环优化点其实还有很多。比如，你还需要检查是否有重复的函数调用、多余的对象申请和构造、多余的局部变量定义，等等。所以，在编写循环代码时，你需要注意识别并剥离出这样的代码实现。

高性能函数方法实现

实现高性能的函数方法，有两个重要的出发点：尽量通过内联来减少运行期函数调用，尽量减少不必要的运行期多态。接下来，我就来给你讲解下为什么要从这两个点出发，以及该如何去做。

第一点，尽量通过内联来减少运行期函数调用。

所谓的“通过内联”，意思就是将代码直接插入到代码调用中执行，以此减少运行期函数调用。那为什么要减少生成真实的运行期函数呢？

这是因为函数调用本身会产生一些额外的性能开销。在函数调用的过程中，需要先把当前局部变量压栈，在调用结束后还需要出栈操作，同时还需要更新相关寄存器。所以当函数体内的逻辑很小时，所产生的额外开销占比会比较高。因此，针对比较小的函数方法，我们可以尽量采用内联实现，从而减少不必要的调用开销。

其实不同的编程语言，支撑函数方法内联的语法和机制有一定的差异。在 Java 语言的开发过程中，我推荐你尽量使用 final 来定义方法，因为这种场景下，Java 的 JIT 会有比较大的概率将这个代码方法内联掉。

而在 C++ 中，针对一些热点小函数，你可以使用 Inline 关键字来定义方法，这样就可以显式地告知编译器尽量将代码内联掉。

补充：在早期 C 语言的开发过程中，因为没有内联语法，程序员还经常使用编译宏来定义方法，以此减少真实方法的调用开销。

但是，最终编译器或解释器是否可以将代码内联掉，还会有很多隐式约束条件，所以你需要在编码实现中多加注意。

第二点，尽量减少不必要的运行期多态。

多态的本质就是函数指针，它需要在运行过程中获取内存中变量的值，来判断代码执行需要跳转到哪个位置。而这种运行期动态决定跳转地址，就很容易导致指令集流水线的中断，造成指令 Cache Miss 的概率增大，从而引起性能下降。

不过在 Java 语言中，因为类方法模式都是抽象的，所以我们可以将关键方法定义成静态方法，从而避免多态调用；对于 C++ 来说，在定义类方法的时候，我们可以根据需求来决定是否需要使用抽象方法，以此减少不必要的多态；而在 C 语言中，我们可以通过尽量避免使用不必要的函数指针，来减少运行期多态。

另外，在实现高性能函数方法的时候，还有一些要点你也需要注意，比如尽量避免递归调用、尽量减少不必要的参数传递，等等。不过这些都是高性能编程的常识问题，所以这里我就不展开介绍了。

高性能表达式实现

其实，现在的编译器针对表达式级别的优化支持能力已经很强大了，比如说，如果你在编写代码的过程中，使用下面的乘法操作：

int y = x * 128;

那么，对于高性能的编译器来说（如新版的 GCC 9.x 等），就可以将这个乘法操作优化为移位操作，从而提升执行性能。

但是，我们在编码的过程中，并不能完全去依赖这种编译器的能力，因为一方面是编译器的优化能力是有边界的，另一方面在编写代码过程中，编译器对表达式的优化也只是举手之劳。

所以这里，我给你总结了高性能表达式实现中几个比较重要的点，它们都属于简单的实现规则，你也可以在编写代码过程中参考注意下。

第一点，尽量将常量计算放到一起。

比如你可以看看下面的代码，这是一个包含了 3 个乘法运算的表达式：

int z = 32 * x * 432 * y;

那么，如果将常量乘法计算放到一起，就很容易在编译期优化掉，从而就可以避免执行时再计算。

第二点，尽量将表达式简化，从而减少冗余运算开销。

我们同样来看一个例子。在下面这段代码示例中，两个表达式的实现逻辑是一样的，都是先乘法再加法，但是你会发现，第二个表达式少了一次乘法运算，所以它的执行性能会更加出色：

 int z = x * x + y *x  ;  //两个乘法操作，一个加法操作
 int z = x * (x+y); //一个乘法操作，一个加法操作

第三点，尽量减少除法运算。

目前 CPU 中对除法计算的开销还比较大，因此如果可以优化为移位操作或者乘法操作，那么就都可以改善执行性能。

高性能控制流程实现

首先你要知道的是，控制流程代码在执行的过程中，CPU 执行会通过指令分支预测，提前将接下来的执行指令搬移到 Cache 中，如果预测失败，就有可能引起指令流水线中断，从而影响执行性能。

所以，你在编写控制流程代码的时候，就需要考虑一下如何才能更好地实现，以此来优化代码的执行性能。那么具体要如何做呢？

这里，我也给你分享一下我在实践过程中总结出来的经验，即尽量减少不必要的分支判断。这个原则是最重要、也是最容易被忽视的。为什么这么说呢？我们来看一个具体的例子。

在下面这段代码中，你可以发现 x==2 和 x==3 对应的分支场景都是一样的，但是它们也被放到了两个代码分支当中，所以这样执行起来不仅很低效，而且还存在重复代码：

 if ( 2 == x ) {   // 场景1
     printf("case 1");
 }
 if ( 3 == x ) {    //场景1
     printf("case 1");
 }
 if ( 4 == x ) {    //场景2
     printf("case 2");
 }

在日常的代码开发中，因为偷懒不想写一个组合的逻辑表达式，而增加分支逻辑的现象，其实是比较普遍的。所以说，我们在实际编写控制流程代码的时候，一定要注意尽量减少不必要的代码分支，这样才能有效地提升执行性能。

可是这里你可能还存在一个问题，就是如果深入去挖掘优化代码中的一些重复的分支逻辑，里面包含的门路还比较多。比如说，通过多态来避免代码中重复的 switch 分支逻辑，利用表驱动来减少 switch 逻辑和小的 for 循环平铺执行，等等。

所以这里，我给你一个小建议，就是在一些特殊场景下（比如 if 条件嵌套非常多的场景），你可以考虑使用 switch 来替换 if，这样也有可能改善代码的执行性能。

小结

今天这节课，我带你一起探讨了高性能编码的价值观，其实我的核心观念就是，高性能编码实现需要和后期的代码热点调优一起互相配合，而不是孤立地去看待其中一个，这样才会更容易开发出高性能的软件。

另外，在明确了高性能实现的价值之后，你还要清楚应该从哪些要点出发，去思考实现高性能编码，以及在高性能编码中针对一些典型业务场景的实现手段。你可以先理解和掌握这节课我给你分享的四种高性能编码实现的方法思路，然后按照这个思路，逐步积累和提升高性能编码的能力，从而帮助你最终开发出高性能的代码。

思考题

每一种编程语言都在不断发展，那么高性能编码手段是不是也要持续同步地更新呢？欢迎给我留言，分享你的思考和见解。如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

13 | 编译期优化：只有修改业务代码才能提升系统性能？

你好，我是尉刚强。

我们知道，所谓的编译，就是把我们编写的软件代码，变成计算机可以识别的汇编代码的过程，而这个编译过程会直接影响到最终运行的软件性能。所以今天这节课，我们就一起来聊聊编译期优化对软件性能的影响。

事实上，编译期优化是做软件性能优化时最常见的优化手段，它的最大优势就是可以在不用修改业务代码的场景下来提升软件的性能。另外，它也可以让开发人员以较低的成本来获取一定的性能收益，所以编译期优化也算是高性能软件系统研发中不可或缺的一个环节。

不过同时它也存在局限性，那就是需要开发人员对语言实现和底层编译过程都有比较深入的理解，否则就会很容易漏掉一些优化方向，导致发挥不出最佳的性能优化效果。举个简单的例子，在我以前参与的一个 C++ 性能优化项目中，只是帮忙调整配置了关于内联相关的编译配置，就直接给产品带来了比较明显的性能提升，而团队之前的性能优化就没有考虑过这个方向。

而且除此之外，不同的编程语言，受制于其语言内置设计机制的差异，导致在编译期优化时的关注点和优化方法也有很大的不同。比如，有些编程语言（如 Java、Python、Ruby 等）将内存管理内置到了语言中，那么在做编译期优化时就需要重点关注内存空间这部分的优化。另外，由于编译期优化和语言的相关性很大，我也不可能逐一介绍。

所以在今天的课程中，我会基于 C/C++ 和 Java 这两门语言，来给你展开讲解编译期优化中比较常用的优化手段和关键原则，以此帮助你明确应该按照怎样的步骤与思路去开展编译期优化工作，从而让你能够在实际的软件研发过程中，选择合适的优化手段来帮助提升产品性能。

这里，我之所以选择 C/C++ 和 Java，一是因为这两门语言是通用语言中的典型代码，二是因为二者也正好代表了两种优化类型，也就是其最后执行指令分别是在虚拟机上执行和在 OS 上执行。这样，你在理解了 C/C++ 和 Java 的编译期优化手段之后，再去思考其他编程语言的编译期优化手段，就可以融会贯通了。

C/C++ 是传统编译型语言中的一个代表，它与底层硬件比较贴近，编译期优化配置手段也比较丰富。所以接下来，我们就先来看下针对 C/C++ 开发高性能系统时，应该如何在编译期进行调优。

C/C++ 编译期优化

首先，C/C++ 在不同操作系统中所使用的编译工具并不是统一的，比如有 GCC、Clang 等，这节课我主要是基于最通用的 GCC 编译器来给你展开介绍。而如果你的产品使用的是其他编译工具链，其大部分的编译优化手段和方法也都是相似的，所以你也可以借鉴参考。

那么下面，我就根据使用 GCC 支撑的编译优化角度，来给你介绍下 C/C++ 在编译期优化时最关键的一些编译配置手段和方法，主要包括编译期优化选项配置、编译辅助函数、C++ 语言特殊优化。

编译期优化选项配置

对于 GCC 来说，它提供的针对编译优化的选项配置开关主要有这几种：-O1，-O2，-O3，-Os，-Of，-Og 等。

其中，-O1，-O2，-O3 分别代表不同的编译优化级别，-Os 代表的是对 Size 的优化，-Of 代表的是 fast 的极速优化，-Og 代表支持 Debug 的优化，这些都属于 GCC 上最常用的编译配置开关，更详细的你可以参考官方文档。

而在调整配置这些开关之前，你需要明白这些编译配置会对编译生成的汇编指令产生哪些影响，以避免盲目调整。然后在调整这些配置时，你还需要做好针对编译花费的时间长短、生成的二进制执行程序的大小、程序执行的性能、程序的可调试能力之间的权衡。比如说：

-Og 在程序中加入了定位调试信息，方便你跟踪定位问题；

从 -O1 到 -O3，编译出来的软件执行速度会越来越快，但也会导致编译时间越来越长，同时如果程序出现异常，你再回头基于汇编指令分析定位问题的难度也会加大；

-Os 在 -O2 的基础之上，会优化生成的目标文件的大小，所以它关闭了可能会使目标文件变大的部分优化选项；

-Of 则会开启所有 -O3 的编译开关，可能会对不符合标准的程序进行优化，所以潜在触发程序异常的概率会更高。

那么对于发布的软件版本来说，通常建议至少可以打开 -O2 级别，如果你的软件对性能要求比较高的话，也可以打开到 -O3（但这样可能会碰到一些因编译期优化而引起的故障问题）。在我之前做过的多个性能优化项目中，通过调整编译期优化开关，都可以给生成的软件带来 10%~20% 不等的性能收益。

其实，以上介绍每一级别的优化配置，都对应了一系列的详细编译配置，你可以根据业务代码进行更深入的配置和分析，具体的你可以参考GCC 的官方文档。但是你也要知道，对编译选项进行更详细的调整优化，其实性价比已经不太高了，一般情况下不建议你一定要这样做。

那么，在做好了编译期优化的选型配置之后，我们还需要选择编译辅助函数来优化生成软件的执行速度。下面我们就继续来看看，C/C++ 是如何与编译器进行配合，来实现更好的编译期优化效果的。

编译辅助函数

其实，对编译器而言，如果它掌握的信息越多，那么编译生成的汇编指令的执行效率就越高。所以不少编译器都提供了编译期优化辅助函数，支持从代码中获取更多额外信息来指导编译。

但这里你需要注意的是，通常这些编译辅助函数并不在编程语言标准中，而是由编译器独自定义的，所以使用前你需要谨慎选择。此外，如果使用编译辅助函数来优化生成软件的执行速度，也存在不少局限性，比如说：

使用内置辅助函数会污染到业务代码实现，导致代码可读性变差；

还有可能因为测试场景与产品运行环境的差异，导致编译辅助函数优化的实际效果并不理想；

如果因为引入了新的业务需求或代码重构，导致软件代码发生变化，也很容易引起内置辅助函数的优化成果失效。

不过，在一些对性能要求非常苛刻的场景下，你可能不得不需要使用这种手段。那么对于 C/C++ 语言来说，早期比较常用的编译辅助函数，应该是 likely 和 unlikely 宏，具体如下所示：

#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)

然后，我们可以将这两个宏放在代码中对应语义的条件判断分支上，来提升代码分支预测效率。

在上节课我也介绍过了，代码分支预测出错有可能会引起 CPU 指令流水线不连续，这是目前影响 CPU 硬件性能发挥的重要因素之一。而使用这个内置函数，就是主动告知编译器哪个分支会被更大概率地执行，从而让编译器在生成指令时优先选择大概率的分支，以此进一步提升 CPU 的执行效率。

不过，现在 GCC 也提供了更加方便的手段来帮助分支预测，具体流程如下图所示。

我来给你介绍下它具体实现的功能：

第一步，GCC 编译时使用 -fprofile-arcs 选项，编译源代码，生成可以跟踪分支预测的可执行程序。

第二步，启动可执行程序，并运行正常业务逻辑，这时候会生成很多 cc.gcda 文件，它们会记录相关的分支预测信息。

第三步，GCC 编译时使用 -fbranch-probabilities 选项，重新编译源文件时，会读取 cc.gcda 分支预测文件，从而最终可以生成执行效率比较高的可执行程序。

另外，还有一个比较常用的编译辅助函数，指令预取指令__builtin_prefetch ，它可以实现在代码执行的过程中，预先加载代码段或数据段到 Cache 中，从而达到降低数据或数据 Cache Miss 的概率。比如，你可以看看这个二分法查找代码，它在查询的过程中，就可以提前一步把接下来要对比的数据，先加载到 Cache 中来提升性能。

 int binarySearch(int *array, int number_of_elements, int key) {
         int low = 0, high = number_of_elements-1, mid;
         while(low <= high) {
                 mid = (low + high)/2;
            // low path
            __builtin_prefetch (&array[(mid + 1 + high)/2], 0, 1);
            // high path
            __builtin_prefetch (&array[(low + mid - 1)/2], 0, 1);
                 if(array[mid] < key)
                         low = mid + 1;
                 else if(array[mid] == key)
                         return mid;
                 else if(array[mid] > key)
                         high = mid-1;
         }
         return -1;
 }

我们知道，Cache 预取手段是 CPU 执行性能的优化手段之一，这是因为在通常情况下，在 CPU 硬件上的 Cache 容量是非常有限的，一旦出现 Cache Miss，就会导致 CPU 的执行速度不能完全发挥出来。而这里所使用预取指令，其实就是通过显式地告知编译器在哪些场景下，把哪些代码段或数据段加载到 Cache 中，从而提升 Cache 命中率来优化性能。

C++ 语言特殊优化

其实在 GCC 中，还有一些专门针对 C++ 语言的编译选项配置，它们对生成的可执行程序的性能影响也很大。这里我主要给你介绍下比较关键的一些配置，你可以根据实际业务中针对有关 C++ 语言特性的使用情况，去选择配置。

首先，在这些配置中，最重要的一个就是内联的优化配置。

通常我们在编写代码时，会使用 inline 关键字来定义方法，而这样的方法是否可以真实地被内联掉，还依赖于编译器。因此，对于 C++ 语言来说，编译中是否开启内联选项，对性能影响是非常大的。

下面给出的是最常用的、与内联相关的几个编译选项配置和具体的含义，你可以参考：

'-fno-inline'   忽略代码中的 inline 关键字
'-finline-functions'  编译期尝试将'简单'函数集成到调用代码处
'-fearly-inlining'   加速编译 默认可用
'-finline-limit=N'   gcc 默认限制内联函数的大小，使用该选项可以控制内联函数的大小

其次，由于目前在 C++ 中，新的语言特性越来越复杂，所以在开发高性能系统时，有很多的特性会因为可能产生的性能问题而很少使用。那么，你就可以在编译过程中，将这些编译特性的支持开关关闭掉，也可以进一步提升生成的可执行程序的性能。

我举几个简单的例子。如果你开发的 C++ 代码中没有使用异常，可以使用-fno-exceptions编译选项来关闭。它不仅可以提升运行性能，还能减少编译生成的二级制文件大小。还有，如果你的代码中没有使用动态类型转换、typeId 运算符、typeinfo 等语法，那么你可以使用-fno-rtti选项关闭运行时 RTTI 机制，来提升性能，等等。

更换编译器

好，最后我要给你介绍的一项编译期调优手段，就是可以通过更换编译器来优化性能。

实际上，对 C/C++ 而言，由于 CPU 硬件的发展变化很快，同时厂家的编译优化技术差异也很大，就导致了编译器生成的二进制性能也会存在差异。比如说，Clang 编译出的软件执行速度，在大部分场景下都要比 GCC 要快，而不同的 GCC 版本之间的编译优化性能也存在差距。所以，在大部分业务场景下，你都可以通过尝试更换编译器或者编译器版本，来进一步的提升性能。

OK，前面我们介绍了 C/C++ 语言在编译期调优时的一些常用优化方法和手段。接下来，我们再来看看在 Java 语言当中，具体要如何更深入地挖掘出软件在编译期的性能优化点。

Java 的 JVM 优化

一般来说，使用 Java 语言来开发软件是基于 JVM 之上来运行的，这是因为 Java 语言将对象内存管理职责交给了 JVM，因此在很大程度上减轻了一线软件开发人员的负担。但同时，由于内存申请与释放操作对软件性能的影响非常大，所以对 JVM 的堆空间配置，就成为了 Java 性能调优中非常重要的手段之一。

而除此之外，在 JVM 中，我们可以借助 JIT（即时编译器）把 Java 生成的热点字节码，转换为可以直接在 CPU 上执行的机器码，从而提升软件执行速度的效果。与此同时，JIT 也对外提供了一些配置选项，方便我们直接对 JIT 的功能和过程进行配置，从而优化软件执行性能。

所以说，针对 JVM 的性能优化一般就主要集中在这两个点上，接下来，我就重点给你讲解这两个方面进行优化配置的思路和经验，方便你参考借鉴。

JVM 的堆空间配置

首先，对 JVM 的堆空间配置，实际上可以分为三个方向来进行，分别是针对堆内存资源大小、堆空间的内部分配和 JVM 中的 GC 算法选择。它们之间是递进的关系，在针对软件的启动配置优化过程中，你就应该按照这个顺序来进行优化配置。

1. JVM 堆内存资源配置

JVM 在启动时，可以配置使用的堆内存资源大小，从而对最终运行的软件性能产生比较直接的影响。那么，关于 JVM 堆内存资源的配置，这里我给你介绍最重要的两个参数，当你开发的软件在服务器部署运行时，使用的资源也会限制在这个资源空间范围内：

-Xms，初始堆大小配置；

-Xmx，最大堆内存配置。

这里你可能要问了，这两个参数具体要怎么使用呢？下面我就给你详细介绍下。

首先，我推荐你根据真实服务器的内存大小，留给 JVM 使用的最大内存空间，去最大化配置 JVM 使用的堆空间（-Xmx）。注意，如果这里 JVM 的最大堆空间配置比较小，就会容易出现 JVM 堆内存上频繁触发 GC，而服务器上还有空闲内存未被充分利用的场景。

其次，对于高吞吐量性能要求和低时延性能要求的软件来说，我比较推荐把 -Xms 和 -Xmx 设置成相同的值，这样可以在 JVM 启动时，就把相关堆内存创建好，以避免程序在运行期间再调整而引起时延抖动。

2. JVM 堆空间内部分配

JVM 的堆空间分为三个部分，分别是新生代、老生代和永久代。因此，你可以通过配置来改变应用的堆空间的分配比例，从而影响或改变软件应用的性能表现。

那么，关于 JVM 堆空间的内部分配，也有比较重要的三个参数需要你关注:

-XX:NewRatio：新生代和老生代的内存大小比例，如果配置 4，则表示新生代与老生代的空间占比是 1:4。

-XX:NewSize：新生代大小。

-XX:SurvivorRatio：Eden 和 Survivor 区的比率。

此外，在配置新生代与老生代的堆空间占比时，你需要重点考虑两个因素，一个是业务软件中短期对象的占比状况，另一个是软件系统的时延要求是否足够高。

如果业务软件中的短期对象比较多，我建议可以配置比较大的新生代堆空间占比，这样在一定程度上就可以减少触发 Minor GC 发生的概率。而系统针对时延要求很高的场景，为了避免新生代空间太大，触发 GC 后的执行时间长，造成业务的时延抖动比较大，你可以把新生代堆空间占比调小一些。

另外在一般情况下，当系统吞吐量比较大且时延要求不高的话，你就可以将新生代的堆空间配置得大一些。

3. JVM 中的 GC 算法选择及配置

JVM 中如何使用不同的 GC 算法，也会对软件应用的性能表现影响比较大，因此你需要根据业务性能要求差异，来选择配置合适的 GC 算法。

目前，JVM 中支持的 GC 算法种类比较多，这里我给你介绍其中最典型的三种，来了解下选择配置的方法。

ParallelScavenge（-XX:+UseParallelGC），它是一个新生代收集器，如果你的业务对时延性能指标不是非常关注，可以考虑配置这种 GC 算法。

CMS（-XX:+UseConcMarkSweepGC），它是一个老生代收集器，其 GC 执行时间会比较短，如果业务对响应时间要求比较高的话，你可以优先选择这个 GC 算法。

GarbageFirst G1（-XX:+UseG1GC），它是新生代和老生代都通用的收集器，在管理大的内存上有比较大的优势，因此当堆内存空间比较大时，推荐你去使用这种。

总之，每种 GC 算法在不同场景下的性能优势都是不一样的，你需要基于业务特性，来选择配置合适的 GC 算法。

JIT 优化

好，最后我们再来了解下 JIT 优化的相关配置，看看如何通过调整 JIT 的配置，来提升软件的执行速度。

首先，对于 JIT 来说，一般情况下包含了两种工作模式：

Client Compiler，简称 C1，-client 参数强制，代表客户端模式。

Server Compiler，简称 C2，-server 参数强制，代表服务器模式。

一般来说，使用 Client 可以获得更快的编译速度，而使用 Server 更容易获得较好的运行性能，所以在产品部署态，你可以先检查下 JVM 是否在服务器模式运行。而如果是在客户端模式下运行，你也可以通过显式地配置来运行服务器模式。

额外知识：GraalVM 是 Oracle 新开发的编译器，在目前的评测中，其生成代码的执行速度会优于 C2（服务器模式），你可以基于真实业务去对比分析下性能，看看是否需要采用。

然后，在 JIT 优化的过程中，也有一些比较重要的优化项，如果你需要对 JIT 进行更深入的优化配置来提升性能，就可以参考使用。

–XX:ReservedCodeCacheSize：调整代码缓存，避免因为缓存问题，导致无法进行 JIT 优化。

-XX:CompileThreshold：热点方法触发内联的阀值，当被执行次数超过这个门限值，才会进行内联优化。

-XX:UseFastAccessorMethods：是否针对 get 方法进行优化。

-XX:+UseCompressedOops：是否进行 64 位指针到 32 位指针的压缩优化等。

最后要注意，这里我只是给你简单介绍了下每个配置项的含义，你可以根据真实的业务软件，调整配置到更适合的值。

小结

在今天的课程中，我带你学习了在 C/C++ 和 Java 两门语言中，针对编译期优化可以使用的手段有哪些，并分享了这些手段在使用过程中的一些经验和建议。这里你要注意一点，就是你需要关注开发的业务软件的实现特点，以及它关注的性能指标是什么，然后再利用这些编译期优化手段，来调整优化到一个比较好的性能效果。

最后呢，我还想重点强调下，这些经验与建议其实都是在一些特定业务场景下总结出来的，但不同行业的软件业务差异会比较大，可能一些建议并不一定适用于你的产品。所以，你在借鉴或者采纳这些经验和建议的时候，还需要先在业务中做进一步验证后，再去使用。

思考题

C/C++ 语言可以通过更换编译器来优化性能，那 Java 语言是不是也可以通过更换 JVM 来优化性能呢？

欢迎给我留言，分享你的思考和看法。如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

14 | 内存使用篇：如何高效使用内存来优化软件性能？

你好，我是尉刚强。今天，我们来聊聊如何通过内存的高效使用，来进一步优化和提升软件性能。

软件的实现是通过变量和变量之上的计算逻辑组成的，而在计算机运行期间，变量主要依赖于内存来承载。所以，如何高效地使用内存，就成为了高性能编码优化的重要手段之一。而在软件编码的过程中，不同实现方式对内存的影响，则主要体现在这三个场景：内存的空间与布局、内存的申请与释放、内存的读取与修改。

不过就我的观察发现，很多研发团队在软件的开发阶段，并不会去关注内存使用优化，这样当业务上线后，伴随着用户规模的快速增长，各种内存引起的性能问题就逐渐暴露出来了，比如内存空间不够、内存操作引起比较大的时延抖动等。

只有到了这个时候，他们才会意识到内存使用优化的重要性，但这时与内存相关的代码实现已经侵入业务的各个地方，调整重构变得举步维艰。所以可见，我们应该在编码实现的过程中，就去掌握优化内存使用的技巧和方法，以避免软件后期引起比较严重的性能问题。

那么今天这节课，我就会从这三个场景出发，带你了解如何通过不同的编码方式，来调整优化内存使用效率，从而提升软件性能。

不过在开始之前，我要先说明一点，就是不同编程语言的语法、解析运行机制的差异都很大，在高性能编码实现的技巧手法上也都不太一样。所以今天，我主要是以使用范围和人群都很广泛的 Java 语言为主，给你讲解如何从内存使用的角度进行高性能编码，从而开发出性能更加优越的软件。而在一些特定场景下，我还会选用一些 C/C++ 代码片段进行对比分析，这样有助于你理解背后的原理与意义。

好了，接下来，我们就一起看看如何通过代码实现来优化内存的空间与布局吧。

内存的空间与布局优化

首先你要清楚的是，通过编码实现手段减少对内存空间的使用，不仅能帮助你节省软件运行期间占用的内存开销，还可以减少代码运行期间对内存空间的操作，从而提升软件运行速度。

那么这样问题也就来了：我们要如何通过编码实现，来减少使用的内存空间呢？

这里，我给你总结了三种优化思路，虽然它们的关注视角不太一样，但最终都可以改善内存使用的性能。当你理解了这三种思路背后的原理，自然就会在编码过程中，朝着提升内存使用效率的方向改进。

下面我就详细给你介绍下。

按照变量存储信息量选择对应的类型定义

第一个手段是：按照变量存储信息量来选择对应的类型定义。这要怎么理解呢？我先带你来看一个具体的例子。

这个例子针对的是一个学生年龄的数据信息，你可以先想一想，一个在校学生的年龄有效范围会是多少呢？首先，基于常识判断，你可以认为该学生年龄是小于 100 岁的。因此针对这个场景，你在 Java 中使用一个 byte 基本类型，差不多就能满足信息的存取需求了。

可是，如果你使用 long 类型来保存，就会造成额外空间的浪费，并且也会潜在地影响程序的执行速度。所以，这就是一个没有根据存储信息量来选择类型定义的反例。遗憾的是，很多开发工程师在实际的代码开发过程中，并没有关注到这样的代码实现细节。

OK，现在我们换个思路，看看还有没有更极致的优化内存空间的方法。

首先我想问你的是，在计算机上最小的存储单位是多大呢？答案是一个 bit 位。那么这里，我们就来看下，在 C/C++ 中是如何记录该学生的结构体定义的，如下所示：

struct Student
{
    unsigned char gender: 1;
    unsigned char gradeId: 3; //年级号（1~6）
    unsigned char classId: 5; //班级号（1~20）
};

可以看到，在这个代码结构体中，使用了一个字节表示该学生的性别（gender）、年级号（gradeId）和班级号（classId）。因为小学一共只有 6 个年级，所以年级号（gradeId）使用 3 个 bit 位保存就足够了，其他字段也是相同原理。

这样一来，因为在 C/C++ 语言中支持位域操作（即可以针对 bit 位来记录变量信息），我们就可以进一步缩减内存空间。而利用 bit 位来节省内存，正是嵌入式或高性能系统中重要的内存优化手段之一，但比较遗憾的是，Java 语言并不提供原生位域能力，因此直接使用 bit 位变量来压缩内存空间会有点不方便。

但是，Java 中有 BitSet 这个类型，它可以支持位操作，不过它的主要思想是通过压缩存储来节省空间开销。

比如，假设你要保存元素值为 64 以内且不重复的数组：[1,3,5,6,10,11,12,25,44,56,2,55]，那么如果你使用正常 byte 数组来保存的话，可能需要十几个字节才可以。而使用 BitSet，使用每一 bit 位来表示一个数字，那么用 8 个字节就可以记录很多个数字了（当然，Java 使用 BitSet 压缩存储的应用场景也并不只限于这种方式）。

不过这样问题也就来了：针对 C/C++ 语言的位域优化实现，Java 是否也可以实现这样类似的功能呢？

其实当然是可以的，但你可能需要借助位运算。这里我们来看一段 Java 代码示例，同样是实现类在一个字节保存 classID，gradeID，gender 等多个信息的能力，从中我们会观察到，在 Java 内也可以使用一个字节，来保存多个有效的字段信息。

public class Student {

    byte data; // |classID(4bit) |gradeID(3bit)| gender(1bit)|

    Student() {
        data = 0;
    }

    public void setGender(boolean isMale) {
        data = (byte) (isMale ? (data | 0x01) : (data & 0xFE));
    }

    public boolean isMale() {
        return (data & 0x01) == (byte) 1 ? true : false;
    }

    public byte getGradeId() {
        return (byte) ((data & 0x0E) >> 1);
    }

    public void setGradeId(byte gradeID) {
        data = (byte) (data | ((gradeID & 0x07) << 1));
    }

    public byte getClassId() {
        return (byte) ((data & 0xF0) >> 4);
    }

    public void setClassId(byte ClassId) {
        data = (byte) (data | ((ClassId & 0x0F) << 4));
    }

}

当然，上面这种实现可以在一些极端性能场景下（比如有大规模实例对象的场景）使用，但我并不建议你经常使用，因为它会导致代码的实现复杂度提升。实际上，针对 Java 语言而言，在绝大部分的应用场景下，选择合适的变量类型就已经能很大程度上减少内存空间的浪费了。

对齐对象实例内的变量空间

好，我们再来了解下第二个手段：对象实例内变量空间对齐。

我们知道，如果一个变量的内存起始地址是字节对齐的，那么对这个变量的读取就是高效且安全的，因为不需要将变量分为多个指令周期进行读取和拼凑。

所以这里，我们来看下这个代码片段，这是一个 C/C++ 的结构体定义（这是不考虑 bit 位压缩存储的实现）：

struct Student
{
    unsigned char flag;
    unsigned int classId;
    unsigned short gradeId;
};

其中，classId 的字段类型 int 长度为 4 个字节，所以如果起始地址没有 4 个字节对齐，那么程序在读取 classId 时，就会将其拆分在两个指令周期内完成，这样势必运行效率就很低下。

因此，为了提升读取操作的性能，我们只能使用空间换时间的策略。在 C/C++ 编译的过程中，一般会使用字节填充技术，在 char 类型后面填充 3 个无效字节，从而保证 classId 的起始地址是 4 个字节对齐的，这样即可实现高效读取（当然你也可以通过编译器指令显示，告知编译器不做这样的优化）。

但很明显，这样会带来一个副作用，就是空间浪费。而解决这个问题的手段，就是手动调整字段顺序来实现字节对齐。比如说，你可以把 classID，gradleID 放到结构体的前面来定义：

struct Student
{
    unsigned int classID;
    unsigned short gradeID;
    unsigned char flag;
};

这样一来，我们就可以在满足字节对齐的场景下，最大化地节省内存空间。

比较幸运的是，JVM 在优化的过程中，使用了字段重排优化技术，这个技术是通过将相同类型的字段放在一起，来减少一些补白操作。所以在通常情况下，你只需要根据变量选择合适的字段类型即可。

尽量使用栈上的内存

OK，现在我们来学习下第三个手段：尽量使用栈上内存。

栈内存就是程序调用栈上使用的内存空间，当调用栈退出之后就可以被自动回收重复利用。在 C/C++ 中，所有的局部变量、结构体和类的实例，都可以在调用栈上临时分配。而在 Java 中，虽然不可以显式地在栈上去分配对象，但对于变量而言，你其实可以选择将它定义在函数内或者在类对象上，这样就可以避免在堆上分配资源。

这里需要说明的是，如果尽量将变量定义在函数内，其实会对性能更加有利。

因为在 Java 中，如果将变量定义在类对象中，当申请内存之后，由于内存回收需要依赖 GC 实现，因此可能在很长一段时间内，这块内存都不能再被回收利用了。另外，虽然在 JVM 编译优化中，会有些栈上分配的优化机制，但它们需要满足很多条件才可以实现，所以在开发代码时并不能完全依赖这个机制。

OK，以上就是通过调整代码实现，来优化内存空间与布局的常用方法和思路，它们的目标都是基于缩减内存空间来实现优化性能的效果，不过仅通过这个方式来提升内存效率还是不够的。

所以接下来，我们再来看看针对内存的申请与释放这一实现方式来说，还有哪些手段也可以提升软件性能。

内存的申请与释放优化

对 Java 语言而言，针对一个对象的 new 操作是非常耗时的操作，不仅需要动态在堆空间上分配内存并进行初始化，而且在使用结束之后，还需要基于 GC 来管理跟踪释放流程。

那么针对内存的申请和释放过程，我们可以通过编码实现来优化它的性能吗？

答案当然是可以的，这里常规的优化思路主要有三点，下面我就给你详细介绍下。

调整内存申请释放发生的时间点

首先是调整内存申请释放发生的时间点。这个优化思路的出发点是：将内存申请操作提前到软件程序的启动阶段中，从而减少运行期间申请内存资源的开销。

这里我们同样来看一个代码片段，这是一个单例模式的示例：

public class Singleton {

    private static final Singleton instance = new Singleton();
    private Singleton() {
    }
    public static Singleton getInstance() {
        return instance;
    }
}

从中我们可以观察到，程序在类初始化的过程中会自动去创建对象实例，这样就可以实现两个优化点：程序在运行期间直接使用对象，而不需要再进行动态申请内存；同时，由于静态对象实例也不会被 JVM 的 GC 垃圾回收，所以在一定程度上减少了 GC 的负荷。

实际上，这个例子也可以认为是预计算性能模式在内存申请场景下的一个应用示例，也就是把内存申请操作提前到启动运行阶段，从而减少运行期的动态申请开销（关于性能优化模式的更多内容，你可以回顾第9、10讲）。

减少内存的申请与释放次数

好了，除了调整内存申请发生的时机之外，我们还可以通过减少内存的申请与释放次数来优化性能。

我们知道，在 Java 语言中，一共包含了 8 种基本类型，分别是 byte、short、int、long、float、double、boolean、char。

那么首先，在业务允许的情况下，我们会优先使用基本类型，避免使用包装类型（如 Integer 等），这样可以减少额外的内存申请操作。

其次，在编码过程中，我们也要避免写一些冗余的对象申请操作，如下代码所示：

    String str = "test ";
    String str_2 = new String("test ");

可以看到，第一行代码里的 str 并不会动态申请对象，而第二行的 str_2 则额外申请了一次内存，从而就会导致额外的性能损耗。

最后，在 Java 中也是最重要的一点，就是尽量使用预分配方式。比如，你可以看看下面给出的这段代码示例，其核心逻辑就是将内存提前申请好，以避免支出运行期动态申请空间的开销：

    StringBuilder sb = new StringBuilder(1024);
    sb.append("test append alloc")

如果你在 StringBuilder 的构造函数中，通过参数提前指定了空间大小，并且已经一次性分配好，那么就可以避免在调用 append 的操作中，触发额外的内存申请操作，进而就可以提升性能。而且，这种预分配空间的机制，在 Java 的各种数据结构类型中，如 ArrayList、HashMap 等的使用过程中，你都可以去使用。

定制化内存申请与释放实现

好，最后一种优化手段，就是定制化内存申请与释放实现。也就是说，针对特定的业务场景，你可以根据使用内存的方式与特点，单独定义一套内存申请和释放的算法实现，来提升软件在内存申请与释放操作的性能。

在动态内存申请与释放的实现过程中，我们需要注意以下几个地方：

首先，需要一个查找算法来寻找合适的内存大小空间；

其次，需要考虑在并发场景下，通过同步手段来保证线程安全性；

最后，要注意当堆空间不足时，有可能会触发内核态 API 调用，从而造成内核态与用户态切换的更大开销（目前不确定在 JVM 实现中是否会发生这个阶段，但在 C/C++ 中会存在）。

实际上，在我以往参与的很多 C/C++ 开发高性能的系统中，定制化内存申请与释放是一个非常重要的性能优化手段。举个简单的例子，针对固定大小的内存申请操作，我们可以基于队列的出队和入队等类似算法实现，来改善内存申请与释放速度。

不过，在一些高实时性嵌入式系统中，动态内存使用是被禁止的，程序的所有内存都需要通过静态预分配与管理来实现。

可是在 Java 中，如果一些业务逻辑内，频繁地申请和释放对象操作对性能的影响比较大，那么我们是否有办法去优化解决呢？

当然是有的！这里我想给你介绍的一个性能优化手段，就是对象池共享技术。

对象池本质上是通过集合来管理已经申请过的对象，如果线程需要这种类型的对象，就直接从集合中取一个元素，但是使用完也一定要归还，否则就会造成内存泄漏。另外，在使用对象池来管理一些创建比较耗时的对象时，我们还可以通过减少对象申请与创建的过程，来提高软件的运行速度和性能。

好了，简而言之，对于内存的申请与释放优化，就是通过减少内存的申请和释放操作，或提升内存申请和释放的速度，来达到提升软件运行性能的目的。那么下面，我们接着来看看，要如何优化内存的读取和修改，来进一步提升软件性能。

内存的读取与修改优化

在 CPU 中，Cache 的存取与替换都是以缓存行（Cacheline）为单位，而且在不同的 CPU 体系架构实现中，缓存行的长度也都不一样，具体从 8 字节到 128 字节之间不等。因此，如果说我们可以把变量空间按照缓存行对齐，那么就可以提升 Cache 的读写效率，从而就能够达到提升性能的效果。

既然如此，具体我们该怎么做呢？

这里我们先来看一个例子，这个例子主要用来说明：变量 A、变量 B 是否在一个 Cacheline 中，并会对 Cache 的读取操作产生影响。

如上图的左半部分所示，如果两个变量不在一个 Cacheline 中，那么在读取变量 A、变量 B 时，就需要读取两个 Cacheline。而在图的右侧，由于两个变量在一个 Cacheline 中，所以只需要读取一个 Cacheline 即可。

也就是说，你在编码的过程中，就需要充分利用局部性原理，把经常一起使用的变量放在一起，从而最大化地实现 Cacheline 的长度对齐，来优化提升软件的运行效率。

注意：由于 CPU 的指令预取技术，通常情况下在串行程序中，Cacheline 对齐对性能的影响可能不是那么明显，所以很容易被我们忽视。

好，除此之外，我们还要知道在多核并发的场景下，由于 Cacheline 没有对齐，造成的伪共享（False Sharing），也会显著地影响程序的运行效率。如下图所示：

已知在 Core 1 上需要更新变量 X，而 Core 2 需要读取变量 Y。但是由于变量 X 与 Y 在一个 Cacheline 中，它们会映射在相同的内存地址上，所以每次当 Core1 上更新变量 X 之后，就会造成 Core 2 上的变量 Y 对应的 Cacheline 失效，需要重新读取，进而就会影响性能。

所以在并发系统的设计中，针对 Cacheline 未对齐造成软件性能受影响的场景，我们可以通过显式字段的冗余来实现 Cacheline 对齐，以避免这种情况的发生。不过好在，Java 8 中引入了 sun.misc.Contended 注解，它就可以针对性地识别并发场景下存在的 Cacheline 伪共享问题。

那么，除了伪共享问题外，在实现编码的过程中，还有一些手段也可以优化内存读取和修改的性能。

比如说，在 Java 语言中，你可以借助一些 native 方法来优化拷贝赋值数据的性能，而其中最常用的，就是使用接口的 System.arraycopy 方法、对象的 clone 方法。那么再进一步，对内存拷贝的性能优化极限就是零拷贝，你可以通过业务逻辑或算法优化来尽量减少拷贝操作，从而进一步优化性能。

而对于 C/C++ 语言来说，对一块内存进行修改时，使用 memcopy 操作性能则优于直接赋值操作，这是因为 memcopy 在汇编过程，使用了特殊的汇编指令来优化连续内存的拷贝操作。

小结

学完了这节课，我们需要明确一点，就是不同编程语言在如何高效地使用内存上，存在不同的权衡策略，但不管是静态类型语言（如 C/C++、Java 等），还是动态类型语言（如 Ruby、Python、Node.js 等），高效使用内存都是编码性能优化的重要考量因素之一。

在面对不同编程语言进行业务编码的过程中，你都可以充分利用编程语言内置的内存使用策略，并从内存空间与布局优化、内存申请与释放优化、内存读取与修改优化三个维度进行思考，从而就能够写出高性能的代码。

思考题

代码在运行期间，也是从内存中加载到 Cache 来运行的吗? 你知道还有哪些手段，可以优化代码逻辑使用内存的效率呢？

欢迎在留言区分享你的思考和看法。如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

15 | 并发实现：掌握不同并发框架的选择和使用秘诀

你好，我是尉刚强。

在学完了第2、3节课之后，我们已经清楚了并行设计的重要性，也掌握了几类典型的并行设计架构模式，但是在编码实现的过程中，这些并行设计架构模式还需要依赖底层的并发框架才能完成。所以今天这节课，我就和你聊一聊并发框架的选择和使用秘诀。

其实不同的编程语言中，可用的并发框架种类非常多，比如 Java 语言有 Thread Pool 框架、 Akka 框架、Reactor 响应式框架等；C++ 语言有 CAF 框架、Theron 框架等；Go 语言有 goroutine 等。

这些并发框架之间的差异很大，如果选择或使用不当，会很容易导致开发出来的软件性能比较差。而且，现在也依旧有不少的程序员，对这些并发框架并没有比较系统的认识，在选择和使用并发框架时，经常是比较随意的。

所以在今天的课程中，我就来告诉你当碰到具体的业务问题时，你应该如何选择更合适的并发框架，以及在使用中要遵循什么样的方法，才能发挥出并发框架的最佳性能。另外，在课程中我主要是以 Java 语言为例，来重点给你讲解 Thread Pool 框架、Akka 并发框架、Reactor 响应式框架的基本原理与特点，以及它们在使用过程中的一些注意事项，让你能最大化地发挥并发框架的性能优势。

而如果你是从事其他编程语言的开发工作，当然也可以参考这节课的内容，因为接下来我要介绍的 Java 并发框架，它们也代表了如今在并发系统设计中比较主流的三种框架（线程池、Actor 模型、响应式架构）。

那接下来，就从我们最熟悉的 Thread Pool 框架开始学习吧。

Java Thread Pool 框架

Java 的 Thread Pool 框架是目前最流行的一个并发框架，因为它使用起来比较方便，适用的场景也非常多。同时，它也是其他并发框架（如 Akka）内部实现所依赖的技术，所以理解和学习这个框架是很重要的。

那么，为了更好地理解 Java Thread Pool 框架，我们先来看下它的框架模型图：

从图的左边开始看，继承接口 Runnable、Callable 的具体实现任务，在调用 ExecutorService.submit 接口时，会提交任务到 ExecutorService 内部的一个任务队列中。同时，在 ExecutorService 内部还存在一个预先申请的线程池（Thread Pool），线程池中的线程会从任务队列中领取一个任务来执行。

那么由此我们也能发现，在 Java 语言中，与直接在代码中创建线程相比，采用 Thread Pool 这种机制有很多好处：第一个好处，就是在 Thread Pool 中，你可以重复利用已创建的线程资源，从而减少线程创建和销毁造成的额外开销；第二个好处是，当有新业务请求到达时，你可以直接使用已创建的线程来处理业务，所以还可以最大化地减少处理时延。

其实，对于一个软件系统而言，线程是非常重要的稀缺资源，而线程池技术也是有效管理线程资源、最大化提升软件性能的关键手段之一。

但是，我见过不少的软件系统，在使用线程池时根本没有章法，每个开发人员在自己的业务模块中随意创建线程池，而对于整个软件系统来说，业务代码中一共创建了多少个线程池、每个线程池的资源规模配置如何，都是很含糊的，从而就导致开发出的软件性能总是处于不可控的状态。

所以通常来说，在使用线程池设计实现并发系统的时候，你需要针对线程池的创建与配置进行全局设计。那么这里的问题就是，你应该依据什么样的规则来划分线程池组？以及如何去配置线程池使用的资源呢？

实际上，就我的实践经验来看，我认为应该根据以下两个维度来划分线程池组：

首先，你应该根据不同的业务逻辑特点来划分线程组，比如说，可以将以 CPU 计算为主和以 IO 处理为主的业务逻辑，划分到不同的线程池组中；

其次，你还可以根据不同的业务功能的优先级，划分出不同的线程池组。

这样，当线程池组的划分确定之后，接下来，你就可以根据 JVM 中可用的 CPU 核资源数目（你可以使用 Runtime.getRuntime().availableProcessors() 获取 JVM 可用的 CPU 核数），给不同的线程池组分配合理的线程资源额度。

然后，在对线程池组配置可用的线程资源时，你还需要针对不同线程池上的业务特点，选择不一样的线程资源配置策略。比如说，针对 CPU 计算密集型业务，只需保持线程池配置可用的线程数，与可以分配的 CPU 核数相等即可；而针对 IO 密集型业务，由于业务中的阻塞请求比较多，所以可以将配置的线程数提高到可用 CPU 核数的两倍以上。

当然，我这里只是介绍了大体的配置思路，当你在为线程池组配置可用的线程时，最好是基于真实的业务运行特性分析，并从全局统筹分配之后，再为每个线程池配置合适的线程资源。

OK，现在我们再回头看一下前面那个线程池框架模型图，不知你发现没有，这个框架模型中并没有考虑线程之间的通信机制要怎么实现。那么你可能就会想：当业务中的线程之间存在信息交互时，应该怎么办呢？

这个时候，你肯定会想到，可以基于 Java 并发消息队列来进行通信，还可以使用各种同步互斥锁呀。

的确，在 Java 语言中，内置的并发消息队列与互斥锁等机制，几乎可以满足线程间的各种同步交互需求，如果合理设计并使用，也可以很好地发挥出软件性能。

不过，在真实的业务开发过程中，并发消息队列和锁机制如果使用不当，不仅容易导致软件出现很严重的故障，而且也容易引起系统中的某些线程长时间阻塞，从而不能很好地满足业务的性能需求。另外，并发消息队列和锁在解决同步互斥和数据一致性的问题时，带来的内部开销也会在一定程度上消耗软件的性能。

那么，有没有不需要基于直接使用并发消息队列和锁，来设计和实现高并发系统的框架呢？

答案当然是有的，我接下来要给你介绍的 Akka 并发框架，就是为了解决这个问题。

Akka 并发框架

首先我们知道，Akka 是基于 Actor 模型实现的一套并发框架。所以这里，我们同样是先通过一个 Actor 核心模型图，来了解下 Akka 并发框架的特点：

在这个模型图中，每个 Actor 代表的是可以被调度执行的轻量单元。如图中所示，Actor A 和 Actor C 在向 Actor B 发送消息时，所有消息会被底层框架发送到 Actor B 的 Mailbox 中，然后底层的 Akka 框架调度代码会触发 Actor B，来接收并执行消息的后续处理。这样，基于 Actor 模型的这套并发框架，首先就保证了消息可以被安全地在各个 Actor 之间传递，同时也保证了每个 Actor 实例可以串行处理接收到的所有消息。

因此，采用基于 Actor 模型的 Akka 框架，在开发实现软件时，你就不需要关注底层的并发交互同步了，只需要聚焦到业务中设计每个 Actor 实现的业务逻辑，它需要接收什么消息，又需要向谁发送什么消息。

另外，由于 Actor 模型中的消息机制，实现了消息在 Actor 之间传递时会被串行处理，所以就天然避免了在消息交互中需要解决的数据一致性的问题。也就是说，针对系统中并发单元间存在大量信息交互的场景，选用 Akka 并发框架在性能上会存在一定的优势。

其实，Actor 模型还有一个更大的优势，就是 Actor 是非常轻量的，它可以支持很大规模的并发，并负载均衡到各个 CPU 核上，从而可以充分发挥硬件资源，进一步提升软件的运行性能。

那么接下来，为了更好地理解这个原理，我们来看一个任务拆分示意图，它描述了两种不同的任务拆分方式，以及将拆分的子任务映射到 CPU 具体核上的执行过程。

在图中，左侧的方法 1，代表的是传统基于线程的粒度并发拆分，你能够发现，这里想要拆分成大小均匀的并发子任务，其实是很有挑战的。而当拆分出的子任务大小规模差别比较大时，然后当它们被映射到底层 CPU 中的核上执行时，就会造成 CPU 核上的负载不均衡的情况。

这也就是说，传统的任务拆分方式会出现某些核处于空闲状态，而另外的核上还有线程在执行的场景，所以在这种场景下，CPU 多核的性能空间就无法发挥到极致。

而图中右侧的方法 2，代表的是 Actor 的细粒度任务拆分，它可以把业务功能拆分成大量的轻量级的 Actor 子任务。而由于每个 Actor 都非常轻量，Akka 的底层调度框架就可以将这些 Actor 子任务均匀地分布到多个 CPU 硬件核上，从而可以最大化地发挥 CPU 的性能。

所以，你在实际的业务开发中要注意，如果在使用 Actor 时，没有利用好 Actor 轻量级的特性，开发出来的 Actor 承载的业务逻辑太多，导致 Actor 的任务粒度过大，那么就很难发挥出 Actor 的最佳性能表现。

OK，在理解了这种并发框架的使用优势之后，你可能仍然存在一个问题，就是究竟什么样的业务系统会存在大量的并发信息交互，比较适合采用 Akka 并发框架呢？

按照我的实践经验，一般来说，CPU 计算密集型的软件系统会比较适合采用 Akka 并发框架。如果你发现业务系统中，存在大量基于并发消息队列的通信，且核心业务都是围绕着 CPU 计算逻辑，而 IO 请求并不是核心的业务逻辑，那么你的系统就很可能比较适用 Akka 并发框架。

实际上，很多种计算执行引擎就是比较典型的代表。比如，我之前开发的智能对话引擎，需要将多个计算模型的计算结果放在一起进行比较分析，那它就非常适合采用 Akka 并发 Actor 框架模型。

不过，对于一些典型的互联网微服务来说，当它们收到 REST 请求后，实现的核心业务逻辑主要是针对数据库 CRUD，或是针对其他服务的 REST 接口调用，同时，这些不同的 REST 请求业务还是相对独立的。那么，这类系统就应该属于 IO 密集型业务，所以选择采用 Akka 并发框架，往往优势就不是很大。

那么，针对 IO 密集型业务，是不是选用线程池并发框架就是性能最佳的方案呢？

其实也不一定，下面我们就一起看下 Reactor 并发框架的实现特点，并了解下它在解决 IO 密集型业务时存在的优势吧。

Reactor 响应式框架

Reactor 架构是一种基于数据流的响应式架构模式，严格来说它可能不算是完整的并发框架，但是却内置了灵活调整并发的机制和能力。

对于不太熟悉函数式编程范式的程序员来说，可能理解与使用 Reactor 架构会有些挑战。不过没有关系，在今天的课程中，我会帮你搞清楚 Reactor 架构模型的基本原理和优势是什么，你并不需要囿于细节。而当你在实际的业务中，需要决策是否使用这款并发框架时，再选择深入学习具体的用法也不迟。

好，首先，我们还是来看看 Reactor 框架的工作原理图：

如上图所示，输入流 Flux 就是 Reactor 中典型的异步消息流，它代表了一个包含 0 个到 N 个的消息序列。另外，图中的 Rule 代表的是一个基于消息的处理逻辑或规则，输入流中的消息可以被中间多个处理逻辑组合连续加工之后，再生成一个包含 0 个到 N 个的输出消息流 Flux。

那么在看完原理图之后，我们需要思考一个问题：Reactor 为什么要采用这样的计算模型呢，它又可以给软件的性能带来什么样的优势呢？

其实，这里主要会带来两个比较明显的优势，接下来我就给你重点介绍下。

第一个比较大的性能优势，就是它提供了背压机制。如果通俗点讲，那就是图中的中间处理规则（Rule），在接收处理消息时采用的是 Pull 模式，所以不存在数据消息积压的情况。而对于传统的分布式并发系统而言，内部消息堆积是一个很普遍的影响性能的因素，所以使用 Reactor 框架，就可以避免这种情况发生。

第二个比较大的性能优势，就是在中间的消息处理规则实现中，针对 IO 的交互操作可以采用非阻塞的异步交互。而原来传统的基于线程与 IO 交互的实现过程中，不管是使用直接的 IO 请求，或者基于 Future 的 get 机制，都不可避免地会发生当前线程被阻塞的情况。所以基于 Reactor 的异步响应式交互模式，在处理多 IO 请求时性能会更出色。

另外，在 Spring Boot 2.0 版本之后，也提供了对 Reactor 的全面支持，可以支持你去实现事件驱动模型的后端开发，从而更好地发挥软件的性能优势。

小结

在今天的课程中，我主要讲解了 Thread Pool 并发框架、Akka 的 Actor 并发模型和 Reactor 的响应式架构的核心原理与性能特点。其中，Thread Pool 是使用最普遍，也是其他并发框架的底座；而基于 Actor 模型的 Akka，更适合对计算密集型且交互比较多的并发场景；而基于 Reactor 响应式架构，在针对消息流处理的、基于 IO 密集型的异步交互场景来说，有比较大的性能优势。

那么，在学习完今天的课程后，当你碰到特定的应用场景时，就可以基于这些并发架构的原理与特点，来选择适合产品的并发架构。但是要注意，选择并发框架只是在一定程度上，减少了开发高性能软件的复杂度，而最终开发出的软件的性能，还取决于你是否找到了更适合业务特性的高性能实现方案。

所以，你还需要继续深入理解业务逻辑，寻找到特定并发框架下，让软件性能更佳的设计与实现方法。

思考题

针对 IO 密集型软件系统，采用 Thread Pool 框架开发软件性能，是不是一定比采用 Akka 的并发框架的性能差呢？

16 | 技术探索：你真的把CPU的潜能都挖掘出来了吗？

你好，我是尉刚强。

通过上节课的学习，我们现在已经了解并发设计和实现的相关技术和方法，而所有这些技术方法的目的，都是为了能最大程度地发挥 CPU 多核的性能。但我们还要知道的是，CPU 体系架构在解决单核性能瓶颈问题、提升处理软件性能的过程中，其实并不是只可以采用增加核数这一种方式。

现在主流的 CPU 体系架构，为了提升计算速度，实际上都借鉴了 GPU 中的向量计算特点，在硬件上引入了向量寄存器，并支持利用向量级指令来提升软件的性能。

这种利用单条指令执行多条数据的机制，我们通常称之为 SIMD（Single Instruction Multiple Data）技术，比如 MMX、SSE、AVX、FMA 等支持 SIMD 技术的指令集。另外像英特尔、AMD 等生产的不同款型的 CPU，也都会选择支持部分指令集技术，来帮助提升计算速度。就以 ClickHouse 为例，它之所以在分析数据上有卓越的性能表现，其中一部分原因就在于其底层大量地使用了 SIMD 技术。

那么，基于向量的 SIMD 技术的原理是什么，为什么它可以提升计算速度呢？我们在软件开发的过程中，要如何使用这种技术来提升性能呢？

今天这节课，我就根据目前比较主流的 AVX 技术的工作原理和具体实现，来帮你解答以上提出的这些问题。这样一来，你在 C/C++ 和 Java 语言的开发项目中，就知道如何使用这种技术来开发高性能的软件了。

基于向量的 SIMD 技术是如何提升计算速度的？

首先，我们需要搞清楚一个问题，就是基于 AVX 的 SIMD 技术为什么计算速度会比较快？

这里我们可以先来看看下面这张图，其中对比了 SIMD 指令与传统的 SISD（Single Instruction Single Data）指令，执行 4 个数字的求和计算操作过程：

图的左边，代表的是单条指令执行单条数据的实现方式，我们可以看到，针对两组包含 4 个元素的数据，在进行两两相加的操作时，最少需要 12 条指令（8 条 mov 指令，4 条 add 指令）才能完成业务。

而图的右边，因为在 CPU 芯片中集成了比较大的寄存器，从而就实现了多条数据导入和多条数据相加操作都可以在一条指令周期内完成，减少了执行 CPU 的指令数，进一步也就提升了计算速度。

目前支持 AVX 的 CPU 芯片，最高已经可以支持 512 位的寄存器，从而可以实现一个寄存器中保存 16 个浮点数的能力。因此，相比传统的单个浮点数的计算来说，其计算速度最高可以提升 16 倍，所以对计算密集型的软件性能提升帮助很大。而对于 GPU 来说，也正是因为它可以实现通过单条指令来运行矩阵或向量计算，才可以在数据处理和人工智能领域有比较大的性能优势。

如何使用 SIMD 技术来提升软件性能？

在了解了 AVX 向量指令集技术后，接下来我们要解决的问题就是：如何在软件开发的过程中，使用这种技术来提升软件性能呢？

实际上，目前很多的编程语言都可以支持基于 SIMD 的编码开发。所以接下来，我会针对 C/C++ 和 Java 这两种使用广泛的编程语言，来带你掌握 SIMD 技术的具体实现。

基于 C/C++ 的 SIMD 实现

事实上，针对 AVX 指令集，目前的 CPU 硬件厂商已经把它的基本功能封装成了 C 函数库，所以对于 C/C++ 的编程用户来说，就可以比较方便地使用 AVX 指令集开发程序。那接下来，我们就先来看一下在 C/C++ 语言中，是如何使用 AVX 优化执行性能的吧。

首先，我们来看一个具体的例子。在如下所示的函数中，是使用传统的指令实现的两个 double 类型的数组求和操作：

void vectorAdd(double* a, double* b, double* c){
    for(int i=0; i<4; i++) {
        c[i] = a[i] + b[i];  //一条代码仅能实现两个数字的计算。
    }
}

因此，为了执行 4 个数字的相加操作，程序需要遍历循环四次。而如果采用 AVX 指令集，来实现相同功能的逻辑，其执行过程是这样的：

void vectorAdd(double *a, double *b, double *re)
{
    __m256d m1, m2; //avx指令集中支持的数据类型
    m1 = _mm256_set_pd(a[i], a[i + 1], a[i + 2], a[i + 3]); //转化为向量变量
    m2 = _mm256_set_pd(b[i], b[i + 1], b[i + 2], b[i + 3]);
    __m256d l1 = _mm256_add_pd(m1, m2); //向量相加操作；
    re[i + 3] = l1.m256d_f64[0];
    re[i + 2] = l1.m256d_f64[1];
    re[i + 1] = l1.m256d_f64[2];
    re[i]     = l1.m256d_f64[3];
}

我来给你具体分析一下：

首先，__m256d 是 AVX 中支持的数据类型，它代表的是 256 位的 double 向量。其实目前的 AVX 内部，已经支持了很多数据类型，而 _m256 则表示可以保存 8 个 float 数字的向量（float 长度 32 位，256 位可以保存 256/32=8 个）。

接下来的两条 _mm256_set_pd 指令，就实现了把 4 个 double 数字，转换为 double 类型的向量。

然后，_mm256_add_pd 实现了向量相加操作，也就是把两个 double 类型的向量中的元素，进行逐个相加，再生出一个新的向量。

最后，再使用 l1.m256d_f64 接口，将向量中的值转换到数组中。

如此一来，通过以上的向量化计算改造，我们就可以减少 CPU 执行的指令数目，从而提升计算速度。

不过这里你要注意，不同的 SIMD 指令集的用法差异是比较大的，我推荐你可以参考一下 Intel 的官网文档，其中涵盖了 Intel CPU 架构封装实现的各种向量指令集的接口定义。

同时，在不同的 CPU 芯片之间，它们对向量级计算的支持能力，以及支持的 SIMD 指令集也都不太一样，所以在进行软件开发之前，我更推荐你先去了解下软件运行的 CPU 芯片是否支持对应的 SIMD 指令集。

另外，从前面的数组求和操作示例中，你可能会发现，使用 AVX 指令集来编写程序会比较繁琐。所以如果你的产品对性能并没有极致的要求，我比较推荐你采用编译器手段来实现 AVX 的指令优化。比如，在做 GCC 编译时，你可以增加下面的选项来编译软件，这样程序在生成指令时，就可以尽量生成向量级操作指令，进而来提升软件性能。

gcc -mavx, -mavx2, -march=native

而如果，你使用的是英特尔的芯片，而且也使用了英特尔提供的 C/C++ 编译器 icc，来进行编译构建，那么你还可以使用下面的编译器宏，来显式地告知编译器进行 SIMD 的相关优化：

#pragma vector aligned
#pragma simd

当然，你还可以使用英特尔开发的 Cilk Plus 并行编程库，来更高效地开发支持并行与向量化的程序，以此帮助提升软件性能。

基于 Java 的 SIMD 实现

OK，我们再来看看 Java 语言中是如何支持实现 SIMD 技术的。

其实在以前，Java 语言并没有提供直接使用向量级指令的能力。早期 Java 的设计者们，是在 HotSpot 虚拟机中，引入了一种叫做 SuperWord 的自动向量优化算法，这个算法会缺省地将循环体内的标量计算，自动优化为向量计算，以此来提升数据运算时的执行效率。

不过，如果在 JIT 中采用这种自动向量优化机制，其实存在一定的局限性。比如说，它只能针对循环内的部分实现进行向量级的优化，同时针对一些复杂计算过程，像是中间包含分支判断、数据依赖等，也会很难将其优化为向量计算指令。

但是，在 JDK 16 之后，JIT 引入了向量化编程的直接支持，这部分的模块代码在 jdk.incubator.vector 模块中。当你安装了新版本的 JDK，并在代码中导入这个模块包之后，你就可以基于向量级指令开发程序了。

那么下面，我们就来看下，在 Java 中具体要怎么用 jdk.incubator.vector，来开发基于向量化的程序。

首先，我们来看一段基于 Java，它实现功能是两个数组内元素先进行平方后，再进行数组对应元素间相加操作的代码示例：

void scalarCalc(float[] a, float[] b, float[] c) {
   for (int i = 0; i < a.length; i++) {
        c[i] = (a[i] * a[i] + b[i] * b[i]);
   }
}

由此你会发现，这段代码的实现其实跟前面 C/C++ 的实现过程是一样的，它同样需要遍历数组中所有的元素，依次根据公式计算出 C 中每个元素的值。

而如果是基于 jdk.incubator.vector 技术，则调整优化后的代码实现如下：

static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_512;
void vectorCalc(float[] a, float[] b, float[] c) {
    for (int i = 0; i < a.length; i += SPECIES.length()) {
        var m = SPECIES.indexInRange(i, a.length);
        var va = FloatVector.fromArray(SPECIES, a, i, m);
        var vb = FloatVector.fromArray(SPECIES, b, i, m);
        var vc = va.mul(va).add(vb.mul(vb));
        vc.intoArray(c, i, m);
    }
}

其中，你需要重点关注的是 vectorCalc 函数，它实现了和上一段代码相同的功能逻辑，也是先计算平方再求和。此外，代码 FloatVector.SPECIES_512，它代表的是将 16 个 float 数字放在一个向量中的类型，这与 AVX 封装的 C 语言指令集也是类似的。

然后，在使用 jdk.incubator.vector 进行运算的过程中，也需要进行同样的转换过程，所以这里首先也要将数组转换为向量类型。

接下来，你需要注意的是在 va.mul(va) 中，基于向量的 mul 操作，它实现的功能是元素逐个相乘，如下图所示：

所以，中间的代码片段就变成了：

va.mul(va).add(vb.mul(vb))

这样一来，就可以实现原来的相同计算逻辑，如下：

 c[i] = (a[i] * a[i] + b[i] * b[i]);

同理，由于基于向量级的操作，可以将原来 16 个 float 上的乘法和加法操作，都转换为一条指令，减少了执行的指令数，所以计算速度会变快。

目前，jdk.incubator.vector 中已经囊括了常用的运算操作功能，包含的接口比较多，详细的你可以参考官方 API。

不过到这里，你可能会觉得，好像所有基于 AVX 的编码实现，都只是把之前代码的实现，修改为基于 AVX 指令的实现。

但实际上并不只是这样，在真实的高性能编码过程中，它的核心挑战并不是修改之前的代码，而是针对同样的一段业务计算逻辑，如何调整编码实现，从而最大化地利用和发挥底层的 CPU 的向量级指令的能力。

我举个例子。下面这段代码展示的是一个 float 数组求和操作，如果是在数组长度为 N 的情况下，那么你可能就需要执行 N 次的求和操作：

float sum(float[] a) {
   float sum =0.0;
   for (int i = 0; i < a.length; i++) {
        sum = sum + a[i] ;
   }
   return sum;
}

所以针对这种情况，你就可以在原始数据构造阶段，把数据记录到两个数组中，然后利用向量级指令的求和计算，就可以实现仅通过 N/16 次的向量加法操作之后，将需要求和的数组规模下降一半的效果。

当然，这里我给出的只是一个很小的示例代码，在真实的业务计算中，你可以通过调整设计与实现，来改变业务功能的计算过程，从而更加充分地发挥向量化计算的性能优势。

小结

今天这节课，我带你了解了针对 CPU 提供的 SIMD 技术的原理，以及它是如何提升软件性能的。同时，我还针对 C/C++ 和 Java 这两种语言，帮你明确了如何在具体编码过程中，去使用这种技术来提升软件性能。如果你在参与一些 CPU 计算密集型的软件系统开发，并且性能要求非常高，那么就可以尝试使用今天课程中学习的 SIMD 技术来提升性能。

不过，SIMD 技术是一种比较贴近底层的优化技术，只会在特定场景下才有效果。因此，你在性能优化的过程中，首先需要考虑其他可用的高性能编码实现技术，只有当其他的高性能实现技术都已经发挥到极致，而且通过打点分析，确认通过计算数据向量化可以进一步提升性能时，再考虑使用这种向量化优化性能的技术。

思考题

今天课程上讲解的向量级指令与人工智能 CPU 中的向量计算原理是一样的吗？它们在使用中有什么差异？

欢迎在留言区分享你的观点和看法。如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

17 | Benchmark测试（上）：如何做好微基准测试？

你好，我是尉刚强。从这节课开始，我们就进入了课程的第三个模块：性能看护篇。接下来，我们会用 5 节课的时间，来学习和掌握性能测试的核心理论、测试工具的选择和使用，并理解如何才能更好地集成在流水线中监控软件产品性能的能力。

今天，我们先来了解下基准测试（Benchmark）的分类，并重点学习下在进行微基准测试时都会碰到哪些问题，以及高效实现微基准测试的方法步骤和手段。

现在，我想先问你一个问题：软件为什么要进行基准测试呢？

实际上，从软件生命周期的视角来看，由于新需求的不断引入，导致软件实现在持续不断地演进与变化，而在这个过程中，软件的熵会不断增大，同时软件的性能也很容易被不断地劣化。所以说，性能优化是一个持续改进的过程，如果没有好的措施来看护软件的性能基线，就很容易导致软件系统的性能长期处于不稳定的状态。

那么，基准测试的目的，就是为软件系统获取一个已知的基线水平。这样，当软件修改变化导致性能发生劣化的时候，我们就可以在第一时间发现问题。

但是，如何对软件系统做好基准测试，是一件非常有挑战的事情！我举个简单的例子，有些互联网 SaaS 服务在进行性能测试时，需要很大规模的用户接入，可是这在测试场景下是很难构造的。

另外，基准测试按照被测系统规模，可以分为微基准测试与宏基准测试。其中，微基准测试主要针对的是软件编码实现层面上的性能基线测试，而宏基准测试则是针对产品系统级所开展的性能基线测试。

所以今天这节课，我会先给你介绍下微基准测试中面临的一些核心挑战与难点，带你分析如何才能做好微基准测试。至于宏基准测试的相关知识点，我会在下节课给你讲解。

不过在开始之前，我还要说明一点，就是由于微基准测试与编程语言实现的相关性比较大，所以接下来，我主要是从程序员使用非常多的 Java 语言为出发点，来给你介绍微基准测试面临的问题。

OK，下面我们就从 Java 软件程序的微基准测试开始，来了解下即时编译对代码实现性能测试的影响吧。

JIT 对代码实现性能测试影响

事实上，对于 Java 软件程序来说，进行微基准测试其实存在很大的挑战，而这其中最大的挑战就来自于 JIT（Just In Time），也就是 JDK 中的 HotSpot 虚拟机的即时编译技术。

JIT 技术会在程序运行过程中，寻找到热点代码，并将这部分代码提前编译成机器码保存起来，这样在下次运行时就可以避免解释执行，而是可以直接运行机器码，以此提升系统性能。

那么 JIT 又是如何影响微基准测试呢？下面我就通过几个场景案例，来给你介绍说明下。

首先，在代码运行的过程中，JIT 中会对一些比较小的函数方法实施内联优化，也就是将一个函数方法（对象方法）生成的指令直接插入到被调用函数的指令内，这样就可以通过减少函数调用开销来提升执行性能。

然后，针对程序中 For 循环频繁执行的代码块，JIT 也会根据循环执行次数来决定是否启动编译优化，当满足一定的次数门限后，就会实施栈上替换（OSR），也就是把循环体内生成的字节码替换为编译好的机器码来加速执行，从而导致 For 循环在不同遍历中的执行代码和运行时间不一致。

同时，JIT 的代码优化是实时动态的行为，会受制于 Code Cache 的大小限制。所以，如果优化后的运行效果不理想，JIT 还会触发逆优化，它的功能是把原来放到 Code Cache 中的机器码删除掉，这部分代码又回退为 Java 字节码执行。

所以综上所述，这些技术手段其实都会造成代码的执行时间发生变化，进一步就会影响微基准测试（但这只是 JIT 即时优化技术中很小的一部分，这里我们只需明白 JIT 技术会影响到代码的微基准测试结果即可）。

而除了各种技术手段的影响之外，还有一个原因，就是 Java 虚拟机在运行期存在两种模式：Client 模式和 Server 模式。Client 模式主要追求编译期的优化速度，而 Server 模式更关注运行期的性能，所以针对这两种模式，JIT 进行热点代码优化的默认策略并不一样，这也会直接影响到微基准测试的结果。

那么根据以上的分析，我们怎样才能避免 JIT 对微基准性能测试带来如此大的干扰呢？

答案就是使用充足的代码预热。也就是说，你首先需要将 Java 的被测代码循环执行很多次，以确保代码已经被 JIT 优化过，然后再对该段代码进行微基准测试，来获取测量值（如何更方便地进行预热，我会在后面的 JMH 测试框架部分讲解）。

补充：在 C/C++ 语言中，由于在编译期间，所有代码都被编译转换成了汇编指令，所以在对代码段进行性能测试时，并不需要这个单独的预热阶段。

所以简而言之，微基准测试就是对代码执行时间的一项测量活动，而既然是对时间的测量，肯定就会受到测量精度的影响。

那么，针对 Java 而言，测量时间的精度是否需要满足微基准测试的需求呢？下面我们就一起来探讨下这个问题。

测量时间的精度问题

在现实世界中，我们会使用手表来计算时间间隔，如果手表上的时间最小单位是秒，那么你可以大致认为测量出的时间间隔误差小于秒。而在计算机系统中，当测量时间使用更小的单位之后，那测量时间间隔的误差是否仍然小于最小的时间单位呢？

这个答案其实是否定的。因为对于计算机系统来说，通常测量获取的时间不是准确的。这要怎么理解呢？接下来我给你举个具体的例子。

在 Java 语言中，测试时间的方法通常会使用 System.currentTimeMillis()，这是一个获取系统当前时刻距离 1970 年 1 月 1 日的毫秒偏移量值，因为返回值是一个 long 类型的数字，所以可以帮助我们更方便地计算时间间隔。

不过，虽然这个接口获取的时间偏移是基于 ms（毫秒）单位的，但受制于底层实现的差异，每次获取时间的准确度并不确定，甚至有些场景下获取的时间偏差可能会超过 10ms。

因此为了解决这个问题，Java 语言中后来引入了一个 System.nanoTime() 方法，这是一个获取系统当前时刻与之前某一个时刻的偏移值，可以支持我们记录更精准的时间间隔。它可以获取更小的时间单位 ns（纳秒），但同样的，这并不代表误差会小于 ns。

补充：目前测量时间间隔的最精确方法是，通过指令获取代码运行期间，CPU 中的时钟寄存器差值，再根据 CPU 的时钟周期频率来计算出时间间隔。这种方式在做 C/C++ 实时系统的运行时间分析时，使用得比较多，但它也受制于 CPU 的指令级发射机制和编译乱序优化的影响，测试出来的时间间隔也会存在一定的误差。

实际上，针对较小的代码段运行时间测不准的问题，微基准测试的一种可行方式，就是迭代、累积运行多次后获取的测试时间间隔，然后再平均到每一次的运行时间上，这样就可以减少获取的时间间隔误差对测量结果的影响。

但这里仍然存在一个问题，就是对代码段迭代很多次，又容易触发 JIT 中的栈上替换（OSR）优化，可真实的业务代码在执行过程中并没有出现 JIT，也没有触发 OSR。所以这样就会导致基准测试值不能反映真实的业务性能水平问题，你也需要注意规避。

总而言之，针对 Java 语言，在进行微基准测试时，我们不能太依赖底层接口获取的测量时间精度，因为 Java 的底层无法保证测量精度是非常准确的。

不过，除了测量时间精度会对测量结果产生影响以外，由于软件代码本身的运行时间也是不确定的，所以针对这种情况，我们在做微基准测试的时候，还需要在基于波动的测量结果的前提下，来尽量准确地获取平均测量结果，以此支撑性能分析。

那么接下来，我们就具体来看看测量结果数据的波动现象。

测量结果数据波动现象

这里我们要先明确一点，就是我们不可能完全剥离掉测试时软硬件运行环境的影响，也不可能完全避免测试结果的计算误差，我们必须客观接受获取的测量结果存在波动的这种现象。

那么，由于测试性能获取的结果会是一直波动的，所以根据单次结果去判断性能是否退化，其实也会比较困难。

所以在这个基础上，我们可以基于统计学方法，先测量计算出性能测试结果的波动范围区间，也就是置信区间，然后根据测试结果是否落在置信区间，来判断性能基线是否发生变化。

可是这样问题就来了：如何计算出测试结果的波动范围区间呢？我们先来看一张示意图：

如上图所示，你可以获取大量的测试值并计算出平均值，假设你觉得 95% 左右的测量结果为可信数据，那么你就可以选择平均值周围 95% 的测量结果的最大值与最小值范围，作为置信区间。

实际上，判断微基准测试的性能是否发生变化，还有一个更有效的手段，就是使用图表协助分析测试结果的变化趋势。

如上图所示，绿色菱形为每一轮基准测量结果，其中你会比较容易看到一个性能拐点。这是因为图表携带了比置信区间更多的有效信息，更容易进行准确判断。另外，对于性能基线微基准测试而言，它的目标也并不在于追求单次测试结果的准确性，而是要测试出性能变化走势的准确性。

OK，在基于以上微基准测试所面临的问题分析之后，现在我们就知道该如何规避这些因素，以避免影响到微基准测试结果。而接下来我们要讨论的，就是如何更好地实施执行微基准测试的具体方法。

实施微基准测试的步骤方法

一般来说，在实施微基准测试的时候，你需要根据具体的被测试代码片段，手动编码很多代码逻辑来获取测量值。但这里存在一个问题，就是你会很容易忽略前面提到的一些实现因素，从而导致测量结果不能准确反映性能。

那么，有没有什么更快速、有效的测试步骤流程呢？这里我根据以往的实践经验，给你总结了一个微基准测试的基本步骤流程，可以帮助你更好地实现微基准测试。

这个步骤方法主要分为四步：

第一步，确定被测程序的软硬件运行环境、运行器配置等，都与真实的产品环境保持一致。

第二步，合理选择被测方法。针对 Java 而言，首先建议你针对包级别的对外接口方法进行测试，这种类型接口方法的性能更加稳定；其次，由于本身微基准测试有一定的成本，因此仅对性能影响比较大的关键方法进行测试才更划算；最后，由于执行时间越短的方法，测试准确的困难越大，建议选择被测方法的执行时间要超过一定的门限，比如 10us 等。

第三步，开发微基准测试用例，并验证正确性和准确性。正确性不仅需要确保被测方法被正常执行，已经完成预热阶段，还需要保证被测方法运行方式与产品上线时一致；准确性需要验证测试结果值是否在一个有效的区间范围内波动，才具有指导意义。

第四步，执行测试，并导出测试结果，并通过可视化手段分析变化趋势。

不过，如果是自己手动来规避微基准测试的各种问题的话，实施起来会比较复杂。好在每种编程语言都有现成的微基准测试框架可供选择，比如对于 Java 语言来说，JMH 就是首选的微基准性能测试框架；而对 C/C++ 语言而言，Google Benchmark 则是首选的微基准测试框架。

所以接下来，我就主要来给你介绍下 Java 的 JMH 框架。

JMH 测试框架是如何帮助完成微基准测试的？

JMH（Java Macrobenchmark Harness）是一个测试 Java 或 JVM 上其他语言的微基准测试工具，它把支撑微基准测试的标准过程机制与手段都内置到了框架中，从而可以支持我们通过注解的方式，来高效率开发微基准测试用例。

我们来看一个例子。如以下代码段所示，我们可以使用 @Benchmark 来标记需要基准测试的方法，然后写一个 main 方法来启动基准测试：

@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 2, time = 1)
@BenchmarkMode({Mode.Throughput})
public class Sample {

    @Benchmark   //这里标注的方法就是一个被测函数方法
    public void helloworld() {
        System.out.println("hello world")
    }
    //
    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(Sample.class.getSimpleName())
                .forks(1)
                .build();

        new Runner(opt).run();  //启动基准测试
    }
}

另外，在 JMH 中，我们还可以使用 @Warmup 注解来配置预热时间。下面的代码示例中，就表示配置预热 3 轮，每轮 1 秒钟，这样就可以跳过预热阶段，来规避 JIT 编译优化对测试结果的影响。

@Warmup(iterations = 3, time = 1)

然后，我们还可以使用 @Measurement 注解来配置基准测试运行时间。下面代码中表示的是配置测试 2 轮，每轮 1 秒钟，在每轮执行期间还会不断地迭代执行。因此，我们会得到两轮执行之后的一个测试结果：

Benchmark            Mode  Cnt       Score       Error         Units
Sample.helloworld    thrpt  2   2703833258.555 ± 354675008.250  us/op

除此之外，JMH 还支持以下几种测试模式：

Throughput，表示吞吐量，测试每秒可以执行操作的次数；

Average Time，表示平均耗时，测试单次操作的平均耗时；

Sample Time，表示采样耗时，测试单次操作的耗时，包括最大、最小耗时，以及百分位耗时等；

Single Shot Time，表示只计算一次的耗时，一般用来测试冷启动的性能（不设置 JVM 预热）；

All，表示测试以上的所有指标。

这样，我们就可以通过如下的方式来选择配置前面提到的测试模式：

@BenchmarkMode({Mode.Throughput})

最后，JMH 还支持多种格式的结果输出，比如 TEST、CSV、SCSV、JSON、LaTeX 等。如下所示，这是一个打印出 JSON 格式的命令：

java -jar benchmark.jar -rf json

而且 JMH 的测试结果在导出后，还可以使用 JMH Visual 进行显示，但这个工具只显示单个测试导出结果。所以在通常情况下，为了更好地监控被测方法的性能变化趋势，我们还需要持续地导出并保存 JMH 结果，这样才能通过其他可视化手段去分析其变化趋势。

当然了，今天这节课，我主要目的是带你理解做好微基准测试的方法与步骤，所以并不会给你详细介绍 JMH 的构建配置过程，这里我给你推荐一个基于 Gradle 构建的JMH 的样例库，你可以直接下载下来，参考开发测试用例或配置构建工程。

小结

热力学之父开尔文男爵（Lord Kelvin）曾经说过一句对性能优化领域有哲学指导意义的话：If you cannot measure it, you cannot improve it. 这句话的大致意思是，你只能优化你能测量到的性能问题。不仅如此，你也只能看护你能测量到的软件性能。

而微基准测试，正是你支撑与看护高性能编码实现的重要手段。

今天这节课，我带你理解了微基准测试会碰到问题与挑战、高效开展微基准测试的方法步骤，以及借助微基准性能测试框架来更好地协助测试的方法。其中，你需要重点关注的是做好微基准测试的理论和方法，这样当具体的测量结果不准确时，你就可以做到有的放矢，找到应对方案。

另外，通过学习今天的课程，你还可以在深入理解基线性能面临的问题与挑战的基础上，来指导在核心高性能模块软件开发的过程中，准确高效地开发微基准测试，并能够及时发现测试中存在的问题。

思考题

在真实的软件产品中，你有没有发现过哪些被测方法代码，很难保持测试态与运行态的执行方式一致的呢?

欢迎在留言区分享你的看法。如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

18 | Benchmark测试（下）：如何做好宏基准测试？

你好，我是尉刚强。在上节课，我给你介绍了如何才能做好微基准性能测试，而这节课的主要关注点则是如何才能做好宏基准级别的性能测试。

现在我们已经知道，宏基准性能测试的目标是获取软件系统级的性能基线水平，以此支撑系统基于性能去弹性扩展、部署运维等，或是指导系统设计层面的性能优化。比如说，针对互联网在线数据产品或在线视频直播产品，当为某个跨年晚会提供服务时，我们就可以根据性能基线水平提前扩容，部署对应规模的服务集群，应对流量峰值，以此避免由于瞬间峰值而导致整个业务瘫痪的尴尬情况。

然而，对大型软件系统进行宏基准测试，其实是一件非常复杂的软件工程活动，我举几个例子你就明白了。

比如，针对互联网服务，其用户服务很多样、复杂，所以很难模拟用户行为；而针对一些大型嵌入式设备，在业务场景中需要与很多具体设备进行连网，但我们知道，测试设备是非常昂贵的资源。

所以在这堂课上，我会来帮你分析系统级别的性能测试，都面临着哪些问题与挑战，以及要如何使用比较低的成本并正确获取系统的关键性能指标，从而更好地支撑你的宏基准性能测试能力。

系统级性能基准测试挑战分析

那么首先，我就来带你分析下对于系统级的性能基准测试来说，目前都存在哪些问题和挑战，以此帮助你理解做好宏基准测试方法论的背后原因。

挑战一：全量系统规模大，不易复制

随着业务与技术的逐步演进，从最初的单体架构演进至 SOA 架构，再到现在的微服务架构，软件的系统架构越来越复杂；而软件从原来的单机物理部署演进至集群部署，再到后来的云部署，部署形态也逐渐多样化；另外，随着分布式业务的增多，分布式协同也更加复杂，这就导致了系统运行状态更加多变且不可预测。

因此，在真实运行的产品系统之外，构造一个同等规模的全量被测系统的成本会非常大！

挑战二：引入多机 Cache 机制，仿真业务性能难

除此之外，在互联网业务场景中，当系统性能不能满足用户的需求时，我们确实还可以通过弹性计算来扩展系统的性能。但是在系统应用弹性扩展的过程中，一方面，弹性扩展引入的负载均衡机制增加了系统测试的复杂度；另一方面，弹性扩展能力也比较容易把系统一些潜在的性能瓶颈隐藏起来，就导致我们不能在项目前期及时发现问题。

所以为了优化软件的服务性能，在系统中的很多环节都会引入增加 Cache 机制。比如，在业务中经常使用分布级 Cache（如 Redis、MemCache 等），对数据库请求结果进行缓存；或使用内存级 Cache（如 Caffeine 等）缓冲一些临时数据；甚至在系统网关服务或 Nginx 上也可以设计缓存机制，来缓存部分页面请求数据等。

可是，对于系统的基准性能测试结果来说，引入的多级 Cache 机制，也更容易导致其不能准确反映真实的系统性能。

挑战三：引入安全机制，导致仿真用户难

在互联网业务中，系统的安全性越来越重要。因此，我们在软件中也会引入很多复杂的安全交互机制。可是，在采用安全交互机制后，支撑保护系统不容易被攻击的同时，也使得构造性能测试场景和数据的难度大大增加。

挑战四：业务场景多样分布，测试难复制

最后，在系统运行的过程中，由于接入的客户端种类越来越多，处理的请求也更加多样。所以单从这个维度上来讲，去仿真软件产品在线业务的请求分布会太过复杂，甚至是不现实的。

总之，基于上述的分析，你可以发现，从全系统级的端到端实施性能基准测试，需要处理解决的问题非常多。所以，直接通过产品端到端的性能测试成本会太大，而且测试效率也会比较低。

所以，我认为更高效的做法是：通过软件系统架构分析，将系统级性能基准指标拆分成规模较小的子系统或服务上的性能测试，然后再通过小规模的性能基准测试结果组合，分析系统级的关键性能，从而实现使用比较低的成本获取更核心的性能指标的目的。

而为了支撑这种性能测试方法，你就需要深入理解产品业务的系统架构，并学会系统科学地分解业务性能指标。

两年前，我参与了一个互联网 SaaS 服务的性能优化项目，在启动优化前需要先从头开始获取系统级的各种性能基线，而我正是通过这种方式去分解性能基准测试，从而实现了用比较短的时间获取到所关注的核心性能基线数据。

另外，当时我参与优化的 SaaS 服务主要是采用微服务架构，而微服务架构也是现在互联网业务的典型架构之一。所以接下来，我会基于当时项目中的性能基准测试经验，剥离掉具体的业务逻辑，在此基础上给你分享下针对微服务架构，进行性能基线测试的分析与设计过程。如果你在以后的项目开发中需要做宏基准性能测试，你也可参考借鉴其中的做法。

基于软件架构设计基准性能测试过程

好，我们先来了解下这个互联网产品的微服务架构，它的简化示意图如下所示：

首先你会发现，整个产品的业务功能可以通过多个微服务来实现，如微服务 A、微服务 B、微服务 C，它们之间可以基于标准接口（如 Rest 等）进行通信；同时，你还能看到每个微服务中都包含了多个实例，每个微服务实例数是不相等的，这是根据业务的性能需求弹性扩展出来的；最后，如图的底部所示，所有微服务会共同依赖一些公共的基础设施，包括数据库、消息队列、分布式协同锁、第三方应用或 SaaS 服务，等等。

而由于对整个系统进行性能基准测试的成本比较大，所以接下来，我们还需要针对这个系统进行性能基准测试分解。

那么具体的分解步骤是怎样的呢？下面我来给你具体讲解一下。

第一步，我将多个微服务组合后的系统级性能测试，划分为了多个微服务级的性能测试。因为对微服务架构而言，单个微服务级的性能基准更具有指导意义。

第二步，在对单个微服务进行性能基准测试时，我又将注意力集中到了最重要的两点。

1. 单个微服务实例的性能基线。

首先，单个微服务实例在部署时，使用的硬件资源规格是相对确定的，因而方便我们进行基准化性能测试；

其次，由于单个微服务实例的性能基准指标具有良好的指导意义，因此当系统面对突发业务时，我们可以更好地基于单个服务性能来规划弹性策略；

最后，对单个微服务实例进行性能基线测试，也会更加节省资源，成本会更低。

2. 微服务多个实例间的并发性能基线。

造成并发性能瓶颈的，可能是来自于数据库访问，也可能是来自于分布式互斥锁。所以，针对数据库访问性能瓶颈，我们可以通过单独数据库级性能测试来检测；而针对分布式互斥锁等，我们需要单独构造性能模拟测试场景，来进行测试分析，或者也可以通过一些数学形式化分析手段来计算获取。

但是，当把单个微服务作为被测系统的时候，你可能会发现它与周边的微服务交互太复杂，因而不好构造测试场景或数据。所以这个时候，我建议你也可以选择组合几个耦合度比较大的微服务一起进行性能基准测试（如果业务的微服务架构设计不是太差的话，相信你可以找到这样的被测微服务或者微服务组）。

第三步，针对公共基础设施的性能测试可以被独立处理，具体还可以分为以下几种情况：

不需要性能测试的基础设施，比如部分数据库，很多数据库的性能基线水平可以通过其他渠道获取，并且判断在短时期内不会对系统造成性能瓶颈，可以先不用测试。

需要精准性能测试的基础设施，比如部分第三方系统或者服务的性能，直接影响到关键业务流程，但是性能基线没有任何参考。

通过形式化验证分析的基础设施，比如互斥并发上限瓶颈等，可以通过形式化或数学分析去验证，尽量避免直接去测试。

如此一来，基于以上分析，我们通过将很多微服务组成的系统级性能测试，分解成了多个微服务或微服务组、公共基础设施的性能测试，然后针对微服务级的性能基准测试，再进一步分解，识别出关键的性能测试目标，从而实现了以大拆小的效果，这样就可以通过尽量小的代价，去获取系统的一些关键性能基线水平。

但是这里要注意，在针对被测系统进行性能基准测试时，如果我们还不了解系统的运行模型，就很容易走进一个死胡同，造成测试不仅费力费时，而且结果还不准确。

那么既然如此，有没有什么办法可以帮助我们理解系统的运行模型呢？当然是有的，接下来我要介绍的利特尔法，就可以很好地帮助你去理解软件系统的运行状态。

利特尔法则

利特尔法则（Little's law）是由麻省理工大学斯隆商学院的教授约翰·利尔特（John Litte）提出，并在 1961 年就已经被证明的一个数学模型，使用这个数学模型来分析现在互联网服务的请求处理负载，也非常适用。

那么首先，我们就来了解下利特尔法则的表述：稳定系统下的处理能力 L，等于系统稳态请求到达速率 γ 乘以单位请求处理时间 W，书写后的公式是：L=γ∗W。

为了更好地理解这个数学模型，这里我们可以把它应用到互联网的请求负载处理中，用 γ 代表并发用户数、W 代表平均响应时间、L 代表系统的吞吐量（TPS），然后基于这个数学模型可以推导出如下所示的图例：

上面的两张图，分别展示了利特尔法则所揭示的软件系统的两个非常重要的性能规律，接下来就分别来看下这两个规律是什么吧。

首先，如图的左侧所示，它展示的关于并发用户数与峰值吞吐量线性关系的第一个性能规律，也就是说当被测系统达到性能饱和状态之前，吞吐量会随着并发用户数逐步上升，但是当系统达到饱和状态后，系统的吞吐量会达到峰值，不能再继续提升。

所以，如果把这个法则应用到微服务请求的性能基准测试中，其直接表现就是：当系统处于饱和态后，微服务的 TPS（Transaction Per Second，性能测试指标）将不会再提升。

接下来，图的右侧部分，它展示的是并发用户数与平均响应时间的第二个性能规律，也就是当被测系统请求达到饱和状态前，请求的平均响应时延是比较稳定的，但随着并发用户数的进一步提升，当被测系统达到饱和状态后，由于吞吐量不变，请求开始排队处理，因而单个请求处理时延将会急速上升，从而直接影响到客户感受。

也就是说，由于我们在对被测系统进行性能测试时，主要目标就是寻找这个系统饱和的边界。因此，我们通常只需要关注其中一个图即可，然后根据利特尔法则，就可以计算出另一个性能指标。

不过，在真实的系统服务中，可能会因为业务间的请求处理而存在一定的干扰，导致与理论的曲线有略微的差别，但是一般情况下对最终的测试结果影响不太大。所以这里你要重点提防的就是，在具体的性能测试过程中，如果测试方法使用不恰当，或者真实并发用户数不真实，都会很容易导致测试获取的性能基线不准确。

那么现在你可能要问了，针对宏基准性能测试而言，测试本身也是一个复杂的软件活动，怎么才能保证性能测试结果正确性呢？

我认为，最好的办法就是在性能测试之前，首先验证性能基准测试的正确性。下面我就带你具体了解一下，这样做的好处和具体操作步骤。

验证性能测试的正确性

为了验证性能测试结果的正确性，需要先去验证请求与响应数据的准确性。我会推荐使用脚本或工具，抓取现网中真实的业务请求与响应消息，来作为性能测试输入数据。

不过，这样做可能在一些场景下会比较困难，所以你也可以使用脚本来构建业务请求，但要尽量追求接近真实的业务请求。

那么，是不是性能测试使用的请求与响应数据都校验合格后，就能保证性能测试的正确性了呢？

还不行！为了验证性能测试结果的准确性，你还需要获取被测系统上的一些监控数据，具体包括两点：系统运行态的资源状态监控信息、软件内部实现态的监控状态。

其中，系统运行态的资源状态监控信息是需要优先保证的，这样做主要有几个好处：

首先，通过检测资源状态信息，可以用来验证被测系统是否真实达到瓶颈，而不是由于测试工具的性能瓶颈造成的假象。

其次，还可以验证被测系统的资源使用瓶颈状态是否与预期一致，用来判断峰值性能的合理性。

最后，获取资源状态监控数据还可以用于支撑软件架构性能的进一步调优。

实际上，系统运行态的资源级可监控信息非常多，这里我推荐你两个工具，来协助监控被测系统运行态的资源使用状态。

第一个工具：Glances。这是一个由 Python 语言开发的工具，它能够报告统计 CPU、内存、网络、磁盘和进程使用状态，可以避免你去记忆操作系统提供的各种复杂的监控工具与命令。

下面是一个使用 Glances 获取监控状态的截图，你能看到各种资源使用率。

但是要注意，使用这个工具获取的是全量状态信息，你还需要识别被测系统或者服务所占用的资源。在一些场景下，你可能需要定制对被测系统的特殊监控信息，而这个时候你就可以使用第二个工具：库—psutil。

使用这个库可以通过代码定制实现监控逻辑，并借助 Git 库管理起来。比如，你使用下面的 Python 代码就可以很方便地获取系统监控信息：

import psutil
psutil.cpu_times() //获取cpu 信息
psutil.virtual_memory() //获取内存信息
psutil.disk_partitions() //获取磁盘信息

当然，psutil 支持的功能是比较多的，详细的 psutil 使用说明，你可以参考官方说明。

小结

十多年前，我在针对无线系统产品级进行性能测试时，使用的每套性能测试设备价值都需要好几百万，所以在当时就尝试过将一些性能测试拆分成更小的子系统级的测试，从而减少了对产品级性能测试设备的依赖，取得了非常好的效果。

那么，在互联网业务领域中，当系统级性能测试也非常复杂的时候，你也可以通过对软件架构的分析，将其拆分成服务级与基础设施上的性能基准测试，然后再借助各种工具来完成更低成本的性能基准测试。

思考题

在你的产品性能基准测试中，有哪些性能基线水平可以通过一部分的测试数据，再加上一些数学分析推导来获取呢？

欢迎在留言区分享你的观点和看法。如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

19 | 性能测试工具：如何选择最合适的性能测试工具？

你好，我是尉刚强。这节课，我们来聊聊性能测试工具的选择和使用。

我们都知道，性能测试是在确保软件功能正确的前提下，通过一些测试与加压手段，来衡量软件性能表现、分析性能问题的一种技术方法。而性能测试工具，则是支撑性能测试工作效率的重要保障之一，所以在进行性能测试之前，选择一款合适的性能测试工具非常重要。

不过，虽然很多工具使用的性能测试原理是相似的，但由于不同业务领域的差异性太大，选用的性能测试工具可能完全不同。

比如说，企业应用服务领域与专用设备领域所使用的性能测试工具，就是完全不同的体系，像 LoadRunner 这类就属于企业应用服务领域的性能测试工具。而在专有设备领域中，智能汽车、无线通信设备所使用的性能测试工具，有很多都是专属硬件设备，基本都是定制化的，所以对其他领域的性能测试工具的选择借鉴意义很小。

因此今天这节课，我要给你介绍的性能测试工具选择，就主要聚焦在企业应用与服务级的维度上。

另外我们也知道，如今处于富技术工具的时代，单独针对某个业务领域的性能测试，可选的性能测试工具也依旧非常多。但如果我们选择的性能测试工具不合适，就将会长期影响到整个团队的性能测试效率和成本。

所以接下来，我会先给你介绍下该领域的性能测试工具现状与发展趋势，在此过程中，你就能明白每种性能测试工具的特点和优势，以及它背后主要解决了什么问题。然后，我还会带你理解选择性能测试工具的参考标准。这样，当你在掌握了选择这些工具的参考标准之后，也就找到了理解它们背后工作原理的捷径，以后在进行性能测试之前，你就可以快速地找到合适的性能测试工具，提升性能测试的效率。

性能测试工具的现状与发展趋势

好，下面我们就先来了解下，应用服务级性能测试工具的现状和发展趋势。不过在开始之前，我想先说明一下今天我要给你介绍的性能测试的相关工具，主要包括：JMeter、LoadRunner、Locust、PTS、k6、Postman、Chrome 调试器等。

虽然在课程中，我无法全面地罗列出目前业界所有的测试工具，但这些工具里有很多都是我在以往项目中真实使用过的，所以它们一定是最具代表性的。学完这部分内容后，接下来你就只需要搞明白自己当前业务产品的性能测试需求，就可以据此选择出合适的性能测试工具了。

OK，现在，我们就来具体了解下性能测试工具的变化趋势。早期的性能测试工具以 JMeter、LoadRunner 为代表，它们都使用桌面程序的运行方式，在业界也已经有很长的应用历史，所以内置的功能很丰富。而由于早期软件工程的生命周期很长，性能测试工具通常是从企业级应用的端到端进行性能测试，并会由专门的测试人员长期负责。所以，这类性能测试工具的特点就是功能相对比较封闭，而且使用起来也比较笨重。

但如今，软件技术架构与软件工程都发生了很大的演进变化，比如软件技术架构从单体转向了分布式微服务化，而软件工程管理也从瀑布式向敏捷式这种更加快速的迭代方式演进。所以，为了更好地适应这个时代，性能测试工具也发生了很大改变。其中，云服务化、代码化就是两个最主要的变化趋势，下面我来重点说明下。

云服务化性能测试工具

现在可以说是云原生（Cloud Native）的时代，所有的技术与服务都逐步开始提供按需服务的能力，而性能测试对计算资源的需求比较大，所以按需使用的能力也很重要。

那么，关于云服务化的性能测试工具，这里我给你介绍一个典型的代表：阿里云的 PTS。

PTS（Performance Test Service）是阿里云上提供的一个性能测试服务，你在登录购买之后就可以立即使用。下面展示的就是 PTS 登录之后的控制器界面视图：

从图中你能看出，阿里云的 PTS 提供了场景录制、场景管理、施压配置、报告分析等性能测试相关的所有基本功能。当然，它最大的特点是支持非常大的弹性计算能力，所以你可以很轻松地配置出超级大规模和强度的压力性能测试。

实际上，云服务化的性能测试工具，与传统的性能测试工具（如 JMeter）在界面交互上是比较像的，所以，对原来使用传统性能测试工具的程序员来说切换成本并不大。同时，云服务化的性能测试还提供了比较完整的帮助手册，所以学习成本也比较低，非常容易上手。

另外，从阿里云的 PTS 使用界面上你也会发现，云端性能测试工具还有一个好处，就是可以帮助我们省去性能测试时安装测试工具的步骤。因此，如果你的性能测试场景不是特别复杂，也不需要考虑性能测试与 CI 流水线集成的场景，那么使用云端性能测试工具，就会是一个不错的选择。

代码化性能测试工具

如果你是一个非常喜欢敲代码的程序员，那么你可能会对界面化的性能测试工具没有什么好感，毕竟这种工具是通过界面的配置能力来生成测试代码的，所以并没有代码实现灵活和丰富。

不过我告诉你，现在有些性能测试工具，已经可以完全基于代码来开发性能测试场景用例了，所以你就可以像开发业务代码一样去编写性能测试用例，比如 Locust、k6 等。我把这种工具归纳为代码化性能测试工具。

这里我先以 Locust 为例，给你展示下它基于代码开发性能测试用例的过程。

实际上，对于这种性能测试工具来说，你可以完全基于 Python 来编写构造性能测试用例，下面的示例是我曾经编写的一个性能测试用例中的小片段，代码中要体现的功能是测试接口发送一个 POST 请求，并且携带 cookies 和 body 消息体：

// test_submit_case.py
from locust import HttpLocust, TaskSet, task
class WebsiteTasks(TaskSet):
    @task
    def add_entry(self):
        body ={"field_1":"asdf"}
        cookies = dict(_session='V1hjaWV4Ujd3WndDWWZBa0ZiTjdvd2tlV2p3NmtkUzA0RTM2c213SUs3TXpDQlhqT1BoRzFKUVBwZEhXbThPMjZGdUU3bUpPYjRTNEx5RFBxaEJaUEFYVUtFZHhMdUFDd1o3SldScjFzbkc1TGpqV2xnRjdhSXRaUEIvYi95WHd1WlZZU3BiQ0VUM0U1M1BDVFNzLzFBPT0tLVB2bUkyMVgrQkJKK0VsNWI5bndzTFE9PQ%3D%3D--109ab671b0bbc1fd56af4b87a1fcea3d61faeedd', path='/', domain='localhost',  Expires='Tue, 19 Jan 2038 03:14:07 GMT')
        self.client.post("/test/api", json=body,cookies=cookies)

class WebsiteUser(HttpLocust):
    task_set = WebsiteTasks
    min_wait = 5000
    max_wait = 15000

这样，在运行如下代码之后，你就可以在界面启动性能测试和查看结果了。

locust -H https://xxx.net -f ./test_submit_case.py

而 k6 也是基于代码化的性能测试工具，但同时，它也提供了基于界面自动生成性能测试代码的机制。这是一个使用 Node.js 编写的性能测试用例，用来测试页面的 get 操作：

//test_get.js
import http from 'k6/http';
import { sleep } from 'k6';
export default function () {
  http.get('http://test.k6.io');
  sleep(1);
}

在安装完 k6 之后，你就可以使用命令k6 run testget.js 来运行上面的性能测试用例，并且生成测试结果以供进行性能分析。

实际上，k6 也是一款云服务化的性能测试工具，当你注册登录了 k6 Cloud 之后，就可以直接在 k6 Cloud 上使用脚本或者界面创建性能测试用例。

作为辅助使用的性能测试工具

另外，还有一些性能测试工具，它们提供的功能并不算非常完备，比如 Postman 工具，它可以临时地针对某个 REST 接口进行性能测试，所以在做性能测试用例开发和调试时，你可以拿来使用。而还有一些工具只是作为性能测试工具的协助手段，比如 Chrome 调试器，你可以用它来快速获取一个 REST 请求的 Node.js 接口调用代码。

这里我们也来看看使用 Chrome 调试器的具体操作流程：首先，使用快捷键进入 Chrome Debug 模式，在操作 Web 页面之后，在菜单 Network 下寻找到被测试的 REST 接口；然后点击右键，我们就可以选择对应的菜单操作，如以下截图所示：

操作之后，你就可以获取对应 REST API 调用的 Node.js 代码，如下所示：

fetch("https://www.joycode.art/images/logo.png", {
  "headers": {
    "sec-ch-ua": "\"Chromium\";v=\"88\", \"Google Chrome\";v=\"88\", \";Not A Brand\";v=\"99\"",
    "sec-ch-ua-mobile": "?0"
  },
  "referrer": "https://www.joycode.art/blog/01-test-design/",
  "referrerPolicy": "strict-origin-when-cross-origin",
  "body": null,
  "method": "GET",
  "mode": "cors"
});

接着，你就可以将其添加到 k6 对应的性能测试脚本中，来运行性能测试。当然，你也可以完全使用 Node.js，来自己快速开发构造一个简易的性能测试场景。

好，到这里，我们就对性能测试工具的使用现状和发展趋势有一定的了解了。不过我们还是需要再进一步思考一下：现在的性能测试工具这么多，并不是每一款都适合自己的性能测试场景呀。所以，我们还需要基于一些评选参考标准，来帮助我们选择合适的性能测试工具。

性能测试工具评选参考因素

我们知道，传统的性能测试是在项目交付的后期开展的，而这样做会出现的问题就是，通过性能测试发现的性能问题，修复成本太大，甚至可能会打乱产品的交付节奏与计划。同时，由于性能测试与开发之间的脱节情况也比较严重，所以就会导致性能测试问题的发现与分析过程效率很低。

因此，为了更好地开展性能测试，我们其实可以把性能测试的工作尽量提前，打破性能测试团队与特性开发团队之间的壁垒，让性能测试成为研发团队内部的一项活动。

那么为了更好地实现这个目标，依据我在性能测试中积累的实践经验，我给你总结了一些很实用的评选参考标准，你可以基于这些标准来选择性能测试工具，这样就可以更好地把测试工作和开发工作结合起来，进一步也就可以帮助团队之间进行良好的协作了：

这些评选参考标准是按照优先级程度由高到低排列的，下面我就逐个给你详细介绍下。

是否支持测试接口类型

首先你要注意，这其实是一个必要的条件。也就是说，这个评判标准与软件是否满足业务功能一样重要。毕竟如果测试工具不支持测试接口，那就不能完成最基本的性能测试需求了。

我举个例子，如果你的被测接口是 FTP 接口，但选择的性能测试工具只支持对 REST 接口的测试，那么这款性能测试工具就不是一个好的选择。像 JMeter 这类历史沉淀比较久的性能测试工具，它所支持的测试接口类型就很丰富，比如 MOM（Message-Oriented Middleware，消息中间件）、JDBC SQL、FTP 等。

但在新的软件技术架构实现中，接口越来越标准化，所以现在新产生的性能测试工具，支持的接口类型可能就比较少（比如 PTS 就没有 LoadRunner 支持的接口多）。因此，在选择性能测试工具前，你首先需要确保该工具支持你需要进行性能测试的所有接口类型。

压测资源的弹性能力

这同样也是一个很重要的考虑因素，但也是在调研和选择性能测试工具时，我们比较容易忽略的因素。以 Locust 性能测试工具为例，受制于本地 CPU 的单核性能，当压测并发用户数达到一定的数量时，测试工具侧的性能就很容易先到达瓶颈，从而造成测试出的系统性能不准确。

那么这个时候，你其实也可以通过分布式部署和运行模式，同时启动很多个节点来进行性能测试，但这样造成的性能测试复杂度就增加了。也就是说，如果你使用 JMeter、LoadRunner 这类需要安装部署的性能测试工具，就需要考虑到这些因素。

但对于像云服务化的性能测试工具，比如 PTS，你就不用担心性能测试工具本身的性能限制了。

代码化能力

这是我个人很看重的一项评估依据。为了提升运维效率，DevOps 已经被更多人所关注和认可，而基础设施代码化是支持 DevOps 的重要技术手段之一。那么对性能测试而言，测试用例代码化带来的好处，就是你不仅可以使用版本管理工具来管理测试用例，还能更容易地实现性能测试自动化，并且能很方便地集成到 CI 流水线上。

测试结果的可视化能力

很多性能测试工具都支持了测试结果的图形化显示，但是你要注意，这中间依旧存在一定的差异。

首先，不同的图表显示方式对结果分析的帮助是不一样的；其次，性能测试结果还有一个重要价值，就是可以帮助我们分析软件迭代演进期间，性能基线的变化趋势。

比如，使用 k6 这种性能测试工具，可以便于我们把最终的测试结果对接到数据库中，然后我们就可以使用 Grafana 去显示和分析性能基线的变化趋势。那么，这种可视化能力的价值，就要大于只能在工具内来图形化显示性能数据的能力。

安装部署便利性

这也是一个考量因素。我们知道，有些性能测试工具，可能只需几个命令就可以自动化安装了，而基于云的性能测试工具，可能压根就不需要去安装。

那么就我的观察和实践，我发现越复杂的安装过程，就越容易在安装过程中出错，而且对每一位使用该工具的研发人员来说，这都是不能逃避的工作。所以，从整个研发团队的角度来思考的话，需要安装部署的性能测试工具所花费的成本会更大。

是否支持录制脚本

使用录制脚本可以比较方便地构造测试场景与数据，但是以我个人的经验而言，这个优先级并不是非常高。

主要是由于两方面的因素：一方面，我们在录制脚本的使用过程中很容易出错，而且还需要手动修改生成的脚本，使用起来也不是非常高效；另一方面，录制脚本的部分能力，我们其实可以依赖其他工具协助完成，比如 Chrome 的 Debug 模式。

是否收费，是否有售后

这个考量因素的优先级因公司而异，有些公司并没有支付性能测试工具的预算，所以收费产品只能排除在外。而有些公司会有这样的预算，那么你就可以从研发团队成本的视角，来考虑选择合适的性能工具。

小结

首先你要知道，对软件系统的性能测试并不是一次性的，为了保持软件系统在演进过程中的性能，可以长期处于有效状态，你需要不断对其进行性能测试，而性能测试工具就是保证性能测试效率的关键因素。

那么在今天的课程中，我就从企业应用与服务级的性能测试视角，给你介绍了性能测试工具的发展现状。同时，基于为了更好地开展性能测试的目标，我还带你了解了选择性能测试工具的一些参考因素。当你参与产品的性能测试场景与需求存在差异时，你仍然也可以参考这些评选参考因素中的内容，来作为你选择性能测试工具的依据。

思考题

在你的产品性能测试中，性能测试用例是代码化的吗？具体用例是怎么管理的呢？

欢迎在留言区分享你的答案和思考。如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

20 | 性能看护： 如何更好地守护产品性能？

你好，我是尉刚强。

前面几节课，我们学习了基准测试的相关技术和方法，知道了如何选择合适的性能测试工具。而所有的这些技术和手段，最终目的其实都是为了更好地看护软件的性能，并更好地支撑软件设计与优化。

但实际上，对目标软件的性能测试与看护，是一项工作量投入比较大的软件工程活动。所以在以往的咨询工作中，我就发现有不少的研发团队，虽然他们有做好软件性能看护的意识，可是在实战过程中，由于没有系统方法论的指导，中间很多工作的开展效率会比较低，而且经常会走一些弯路，从而就出现成本与收益不匹配的情况，进一步就会丧失继续投入性能测试与看护的信心。

当然，也有不少的研发团队在项目开发的过程中，并不重视性能测试与看护环节，因此就很容易陷入产品性能恶化的泥潭中不能自拔。

所以，今天我们就一起来好好思考下，怎么样才能更好地实现软件产品的性能看护。

不过我也要说明一点，就是这节课我并不是要讲解具体的工具和技术，而是要帮你建立起高效实施性能测试与看护的核心价值观。这是因为可复用的方法，往往会比具体的工具和技术使用技巧重要许多，它可以帮你把有限的精力用到刀刃上，这样你就可以花费更少的成本获得更佳的性能测试与看护效果。

所以在这节课中，我会给你分享我在之前参与过的众多性能优化项目中，不断摸爬滚打之后提炼总结出来的经验和方法。我把这些经验方法归纳为了三条指导原则，分别是自动化、测试前置、测试驱动。掌握了这三条指导原则以后，你就可以有效地提高性能测试与看护的工作效率了。

那么接下来，我们就从第一条原则“自动化”开始学习吧。

自动化

所谓的自动化，就是把所有重复性的手动工作，尽量交给机器去自动执行，这样就可以把人解放出来，做更有价值的事情。而我们知道，在对软件系统进行性能测试的过程中，中间潜在的繁琐、重复性的工作会非常多，所以如果可以把这部分工作都自动化，就可以大大提高性能测试的效率。

那么接下来，我就通过一个例子，来带你看看如何通过自动化来影响性能测试的效率。

我刚开始参加工作不久，有一段时间主要是负责项目的性能攻关测试工作。在每一轮性能测试的过程中，我都需要同时操作十几台电脑，并在每台电脑上启动一个测试程序，然后记录不同时间段的测试结果，并重新修改测试配置，一直重复这个过程直到完成下一轮的测试。

可是这个过程实在是太麻烦了，所以到后来，我终于下定决心编写了一个测试程序，帮我自动化地进行测试，从而让我省下了很多时间去学习业务代码。

实际上，在进行性能测试的过程中，不仅中间会包含很多像我这样非常繁琐的重复性工作，而且很多时候，有些重复性工作还比较隐蔽，它们会偷偷消耗你的时间，而你还无法及时发现。

所以，在做性能测试的时候，我们如何才能得知哪些环节可能存在重复性工作呢？以及我们应该怎么去避免呢？

其实，这里我们可以直接根据性能测试的各个阶段，来主动规避重复性工作。下面我就给你具体介绍一下。

测试数据准备阶段

虽然不同软件产品在性能测试的过程中，获取和准备测试数据的方法都是不一样的，但是中间的大部分工作都应该尽量做到自动化，比如说自动生成数据、通过代码抓取现网数据等。

这里我举一个真实的例子，之前我在测试一个产品的性能时所需要的数据，是从 MongoDB 中 dump 出来的 JSON 文件，但它与性能测试接口的请求格式并不一样。当碰到这种情况时，聪明一点的性能测试人员可能会找一个 IDE 去批量修改这些数据，但这样依旧会花费很多时间，而且下一次测试还需要再来一次。

那么我认为最佳的方式，就是通过代码来实现这个数据的转换过程，这样一次的代码开发成本，就可以节省下后续很多性能测试中需要的数据处理时间。

其实，不光是按照测试需求来生成数据，在现网中采集数据等环节，也都有很多重复性的工作。就拿这个例子来说，从 MongoDB 中 dump 文件也是重复性工作，它也可以做到代码自动化。所以，从项目管理的视角出发，你更应该关注这个阶段的自动化。

测试环境准备阶段

其实，在测试环境准备阶段也有很多工作可以自动化，比如测试软件、测试脚本部署、系统环境变量设置等工作，这些都可以通过代码化管理。我就举个简单的例子，比如你可以将测试工具安装标准化到 Dockerfile 中，然后基于容器化运行，并且将环境变量配置都通过 Shell 命令来实现，这样就可以使得整个测试环境的准备过程完全自动化，不需要手工操作。

测试执行阶段

然后就是测试执行阶段，对于基于网络服务的很多性能测试工作来说，你就可以选择上节课我介绍的代码化性能测试工具（如 Locust、k6），来更好地支持代码自动化执行。对于很多嵌入式设备来说，定制化的性能测试工具其实也可以朝着自动化逐步演进，这样就不仅可以提升测试效率，还可以降低人力成本。

测试结果记录阶段

最后，就是测试结果的记录工作。由于手动记录测试结果比较方便，所以这个环节经常容易被忽视。但在我之前参与的性能测试项目的分析过程中，就出现过不少次因为测试结果记录丢失或者出错，导致整个性能测试返工的情况。

所以对此你也需要注意，如果可以通过代码来实现自动化的话，就可以尽量避免出现这种记录丢失或出错的情况。

总而言之，这里我总结的是在性能测试的过程中，你应该从哪些环节去尽量挖掘可以自动化的点。你在做性能测试的时候，就可以参考这些要点，去寻找和分析那些影响测试效率的重复性工作，并将它们自动化。

接下来，我们继续了解第二条提升性能测试与看护工作效率的指导原则：测试前置。

测试前置

测试前置的意思就是在软件生命周期中，尽早启动性能测试，尽早获取反馈，而不是把性能测试只作为产品发布上线前的最后一个动作。我估计这里你可能要问了：性能测试前置对软件性能看护来说有什么好处呢？下面我就通过一个例子来给你分析下。

很早之前，我参与过一个嵌入式系统的性能主导重构项目，在项目的开发阶段，我们团队就搭建了针对该子系统的基准性能测试工程。虽然这套性能测试工程运行在通用 PC 上，与真实的嵌入式系统的硬件存在一定差异，但并不妨碍我们通过这套工程，来提前识别出很多系统在设计与实现的过程中潜在的性能问题。

就比如说，你可能也遇到过在代码中添加了 SQL 操作，引入慢查询的性能问题，而你应该也很清楚，这些问题在软件交付的后期会造成比较大的成本浪费。

所以，也就是从那个时候开始，我在参与接下来的性能优化项目时，就开始思考如何突破在软件后期才开展性能测试的传统思维。后来我发现，其实我们可以把性能测试工作尽快提前和尽量拆小，也就是说将更多的性能测试拆分成组件 / 服务级的性能测试，与核心模块的单元级性能测试一起来实现。而实现这个过程的指导原则正是测试前置，由此我就推导出了一个性能测试体系，可以帮助我更加高效地对系统进行性能测试与看护。

那么这个体系具体是什么呢？我们来看一张图，这是我总结的性能测试理想架构与传统功能测试理想架构的对比图。

传统功能测试从上到下大致可以分为三层，分别是系统级测试、组件级测试、单元机测试。而你可以看到，在图中的左边，功能测试的呈现层次的是金字塔形，也就是说其理想的软件测试分布规模为：大规模的单元级测试，仅次于单元测试规模的组件或服务级的测试，最后是少量规模的系统级测试。

而性能测试的理想架构如图的右侧所示，我认为应该由少量的系统级性能测试和单元模块级别的性能测试，与尽量多的组件或服务级的性能测试组成。

这是因为，对于全系统级别的性能测试来说，受制于系统的业务复杂性，容易导致测试场景和执行性能测试的成本非常大。就比如说，在我曾经参与的性能测试项目中，有些团队在全系统级别性能测试上投入了很大的精力，但最后的收益并不够理想。

而对于单元模块级别的性能测试（也就是第 17 讲提到的微基准测试）来说，因为系统中的大部分业务代码并不是热点代码（依据 2/8 原则），所以对性能影响并不关键的业务逻辑而言，开发性能微基准测试用例的性价比并不是非常高。

补充：这里你要注意，对于一些系统性能的关键模块，比如核心算法模块来说，开发微基准测试其实还是比较有价值的。

那么现在，我们就来探究下为什么组件或服务级性能测试，其分布规模需要是最大的。

实际上，我在第 18 讲中就提到过，如果将系统级的性能测试指标，分解到组件或服务级的性能测试上，可以降低测试的复杂度和成本，实现以大拆小的效果。而这样做，其实也更容易通过测试前置，更早地识别和发现性能问题（具体的分解方法步骤，你可以再去回顾复习下上节课的知识点）。

但是，目前大部分的软件研发团队，目光只聚焦在系统级的性能测试，而组件或服务级性能测试和单元模块级的性能测试这块是空白的，因此就比较容易陷入到系统级性能测试的各种复杂问题之中，花费很大的成本，但是收益并不高。这就是因为，团队没有采用性能测试前置的原则，去构筑更高效的性能测试体系。

测试驱动

OK，最后一个关键的指导原则就是测试驱动。你应该会想，这是什么意思呢？

在一般情况下，我们都会认为性能测试最主要的目标，就是获取产品的基线性能，这样在基线性能出现恶化的时候，我们就可以第一时间发现问题。那么除此之外，性能测试还有别的用处吗？

事实上，由于软件需求的不断演进和变化，为了保证软件的性能可以长期保持竞争力，我们需要从高性能设计、高性能编码实现、性能调优等多个维度一起入手，而所有这些优化手段都应该基于性能测试来驱动进行。

我为什么会得出这样的结论呢？其实在前面的课程中，我也已经多次给你阐述过这个观点。比如在第 7 讲数据库选型设计中，我提到筛选的依据往往需要基于数据库的性能测试结果；在第 2 讲并行设计架构模式中，我也讲过需要基于不同业务逻辑的性能测试结果来分解，而不是随便一刀切；第 11 讲针对数据结构和算法的选择，我也明确点出需要基于性能测试来调整优化才更有效。

所以首先，我们在确定高性能设计的关键决策点时，都应该有针对性地开发性能测试用例，并监控不同软件版本变更后的性能表现差异，这样才可以决策是否需要在软件设计上做调整。

而对于高性能编码实现来说，也是同样的道理。比如核心数据结构和算法的性能，也会随着业务数据的特性和规模改变而发生变化。因此，我们也应该针对这些核心模块，开发微基准测试用例，来监控分析性能的变化趋势。

其实，测试驱动的核心理念，就是在做软件设计优化、编码优化、性能调优的时候，都基于性能测试来驱动优化工作，而不是想当然。

我举个例子。有的研发团队业务数据量规模非常小，查询计算逻辑比较复杂，因此开发人员就主观分析认为，使用 Elasticsearch 可以提升查询速度，然后就花费比较大的成本将业务数据都迁移到了 Elasticsearch 中，结果性能也并没有明显的改善。

但其实，如果该研发团队在决定迁移数据之前，先做好性能测试分析工作，可能就会发现这种解决方法并不能提升性能，这样就避免出现收益小且效果不好的情况了。

小结

今天这节课，我给你总结了曾经在很多的性能优化项目中，我实现高效性能测试的实践经验，我把它称之为高效性能看护的核心价值观。那么，在学习完今天的课程后，我希望你就可以借鉴这些经验来观察和思考下，在你的软件性能测试与看护过程中，是否也有一些环节可以改进，以此帮助提升自己的工作效率。

当然，我总结的可能并不全面，所以我更希望你可以在学完课程之后，主动去思考和总结下，在性能测试的过程中还有哪些要点对工作效率也很关键。然后，你可以提炼出一套独立的心得和经验，来指导自己或团队的工作。

思考题

在你参与的性能测试工作过程中，有没有哪些工作也是比较繁琐的，但是又不能很好地进行自动化执行呢？你可以留言分享出来，我们一起交流讨论，看看有没有其他的解决方法。

如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

21 | 性能CI：性能测试也可以集成到Pipeline中吗？

你好，我是尉刚强。这节课，我们来讲讲如何利用 Pipeline 来实现更好的性能测试效果。

如果你用过开源数据库 MongoDB，那你可能会遇到或者是听说过一个比较典型的性能问题，也就是 N+1 性能问题。

这个问题描述是：本来业务实现中需要查询 N 条数据项，因此最佳的性能实现方式，当然是通过 1 条查询语句返回所有数据。但是，如果编码人员对 MongoDB 客户端的 API 接口不太熟悉，或者是编码过程中不小心，都有可能导致最后实现的查询代码，执行了 N+1 次数据库查询请求，从而造成性能浪费。而如果 N 的数字比较大，可能还会对软件性能造成更严重的影响。

那么针对这类性能问题，有没有什么好的解决办法呢？

当然是有的，我们可以把组件或者微服务级的性能测试集成到 Pipeline（流水线）上，让它成为 CI（持续集成）中的一部分，就可以很好地解决这类问题。

而至于具体的原因，今天这节课我就会先带你一起探究下。然后，我还会针对不同种类的性能测试，给你分享一些实用的集成到 Pipeline 中的策略和思路。你可以根据今天学习的内容，将自己产品中的一些关键性能测试也集成到 Pipeline 上，来帮助团队更早地发现性能问题，从而提升研发效率。

好，下面我们就先来了解下 Pipeline 的工作原理，看看为什么可以把性能测试集成到 Pipeline 上。

为什么性能测试可以集成到 Pipeline 上？

首先，我这里所说的 Pipeline，其实指的是 DevOps 软件开发方法中提出 CI/CD 的一种技术实现手段。

补充：DevOps 是一种软件开发方法，它将持续开发、持续测试、持续集成、持续部署和持续监控贯穿于软件开发的整个生命周期，基于这种方法可以帮助提升团队的开发效率，加快产品的交付。

在一个 Pipeline 中，你定义多个步骤或阶段的执行操作（编译、构建、测试、部署等）时，都可以在代码提交后自动触发执行。而如果中间某个阶段出现了错误，就会导致整个 Pipeline 失败，然后你就可以从中找到引入问题的代码合入节点。

其实，在不同的平台或工具上，支持 DevOps 的 CI/CD 的流水线技术都是不一样的。比如说，在 Jenkins 中主要是基于 Pipeline 的插件来完成的；而在 GitHub 中，你可以通过基于 action 的 Workflow 来实现。

但是，它们解决问题的原理与思路是比较相似的。所以下面，我们就通过 Jenkins 中的 Pipeline 插件，来理解下 Pipeline 的工作原理。

如上图所示，在 Jenkins 中，每个 Pipeline 其实是定义了一个工作流，中间由多个 Stage（阶段）组成，它们分别完成不同的流水线功能，比如编译、测试等。但是在每个 Stage 执行时，都需要一个运行时环境，这个在 Jenkins 中是使用 Agent 来表示的。

一般情况下，Pipeline 会基于代码仓提交来触发执行，但是软件的编译、构建等相关功能使用的工具链，通常是比较稳定的，我们不需要在代码仓中管理。所以更有效的实现方式，就是将这些工具链都安装到 Pipeline 中的执行 Agent 内，来实现复用。

事实上，不同的软件产品构建 Agent 运行环境的差异非常大。比如说，在很多嵌入式内核与驱动的软件构建流水线中，很可能会在 Agent 中安装 Linux 源码或框架，还有一堆交叉编译工具链。所以你其实可以认为，Agent 就是目前封装 Pipeline 的基础设施的重要手段。

那么，对于性能测试工具来说，你就可以把它安装到 Agent 这个 Pipeline 的基础设施中。而再进一步，既然性能测试工具可以集成到 Pipeline 的基础设施上，自然性能测试也就可以集成到 Pipeline 中了。

好，在明确了 Pipeline 的原理之后，接下来我们还需要解决的问题就是：如何才能将性能测试集成到 Pipeline 当中呢？

性能测试集成 Pipeline 的策略思路

在第17和18讲中，我把性能基准测试划分成了两类，分别是微基准测试和宏基准测试。所以实际上，这两类性能基准测试集成到 Pipeline 中，所碰到的问题与解决思路是有些差异的，下面我们就来分别讨论下。

微基准性能测试

首先，对于微基准测试而言，其实它的测试运行过程和普通的单元测试比较相似，所以它可以比较方便地集成到 Pipeline 中。但这里你需要注意的是，微基准测试与单元测试存在一个比较大的差异，就是微基准测试对执行时间比较敏感。

因为执行时长会直接影响测试结果的准确性，而一般的单元测试并不会，所以针对微基准性能测试集成到 Pipeline 时，你需要确保它在执行期间使用的资源配置是确定的，才能保证最后的执行结果是有意义的。

宏基准性能测试

其次，对于宏基准测试来说，这里我们可以将其进一步划分为全系统端到端的性能测试和组件 / 服务级的性能测试。

我们先来看下，全系统级的性能测试的原理模型是什么样的：

如上图所示，对于系统级性能测试而言，被测系统中通常包含了很多的组件 / 服务，而这些组件或服务通常会依赖很多个代码仓。所以说，我们将系统级性能测试挂接到某一个具体代码仓提交触发的流水线上，其实并不合适。

那么具体我们应该怎么做呢？其实，针对这种类型的性能测试，我们可以定义独立的流水线。因为它和代码仓是独立的，所以你可以使用定时器触发机制来触发性能测试流水线，并生成性能测试报告。

对于云服务化的性能测试工具来说，其实很多都提供了定时触发机制，比如阿里云的 PTS 等。但假如你使用的是单机版的性能测试工具，那你就可以基于流水线来实现相似的能力。

好了，现在我们接着来看看，要如何将组件 / 服务级的性能测试集成到 Pipeline 中。

在上节课，我也给你介绍了将系统级的性能测试，拆分成组件 / 服务级的性能测试的各种好处，但我之前讲得并不全面。其实，这种拆分方式还有一个明显的好处，就是你可以更容易地把拆分后的性能测试添加 Pipeline 中。

那么，如果我们将组件 / 服务级基线的性能测试集成到 Pipeline 后，它对应的工作流图就如下图所示：

我们对比之前的 Pipeline 工作原理模型图，可以发现在将组件 / 服务级性能测试集成到 Pipeline 后，它可以在原来的 Deploy 阶段执行，从而实现将组件 / 服务实例部署到被测运行环境中的目的。当然，这个测试运行环境的系统资源与规格应该是确定的，比如说 CPU 核数、内存等。

然后，我们就可以在 Deploy 阶段后，添加一个自动化性能测试阶段（Performance Test），来执行对被测系统的自动化性能测试，并生成性能测试报告。

另外从上图中，你还可以发现，在自动化性能测试阶段运行的 Agent，是一个包含了被测工具的运行时环境，那么这个 Agent 应该如何添加呢？

其实，这一步并不难。现在主流的 Pipeline 工具基本都支持容器化的运行环境，所以你其实只需要将被测相关工具，通过 Dockerfile 方式构建成镜像，并上传到特定的容器镜像仓之后（比如 docker.io 上），你就可以在 Pipeline 中选择这个镜像作为 Agent 了。

好了，现在我们知道，将组件 / 服务的性能测试集成到 Pipeline 中，可以帮助我们第一时间发现代码合入引入的性能恶化问题。

但对于很多软件系统来说，还有一点也很重要，就是我们应该将性能基线数据持续地导出并可视化，来帮助分析软件性能的变化趋势。因为在大部分场景下，性能的优化其实是一个逐步累积的过程，并不是一次性的。

总而言之，在做性能测试的过程中，你要有尽量将性能测试集成到流水线中的意识。但是在实践的过程中，你还需要注意的就是，当性能测试集成到 Pipeline 之后，其实还会带来一些新的问题与挑战。

所以接下来，我们就一起来看看要如何应对这些挑战。

性能测试集成 Pipeline 的问题和挑战

我们知道，原来的性能测试通常是由手动触发执行的，所以执行频率会比较低，即使出错了我们也可以进行人为的分析。但是，如果把它集成到了 Pipeline 中，首先就意味着它会频繁执行，而且还需要比较高的稳定性。因此，这就对性能测试工作提出了更高的要求。

那么这里，我就给你总结了把性能测试集成到 Pipeline 之后，可能会引入的核心变化和挑战，然后我也会给你分享更好地应对这些挑战的方法和手段，让你能在具体的业务开发过程中用得更好，少踩坑。

更低的执行成本

首先，性能测试的执行花销是不能忽视的，比如说，有些被测组件 / 服务，在业务执行期间会调用第三方服务 API，而很多都是按照请求调用次数来进行收费的（比如短信验证、云对象文件存储下载等）。另外在性能测试期间，被测组件 / 服务会频繁调用接口，如果再进一步将性能测试集成到 Pipeline 中，就势必会带来比较昂贵的成本。

那么针对这类问题，其实你可以将这些与收费相关的依赖都打桩实现，然后在性能测试时使用桩接口即可。

更高的稳定性要求

其次，很多系统实现性能测试的稳定性并不高，比如说，有些被测组件 / 服务，对真实的物理环境依赖比较强，尤其在嵌入式场景下非常普遍。所以针对这种情况，你就需要考虑将性能系统中，软 / 硬件稳定性比较差的依赖项隔离出去，也可以采用打桩的方式。

更准确的性能结果

最后，针对更准确的性能结果，这个其实是对性能测试提出了更高的要求。因为前面我也介绍过，很多性能测试结果值都是波动的，所以这样就导致我们根据测试结果，来判断性能是否劣化会比较困难。这里呢，我给你提供两个解决思路，你可以参考使用。

第一，通过优化软件设计与实现来减少性能指标抖动。比如，我在第 10 讲中提到的剥离非核心业务、引入延迟计算服务等，来解决业务处理时延的长尾效应；

第二，你可以选择一些性能抖动比较小的部分业务模块的性能指标，来替换整体业务流程性能指标，以此进行判断分析。

事实上，由于性能测试集成到 Pipeline 时会存在很多的挑战，所以大部分团队并不愿意投入很多时间精力和成本。但是你会发现，在性能测试集成到 Pipeline 过程中所暴露的问题，其实是帮你指出了性能测试的优化改进方向。如果你沿着这个方向去优化和设计性能测试，就可以让性能测试工作朝着更高效的方向演进。

小结

这节课，我带你了解了将性能测试集成到 Pipeline 上的优势和方法策略。其中，你要重点把握的地方，就是要根据不同的性能基准测试类型来选择具体的集成策略。另外，在将性能测试集成到 Pipeline 的实践过程中，你还需要注意去规避解决一些典型问题和挑战，比如成本的问题、稳定性的问题等。

实际上，现在很多的研发团队所做的性能基线测试，与代码提交都是脱节的，而这样就不能在第一时间发现代码提交引入的性能劣化问题。所以间接也就会导致，软件产品的性能问题解决不及时，投入的研发成本也会很大。

因此，在学完今天的课程之后，你就可以借鉴我介绍的把性能测试集成到 Pipeline 的思路方法，来改进和优化性能测试工作，然后将其集成到 Pipeline 中，助力团队更早地发现和解决软件产品中的性能问题。而且你想一想，如果每次代码提交，都可以看到软件的性能变化是不是还挺酷的？

思考题

如果性能测试工具的负载量非常大，需要部署为集群模式，那么是否也可以集成到 Pipeline 中呢？

欢迎在留言区分享出你的思考和答案，如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

22 | 性能调优方法论：如何科学高效地定位性能问题？

你好，我是尉刚强。

一提起软件系统中的性能问题，可能你首先会想到的是 CPU 使用率过高，或是内存占用率太大，导致程序执行速度变慢，然后关注点只停留在软件实现层面的性能调优。

实际上，这种以最小化资源占用为导向的性能优化，它的核心目标是降低成本。但对于产品而言，最关键的其实是客户视角所关注的业务性能，比如用户的平均响应时延、99% 的用户响应时延分布等，而这类性能优化的核心目标是提升用户体验，提升产品的行业竞争力。

可是问题在于，在互联网服务领域，从客户视角去定位分析业务的性能问题，会有一定的挑战，主要原因有两点：

首先，针对一些存在业务性能问题的场景，其系统内的服务或组件，并不一定处于饱和状态，所以你不能直接从系统资源级监控中识别出问题；

其次，如果只是个别服务或组件处于饱和状态，往往也可以通过弹性扩展来更快地提升性能体验，所以这种也并不是最棘手的性能问题。

我们再来看看嵌入式领域，就拿无线通信领域来说，它也存在很多业务性能优化非常苛刻的场景，比如说 FTP 下载速率优化、速度平稳没有毛刺优化等。

那么在这类系统的实现过程中，导致出现业务性能问题的原因会更多更复杂，其问题可能发生在链路上任何一个网元设备内，或具体设备内的任何一个软件组件或硬件单元上。因此，定位分析业务层性能问题的挑战会更大。

而且事实上，定位分析业务的性能问题，也是很多程序员都很头疼的问题。它需要你具备很高的业务能力，包括对业务流程的熟悉度、对软件架构及软件内实现逻辑的理解程度，甚至是对 OS 和硬件原理都要有深入的理解。

不过就我的实践经验来看，即使是掌握了这些信息，如果没有系统的定位分析方法的指导，那你依旧很难定位出性能问题。

所以今天这节课，我会给你分享一套性能调优方法论，带你理解企业应用系统架构的整体逻辑和工作流程，然后在此基础上去了解都有哪些引起性能问题的潜在软硬件瓶颈点，最后会介绍系统分析定位的方法。基于这个方法，当你再碰到比较棘手的业务的性能问题时，就可以做到有的放矢，使用这套系统定位分析方法去解决真实的性能问题。

好了，下面我们就来了解下系统的整体架构。

系统架构视图

为了能更好地分析与定位复杂的性能问题，你首先需要理解整个系统架构视图，其核心主要包含这三层：产品业务模型、软件系统架构、组件或服务实现，如下图中所示：

下面我来介绍下不同层次模块的具体组成结构。

产品业务模型

这是系统对外提供的业务功能与逻辑。就拿购物流程来举例，它对外提供的功能逻辑是：浏览商品—> 添加购物车—> 查看库存—> 支付—> 发快递等，这其实是站在用户视角所感知到的与系统发生交互的过程。这个业务模型是软件业务领域建模阶段的重要产物，也是每个系统级产品都应该包含的模型。

软件系统架构

软件系统架构，它表示的是整个软件系统到具体组件或服务之间拆分逻辑，以及组件或服务间的交互关系，如图中 A、B、C、E、F 等。这些软件运行单元可以是一个微服务实例，也可以是一个组件实例，它们会通过通信与交互支撑实现复杂的业务功能。

实际上，不管是嵌入式领域或者互联网领域，软件系统架构设计本质上都是对业务功能拆解的一个过程，通过拆分形成具有独立清晰边界的、更小的软件运行单元。

组件或服务实现

这是组件或服务的内部代码实现，它会依托于进程、内核、硬件的协作来完成核心的业务功能。我们知道，系统的业务性能是由硬件、OS、软件实现以及之上的业务流程综合决定的，当我们碰到业务性能问题时，会基于系统的架构视图从上到下进行分析，最后总能找到具体的制约性能的瓶颈点。

那么，如果你可以提前认识软硬件中常见的一些性能瓶颈点，了解它们对系统性能的影响，就能够帮助你更加准确地分析业务性能问题。但是，很多程序员在定位性能问题时，喜欢只聚焦到某个点上持续优化，而这个点可能并不是性能瓶颈，所以最后很难有比较好的效果。

那么，系统潜在的性能瓶颈点都有哪些呢？接下来，我们就一起来看看。

潜在的性能瓶颈点

首先一般来说，性能瓶颈都是由一些关键资源使用过度而引起的，而且不同资源使用到达瓶颈（饱和）状态对性能产生的影响和规律是不一样的。在计算机系统中，每种硬件资源如 CPU、Cache、内存、磁盘、网络接口、总线等，都可能成为性能瓶颈。这是因为当硬件处理到达饱和状态时，会直接引起硬件上的软件运行性能下降，这样最终就会导致业务性能受到影响。

但是在通常情况下，监控工具对硬件资源的使用状态监测比较成熟，而且当资源使用到达饱和状态时，对性能的影响规律也比较明确，所以这部分内容就不在咱们今天课程的讲解范围之内了。

那么相比较而言，软件实现所导致的性能问题及影响，其实很容易被忽略。所以今天，我会重点介绍下由于软件实现而引起业务性能问题的瓶颈点，来帮助你在分析业务性能问题的过程中，更好地去识别和发现这些问题。

串行资源受限

第一类典型的瓶颈问题就是串行资源（或互斥资源），这是一个可能触发业务性能问题的软件实现。下图是串行资源的扩展对性能影响的模型图，表示随着业务规模的扩大，对性能造成的影响。

如图中左侧所示，因为串行资源是有限的，随着业务请求量增加，当资源使用饱和后，会导致请求处理吞吐量到达峰值后就不能再提升了；同时，如图的右侧所示，当串行资源使用饱和后，平均处理时延也会因为排队或者阻塞而不断拉长。

在软件实现中，对性能影响比较大的串行资源种类有很多，粒度从小到大可以包含：互斥锁、并行设计中的串行部分、系统依赖的不可扩展的服务或接口等，都有相似的影响。

但你也要注意，在业务场景中，还有不少资源数目是大于等于 2 的，比如线程池、数据库连接池、其他不能无限扩展的服务或接口。这种有限软件资源的场景，它们对性能的影响与串行资源对性能的影响也是比较类似的，所以你同样可以参考串行资源对性能影响的分析视图。

缓冲类资源消息溢出

第二种容易引发性能问题的软件实现是缓冲类资源，比如缓冲区、消息队列、消息中间件等都属于缓冲类资源。缓冲技术可以实现削峰填谷的机制，可以平滑上游和下游处理速度之间的差异。

如下图所示，如果缓冲区设置过小，当上游请求到达峰值时，可能会造成部分请求被阻塞或丢弃，影响到业务性能。如果缓冲区设置越大，所实现削峰填谷的能力则会更强。

但如果缓冲区设置得过大，也会造成内存资源的浪费，所以缓冲区大小设置对性能的影响也很关键。

缓存命中率过低

缓存技术是软件实现层做性能优化的一个重要手段，同时也是影响业务性能的潜在因素之一。我们知道，缓存使用有一个很重要的指标：缓存命中率 = 缓存命中个数 /（缓存命中个数 + 缓存未命中个数），这个指标对业务性能的影响如下图所示：

可以看到，当命中率提高时，由于命中之后的请求处理时延变短，就导致了业务的平均处理时延降低，并且吞吐量也会随之提高；反之，当缓存命中率降低时则性能下降会比较明显。

软件 Bug

最后，在软件实现中还有一个对性能影响非常大的因素，就是软件 Bug。

在以往定位分析业务性能问题的过程中，由于软件 Bug 而引起的性能问题不占少数。比如，代码中本来是批处理操作，但是由于处理错误而退化成循环调用等；或是在使用 Cache 技术时，由于 Cache Key 的构造错误，导致缓存永远不可能命中，引起业务性能突然下降等。

不过看到这里，你可能要问了：是不是所有的业务性能问题，都是由于软硬件层的资源性能瓶颈触发而引起的呢？

我认为并不是，有些性能问题可能是由于业务模型或者流程本身的问题，软件和硬件只是载体而已。比如说，因为业务中的一些限速策略，就导致所处理的性能受限。

另外在有些更复杂的系统业务设计中，也可能会引入性能问题，这就像一些设计不合理的十字路口红绿灯一样，会导致拥堵严重。不过，针对这种因业务模型等而引起的性能问题，我们其实可以通过调整红绿灯时间，也就是通过调整软件不同模块的业务职责和接口实现，来显著改善拥堵现象。

好了，总之在软件实现当中，可能引起潜在性能问题的因素非常多。既然如此，我们就需要系统的分析定位方法指导，否则会很难准确识别出系统中所有的性能瓶颈点。

所以接下来，我们就一起探讨下这个系统分析定位方法吧。

系统分析定位法

实际上，分析定位性能问题的方法有很多种，比如 USE 方法（即从检查每个资源、使用率饱和的视角寻找可能的性能瓶颈）、从下向上逐步分析系统中资源使用指标法来寻找性能瓶颈。其他的还有随机变动讹方法（这是一种随机猜测然后验证的方法）、科学实验法等。针对特定的性能问题时，可能每种定位分析法的效果也不一样。

不过，今天我要给你介绍这种方法，是我根据以前分析各种性能问题积累的经验，而总结出的一种方法思路，它是一种相对系统且比较高效的方法，你在碰到各种业务领域的性能问题时，都可以使用。具体如下图所示：

整个定位分析方法从上到下一共分为三层，分别是：业务模型分析、软件架构分析、组件或服务实现分析。下面我来给你一一介绍下。

业务模型分析

首先，业务模型分析的目标是寻找到引起业务性能问题的业务触发点，并验证分析的正确性。越复杂的业务模型，导致出现业务性能问题的根因可能会越隐蔽，如果你在业务模型分析时，发现的根因不对，那么后续花费更多力气去进行深入分析也都将是徒劳。

比如，分析 TCP 流量下降的性能问题，可能是下行链路的问题，也可能是上行链路的问题。如果因为上行链路延迟增大，而导致 ACK 反馈不及时触发拥塞控制，导致 TCP 流量不高，那么你花费再大的精力，在下行链路上分析也都是无用的。

因此，验证业务层性能问题根因分析的结论是否正确，是很有必要的。这里我建议你可以通过打桩方式将根因触发点暂时规避掉，来观察性能问题是否改善，以此作为有效的验证手段。

当然，不同系统间的业务模型差异非常大，所以业务模型没有通用的方法或规律可循，你只能靠对业务逻辑的深入理解，并根据业务层实现的运行状态监控信息来进行分析。当做好了业务模型分析后，你会发现导致性能问题的原因，可能是由某个具体业务流程引起的，然后你就可以针对这个具体业务流程，深入到软件架构中进一步分析。

软件架构分析

在软件架构分析中，我们主要依赖软件架构中的组件或服务的接口交互关系来进行分析，通常对大的系统级产品而言，组件或服务间接口的交互信息一定是可以被监控获取分析的。这样，根据获取的接口交互监控信息，我们可以寻找到触发业务性能问题的具体组件或服务。

另外，与业务模型分析一样，我们在软件架构分析阶段识别出的性能瓶颈的组件或者服务，也需要做进一步验证，在确保分析结果正确之后，再进行下一阶段的深入分析。

我以前在通信领域从事性能问题分析时，每个子系统组件都是由不同团队开发维护的，而当面对复杂的业务性能问题时，我就需要沿着子系统组件的接口边界逐步排查分析，将性能问题交接给下一个子系统组件，并且还要提供充足的分析与验证信息。但即使是在这样的场景下，也依旧会存在分析出错，导致返工的情况。所以这一点，你一定要注意。

软件内部分析

但事实上，在互联网业务领域分析性能问题时，不管是业务模型分析还是软件架构分析的阶段，验证分析结论正确性的过程，其实都很容易被我们所忽视，从而就会导致性能问题分析定位的返工或者出错。

所以就我的实践经验来看，当定位到具体的某个组件性能瓶颈时，你就可以深入到组件或服务内实现来进行分析了，然后根据我前面介绍的资源性能瓶颈点以及对性能的影响规律，来准确识别发现导致业务性能问题的软硬件资源。

那么下面，我就通过一个具体的性能问题案例，来帮助你更深入地了解如何使用这套定位分析方法，来定位分析性能问题。

如何在真实的数据分析业务中应用这套方法论？

这是我曾经处理在线数据分析业务中碰到的真实性能问题，当时产品有部分用户投诉，在 Web 页面中的查询数据下载功能性能很差，造成用户等待时间长。而我采用的就是这套性能定位方法，具体定位过程如下图所示：

第一阶段：通过业务模型分析，识别出性能问题的业务流程。在互联网服务领域，有很多业务的性能问题是用户帮你发现的，但在其他的业务领域内，业务性能瓶颈点则需要依赖业务领域的监控统计，并结合业务模型一起分析才可以识别。

因此对于这个案例来说，系统中可能包含了很多种业务流程。那在业务模型分析阶段中，我们找到了存在性能问题的业务流程是：查询数据下载功能。具体用户业务流程是：选择查询条件—> 查询返回数据—> 生成压缩数据—> 上传对象存储—> 生成下载链接。

然后我们就可以基于这个业务流程，来寻找定位引入性能瓶颈的组件和服务。

第二阶段：在软件架构分析阶段，我们就可以根据存在性能问题的业务流程，寻找到软件导致性能瓶颈的组件和服务。

这个阶段我主要使用系统的微服务间的接口日志进行分析，主要基于 Elasticsearch+Kibana 工具，沿着业务请求接口链路寻找到具体组件或者服务，最后将性能问题定位到一个确定的微服务组件中。

第三阶段：组件或服务内实现分析，在这个阶段深入到导致性能问题的微服务，通过查看内部实现的相关监控或统计信息，发现导致性能问题的原因是：压缩数据处理与浏览统计逻辑使用相同的计算任务队列，由于之前的突发浏览业务导致队列中积攒了很多待处理的浏览统计任务，从而引起压缩数据处理任务被排队阻塞，时延很长。

如此一来，通过仔细分析后我们发现，由于用户无法感知到浏览统计逻辑，而压缩数据处理对用户来说却比较敏感，所以这里使用一个相同的任务队列，就造成非关键业务逻辑影响到了核心业务的处理时延。

所以，我们最后的解决方案就是，将两种任务的处理队列拆分开，从而避免类似的性能问题再次发生。

小结

我们在分析处理业务性能问题的时候，其实很容易犯一些错误，比如当发现一个性能问题的根因或瓶颈后，就像抓住了救命稻草一样，将所有的问题都往上靠，最后等解决了这个根因之后，却会发现系统的性能问题并没有得到彻底解决。所以，我们的性能分析定位方法，一定要科学且严谨的分析所有可能的原因。

今天课程上介绍了当碰到业务性能问题时，可以从上到下依次进行业务模型分析、软件架构分析、组件或服务内实现分析，然后识别出引起业务性能问题的具体软件或硬件资源。在每个阶段的分析定位过程中，都需要进行验证工作，确保分析结果准确性和完备性。

思考题

在互联网业务流程中对数据库的操作请求非常多，如果业务中经常出现数据库查询时间比较长的情况，你一般都会怎么分析定位呢？

欢迎在留言区分享出你的思考和答案，如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

23 | 监控分析：你的性能调优工具足够有效吗？

你好，我是尉刚强，今天我们来聊聊如何选择合适的性能调优工具。

实际上，选择合适的性能调优工具，就像医生会借助一些医疗设备来诊断病因一样，主要是为了验证性能问题根因分析的准确性，以此更好地支撑性能调整与优化。

可是一方面，由于现在业务复杂度的不断提升，软件的技术架构越来越复杂，我们对软件系统运行的监控分析也越来越困难了。因此，如果仍继续使用单一功能的 OS 或硬件级的监控工具来获取监控数据的话，其实很难有效地支撑性能问题分析工作。

而另一方面，正是由于业务复杂度的提升，导致监控工具也越来越复杂化，我们需要获取监控调优相关的数据种类也越来越多了。所以手动分析这些数据的效率，就成为了性能调优工作的重中之重。

也就是说，我们需要建立一套系统的监控分析工具体系，这样既能够帮助获取多个层级的监控数据，也可以通过体系化的工具选择和使用，避免一些繁琐的重复性工作，来有效提升分析效率。

所以今天这节课，我就来给你介绍下目前常用的一些观测技术手段，让你明白它们在监控分析中起到的作用和价值。然后，我会带你学习如何针对软件系统去设计整个监控分析的工具体系，以此获取并分析相关的监控数据，从而帮助你更加高效地进行性能调优。

常用的观测技术手段

那么首先，我们来了解下目前业界比较常用的观测技术手段，主要有四种，分别是计数器、跟踪、剖析和监视。

计数器

计数器是针对特定事件的一种统计手段，这是一种成本非常低的观测手段，所以通常你可以一直开启它来监测特定事件发生的次数。

在 OS 级别与硬件级别有很多计数器可以使用，比如使用命令 iostat，来获取与 io 相关的统计信息，如下所示：

跟踪

这是通过收集一系列操作或事件内容来跟踪分析业务流程的观测手段，比如日志打印就是一种跟踪手段。不过通常来说，跟踪是存在一定的开销的，所以一般不建议全部开启。以日志打印为例，在高性能软件系统中，一般只有错误或告警级别才会默认开启。

剖析

这是通过对目标进行采样或者快照来归纳目标特征的观测手段，但是受制于采样周期的影响，往往会存在采样反馈特征不准确或者有偏差的问题。因此，一般只有在分析软件代码函数调用栈概率分布的时候，我们才会使用这种方式。举个简单的例子，使用 JProfile 来分析 Java 程序的热点代码，就是采用的这种方式。

监视

这是一种主动触发控制的观测手段，它会通过显式触发获取特定周期内的信息，来支持监控数据获取的过程，比如操作系统级别的 Sar 命令等。

其实对于操作系统内核和硬件来说，已经有很多基于这四种观测手段开发的监控工具，来实现对其内部进行深入的性能监控分析。比如说，使用 iostat 命令获取 IO 统计、使用 top 命令获取进程使用统计等（如果你想更深入地学习这部分知识，可以参考学习 Brendan Gregg《性能之巅》）。

但是，对于实际开发的软件系统来说，仅仅基于操作系统内核和硬件级的监控观察手段，来分析复杂的业务性能问题其实还远远不够。

我举个例子。假设你的公司研发了一个演唱会购票交易网站，然后因为某个演唱会非常火热，在卖票期间出现整个网页卡死的性能问题。那么为了更好地监控分析这个性能问题，你就需要在业务中增加很多观测手段，比如获取网页在线人数、同时提交购票请求人数、购票交易平均时长等很多观测数据，来协助进行性能分析。

而这样一来，为了能更深入地对业务性能进行监控分析，你还需要在实际软件系统的开发过程中，单独定制化实现以上不同种类的观测手段。

不仅如此，由于软件系统都是运行在操作系统 OS 和具体硬件芯片之上，所以针对一些复杂的业务性能问题，你还需要统筹好操作系统与软硬件定制开发的观测手段，一起配合，这样才能更加有效地支撑性能问题的分析定位。

可是要想实现以上目标，就需要我们对软件系统的性能监控观测节点和观测手段有个全面的了解和把握。所以接下来，我们就通过一个通用的软件系统观测视图，来了解下如何才能更有效地支撑性能问题的监控分析工作。

实际软件系统的观测节点和手段都有哪些？

现在的软件系统通常都不是单机系统，而是一个复杂的分布式系统或是云服务化产品。所以，针对软件系统的监控分析就不再会停留在单机上，而是需要对系统中很多的节点和元素进行观测。

下面展示的是一个软件产品或服务的系统观测视图，你可以认为图中的每一个矩形元素都是一个观测节点，它们都提供了前面四种观测手段中的一种或者几种观测手段。

那么在这个系统观测视图中，你可以发现上层的软件与服务是运行在下层的软硬件之上的，因此我们针对下层软硬件的监控分析，其实也可以支撑上层服务和组件的性能分析。

所以这里，我就从下到上来依次给你介绍下它们所提供的观测实现手段，帮助你更好地理解系统观测视图的全局。

硬件层的 CPU

首先，在硬件芯片层，每个物理设备都有对应观测方式。就拿 CPU 芯片来说，现在的 CPU 芯片架构越来越复杂，所以如何写出对 CPU 更加友好的代码，充分发挥 CPU 的硬件性能，就是一个很重要的性能调优方向。

以 CPU 观测分析为例，现在的 CPU 大多都有性能监控单元（Performance Monitoring Unit, PMU），可以统计系统中发生的特定硬件事件，比如缓存未命中（Cache Miss）或者分支预测错误（Branch Misprediction）等。所以呢，你就可以利用这些观测数据来分析，并指导优化软件的编码实现，来提升在 CPU 芯片上执行效率。

以 Intel 的 CPU 体系架构为例，其内置的计数器有几百个，你可以使用工具 perf 获取这些计数器的值，再借助工具如 pmu-tools,、intel-Vtune profile，并使用TMAM（Top-down Microarchitecture Analysis Method，自顶向下的微架构分析方法），分析出取指令阻塞、取数据阻塞、指令分支预测效率低等影响 CPU 性能发挥的因素，从而指导编码优化来提升 CPU 的 IPC（单周期的执行指令数），以此提升软件的性能。

OS 内核层的进程

在芯片之上是操作系统内核层，包含了进程、协议栈、文件系统等功能，每个功能也都有对应的观测工具与手段。以进程为例，我们可以通过 ps、pmap 等命令获取内存和 CPU 使用统计信息，也可以通过 strace 等命令获取系统调用的跟踪信息。

云平台

现在，我们的软件系统通常并不是只运行在一个单独的操作系统内核上，而往往是运行在一个云平台上。针对互联网应用与服务产品而言，这个平台通常就是指的是 PaaS 平台，各大云厂商如 AWS、阿里云、Azure 等，也都提供了研发 PasS 云平台，当然你也可以使用 kubernetes 来组件一个私用的 PaaS 云平台。

但是不管选择使用哪一款 PaaS 平台，它们其实都提供了对应的监控运维工具，可以支持获取平台上的弹性资源、负载均衡等相关的监控观测数据。

公共服务

另外，对于软件系统而言，通常还会依赖很多公共服务，常见的包括数据库、消息队列或其他第三方服务等。这些公共软件与服务，通常也都有对应观测与监控分析工具，以数据库 MongoDB 为例，有基于统计的观测工具 mongostat，还可以使用 Profiler 进行查询分析，也有专业监控分析工具 MMS（MongoDB Monitoring Service）等。

软件系统架构中的服务 / 组件

在操作系统与云平台之上是软件系统架构，软件系统架构是由很多个服务或组件组成的。首先，这些服务与组件通常都可以支持获取线程使用、内存使用、CPU 使用、IO 使用等与底层相关监控观察数据，只是因为运行虚拟机和编程语言的差异，可以选择使用的工具不同。比如 C/C++ 语言中，常用的剖析分析工具有 gperftools、gprof、Valgrind 等；针对 Java 语言，常用的剖析分析工具有 VisualVM、JProfiler 等。

其次，这些组件与服务还需要根据特定的业务场景，开发实现一些自定义的观察手段或者工具，支持从业务视角进行性能分析。比如说，增加业务相关的计数器统计，或添加动态 Trace 机制等。实际上这部分的观察手段与能力，也是很多业务性能问题分析的关键，所以是需要重点考虑并建设的部分。

再进一步，云平台、软件系统、公共服务一起协作实现了整个产品的业务功能，而对一个复杂的业务系统而言，也应该有对应的观测分析工具。所以，对于很多大型业务系统来说，通常都在后台管理工具中集成了各种观测数据分析工具，这样你就可以比较方便地进行性能问题分析和调优。

但针对一些不成熟的软件系统，它们提供的观测分析手段就可能会比较薄弱，从而就导致了对软件系统进行性能调优非常困难。

所以根据以上的介绍分析，你可能会发现，对一个业务软件系统而言，从上到下的可观测节点数目会非常多，而且每个观测节点提供的观测分析工具种类也很多。

那么在对软件系统的性能进行监控分析时，如何寻找定位到待观测分析的节点，并使用对应的观测分析工具获取监控数据，这个过程通常是非常耗时的。从我的实践经验来看，很多研发人员在进行性能调优时，绝大部分时间都花费在了学习和选择观测分析工具上，效率就非常低。

因此，构筑一套观测工具框架体系，来支撑对系统的观察分析，就是提升性能调优效率的关键。那么具体要如何做呢？下面我就来给你分享下我的构建思路。

如何构筑软件系统观测工具体系？

首先，为了更好地使用观察工具来支撑对系统的性能调优，我们应该从种类繁多的监控工具中解脱出来，尽量使用统一的监控平台与工具来支撑性能调优。

如下图右侧所示，我们可以尽量将系统中的观察分析数据对接到统一的分析平台上，来减少寻找使用各种工具的成本。

在通常情况下，各种云厂商提供的 PaaS 平台其实都携带了监控运维工具，并且也都自动集成了操作系统层、硬件层常用的观测数据，所以很多时候，你并不需要再深入到具体观测工具的使用方法当中。

其次，根据业务中针对性能调优的需求，你可以将软件组件与服务、业务层、公共服务层等相关的观察数据，通过各种适配器和服务对接到统一的 PasS 监控运维工具中。这样做的好处是可以将不同系统服务间获取的监控数据关联起来分析，从而能够更好支撑性能问题的分析与定位 。

在我以前参与的一个性能优化项目中，其产品是部署在 AWS 上的，为了更好地支撑性能优化，我们首先将 ClickHouse、MongoDB 等重要观察数据，都注入到了统一的 Paas 监控数据分析平台中。这样当出现性能问题时，只需要基于这个 Paas 平台上的监控数据，就可以初步确定性能问题发生在哪个服务或组件上。

而如果你参与的项目案例中，并没有这个 PasS 数据分析平台，或者平台能力并不成熟，你也可以基于相关的开源技术来构筑这部分工具能力。比如，使用 Elasticsearch 和 Kibana，来记录跟踪业务处理日志或组件与服务间的交互日志；或者是使用 Promethoeus 和 Grafana，来记录系统中相关的计数器事件等。

其实很多时候，我们在进行系统调优的过程中，也是构筑系统级监控分析工具的最佳时机。如果我们将这部分能力集成和沉淀下来，就可以帮助自己提升后续性能调优的分析工作效率。

小结

俗话说得好：工欲善其事，必先利其器。在对一个软件系统进行深入的性能分析与调优时，你需要对多层级的很多观测节点获取测量分析数据，而如何使用工具来高效获取这些测量数据，就是提升性能调优效率的关键。

在今天的课程上，我介绍了把系统软件与业务级、公共服务级相关的性能观测数据都集成到统一的 PasS 数据分析平台上的方法与思路。你需要重点关注不同层级的观察节点，都提供了哪些观察手段与能力，然后当你在碰到性能问题时，就可以基于上节课学习的性能分析方法，寻找到比较好的切入观察点，并获取观察数据来支持性能分析。

这样，你就可以借鉴今天的学习内容，构筑一套自己的系统观测工具体系，并在性能调优的过程中，逐步集成和完善监控分析工具的能力，以支持更高效的性能调优。

思考题

你认为针对互联网服务级别的软件系统，在构筑一套完整的观察工具体系的过程中，哪些观察工具的优先级是更高的呢？欢迎在留言区分享你的思路和见解，我们一起讨论。如果觉得有收获，有欢迎你把今天的内容分享给更多的朋友。

24 | 动态监控：你的产品系统中有动态监控的能力吗？

你好，我是尉刚强。今天，我们来讨论下与系统动态监控相关的能力和配置思路。

对于从事 Java 语言开发的程序员来说，应该基本上都使用过 Slf4j+Logback 来打印日志。而对于一些软件性能要求比较高的系统来说，为了减少日志打印对性能的影响，默认情况下，系统一般只会打开 WARN 或 ERROR 级别的日志。

那么，如果软件系统在执行的过程中出现了一些异常情况，需要做进一步的分析，想要打开 INFO 级别的日志，该怎么办呢？其实这个时候，你就需要在软件执行期间，实现动态修改日志打印级别的能力。

这里我们以 Logback 配置为例，其修改打印日志级别的代码如下所示：

LoggerContext loggerContext =
(LoggerContext) LoggerFactory.getILoggerFactory();
Logger logger = loggerContext.getLogger("root");
((ch.qos.logback.classic.Logger)logger).setLevel(Level.valueOf(logLevel));

当然，你也可以给软件提供一个外部接口（比如 REST 接口），然后在接口实现中去调用以上代码来修改打印级别，其实这就是一个比较简单的动态监控手段。

所以在今天这堂课上，我会从互联网应用服务领域出发，带你分析在软件系统的开发过程中，都应该扩展实现哪些动态监控的机制和能力，以及剖析在系统中实现动态监控配置的过程，从而帮助你系统化地理解和认识动态监控的手段和能力。这样，你在参与开发高性能软件系统时，就知道如何设计并实现动态监控机制来支撑对软件的性能调优了。

好，那么在开始之前，我们先来深入思考一下，为什么软件需要这种动态监控的能力呢？

为什么需要动态监控的能力？

在上节课，我们学习了系统的四种监控观测手段：计数器、跟踪、剖析、监视。但实际上，在软件业务的实现过程中，你每增加一种观测手段，都会给软件执行带来额外的性能开销。

我举个简单的例子，假设你在关键业务流程中增加了一个计数器统计，并且记录到分布式缓存 Redis 中，那么可能就会造成对应的业务处理时延增加接近 0.5ms 的时间（真实性能优化项目中的测试结果，仅供参考）；如果你在代码实现中增加了一条日志打印需求，也很有可能会给软件额外增加了一条 IO 请求操作，从而就会对业务性能造成影响（这里你其实可以将日志写入模式配置成异步模式，来减少日志打印的性能开销）。

在有些应用软件系统实现中，日志打印甚至会造成应用的性能下降 20%，但这与具体业务代码实现相关性非常大，所以数字借鉴意义不大，但也在一定程度上可以说明日志打印对应用性能的影响比较大。

也正是因为观测手段实现都会存在性能开销，所以在给一个高性能软件系统进行深度优化时，观测手段的实现与业务性能之间的冲突就会越来越大。

而这个时候，给软件添加动态监控的机制，就成为了解决这个冲突的重要手段。

接下来，我们就通过一个日志打印的观测案例，来进一步理解下采用动态监控机制的优势。

我之前设计实现过一个数据实时同步服务，业务中需要分析数据同步时出现较大时延抖动的原因。在早期的实现版本中，我主要是依赖打印日志中记录的时间值来进行分析，但是当软件处于高负载模式运行时，就会发现打印日志大量丢失，从而完全不能支撑业务分析。

所以这个时候，我采用了将数据同步的关键时延信息批量写入到数据库中的方式，从而有效支撑了高负荷模式下的性能分析工作。

实际上，这个案例主要反映了一个比较常见的现象，那就是当系统处于高负荷模式时，很有可能导致业务添加的观测手段失效，从而影响到软件业务的性能分析。那么针对这种情况，你就可以通过动态控制和监控观测数据的获取，来规避和解决这类问题。

总之，在开发和监控软件性能的过程中，我们不能忽视因观测手段实现而引入的额外性能开销，并且还要保证软件业务在高负载模式下，实现的监控观测手段的可用性。所以，设计并实现动态监控的机制手段，就是在开发高性能软件系统时，非常重要的一项工作。

那么对于一个高性能的软件系统来说，都应该实现哪些动态监控手段呢？下面我们就具体来看看。

高性能系统应该实现哪些动态监控能力？

首先我们来想一下，计数器需要动态监控吗？

答案其实是需要的。我们都知道，计数器是一个非常重要的监控统计手段，很多与业务相关的性能指标都可以基于计数器来呈现。

在传统的应用和嵌入式系统开发过程中，计数器一般是直接记录到内存中；而在互联网分布式系统中，则通常会选择将计数器保存到数据库或缓存中。那么针对计数器记录到数据库的这种场景，有关计数器的操作开销就不能再被忽视了。

所以，你可能就需要去设计实现计数器的动态监控机制。

其实，对于很多 ToC（面向消费者业务）的互联网服务和产品来说，在针对某个用户业务进行性能分析时，很可能就需要用户级别的统计信息（计数器）。但从系统实现的角度来看，如果针对所有用户都增加统计计数器的话，势必带来的性能开销会比较大。

那么这个时候，你就可以实现仅针对特定用户的计数器统计信息，然后在软件执行的过程中动态修改监控用户，来开启对特定用户的计数器统计。

注意：在具体的业务中，你可能需要设计出大小不同的、多种粒度的计数器，来支持其动态启动和关闭。

OK，我们再来看看对于日志打印，是否如这节课开头所说，只需要动态配置日志打印级别就可以了？

其实也不是，这种跟踪分析业务流程的观测手段，其动态监控机制的设计与实现，主要还是取决于软件系统对性能的要求是否苛刻。

这里我就给你举几个例子，来带你分析下我之前参与的高性能软件系统中，是如何实现日志打印的。如此你就会明白，原来基于日志的动态监控还可以有那么多的变化。

首先，日志打印还可以按照模块进一步划分，这样我们就可以从模块和日志打印级别两个维度，来更精确地配置打印信息了。

不过你要注意，在很多高性能的软件系统中，使用模块和日志级别两个层级配置的打印，再打开日志后可能还是会瞬间给软件引入比较大的 IO 负载开销。因此针对这种场景，你可以控制每次打印日志的条数，比如通过代码控制每次打印确定数目的日志信息（如 100 条），或者通过代码控制只打印特定流程下的日志信息。

当然，虽然使用这种控制打印日志条数的实现方式，可以将打印日志对软件系统带来的额外开销降到比较低，但也很有可能因为打印日志信息有限，不利于有些性能问题的分析。

比方说，你通过日志打印寻找定位慢查询的请求相关信息，但是每次只能打印 100 条日志，还没有捕获到慢查询就已经打印结束了。所以，在对性能要求非常苛刻，而且又需要打印日志信息比较丰富的场景中，你就需要定时化手段来实现日志信息的打印了，这样才能实现在最小性能损耗的情况下获取更多的日志打印信息。

那么具体该怎么做呢？

我们先来看一条，Logback 配置中的打印日志的模板配置，如下所示：

<pattern>%date %level [%thread] %logger{10} [%file:%line] %msg%n</pattern>

在上面的配置中，%date 代表打印时间，%level 代表日志级别。%thread 代表日志打印所在线程名，%file 代表打印日志所在文件，%line 代表打印所在行，%msg% 代表包含了参数的消息内容。

你认真思考就会发现，如果针对每条打印日志都进行统一编号，那就可以通过这个编号来获取日志级别、线程名、所在文件、代码行等多类信息了。显然，保存和传递编号的数据量会小很多。

所以说，采用标准配置模式来打印完整的日志信息，其实并不是性能最高效的实现方式。

实际上，在一些性能要求非常苛刻的业务系统中，更高效的实现是通过动态监控的手段来定制化实现日志打印，只把错误码、参数信息等批量记录下来，然后通过单独消息通道发送给观察分析子系统，具体如下图所示：

而且，除了打印日志之外，还有很多动态观测数据都可以通过上述消息通道的方式，来传递给外部的观察分析工具，比如在第 8 讲中，提到的 UDT 技术或者自定义的监控数据等。另外，我们还可以将这些观测数据持久化写入到数据库中，以支持更长时间维度上的观测数据分析。

补充：不同的软件系统对处理时延性能的敏感度是不一样的，有的系统性能优化甚至需要关注到微秒级别（1 秒 =1000000 微秒），在这种场景下增加一条打印日志都有可能对软件性能产生影响。所以，是否可以接受监控数据对性能的影响，是与业务强相关的。

其实到这里，我只介绍了动态监控技术中的很少一部分实现策略，而且也不是说所有的高性能系统中都必须要这样去实现动态监控的机制。你要知道，不同软件系统所需要的观测手段是存在差异的，而且它们对性能的敏感度是不一样的，所以你还需要针对具体的业务场景，来实现相应的动态监控手段。

好，那么在知道了软件系统应该实现什么样的动态监控手段以后，你还需要思考的问题就是：在互联网分布式的场景下，应该如何对软件进行动态监控的配置呢？

如何对软件系统进行动态监控的配置？

传统的企业级应用软件，经常会使用单机部署模式，这样就只需要提供单独的监控配置接口来处理动态监控配置需求。而针对云平台上的分布式系统，软件服务通常是多实例部署模式，这时候依赖单独增加配置接口实现，就不能满足支持一对多的监控配置需求了。

所以，针对互联网多实例服务集群来说，比较常用的动态监控配置实现架构，就如下图所示：

也就是说，你可以从监控配置中心发布一个动态监控请求，然后业务服务实例会订阅监控配置消息，来开启动态监控功能以获取对应的监控数据。

另外你也要知道，图中的监控配置中心，对于互联网分布式服务架构而言，通常是一个后台配置服务，所以你可以将其与业务特性配置管理服务集成在一起。

还有一点，就是监控配置中心与业务服务 / 组件间可以采用发布订阅，这样就可以很容易地实现一对多的通信（绝大多数的消息通信中间件如 RabbitMQ、Kafka 都提供这种机制和能力）。但要注意，这里使用发布订阅模式还是有一定的局限性，那就是如果业务实例重启了，对应的动态监控信息很有可能就会丢失。

所以在这种情况下，我们要怎么解决呢？

补充：在嵌入式场景下，通常会有一个假设是业务实例进程运行非常稳定，不会经常重启。所以在监控配置的过程中，你可能不需要考虑业务实例进程重启的情况。而在云平台的分布式系统中，因为业务实例的动态升级操作会非常频繁，所以在配置过程中，你就需要考虑业务实例重启场景下的配置流程是否正常。

其实，对于互联网分布式服务架构的软件系统来说，针对这种场景，你可以将动态监控配置信息持久化到数据库中，然后由业务实例去主动查询数据库的监控配置，具体如下图所示：

而这里你需要注意的是：具体的业务服务实例在读取数据库中监控配置时，需要保证一定的实时性，这样才能保证动态监控的启动延迟是可以接受的。

这样，当业务启动动态监控之后，你就可以按照我前面介绍的，将动态监控获取的信息通过消息通道，传递到观测数据分析工具中，从而来支持性能调优分析了（具体到监控获取的观测数据的编码设计、消息通道交互设计等环节，你就可以参考第 6 讲“通信设计”来指导实现，从而在最大程度上减少额外的性能开销）。

小结

我们要知道，在对软件进行性能调优时，通常只依赖操作系统 OS 或硬件芯片级别的监控信息是远远不够的。因此在软件产品设计与实现中，还需要添加一些监控手段获取业务相关的观测信息，以此来支持业务的故障定位和性能分析。

而针对一些对性能要求非常苛刻的软件系统来说，为了权衡监控观察对业务带来的额外性能开销，并且要满足极限负载下的监控数据获取要求，采用动态监控手段，就成为了一种必然的选择。

那么在今天的课程中，我就给你讲解了软件系统应该实现哪些动态监控的观测手段，以及针对互联网分布式服务架构，应该如何去实现动态监控的配置过程。你可以借鉴今天的学习内容，去分析自己当前业务场景中的监控观测机制，看看有哪些是有必要修改为动态监控模式的，然后针对软件系统，来设计实现动态监控的配置能力，从而帮助软件系统在追求高性能的同时，还可以具备足够强的观测分析手段。

思考题

在你参与的软件产品服务中，有没有一些与业务相关的监控手段，因为性能的原因默认是关闭的，只在特定的场景下才会打开呢？

欢迎在留言区写下你的答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎分享给你的朋友。我们下节课见。

25 | 性能调优什么时候应该停止？

你好，我是尉刚强。

我在以往参与性能优化项目的过程中，曾不止一次地被问到过：软件性能调优什么时候应该停止呢？因为我发现，有很多研发人员在做性能调优的过程中，可能进展并不理想，而且也因为性能优化目标迟迟没有达成，然后就陷入了性能调优什么时候才能结束的迷茫中。

其实，这个问题也曾困扰过我，我当时在参与第一个性能优化项目的时候，每天的工作就是寻找代码中的一些低效率实现，然后修改重构并验证性能提升效果，日复一日。所以，我当时就很想说服团队 Leader 这个性能调优任务可以结束了。但是，首先我都没有办法说服我自己，也是基于这个原因，我才开始思考这个问题。

所以这节课，我想和你按照线性递进的思考逻辑，去寻找解答这个问题的最佳答案。我会先和你一起讨论之所以产生这个问题的根源，然后带你剖析下正确的性能调优的方法步骤。最后在这个过程中，我们就可以寻找到在做性能调优时一些需要喊停的死角区域，从而就可以在实际的软件开发设计中，更高效地做好性能调优工作，更容易达成性能优化的目标。

好，那么下面，我们就先来思考下，这个问题提出背后的原因是什么呢？

为什么会提出这个问题？

我们先来做个假设，现在团队开发的一个软件产品需要进行性能调优，其指定的性能调优目标是提升 20%。

那么我们来思考一下，这个目标好达成吗？

其实在进行深入性能分析之前，我们是很难回答这个问题的。这是因为，不同软件的设计与实现差异很大，而针对性能这个模块，我们可以优化提升的空间是不一样的。

我举个真实的例子，在我曾经参与的一个协议栈报文子系统的性能优化项目中，因为在代码实现优化中减少了一次内存拷贝，就一次性将系统处理的性能提升了 20%。而在我参与的另一个配置管理子系统的优化项目中，由于没有找到比较大的性能优化点，所以花费了很长时间与精力，才将性能提升了 10% 左右。

所以我才说，不同软件系统的性能优化提升效果和优化投入成本之间的关系差异很大，具体可以参考下图：

在这个图中，你可以观察到两个比较明显的规律：

不同软件系统（软件 A 和软件 B）在性能调优的过程中，能够达到的性能提升百分比上限是不一样的。

在性能调优的前期，投入很少成本就可以获取比较好的性能提升效果，但是在性能调优的中后期，获取同样多的性能收益，需要花费的精力和成本会越来越大。

其实，在进行性能调优的时候，首要追求的目标应该是最大的投资收益比，也就是获取的性能优化收益值和消耗工作量成本之间的比值要最高。

所以在理想情况下，我们应该将性能调优目标设定到一个性能提升临界值（通常会接近性能提升的上限）。如果达到这个临界值就意味着，即使后续进行再多的性能调优工作，我们能获取的性能收益都会越来越有限。那么在这个时候，我们就可以适当调整下性能调优的节奏。如前面的示意图所示，软件 A 和软件 B 的性能调优目标设置的临界值，可能会在三角形所标识的位置附近。

但问题是，对于一个软件系统来说，性能调优提升目标的临界值要设定成多少才是合理的呢？我们要如何确定这个临界值呢？

所以在一般情况下，研发团队在设定性能调优目标时，会采取两种方式：

第一种，以客户关注的性能需求目标为导向。比如我之前参与的百万表单数据查询分析优化项目，它的核心目标就是客户在操作过程中不卡顿，因此我只需把查询请求响应时间优化到 1 秒内即可；

第二种，以降低产品的部署运维成本为导向。这种方式通常会先敲定一个性能提升百分比，比如将系统服务的响应时间降低 20%（从 100ms 到 80ms），减少产品部署使用的集群机器规模 20%，等等。

不过这里你也要注意，就是不管采用哪种方式制定的性能调优目标，它都可能无法与软件优化可以达到的临界值完全匹配。那么在这种场景下，就会很容易导致性能调优的目标没有达成，但是性能调优任务却无法继续开展的情况。

所以，我们在性能调优的过程中，一定要谨记一点：未经分析就敲定性能优化的目标是不可取的。

既然如此，那么正确开展和实施性能调优的方法步骤是什么呢？下面我就带你来分析分析。

正确开展性能调优的方法步骤

实际上，很多研发团队心目中的性能调优工作，可能就是选择一款代码 Profiling 工具，然后针对软件执行期间进行性能分析，逐个寻找热点函数，最后进行修改和优化。

但是我们要知道，这种方法存在很大的局限性，它可以识别出的性能优化点非常有限。比如说，并发设计、通信设计、IO 设计等软件设计引入的性能问题，它都无法识别出来；不仅如此，软件编码实现层引入的性能问题，它也都无法识别出来，比如数据结构和算法选择等。

所以这里，我就根据以往参与的性能优化项目经验，总结出了实施性能调优的方法步骤，接下来我就给你具体分析一下。

第一步，系统性的性能优化分析诊断。

这里你可以采用第 22 讲学习的性能问题分析定位方法，自顶向下地分析识别所有导致性能劣化的可优化点。这里输出的应该包含软件设计优化点、软件实现优化点等比较完整的列表，比如调整并发任务拆分、调整数据结构、选择性能优化模式，等等。

第二步，分析调整性能调优目标值。

这一步的意思就是根据识别出的性能优化点，分析修改后的性能提升收益。但要注意，这一步针对每个优化点的分析过程是不一样的，而且并没有统一的方法参考。

因此这里为了帮助你更好地理解这个过程，我给你举两个我以前参与的性能优化案例，来具体说明下。

案例 1：一个协议栈报文子系统的性能优化项目

在这个项目中，我们通过基于性能优化分析诊断后发现：业务在处理过程中，对报文数据执行了一次 copy 操作，但是协议在处理过程中，只修改了报文数据头部很少一部分字节的信息。所以在这种场景下，业务中的 copy 操作开销就可以优化掉。那么优化修改后的性能提升值有多少呢？这里我根据 copy 的数据量在单板上进行了测量计算，然后就在优化修改之前，计算出了性能的预期收益。

案例 2：一个后端微服务的性能优化项目

在这个项目中，我们经过性能优化分析诊断后发现，业务存在很多的慢查询操作，对软件性能造成的影响比较大。而在进一步分析后发现，这些慢查询所获取数据其实是很少变化的，所以就考虑采用缓存策略来优化性能。那么在这种场景下，我就可以根据慢查询的请求处理时延和请求的频次，来分析计算出引入 Cache 场景下的性能提升收益。

总之你要知道，对于性能优化点来说，性能提升收益分析是一个非常重要的环节，不应该被忽视。

第三步，按照成本收益逐步实施性能调优。

接下来，我们就可以对性能优化点按照优先级进行排序，逐步修改并验证优化效果。那么在对性能优化点进行排序时，我们需要考虑的因素主要有几个：性能收益大小、修改的工作量大小，以及对软件质量产生的影响（比如导致软件变复杂、引入故障风险高等）。

另外这里你要记住，如果把编译期选项配置优化和编码实现优化进行优先级排序，在同等性能收益的情况下，一般来说编译期优化的修改工作量会比较小，引入故障的风险率比较低，所以优先级应该更高一些。

补充：拿性能调优对软件质量产生的影响来说，并不是所有的软件调优工作都会导致软件质量变差。比如，你选择了更适合业务特性的数据结构和算法，可能不仅没有导致质量变差，可能还会让业务处理逻辑更加清晰、更易理解。

第四步，增加完善性能基线测试。

当性能调优合入后，你就可以同步修改完善性能基线测试了。这部分你可以参考第 20 讲学习的内容，复习下更好地看护软件系统性能的实现思路。

不过事实上，很少有研发团队可以按照上述的步骤来实施性能调优，因此在性能调优过程中，就容易陷入僵局，而且还花费很大的精力，却并没有给软件产品带来价值提升。所以在这个时候，研发团队就应该及时喊停，重新调整性能调优的工作方式与节奏了。

那么你再想想，除此之外，在其他什么样的场景下，还应该及时喊停呢？接下来我就给你分享下，我在实践过程中总结出来的答案。

什么时候需要喊停性能调优工作？

这里，我总结了以前参与软件性能调优过程中，观察到性能调优的一些反模式，你可以参考下，如果你发现自己所在的研发团队也出现这些状况时，就应该考虑调整下性能调优的方向和策略。

第一种性能调优反模式是：性能调优严重破坏了软件的质量。

这里我举一个真实的案例。在我曾经参与的一个嵌入式系统性能优化项目中，原来的性能优化团队发现，通过宏替换个别函数调用会带来性能提升，于是就几乎将代码中的所有函数都通过宏重新实现来整改替换。而最后导致的后果就是：大量的宏实现函数导致代码编写和阅读成本显著增大；同时在代码整改的过程中，引入了非常多的故障，而且很长时间无法得到很好的解决；更糟糕的是，最后的软件性能优化效果也没有达到预期。

其实，这种严重破坏软件设计质量的性能调优还是比较普遍的，比如说，在代码中随意添加条件分支进行特殊处理、最后因为加入太多特殊流程，导致代码很难再添加新的业务特性。

第二种性能调优反模式是：盲目修改代码来尝试优化。

有的性能优化团队为了提升指令 Cache 命中率，会随机调整函数的位置。比如说，把一个函数从一个文件中搬移到另外一个文件中；或者把一个函数从一个类搬移到另外一个类中，来判断 Cache 命中率是否有提升。这种性能调优方式，由于背后并没有理论指导，即使可以获取到一些短暂的性能提升收益，也是不稳定的，所以我们应该尽量避免这样做。

第三种性能调优反模式是：在业务的非性能瓶颈点上反复调优。

举个简单的例子，软件的查询请求处理的吞吐量，受制于底层网络传输带宽值的上限，理论上不可能再提升。这个时候，还在持续分析调优软件实现，期望提升吞吐量是没有任何意义的。

第四种性能调优反模式是：没有价值驱动的性能调优。

其实这种情况也挺常见，在软件系统中存在一些服务 / 组件（比如：操作事务记录，配置管理后台等），它们的处理性能并不会直接影响用户感受，而且占用的机器资源都很少，这时候如果还投入很大的工作量去优化软件性能，其实是没有意义的。

小结

今天这节课，我们学习了正确实施性能调优的方法步骤，不过总的来说，在性能调优的过程中，需要深入到业务与实现中，并使用专栏中学习到很多技术与方法，才能更高效支持性能调优工作。这里你需要注意，分析出性能优化点应该是包含了软件设计、软件编码等多方位的性能优化点，而且应该按照一定的优先级排序来逐步实施优化。

同时，在性能调优过程中还有很多比较常见的误区，比如严重破坏软件设计的性能调优、盲目修改代码的调优、非性能瓶颈点上的反复调优、没有价值驱动的性能调优等。你也可以一起学习下，避免在性能调优过程中出现类似的问题。

在学习完今天的课程后，你应该就可以掌握性能调优的方法和节奏，然后比较轻松地回答这个问题：性能调优应该在什么时候停止？

思考题

在性能调优的过程中，如果你发现导致业务请求时延长的原因，是存在很多的数据库慢查询请求，那么你该怎么优化呢？

欢迎给我留言，分享你的思考和观点。如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

26 | 一个嵌入式实时系统都要从哪些方面做好性能优化？

你好，我是尉刚强。从这节课开始，我们就进入课程的案例分享模块了。在这个模块中，我会通过之前参与的一些真实项目案例，来帮助你巩固前面课程中学习到的各种性能优化技术，并带你进一步深入了解实际项目中的技术落地细节。

今天，我要给你分享的是一个完整的性能优化案例，我会从启动这个性能优化任务开始，带你了解每一步的工作内容，包括如何启动性能分析、设计、实施性能优化工作，以及中间的思考过程，直到最后达成性能优化目标。

在这个具体剖析的过程中，你会发现做好性能优化并不是一锤子的买卖，而是一个系统化的软件工程活动。同时，案例中涵盖的高性能软件设计、高性能编码、性能测试与看护、性能调优等很多方面的具体技巧，你也可以直接拿来在自己参与的项目中使用。

这个性能优化攻关案例，我会按照案例背景、性能分析诊断、性能测试看护、设计与实现优化、优化成果对比的顺序进行介绍，这与我们性能优化工作的开展节奏也是基本一致的。另外为了方便理解，我在讲解案例的过程中，也会省略或简化掉很多跟领域相关的知识，主要聚焦在项目中所使用的性能优化技术。所以这节课，你需要重点关注的就是性能优化的完整实施过程。

那么下面，我们就先来了解下这个案例的背景吧。

案例背景

这是一个基于 C/C++ 开发的嵌入式实时软件子系统，它的核心业务逻辑是采用一定排序算法，选择出一部分优先级比较高的用户，分配相应的传输带宽资源。其中有几个关键的约束分别是：带宽资源、用户优先级、用户的需求都在实时动态变化中；核心业务逻辑的处理时间不能超过 1ms，否则就会导致系统业务出现错误。

然后，这个软件系统最核心的性能指标是 1ms 的调度用户数（完成排序选择和请求带宽资源分配的用户数目），同时还有一些隐含的性能要求，比如版本二级制大小不能增加、调度时延抖动情况不能增加、内存占用空间不能上升，等等。当然，与其他很多性能优化项目一样，这个团队也设定了性能优化攻关目标：1ms 内调度用户数性能提升一倍（比如从 8 用户 /s 提升到 16 用户 /s）。

那么面对这样的需求任务，接下来我们该从哪里开始呢？

性能分析诊断

实际上，我们首先应该进行系统化的性能分析诊断，这样才能找出系统中的所有性能瓶颈资源，从而识别出潜在的性能优化点。

那么，在对这个软件系统进行性能分析和诊断时，采用的就是自顶向下的性能分析方法，从高性能设计出发（如并发设计、通信设计等），再具体到编码实现技术（如性能模式、数据结构与算法等）。当然，在性能分析的过程中，我们需要同步获取相关的监控观测数据，来帮助验证这些性能分析结论是否是有效的。

在经过系统性的性能分析诊断后，我们最终识别出了一系列的性能优化点，比如并发任务拆分不均衡、消息交互过度互斥、排序算法效率不高、大量的重复计算逻辑，等等。不过这么多的性能优化点，用一节课的时间并不能完全分析清楚，所以这里我选择了其中两个比较有借鉴意义的优化点，来给你具体介绍下，这样你在真实的项目开发过程中，也能识别出这类引起性能劣化的软件设计问题。

优化点 1：类对象存储模型对 Cache 不友好

为了方便理解，我把这个软件系统中几个比较关键的类的领域关系，进行了简化处理，然后使用了一个类图来表示，具体如下图所示：

在这个软件系统中，存在多个 Group 的对象实例，其中每个 Group 对象都包含了一组 User 对象实例（规模一般在 1000 左右），而每个 User 对象（用户）又包含了多个特性实例，比如 Feature1 和 Feature2 的对象实例。

而这个软件系统的核心计算逻辑是：按照不同 User 中的 Feature 对象实例进行遍历，然后进行排序。

那么这个时候，你其实会发现这些 Feature 对象实例空间，在内存中完全是离散分布的，所以就导致了在数据遍历的过程中，数据局部性不友好。当然，这也是该软件系统在运行过程中，数据 Cache Miss 的概率比较高的主要原因之一。

实际上，这个优化点反映了一个比较重要的现象，也就是很多系统的对象存储模型，会影响到软件的执行性能表现。

优化点 2：业务代码未进行预裁剪，导致代码执行效率不高

在走读和分析业务代码的过程中，我们发现有大量使用模式的分支选择逻辑（有几百次以上），如下所示：

if(mode == TDD1)
{
 calcBandResTDD1();
}
if(mode == TDD2)
{
calcBandResTDD2();
}

你要知道，如果业务代码中存在大量的重复分支逻辑判断，就会潜在地影响到指令分支预判成功率，具体表现在指令 Cache Miss 概率比较高。所以我们再进一步分析业务逻辑，发现这个软件系统一共有 7 种工作模式，它们核心的处理流程都是相似的，只是具体的处理细节中存在差异，这样就会导致系统中存在大量的分支选择判断。

但其实，这个软件系统的工作模式选择在运行之前就可以确定了，所以没必要像这个软件编码实现一样，把这种运行前的选择逻辑放到运行过程中去实现。也就是说，我们可以认为本质上所有的这些分支选择逻辑代码，都可以在运行前裁剪掉，从而减少运行时引入的开销。而且，这样也能避免因为在代码运行时加载了很多不需要的代码，而导致出现一些其他的性能问题，比如冗余的内存空间浪费、二进制文件变大等。

其实，这个软件设计与实现问题，也是一个比较典型的导致软件性能劣化的原因之一。就举个简单的例子，在 ToB 的企业应用开发中，给企业 A 提供的软件中会包含给企业 C 开发的一些定制功能，从而就导致企业 A 的软件运行效率不是最佳的；或者说，某一款电话手表开发的软件 App，在运行中加载了很多其他型号电话手表中，定制化的业务代码和对象实例，从而就会引起执行性能不佳，导致 App 卡顿等问题。

另外不知你发现了没有，这里识别出的这个软件设计实现优化点，并不是通过工具链扫描发现的，而是通过主观地分析和走读业务代码发现的。而且，我们使用工具进一步获取的观测数据，只是为了验证性能分析结论的正确性，以及它对性能产生的具体影响大小。也就是说，在性能优化过程中，你依赖的应该是自己的独立思考和分析能力，而不是一个具体的工具。

好，那么当识别出了这么多性能优化点之后，我们是不是就可以直接去动手修改优化代码实现了呢？

实际上，我们并没有这么做，因为在代码设计与实现优化前，我们首先应该构筑性能测试看护网。

性能测试看护

因为原来这个软件系统的性能测试是基于真实设备上运行的，所以获取软件性能表现的反馈周期会比较长，而且也不方便获取，软件内部模块级细粒度执行开销的观测统计数据。那么在这种现状下，对软件设计与实现进行优化重构修改，其实会很难获取到及时的反馈信息，从而就会影响到性能调优的工作效率。

因此，我们首先就针对这个软件子系统，开发了本地化的微基准性能测试用例，希望可以支持快速获取软件优化重构后，其内部细粒度的观测数据和性能提升的效果反馈。

下面给出的是针对这个软件系统的观测数据伪代码，以及本地性能测试结果的打印效果图，具体如下：

在图中，左边是添加打点代码的示例，其中大括号中的代码会按照 TIME_RECORD_ID，来统计执行花费的 CPU 的 Cycle 数目（指令周期）；右边则是根据性能测试场景，打印出的各个业务流程的具体执行开销。

这样一来，由于这个测试用例可以快速地本地化执行，所以我们就可以很方便地验证软件优化重构后的性能提升效果。接下来，我们就可以开始对软件进行设计与实现的优化了。

设计与实现优化

首先我们都知道，软件设计与实现的优化工作是针对性能分析识别出的性能优化点，采用高性能设计与编码技巧，对软件代码设计与实现进行重构，然后逐步优化提升软件性能的过程。

当然，每一种性能优化点对应的修改解决方法是不一样的，这里我们再回到前面提到的两个性能优化点，来分别看下怎么去对应解决。

优化点 1：类对象存储模型优化设计

针对前面提到的那个类对象模型场景，核心的解决思路应该是将所有 User 对象间的 Feature 的对象实例内存空间放到一起，比如把系统中所有的 Feature1 对象实例放在一起，所有的 Feature2 对象实例放在一起。这样就可以在遍历过程中，实现数据访问有更好的局部性。

具体的设计与实现有很多种方式，这里我就不进行深入介绍了。不过在游戏开发领域，有一个典型的 ECS 架构模式，其特点之一也是解决了对象存储模型与计算模型解耦的问题，从而提升了软件的 Cache 友好性，具体你可以参考ECS 架构简介。

优化点 2：裁剪掉未使用的业务代码逻辑

我们再回到之前介绍的那个存在大量重复分支判断的场景，现在我们已经知道，为了提升运行时的性能，本质上就需要在软件运行过程中裁剪不需要的功能与代码。那么这一步具体要如何实现呢?

对于 C/C++ 语言来说，其实有很多种手段来解决这个问题，下面我就来给你具体介绍一下。

一般情况下，你可以通过预编译宏或者构建时的静态多态技术，来为不同的工作模式生成不同的二级制交付版本。但这种方式有些局限性，引入过多的编译宏会导致代码的可阅读性变差，同时也会给版本管理增加复杂度。

当然，你还可以使用运行时多态，通过为不同的模式创建对应的子类，来减少这种类型的分支判断处理。但这种方式也存在局限性，那就是虚指针调用本质上也是运行时判断的，所以也会影响运行时开销。

最后，我们在优化实现的过程中，可以采用组合式模式，比如使用泛型技术，去组合通用计算逻辑和各种工作模式下的差异计算逻辑，从而生成在特定工作模式下的代码，来规避虚指针引入的额外性能开销，以及过多编译宏导致代码可读性差的问题。

补充：对于 Java 来说，在采用多态的方式来解决这种问题的时候，可以充分利用 JVM 在运行的过程中，只会加载需要的 class 文件的机制，来避免一些不必要的执行开销；而像 Ruby 这样的动态语言，你还可以在软件执行期间，通过动态删除或修改函数方法实现来优化性能。所以说，不同语言的解决方法有很多差异，当碰到具体的代码实现优化问题时，你还需要结合特定语言的机制，来重构和优化代码。

总之，在性能优化攻关期间，我们需要持续重构优化后的代码并合入到主线中，同时为了保证性能优化结果可以长期有效，我们需要将微基准性能测试集成到 CI 流水线中，来帮助我们在软件性能劣化时，可以第一时间发现问题。

最后，这个嵌入式性能优化攻关项目，在经过系统性的性能分析与诊断，以及持续地重构与优化之后，在一年半的时间内，其软件关键性能指标就取得了近一倍的提升。同时还实现了在性能优化的过程中，软件设计更加清晰，代码更加简洁。

小结

对于性能优化领域来说，凡是工具可以直接定位和解决的问题，很有可能都不是系统最核心的软件性能问题。所以在对一个复杂软件系统进行性能优化时，需要你具备很强的软件设计和编码能力，同时还需要深入到业务设计与实现中，去识别和发现各种性能优化点，并且还需要逐步修改和重构代码来优化性能，这样才有可能设计和开发出高性能的软件系统。

那么今天这节课，我给你讲解了一个嵌入式实时系统的性能优化攻关项目案例。你应该重点关注的是这个性能优化案例的实施过程，包括其中每一个步骤的核心关注点和解决的问题都是什么，以及这样做的好处是什么。在掌握了实际项目中落地实施性能优化技术的细节之后，你就可以在具体的性能优化项目中，去借鉴和采用这些方法，从而可以在更大程度上去帮助改进软件产品的性能表现。

思考题

在你参与的性能优化项目中，关键的性能优化点是通过深入分析业务系统的设计与实现发现的，还是通过监控分析工具来发现的呢？

欢迎在留言区分享出你的答案和分析诊断思路。如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

27 | 解决一个互斥问题，系统并发用户数提升了10倍！

你好，我是尉刚强。

互斥锁是实现并发场景下业务操作原子性、解决互斥访问问题的有效手段之一，由于它的使用方式相对比较简单和安全，所以不论是在互联网的分布式系统中，还是在嵌入式并发场景下，应用都比较广泛。

但是，如果互斥锁的选择和使用不当，就很可能成为系统的性能瓶颈之一，所以合理优化互斥锁，就成为了系统性能优化的一个重要手段。

那么在今天的课程中，我就会给你分享一个互联网场景下使用互斥锁优化的案例，按照优化前的软件实现、性能瓶颈分析、优化解决方案的思路，带你剖析我是通过什么样的方法优化业务中的互斥锁，以及是如何提升业务 RPS（Requests per second，请求吞吐量）性能指标 10 倍以上的，从而帮助你全面地了解分析与优化互斥锁的详细过程。

这样，通过学习这个性能优化案例，你在业务中就可以准确识别出哪些场景下的互斥锁可以优化掉，而哪些场景下不可以。并且你还会掌握一种手动实现事务的机制（支持业务操作回滚机制），来替代业务中互斥锁的手段，进一步来帮助优化提升软件的性能。

那接下来我们就先看看，在该案例中，业务优化前的实现是怎么样的，以及它都存在什么性能问题。

优化前的业务实现为什么会有性能问题？

这个性能优化案例的业务场景是这样的：用户给在线表单提交一条记录，在这条记录中会包含很多个字段内容，其中有些字段在插入时有一个规则要求，即不能与已有的字段值重复。

为了便于理解，这里我用一个图来描述下，原业务系统中实现字段插入值不重复规则的实现逻辑，具体如下所示：

可见，在该业务中使用了一个 Redis 锁来实现互斥访问，从而实现了被加锁的业务逻辑执行的原子性，所以这部分计算逻辑在系统中是串行执行的。而被加锁的业务逻辑主要有三个关键操作，分别是：

字段不重复检测：检查插入的字段值在数据库中是否有重复的情况出现，如果出现重复的值，插入失败直接退出，否则执行下一步的操作。这里系统会遍历要求值不能重复的所有字段项，如果其中任何一个字段项出现值重复，就都会退出。

其他操作：即用户提交记录的过程中一些关键业务操作，其特点是不能被拆分执行，也不能被回滚。如果操作成功的话，就会执行下一步的操作，否则也会直接退出。

所有字段插入：由于这三个操作，通过加锁保证了原子性执行，所以前面检测的“字段值不重复”的条件仍然是有效的，这一步会将所有的字段进行插入。

除此之外，在优化前的代码实现中，需要进行重复性校验的字段都会记录在 Redis 中。所以，图中的操作 1、操作 3 都是基于 Redis 来实现的。

那么在看完这个业务实现逻辑图之后，你可能会比较好奇：这种字段唯一性检测机制，为什么不使用关系数据库中的字段唯一性检测机制来实现呢？这是一个好问题，我在刚看到这个业务逻辑实现的时候也很好奇，而到后来深入分析了业务之后，才理解为什么会这样实现。

其实这是因为，在这个业务系统中有大约 1000 万张表单，其中每张表单的字段唯一性规则可能都不一样，而且用户还可以随意修改这个规则。所以，这个系统在设计实现的时候，就将所有表单中的所有字段，都放到了一张很大的数据库表中，因此这样我们就没有办法使用数据库表上的字段唯一性规则，来处理这个问题了。

原来的这种 Redis 加锁的实现方式比较简单，而且是按照单个表单来进行加锁的，所以在单个表单并发提交请求吞吐量不是很大的情况下，并不会对系统性能产生太大的影响。

可问题就是，随着系统的业务规模逐渐增大，会出现少量表单的并发请求吞吐量暴增的情况，而这个时候，当单个表单提交请求超过了并发请求吞吐量的上限值后，就会引起两个比较严重的性能问题：

针对超过并发请求吞吐量性能上限值的那个表单，用户在提交表单的页面会出现卡死，导致提交数据失败；

由于后端服务系统是基于进程模型的，而进程资源的数目是有限的，所以一旦个别表单提交数据请求的处理进程被阻塞，占用了大量的进程资源，就会导致整个系统无法正常处理所有的业务请求。

因此，提升单个表单提交请求吞吐量的性能指标，就成为了这个软件系统性能优化的关键问题。那么接下来，我们就要先搞明白，这个互斥锁是如何影响这个表单的请求吞吐量性能的。

补充：在这个软件系统实现中，由于单个表单的吞吐量性能瓶颈，而导致的整个系统业务处理受阻，实际上是另外一个软件设计问题，所以不在我们今天的课程讲解范围内。

互斥锁是如何影响最大请求吞吐量的？

在第 22 讲中，我们已经学习过加锁的计算逻辑属于串行资源，这是系统中潜在的性能瓶颈之一，会影响系统的请求吞吐量的性能指标。

接下来，我就使用一个公式来描述下在这个案例中，使用了 Redis 互斥锁以后，来计算 Max RPS（最大请求吞吐量）的计算方法，具体公式如下所示：

在这个公式中，因为 Lock 和 Unlock 是采用 Redis 的互斥锁来实现的，它们所使用的 Redis 的 script 脚本实现如图中所示，通过在真实系统中进行测量，其中 Lock time + Unlock time 的操作时间之和在 3ms 左右。

然后你就可以通过上面的公式计算出，如果中间加锁的计算逻辑（resource competition）执行开销为 30ms 左右，那么对应的 Max RPS=1s/(3ms+30ms)，也就是大约在 30RPS 左右。

也就是说，如果把加锁的计算逻辑降低极限值为 0 时，对应的 Max RPS 才可以到达 300RPS 左右。

那么这里你需要注意的是，因为业务中的互斥锁是全局控制的，所以当系统达到最大 RPS 时，即使通过弹性扩展机制部署再多的后端服务实例进程，也不能再提升这个性能指标了。

因此到这里，在这个性能优化案例中，我们经过测量加锁的计算逻辑执行时间为 30RPS，然后根据上面的公式，我们计算出的最大 RPS 值也为 30RPS 左右，这与真实的性能测试获取的性能指标值是完全一致的。

好的，现在问题就已经比较清楚了，那么有没有办法可以优化提升这个系统的性能呢？下面我们来看一下。

性能优化解决方案

针对这个业务案例，你应该也能发现一个明显的特点：绝大多数情况下，并不会出现并发提交相同字段值的情况。也就是说，如果这个业务逻辑没有增加互斥锁，在 99.9% 的情况下业务逻辑也是正确的。所以，针对这种场景，我们就可以采用手动实现事务机制，来优化掉业务代码中的互斥锁，来提升请求吞吐量的性能。

而这里我们已经知道，在这个案例中，使用互斥锁解决的核心问题是判断字段不重复和字段插入操作的原子性问题。所以说，我们其实可以考虑采用一些优化机制，来单独实现这两个操作组合的原子性。

但是要注意，如果在互斥锁的使用场景中，被加锁的业务操作还有更复杂的一致性要求，比方说存在数据库写冲突的问题等，那么这种互斥锁实现就不能被简单地优化掉了。

那么对于这个案例中的互斥锁而言，我们应该怎样优化呢？

我来说说我想到的优化思路。这里呢，为了更清晰地描述该解决方案，我用了一个流程图来给你详细地介绍下性能优化后的具体实现过程，如下图所示。

也就是说，我们可以把“字段不重复检测”“单个字段插入”“其他操作”三个操作绑定到一起，然后实现一种事务机制的能力，支持在后面操作失败的情况下，可以回滚到前面的操作中。

实际上，原来的 Redis 互斥锁主要是为了实现“字段不重复检测”和“字段的插入操作”的原子性，而在手动实现事务机制之后，我们就可以把这两步操作放到开始处执行，然后使用 Redis 的 Pipeline 机制保证这两步操作组合的原子性，从而不会被其他 Redis 操作干扰到。

这样针对接下来的其他操作（也就是用户提交数据过程中的一些不可拆分的关键业务操作），如果操作成功，就提交任务成功结束；如果操作失败，则需要回滚之前的字段插入操作。另外为了实现事务的机制和能力，我们还需要在前面字段插入时，同时记录插入前的状态和插入后的变更状态，从而就可以实现失败后的回滚机制。

其实，这里我还考虑过另外两种实现方案，分别是基于 Redis 的事务机制和基于 MongoDB 上的事务机制。

但是，我最后在实现时并没有采纳，这背后其实有很多的原因。比方说，使用 MongoDB 的事务需要进行数据迁移，而且需要升级系统的 MongoDB 集群的数据库版本等，以及使用 Redis 事务机制的代码实现并不友好，等等。

不过这里有一个最重要的原因就是，不管是使用 Redis 事务还是 MongoDB 上的事务，它们都把对字段插入操作的冲突时间，拉长到了步骤 3“其他操作”结束之后，而这样就显著增大了事务冲突失败的概率。

所以最后，我们采用前面这种优化后的实现机制，因为去除了互斥锁，所以用户间的提交记录可以更大程度地并行。而且优化后的实现方式，只有 Pipeline 操作会排队处理，而由于单个 Pipeline 的执行时长在 1ms~3ms 之间，所以最后优化后的表单最大请求吞吐量，就从原来的 30RPS，提升到了 300RPS 左右，这样就实现了性能提升超过 10 倍的目标。

小结

同步互斥是高性能分布式系统设计实现的关键技术之一，而在软件开发的过程中，我们很容易因为设计或实现不高效，导致整个软件系统的性能不够理想。所以在今天的课堂上，我通过一个真实的性能优化案例，给你讲解了业务中使用互斥锁存在的性能问题，包括它对请求吞吐量的性能影响，以及优化互斥锁来提升系统性能的方法和过程。

但是，每个业务系统中的同步互斥问题都存在特有的复杂性，所以你应该学习收获到的是，如何分析同步互斥对性能的影响，以及如何优化同步互斥的实现来提升性能的方法。在今天所讲的案例中，采用的是先默认成功来执行操作，后面出现异常时再回滚的机制，你也可以在自己项目优化中去使用。

思考题

基于 Redis 上有 CAS 命令，有 Pipeline 机制，还支持 lua.script 和事务能力，那么在业务中你会怎么选择来优化软件的性能呢？欢迎给我留言，分享你的思考和看法。如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

28 | Web服务业务代码一行不动，性能提升20%，怎么做到的？

你好，我是尉刚强。

在软件开发的过程中，为了保持软件系统设计的简单性，一般情况下，我们会把业务操作实现成强一致性的，而且是实时生效的。但是，在设计与实现高性能软件系统的过程中，我们其实还可以通过降低一些非关键业务操作的一致性或实时性，来调整软件设计与实现，从而换取更大的性能收益。就比方说，我们经常使用的部分 Cache 技术，其背后的原理就是通过降低数据的一致性，来提升软件的执行速度。

那么今天这节课，我要分享的也是一个通过降低业务操作的一致性和实时性，来换取软件性能提升的案例。

我会按照“优化前性能分析”“优化解决方案”“优化成果分析”的顺序来进行讲解，并带你剖析在这个过程中我是如何思考问题，以及如何根据具体业务场景和软件实现现状进行权衡的，以此来让你可以更加清楚和明白，如何去分析不同的业务操作的一致性和实时性差异和影响范围，从而设计出更加适合业务场景的 Cache 技术解决方案，来优化提升软件的性能。

下面，我们先来了解下这个案例的背景，一起来分析下这个软件系统优化前的性能。

优化前性能分析

在互联网的 Web 服务中，A/B 测试作为一种数据驱动产品进行优化的科学方法，应用比较广泛。其中 A 代表原有实现方案，B 代表新的实现方案，然后通过显式地控制与调整使用方案 A 和方案 B 的用户占比，并获取观察分析数据，来评估新功能或者实现方案上线后是否有效，以及预期收益是否在合理的范围内。

由于 A/B 测试的机制原理是业务无关的，所以，很多编程语言中的一些第三方库已经实现了 A/B 测试的功能，方便我们使用。今天我介绍的 Web 服务性能优化项目中（基于 Ruby 语言开发的），也正是使用了一个 A/B 测试的库Split。

首先，在这个 Web 服务性能优化项目中，会使用监控分析工具 NewRelic，来获取请求内部处理的跟踪信息。而我们通过分析跟踪获取信息后发现，有很多基于 Redis 的请求操作占用了比较多的处理时间，再进一步分析软件的代码实现后发现，这些 Redis 的请求操作主要都来自这个 A/B 测试的第三方库 Split。

因此，为了进一步地分析 A/B 测试的库 Split 对软件性能的影响，接下来我主要做了两件事情：

学习 Github 上 Split 开源库文档中的设计思路，以及这个库的主要流程的源代码，目的是分析是否有 Split 库使用场景不当，导致引入性能问题；

梳理出这个 Web 服务中一次 Split 的调用期间，所有的 Redis 的 API 请求的详细列表，这一步主要是为了寻找是否有冗余的 Redis 读取操作。

其中，针对 Redis 的 API 请求列表梳理结果如下：

GDRedis.instance.exists "new_feature_swich"
GDRedis.instance.type "new_feature_swich"
GDRedis.instance.lrange "new_feature_swich",0, -1
GDRedis.instance.lrange "new_feature_swich:goals", 0, -1
GDRedis.instance.get "new_feature_swich:metadata",
GDRedis.instance.hset "experiment_configurations/new_feature_swich", resettable, true
GDRedis.instance.hset "experiment_configurations/new_feature_swich", :algorithm, "Split::Algorithms::WeightedSample"
GDRedis.instance.hkeys "split:530c9534d48164466d000216"

GDRedis.instance.exists "new_feature_swich"
GDRedis.instance.hgetall "experiment_configurations/new_feature_swich"
GDRedis.instance.type "new_feature_swich"
GDRedis.instance.lrange "new_feature_swich",0, -1
GDRedis.instance.lrange "new_feature_swich:goals", 0, -1
GDRedis.instance.get "new_feature_swich:metadata",

GDRedis.instance.hget "experiment_winner", "new_feature_swich"
GDRedis.instance.hget "experiment_start_times", "new_feature_swich"
GDRedis.instance.hget "experiment_winner" "new_feature_swich"
GDRedis.instance.get "new_feature_swich:version"
GDRedis.instance.hkeys "split:5c6b6a1377fa104b6a427c8e"
GDRedis.instance.hget "experiment_start_times", "new_feature_swich"
GDRedis.instance.hget "split:530c9534d48164466d000216", "new_feature_swich:5"
GDRedis.instance.hset "split:530c9534d48164466d000216", "new_feature_swich:5", "disabled

那么，如果仔细观察这些 Redis 的 API 请求列表，你就会发现，这个列表中有好些 API 请求是重复的，比如说：

GDRedis.instance.exists "new_feature_swich"
GDRedis.instance.type "new_feature_swich"
GDRedis.instance.lrange "new_feature_swich",0, -1
GDRedis.instance.get "new_feature_swich:metadata",
GDRedis.instance.lrange "new_feature_swich:goals", 0, -1

这样在整体阅读完代码后，你就会明白，这个 A/B 测试的 Split 库的一次接口调用过程中，Split 内部的不同模块间重复读取了 Redis 中的同一条数据。但其实，这是完全没有必要的，因为这是一个很明显的低效率编码实现问题。

补充：其实我们的软件系统引入的第三方库，因为使用方式和场景不是最佳的，很有可能会导致运行性能比较差，从而也会直接影响到软件系统的性能。

再深入分析业务代码实现，我们发现这个 Web 服务中每个 REST 请求，平均会调用这个 A/B 测试的 Split 库的接口 1.5 次左右，而每个 Split 接口实现中大概会调用 Redis 接口的次数在 10~30 之间。

这里我们根据 Redis 的 API 平均处理时延为 0.5ms 左右，来进行粗略估算，就可以计算出 Web 服务的 REST 请求中，使用 A/B 测试接口占用的处理时间开销大约为：1.5 （10+30）/20.5 = 15ms 左右。又因为目前这个 Web 服务的 REST 请求平均处理时延在 120ms 左右，所以就可以预估出 A/B 测试占用开销为 15ms/120ms=12% 左右。

因此，如果可以优化掉这个执行开销，那么性能收益其实是相当可观的。

好了，现在，我们已经深入了解了 Split 的内部原理和代码实现，以及它对业务的性能影响，那接下来，我们就可以考虑怎么来优化下这里的软件性能了。

性能优化解决方案

不过，在确定优化解决方案之前，我们还需要分析下 A/B 测试功能在业务中的使用场景，这样才能更容易寻找到性能比较好的解决方案。

在这个 Web 服务中，A/B 测试属于常用功能，一般情况下是按照一周的时间维度来手动配置的。所以，在理论上你可以认为 A/B 测试的配置是比较稳定的，不容易频繁变化。但是，在业务实现中的每个 REST 请求中，都会去读取和更新 A/B 测试的配置，因此你可以认为，A/B 测试配置变更可以在 ms 级别后实时生效。

那么这里，我就有一个问题想问：针对 A/B 测试的配置变更操作，是不是一定要在 ms 级实时生效呢？

答案是，不需要。实际上，通过进一步分析 A/B 测试的业务使用场景，你会发现系统中的各个业务请求间都是相对独立的，如果获取到 A/B 测试的配置数据在短时间内不完全一致时，其实并不会影响到业务功能。

所以基于这个分析判断，我们可以得出一个结论：适当地降低 A/B 测试配置生效的实时性和一致性，就可以给 Cache 技术留下比较大的空间来优化提升软件的性能。

补充：可能会有极小的概率会出现，单个用户在很短时间内多个请求，页面的显示风格有些差异，但也是用户可以接受的。

那么，针对这个 A/B 测试的功能，现在的代码实现是每个请求都会读取配置。如果可以按照每个服务的进程，使用内存 Cache 手段来保存 A/B 开关配置，就可以优化掉绝大部分的 A/B 测试中，Redis 的 API 占用的时间开销。

而这里的内存 Cache 机制，我们可以采用基于时间失效（即 Cache 保存的数据超过一定时间后自动失效）这种比较简单的实现方式，在经过业务分析后，我们得知配置失效时间在 5s~10s 是可以接受的。

那么现在，针对该项目的性能优化思路就已经很清晰了，也就是我们可以考虑怎么在代码中实现内存 Cache 机制，来保存 A/B 测试配置信息。这里有两种方式可供选择。

方式 1：将 A/B 测试的配置的 Cache 机制直接实现在这个 A/B 测试库 split 的源码中。

方式 2：在 A/B 测试库的外部再封装一层接口，来实现 Cache 机制，然后业务代码修改使用新的接口。

这两种实现方式我们具体该怎么选择呢？

使用方式 1 有一个好处是：所有的代码修改对 Web 服务中业务代码都完全不感知，如果出现异常时，直接更新依赖的 Split 库的版本即可。如果使用方式 2，业务中所有调用 A/B 测试库的接口处都需要进行修改，由于目前业务代码中使用 A/B 测试接口的地方特别多，所以综合分析之后，我选择采用方式 1。

这里你可能会想：在 A/B 测试库 split 的源码中直接实现内存 Cache 机制，不是会很复杂吗？因为它还需要引入一个内存 Cache 的库才行。

但其实，对于很多软件实现来说，可能你只需要加入一个 HashMap 数据结构，就可以实现 Cache 的机制了，所以，修改 A/B 测试功能的 Split 库的源代码，增加内存 Cache 机制的原理是比较简单的，具体原理如下图所示：

如图中所示，首先读取内存 Cache 中 A/B 开关值的时间戳，然后基于时间戳判断，这个 Cache 中数据是否已经过期（这里配置过期时间暂定 5s）。如果没有过期的情况下，系统可以直接返回 Cache 中记录的 A/B 开关值；否则的话，就需要重新读取 Redis 中的 A/B 开关配置信息，更新到 Cache 中并记录更新时间戳，然后再返回新读取到的 A/B 开关值。

完成这一步的 Cache 机制的代码修改时，其实还并没有修改我们在前面的分析中所发现的低性能问题：A/B 测试库 Split 实现中会重复读取 Redis 的值。那么，这个性能问题需要进行修改吗？

其实引入 Cache 之后，由于大量的 A/B 开关值都是从 Cache 中读取的，原来在 Split 库中重复读取 Redis 值请求的问题，对 Web 服务的性能影响会比较小，因此理论上你可以不用修改。

补充：不过我在阅读代码的过程中发现，其实只需要修改几行代码就可以解决这个问题，所以就顺带也修改了，但实际上我并不推荐这么做，因为这里修改性能收益是比较有限的。

那么到这里，我们就已经完成了这个性能优化方案的所有代码修改工作。不过现在我们还需要确认一个问题，就是在 A/B 测试的 Split 库中增加了内存级 Cache 优化修改后，这个 Web 服务的性能提升效果和预期是一致的吗？

优化成果分析

为了更好地支撑优化成果分析，在优化版本上线过程中，我们又使用监控工具来获取了系统的平均响应时延变化情况，具体如下图所示：

从图上你可以看到，系统的平均响应时延值降低了接近 20%，远超过了性能优化分析的预估值 12%。其实，这种现象是比较正常的，这是因为减少业务中大量 IO 请求操作的同时，也会减少进程 / 线程的切换发生次数，而这部分的性能优化提升效果是比较难进行估算分析的。

同时我们还会发现，整个软件服务器集群对 Redis 的 API 请求操作次数也降低了接近一倍，具体如下图所示：

可以发现，当系统对 Redis 的 API 操作请求次数减少之后，也就意味着，现在系统中的 Redis 数据库就可以服务于更大的软件服务规模。所以很多时候，一个点上的性能优化会改善产品中多个方面的性能特征。

小结

今天我分享的这个性能优化案例，并没有去修改业务中的代码，只是修改了第三方库中少量的源代码，但是性能优化提升的效果却是非常明显的，所以在剖析完这个优化案例之后，你可以重点关注以下几个要点，来帮助支撑你的性能优化工作。

首先，我们业务软件的性能不仅会受制于系统内的代码实现，还会受到外部依赖库的执行性能影响；

其次，在性能优化的过程中，最具挑战的事情可能并不在于最后的代码修改，而在于前面的解决方案设计过程中，如何深入业务场景和软件设计实现，在每一个环节都进行分析、评估、取舍，最后才确定出更优的解决方案；

最后，就是其实在软件系统中有不少的业务操作，对实时性和一致性要求并不是非常高，你就可以采用 Cache 机制来优化提升软件性能。

思考题

在今天分享的性能优化案例中，Cache 机制中的 A/B 测试开关的失效时间配置为 5s，那么如果这个配置得再长一些，你认为会对软件有什么影响呢？

欢迎在留言区分享你的看法。如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

结束语 | 千里之行，始于足下，你已踏上修炼之道！

你好，我是尉刚强。

到这里，这门《性能优化高手课》就要和你说再见了。在前面的课程中，我一直在和你讨论软件系统性能优化的各项技术手段，不过到了最后一讲，我想再给你分享一些跟技术相关的，但也不局限于技术的话题，希望能通过我以往在工作或生活上的一些体会和感悟，给你带来一点点启示。

简单是正确的向导

我刚开始创业的时候，有段时间一直在加班加点，给公司研发的智能聊天机器人添加各种功能，但问题是用户量一直上不去，导致了团队长时间处于低谷之中。然后，有一次我在与投资人聊天的时候，讲到我们如今创业的处境太艰难了，我的投资人当时就说了一句话：通往成功的路，往往是一条最简单容易的道路。你们之所以做得太艰难，恰恰是因为没有找到那条简单的路而已。

这句话对我简直就是当头一棒，而且我之所以会记忆得如此深刻，一个很重要的原因其实是一直以来，我也在秉持这种做事方式，但是却没有悟出这个道理的真谛所在。就比如说：

在对软件进行架构设计的时候，如果给出的设计方案过于复杂，其实大概率是因为我没有深入理解业务背后的领域模型；

在定位分析一个性能问题时，如果整个分析和定位的过程很繁琐，很可能只是我没有找到简单的分析方法，所以才久久不能发现具体的性能瓶颈。

针对一个现成的软件，我需要添加过于复杂的业务特性时，很有可能是因为软件在最初设计的时候，并没有做好简单设计，现在只是还之前欠的债。

所以我想告诉你的是，不要忽视简单的价值，才能在工作中更容易获得成功。

要知道，很多啰啰嗦嗦、洋洋洒洒都说不清楚一件事的人，其实是能力不够造成的。而能一语道破本质、能轻松应付难题、甚至是工作效率比你高的人，实际上是因为他比你更早地养成了简单做事的思维习惯。

我以前参与过一个软件项目，团队需要给一个嵌入式软件开发一个组件级的测试框架，支持用户开发特性级别的测试用例。由于这个嵌入式系统是一个复杂的并发系统，底层有线程、进程、定时器、消息通道等各种机制，所以当时我就提出了两种方案。

方案 1：尽量真实地仿真底层并发机制，期望在特性测试中可以发现并发问题；

方案 2：尽量简单地仿真底层并发机制，主要聚焦于业务特性测试，而不会测试并发场景。

当时，在项目的开始阶段，我是比较倾向于方案 1 的，不过在开发推进了两个月之后，我们却逐渐发现，因为测试框架太复杂，导致测试用例开发困难，而且还很不稳定。所以最后，我们不得不完全废弃方案 1，改用方案 2，结果不到一个月的时间就完成功能开发并上线，最后也取得了非常不错的测试防护效果。

你要注意，凡事都坚持复杂化的人，恰恰是对那些情况不怎么熟悉的人，因为复杂只是冗余的堆加，是一种简单的缺失。而把简单作为做事的标准之一，让大脑建立一种认知和行为的常规模式，你在做任何事情的时候，甚至都不需要耗费很多精力。

细节是成败的关键

接下来想分享给你的一个要点，是细节。

细节是什么呢？我觉得细节是起关键作用的小事。因为很多时候，细节好像都是我们经常会忽略的一个问题，比如在编写代码的时候，一不注意就会埋入一个小小的 Bug，结果导致好几天的努力都付诸东流。

所以这里我想强调的，就是你对细节的态度，可能在无形中就决定了做一件事情的成与败。如果持有“差不多就行”的思想，其实很容易在后面吃大亏。

就拿我自己来说，我曾经做过一个百万表单的性能优化项目，当时为了提升数据的查询分析速度，我有了一个比较大胆的想法：将所有数据都进行一次编码操作来压缩数据，然后记录到分析数据库中。不过这样，我就需要把所有原来的业务请求，都转换成基于编码之后的数据上的查询分析请求。

因此接下来，我先进行了原型验证，发现这个性能优化思路确实可以取得很大程度上的性能提升（执行速度提升了几十倍）。

不过只做原型验证还不够，所以接下来还有一个很重要的环节是可行性验证。在深入验证这个方案是否可行的过程中，我需要针对业务中的每一种字段类型来设计编码方式，而且要确保不同字段的编码和解码规则不冲突，还要验证各种组合场景下的功能是否能够满足要求。因为如果中间任何一个字段编码规则出现问题，都会导致整个方案不可行。

这样，为了进行充分的验证，我就前后验证分析了 30 多种字段类型在多种编码规则下的组合场景。最后才保证了性能方案可以正常落地实施，并获得了很好的性能提升效果。

所以我才说，细节是魔鬼。如果你设计了一个软件方案，从各个角度来看，貌似都很完美地解决了所有问题，那肯定是还有些细节问题没有被挖掘出来。

另外，你也不要觉得把握细节，好像就意味着你要事必躬亲。其实最重要的是你要知道什么才是关键点，然后更细粒度地来思考和分解问题。

走出舒适区，成长才更快

最后，我还想分享给你一句对我现在工作帮助很大的话：只有让自己待在非舒适区，才能成长得更快。

我从事嵌入式领域研发工作已经有好些年了，在这个领域的技术积累和沉淀，也已经足够让我比较安逸地在这个行业工作到退休了。不过后来，我选择了进入一个全新的行业领域，也就是大数据领域。我现在依稀还记得，当时所面临的各种困难和挑战，比如要丢掉头上专家的光环、需要从 0 开始学习各种新知识，等等。

但是，在深入这个新领域一段时间后，我渐渐发现不同领域间的软件设计与实现过程中的底层原理是相通的，是可以互相借鉴学习的。比方说，大数据平台调度器 YARN 的调度器中，与无线空口资源的调度器中的一些算法原理是相似的，我们在优化配置时就可以借鉴；而大数据领域的并行设计，以及计算和数据分离的解决方案，我们也可以用在嵌入式领域的性能优化上。

所以，当有一天你可以成为新领域的专家时，你会发现自己比只从事一种领域的专家具备更广阔的认知视野，以及更多维度的问题解决思路和方法。我也是因为在好几个技术领域上所积累的经验，才帮助我现在能够更快地融入新的软件咨询项目中，并找到解决思路和方向。

当然，我这里说的非舒适区，并不是说你一定要切换一个计算领域，甚至要换一份工作。可能只是在工作或生活中，你可以及时地识别出哪些方面的技术或者能力相对比较薄弱，主动寻找机会来锻炼和提升，从而可以取得事业上更大概率的成功。

就比如说我之所以写这个专栏，其中一个很重要的原因就是：在平时参与项目中的模式经常是，快速解决问题然后就结束了，事后总结和提炼得非常少，所以给别人讲东西的时候会不太系统。而写专栏恰好也是挑战一下自己的非舒适区，来锻炼提升自己。并且，通过写专栏，我能够系统化地把性能优化的理论、方法和实践经验分享出来，让你有所思考、有所收获、有所成长，也带给了我足够大的动力，所以这里也非常感谢每位同学的支持与同行。

最后我还想说的是，千里之行，始于足下，课程结束并非是终点，我们还可以在留言区互动交流，也祝你享受成长，学有所成。另外，我准备了一份毕业问卷，希望你能花两三分钟填写一下，我也非常期待听到你对这门课的反馈。

好了，就到这里，我们再会。