本文将从背景、原理、架构、实现、参数状态等方面详细介绍percona-线程池。此外,还将简单介绍腾讯云企业级MySQL(CDB)内核技术--TXSQL,关于线程池的动态启停、负载均衡以及快速断连等优化。
社区版的MySQL的连接处理方法默认是为每个连接创建一个工作线程的one-thread-per-connection(Per_thread)模式。这种模式下,由于系统的资源是有限的,随着连接数的增加,资源的竞争也增加,连接的响应时间也随之增加,如response time图所示。
对于数据库整体吞吐而言,则是在资源未耗尽时随着连接数增加,一旦连接数超过了某个耗尽系统资源的临界点,数据库整体吞吐就会随着各连接的资源争抢而下降,如下图所示。
如何避免在连接数暴增时,因资源竞争而导致系统吞吐下降的问题呢?MariaDB&&Percona中给出了简洁的答案:线程池。线程池的原理在博客中(链接参考文献1)有生动的介绍,其大致可类比为早高峰期间大量汽车想通过一座大桥,如果采用one-thread-per-connection的方式则放任汽车自由行驶,由于桥面宽度有限,最终将导致所有汽车寸步难行。线程池的解决方案是限制同时行驶的汽车数,让桥面时刻保持最大吞吐,尽快让所有汽车抵达对岸。回归到数据库本身,线程池的思路即为限制同时运行的线程数,减少线程池间上下文切换和热锁争用,从而对OLTP工作负载(CPU消耗较少的查询)产生积极影响。当连接数上升时,在线程池的帮助下数据库整体吞吐维持在一个较高水准,如图所示。
线程池的基本原理为:预先创建一定数量的工作线程(worker线程)。在线程池监听线程(listener线程)从现有连接中监听到新请求时,从工作线程中分配一个线程来提供服务。工作线程在服务结束之后不销毁线程,而是保留在线程池中继续等待下一个请求来临。下面我们将从线程池架构、新连接的创建与分配、listener线程、worker线程、timer线程等几个方面来介绍percona线程池的实现。
2.1 线程池的架构
线程池由多个线程组(thread group)和timer线程组成,如下图所示。线程组的数量是线程池并发的上限,通常而言线程组的数量需要配置成数据库实例的CPU数量,从而充分利用CPU。线程池中还有一个服务于所有线程组的timer线程,负责周期性检查线程组是否处于阻塞状态。当检测到阻塞的线程组时,timer线程会通过唤醒或创建新的工作线程来让线程组恢复工作。
线程组内部由多个worker线程、0或1个listener线程、高低优先级事件队列(由网络事件event构成)、mutex、epollfd、统计信息等组成。如下图所示:
2.2 新连接的创建与分配
新连接接入时,线程池按照新连接的线程id取模线程组个数来确定新连接归属的线程组(thd→thread_id() % group_count)。这样的分配逻辑非常简洁,但由于没有充分考虑连接的负载情况,繁忙的连接可能会恰巧被分配到相同的线程组,从而导致负载不均衡的现象,这是percona线程池值得被优化的点。
选定新连接归属的线程组后,新连接申请被作为事件放入低优先级队列中,等待线程组中worker线程将高优先级事件队列处理完后,就会处理低优先级队列中的请求。
2.3 listener线程
listener线程是负责监听连接请求的线程,每个线程组都有一个listener线程。percona线程池的listener采用epoll实现。当epoll监听到请求事件时,listener会根据请求事件的类型来决定将其放入哪个优先级事件队列。将事件放入高优先级队列的条件如下,只需要满足其一即可:
被放入高优先级事件队列的事件可以优先被worker线程处理。只有当高优先级队列为空,并且当前线程组不繁忙的时候才处理低优先级队列中的事件。线程组繁忙(too_many_busy_threads)的判断条件是当前组内活跃工作线程数+组内处于等待状态的线程数大于线程组工作线程额定值(thread_pool_oversubscribe+1)。这样的设计可能带来的问题是在高优先级队列不为空或者线程组繁忙时低优先级队列中的事件迟迟得不到响应,这同样也是percona线程池值得被优化的一个点。listener线程将事件放入高低优先级队列后,如果线程组的活跃worker数量为0,则唤醒或创建新的worker线程来处理事件。
percona的线程池中listener线程和worker线程是可以互相切换的,详细的切换逻辑会在worker线程模块介绍。epoll监听到请求事件时,如果高低优先级事件队列都为空,意味着此时线程组非常空闲,大概率不存在活跃的worker线程。listener在此情况下会将除第一个事件外的所有事件按前述规则放入高低优先级事件队列,然后退出监听任务,亲自处理第一个事件。这样设计的好处在于当线程组非常空闲时,可以避免listener线程将事件放入队列,唤醒或创建worker线程来处理事件的开销,提高工作效率。
资料领取直通车:大厂面试题锦集+视频教程
Linux服务器学习网站:C/C++Linux服务器开发/后台架构师
2.4 worker线程
worker线程是线程池中真正干活的线程,正常情况下,每个线程组都会有一个活跃的worker线程。worker在理想状态下,可以高效运转并且快速处理完高低优先级队列中的事件。但是在实际场景中,worker经常会遭遇IO、锁等等待情况而难以高效完成任务,此时任凭worker线程等待将使得在队列中的事件迟迟得不到处理、甚至可能出现长时间没有listener线程监听新请求的情况。为此,每当worker遭遇IO、锁等等待情况,如果此时线程组中没有listener线程或者高低优先级事件队列非空,并且没有过多活跃worker,则会尝试唤醒或者创建一个worker。为了避免短时间内创建大量worker,带来系统吞吐波动,线程池创建worker线程时有一个控制单位时间创建worker线程上限的逻辑,线程组内连接数越多则创建下一个线程需要等待的时间越长。
当线程组活跃worker线程数量大于等于too_many_active_threads+1时,认为线程组的活跃worker数量过多。此时需要对worker数量进行适当收敛,首先判断当前线程组是否有listener线程,如果没有则将当前worker线程转化为listener线程。如果当前有listener线程,则在进入休眠前尝试通过epoll_wait获取一个尚未进入队列的事件,成功获取到后立刻处理该事件,否则进入休眠等待被唤醒,等待threadpool_idle_timeout时间后仍未被唤醒则销毁该worker线程。
worker线程与listener线程的切换如下图所示:
2.5 timer线程
timer线程每隔threadpool_stall_limit时间进行一次所有线程组的扫描(check_stall)。当线程组高低优先级队列中存在事件,并且自上次检查至今没有新的事件被worker消费则认为线程组处于停滞状态。停滞的主要原因可能是长时间执行的非阻塞请求, 也可能发生于线程正在等待但 wait_begin/wait_end (尝试唤醒或创建新的worker线程)被上层函数忘记调用的场景。timer线程会通过唤醒或创建新的worker线程来让停滞的线程组恢复工作。timer线程为了尽量减少对正常工作的线程组的影响,在check_stall时采用的是try_lock的方式,如果加不上锁则认为线程组运转良好,不再去打扰。
timer线程除上述工作外,还负责终止空闲时间超过 wait_timeout 秒的客户端。
线程池采用一定数量的工作线程来处理用户连接请求,通常比较适应于OLTP工作负载的场景。但线程池并不是万能的,线程池的不足在于当用户请求偏向于慢查询时,工作线程阻塞在高时延操作上,难以快速响应新的用户请求,导致系统吞吐量反而相较于Per_thread模式更低。
正如前文所说,Per_thread模式与Thread_pool模式各有优劣,系统需要根据用户的业务类型灵活切换两种模式。在业务高峰时段切换模式,重启服务器,会严重影响用户业务。为了解决此问题,TXSQL提出了线程池动态切换的优化,即在不重启数据库服务的情况下,动态开启或关闭线程池。
详细介绍及使用方法参考下述视频:
腾讯云CDB-动态线程池功能
3.1 动态线程池的实现介绍
在了解了TXSQL动态线程池的使用方法后,我们再来了解一下其具体的实现。
mysql的thread_handling参数代表了连接管理方法。在过去thread_handling是只读参数,不允许在线修改。thread_handling参数对应的底层实现对象是Connection_handler_manager,后者是是mysql提供连接管理服务的单例类,可对外提供Per_thread、No_threads、Thread_pool、Plugin_connection_handler等多种连接管理服务。由于thread_handling在过去是只读参数,在mysql启动时Connection_handler_manager只需要按照thread_handling初始化一种连接管理方法即可。为了支持动态线程池,允许用户连接从Per_thread和Thread_pool模式中来回切换,我们需要允许多种连接管理方法同时存在。因此,在mysql初始化阶段,我们初始化了所有连接管理方法。
在支持thread_handling在Per_thread和Thread_pool模式中来回切换后,我们需要考虑的问题主要有以下几个:
1) 活跃用户连接的thread_handling切换
Per_thread模式下,每个用户连接对应一个handle_connection线程,handle_connection线程既负责用户网络请求的监听,又负责处理请求的处理。Thread_pool模式下,每个thread_group都用epoll来管理其中所有用户连接的网络事件,监听到的事件放入事件队列中,交予worker处理。不论是哪种模式,在处理请求的过程中(do_command)切换都不是一个好选择,而在完成一次command之后,尚未接到下一次请求之前是一个较合适的切换点。
为实现用户连接从Per_thread到Thread_pool的切换,需要在请求处理完(do_command)之后判断thread_handling是否发生了变化。如需切换则立刻按照2.2中介绍的逻辑,通过thread_id%group_size选定目标thread_group,将当前用户连接迁移至Thread_pool的目标thread_group中,后续该用户连接的所有网络事件统一交予thread_group的epoll监听。在完成连接迁移之后,handle_connection线程即可完成退出或者缓存至下一次Per_thread模式处理新连接时复用(此为原生mysql支持的逻辑,目的是避免Per_thread模式下频繁地创建和销毁handle_connection线程)。
为实现用户连接从Thread_pool到Per_thread的切换,需要在请求处理完(threadpool_process_request)后,将用户线程网络句柄重新挂载到epoll(start_io)之前判断thread_handling是否发生了变化。如需切换则先将网络句柄从epoll中移除以及将连接的信息从对应thread_group中清除。由于Per_thread模式下每个连接对应一个handle_connection线程,还需为当前用户连接创建一个handle_connection线程,后续当前用户连接的网络监听和请求处理都交予该handle_connection线程处理。
2) 新连接的处理
由于thread_handling可能随时动态变化,为了使得新连接能被新thread_handling处理,需要在新连接处理接口Connection_handler_manager::process_new_connection中,读取最新的thread_handling,利用其相应的连接管理方法添加新连接。对于Per_thread模式,需要为新连接创建handle_connection线程;对于Thread_pool模式,则需要为新连接选定thread_group和将其网络句柄绑定到thread_group的epoll中。
3) thread_handling切换的快速生效
从上述1)的讨论中可以看到,处于连接状态的用户线程需要等到一个请求处理结束才会等到合适的切换点。如果该用户连接迟迟不发送网络请求,则连接会阻塞在do_command下的get_command的网络等待中,无法及时切换到Thread_pool。如何快速完成此类线程的切换呢?一种比较激进的方法就是迫使此类连接重连,在重连后作为新连接自然地切换到Thread_pool中,其下一个网络请求也将被Thread_pool应答。
如前文2.2所述,新连接按照线程id取模线程组个数来确定新连接归属的线程组(thd→thread_id() % group_count)。这样的分配方式未能将各线程组的实际负载考虑在内,因此可能将繁忙的连接分配到相同的线程组,使得线程池出现负载不均衡的现象。为了避免负载不均衡的发生,TXSQL提出了线程池负载均衡优化。
4.1 负载的度量
在提出负载均衡的算法之前,我们首先需要找到一种度量线程组负载状态的方法,通常我们称之为"信息策略“。下面我们分别讨论几种可能的信息策略。
1) queue_length
queue_length代表线程组中低优先级队列和高优先级队列的长度。此信息策略的最大优势在于简单,直接用在工作队列中尚未处理的event的数量描述当前线程组的工作负载情况。此信息策略的不足,无法将每个网络事件event的处理效率纳入考量。由于每个event的处理效率并不相同,简单地以工作队列长度作为度量标准会带来一些误判。
2) average_wait_usecs_in_queue
average_wait_usecs_in_queue表示最近n个event在队列中的平均等待时间。此信息策略的优势在于能够直观地反映线程组处理event的响应速度。某线程组average_wait_usecs_in_queue明显高于其他线程组说明其工作队列中的event无法及时被处理,需要其他线程组对其提供帮助。
3) group_efficiency
group_efficiency表示一定的时间周期内,线程组处理完的event总数占(工作队列存量event数+新增event数)的比例。此信息策略的优势在于能够直观反映出线程组一定时间周期内的工作效率,不足在于对于运转良好的线程组也可能存在误判:当时间周期选择不合适时,运转良好的线程组可能存在时而group_efficiency小于1,时而大于1的情况。
上述三种信息策略只是举例说明,还有更多信息策略可以被采用,就不再一一罗列。
4.2 负载均衡的实现介绍
在明确了度量线程组负载的方法之后,我们接下来讨论如何均衡负载。我们需要考虑的问题主要如下:
1) 负载均衡算法的触发条件
负载均衡操作会将用户连接从一个线程组迁移至另一个线程组,在非必要情况下触发用户连接的迁移将因反而导致用户连接的性能抖动。为尽可能避免负载均衡算法错误触发,我们需要为触发负载均衡算法设定一个负载阈值M,以及负载比例N。只有线程组的负载阈值大于M,并且其与参与均衡负载的线程组的负载比例大于N时,才需要启动负载均衡算法平衡负载。
2) 负载均衡的参数对象
当线程组触发了负载均衡算法后,该由哪些线程组参与平衡高负载线程组的负载呢?
很容易想到的一个方案是我们维护全局的线程组负载动态序列,让负载最轻的线程组负责分担负载。但是遗憾的是为了维护全局线程组负载动态序列,线程组每处理完一次任务都可能需要更新自身的状态,并在全局锁的保护下更新其在全局负载序列中的位置,如此一来对性能的影响势必较大,因此全局线程组负载动态序列的方案并不理想。
为了避免均衡负载对线程池整体性能的影响,需改全局负载比较为局部负载比较。一种可能的方法为当当前线程组的负载高于阈值M时,只比较其与左右相邻的X个(通常1-2个)线程组的负载差异,当当前线程组的负载与相邻线程组的比例也高于N倍时,从当前线程组向低负载线程组迁移用户连接。需要注意的是当当前线程组的负载与相邻线程组的比例不足N倍时,说明要么当前线程组还不够繁忙、要么其相邻线程组也较为忙碌,此时为了避免线程池整体表现恶化,不适合强行均衡负载。
3) 均衡负载的方法
讨论完负载均衡的触发条件及参与对象之后,接下来我们需要讨论高负载线程组向低负载线程组迁移负载的方法。总体而言,包括两种方法:新连接的优化分配、旧连接的合理转移。
在掌握了线程组的量化负载之后,较容易实现的均衡负载方法是在新连接分配线程组时特意避开高负载线程组,这样一来已经处于高负载状态的线程组便不会因新连接的加入进一步恶化。但仅仅如此还不够,如果高负载线程组的响应已经很迟钝,我们还需要主动将其中的旧连接迁移至合适的低负载线程组,具体迁移时机在3.1中已有述及,为在请求处理完(threadpool_process_request)后,将用户线程网络句柄重新挂载到epoll(start_io)之前,此处便不再展开讨论。
如前文2.3所述,线程池采用epoll来处理网络事件。当epoll监听到网络事件时,listener会将网络事件放入事件队列或自己处理,此时相应用户连接不会被epoll监听。percona线程池需要等到请求处理结束之后才会使用epoll重新监听用户连接的新网络事件。percona线程池这样的设计通常不会带来问题,因为用户连接在请求未被处理时,也不会有发送新请求的需求。但特殊情况下,如果用户连接在重新被epoll监听前自行退出了,此时用户连接发出的断连信号无法被epoll捕捉,因此在mysql服务器端无法及时退出该用户连接。这样带来的影响主要有两点:
为解决上述问题,TXSQL提出了线程池断连优化。
5.1 断连优化的实现介绍
断连优化的重点在于及时监听用户连接的断连事件并及时处理。为此需要作出的优化如下:
以下是对线程池相关参数的介绍:
下面对TXSQL新增的show threadpool status命令展示的相关状态进行说明: