作者:盧文雙 資深資料庫內核研發 本文首發於 2023-05-04 22:07:40 http://dbkernel.com/2023/05/04/mysql-threadpool-main-solutions-details/# 本文主要從功能層面對比 percona-server、mariadb ...
作者:盧文雙 資深資料庫內核研發
本文首發於 2023-05-04 22:07:40
http://dbkernel.com/2023/05/04/mysql-threadpool-main-solutions-details/#
本文主要從功能層面對比 percona-server、mariadb、阿裡雲 AliSQL、騰訊 TXSQL、MySQL 企業版線程池方案,都基於 MySQL 8.0。
至於源碼層面,騰訊、阿裡雲、MySQL 企業版不開源,percona 借鑒了 mariadb 早期版本的實現,但考慮到線程池代碼只有 2000 行左右,相對簡單,本文就不做深入闡述。
版本:
MariaDB 10.9,
Percona-Server-8.0.32-24
背景
社區版的 MySQL 的連接處理方法預設是為每個連接創建一個工作線程的one-thread-per-connection
(Per_thread)模式。這種模式存在如下弊端:
- 由於系統的資源是有限的,隨著連接數的增加,資源的競爭也增加,連接的響應時間也隨之增加,如 response time 圖所示。
- 在資源未耗盡時,資料庫整體吞吐隨著連接數增加。一旦連接數超過了某個耗盡系統資源的臨界點,由於各線程互相競爭,CPU 時間片在大量線程間頻繁調度,不同線程上下文頻繁切換,徒增系統開銷,資料庫整體吞吐反而會下降,如下圖所示。
Q:如何避免 在連接數暴增時,因資源競爭而導致系統吞吐下降的問題呢?
MariaDB & Percona 中給出了簡潔的答案: 線程池。
線程池的原理在percona blog 中有生動的介紹,其大致可類比為早高峰期間大量汽車想通過一座大橋,如果採用one-thread-per-connection
的方式則放任汽車自由行駛,由於橋面寬度有限,最終將導致所有汽車寸步難行。線程池的解決方案是限制同時行駛的汽車數,讓橋面時刻保持最大吞吐,儘快讓所有汽車抵達對岸。
資料庫內核月報文章 《MySQL · 最佳實踐 · MySQL 多隊列線程池優化》中舉了一個高鐵買票的例子,也很形象,由於售票員(類比為 CPU 的核數)有限,當有 1000 個用戶(類比為資料庫連接)都想買票時,如果採用 one-thread-per-connection
的方式,則每個人都有一個專用視窗,需要售票員跑來跑去(CPU 上下文切換,售票視窗越多,跑起來越費力)來為你服務,可以看到這是不夠合理的,特別是售票員比較少而購票者很多的場景。如果採用線程池的思想,則不再是每個人都有一個專用的售票視窗(每個客戶端對應一個後端線程),而是通過限定售票視窗數,讓購票者排隊,來減少售票員跑來跑去的成本。
回歸到資料庫本身,MySQL 預設的線程使用模式是會話獨占模式(one-thread-per-connection
),每個會話都會創建一個獨占的線程。當有大量的會話存在時,會導致大量的資源競爭,同時,大量的系統線程調度和緩存失效也會導致性能急劇下降。
線程池線程池功能旨在解決以上問題,在存在大量連接的場景下,通過線程池實現線程復用:
- 當連接多、併發低時,通過連接復用,避免創建大量空閑線程,減少系統資源開銷。
- 當連接多、併發高時,通過限制同時運行的線程數,將其控制在合理的範圍內,可避免線程調度工作過多和大量緩存失效,減少線程池間上下文切換和熱鎖爭用,從而對 OLTP 場景產生積極影響。
當連接數上升時,線上程池的幫助下,將資料庫整體吞吐維持在一個較高水準,如圖所示。
適用場景
線程池採用一定數量的工作線程來處理連接請求,線程池在查詢相對較短且工作負載受 CPU 限制的情況下效率最高,通常比較適應於 OLTP 工作負載的場景。如果工作負載不受 CPU 限制,那麼您仍然可以通過限制線程數量來為資料庫記憶體緩衝區節省記憶體。
線程池的不足在於當請求偏向於慢查詢時,工作線程阻塞在高時延操作上,難以快速響應新的請求,導致系統吞吐量反而相較於傳統 one-thread-per-connection 模式更低。
線程池適用的場景:
- 對於大量連接的 OLTP 短查詢的場景將有最大收益。
- 對於大量連接的只讀短查詢也有明顯收益。
- 有效避免大量連接高併發下資料庫性能衰減。
不太適合用線程池的場景:
- 具有突發工作負載的場景。在這種場景下,許多用戶往往長時間處於非活躍狀態,但個別時候又處於特別活躍的狀態,同時,對延遲的容忍度較低,因此,線程池節流效果不太理想。不過,即使在這種情況下,也可以通過調整線程的退役頻率來提高性能(使用
thread_pool_idle_timeout
參數)。 - 高併發、長耗時語句的場景。在這種場景下,併發較多,且都是執行時間較長的語句,會導致工作線程堆積,一旦達到上限,完全阻止後續語句的執行,比如最常見的數據倉庫場景。當然這樣的場景下,不管是否使用線程池,資料庫的表現都是不夠理想的,需要應用側控制大查詢的併發度。
- 有較嚴重的鎖衝突,如果處於鎖等待的工作線程數超過匯流排程數,也會堆積起來,阻止無鎖待的處理請求。比如某個會話執行
FLUSH TABLES WITH READ LOCK
語句獲得全局鎖後暫停,那麼其他執行寫操作的客戶端連接就會阻塞,當阻塞的數量超過線程池的上限時,整個 server 都會阻塞。當然這樣的場景下,不管是否使用線程池,資料庫的表現都是不夠理想的,需要應用側進行優化。 - 極高併發的 Prepared Statement 請求。使用 Prepared Statement(Java 應用不算)時,會使用 MySQL Binary Protocol,會增加很多的網路來回操作,比如參數的綁定、結果集的返回,在極高請求壓力下會給 epoll 監聽進程帶來一定的壓力,處於事務狀態中時,可能會讓普通請求得不到執行機會。
為了應對這種阻塞問題,一般會允許配置 extra_port
或 admin_port
來管理連接。
總結一句話,線程池更適合短連接或短查詢的場景。
行業方案:Percona 線程池實現
由於市面上的線程池方案大多都借鑒了 percona、mariadb 的方案,因此,首先介紹下 percona 線程池的工作機制,再說明其他方案相較於 percona 做了什麼改進。
0. 基本原理
線程池的基本原理為:預先創建一定數量的工作線程(worker 線程)。線上程池監聽線程(listener 線程)從現有連接中監聽到新請求時,從工作線程中分配一個線程來提供服務。工作線程在服務結束之後不銷毀線程(處於 idle 狀態一段時間後會退出),而是保留線上程池中繼續等待下一個請求來臨。
下麵我們將從線程池架構、新連接的創建與分配、listener 線程、worker 線程、timer 線程等幾個方面來介紹 percona 線程池的實現。
1. 線程池的架構
線程池由多個線程組(thread group)和timer 線程組成,如下圖所示。
線程組的數量是線程池併發的上限,通常而言線程組的數量需要配置成資料庫實例的 CPU 核心數量(可通過參數thread_pool_size
設置),從而充分利用 CPU。線程組之間通過線程ID % 線程組數
的方式分配連接,線程組內通過競爭方式處理連接。
線程池中還有一個服務於所有線程組的timer 線程,負責周期性(檢查時間間隔為threadpool_stall_limit
毫秒)檢查線程組是否處於阻塞狀態。當檢測到阻塞的線程組時,timer 線程會通過喚醒或創建新的工作線程(wake_or_create_thread
函數)來讓線程組恢復工作。
創建新的工作線程不是每次都能創建成功,要根據當前的線程組中的線程數是否大於線程組中的連接數,活躍線程數是否為 0,以及上一次創建線程的時間間隔是否超過閾值(這個閾值與線程組中的線程數有關,線程組中的線程數越多,時間間隔越大)。
線程組內部由多個 worker 線程、0 或 1 個動態 listener 線程、高低優先順序事件隊列(由網路事件 event 構成)、mutex、epollfd、統計信息等組成。如下圖所示:
worker 線程:主要作用是從隊列中讀取並處理事件。
- 如果該線程所在組中沒有 listener 線程,則該 worker 線程將成為 listener 線程,通過 epoll 的方式監聽數據,並將監聽到的 event 放到線程組中的隊列。
- worker 線程數目動態變化,併發較大時會創建更多的 worker 線程,當從隊列中取不到 event 時,work 線程將休眠,超過一定時間後結束線程。
- 一個 worker 線程只屬於一個線程組。
listener 線程:當高低隊列為空,listen 線程會自己處理,無論這次獲取到多少事務。否則 listen 線程會把請求加入到隊列中,如果此時active_thread_count=0
,喚醒一個工作線程。
高低優先順序隊列:為了提高性能,將隊列分為優先隊列和普通隊列。這裡採用引入兩個新變數thread_pool_high_prio_tickets
和thread_pool_high_prio_mode
。由它們控制高優先順序隊列策略。對每個新連接分配可以進入高優先順序隊列的 ticket。
2. 新連接的創建與分配
新連接接入時,線程池按照新連接的線程 id 取模線程組個數來確定新連接歸屬的線程組(thd->thread_id() % group_count
)。這樣的分配邏輯非常簡潔,但由於沒有充分考慮連接的負載情況,繁忙的連接可能會恰巧被分配到相同的線程組,從而導致負載不均衡的現象,這是 percona 線程池值得被優化的點。
選定新連接歸屬的線程組後,新連接申請被作為事件放入低優先順序隊列中,等待線程組中 worker 線程將高優先順序事件隊列處理完後,就會處理低優先順序隊列中的請求。
3. listener 線程
listener 線程是負責監聽連接請求的線程,每個線程組都有一個listener 線程。
percona 線程池的 listener 採用epoll實現。當 epoll 監聽到請求事件時,listener 會根據請求事件的類型來決定將其放入哪個優先順序事件隊列。將事件放入高優先順序隊列的條件如下(見函數connection_is_high_prio
),只需要滿足其一即可:
- 當前線程池的工作模式為高優先順序模式,在此模式下只啟用高優先順序隊列。(
mode == TP_HIGH_PRIO_MODE_STATEMENTS
) - 當前線程池的工作模式為高優先順序事務模式,在此模式下每個連接的 event最多被放入高優先順序隊列
threadpool_high_prio_tickets
次。超過threadpool_high_prio_tickets
次後,該連接的請求事件只能被放入低優先順序(mode == TP_HIGH_PRIO_MODE_TRANSACTIONS
),同時,也會重置票數。 - 連接持有表鎖
- 連接持有mdl 鎖
- 連接持有全局讀鎖
- 連接持有backup 鎖
inline bool connection_is_high_prio(const connection_t &c) noexcept {
const ulong mode = c.thd->variables.threadpool_high_prio_mode;
return (mode == TP_HIGH_PRIO_MODE_STATEMENTS) ||
(mode == TP_HIGH_PRIO_MODE_TRANSACTIONS && c.tickets > 0 &&
(thd_is_transaction_active(c.thd) ||
c.thd->variables.option_bits & OPTION_TABLE_LOCK ||
c.thd->locked_tables_mode != LTM_NONE ||
c.thd->mdl_context.has_locks() ||
c.thd->global_read_lock.is_acquired() ||
c.thd->backup_tables_lock.is_acquired() ||
c.thd->mdl_context.has_locks(MDL_key::USER_LEVEL_LOCK) ||
c.thd->mdl_context.has_locks(MDL_key::LOCKING_SERVICE)));
}
被放入高優先順序隊列的事件可以優先被 worker 線程處理。
只有當高優先順序隊列為空,並且當前線程組不繁忙的時候才處理低優先順序隊列中的事件。線程組繁忙(too_many_busy_threads
)的判斷條件是當前組內活躍工作線程數+組內處於等待狀態的線程數大於線程組工作線程額定值(thread_pool_oversubscribe+1
)。這樣的設計可能帶來的問題是在高優先順序隊列不為空或者線程組繁忙時低優先順序隊列中的事件遲遲得不到響應,這同樣也是 percona 線程池值得被優化的一個點。
listener 線程將事件放入高低優先順序隊列後,如果線程組的活躍 worker 數量為 0,則喚醒或創建新的 worker 線程來處理事件。
percona 的線程池中listener 線程和 worker 線程是可以互相切換的,詳細的切換邏輯會在「worker 線程」一節介紹。
- epoll 監聽到請求事件時,如果高低優先順序事件隊列都為空,意味著此時線程組非常空閑,大概率不存在活躍的 worker 線程。
- listener 在此情況下會將除第一個事件外的所有事件按前述規則放入高低優先順序事件隊列,然後退出監聽任務,親自處理第一個事件。
- 這樣設計的好處在於當線程組非常空閑時,可以避免 listener 線程將事件放入隊列,喚醒或創建 worker 線程來處理事件的開銷,提高工作效率。
上圖來源於騰訊資料庫技術公眾號
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 線程:
- 如果沒有 listener 線程,則將當前 worker 線程轉化為 listener 線程。
- 如果當前有 listener 線程,則在進入休眠前嘗試通過
epoll_wait
獲取一個尚未進入隊列的事件,成功獲取到後立刻處理該事件,否則進入休眠等待被喚醒,等待threadpool_idle_timeout
時間後仍未被喚醒則銷毀該 worker 線程。
worker 線程與 listener 線程的切換如下圖所示:
上圖來自於騰訊資料庫技術公眾號
5. timer 線程
timer 線程每隔threadpool_stall_limit
時間進行一次所有線程組的掃描(check_stall
)。
當線程組高低優先順序隊列中存在事件,並且自上次檢查至今沒有新的事件被 worker 消費,則認為線程組處於停滯狀態。
- 停滯的主要原因可能是長時間執行的非阻塞請求, 也可能發生於線程正在等待但
wait_begin/wait_end
(嘗試喚醒或創建新的 worker 線程)被上層函數忘記調用的場景。 - timer 線程會通過喚醒或創建新的 worker 線程來讓停滯的線程組恢復工作。
timer 線程為了儘量減少對正常工作的線程組的影響,在check_stall
時採用的是try_lock
的方式,如果加不上鎖則認為線程組運轉良好,不再去打擾。
timer 線程除上述工作外,還負責終止空閑時間超過wait_timeout
秒的客戶端。
下麵是 Percona 的實現:
check_stall
函數:
check_stall
|-- if (!thread_group->listener && !thread_group->io_event_count) {
|-- wake_or_create_thread(thread_group); // 重點函數
|-- }
|-- thread_group->io_event_count = 0; // 表示自上次 check 之後,當前線程組新獲取的 event 數
|-- if (!thread_group->queue_event_count && !queues_are_empty(*thread_group)) { // 重點函數
|-- thread_group->stalled = true;
|-- wake_or_create_thread(thread_group); // 重點函數
|-- }
|-- thread_group->queue_event_count = 0;
static bool queues_are_empty(const thread_group_t &tg) noexcept {
return (tg.high_prio_queue.is_empty() && // 重點函數
(tg.queue.is_empty() || too_many_busy_threads(tg))); // 重點函數
}
- io_event_count:當 Listen 線程監聽到事件時++
- queue_event_count :當 work 線程從隊列中獲取事件時++
行業主流方案對比
MySQL 企業版 vs MariaDB
MySQL 企業版是在 5.5 版本引入的線程池,以插件的方式實現的。
相同點:
- 都具備線程池功能,都支持
thread_pool_size
參數。 - 都支持專有 listener 線程(
thread_pool_dedicated_listeners
參數)。 - 都支持高低優先順序隊列,且在避免低優先順序隊列事件餓死方面,二者採用了相同方案,即低優先順序隊列事件等待一段時間(
thread_pool_prio_kickup_timer
參數)即可移入高優先順序隊列。 - 都使用相同的機制來探測處於停滯(stall)狀態的線程,都提供了
thread_pool_stall_limit
參數(MariaDB 單位是 ms,MySQL 企業版單位是 10ms)。
不同點: Windows 平臺實現方式不同。
- MariaDB 使用 Windows 自帶的線程池,而 MySQL 企業版的實現用到了
WSAPoll()
函數(為了便於移植 Unix 程式而提供),因此,MySQL 企業版的實現將不能使用命名管道和共用記憶體。 - MariaDB 為每個操作系統都使用最高效的 IO 多路復用機制。
- Windows:原生線程池
- Linux:
epoll
- Solaris (
event ports
) - FreeBSD and OSX (
kevent
)
- 而 MySQL 企業版只在 Linux 上才使用優化過的 IO 多路復用機制
epoll
,其他平臺則用poll
。
MariaDB vs Percona
Percona 的實現移植自 MariaDB,併在此基礎上添加了一些功能。特別是 Percona 在 5.5-5.7 版本添加了優先順序調度。而 MariaDB 10.2 也支持了優先順序調度,和 Percona 的工作方式類似,只是細節有所不同。
- MariaDB 10.2 版本的參數
thread_pool_priority=auto,high,low
對應於 Percona 的thread_pool_high_prio_mode=transactions,statements,none
- MariaDB 10.2 版本中只有處於事務中的連接才是高優先順序,而 Percona 中符合高優先順序的情況包括:1)處於事務中;2)持有表鎖;3)持有 MDL 鎖;4)持有全局讀鎖;5)持有 backup 鎖。
- 關於避免低優先順序隊列語句餓死的問題:
- Percona 有一個
thread_pool_high_prio_tickets
參數,用於指定每個連接在高優先順序隊列中的 tickets 數量,而 MariaDB 沒有相應參數。 - MariaDB 有一個
thread_pool_prio_kickup_timer
參數,可讓低優先隊列中的語句在等待指定時間後移入高優先順序隊列,而 Percona 沒有相應參數。
- Percona 有一個
- MariaDB 有參數
thread_pool_dedicated_listener
、thread_pool_exact_stats
,而 Percona 沒有。thread_pool_dedicated_listener
:可用於指定專有 listener 線程,其只負責epoll_wait
等待網路事件,不會變為 worker 線程。預設為 OFF,表示不固定 listener。thread_pool_exact_stats
:是否使用高精度時間戳。
- MariaDB (比如 10.9 版本)在
information_schema
中新增了四張表(THREAD_POOL_GROUPS
、THREAD_POOL_QUEUES
、THREAD_POOL_STATS
、THREAD_POOL_WAITS
),便於監控線程池狀態。
AliSQL vs Percona
AliSQL 線程池也一定程度借鑒了 Percona 的機制,但也有自己的特色:
- AliSQL 線程池給予管理類的 SQL 語句更高的優先順序,保證這些語句優先執行。這樣在系統負載很高時,新建連接、管理、監控等操作也能夠穩定執行。
- AliSQL 線程池給予複雜查詢 SQL 語句相對較低的優先順序,並且有最大併發數的限制。這樣可以避免過多的複雜 SQL 語句將系統資源耗盡,導致整個資料庫服務不可用。
- AliSQL 支持動態開關線程池。
- 從官網手冊及內核月報公開資料,無法獲知 AliSQL 是否支持線程組專職 listener 。
AliSQL 雖然也使用了隊列,但沒有直接採用 percona 或 mariadb 的高低優先順序調度策略,結合官方手冊和資料庫內核月報 2019 年 2 月份的文章 《MySQL 多隊列線程優化》,推測是使用了兩層隊列:
- 第一層隊列為網路請求隊列,可以區分為請求隊列(不在事務狀態中的請求)和高優先順序隊列(已經在事務狀態中的請求,收到請求後會馬上執行,不進入第二隊列)。
- 第二層隊列為工作任務隊列,可以區分為查詢隊列、更新隊列和事務隊列。
第一層請求隊列的請求經過快速的處理和分析進入第二層隊列。如果是管理操作,則直接執行(假定所有管理操作都是小操作)。
對第二層隊列,可以分別設置一個允許的併發度(可以接近 CPU 的個數),以實現匯流排程數的控制。只要線程數大於四類操作的設計併發度之和,則不同類型的操作不會互相干涉(在這裡是假定同一操作超過各自併發度而進行排隊是合理的)。任何一個隊列超過一定的時間,如果沒有完成任何語句,處於阻塞模式,則可以考慮放行,在 MySQL 線程池中有thread_pool_stall_limit
變數來控制這個間隔,以防止任何一個隊列掛起。
可以從配置參數的變化來瞭解優化後的線程池工作機制:
thread_pool_enabled
:線程池開關。thread_pool_idle_timeout
:線程最大空閑時間,超過則退出。thread_pool_max_threads
:線程池最大工作線程數。thread_pool_oversubscribe
:每個 Thread Group 的目標線程數。thread_pool_normal_weights
(相較 percona 新加):查詢、更新操作的目標線程比例(假定這兩類操作的比重相同),即併發度 = thread_pool_oversubscribe * 目標比例/100
。thread_pool_trans_weights
(相較 percona 新加):事務操作的目標線程比例,即併發度 = thread_pool_oversubscribe * 目標比例/100
。thread_pool_stall_limit
:阻塞模式檢查頻率(同時檢查 5 個隊列的狀態)thread_pool_size
:線程組的個數(在優化鎖併發後,線程組的個數不是很關鍵,可以用來根據物理機器的資源配置情況來軟性調節處理能力)
另外,AliSQL 新增了 6 個狀態變數:thread_pool_active_threads
,thread_pool_big_threads
,thread_pool_dml_threads
,thread_pool_qry_threads
,thread_pool_trx_threads
,thread_pool_wait_threads
。還有 2 個狀態變數與 percona 線程池含義相同,只是名字不同。
TXSQL vs Percona
騰訊雲 TXSQL 線程池核心方案與 Percona 完全一樣,額外支持的功能如下:
1. 支持線程池動態切換
線程池採用一定數量的工作線程來處理用戶連接請求,通常比較適應於 OLTP 工作負載的場景。但線程池並不是萬能的,線程池的不足在於當用戶請求偏向於慢查詢時,工作線程阻塞在高時延操作上,難以快速響應新的用戶請求,導致系統吞吐量反而相較於 one-thread-per-connection(簡稱為 Per_thread)模式更低。
Per_thread 模式與 Thread_pool 模式各有優劣,系統需要根據用戶的業務類型靈活切換兩種模式。在業務高峰時段切換模式,重啟伺服器,會嚴重影響用戶業務。為瞭解決此問題,TXSQL 提出了線程池動態切換的優化,即在不重啟資料庫服務的情況下,動態開啟或關閉線程池。
通過參數 thread_handling_switch_mode
控制,可選值及含義如下:
可選值 | 含義 |
---|---|
disabled | 禁止模式動態遷移 |
stable | 只有新連接遷移 |
fast | 新連接 + 新請求都遷移,預設模式 |
sharp | kill 當前活躍連接,迫使用戶重連,達到快速切換的效果 |
在瞭解了 TXSQL 動態線程池的使用方法後,我們再來瞭解一下其具體的實現。
mysql 的thread_handling
參數代表了連接管理方法。
在原生 mysql 中,thread_handling 是只讀參數,不允許線上修改。
thread_handling
參數對應的底層實現對象是Connection_handler_manager
,後者是 mysql 提供連接管理服務的單例類,可對外提供多種連接管理服務:
Per_thread
: 參數值是 one-thread-per-connectionNo_threads
: 參數值是 no-threadsThread_pool
: 新加Plugin_connection_handler
: 參數值是 loaded-dynamically
在 mysql 啟動時Connection_handler_manager
只需要按照thread_handling
初始化一種連接管理方法即可。
為了支持動態線程池,允許用戶連接從 Per_thread 和 Thread_pool 模式中來回切換,我們需要允許多種連接管理方法同時存在。因此,在 mysql 初始化階段,TXSQL 初始化了所有連接管理方法。
在支持thread_handling
在Per_thread 和 Thread_pool 模式中來回切換後,我們需要考慮的問題主要有以下幾個:
1.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 線程)。- handle_connection 函數被
Per_thread_connection_handler::add_connection
函數調用。
- 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 線程處理。
1.2. 新連接的處理
由於 thread_handling 可能隨時動態變化,為了使得新連接能被新 thread_handling 處理,需要在新連接處理介面Connection_handler_manager::process_new_connection
中,讀取最新的 thread_handling,利用其相應的連接管理方法添加新連接。
- 對於 Per_thread 模式,需要為新連接創建
handle_connection
線程; - 對於 Thread_pool 模式,則需要為新連接選定 thread_group 和將其網路句柄綁定到 thread_group 的
epoll
中。
1.3. thread_handling 切換的快速生效
從前文的討論中可以看到,處於連接狀態的用戶線程需要等到一個請求處理結束才會等到合適的切換點。
如果該用戶連接遲遲不發送網路請求,則連接會阻塞在 do_command 下的get_command
的網路等待中,無法及時切換到 Thread_pool。如何快速完成此類線程的切換呢?
一種比較激進的方法就是迫使此類連接重連,在重連後作為新連接自然地切換到 Thread_pool 中,其下一個網路請求也將被 Thread_pool 應答。
線程池動態切換對性能的影響:
pool-of-threads
切換為one-thread-per-connection
過程本身不會帶來 query 堆積,以及性能影響。one-thread-per-connection
切換為pool-of-threads
過程,由於之前線程池處於休眠狀態,在 QPS 極高並且有持續高壓的情況下,可能存在一定的請求累積。解決方案如下:- 方案 1:適當增大
thread_pool_oversubscribe
,並適當調小thread_pool_stall_limit
,快速激活線程池。待消化完堆積 SQL 再視情況還原上述修改。 - 方案 2:出現 SQL 累積時,短暫暫停或降低業務流量幾秒鐘,等待
pool-of-threads
完成激活,再恢復持續高壓業務流量。
- 方案 1:適當增大
2. 線程池負載均衡優化
如前文所述,新連接按照線程 id 取模線程組個數來確定新連接歸屬的線程組(thd->thread_id() % group_count
)。這樣的分配方式未能將各線程組的實際負載考慮在內,因此可能將繁忙的連接分配到相同的線程組,使得線程池出現負載不均衡的現象。為了避免負載不均衡的發生,TXSQL 提出了線程池負載均衡優化。
2.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 的情況。
上述三種信息策略只是舉例說明,還有更多信息策略可以被採用,就不再一一羅列。
2 .2. 負載均衡的實現介紹
在明確了度量線程組負載的方法之後,我們接下來討論如何均衡負載。我們需要考慮的問題主要如下:
1) 負載均衡演算法的觸發條件
負載均衡操作會將用戶連接從一個線程組遷移至另一個線程組,在非必要情況下觸發用戶連接的遷移反而會導致用戶連接的性能抖動。為儘可能避免負載均衡演算法錯誤觸發,我們需要為觸發負載均衡演算法設定一個負載閾值 M,以及負載比例 N。只有線程組的負載閾值大於 M,並且其與參與均衡負載的線程組的負載比例大於 N 時,才需要啟動負載均衡演算法平衡負載。
2) 負載均衡的參數對象
Q:當線程組觸發了負載均衡演算法後,該由哪些線程組參與平衡高負載線程組的負載呢?
很容易想到的一個方案是我們維護全局的線程組負載動態序列,讓負載最輕的線程組負責分擔負載。但是遺憾的是為了維護全局線程組負載動態序列,線程組每處理完一次任務都可能需要更新自身的狀態,併在全局鎖的保護下更新其在全局負載序列中的位置,如此一來對性能的影響勢必較大,因此全局線程組負載動態序列的方案並不理想。
為了避免均衡負載對線程池整體性能的影響,需改全局負載比較為局部負載比較。一種可能的方法為噹噹前線程組的負載高於閾值 M 時,只比較其與左右相鄰的 X 個(通常 1-2 個)線程組的負載差異,噹噹前線程組的負載與相鄰線程組的比例也高於 N 倍時,從當前線程組向低負載線程組遷移用戶連接。需要註意的是噹噹前線程組的負載與相鄰線程組的比例不足 N 倍時,說明要麼當前線程組還不夠繁忙、要麼其相鄰線程組也較為忙碌,此時為了避免線程池整體表現惡化,不適合強行均衡負載。
3) 均衡負載的方法
討論完負載均衡的觸發條件及參與對象之後,接下來我們需要討論高負載線程組向低負載線程組遷移負載的方法。總體而言,包括兩種方法:新連接的優化分配、舊連接的合理轉移。
在掌握了線程組的量化負載之後,較容易實現的均衡負載方法是在新連接分配線程組時特意避開高負載線程組,這樣一來已經處於高負載狀態的線程組便不會因新連接的加入進一步惡化。但僅僅如此還不夠,如果高負載線程組的響應已經很遲鈍,我們還需要主動將其中的舊連接遷移至合適的低負載線程組,具體遷移時機在 3.1 中已有述及,為在請求處理完(threadpool_process_request
)後,將用戶線程網路句柄重新掛載到epoll
(start_io)之前,此處便不再展開討論。
3. 線程池斷連優化
3.1. percona 線程池問題
如前文所述,線程池採用 epoll 來處理網路事件。當 epoll 監聽到網路事件時,listener 會將網路事件放入事件隊列或自己處理,此時相應用戶連接不會被 epoll 監聽。percona 線程池需要等到請求處理結束之後才會使用 epoll 重新監聽用戶連接的新網路事件。percona 線程池這樣的設計通常不會帶來問題,因為用戶連接在請求未被處理時,也不會有發送新請求的需求。但特殊情況下,如果用戶連接在重新被 epoll 監聽前自行退出了,此時用戶連接發出的斷連信號無法被 epoll 捕捉,因此在 mysql 伺服器端無法及時退出該用戶連接。這樣帶來的影響主要有兩點:
- 用戶連接客戶端雖已退出,但 mysql 伺服器端卻仍在運行該連接,繼續消耗 CPU、記憶體資源,甚至可能繼續持有鎖,只有等到連接超時才能退出;
- 由於用戶連接在 mysql 伺服器端未及時退出,連接數也並未清理,如果用戶業務連接數較多,可能導致用戶新連接數觸達最大連接數上限,用戶無法連接資料庫,嚴重影響業務。
為解決上述問題,TXSQL 提出了線程池斷連優化。
3.2. 斷連優化的實現介紹
斷連優化的重點在於及時監聽用戶連接的斷連事件並及時處理。為此需要作出的優化如下:
- 在 epoll 接到用戶連接的正常網路事件後,立刻監聽該用戶連接的斷連事件;
- 所有用戶連接退出從同步改為非同步,所有退出的連接先放入
quit_connection_queue
,後統一處理; - 一旦 epoll 接到斷連事件後立刻將用戶連接 thd->killed 設置為`THD::KILL_CONNECTION 狀態,並將連接放入 quit_connection_queue 中非同步退出;
- listener 每隔固定時間(例如 100ms)處理一次 quit_connection_queue,讓其中的用戶連接退出。
4. 新增用於監控的狀態變數
- 新增指令
show threadpool status
,可展示 25 個線程池狀態變數。 - 在
show full processlist
中新增如下狀態變數:Moved_to_per_thread
表示該連接遷移到 Per_thread 的次數。Moved_to_thread_pool
表示該連接遷移到 Thread_pool 的次數。
性能結果
由於騰訊 TXSQL、Percona 官方手冊都沒有性能數據,因此僅列出其他幾種方案的性能結果。
MariaDB 5.5 - 無優先順序隊列
本小節內容來源於官網手冊。
MariaDB 官網是基於 5.5 版本線程池測試的,也就是不支持高低優先順序隊列的版本。
採用 Sysbench 0.4,以pitbull (Linux, 24 cores) 的情況來說明在不同場景下的 QPS 情況。
OLTP_RO
併發數 | 16 | 32 | 64 | 128 | 256 | 512 | 1024 | 2048 | 4096 |
---|---|---|---|---|---|---|---|---|---|
per_thread | 6754 | 7905 | 8152 | 7948 | 7924 | 7587 | 5313 | 3827 | 208 |
threadpool | 6566 | 7725 | 8108 | 8079 | 7976 | 7793 | 7429 | 6523 | 4456 |
OLTP_RW
併發數 | 16 | 32 | 64 | 128 | 256 | 512 | 1024 | 2048 | 4096 |
---|---|---|---|---|---|---|---|---|---|
per_thread | 4561 | 5316 | 5332 | 3512 | 2874 | 2476 | 1380 | 265 | 53 |
threadpool | 4504 | 5382 | 5694 | 5567 | 5302 | 4514 | 2548 | 1186 | 484 |
POINT_SELECT
併發數 | 16 | 32 | 64 | 128 | 256 | 512 | 1024 | 2048 | 4096 |
---|---|---|---|---|---|---|---|---|---|
per_thread | 148673 | 161547 | 169747 | 172083 | 69036 | 42041 | 21775 | 4368 | 282 |
threadpool | 143222 | 167069 | 167270 | 165977 | 164983 | 158410 | 148690 | 147107 | 143934 |
UPDATE_NOKEY
併發數 | 16 | 32 | 64 | 128 | 256 | 512 | 1024 | 2048 | 4096 |
---|---|---|---|---|---|---|---|---|---|
per_thread | 65213 | 71680 | 19418 | 13008 | 11155 | 8742 | 5645 | 635 | 332 |
threadpool | 64902 | 70236 | 70037 | 68926 | 69930 | 69929 | 67099 | 62376 | 17766 |
AliSQL
如下是開啟線程池和不開啟線程池的性能對比。從測試結果可以看出線程池在高併發的情況下有著明顯的性能優勢。
update_non_index
write_only
read_write
point_select
總結
功能區別
MySQL 企業版 | MariaDB | Percona | 騰訊 TXSQL | 阿裡雲 AliSQL | |
---|---|---|---|---|---|
功能實現方式 | 插件 | 非插件 | 非插件 | 非插件 | 推測是非插件 |
版本 | 5.5 版本引入 | 5.5 版本引入,10.2 版本完善 | 5.5-5.7/8.0 | 5.7/8.0 | 5.6/5.7/8.0 |
是否開源 | 否 | 是 | 是 | 否 | 否 |
動態開關線程池 | 插件式,不支持 | 不支持 | 不支持 | 支持 | 支持 |
優先順序處理策略 | 設定高低優先順序,且低優先順序事件等待一段時間可升為高優先順序隊列 | 設定高低優先順序,且低優先順序事件等待一段時間可升為高優先順序隊列 | 設定高低優先順序,且限制每個連接在高優先順序隊列中的票數 | 設定高低優先順序,且限制每個連接在高優先順序隊列中的票數 | 控制事務、非事務語句的比例 |
各線程組之間負載均衡優化 | 不支持 | 不支持 | 不支持 | 支持 | - |
線程池斷連優化 | - | 不支持 | 不支持 | 支持 | - |
監控 | - | 2 個狀態變數 | 2 個狀態變數 | 27 個狀態變數 | 8 個狀態變數 |
借鑒方案 | - | - | MariaDB | Percona | MariaDB 5.5 |
跨平臺 | Windows/Unix | Windows/Unix/MacOS | Windows/Unix | - | - |
Q:如果線程池阻塞了,怎麼處理?
MySQL 8.0.14 以前的版本使用
extra_port
功能(percona & mariadb),8.0.14 及之後版本官方支持了admin_port
功能。
參數區別
由於業內線程池方案基本都會參考 MariaDB 或 Percona,因此,以 Percona 和 MariaDB 的參數為準,基於 MySQL 8.0,總結其他方案是否有相同或類似參數。
註意:MySQL 企業版核心方案與 MariaDB 類似,且關於差異點,官方描述較少,因此,不做對比。
MariaDB | Percona | 騰訊 TXSQL | 阿裡雲 AliSQL | |
---|---|---|---|---|
thread_handling
線程池開關 |
有 | 有 | 有類似參數 thread_handling_switch_mode (支持動態開關) |
有類似參數 thread_pool_enabled (支持動態開關) |
thread_pool_idle_timeout
線程最大空閑時間,超過則退出。 |
有 | 有 | 有 | 有 |
thread_pool_high_prio_mode 高優先順序隊列調度策略,支持 transactions ,statements ,none 三種策略 |
有類似參數 thread_pool_priority ,支持 high , low , auto 三種策略 |
有 | 有 | 無 |
thread_pool_high_prio_tickets 控制每個連接在高優先順序中的票數,僅在調度模式是事務模式時生效 |
無 | 有 | 有 | 無 |
thread_pool_max_threads 線程池最大工作線程數 |
有 | 有 | 有 | 有 |
thread_pool_oversubscribe 每個線程組中的最大工作線程數 |
有 | 有 | 有 | 有 |
thread_pool_size 線程組數,一般推薦設為 CPU 核心數 |
有 | 有 | 有 | 有 |
thread_pool_stall_limit timer 線程判斷線程組是否停滯(定期調用 check_stall )的時間間隔 |
有 | 有 | 有 | 有 |
thread_pool_prio_kickup_timer 低優先隊列中的語句在等待該值指定的時間後,則移入高優先順序隊列 |
有 | 無 | 無 | 無 |
thread_pool_dedicated_listener 是否啟用專用 listener 線程。若關閉,則 listener 有可能變為 worker。 |
有 | 無 | 無 | 無 |
thread_pool_exact_stats 是否使用高精度時間戳 |
有 | 無 | 無 | 無 |
thread_pool_normal_weights :查詢、更新操作的目標線程比例(假定這兩類操作的比重相同),即併發度= thread_pool_oversubscribe * 目標比例/100 |
無 | 無 | 無 | 有 |
thread_pool_trans_weights :事務操作的目標線程比例,即併發度= thread_pool_oversubscribe * 目標比例/100 |
無 | 無 | 無 | 有 |
可見:
- 阿裡雲 AliSQL 線程池資料較少,雖然有些參數不具備,但並不說明未實現對應機制,比如專用 listener 線程。
監控區別
Percona、MariaDB:
只有兩個狀態變數:
-
Threadpool_threads
Threadpool_idle_threads
阿裡雲 AliSQL:
新增了一些狀態變數:
狀態名 | 狀態說明 |
---|---|
thread_pool_active_threads | 線程池中的活躍線程數 |
thread_pool_big_threads | 線程池中正在執行複雜查詢的線程數。複雜查詢包括有子查詢、聚合函數、group by、limit 等的查詢語句。 |
thread_pool_dml_threads | 線程池中的在執行 DML 的線程數 |
thread_pool_idle_threads | 線程池中的空閑線程數 |
thread_pool_qry_threads | 線程池中正在執行簡單查詢的線程數 |
thread_pool_total_threads | 線程池中的匯流排程數 |
thread_pool_trx_threads | 線程池中正在執行事務的線程數 |
thread_pool_wait_threads | 線程池中正在等待磁碟 IO、事務提交的線程數 |
騰訊雲 TXSQL:
新增 show threadpool status
指令,展示的相關狀態如下:
狀態名 | 狀態說明 |
---|---|
groupid | 線程組 id |
connection_count | 線程組用戶連接數 |
thread_count | 線程組內工作線程數 |
havelistener | 線程組當前是否存在 listener |
active_thread_count | 線程組內活躍 worker 數量 |
waiting_thread_count | 線程組內等待中的 worker 數量(調用 wait_begin 的 worker) |
waiting_threads_size | 線程組中無網路事件需要處理,進入休眠期等待被喚醒的 worker 數量(等待 thread_pool_idle_timeout 秒後自動銷毀) |
queue_size | 線程組普通優先順序隊列長度 |
high_prio_queue_size | 線程組高優先順序隊列長度 |
get_high_prio_queue_num | 線程組內事件從高優先順序隊列被取走的總次數 |
get_normal_queue_num | 線程組內事件從普通優先順序隊列被取走的總次數 |
create_thread_num | 線程組內創建的 worker 線程總數 |
wake_thread_num | 線程組內從 waiting_threads 隊列中喚醒的 worker 總數 |
oversubscribed_num | 線程組內 worker 發現當前線程組處於 oversubscribed 狀態,並且準備進入休眠的次數 |
mysql_cond_timedwait_num | 線程組內 worker 進入 waiting_threads 隊列的總次數 |
check_stall_nolistener | 線程組被 timer 線程 check_stall 檢查中發現沒有 listener 的總次數 |
check_stall_stall | 線程組被 timer 線程 check_stall 檢查中被判定為 stall 狀態的總次數 |
max_req_latency_us | 線程組中用戶連接在隊列等待的最長時間(單位毫秒) |
conns_timeout_killed | 線程組中用戶連接因客戶端無新消息時間超過閾值(net_wait_timeout)被 killed 的總次數 |
connections_moved_in | 從其他線程組中遷入該線程組的連接總數 |
connections_moved_out | 從該線程組遷出到其他線程組的連接總數 |
connections_moved_from_per_thread | 從 one-thread-per-connection 模式中遷入該線程組的連接總數 |
connections_moved_to_per_thread | 從該線程組中遷出到 one-thread-per-connection 模式的連接總數 |
events_consumed | 線程組處理過的 events 總數 |
average_wait_usecs_in_queue | 線程組內所有 events 在隊列中的平均等待時間 |
在 show full processlist
中新增如下狀態:
Moved_to_per_thread
表示該連接遷移到 Per_thread 的次數。Moved_to_thread_pool
表示該連接遷移到 Thread_pool 的次數.
參考鏈接
- 騰訊 TXSQL:
- Percona:
- MariaDB:
- 阿裡雲 AliSQL:
- MySQL 企業版:
歡迎關註我的微信公眾號【資料庫內核】:分享主流開源資料庫和存儲引擎相關技術。
標題 | 網址 |
---|---|
GitHub | https://dbkernel.github.io |
知乎 | https://www.zhihu.com/people/dbkernel/posts |
思否(SegmentFault) | https://segmentfault.com/u/dbkernel |
掘金 | https://juejin.im/user/5e9d3ed251882538083fed1f/posts |
CSDN | https://blog.csdn.net/dbkernel |
博客園(cnblogs) | https://www.cnblogs.com/dbkernel |