原文来自 Java 最佳数据库连接池 HikariCP,点此查看
开发人员经常在配置连接池上犯错。在配置连接池池时,需要理解几个有点反直觉的原则。
10,000个同时在线的前端用户
想象一下,你有一个网站,虽然没到 Facebook 的规模,但仍然经常有 10,000 个用户同时发出数据库请求 -- 每秒约 20,000 个事务。你的连接池应该有多大?你可能会惊讶地发现,问题不在于有多大,而在于有多小!
观看这段来自 Oracle 真实性能组的简短视频,进行令人大开眼界的演示(约10分钟):
{剧透警报} 如果你没有看视频的话。哦,天! 看完后再来这里。
你可以从视频中看到,在没有任何其他变化的情况下,仅减少连接池的大小,就将应用程序的响应时间从 ~100ms 减少到 ~2ms -- 超过50倍的提升。
那是为什么呢?
最近,在计算机的其他领域,我们似乎已经明白了“少即是多”的道理。为什么只有 4 个线程的 nginx web 服务比拥有 100 个进程的 Apache web 服务变现更好?如果你回想一下计算机科学 101,这不是很明显吗?
即使是只有一个CPU核心的计算机也可以 "同时 "支持几十个或几百个线程。但我们都[应该]知道,这只是操作系统通过时间切分的魔法玩的一个把戏。实际上,这个单一的内核一次只能执行一个线程;然后操作系统切换上下文,这个内核执行另一个线程的代码,以此类推。这是一个基本的计算定律,即给定一个CPU资源,按顺序执行A和B总是比通过时间切分 "同时 "执行A和B要快。一旦线程的数量超过了CPU核心的数量,你就会因为增加线程而变慢,而不是变快。
这几乎是真的...
受限的资源
这并不像上面所说的那么简单,但也很接近。还有一些其他因素在起作用。当我们看一下数据库的主要瓶颈是什么时,它们可以被总结为三个基本类别:CPU、硬盘、网络。我们可以把内存加进去,但是与硬盘和网络相比,在带宽上有几个数量级的差别。
如果我们忽略了硬盘和网络,那就很简单了。在一个有 8 个计算核心的服务器上,将连接数设置为 8 将提供最佳性能,而任何超过这个数量的连接将由于上下文切换的开销而开始变慢。但我们不能忽视硬盘和网络。数据库通常在硬盘上存储数据,传统上,磁盘是由旋转的金属板组成的,其上有安装在步进电机驱动臂上的读/写头。读/写头在同一时间只能在一个地方(为单个查询读/写数据),必须 “寻找” 到一个新的位置,为不同的查询读/写数据。因此,有一个寻找时间成本,也有一个旋转时间成本,即磁盘必须等待数据在盘面上 "再次出现 "以进行读/写。当然,缓存在这里有帮助,但原理仍然适用。
在这段时间内("I/O等待"),连接/查询/线程只是 “阻塞” 了一下,等待磁盘。而正是在这段时间里,操作系统可以通过为另一个线程执行一些更多的代码来更好地利用CPU资源。因此,由于线程在I/O时被阻塞,我们实际上可以通过拥有比物理计算核心数量更多的连接/线程来完成更多的工作。
多多少呢?我们马上会看到。多多少的问题也取决于硬盘系统,因为较新的固态硬盘没有 “寻找时间” 成本或旋转因素需要处理。不要被骗了,以为 “SSD更快,因此我可以有更多的线程”。这完全是 180 度的倒退。更快,没有搜索,没有旋转延迟意味着更少的阻塞,因此更少的线程[更接近于核心数]将比更多的线程表现得更好。更多的线程只有在阻塞为执行创造机会时才会表现得更好。
网络与硬盘类似。通过以太网接口,在网线上写入数据,当发送/接收缓冲区填满并停滞时,也会阻塞。一个万兆的接口会比千兆的以太网停滞得少,而千兆的以太网会比百兆的停滞得少。但在资源阻塞方面,网络是第三位的选手,有些人经常在计算中省略它。
这里有另一张图表来打破文字墙。
在上面的 PostgreSQL 基准测试中,你可以看到 TPS 在 50 个连接左右就开始趋于平稳。在 Oracle 的视频中,他们展示了将连接数从 2048 个下降到 96 个。我们会说,即使是 96 个也可能太高了,除非你是在一个 16 或 32 核的处理器上。
公式
下面是由 PostgreSQL 项目提供的一个作为起点的公式,但我们相信它将在很大程度上适用于所有数据库。你应该测试你的应用程序,即模拟预期的负载,并围绕这个起点尝试不同的池设置。
connections = ((core_count * 2) + effective_spindle_count)
多年来,一个在许多基准测试中都保持得很好的公式是为了达到最佳的吞吐量,活动连接数应该是在 ((core_count * 2) + effective_spindle_count) 附近。核心数不应该包括超线程,即使超线程被启用。如果活动数据集被完全缓存,则 effective_spindle_count 为零,并在缓存命中率下降时接近实际的主轴数。到目前为止,还没有任何分析表明该公式在固态硬盘上的效果如何。
猜猜这意味着什么?你的小 4 核 i7 服务器在仅有一个硬盘时,应该这样配置连接数:9 = ((4 * 2) + 1). 我们选择一个漂亮的整数 10。看起来很低?试试吧,我敢打赌,在这样的设置下,你可以轻松地处理 3000 个前端用户在 6000 TPS 下运行简单的查询。如果你运行负载测试,你可能会看到 TPS 开始下降,前端响应时间开始攀升,当你把连接数设置到 10 以上(在给定的硬件上)。
公理:你需要一个小的连接池,里面充满了等待连接的线程。
如果你有 10,000 个前端用户,拥有 10,000 个线程的连接池将是极端疯狂的行为。1000 个也很可怕。甚至 100 个线程,也是矫枉过正。你需要一个最多包含几十个连接的小池,并且池中的其余应用程序线程在阻塞的等待连接。如果池子调整得当,它就会被设置在数据库能够同时处理的查询数量的极限上 -- 如上所述,这很少超过(CPU核心*2)。
我们从未停止惊讶于我们所遇到的内部 web 应用程序,几十个前端用户执行周期性活动,而连接池有 100 个连接。不要过度预备线程。
"Pool-locking"(池锁)
对于获得许多连接的单个参与者来说,“池锁定”的前景已经被提出。这在很大程度上是一个应用程序级别的问题。是的,在这些场景中,增加池的大小可以缓解锁定,但我们建议您在扩大池之前,首先检查在应用程序级别可以做什么。
为了避免死锁,池大小的计算有一个相当简单的资源分配公式:
pool size = Tn x (Cm - 1) + 1
其中 Tn 为最大线程数,Cm 为单个线程同时持有的最大连接数。
例如,假设有3个线程(Tn=3),每个线程需要4个连接来执行某些任务(Cm=4)。确保死锁永远不会发生所需的池大小是:
pool size = 3 x (4 - 1) + 1 = 10
另一个例子是,您最多有8个线程(Tn=8),每个线程需要3个连接来执行某些任务(Cm=3)。确保死锁永远不会发生所需的池大小是:
pool size = 8 x (3 - 1) + 1 = 17
👉 这不一定是最优的池大小,而是避免死锁所需的最小值。
👉 在某些环境中,使用JTA (Java事务管理器)可以通过从getConnection()返回相同的连接到当前事务中已经持有一个连接的线程,从而显著减少所需的连接数量。
注意事项
池的大小最终取决于部署。
例如,混合了长时间运行的事务和非常短的事务的系统通常在任何连接池中都最难调优。在这些情况下,创建两个池实例可以很好地工作(例如。一个用于长时间运行的作业,另一个用于“实时”查询)。
在主要使用长时间运行的事务的系统中,通常会有一个“外部”约束来限制所需的连接数量——比如一个作业执行队列,它只允许一次运行一定数量的作业。在这些情况下,作业队列大小应该“合适”以匹配池(而不是相反)。