最近遇到一個數據查詢接口性能低下的問題,需要進行優化,從解決方案的調研與梳理到方案的確定,再到最終方案的執行落地,我將優化的過程完整的記錄了下來,與大家分享學習,希望能給大家有所幫助和啟發。
PS:以下我所描述的所有表和字段都是虛擬的。
問題產生
我們有很多上報的數據,數據量比較大。這些數據保存在 report_info 表中的,表結構如下所示:

上面的結構中我用 other_fields 來統一表示其他業務字段。
上報的數據,我們需要在頁面上進行查詢,所以我們對 report_info 表有一個簡單的查詢,有若干個查詢條件。
查詢語句很簡單,一個單表查詢即可實現,對查詢條件中的字段根據實際情況增加一些索引進行優化,6百萬的數據量分頁查詢的時延大概在 1s 左右,基本上可以接受。
隨着業務的發展,我們需要對上報的數據進行處理,例如進行 process1 和 process2 的處理,並且需要將處理的結果保存起來,包括處理是成功還是失敗,失敗的原因。
所以我們又新建了兩個關聯表 report_handle1 和 report_handle2。
report_handle1 表結構如下所示:

report_handle2 的結構類似,都包含 is_success 和 fail_reason 字段,只是 other_fields 不同。
PS:這里只是討論優化的過程,具體的表結構設計不作為本篇文章的討論范圍。
以下將 report_handle1 和 report_handle2 簡稱為 h1 和 h2。
現在我們需要將流程1和流程2的處理結果在頁面上展示出來,那將原來的語句做一個修改,根據 report_uuid 與 h1 和 h2 進行 left join,將 h1 和 h2 表中的結果返回,如下所示:

目前這樣也沒有問題,查詢的性能和原來的單表查詢沒有太大的變化。
隨着業務的發展我們又需要查詢流程1(或流程2)中執行成功(或失敗)的記錄,即頁面上需要增加兩個查詢字段,分別對應 h1 和 h2 中 is_success 字段。
這下我們的查詢語句就變成了這樣:

原來的查詢語句雖然也對 h1 和 h2 表進行了關聯查詢,但是都會走索引,而且查詢條件也都是針對 report_info 表,所以性能不會有太大的問題。
但是現在要將 h1 和 h2 中的 is_success 字段作為查詢條件,那就相當於對三張表做了關聯查詢,然后再對三張表中的字段進行過濾,並且 h1 和 h2 中的 is_success 字段區分度很低,只有 0 和 1 兩種值,所以加索引意義也不大。
上述的語句在線上執行超時,因為三張表的數據量都是百萬級的,所以必須要重新設計查詢方案。
優化方案
出現了問題,那就需要找優化的方案,通過自己思考和咨詢其他小伙伴,一共收集到很多優化的方案,下面我列舉一些:
一、冗余查詢字段
我首先想到的就是在 report_info 表中冗余兩個查詢字段,分別對應 h1 和 h2 中的 is_success 字段,這樣就將原來的關聯查詢轉換成了單表查詢,優點肯定是性能上的飛躍提升,缺點是要對現有的代碼進行修改,兩個流程處理完之后要更新 report_info 表中的冗余字段的值,但是更新不是太大,可以接受。
二、使用數據倉庫
第二種方案是將原來的數據同步到數據倉庫中,在數據倉庫中做查詢,不過這種方案涉及到的改動比較大,而且我也沒有研究過數據倉庫的玩法,存在一定的改造成本。
三、分庫分表
第三種方案是對現有的庫表設計進行拆分,但是目前的數據量還不至於要進行拆分,而且分庫分表依據什么進行拆分還需要根據業務進行分析,拆分后又會引入新的問題,代碼復雜度肯定會升高,雖然現在已經有很多分庫分表的中間件,但是不到萬不得已還是不要使用分庫分表。
四、使用中間表
第四種方案是使用數據庫同步機制將數據同步到一個中間表,然后直接查詢該中間表。該方案顯得很笨,但是
五、使用 es 或者 solr
第五種方案,將數據保存到 es 或者 solr 等搜索引擎中,把數據拍平,通過搜索引擎進行篩選項的查詢,拿到結果后,再結合 mysql 查詢出最終結果返回給前端頁面。
通過分析各種方案的復雜情況,對現有系統的調整,以及引入的新框架或者服務等各個方面,最簡單,對現有代碼改動最小的就是第一種方案。
優化過程
確定了優化的方案后,我們就可以進行實際的改造了。
一、新增冗余字段
首先我們在 report_info 表中新加兩個冗余字段,例如 h1_success 和 h2_success ,修改后的 report_info 表結構如下所示:

二、修改處理邏輯
接着我們需要將原來的處理邏輯進行修改,要再原來的流程1和流程2處理完之后,根據 report_uuid 去更新冗余字段的值。
三、修改查詢語句
最后我們只需要將我們原來的關聯查詢的語句修改為單表查詢即可,如下所示:

修改后,現在的查詢性能和原來的沒有太大的變化,時延可以接受。
歷史數據訂正
優化方案是確定了,並且代碼上也進行了調整,但是新加的冗余字段對於歷史數據是沒有值的,所以需要從關聯表中把冗余字段的值更新到 report_info 表中去。
最簡單的就是執行一個 update 語句,如下所示:

咋一看上去好像沒什么問題,但是仔細想一想你就會發現如果在線上執行這樣一條語句,將會造成怎樣災難性的后果。
對於線上數據需要進行訂正的,可以通過代碼分批次修正,為什么要分批次修正,主要是因為一次性更新涉及到的記錄數太多很可能把db搞死。
比如線上有幾百萬的歷史數據需要進行訂正,如果一次性更新會產生過大的事務,可能會把db搞死。具體的可能會對 slave 造成影響,也可能將 innodb 的系統表空間撐得很大。
而 undo 是按照 segment 為基礎單元申請 buffer 空間的,如果一個或幾個 segment 能夠滿足事務的大小,就會復用,所以小事務會循環利用已有的 segment,但是如果已有的 segment 不能滿足當前事務的大小就需要重新申請新的 segment,所以大的事務會申請超級大的 buffer,最終就會導致 innodb 的系統表空間被撐得很大。
所以如果我們要對歷史數據進行訂正的話,應該避免一次性更新太多的數據,咨詢了一個 dba 朋友,他建議每次更新 2000 條左右的記錄。
數據修訂程序
確定了數據修訂的方案后,我們就可以着手來寫我們的數據修訂的程序了。
首先我們確定了需要分批次進行訂正,那么我們可以像分頁查詢數據一樣,定義總記錄數,頁數,以及每頁的大小,根據主鍵 id 來分批次,然后通過一個循環來執行每一批中的數據訂正即可。
定義下面這樣一個類來執行具體的數據訂正,如下列代碼所示:

在 doFix 方法中我們只需要執行下面的 sql 即可:

存在的問題
上面的訂正語句存在的一個問題是一次更新了兩個字段,這樣需要一次關聯兩張表,可能會比較慢,事務會更大,我們能否將這條大語句拆分成兩個更小的語句呢。答案是可以的,如下所示:

這樣就將一條大的 update 語句拆成了兩條相對小的語句,然后我們通過兩個線程去執行效果應該會好很多。
優化程序
這樣的話我們就需要對我們的程序進行優化,將原來的類修改為一個 Runnable,如下所示:

然后我們創建兩個 AbstractDataFixer 的實例,分別實現 doFix 的方法,例如 Handle1DataFixer 的 doFix 方法調用第一條 update 語句,Handle2DataFixer 的 doFix 方法調用第二條 update 語句。
這樣我們就可以用兩個線程來同步執行兩個字段的更新操作,事務也比較小,更新應該會比較快。
繼續優化
到這里可能有的同學覺得應該差不多了,但是通過兩個線程來執行的話,會不會有問題呢?假設 id 的范圍是 1 到 1000 那么兩個線程在 id 從小到大執行的過程中,可能會 “相遇” 多次,當對同一個 id 執行 update 操作時是會對這行記錄進行鎖定的,這時兩個線程就會存在競爭的關系,一個線程在鎖定了行記錄的時候,另一個線程想更新這行記錄就只能等待。
那有沒有好的辦法減少兩個線程之間的競爭關系呢,答案肯定是有的,一個簡單的方法就是,讓一個線程從小到大更新,另一個線程從大到小更新,這樣的話,兩個線程至多只會 “相遇” 一次,這樣就能大大降低競爭關系。
分析清楚了具體的原理之后,實現起來就很簡單了,只需要在原來的代碼中增加一個 reverse 屬性,表示是否需要進行方向更新,即 id 從大到小進行更新,修改后的代碼如下:

然后要做的跟之前的一樣,定義兩個 Fixer 實現類,分別執行 handle1 的 update 語句和 handle2 的 update 語句。
至此整個優化的過程已經全部分析結束了。