作者:田傑
在數據庫的日常使用中,來自應用的高併發場景並不罕見,其標誌性的表現為 高新連接創建速率(CPS,比如 PHP 短連接)、發送大量請求到 DB 數據庫層。
如同 海嘯,大量的新建連接和請求猛烈的衝擊考驗著 DB 層的處理能力,非常容易出現數據庫被衝擊 hang 住或響應極其緩慢的情況(想象下無預知無緩衝的短時間內突然工作量翻漲數倍,會不會立時被忙哭了 ^_^)。
而數據庫通常作為架構最下端的數據存取匯聚單元,其性能表現和穩定性往往決定了應用的最終表現和使用體驗,可謂業務生死之大事,不可不察。
由此,我們一起看一下 “海嘯” 場景下可以用來 “保命” 的各種解決方案。
注:
• 本文目標是總結高併發場景下的應對處理方法,而應對熱點更新(秒殺)場景的“招數”會另文介紹。
• 本文的主旨在於方便數據庫的使用者理解業務高併發請求場景下的保障 DB 可用性和穩定性的機制和方法,非機制的全面深度技術細節介紹。
1. 線程池
1.1 模型
我們舉一個生活中的例子方便大家理解 線程池(Thread Pool)。
比如有個銀行,有 10 個窗口(實例規格 CPU 數量),官方說可以容納 10 人(Client Thread)。平時呢,人也不多,一直順暢。稍微忙一點呢,大家擠擠。這個 10 人的地方,擠個 50 人也可以(不是每個人時刻都在窗口辦業務)。效率也挺高。
年底發工資、公司結算、發行紀念幣來了一大幫人,大家一起擠,誰也不讓,就把銀行擠滿了;大家接踵摩肩,動也動不了,再發生些爭搶,那這銀行誰也辦不了業務了。
好了,來個保安(Timer Thread),搞了個隊伍機制(10 個隊列 loose_thread_pool_size = 10),按規定執行,一次放 10 個人。這個效率也不錯。
當然了,如果一下子來了 1000 個人,那麼門口等待的隊伍會很長,雖然不致於把銀行撐爆,但是後面的同學要等很長時間,有的會去抱怨了(應用側等待超過自身定義的超時時間後返回錯誤)。
問題來了,有的同學搞不清楚買哪種紀念幣,一直在看看停停,保安看他們也不像馬上能決定的樣子,而且窗口櫃員也不是非常忙,保安就又搞了個規則,叫 “stall_limit”。
看一些同學猶豫超過 stall_limit 定義的時間,那麼就算他們 stall 了,可以再放 1 個人進去(oversubscribing)。但去窗口辦業務的人數是有上限的,最多 50 個人(10 個窗口每個窗口 5 個人, loose_thread_pool_oversubscribe = 4)。
之後,只能出一個,進一個; 如果都不出來,那也 hang 了。這個時候,至少要讓保安能進去,把這些太慢的同學趕出來幾個,讓等待的隊列動起來。
還有,有的同學在裡面發現忘帶證件了,需要等送進來。他們找地方等(lock wait)。那麼他們是在等待了,這個是不算 oversubscribing 數量的,所以保安也可以放人,一直放到 thread_pool_max_threads 個人。
如果證件還沒送來,那麼銀行就被這些等證件的霸佔了(hang 了)。另外如果一下子證件都送來,那這個銀行一下子忙起來,也爆了(熱點更新)。
當然如果這個銀行沒有大量客戶同時辦業務的場景,是可以不需要搞個保安,不需要搞個隊伍的(loose_thread_pool_enabled = OFF)。這個銀行本身最多可以 50 個人,但是保安只讓 10 個人進去,那效率就會低了。
還有,門口等待隊伍長了,這個可以有 3 種可能,
• 顧客動作慢(慢 SQL),建議考慮優化 SQL 降低執行成本。
• 銀行小, 窗口數量少(實例規格小)建議擴店(升級實例規格)。
• 窗口動作慢(物理機問題、數據庫 bug;不在本文討論範圍內)。
從上面的例子中,我們可以看到 Thread Pool 是通過隊列機制限制數據庫的 Client Thread 的併發度(控制 Running Thread 數量),避免大量的爭搶和創建 Client Thread 的開銷來提升 CPU 使用 效率,保障吞吐的(在應用給與 DB 的訪問壓力不斷增加的情況下,保持 DB 吞吐處理能力)。
1.2 適用場景
如果我們仔細品位下上面的例子,可以發現 Thread Pool 的適用場景:
• 每個要辦的業務簡短(OLTP 場景)且性能瓶頸在 CPU 資源上
• 場景中不存在 大量 需要長時間執行且無停頓(可以暫時不使用 CPU)的 SQL
• 能夠接受一定損失(錯誤/開銷)的業務(啟用 Thread Pool 後需要一定開銷,存在簡單的查詢比不啟用 Thread Pool 的情況下執行時間增加的可能,比如被分配到了 stall 的 thread group 而要花時間等待執行)
1.3 小結
參數 | 開放修改? | 默認值 | 說明 |
---|---|---|---|
1 | loose_thread_pool_enabled | Yes | 是否啟用 Thread Pool |
2 | loose_thread_pool_oversubscribe | Yes | 每個 Thread Group 在出現 Stall Thread 的情況下可以額外同時執行(active)的線程個數;線程池最多可以同時執行(active)的線程數 =(thread_pool_oversubscribe + 1)* thread_pool_size;建議 >=3 |
3 | tloose_thread_pool_size | Yes (RDS) | Thread Pool 中分組(Thread Group)的個數,建議設置為實例規格 CPU 個數 |
4 | thread_pool_max_threads | No | Thread Pool 中最大線程數量,到達這個數量後,無法再創建新的 thread |
5 | thread_pool_idle_timeout | No | Thread Group 中空閒的線程退出前的空閒等待(idle)時間 |
6 | thread_pool_stall_limit | No | Timer Thread 檢查 “Stall” 情況的間隔,避免一個 thread 長時間霸佔一個 thread group |
那麼面對存在長時間執行的查詢,除了優化 SQL 降低執行成本外(有時不具有可操作性,當然如果該查詢對數據時效性不敏感可以考慮轉移到只讀實例上執行),是否還有其他招數可用? 請看下一招“限流”。
2. 限流
如果“海嘯”來的異常猛烈,並且在“海嘯”中能夠定義出一批帶有同樣特徵的查詢,比如 Redis 緩存被擊穿,大量相似重複查詢打到 DB 層,或者如上例 Thread Pool 中的長時間執行的查詢,那麼在業務支持/允許降級的情況下我們可以通過對這批請求採取限流的方式來“保命”。
相對 thread pool 這種對“海嘯” 全方位覆蓋的應對機制,限流更像是集力量於一點的定向打擊。
2.1 Statement Concurrency Control
對於 RDS for MySQL 8.0 和 PolarDB for MySQL,我們可以通過“語句併發控制”(Statement Concurrency Control)特性來實現針對指定語句的限流。
比如發現下面的查詢在高併發的場景下拖累了整個實例的性能,和業務核實,業務可以接受該查詢被限流。
# 高成本慢查詢
select count(*)
from jacky.mytab
where cid = 90363
or uid = ???
Copy
確定 SQL 語句後,可以根據語句特徵來調用 dbms_ccl 工具包創建規則進行限流。
# 增加限流規則,限制最多 1 個併發執行
call dbms_ccl.add_ccl_rule('select','jacky','mytab',1,'cid=;uid=');
# 顯示當前的限流規則
call dbms_ccl.show_ccl_rule();
+------+--------+--------+-------+-------+-------+-------------------+---------+---------+----------+-----------+
| ID | TYPE | SCHEMA | TABLE | STATE | ORDER | CONCURRENCY_COUNT | MATCHED | RUNNING | WAITTING | KEYWORDS |
+------+--------+--------+-------+-------+-------+-------------------+---------+---------+----------+-----------+
| 2 | SELECT | jacky | mytab | Y | N | 1 | 116 | 1 | 26 | cid=;uid= |
+------+--------+--------+-------+-------+-------+-------------------+---------+---------+----------+-----------+
Copy
限流規則添加後,超過定義的併發度的 SQL 請求在 "Concurrency control waiting" 狀態
限流前後對比,可以看到限流後 CPU 使用率從 100% 降低到 50% 左右,有效恢復業務可用性。
2.2 DAS 限流
對於 RDS for MySQL 5.6 和 5.7 ,控制檯的 CloudDBA 功能直接集成了 SQL 限流功能。
我們來看一個真實生活中的例子,某客戶在業務高峰期出現大量的集中請求,導致高配實例 CPU 完全打滿,由於實例響應極其緩慢,能採集到的監控數據顯示當時 活動會話達到 14700+ 。
在業務層反覆調整無法恢復的情況下 在 2020.3.24 21:35 通過設置 SQL 限流恢復了業務可用性。
RDS 實例會話情況
RDS 實例 CPU 使用率情況
3. 禦敵於外
上面介紹的都是數據庫層面的應對之策,那麼是否我們一定要被動的在數據庫層面“兵來將擋”呢?有沒有主動“禦敵於外”的辦法呢?
3.1 名詞解釋
名稱 | 說明 | |
---|---|---|
1 | 短連接 | 通信雙方有數據交互時,就建立一個 TCP 連接,數據發送完成後,則斷開此 TCP 連接;通常基於 PHP 語言的應用採用短連接方式訪問數據庫 |
2 | 長連接 | 通信雙方有數據交互時,首先嚐試複用已有空閒 TCP 連接,如果沒有空閒 TCP 連接則嘗試創建新連接;數據發送完成後,通常不斷開此 TCP 連接以便後續複用;通常基於 Java 語言的應用採用長連接方式訪問數據庫 |
3 | syn queue | 用於存儲接收到的 syn 請求的連接 socket 隊列,TCP 協議棧接收到 syn 後系統內核自動回覆 syn,ack 同時將 syn 代表的連接放入到 syn queue 隊列中,並管理是否需要重傳 syn,ack;其長度由 tcp_max_syn_backlog (或 somaxconn)Linux 內核參數確定 |
4 | accept queue | 用於存儲完成 TCP 三次握手的連接 socket 隊列,當 MySQL 調用 accept() 時從該隊列取走一個 socket 處理,其長度由 應用設置的 backlog 參數和內核參數 somaxconn 的較小值決定 |
5 | ListenOverFlow | 由於 syn queue 已經打滿,新收到的 syn 請求不被處理而丟棄的場景發生數量 |
6 | ListenDrops | 由於 accept queue 已經打滿,完成 TCP 三次握手的連接不被處理而丟棄的場景發生數量 |
3.2 短連接優化
首先我們來看看一個普通的 SQL 請求是如何被從應用通過網絡發送給 DB 層進而得到處理的。
仔細看一下上述時序圖,就會發現如果應用和數據庫之間在沒有可用的網絡連接情況下,需要首先建立起一條基於 TCP/IP 協議棧的 MySQL 網絡連接才能夠將 SQL 請求發送給數據庫實例並獲取到處理的結果集。
在應用採用短連接機制(比如基於 PHP 語言開發的應用)的情況下,每個 SQL/Query 都需要和數據庫實例創建一個 TCP 網絡連接,需要消耗數據庫實例(和其所在物理機)的 CPU 資源。
在“海嘯”的場景下,採用短連接機制的應用會保持很高的新連接創建速率(CPS,大於等於 QPS),這樣在高負載(QPS) 的基礎上進一步消耗數據庫實例的 CPU 資源,拉高 CPU 使用率,降低 CPU 使用效率,進入惡性循環容易觸發數據庫雪崩式崩潰。
在 CPU 資源緊張的情況下會出現大量連接請求積壓無法處理而觸發 ListenOverFlow 和 ListenDrops 情況出現。
這裡我們看一個真實世界中的例子。
客戶在 13:30 將應用從長連接模式調整為短連接模式,由於短連接模式的高併發新建連接請求速率(CPS - 每秒新建連接數),修改後實例 CPU 使用率總體上升 25+% 左右,業務側出現大量連接失敗錯誤並感知 RDS 實例響應緩慢。
部分 CPU 被完全打滿,無法滿足處理高連接請求的需求而出現 ListenOverFlow / ListenDrops。
線程池 Thread Pool 是數據庫層對該場景較好的解決方案,而啟用了數據庫獨立代理(RDS for MySQL 讀寫分離地址 和 PolarDB for MySQL 的集群地址)的實例還可以選擇啟用“短連接優化”的鏈路層解決方案。
當應用斷開連接後,數據庫獨享代理會判斷之前的連接是否為空閒(idle)連接,如果是空閒連接,代理會將代理與數據庫之間的連接保留在連接池內一段時間(僅釋放應用與代理之間的連接)。
在保留連接的這段時間內如果應用發起新連接,代理會直接從連接池裡使用保留的連接,從而減少與數據庫建立連接的開銷。
官方文檔:短連接優化