跳转至

08 高性能设计:自顶向下的高性能Go程序设计与优化

你好,我是郑建勋。

用最少的资源将程序的性能优势最大化,这不仅是每一个有追求的开发者的目标,更是企业为了达到更好的用户体验(例如更小的响应时间)、更低的成本面临的现实困境。

性能问题无处不在,从设计、开发阶段如何避免性能问题,再到如何发现问题,发现问题后如何分析、排查、调优,“性能”二字贯穿于系统的整个生命周期。

但是应该怎样攻破性能问题呢?性能问题涉及到的知识面广而且深刻,如果没有方法论的支撑,要解决它们无异于大海捞针。接下来的两节课,我会带你构建起一种分层的分析范式,并通过它对问题进行分层抽象,抽丝剥茧,将问题逐个击破。

过早的优化是万恶之源?

说到这里,你可能迫不及待想要看看我的这个分层分析范式,验证一下它到底有多大本事。先别急,计算机科学中有句名言:过早的优化是万恶之源。

不过,这个“过早”指的是多早呢?很多人习惯用这句话为自己不考虑程序性能的行为开脱,他们又有没有道理呢?其实,读懂这句话的上下文,这些问题就迎刃而解了。

首先,不加思考,一上来就优化肯定是不对的,这是这句话隐含的第一层意思。开发者应该更多地关注程序中的关键部分,忽略掉不关键的部分。有些开发者过于追求完美,希望考虑到所有的细节。但是,最快的代码是从未运行过的代码。你几乎总是能够使程序变得更快,优化通常是一种收益递减的游戏。

性能优化是一种手段,最后需要为目标服务。资源可能是多方面的,例如时间、延迟、硬件、CPU、人力、内存等等……很多时候一种资源比另一种资源更稀缺,因此可能牺牲其他资源来优化某一种资源。不得不说,漫无目的的优化会无端消耗精力,开发者需要直面遇到的最严重的性能瓶颈问题,并尽可能想办法解决。

二八定律在性能优化中仍然适用。20%的代码消耗了程序80%的时间。如果你将只占用 5% 运行时间的代码速度提高一倍,那么总程序的速度只会提高 2.5%。但是,将占用程序80%时间的代码,仅加速 10% 就将为程序带来8%的提速,成本与收益是显而易见的。

图片

但是,这句话还隐含了第二层意思,那就是你迟早要对关键代码做优化。

实际上,大部分的架构与代码,都是逐渐迭代的过程。很多代码现在看起来很糟糕,但当时也许是成本最低的一种实现方式。然而,随着负载、数据和程序版本的变化,系统可能出现各种超出预期的性能瓶颈,例如响应时间上升、内存泄露、CPU飙升甚至是程序卡死等等。同时,有时候企业会面临降低成本的压力。新的变化总会导致新的问题,所以有必要进行优化。这节课,我会重点讨论怎么在有限的CPU和内存资源下,通过对服务的设计和调优增加服务QPS,降低服务的响应延迟。其他资源都可以采取类似的范式进行分析。

“过早的优化是万恶之源”隐含的第三层意思是,在设计阶段即进行优化仍然是有意义的。

实际上,一个完全不考虑设计的系统,最终带来的就是难以维护的“屎山”,它会逐渐变得又慢又臭。就像《人月神话》中描述的焦油坑,所有进入其中的动物都会慢慢下陷,直到被埋葬。相信你在过往经历中也有所体会。所以,我们要在设计阶段就全面考虑如何规避性能问题。

图片

性能设计分层抽象

怎么做呢?性能问题复杂多样,只有拥有了具体的方法论支撑,才能将问题分离出来,在面对性能问题时有的放矢。我参考《Efficient Go》这本书将性能优化自上而下划分为了5个级别:

图片

这个性能优化的分层抽象可以帮我们把复杂的性能问题拆解开来,然后逐个击破。我们先来看看第一层级的性能挑战:系统级别。

系统级别

我们都知道,系统架构经历了从单体应用到分布式应用的发展进程。随着数据规模日渐庞大,单台机器难以承受所有流量。因此,现代大型系统普遍采用了分布式、微服务的系统架构。借助灵活的程序伸缩快速适应动态的外部变化。

图片

随着功能越来越复杂,服务变得越来越多,如何将大规模服务有机统一起来变成了一个新的难题,这也催生了架构师这样的职业。在系统级别进行思考意味着我们要以架构师的视角构建“概念完整性”的系统,从全局角度思考程序的系统问题。如果能把大方向把控好,局部的服务也很难差到哪里去。大方向如果把控不好,那就是覆巢之下无完卵也。

系统级别优化与架构设计的过程息息相关,其考虑的方面在于“如何对服务进行拆分”、“如何将服务链接在一起”、“服务调用的关系以及调用频率”等。

更具体地来说,如何让服务随着负载的增加具有可扩展性?是否采用DDD的架构设计?如何进行分布式的协调?选择何种中间件、缓存数据库与存储数据库?使用何种通信方式?

这些设计决策都深深影响了服务的性能。举两个小的例子,我们经常会用缓存的设计来解决存储系统的I/O瓶颈问题,当缓存系统中(例如Redis)查不到数据时,才会直接访问数据库。那么我们如何设计缓存与数据库的关系,才能避免缓存失效之后大量数据直接打到数据库导致的服务响应变慢甚至服务雪崩的问题呢?

又如,分布式系统中数据的一致性,如果业务能够接受读取到的数据不是最新写入的数据,那么就一定能设计出比强一致性读取响应延迟更低的系统。

最后,大型微服务集群的性能优化还包括了服务的治理,它涉及到服务的监控与告警、服务的降级策略(限流、重试、降级、熔断),涉及到分布式追踪与分布式日志收集等手段,这些策略在大型企业常常是基础设施的一部分。

系统级别优化涉及到的内容很多,我只能把核心问题列出来,给你一个深入下去的框架。在后面的课程中我还会详细介绍微服务与分布式系统的演进、挑战与解决方案。

如果你想进一步了解如何更好地设计一个分布式系统,尤其与“数据”频繁打交道的时候,推荐你去读一读这本经典著作:《Designing Data-Intensive Applications》。

程序设计和组织级别

复杂的系统就像是一个小社会,如果说系统层面的设计决定了制度安排、服务的分工、服务的关系。那么让这种分工发挥最大价值的,就是具体服务(程序)的性能表现。

程序内好的设计是构建高性能、可维护程序的基础。程序设计包括了如何完成功能的拆分、流程的抽象、使用何种形式组织代码、定义清晰的模块间的接口边界、使用何种框架、并发处理模型;甚至包括搭建的开发流程和规范,重点指标体系的设计和监控。

好的程序设计,能够比较轻松地进行扩展和后续的优化,也能够总体上提升程序的性能。

要让程序具有高性能,你需要围绕着实现的性能目标,选择最佳的高性能方案。这里我以充分利用系统CPU资源,提高系统的QPS,减少服务延迟为例,来说明高性能程序设计的几个原则。

第一是流程异步化。为了外部用户的体验,降低延迟,有时我们可以结合业务对流程进行异步化,快速返回结果给外部用户。这可以提高用户体验、服务的QPS与吞吐量。

例如,任务执行完毕后需要将一些数据存入缓存中。这时可以直接返回结果,并异步地写入数据库。又如,调用一个执行周期很长的函数,可以先直接返回,然后在执行完毕后请求用户给的回调地址。不过要注意的是,无论怎样异步化,终究是需要执行任务的。

第二是在执行的关键阶段请求并行化,尽可能把串行改为并行。你可能听说过华罗庚烧水泡茶的故事,这个故事的要点,就是将整个大任务分割为小任务,让关键任务并行进行处理,这个方案可以大大减少整个任务的处理时间。

例如,三个任务分别耗时 T1、T2、T3,如果串行调用,总耗时为 T=T1+T2+T3。但是如果三个任务并行执行,总耗时就是max(T1,T 2,T3)。在程序设计中也遵循类似的思路。只有做到真正的并行,利用Go语言运行时对协程的自动调度,才能充分发挥多核CPU的性能。

图片

第三是要合理选择与实际系统匹配的并发模型,根据自身服务的不同,需要了解Go语言在网络I/O、磁盘I/O,CPU密集型系统在程序处理过程中的不同处理模型。并根据不同的场景选择不同的高并发模型。 关于这一点的详细论述,你可以参考第7讲

最后一点是要考虑无锁化与缓存化,保证并发的威力。试想一个极端的不合理的锁设计,它可能会让所有的用户协程等待某一个协程执行完成,导致并行处理退化为串行执行。无锁化并不是完全不加锁,而是要合理设计并发控制。例如设计无锁的结构,在多读少写场景用读锁替代写锁,用局部缓存来减少对于全局结构的访问(关于如何设计无锁化结构,你可以参考sync.pool库、go内存分配、go调度器等模块在并行处理中的极致优化,我在《Go底层原理剖析》这本书里也有过详细的解读)。

充分考虑完上面四点之后,怎么用工具和指标来验证程序实际并行的效率呢?

我们知道,如果瞬时协程的数量大于GOMAXPROCS,也就是当前线程数量,CPU才有可能被充分压榨。因此协程的瞬时数量是一个重要的观测指标,它反映了当前程序的并行处理状况。

获取协程数量的方式有多种:

  • 第一种方式是借助Debug库中的NumGoroutine函数,GOMAXPROCS还可以获取逻辑处理器P;
  • 第二种方式是使用runtime/metrics包,获取运行时metric,进而获取到协程数量;
  • 第三种方式是通过pprof获取当前的协程数量。

这种瞬时的协程数,可以通过metric采样的方式采集到监控平台,从而变得有时序性,更有监控意义。在这里要注意的是,协程数量并不是一个准确的东西,因为有一些协程(比如初始化时候的定时任务)并不需要长时间CPU运行。再比如,两个协程由于锁的原因并不能够同时运行。因此,除了观察协程的数量,还需要分析整个调度器的运行状态,有这样两种思路:

  • 第一是启动GODEBUG特定环境变量方式,查看调度器日志;
  • 第二是通过pprof和trace工具,可视化分析调度器的运行状态。

当cpu idle 随着负载的增加仍然维持在高位,同时请求的p99耗时增加,这种情况很可能是由于并发不够导致的。第一种思路是在启动时加入启动参数scheddetail=1,并将schedtrace指定为1000毫秒,意思是1秒打印一次调度器瞬时的运行情况。启动命令如下所示:

GODEBUG=schedtrace=1000,scheddetail=1 ./main

调度器打印的日志信息如下图所示。调度器可以打印出GMP之间的对应关系还有局部运行队列与全局运行队列的个数。如果当前M都绑定了G,那么curg对应的是G的协程id。如果当前所有的M都有对应的G运行,那么表明当前线程都已充分运行。关于调度器打印信息的详细说明,可参考这篇文章

图片

分析调度器运行状态的第二个思路,就是使用pprof和trace工具。pprof和trace工具是分析和排查Go性能问题的强悍工具。它可以可视化分析某一时刻程序的快照,还可以分析一段时间内程序线程、协程、逻辑处理器的运行状况。在后面的课程中,我还会详细地介绍pprof和trace的案例、原理和最佳实践。

总结

这节课就讲到这里。

性能优化重要且非常复杂,它考验的是开发者的内功。 对于刚刚入门的开发者来说,性能优化是个知识大杂烩,摸不着头脑。 然而,通过对知识的分层抽象与梳理,可以让你有的放矢,将问题聚焦于特定的层面。

程序面临的任何性能优化问题都可以对应到我们这节课讲的五层抽象模型中。对于性能优化,我们需要聚焦于核心的瓶颈问题,自上而下逐个击破,找到对应的设计思路、观察指标、排查手段和解决方法。这将帮助你更早地规避性能问题、更快地定位性能问题、更有效地解决性能问题。

图片

下节课,我们会从上层设计衍生到下层分析高性能程序的底层基石:代码实施、操作系统与硬件。最后如果你想深入的了解性能问题的术语、背景知识和方法论,我推荐你阅读《Systems Performance, 2nd Edition》。

课后题

学完这节课,我也给你留两道思考题吧。

  1. 对一个爬虫服务,通常一个网站之中又会有若干需要进一步爬取的网站,就像一棵树一样。如果放在一个协程中处理,将会非常慢。那么你会考虑怎样的程序设计来保证爬虫的高性能?
  2. 假如当前服务响应时间P99太高,导致了QPS无法增加,你觉得可以用什么指标和工具来定位和解决问题?

欢迎你在留言区留下自己思考的结果,也可以把这节课分享给对这个话题感兴趣的同事和朋友,我们下节课再见!

精选留言(7)
  • 8.13.3.27.30 👍(1) 💬(1)

    第三是要合理选择与实际系统匹配的并发模型 如何合理选择? 我理解我选择了GO,它得并发模型就定了。还可以使用GO的其他并发模型吗

    2022-11-10

  • 8.13.3.27.30 👍(1) 💬(1)

    如何让服务随着负载的增加具有可扩展性?是否采用 DDD 的架构设计?如何进行分布式的协调?选择何种中间件、缓存数据库与存储数据库?使用何种通信方式? 能否具体描叙一下到底如何?

    2022-11-10

  • czy 👍(1) 💬(1)

    第几章进入代码环节呀?

    2022-10-30

  • Realm 👍(11) 💬(0)

    1 下载器和解析器解耦分离,通过chan联系起来,各司其职,不够就增加worker; 2 从《linux性能优化》专栏学习到,影响系统性能的有: 第一种资源瓶颈,如CPU、内存、磁盘和文件系统 I/O、网络以及内核资源等各类软硬件资源出现了瓶颈,从而导致应用程序的运行受限。对于这种情况,我们就可以用前面系统资源瓶颈模块提到的各种方法来分析。 第二种依赖服务的瓶颈,也就是诸如数据库、分布式缓存、中间件等应用程序,直接或者间接调用的服务出现了性能问题,从而导致应用程序的响应变慢,或者错误率升高。这说白了就是跨应用的性能问题,使用全链路跟踪系统,就可以帮你快速定位这类问题的根源。 最后一种,应用程序自身的性能问题,包括了多线程处理不当、死锁、业务算法的复杂度过高等等,用火焰图辅助分析.

    2022-10-27

  • G55 👍(8) 💬(0)

    第一个问题。所有要爬取的网页连接可以看做是一个DAG图。 可以采用BFS遍历的方式来实现爬取。维护一个待爬取url的channel, 每次从一个网页上获取到下一级的url就加入到这个channel中。 同时, channel的另一侧读取channel, 待爬取url channel 不为空时就读取url并启动一个新的协程去爬取对应url 并解析返回内容。

    2022-10-27

  • 牙小木 👍(0) 💬(0)

    从上一个网络模型到了性能这里,有点突兀,^_^

    2023-08-14

  • 文经 👍(0) 💬(0)

    这一讲有点抽象,后面遇到实际的例子再回头看看。

    2022-11-26