用最少的资源将程序的性能优势最大化,这不仅是每一个有追求的开发者的目标,更是企业为了达到更好的用户体验(例如更小的响应时间)、更低的成本面临的现实困境。
性能问题无处不在,从设计、开发阶段如何避免性能问题,再到如何发现问题,发现问题后如何分析、排查、调优,“性能”二字贯穿于系统的整个生命周期。
但是应该怎样攻破性能问题呢?性能问题涉及到的知识面广而且深刻,如果没有方法论的支撑,要解决它们无异于大海捞针。接下来的两节课,我会带你构建起一种分层的分析范式,并通过它对问题进行分层抽象,抽丝剥茧,将问题逐个击破。
怎么做呢?性能问题复杂多样,只有拥有了具体的方法论支撑,才能将问题分离出来,在面对性能问题时有的放矢。我参考《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 密集型系统在程序处理过程中的不同处理模型。并根据不同的场景选择不同的高并发模型。 关于这一点的详细论述,你可以参考go 进阶 · 分布式爬虫实战day04
最后一点是要考虑无锁化与缓存化,保证并发的威力。 试想一个极端的不合理的锁设计,它可能会让所有的用户协程等待某一个协程执行完成,导致并行处理退化为串行执行。无锁化并不是完全不加锁,而是要合理设计并发控制。例如设计无锁的结构,在多读少写场景用读锁替代写锁,用局部缓存来减少对于全局结构的访问(关于如何设计无锁化结构,你可以参考 sync.pool 库、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 的案例、原理和最佳实践。
这节课就讲到这里。