配置连接池是开发人员经常会遇到的事情。本文讲述配置连接池时需要了解的原则,其中几个可能违反直觉。
如何应对一万个前端并发访问
想象一下,如果你有这样一个网站,它虽没有达到Facebook的规模,但仍有每秒10000个用户同时发出数据库请求 - 每秒约有20000笔交易。你的连接池应该设为多大?可能会让感到惊讶的是,问题不是应该设为多大,而是多小!
首先请观看Oracle Real-World Performance小组发布的这段短视频。
从视频中可以看到,你可以看到只需减少连接池大小,在没有任何其他变化的情况下,应用程序的响应时间就从约100ms减少到约2ms - 约50倍的性能提升。
为什么?
在计算的其他领域我们似乎已经熟知“少即是多”。但为什么只有4线程的nginx的性能可以大大超过拥有100个进程的Apache服务器?如果回想一下计算机科学基础,原因不是很明显吗?
即使是只有一个CPU核心的计算机也可以“同时”支持数十或数百个线程。但是我们应该知道这只是操作系统的一个障眼法,背后只是时间切片的魔法。实际上,这个单核一次只能执行一个线程,操作系统通过切换上下文,让该内核再为另一个线程执行代码,依此类推,给人以多线程同时执行的感觉。密集计算的基本法则是:给定一个CPU资源,按顺序执行A和B总是比通过时间片“同时”执行A和B要快。一旦线程数量超过了CPU核心的数量,通过添加更多的线程就会变慢,而不是更快。
但万事总有例外......
有限的资源
现实的情况并不像上面所说的那么简单,还有其他一些因素需要考虑。让我们看看数据库的主要瓶颈有哪些,它们可以归纳为三个基本类别:CPU,磁盘,网络。内存也可以算作是瓶颈的一种,但你应该知道与磁盘和网络相比,它的性能有几个数量级的差异。
如果我们忽略磁盘和网络,问题就会很简单。在具有8个核心的服务器上,将连接数设置为8会提供最佳性能。由于上下文切换的开销,超出此范围的任何设置都会导致性能下降。
但我们不能忽视磁盘和网络。数据库通常将数据存储在磁盘上。而传统磁盘由金属旋转板组成,读写头安装在步进电机驱动臂上。读写头一次只能在一个地方读写,并且必须寻找新的磁道以读写不同的数据。所以即有寻道时间的成本,也有轮转时间成本。也就是说我们需要等待磁盘转到正确的位置,以便再次读取或写入数据。硬盘缓存虽然有帮助,但这个原则仍然适用。
在此期间(即IO等待期),连接或查询线程只能阻塞在那里等待磁盘。在这个时间段,操作系统可以通过为另一个线程执行代码来更好地使用CPU资源。所以,正是由于线程在IO上被阻塞,我们才可以通过创建大于CPU核心数的连接线程来完成更多的工作。
但应该创建多少线程呢?下面我们继续讨论。具体多少还取决于磁盘子系统,较新的SSD驱动器没有“寻道时间”成本。但不要盲目地以为,“SSD更快,因此我就应该创建更多线程”。事实恰恰相反在:更快的读写速度,无寻道成本,没有机械旋转的延迟,意味着更少的阻塞,因此更少的线程(即更接近内核数)会比更多的线程效果更好。
网络与磁盘类似。通过以太网接口读写数据时,也会在它接口的发送/接收缓冲区填满时引入阻塞。 万兆网卡优于千兆网卡,千兆网上优于百兆网卡。由于在资源阻塞方面,网络处于最后一位,有些人经常在计算中忽略它。
下图是另一份连接数与性能关系图表:
可以从上图的PostgreSQL基准测试表现里看出,从大约50个连接时,TPS开始变平。前面的Oracle视频展示了将连接数从2048降到96。我们会说即使是96也极有能可能太在,除非你用的是16或32核的CPU。
公式
下面的公式是从PostgreSQL文档里摘录的,但我们相信它也适用于大部分其它数据库。你应该以此为出发点,测试你的应用,模拟预期负载,并据此尝试不同的连接池设置:
连接数 =((CPU核心数 * 2)+ 有效的转轴数)
如何理解此公式呢?以带有一个硬盘的4核i7服务器为例,连接池大小就为9,即((4 * 2)+ 1)。取整数为10也行。看来是不是太小?试试看,我打赌用此设置,你可以轻松处理3000个前端用户在6000 TPS下运行简单查询。在测试中你可能会发现,将连接数从10逐渐增大时,TPS开始下降,并且前端响应时间开始攀升。
你需要一个尽量小的连接池,所有线程均处于等待连接状态。
如果你拥有10000个前端用户,那么将连接池大小设为10000就是疯狂;设为1000就是可怕,设为100仍然过大。你需要尽量小的连接池,最多几十,应用的其它线程阻塞应该在等待连接池的连接上。一个调整适当连接池,应设置在数据库能并发查询的极限处 - 这很少超过上面提到的(CPU内核* 2)。
在我的内部项目中,我总能发现一些令人惊讶的Web应用,它们通常只有几十个前端用户,执行周期性操作,却配有上百个连接的连接池。请不要过度配置你的数据库。
池死锁
对于同时获取许多连接的单个请求者,确实存在“池死锁”的问题。这在很大程度上是应用级别的问题。增加池大小的确可以缓解这种情况下的锁定问题,但你在增加连接池大小之前应首先检查在应用级别可以执行的操作。
为避免死锁,连接池大小可通过一个相当简单的公式估算:
连接池大小 = Tn×(Cm-1)+ 1
其中Tn
是线程的最大数量,Cm
是单个线程持有的最大并发连接数。
例如,有三个线程(Tn = 3
),每个线程需要4个连接来执行某个任务(Cm = 4
)。能确保无死锁的连接池大小最小为:3 x(4 - 1)+ 1 = 10
。
再例如,对于最多8个线程(Tn = 8
),每个线程需要3个连接来执行一些任务(Cm = 3
)。能确保无死锁的连接池大小最小为:8×(3-1)+ 1 = 17
。
上述大小不一定是连接池的最佳大小,但是它是避免死锁所需的最小值。
在某些环境中,使用JTA(Java事务管理器)可以显著减少从同一线程的
getConnection()
请求的连接数。
友好提示
连接线程池的大小的设置非常依赖于具体的部署场景。
例如,同时存在长时间和短时间的事务的系统通常最难调优。在这些情况下,创建两个连接池实例或许可以很好地工作(一个用于长时间运行的事务,另一个用于实时查询)。
在以长时间事务为主的系统中,所需连接数大小通常由外部因素限制 - 例如只允许一定数量的任务队列。在这些情况下,任务队列大小应该是与连接池大小匹配。