从数据库连接池说起----池的大小

本文翻译自https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing

配置连接池是开发人员经常犯的错误。在配置池时,需要理解一些原则,这些原则可能与某些人的直觉相悖。

有10,000并发访问

​ 假设你有一个网站,可能没有 Facebook 的规模,但经常有10,000个用户同时发出数据库请求——TPS大约有20,000。那么数据库连接池应该设多大?你可能会感到惊讶,其实问题不是设多大,而是应该设多小

观看下面这段来自Oracle Real-World Performance group的的演示短视频(11分钟),会令人打开眼界

以下涉及剧透,如果你没有看视频,听话!!先看完视频。

可以从视频中看到,在没有任何其他变化的情况下,仅降低了连接池的大小, 程序的响应时间从〜100ms减少到〜2ms,超过50倍。

这是为啥嘞

​ 我们可能在学习其他技术时了解了“少即是多”的道理。为什么只有4个线程的nginx web服务器的性能远远超过有100个进程的Apache web服务器?如果你回想一下计算机基本原理,它就很明显了。

​ 即使是只有一个CPU核心的计算机也可以“同时”支持数十或数百个线程。但我们都应该知道,这只是操作系统利用时间片的魔力耍的一个把戏。实际上,这个单核一次只能执行一个线程,然后操作系统切换上下文,内核执行另一个线程的代码。在计算机领域有一条基本定律,即在单个 CPU 资源上,顺序执行A和B总是比通过时间分片“同时”执行A和B快。一旦线程数超过CPU核数就会因添加更多线程而变慢,而不是变快。

​ 这基本就是事情的真相……

有限的资源

​ 事情并不像上面说的那么简单,但也差不多。还有其他一些因素在起作用。数据库的主要瓶颈可以分为三类:CPU、磁盘和网络。内存其实也算,但与磁盘和网络相比,带宽有几个数量级的差异。

​ 如果我们忽略磁盘和网络,那就很简单。在具有8个计算核心的服务器上,将连接数设置为8,服务器将提供最佳性能,如果超出这个范围,就会因为上下文切换的开销而开始变慢。但是我们不能忽略磁盘和网络。数据库通常将数据存储在磁盘上,硬盘一般是由高速转动的盘片以及放在执行器悬臂上的读/写磁头组成。磁头同一时间只能出现在一个地方(单个查询的读/写数据),要读写新的数据必须“寻找”到新的位置。因此,这里有一个寻道的时间成本,还有旋转延迟,磁盘必须等到数据“再次出现”才能读写。当然,这里可以用缓存,但该原则仍然适用。

磁盘读写过程:首先读写头沿径向移动,移到要读取的扇区所在磁道的上方,这段时间称为寻道时间(seek time)。

然后,通过盘片的旋转,使得要读取的扇区转到读写头的下方,这段时间称为旋转延迟时间(rotational latency time)。

​ 在这段时间内(“I/O等待”),连接/查询/线程都被“阻塞”等待磁盘。在此期间,操作系统可以执行另一个线程的代码来更好地利用CPU资源。因此,由于线程在I/O上被阻塞,我们实际上可以通过设置比物理核数更多的连接/线程数,来完成更多的工作。

​ 更多是多多少呢?一起看下。多多少的问题也取决于磁盘子系统,因为更新的SSD(固态硬盘)不需要处理“寻道时间”成本或旋转因素。不要误以为“SSD更快,因此我可以设置更多的线程”。恰恰相反,更快,没有寻道,没有旋转延迟意味着更少的阻塞,因此更少的线程(接近CPU核数)将比更多的线程执行得更好。只有当线程阻塞为其他线程执行创造了机会时,更多的线程才能执行得更好。

​ 网络类似于磁盘。通过以太网接口发送数据,会在发送/接收缓冲区填满后暂停,发生阻塞。但在资源阻塞方面,网络是第三位,有些人经常计算时间时忽略它。

TCP协议需要保证数据可靠地、有序地传输,并且端与端之间有流量控制。TCP有个发送缓冲区,会先把数据先拷贝到 发送缓冲区,然后TCP 自行控制发送的时间和逻辑,有可能还有重传什么的。

下面是另一个打破文字墙的图表。

img

可以从上面的PostgreSQL基准测试中看到,TPS速率在50个连接左右开始趋于平缓。在上面甲骨文的视频中,他们展示了连接数从2048下降到只有96。我们会说即使96也可能太高了,即使是16或32核。

公式

​ 下面的公式是由PostgreSQL项目提供的,作为一个起点,但我们相信它将在很大程度上适用于所有数据库。你应该测试你的应用程序,即模拟预期的负载,并在这个起点附近尝试不同的池设置

connections = ((core_count * 2) + effective_spindle_count)
连接数 = 核数 * 2 + 有效轴数(一个机械硬盘是一个轴)
((core_count * 2) + effective_spindle_count)
这个公式多年来在许多基准测试中表现都很好,连接的数量接近这个值时可以获得最佳吞吐量。
即使启用了超线程,core_count也不应该包括超线程。
如果数据集完全走缓存,effective_spindle_count为零,并且随着缓存命中率的下降而接近实际的主轴数. ...
到目前为止,还没有使用SSD时对这个公式的分析(我理解是SSD不适用于这个公式)。

​ 猜猜这意味着什么?你的小型4核i7带有一个硬盘服务器的连接池应该是:9 =((4 * 2)+ 1)。似乎比较低?试一试,我们敢打赌,在这样的设置下,您可以轻松处理3000个前端用户以6000 TPS的速度运行简单查询。如果运行负载测试,将连接池的值大大超过10,可能会看到TPS速率开始下降,前端响应时间开始攀升。

原则:你只需要一个小连接池,大量线程应该处于等待连接状态。

​ 如果你有10,000个并发访问,那么连接池大小10,000个将是疯狂的,1000仍然很可怕,即使是100个连接也多了。你需要最多只有几十个连接的小池,并且希望其余的线程阻塞在池中等待连接。如果对池进行了适当的调优,则将其设置为数据库能够同时处理的查询数量的最大值——如上文所述,很少会超过(CPU核* 2)。

​ 我们可能经常看到一些内部使用的Web应用,大约有几十的并发,连接池却有有100个连接。不要过度配置你的数据库连接池的大小。


“Pool-locking”

​ 当一个线程需要持有多个连接时,出现池锁(pool locking)的概率就会增加。这在很大程度上是应用程序级别的问题,虽然增加池的大小可以缓解这些场景中的锁定,但是建议在扩大池之前先检查一下在应用程序级别可以做些什么。

​ 为了避免死锁,下面是计算池大小的一个相当简单的公式:

poolsize=Tn(Cm1)+1pool size = T_n * (C_m - 1) + 1

​ 其中Tn是最大线程数,Cm是单个线程同时保持的最大连接数。

​ 例如,假设有三个线程(Tn=3),每个线程都需要四个连接来执行某个任务(Cm=4)。确保永远不会发生死锁所需的池大小为:

pool size = 3 x (4 - 1) + 1 = 10

另一个例子,你有最多八个线程(Tn=8),每个线程需要三个连接来执行一些任务(Cm=3)。确保永远不会发生死锁所需的池大小为

pool size = 8 x (3 - 1) + 1 = 17

👉这只是避免死锁所需的最小池大小,并不一定是最佳池大小,

👉在某些环境中,使用JTA (Java事务管理器)可以通过将相同的连接从getConnection()返回到当前事务中已经持有连接的线程,从而极大地减少所需的连接数量。


敬告读者(Caveat Lector)

池大小最终是取决于特定业务场景及部署环境的。

混合了长时间运行的事务和非常短的事务的系统通常最难以用任何连接池进行调优。在这种情况下,正确的做法应该是创建两个连接池,一个用于长事务,一个用于"实时"查询,也就是短事务。

在主要是长事务的系统中,通常会有一个外部约束来限制连接数量,比如一个任务执行队列一般会配置同时运行的任务数量。这个时候,我们就应该让并发任务数去适配连接池大小,而不是池大小去适配并发任务数。