本系列文章主要是本人在游戲服務端開發過程中,遇到的一些不那么為人熟知但我又覺得比較重要的MySQL知識的介紹。希望里面淺薄的文字能為了提供一點點的幫助。
慢查詢
從我有限的從業經驗來看,大多數數據庫導致的游戲服務器問題,十有八九慢查詢。比如:SQL寫的亂七八糟導致的慢查詢、表數據太多導致的慢查詢、沒有使用索引導致的慢查詢、突然大并發量導致的慢查詢等等。數據庫一出現以上這些問題,開發團隊免不了要背大鍋。所以我認為數據庫慢查詢應該成為每個服務器開發人員必知必會的知識點。
慢查詢事故分享:
這里我分享一下我參與的第一次慢查詢定位和優化經歷,這是一次非常慘痛的經歷:
當時我所在的項目組上線了一個零點秒殺的活動,秒殺活動開啟的第一個晚上有大量玩家投訴卡死在活動界面。我們當時按直覺判斷肯定是太多人秒殺導致游戲服務器壓力過大處理不過來,然后就決定發公告宣布延遲活動和重啟游戲服務器。重啟服務器之后,玩家再次進行秒殺問題還是如此。這時有個同事說,壓力應該主要在MySQL服務器上應該重啟MySQL服務器。然后我們又又發公告宣布延遲活動 + 重啟游戲服務器 + 重啟MySQL服務器。可能是因為大部分玩家都放棄的原因吧,這次問題終于解決。
后期我們進行了復盤發現問題是:游戲服務器后臺會在零點執行一個定時統計任務,其中包含許多復雜的SQL語句(各種join、全表查詢等等)。剛好這些SQL所操作的表又和秒殺活動需要操作的表完全重合,最終導致數據庫大量慢查詢、MySQL服務器假死、線上玩家卡在秒殺界面。而出問題那會項目組成員甚至不知道慢查詢是個什么東西,只是單純認為是并發量太大導致服務器壓力太大負載不下,然后在沒有準確定位根本原因的情況下就天真地用重啟游戲服務器和MySQL服務器來解決問題。下圖說明了但是MySQL服務器面臨的兩方面的壓力:
第一次我們只重啟游戲服務器,非但沒有減輕MySQL服務器的壓力,反而導致其壓力變得更大了。這是因為:
- 游戲服務器重啟有大量在線玩家被強制下線,這些玩家的數據需要刷進數據庫,加重服務器壓力(本來上面那些慢查詢還沒執行完,現在又來新的了);
- 游戲服務器重啟之后,零點執行的那個定時統計任務會自主判斷是否要繼續執行(由于上一次重啟游戲服務器導致今天的任務中斷,所以判斷結果是還要執行一遍),這樣對于MySQL而言就算是同時執行兩個這個定時統計任務了。
在知道慢查詢日志這個東西之后,我們還嘗試去線上找這個日志來看看,但由于數據庫默認不開啟慢查詢日志的打印,結果可想而知。不過我們還是模擬了當時線上的情況重新測試一遍(這次開了慢查詢),表現和當時線上的狀況一致。最終才將這個問題全部復盤。
有上面這個案例打底之后,接下就開始慢查詢的介紹
慢查詢是指那些執行時間過長且涉及行數過多的語句(一般超過配置的慢查詢時間和涉及行數才算)。慢查詢并不是一定是SELECT語句,其實所有的DML類型的SQL(INSERT
、UPDATE
、REPLACE
、DELETE
)都可能是慢查詢。根據官網的說明,慢查詢日志的監聽和打印可能會是一個耗時任務,所以MySQL服務器是默認不開啟的慢查詢。
慢查詢配置(官方文檔):
參數名 | 作用 | 默認值 | 備注 |
---|---|---|---|
slow_query_log |
是否開啟slow log慢日志記錄 | OFF | 默認關閉慢查詢功能 |
slow_query_log_file |
slow log日志的路徑 | host_name-slow.log | 注意:MySQL服務器要對指定的目錄有寫權限 |
long_query_time |
當SQL語句執行時間超過該配置時間則算是慢查詢,如果有開啟慢查詢日志的話就會將慢查詢SQL記錄到慢查詢日志中。 | 10 | 該值的取值范圍是0-10,最小精度單位是微秒(比如可以配成0.001),如果該值配成0那就是所有SQL都是慢查詢。 |
min_examined_row_limit |
當一個SQL語句掃描行數小于該值時,不會計入到慢查詢中 | 0 | 可以屏蔽掉一些偶發性干擾的慢查詢(比如一句十分簡單且用了索引的select語句也會因為抖動或其他因素變為慢查詢,而實際上是沒問題的) |
log_queries_not_using_indexes |
不走索引的查詢是否被記錄 | OFF | 不走索引的查詢是否被記錄,即默認是不走索引就不記錄慢查詢日志 |
log_throttle_queries_not_using_indexes |
不走索引被記錄的語句條數閾值 | 0 | 當一分鐘不走索引慢查詢記錄數據超過該值就不再記錄,每分鐘清0。 |
log_slow_admin_statements |
對服務器管理語句是否進行記錄 | OFF | 管理語句有:ALTER TABLE , ANALYZE TABLE , CHECK TABLE 等等,默認不記錄 |
log_output |
指定慢查詢日志的打印目的地 | FILE | 該值可選項有FILE ,TABLE ,NONE (低版本可能沒有TABLE 這個選項),可以是一個也可以包含多個(多個的話就用逗號分隔,如:TABLE , FILE )。 選擇TABLE 記錄到mysql庫中的 slow_log 表中; 選擇FILE 記錄到日志文件;NONE 禁用日志記錄,如果存在NONE 則其他均無效。 |
以上可以通過SHOW VARIABLES LIKE '%***%'
這種形式進行查詢,并且可以通過SET GLOBAL ***=?
來進行動態修改。這些變量均是全局變量,對MySQL所有庫所有用戶生效。
查詢配置示例:
我在上面介紹information_schema庫的時候就有說過,能用SHOW VARIABLES LIKE '%***%'
查詢到的東西基本上也可以在information_schema庫查到。而且上面這些配置都是全局的,所以通過information_schema庫的GLOBAL_VARIABLES
表也能查到(某些版本的MySQL可能是在performance_schema庫中,可以通過:SELECT table_schema FROM information_schema.TABLES WHERE table_name = 'GLOBAL_VARIABLES'
來查詢到底在哪個庫),如下圖:
慢查詢的判斷流程:
這里想強調的是:MySQL服務器是否打印慢查詢日志,不是只通過SQL語句的執行速度來判斷的,還和上面這些參數有關(源碼請戳)。這里上一張判斷流程圖說明一下:
判斷是否要打印慢查詢日志的方法在log.cc中:
日志格式(官方文檔):
我把long_query_time設置為0(SET GLOBAL long_query_time=0
),打印格式設置為FILE和TABLE兩種模式(SET GLOBAL log_output='FILE,TABLE'
)。下面截圖分別是慢查詢日志和慢查詢表的數據:
這里說明一下幾個關鍵的字段:
-
Query_time
:該SQL語句執行時長(秒),精確到微秒; -
Lock_time
:該SQL持有鎖的時長(秒),精確到微秒。單純的select語句不會持有鎖(這時該值為0); -
Rows_sent
:發送給客戶端的數據行數 -
Rows_examined
:Server層掃描的數據行數
注意不管是慢查詢日志還是慢查詢表,記錄的信息都是一條一條的慢查詢SQL語句。這樣的形式直接進行分析會非常麻煩(比如想知道某一條慢查詢具體的耗時、鎖持有時間,或者對某一張表的慢查詢分析)。這里介紹一下業界比較常用的慢查詢日志分析工具:
- mysqldumpslow:官方慢查詢分析工具,會分析指定的慢查詢日志并統計出同一慢查詢SQL出現的次數、總耗時等信息(如下圖),操作十分簡單(Windows的需要安裝perl才行,使用說明)。
- pt-query-digest:業界常用的慢查詢日志分析工具。對分表分庫的情況有很好的支持;定制化功能很多;并且該工具不止能分析慢查詢日志,還能分析一般SQL日志(general_log)和binlog日志等。但是使用門檻比較高,如果應用在線上環境需要一定的上手時間。
- Anemometer:圖形化界面,需要在pt-query-digest的基礎上才能工作。搭建繁瑣
業界對慢查詢的通用的處理流程:
- 如果是使用云服務器,可以在MySQL云服務器上設置慢查詢日志分析and告警,做到異常就郵件、釘釘甚至是電話告警,云服務廠商都有這個功能(阿里慢查詢告警設置);
- 每周甚至是每天匯總一份慢查詢的統計自動發郵件給DBA、運維和開發同學;
- 對有問題的慢查詢做分析(有一些慢查詢是偶發性的,可以忽略),然后做歸零處理。
如何避免和解決慢查詢:
如何避免:
當數據庫壓力過大時(CPU和I/O),更容易出現慢查詢。本來一些正常的操作也會因為阻塞而變成慢查詢。有效降低數據庫壓力就是一個標本兼治的方案,慢查詢的避免倒是順帶的。降低數據庫壓力有很多種,這里介紹下我認為比較有效的幾種給大家參考一下:
-
使用緩存降低數據庫壓力:對于游戲服務器來說,更新和讀取數據的頻率是非常驚人的(很多炸服的游戲就是栽在這上面)。很多時候玩家一個操作可能涉及大量數據的更新或者是查詢(比如:玩家登錄需要加載大量玩家數據;戰斗結束需要發放獎品和更新戰斗記錄等等)。如果這些操作都直接落到數據庫上面,那么數據庫的壓力將十分巨大。
通過引用緩存中間件(MongoDB、Redis)和游戲服務器進程緩存來合并玩家操作產生的數據庫SQL、延遲回寫數據庫(延遲回寫這個不同項目可能有不同要求),可以很大程度上改善這種情況。并且引入緩存之后,玩家讀取相關數據也會命中緩存而不是直接打到數據庫,這一點對降低服務器壓力幫助是非常有效的。如果不想引入緩存中間件,那直接使用進程緩存也是可以的,不過要注意內存空間和釋放的問題。許多大廠商對他們的產品(自研或代理)在擊穿數據庫這方面(即服務器繞過緩存直接訪問數據庫加載玩家數據)都有嚴格的要求和限制。他們都會極力避免代碼中出現直接操作數據庫的行為,最最重要的原因還是為了防止流量洪峰對數據庫的直接沖擊(即使是MySQL自己也有內置的采用LRU策略的buff pool內存緩存)。
注意!雖然引入緩存有好處,但是也會有一定的風險。增加緩存不僅會增加系統復雜度,同時也意味著數據冗余,這很可能導致數據不一致。事實也是如此,大部分的回檔、數據錯亂等問題都是緩存部分代碼有bug導致的,所以決定引入緩存之前要預留好充足的測試和壓測的時間。
- 多服務器分攤壓力:上面關于緩存這一點更多是從客戶端(相對于數據庫)來解決問題,同時我們也可以通過增強數據庫服務器性能這一點來實現。比如使用主從模式下的讀寫分離、分庫分表等方法來將壓力分攤到多臺數據庫服務器上。
-
避免在線上跑統計:一般來說,游戲業務功能(即玩家直接交互的玩法)不會有過于復雜的SQL出現,無非就是把一個玩家的數據select出來、update一些之類的而且都會帶上where條件,join這些操作基本沒有。
復雜SQL主要出現在一些統計work(比如:統計玩家付費和在線時長的關系)上。這些統計工作一般都會在夜深人靜的時候偷偷在線上服務器跑,然后第二天把數據給到策劃。但是總有一些夜深人不靜的時候,比如上面我介紹的零點秒殺活動。一旦高并發+復雜SQL,出問題就是分分鐘的事。
這里給的建議是——盡量不要在線上環境跑這些統計work,把這個工作放到冷備庫去做(當然這會有延遲),如果這一點做不到,那就盡量把一條大SQL拆分成多個簡單的SQL分批執行,還是做不到的話那我建議跑work的時間定在凌晨3、4點。
- 寫SQL的時候使用explain分析一下:寫復雜SQL的時候使用explain分析SQL看看索引使用、復雜度等情況是非常必要的。根據分析結果和實際功能情況,該加索引的加索引,該拆分就拆分。如果不得不做一些復雜操作需要復雜的SQL,還是強烈建議將大SQL拆分成多個簡單的SQL分批執行。不要對自己寫SQL的本事盲目自信,要明確知道數據庫給你的反饋。
如何解決:
上面說了如何預防慢查詢,這里說下如果出現慢查詢怎么辦。
- 偶發慢SQL:首先要說明一下,不一定所有的慢查詢都是異常能夠優化的。有些時候MySQL服務器內部的一些動作(比如臟頁落盤),會導致MySQL有抖動,這時是可能導致原本正常的SQL變成慢查詢。只要SQL不復雜且有用索引、這條SQL只在慢查詢中出現過一次并且MySQL當時的表現良好(比如當時的IO、CPU監控和慢查詢數量),基本就能確認是這種情況。這種情況可以不用理會。
-
SQL一直執行:這種情況一般是死鎖導致的,這里可以使用
show processlist
查看MySQL線程池的狀態和正在執行的SQL語句:
image.png
當然你也可以從information_schema庫里的PROCESSLIST表找到這些信息(有些版本在performance_schema庫中):
image.png
其中Time
字段代表線程執行在某個狀態下的時長(這張表其他字段含義請戳)。當發現一條SQL異常準備,就可以通過kill命令和線程ID來kill掉這個線程,這樣有問題的命令將會停止執行,但是要注意客戶端是否有重試邏輯。注意如果是使用線程池方案的項目組,可以通過設置thread_pool_stall_limit
參數來實現SQL語句最大執行時長的限制(該值僅對使用線程池方案的MySQL生效),至于線程池能否可以kill線程我還沒試過,有興趣的同學可以嘗試一下。