开源mongodb 代码规模数百万行 , 本篇文章内容主要 分析mongodb 网络传输模块 内部 实现 及其 性能调优方法 , 学习网络IO 处理流程,体验不同工作线程模型性能极致设计原理 。 另外一个目的就是引导大家快速进行百万级别规模源码阅读,做到不同大工程源码 ” 举一反三 ” 快速阅读的目的。
此外,mognodb 网络工作线程模型设计非常好,不仅非常值得数据库相关研发人员学习,中间件、分布式、高并发、服务端等相关研发人员也可以借鉴,极力推荐大家学习。
前滴滴出行技术专家,现任OPPO 文档数据库 mongodb 负责人,负责 oppo 千万级峰值 TPS/ 十万亿级数据量文档数据库 mongodb 研发和运维工作,一直专注于分布式缓存、高性能服务端、数据库、中间件等相关研发。后续持续分享《 MongoDB 内核源码设计、性能优化、最佳运维实践》, Github 账号地址 : https://github.com/y123456yz
详见我的上一篇分享:
mongodb源码实现、调优、最佳实践系列-数百万行mongodb内核源码阅读经验分享
从1.5 章节中,我们把 transport 功能模块细化拆分成了网络传输数据压缩子模块、服务入口子模块、线程模型子模块、状态机处理子模块、 session 会话信息子模块、数据分发子模块、套接字处理和传输管理子模块,总共七个子模块。
实际上mongodb 服务层代码的底层网络 IO 实现依赖 asio 库完成,因此 transport 功能模块应该是 7+1 个子模块构成,也就是服务层代码实现由 8 个子模块支持。
Asio 是一个优秀网络库,依赖于boost 库的部分实现,支持 linux 、 windos 、 unix 等多平台, mongodb 基于 asio 库来实现网络 IO 及定时器处理。 asio 库由于为了支持多平台,在代码实现中用了很多 C++ 的模板,同时用了很多 C++ 的新语法特性,因此整体代码可读性相比 mongodb 服务层代码差很多。
服务端网络IO 异步处理流程大体如下:
1. 调用 socket() 创建一个套接字,获取一个 socket 描述符。
2. 调用 bind () 绑定 套接字 ,同时通过 listen () 来监听客户端链接,注册该 socket 描述符到 epoll 事件集列表,等待 accept 对应的新连接读事件到来。
3. 通过epoll_wait 获取到 accept 对应的读事件信息,然后 调用accept () 来接受客户的连接 ,并获取一个新的链接描述符new_fd 。
4. 注册新的new_fd 到 epoll 事件集列表,当该 new_fd 描述符上有读事件到来,于是通过 epoll_wait 获取该事件,开始该 fd 上的数据读取。
5. 读取数据完毕后,开始内部处理,处理完后发送对应数据到客户端。如果一次write 数据到内核协议栈写太多,造成协议栈写满,则添加写事件到 epoll 事件列表。
服务端网络IO 同步方式处理流程和异步流程大同小异,少了 epoll 注册和 epoll 事件通知过程,直接同步调用 accept() 、 recv() 、 send() 进行 IO 处理。
同步IO 处理方式相对比较简单,下面仅分析和 mongodb 服务层 transport 模块结合比较紧密的 asio 异步 IO 实现原理。
Mongodb 服务层用到的 Asio 库功能中最重要的几个结构有 io_context 、 scheduler 、 epoll_reactor 。 Asio 把网络 IO 处理任务、状态机调度任务做为 2 种不同操作,分别由两个继承自 operation 的类结构管理,每种类型的操作也就是一个任务 task 。 io_context 、 scheduler 、 epoll_reactor 最重要的功能就是管理和调度这些 task 有序并且高效的运行。
io_context 上下文类是 mongodb 服务层和 asio 网络库交互的枢纽,是 mongodb 服务层和 asio 库进行 operation 任务交互的入口。该类负责 mongodb 相关任务的入队、出队,并与 scheduler 调度处理类配合实现各种任务的高效率运行。 Mongodb 服务层在实现的时候, accept 新连接任务使用 _acceptorIOContext 这个IO 上下文成员实现,数据分发及其相应回调处理由 _workerIOContext 上下文成员实现。
该类的几个核心接口功能如下表所示:
Io_context 类成员 / 函数名 |
功能 |
备注说明 |
impl_type& impl_; |
Mongodb 对应的 type 类型为 scheduler |
通过该成员来调用 scheduler 调度类的接口 |
io_context::run() |
负责 accept 对应异步回调处理 |
1.mongodb 中该接口只针对 accept 对应 IO 异步处理 2. 调用 scheduler::run() 进行 accept 异步读操作 |
io_context::stop() |
停止 IO 调度处理 |
调用 scheduler::stop() 接口 |
io_context::run_one_until() |
1. 从全局队列上获取一个任务执行 2. 如果全局队列为空,则调用 epoll_wait() 获取网络 IO 事件处理 |
调用 schedule::wait_one() |
io_context::post() |
任务入队到全局队列 |
调用 scheduler::post_immediate_completion() |
io_context::dispatch() |
1. 如果调用该接口的线程已经运行过全局队列中的任务,则直接继续由本线程运行该入队的任务 2. 如果不满足条件 1 条件,则直接入队到全局队列,等待调度执行 |
如果条件 1 满足,则直接由本线程执行 如果条件 1 不满足,则调用 scheduler::do_dispatch () |
总结:
1. 从上表的分析可以看出,和 mongodb 直接相关的几个接口最终都是调用 schedule 类的相关接口,整个实现过程参考下一节 scheduler 调度实现模块。
2. 上表中的几个接口按照功能不同,可以分为入队型接口 (poll 、 dispatch) 和出队型接口 (run_for 、 run 、 run_one_for) 。
3. 按照和 io_context 的关联性不同,可以分为 accept 相关 io(_acceptorIOContext) 处理的接口 (run 、 stop) 和新链接 fd 对应 Io(_workerIOContext) 数据分发相关处理及回调处理的接口 (run_for 、 run_one_for 、 poll 、 dispatch) 。
4. io_context 上下文的上述接口,除了 dispatch 在某些情况下直接运行 handler 外,其他接口最终都会间接调用 scheduler 调度类接口。
上一节的io_context 上下文中提到 mongodb 操作的 io 上下文最终都会调用 scheduler 的几个核心接口, io_context 只是起衔接 mongodb 和 asio 库的链接桥梁。 scheduler 类主要工作在于完成任务调度,该类和 mongodb 相关的几个主要成员变量及接口如下表:
scheduler 类主要成员 / 接口 |
功能 |
备注说明 |
mutable mutex mutex_; |
互斥锁,全局队列访问保护 |
多线程从全局队列获取任务的时候加锁保护 |
op_queue<operation> op_queue_; |
全局任务队列,全局任务和网络事件相关任务都添加到该队列 |
3.1.1 中的 5 种类型的任务都入队到了该全局队列 |
bool stopped_; |
线程是否可调度标识 |
为 true 后,将不再处理 epoll 相关事件,参考 scheduler::do_run_one |
event wakeup_event_; |
唤醒等待锁得线程 |
实际 event 由信号量封装 |
task_operation task_operation_; |
特殊的 operation |
在链表中没进行一次 epoll 获取到 IO 任务加入全局队列后,都会紧接着添加一个特殊 operation |
reactor* task_; |
也就是 epoll_reactor |
借助 epoll 实现网络事件异步处理 |
atomic_count outstanding_work_; |
套接字描述符个数 |
accept 获取到的链接数 fd 个数 +1( 定时器 fd) |
scheduler::run() |
循环处理 epoll 获取到的 accept 事件信息 |
循环调用 scheduler::do_run_one() 接口 |
scheduler::do_dispatch() |
任务入队 |
任务入队到全局队列 op_queue_ |
scheduler::do_wait_one() |
任务出队执行 |
如果队列为空则获取 epoll 事件集对应的网络 IO 任务放入全局 op_queue_ 队列 |
scheduler::restart() |
重新启用调度 |
实际上就是修改 stopped_ 标识为 false |
scheduler::stop_all_threads() |
停止调度 |
实际上就是修改 stopped_ 标识为 true |
从前面的分析可以看出,一个任务对应一个operation 类结构, asio 异步实现中 schduler 调度的任务分为 IO 处理任务 (accept 处理、读 io 处理、写 io 处理、网络 IO 处理回调处理 ) 和全局状态机任务,总共 2 种任务小类。
此外,asio 还有一种特殊的 operation ,该 Operastion 什么也不做,只是一个特殊标记。网络 IO 处理任务、状态机处理任务、特殊任务这三类任务分别对应三个类结构,分别是: reactor_op 、 completion_handler 、 task_operation_ ,这三个类都会继承基类 operation 。
1. operation 基类实现
operation 基类实际上就是 scheduler_operation 类,通过 typedef scheduler_operation operation 指定,是其他三个任务的父类,其主要实现接口如下:
operation 类主要成员 / 接口 |
功能 |
备注说明 |
unsigned int task_result_ |
Epoll_wait 获取到的事件位图信息记录到该结构中 |
在 descriptor_state::do_complete 中取出位图上的事件信息做底层 IO 读写处理 |
func_type func_; |
需要执行的任务 |
|
scheduler_operation ::complete() |
执行 func_() |
任务的内容在 func() 中运行 |
1. completion_handler 状态机任务
当mongodb 通过 listener 线程接受到一个新链接后,会生成一个状态机调度任务,然后入队到全局队列 op_queue_ , worker 线程从全局队列获取到该任务后调度执行,从而进入状态机调度流程,在该流程中会触发 epoll 相关得网络 IO 注册及异步 IO 处理。一个全局状态机任务对应一个 completion_handler 类,该类主要成员及接口说明如下表所示:
completion_handler 类主要成员 / 接口 |
功能 |
备注说明 |
Handler handler_; |
全局状态机任务函数 |
这个 handler 就相当于一个任务,实际上是一个函数 |
completion_handler(Handler& h) |
构造初始化 |
启用该任务,等待调度 |
completion_handler::do_complete() |
执行 handler_ 回调 |
任务的内容在 handler_() 中运行 |
completion_handler 状态机任务类实现过程比较简单,就是初始化和运行两个接口。全局任务入队的时候有两种方式,一种是 io_context::dispatch 方式,另一种是 io_context::post 。从前面章节对这两个接口的代码分析可以看出,任务直接入队到全局队列 op_queue_ 中,然后工作线程通过 scheduler::do_wait_one 从队列获取该任务执行。
注意: 状态机任务入队由Listener 线程 ( 新链接到来的初始状态机任务 ) 和工作线程 ( 状态转换任务 ) 共同完成,任务出队调度执行由 mongodb 工作线程执行,状态机具体任务内容在后面《状态机实现》章节实现。
1. 网络IO 事件处理任务
网络IO 事件对应的 Opration 任务最终由 reactor_op 类实现,该类主要成员及接口如下:
reactor_op 类主要成员 / 接口 |
功能 |
备注说明 |
asio::error_code ec_; |
全局状态机任务函数 |
这个 handler 就相当于一个任务,实际上是一个函数 |
std::size_t bytes_transferred_; |
读取或者发送的数据字节数 |
Epoll_wait 返回后获取到对应的读写事件,然后进行数据分发操作 |
enum status; |
底层数据读写状态 |
标识读写数据的状态 |
perform_func_type perform_func_; |
底层 IO 操作的函数指针 |
perform() 中运行 |
status perform() ; |
运行 perform_func_ 函数 |
perform 实际上就是数据读写的底层实现 |
reactor_op(perform_func_type perform_func, func_type complete_func) |
类初始化 |
这里有两个 func: 1. 底层数据读写实现的接口,也就是 perform_func 2. 读取或者发送一个完整 mongodb 报文的回调接口,也就是 complete_func |
从reactor_op 类可以看出,该类的主要两个函数成员: perform_func_ 和 complete_func 。其中 perform_func_ 函数主要负责异步网络 IO 底层处理, complete_func 用于获取到一个新链接、接收或者发送一个完整 mongodb 报文后的后续回调处理逻辑。
perform_func_ 具体功能包含如下三种如下:
1. 通过 epoll 事件集处理底层 accept 获取新连接 fd 。
2. fd 上的数据异步接收
3. fd 上的数据异步发送
针对上面的三个网络IO 处理功能, ASIO 在实现的时候,分别通过三个不同的类 (reactive_socket_accept_op_base 、 reactive_socket_recv_op_base 、
reactive_socket_send_op_base) 实现,这三个类都继承父类 reactor_op 。这三个类的功能总结如下表所示:
类名 |
功能 |
说明 |
reactive_socket_accept_op_base |
1. Accept() 系统调用获取新 fd 2. 获取到一个新 fd 后的 mongodb 层逻辑回调处理 |
Accept() 系统调用由 perform_func() 函数处理 获取到新链接后的逻辑回调由 complete_func 执行 |
reactive_socket_recv_op_base |
1. 读取一个完整 mongodb 报文读取 2. 读取完整报文后的 mongodb 服务层逻辑回调处理 |
从一个链接上读取一个完整 mongodb 报文读取由 perform_func() 函数处理 读取完整报文后的 mongodb 服务层逻辑回调处理由 complete_func 执行 |
reactive_socket_send_op_base |
1. 发送一个完整的 mongodb 报文 2. 发送完一个完整 mongodb 报文后的 mongodb 服务层逻辑回调处理 |
Accept() 系统调用由 perform_func() 函数处理 获取到新链接后的逻辑回调由 complete_func 执行 |
总结: asio 在实现的时候,把 accept 处理、数据读、数据写分开处理,都继承自公共基类 reactor_op ,该类由两个操作组成:底层 IO 操作和回调处理。其中, asio 的底层 IO 操作最终由 epoll_reactor 类实现,回调操作最终由 mongodb 服务层指定,底层 IO 操作的回调映射表如下:
底层 IO 操作类型 |
Mongodb 服务层回调 |
说明 |
Accept( reactive_socket_accept_op_base ) |
ServiceEntryPointImpl::startSession ,回调中进入状态机任务流程 |
Listener 线程获取到一个新链接后 mongodb 的回调处理 |
Recv( reactive_socket_recv_op_base ) |
ServiceStateMachine::_sourceCallback ,回调中进入状态机任务流程 |
接收一个完整 mongodb 报文的回调处理 |
Send( reactive_socket_send_op_base ) |
ServiceStateMachine::_sinkCallback ,回调中进入状态机任务流程 |
发送一个完整 mongodb 报文的回调处理 |
说明: 网络IO 事件处理任务实际上在状态机任务内运行,也就是状态机任务中调用 asio 库进行底层 IO 事件运行处理。
4. 特殊任务task_operation
前面提到,ASIO 库中还包含一种特殊的 task_operation 任务, asio 通过 epoll_wait 获取到一批 IO 事件后,会添加到 op_queue_ 全局队列,工作线程从队列取出任务有序执行。每次通过 epoll_wait 获取到 IO 事件信息后,除了添加这些读写事件对应的底层 IO 处理任务到全局队列外,每次还会额外生成一个特殊 task_operation 任务添加到队列中。
为何引入一个特殊任务的Opration ?
工作线程变量全局op_queue_ 队列取出任务执行,如果从队列头部取出的是特殊 Op 操作,就会立马触发获取 epoll 网络事件信息,避免底层网络 IO 任务长时间不被处理引起的 " 饥饿 " 状态,保证状态机任务和底层 IO 任务都能 ” 平衡 ” 运行。
asio 库底层处理实际上由 epoll_reactor 类实现,该类主要负责 epoll 相关异步 IO 实现处理, 鉴于篇幅epoll reactor 相关实现将在后续《 mongodb 内核源码实现及调优系列》相关章节详细分析。
网络传输数据压缩子模块主要用于减少网络带宽占用,通过CPU 来换取 IO 消耗,也就是以更多 CPU 消耗来减少网络 IO 压力。
鉴于篇幅,该模块的详细源码实现过程将在《mongodb 内核源码实现及调优系列》相关章节分享。
transport_layer 套接字处理及传输层管理子模块功能主要如下 :
1. 套接字相关初始化处理
2. 结合asio 库实现异步 accept 处理
3. 不同线程模型管理及初始化
鉴于篇幅,该模块的详细源码实现过程将在《mongodb 内核源码实现及调优系列》相关章节详细分析。
Session 会话模块功能主要如下:
1. 负责记录HostAndPort 、新连接 fd 信息
2. 通过和底层asio 库的直接互动,实现数据的同步或者异步收发。
鉴于篇幅,该模块的详细源码实现过程将在《mongodb 内核源码实现及调优系列》相关章节详细分析。
Ticket 数据分发子模块主要功能如下:
1. 调用session 子模块进行底层 asio 库处理
2. 拆分数据接收和数据发送到两个类,分别实现。
3. 完整mongodb 报文读取
4. 接收或者发送mongodb 报文后的回调处理
鉴于篇幅,该模块的详细源码实现过程将在《mongodb 内核源码实现及调优系列》相关章节详细分析。
service_state_machine 状态机处理模块主要功能如下:
1. Mongodb 网络数据处理状态转换
2. 配合状态转换逻辑把一次mongodb 请求拆分为二个大的状态任务 : 接收一个完整长度 mongodb 报文、接收到一个完整报文后的后续所有处理 ( 含报文解析、认证、引擎层处理、应答客户端等 ) 。
3. 配合工作线程模型子模块,把步骤2 的两个任务按照指定的状态转换关系进行调度运行。
鉴于篇幅,该模块的详细源码实现过程将在《mongodb 内核源码实现及调优系列》相关章节详细分析。
service_entry_point 服务入口点子模块主要负责如下功能:
1. 连接数控制
2. Session 会话管理
3. 接收到一个完整报文后的回调处理( 含报文解析、认证、引擎层处理等 )
鉴于篇幅,该模块的详细源码实现过程将在《mongodb 内核源码实现及调优系列》相关章节详细分析。
线程模型设计在数据库性能指标中起着非常重要的作用,因此本文将重点分析mongodb 服务层线程模型设计,体验 mongodb 如何通过优秀的工作线程模型来达到多种业务场景下的性能极致表现。
service_executor 线程子模块,在代码实现中,把线程模型分为两种: synchronous 线程模式和 adaptive 线程模型。 Mongodb 启动的时候通过配置参数 net. serviceExecutor 来确定采用那种线程模式运行mongo 实例,配置方式如下:
net: // 同步线程模式配置
serviceExecutor: synchronous
或者 // 动态线程池模式配置
net:
serviceExecutor: adaptive
synchronous 同步线程模型, listener 线程每接收到一个链接就会创建一个线程,该链接上的所有数据读写及内部请求处理流程将一直由本线程负责,整个线程的生命周期就是这个链接的生命周期。
1. 网络 IO 操作方式
synchronous 同步线程模型实现过程比较简单,线程循循环以 同步IO 操作方式 从fd 读取数据,然后处理数据,最后返回客户端对应得数据。同步线程模型方式针对某个链接的系统调用如下图所示 (mongo shell 建立链接后 show dbs) :
2. 性能极致提升小细节
虽然synchronous 线程模型比较简单,但是 mongodb 服务层再实现的时候针对细节做了极致的优化,主要体现在如下代码实现细节上面:
具体实现中,mongodb 线程每处理 16 次用户请求,就让线程空闲一会儿。同时,当总的工作线程数大于 cpu 核数后,每次都做让出一次 CPU 调度。通过这两种方式,在性能测试中可以提升 5% 的性能,虽然提升性能不多,但是充分体现了 mongodb 在性能优化提升方面所做的努力。
2. 同步线程模型监控统计
可以通过如下命令获取同步线程模型方式获取当前mongodb 中的链接数信息:
该监控中主要由两个字段组成:passthrough 代表同步线程模式, threadsRunning 表示当前进程的工作线程数。
adaptive 动态线程池模型,内核实现的时候会根据当前系统的访问负载动态的调整线程数。当线程 CPU 工作比较频繁的时候,控制线程增加工作线程数;当线程 CPU 比较空闲后,本线程就会自动消耗退出。下面一起体验 adaptive 线程模式下, mongodb 是如何做到性能极致设计的。
1. 线程池初始化
Mongodb 默认初始化后,线程池线程数默认等于 CPU 核心数 /2 ,主要实现如下:
从代码实现可以看出,线程池中最低线程数可以通过adaptiveServiceExecutorReservedThreads 配置,如果没有配置则默认设置为 CPU/2 。
1. 工作线程运行时间相关的几个统计
3.6 状态机调度模块中提到,一个完整的客户端请求处理可以转换为2 个任务:通过 asio 库接收一个完整 mongodb 报文、接收到报文后的后续所有处理 ( 含报文解析、认证、引擎层处理、发送数据给客户端等 ) 。假设这两个任务对应的任务名、运行时间分别如下表所示:
任务名 |
功能 |
运行时间 |
Task1 |
调用底层 asio 库接收一个完整 mongodb 报文 |
T1 |
Task2 |
接收到报文后的后续所有处理 ( 含报文解析、认证、引擎层处理、发送数据给客户端等 ) |
T2 |
客户端一次完整请求过程中,mongodb 内部处理过程 =task1 + task2 ,整个请求过程中 mongodb 内部消耗的时间 T1+T2 。
实际上如果fd 上没有数据请求,则工作线程就会等待数据,等待数据的过程就相当于空闲时间,我们把这个时间定义为 T3 。于是一个工作线程总运行时间 = 内部任务处理时间 + 空闲等待时间,也就是线程总时间 =T1+T2+T3 ,只是 T3 是无用等待时间。
2. 单个工作线程如何判断自己处于 ” 空闲 ” 状态
步骤2 中提到,线程运行总时间 =T1 + T2 +T3 ,其中 T3 是无用等待时间。如果 T3 的无用等待时间占比很大,则说明线程比较空闲。
Mongodb 工作线程每次运行完一次 task 任务后,都会判断本线程的有效运行时间占比,有效运行时间占比 =(T1+T2)/(T1+T2+T3) ,如果有效运行时间占比小于某个阀值,则该线程自动退出销毁,该阀值由 adaptiveServiceExecutorIdlePctThreshold 参数指定。该参数在线调整方式:
db.adminCommand( { setParameter: 1, adaptiveServiceExecutorIdlePctThreshold: 50} )
3. 如何判断线程池中工作线程“太忙”
Mongodb 服务层有个专门的控制线程用于判断线程池中工作线程的压力情况,以此来决定是否在线程池中创建新的工作线程来提升性能。
控制线程每过一定时间循环检查线程池中的线程压力状态,实现原理就是简单的实时记录线程池中的线程当前运行情况,为以下两类计数:总线程数_threadsRunning 、
当前正在运行task 任务的线程数 _threadsInUse 。如果 _threadsRunning=_threadsRunning ,说明所有工作线程当前都在处理 task 任务,这时候已经没有多余线程去 asio 库中的全局任务队列 op_queue_ 中取任务执行了,这时候队列中的任务就不会得到及时的执行,就会成为响应客户端请求的瓶颈点。
5. 如何判断线程池中所有线程比较“空闲”
control 控制线程会在收集线程池中所有工作线程的有效运行时间占比,如果占比小于指定配置的阀值,则代表整个线程池空闲。
前面已经说明一个线程的有效时间占比为:(T1+T2)/(T1+T2+T3) ,那么所有线程池中的线程总的有效时间占比计算方式如下:
所有线程的总有效时间TT1 = ( 线程池中工作线程 1 的有效时间 T1+T2) + ( 线程池中工作线程 2 的有效时间 T1+T2) + ..... + ( 线程池中工作线程 n 的有效时间 T1+T2)
所有线程总运行时间TT2 = ( 线程池中工作线程 1 的有效时间 T1+T2+T3) + ( 线程池中工作线程 2 的有效时间 T1+T2+T3) + ..... + ( 线程池中工作线程 n 的有效时间 T1+T2+T3)
线程池中所有线程的总有效工作时间占比 = TT1/TT2
6. control 控制线程如何动态增加线程池中线程数
Mongodb 在启动初始化的时候,会创建一个线程名为 ”worker-controller” 的控制线程,该线程主要工作就是判断线程池中是否有充足的工作线程来处理asio 库中全局队列 op_queue_ 中的 task 任务,如果发现线程池比较忙,没有足够的线程来处理队列中的任务,则在线程池中动态增加线程来避免 task 任务在队列上排队等待。
control 控制线程循环主体主要压力判断控制流程如下:
while { #等待工作线程唤醒条件变量,最长等待stuckThreadTimeout _scheduleCondition.wait_for(stuckThreadTimeout) #获取线程池中所有线程最近一次运行任务的总有效时间TT1 Executing = _getThreadTimerTotal(ThreadTimer::Executing); #获取线程池中所有线程最近一次运行任务的总运行时间TT2 Running = _getThreadTimerTotal(ThreadTimer::Running); #线程池中所有线程的总有效工作时间占比 = TT1/TT2 utilizationPct = Executing / Running; #代表control线程太久没有进行线程池压力检查了 if(本次循环到该行代码的时间 > stuckThreadTimeout阀值) { #说明太久没做压力检查,造成工作线程不够用了 if(_threadsInUse == _threadsRunning) { #批量创建一批工作线程 for(; i < reservedThreads; i++) #创建工作线程 _startWorkerThread(); } #control线程继续下一次循环压力检查 continue; } #如果当前线程池中总线程数小于最小线程数配置 #则创建一批线程,保证最少工作线程数达到要求 if (threadsRunning < reservedThreads) { while (_threadsRunning < reservedThreads) { _startWorkerThread(); } } #检查上一次循环到本次循环这段时间范围内线程池中线程的工作压力 #如果压力不大,则说明无需增加工作线程数,则继续下一次循环 if (utilizationPct < idlePctThreshold) { continue; } #如果发现已经有线程创建起来了,但是这些线程还没有运行任务 #这说明当前可用线程数可能足够了,我们休息sleep_for会儿在判断一下 #该循环最多持续stuckThreadTimeout时间 do { stdx::this_thread::sleep_for(); } while ((_threadsPending.load() > 0) && (sinceLastControlRound.sinceStart() < stuckThreadTimeout) #如果tasksQueued队列中的任务数大于工作线程数,说明任务在排队了 #该扩容线程池中线程了 if (_isStarved()) { _startWorkerThread(); } }
7. 实时 serviceExecutorTaskStats 线程模型统计信息
本文分析的mongodb 版本为 3.6.1 ,其 network.serviceExecutorTaskStats 网络线程模型相关统计通过 db.serverStatus().network.serviceExecutorTaskStats 可以查看,如下图所示:
上图的几个信息功能可以分类为三大类,说明如下:
大类类名 |
字段名 |
功能 |
无 |
executor |
Adaptive ,说明是动态线程池模式 |
线程统计 |
threadsInUse |
当前正在运行 task 任务的线程数 |
threadsRunning |
当前运行的线程数 | |
threadsPending |
当前创建起来,但是还没有执行过 task 任务的线程数 | |
队列统计 |
totalExecuted |
线程池运行成功的任务总数 |
tasksQueued |
入队到全局队列的任务数 | |
deferredTasksQueued |
等待接收网络 IO 数据来读取一个完整 mongodb 报文的任务数 | |
时间统计 |
totalTimeRunningMicros |
所有工作线程运行总时间 ( 含等待网络 IO 的时间 T1 + 读一个 mongodb 报文任务的时间 T2 + 一个请求后续处理的时间 T3) |
totalTimeExecutingMicros |
也就是 T2+T3 , mongodb 内部响应一个完整 mongodb 耗费的时间 | |
totalTimeQueuedMicros |
线程池中所有线程从创建到被用来执行第一个任务的等待时间 |
上表中各个字段的都有各自的意义,我们需要注意这些参数的以下情况:
1. threadsRunning - threadsInUse 的差值越大说明线程池中线程比较空闲,差值越小说明压力越大
2. threadsPending 越大,表示线程池越空闲
3. tasksQueued - totalExecuted 的差值越大说明任务队列上等待执行的任务越多,说明任务积压现象越明显
4. deferredTasksQueued 越大说明工作线程比较空闲,在等待客户端数据到来
5. totalTimeRunningMicros - totalTimeExecutingMicros 差值越大说明越空闲
上面三个大类中的总体反映趋势都是一样的,任何一个差值越大就说明越空闲。
在后续mongodb 最新版本中,去掉了部分重复统计的字段,同时也增加了以下字段,如下图所示:
新版本增加的几个统计项实际上和3.6.1 大同小异,只是把状态机任务按照不通类型进行了更加详细的统计。新版本中,更重要的一个功能就是 control 线程在发现线程池压力过大的时候创建新线程的触发情况也进行了统计,这样我们就可以更加直观的查看动态创建的线程是因为什么原因创建的。
8. Mongodb-3.6 早期版本 control 线程动态调整动态增加线程缺陷 1 例
从步骤6 中可以看出, control 控制线程创建工作线程的第一个条件为:如果该线程超过 stuckThreadTimeout 阀值都没有做线程压力控制检查,并且线程池中线程数全部在处理任务队列中的任务,这种情况 control 线程一次性会创建 reservedThreads 个线程。 reservedThreads 由 adaptiveServiceExecutorReservedThreads 配置,如果没有配置,则采用初始值 CPU/2 。
那么问题来了,如果我提前通过命令行配置了这个值,并且这个值配置的非常大,例如一百万,这里岂不是要创建一百万个线程,这样会造成操作系统负载升高,更容易引起耗尽系统pid 信息,这会引起严重的系统级问题。
不过,不用担心,最新版本的mongodb 代码,内核代码已经做了限制,这种情况下创建的线程数变为了 1 ,也就是这种情况只创建一个线程。
9. adaptive 线程模型实时参数
动态线程模设计的时候,mongodb 设计者考虑到了不通应用场景的情况,因此在核心关键点增加了实时在线参数调整设置,主要包含如下 7 种参数,如下表所示:
参数名 |
作用 |
adaptiveServiceExecutorReservedThreads |
默认线程池最少线程数 |
adaptiveServiceExecutorRunTimeMillis |
工作线程从全局队列中获取任务执行,如果队列中没有任务则需要等待,该配置就是限制等待时间的最大值 |
adaptiveServiceExecutorRunTimeJitterMillis |
如果配置为 0 ,则任务入队从队列获取任务等待时间则不需要添加一个随机数 |
adaptiveServiceExecutorStuckThreadTimeoutMillis |
保证 control 线程一次 while 循环操作 ( 循环体里面判断是否需要增加线程池中线程,如果发现线程池压力大,则增加线程 ) 的时间为该配置的值 |
adaptiveServiceExecutorMaxQueueLatencyMicros |
如果 control 线程一次循环的时间不到 adaptiveServiceExecutorStuckThreadTimeoutMillis ,则 do {} while() ,直到保证本次 while 循环达到需要的时间值。 {} 中就是简单的 sleep , sleep 的值就是本配置项的值。 |
adaptiveServiceExecutorIdlePctThreshold |
单个线程循环从全局队列获取 task 任务执行,同时在每次循环中会判断该本工作线程的有效运行时间占比,如果占比小于该配置值,则本线程自动退出销毁。 |
adaptiveServiceExecutorRecursionLimit |
由于 adaptive 采用异步 IO 操作,因此可能存在线程同时处理多个请求的情况,这时候我们就需要限制这个递归深度,如果深度过大,容易引起部分请求慢的情况。 |
命令行实时参数调整方法如下,以adaptiveServiceExecutorReservedThreads 为例,其他参数调整方法类似:
db.adminCommand( { setParameter: 1, adaptiveServiceExecutorReservedThreads: xx} )
Mongodb 服务层的 adaptive 动态线程模型设计代码实现非常优秀,有很多实现细节针对不同应用场景做了极致优化, 鉴于篇幅,该模块的详细源码实现过程将在《mongodb 内核源码实现及调优系列》相关章节详细分析。
前面对线程模型进行了分析,下面针对Synchronous 和 adaptive 两种模型设计进行不同场景和不同纬度的测试,总结两种模型各种的使用场景,并根据测试结果结合前面的理论分析得出不同场景下那种线程模型更合适。
测试纬度主要包括:并发数、请求快慢。本文的压力测试工具采用sysbench 实现,以下是这几种纬度的名称定义:
并发数: 也就是sysbench 启动的线程数,默认一个线程对应一个链接
请求快慢: 快请求就是请求返回比较快,sysbench 的 lua 测试脚本通过 read 同一条数据模拟快请求 ( 走存储引擎缓存 ) ,内部处理时延小于 1ms 。 慢请求也通过 sysbench 测试,测试脚本做 range 操作,单次操作时延几十 ms 。
sysbench 慢操作测试原理 : 首先写 20000 万数据到库中,然后通过 range 操作测试, range 操作比较慢,慢操作启动方式:
./sysbench --mongo-write-concern=1 --mongo-url="mongodb://xxx" --mongo-database-name=sbtest11 --oltp_table_size=600 --rand-type=pareto --report-interval=2 --max-requests=0 --max-time=200 --test=./tests/mongodb/ranges_ro.lua --oltp_range_size=2000 --num-threads=xx run
测试硬件资源,容器一台,配置如下:
1. CPU=32
2. 内存=64G
测试场景 |
线程模式 |
测试结果 |
70 线程 + 快请求 |
Synchronous |
总 tps( 包含异常请求 ):19.8W/s ,错误请求总数 :0 ,平均时延: 0.35ms 95 百分位时延 :0.57ms, 最大时延: 51ms |
Adaptive |
总 tps( 包含异常请求 ):18.1W/s ,错误请求总数 :0 ,平均时延: 0.38ms 95 百分位时延 :0.6ms, 最大时延: 41ms | |
500 线程 + 快请求 |
Synchronous |
总 tps( 包含异常请求 ):19.5W/s ,错误请求总数 :0 ,平均时延: 2.53ms 95 百分位时延 :5.39ms, 最大时延: 4033ms |
Adaptive |
总 tps( 包含异常请求 ):18.2W/s ,错误请求总数 :0 ,平均时延: 2.7ms 95 百分位时延 :3.77ms, 最大时延: 1049ms | |
1000 线程 + 快请求 |
Synchronous |
总 tps( 包含异常请求 ):18.4W/s ,错误请求总数 :4448/s ,有效请求 tps:17.9W/s ,平均时延: 5.41ms , 95 百分位时延 :20.58ms, 最大时延: 16595ms |
Adaptive |
总 tps( 包含异常请求 ):18.8W/s ,错误请求总数 :5000/s ,有效请求 tps:18.3W/s, 平均时延: 5.28ms , 95 百分位时延 :17.6ms, 最大时延: 4087ms | |
5000 线程 + 快请求 |
Synchronous |
总 tps( 包含异常请求 ):18.2W/s ,错误请求总数 :7000/s ,有效请求 tps:17.5W/s ,平均时延: 27.3ms , 95 百分位时延 :44.1ms, 最大时延: 5043ms |
Adaptive |
总 tps( 包含异常请求 ):18.2W/s ,错误请求总数 :37000/s ,有效请求 tps:14.5W/s ,平均时延: 27.4ms , 95 百分位时延 :108ms, 最大时延: 22226ms | |
30000 线程 + 快请求 |
Synchronous |
总 tps( 包含异常请求 ):21W/s ,错误请求总数 :140000/s ,有效请求 tps:6W/s ,平均时延: 139ms ,95 百分位时延 :805ms, 最大时延: 53775ms |
Adaptive |
总 tps( 包含异常请求 ):10W/s ,错误请求总数 :80/s ,有效请求 tps:10W/s ,平均时延: 195ms, 95 百分位时延 :985ms, 最大时延: 17030ms | |
30 线程 + 慢请求 |
Synchronous |
总 tps( 包含异常请求 ):850/s ,错误请求总数 :0 ,平均时延: 35ms 95 百分位时延 :45ms, 最大时延: 92ms |
Adaptive |
总 tps( 包含异常请求 ):674/s ,错误请求总数 :0 ,平均时延: 44ms 95 百分位时延 :52ms, 最大时延: 132ms | |
500 线程 + 慢请求
|
Synchronous |
总 tps( 包含异常请求 ):765/s ,错误请求总数 :0 ,平均时延: 652ms 95 百分位时延 :853ms, 最大时延: 2334ms |
Adaptive |
总 tps( 包含异常请求 ):783/s ,错误请求总数 :0 ,平均时延: 637ms 95 百分位时延 :696ms, 最大时延: 1847ms | |
1000 线程 + 慢请求 |
Synchronous |
总 tps( 包含异常请求 ):2840/s ,错误请求总数 :2140/s ,有效请求 tps:700/s ,平均时延: 351ms 95 百分位时延 :1602ms, 最大时延: 6977ms |
Adaptive |
总 tps( 包含异常请求 ):3604/s ,错误请求总数 :2839/s ,有效请求 tps:800/s, 平均时延: 277ms 95 百分位时延 :1335ms, 最大时延: 6615ms | |
5000 线程 + 慢请求 |
Synchronous |
总 tps( 包含异常请求 ):4535/s ,错误请求总数 :4000/s ,有效请求 tps:500/s ,平均时延: 1092ms 95 百分位时延 :8878ms, 最大时延: 25279ms |
Adaptive |
总 tps( 包含异常请求 ):4952/s ,错误请求总数 :4236/s ,有效请求 tps:700/s ,平均时延: 998ms 95 百分位时延 :7025ms, 最大时延: 16923ms | |
10000 线程 + 慢请求 |
Synchronous |
总 tps( 包含异常请求 ):4720/s ,错误请求总数 :4240/s ,有效请求 tps:500/s ,平均时延: 2075ms 95 百分位时延 :19539ms, 最大时延: 63247ms |
Adaptive |
总 tps( 包含异常请求 ):8890/s ,错误请求总数 :8230/s ,有效请求 tps:650/s ,平均时延: 1101ms 95 百分位时延 :14226ms, 最大时延: 40895ms | |
20000 线程 + 慢请求 |
Synchronous |
总 tps( 包含异常请求 ):7950/s ,错误请求总数 :7500/s ,有效请求 tps:450/s ,平均时延: 2413ms 95 百分位时延 :17812ms, 最大时延: 142752ms |
Adaptive |
总 tps( 包含异常请求 ):8800/s ,错误请求总数 :8130/s ,有效请求 tps:700/s ,平均时延: 2173ms 95 百分位时延 :27675ms, 最大时延: 57886ms |
根据测试数据及其前面理论章节的分析,可以得出不同业务场景结论:
1. 低并发场景( 并发数小于 1000) , Synchronous 线程模型性能更好。
2. 高并发场景( 并发数大于 5000) , adaptive 动态线程模型性能更优。
3. adaptive 动态线程模型, 95 分位时延和最大时延整体比 Synchronous 线程模型更优。
4. 并发越高,adaptive 相比 Synchronous 性能更好。
5. 并发越高,Synchronous 线程模型错误率相对更高。
6. 空闲链接越多,Synchronous 线程模型性能越差。 ( 由于时间问题,该场景未来得及测试,这是官方的数据总结 )
7. 此外,短链接场景( 例如 PHP 相关业务 ) , adaptive 模型性能会更优,因为该模型不会有链接关闭引起的线程销毁的开销。
为什么并发越高,adaptive 动态线程模型性能比 Synchronous 会更好,而并发低的时候反而更差,原因如下:
1. Synchronous 模型,一个链接一个线程,并发越高,链接数就会越多,系统负载、内存消耗等就会更高。
2. 低并发场景下,链接数不多,Synchronous 模式线程数也不多,系统 CPU 调度几乎不会受到影响,负载也影响不大。而在 adaptive 场景下,由于 asio 库在设计的时候,任务放入全局队列 op_queue_ 中,工作线程每次获取任务运行,都会有锁竞争,因此在低并发场景下性能不及 adaptive 模式。
前面3.6.2 章节讲了 adaptive 线程模型的工作原理,其中有 8 个参数供我们对线程池运行状态进行调优。大体总结如下 :
参数名 |
作用 |
adaptiveServiceExecutorReservedThreads |
如果业务场景是针对类似整点推送、电商定期抢购等超大流量冲击的场景,可以适当的调高该值,避免冲击瞬间线程池不够用引起的任务排队、瞬间创建大量线程、时延过大的情况 |
adaptiveServiceExecutorRunTimeMillis |
不建议调整 |
adaptiveServiceExecutorRunTimeJitterMillis |
不建议调整 |
adaptiveServiceExecutorStuckThreadTimeoutMillis |
可以适当调小该值,减少 control 控制线程休眠时间,从而可以更快的检测到线程池中工作线程数是否够用 |
adaptiveServiceExecutorMaxQueueLatencyMicros |
不建议调整 |
adaptiveServiceExecutorIdlePctThreshold |
如果流量是波浪形形式,例如上一秒 tps=10 万 /S ,下一秒降为几十,甚至跌 0 的情况,可以考虑调小该值,避免流量瞬间下降引起的线程瞬间批量消耗及流量上升后的大量线程创建 |
adaptiveServiceExecutorRecursionLimit |
不建议调整 |
前面的分析可以看出adaptive 动态线程模型,为了获取全局任务队列 op_queue_ 上的任务,需要进行全局锁竞争,这实际上是整个线程池从队列获取任务运行最大的一个瓶颈。
优化思路: 我们可以通过优化队列和锁来提升整体性能,当前的队列只有一个,我们可以把单个队列调整为多个队列,每个队列一把锁,任务入队的时候散列到多个队列,通过该优化,锁竞争及排队将会得到极大的改善。
优化前队列架构:
优化后队列架构:
如上图,把一个全局队列拆分为多个队列,任务入队的时候按照hash 散列到各自的队列,工作线程获取获取任务的时候,同理通过 hash 的方式去对应的队列获取任务,通过这种方式减少锁竞争,同时提升整体性能。
鉴于篇幅,transport 模块的详细源码实现过程将在《 mongodb 内核源码实现及调优系列》相关章节详细分析。
网络传输各个子模块及Asio 库源码详细注释详见 :
https://github.com/y123456yz/reading-and-annotate-mongodb-3.6.1/blob/master/README.md
本文mongodb对应的sysbench代码目录(该工具来自Percona,本文只是简单做了改动):
https://github.com/y123456yz/reading-and-annotate-mongodb-3.6.1/tree/master/mongo/sysbench-mongodb
Sysbench-mongodb对应的lua脚本目录:
来自 “ ITPUB博客 ” ,链接:http://blog.itpub.net/69984922/viewspace-2729400/,如需转载,请注明出处,否则将追究法律责任。