来想象这样一个场景,一天,公司 CEO 把你叫到会议室,告诉你公司看到了一个新的商业机会,希望你能带领一位兄弟,迅速研发出一套面向某个垂直领域的电商系统。
在资源匮乏、时间紧迫的情况下,我迅速采用了一种极为简化的系统架构:一台Web服务器负责运行业务代码,而一台独立的数据库服务器则存储所有业务数据。这种简单的架构设计允许我们尽快启动项目并专注于核心功能的开发,以在有限的资源和时间内完成任务。
图片
当垂直电商系统开始吸引更多用户流量时,我们遇到了性能问题,主要是因为我们的数据库连接方式。在原始设计中,每次查询都需要建立和关闭数据库连接,这导致了性能下降。为了解决这个问题,我们需要优化数据库连接管理,考虑使用连接池技术来减少连接的频繁建立和关闭,以提高系统的响应速度和性能。这将是一个重要的性能优化步骤,以适应不断增长的用户流量。
那么为什么频繁创建连接会造成响应时间慢呢?来看一个实际的测试。
通过运行"tcpdump -i bond0 -nn -tttt port 4490"命令来捕获线上MySQL连接的网络包后,我们可以将MySQL连接过程简化为两个主要阶段:
前三个数据包描绘了MySQL连接的起始阶段。首先,客户端发送了一个带有SYN标志的数据包,表示要与服务器建立连接。然后,服务器回应客户端,确认收到连接请求,并且也发送一个带有SYN标志的数据包。最后,客户端再次回应服务器,确认连接已建立。这个过程就是TCP协议中的三次握手,用于确保客户端和服务器之间的连接正常建立。
第二部分是 MySQL 服务端校验客户端密码的过程。其中第一个包是服务端发给客户端要求认证的报文,第二和第三个包是客户端将加密后的密码发送给服务端的包,最后两个包是服务端回给客户端认证 OK 的报文。从图中,你可以看到整个连接过程大概消耗了 4ms(969012-964904)。
图片
根据我们的统计数据,单条SQL执行时间平均约为1毫秒,这意味着相对于SQL的执行来说,MySQL建立连接的过程所花费的时间较长。当请求量较小时,这并不会产生显著影响,因为不管是建立连接还是执行SQL,都在毫秒级别完成。然而,一旦请求量增加,如果每次都按照原始方式建立连接,然后只执行一条SQL,那么每秒只能执行大约200次数据库查询,其中有四分之三的时间都花在了建立连接上。
那这时你要怎么做呢?
在进行了一番谷歌搜索后,我找到了一个相对简单的解决方案:使用连接池来预先建立数据库连接。通过这个改进,我们不再需要在每次使用数据库时都频繁地创建连接。调整后,系统的性能显著提高,现在每秒可以执行1000次数据库查询,这是之前的5倍,从而更好地满足了高请求量的需求。这种连接池的优化方式有效地减少了连接建立的开销,提高了系统的响应速度和性能。
用连接池预先建立数据库连接
虽然短时间解决了问题,不过你还是想彻底搞明白解决问题的核心原理,于是又开始补课。
在开发过程中,我们经常使用连接池,比如数据库连接池、HTTP连接池和Redis连接池等。连接池的管理是连接池设计的核心,让我以数据库连接池为例,简要说明一下关键点。
数据库连接池有两个关键配置参数:最小连接数和最大连接数。这些参数控制着连接池如何管理连接的获取:
- 如果当前连接数小于最小连接数,连接池会创建新的连接来处理数据库请求。
- 如果连接池中有空闲连接,它将会被重用。
- 如果空闲连接池没有可用连接,且当前连接数小于最大连接数,连接池会创建新的连接来处理请求。
- 如果当前连接数已经达到最大连接数,连接池会根据设定的等待时间(比如C3P0连接池中的checkoutTimeout配置)等待已有连接变为可用。
- 如果等待超过了设定的时间,连接池将向用户抛出错误。
为了方便你理解记忆这个流程,我来举个例子。
假设你在机场里运营一家按摩椅的小店,店里总共有10台按摩椅(类似于数据库连接池的最大连接数)。为了控制成本(按摩椅费电),通常你会保持4台按摩椅开启(最小连接数),其余6台关闭。当顾客到来时,如果这4台按摩椅中有空位,你可以直接为顾客提供服务。但如果所有4台按摩椅都在使用中,那么你会开启一台新的按摩椅,直到所有10台都被占用。
当所有10台按摩椅都在使用中时,你会告知等待的顾客:“请稍等5分钟,我保证在这段时间内会有按摩椅空出来。”然后第11位顾客开始等待。这时有两种可能性:如果在5分钟内有按摩椅空出来,顾客可以立即使用;但如果等待了5分钟还没有按摩椅空出来,你需要道歉并建议顾客去其他地方尝试。
对于数据库连接池,根据我的经验,一般在线上我建议最小连接数控制在 10 左右,最大连接数控制在 20~30 左右即可。
用线程池预先创建线程
JDK 1.5引入的ThreadPoolExecutor是一种线程池的实现,它有两个关键参数:coreThreadCount和maxThreadCount。它的工作原理类似于之前描述的按摩椅店模式:
图片
这个任务处理流程看似简单,实际上有很多坑,你在使用的时候一定要注意。
JDK实现的ThreadPoolExecutor在处理任务时,更倾向于将任务暂存于队列中而不是过早地创建新线程。这种方式更适合执行CPU密集型任务,即需要大量的CPU计算任务。为什么呢?
这是因为在执行CPU密集型任务时,CPU非常繁忙,因此只需要创建与CPU核心数相近的线程即可。过多的线程反而会导致线程上下文切换,降低任务执行效率。所以,当当前线程数超过核心线程数时,线程池不会立即创建更多线程,而是将任务放入队列中等待核心线程的空闲。
但是,在我们通常的Web系统中,存在大量的IO操作,例如数据库查询、缓存查询等。当任务执行IO操作时,CPU空闲下来,此时如果增加执行任务的线程而不是将任务暂存在队列中,可以在单位时间内执行更多的任务,从而大大提高任务执行的吞吐量。这种情况下,Tomcat等Web服务器通常会对线程池进行定制,当线程数超过核心线程数时,它们会优先创建新线程,直到达到线程池的最大线程数,以更好地适应Web系统中的大量IO操作场景。你可以在实际应用中考虑类似的线程池调整来提高性能。
此外,要监控线程池中队列的积压情况也非常重要,特别是对于实时性要求较高的任务。我曾在实际项目中遇到过一个奇怪的问题:任务被提交到线程池后,长时间没有被执行。起初,我怀疑是代码中的bug,但经过排查发现,问题出在线程池的配置上,coreThreadCount和maxThreadCount设置得太小,导致任务在队列中积压。一旦我增大了这些参数,问题就得以解决。
从那以后,我将线程池队列的任务积压量视为系统监控的重要指标,将其显示在监控大屏上。最后,我要强调,如果使用线程池,请不要使用无界队列(即没有设置固定大小的队列)。有人可能认为无界队列可以确保任务永远不会丢失,只要任务对实时性要求不高,迟早会被执行完。然而,大量任务的堆积会占用大量内存空间。一旦内存空间用尽,将频繁触发Full GC,导致服务不可用。我曾经排查过一次因为Full GC引发的系统宕机,而它的根本原因就是系统中的一个线程池使用了无界队列。因此,理解线程池的关键要点后,我确保在系统中设置了合适的配置,保障了系统的稳定性,成功完成了公司交给我的研发任务。
回顾连接池和线程池,它们都有一个共同特点:它们管理的对象,无论是连接还是线程,都需要耗费相对较多的时间和系统资源来创建。因此,我们将这些对象放入一个池中进行统一管理,以提高性能和资源的重复利用。这是一种常见的软件设计思想,称为池化技术。其核心思想是通过提前创建对象来减少频繁创建对象的性能开销,同时实现对象的统一管理,从而降低对象使用的成本。总之,池化技术带来了许多好处。
然而,池化技术也存在一些缺点。例如,存储池中的对象会占用额外的内存,如果对象不经常使用,可能会导致内存浪费。此外,池中的对象需要在系统启动时预先创建,这可能增加系统启动时间。然而,这些缺点相对于池化技术的优势来说通常较小,只要我们明确需要频繁创建和销毁的对象在创建时确实具有高成本和资源消耗,并且这些对象确实会被频繁使用,那么使用池化技术来优化是值得的。