場景:
有一個訂單功能,裏面的主表有幾千萬數據量,加上關聯表,數據量達到上億。
我們嘗試了優化表結構、業務代碼、索引、SQL 語句等辦法來提高響應速度,但查詢速度還是很慢。
一、什麼是冷熱分離
最終,我們決定採用一個性價比高的解決方案,在處理數據時,我們將數據庫分成了冷庫和熱庫 2 個庫,不常用數據放冷庫,常用數據放熱庫。
這就是“冷熱分離”。
二、什麼情況下使用冷熱分離?
數據走到終態後,只有讀沒有寫的需求,比如訂單完結狀態;
用戶能接受新舊數據分開查詢,比如有些網站默認只讓查詢3個月內的訂單,如果你要查詢3個月前的訂單,還需要訪問另外的單獨頁面。
三、冷熱分離實現思路
在實際操作過程中,冷熱分離整體實現思路如下:
1、如何判斷一個數據到底是冷數據還是熱數據?
2、如何觸發冷熱數據分離?
3、如何實現冷熱數據分離?
4、如何使用冷熱數據?
接下來,我們針對以上4個問題進行詳細的分析。
(一)如何判斷一個數據到底是冷數據還是熱數據?
一般而言,在判斷一個數據到底是冷數據還是熱數據時,我們主要採用主表裏的 1 個或多個字段組合的方式作爲區分標識。這個字段可以是時間維度,比如“下單時間”這個字段,我們可以把 3 個月前的訂單數據當作冷數據,3 個月內的當作熱數據。
這個字段也可以是狀態維度,比如根據“訂單狀態”字段來區分,已完結的訂單當作冷數據,未完結的訂單當作熱數據。
我們還可以採用組合字段的方式來區分,比如我們把下單時間 > 3 個月且狀態爲“已完結”的訂單標識爲冷數據,其他的當作熱數據。
關於判斷冷熱數據的邏輯有 2 個注意要點必須說明:
- 如果一個數據被標識爲冷數據,業務代碼不會再對它進行寫操作;
- 不會同時存在讀冷/熱數據的需求。
(二)如何觸發冷熱數據分離?
瞭解了冷熱數據的判斷邏輯後,我們就要開始考慮如何觸發冷熱數據分離了。一般來說,冷熱數據分離的觸發邏輯分3種。
1、直接修改業務代碼,每次修改數據時觸發冷熱分離(比如每次更新了訂單的狀態,就去觸發這個邏輯);
2、如果不想修改原來業務代碼,可通過監聽數據庫變更日誌binlog的方式來觸發(數據庫觸發器也可);
3、通過定時掃描數據的方式來觸發(數據庫定時任務或通過程序定時任務來觸發);
修改寫操作的業務代碼
優點
1、代碼靈活可控。2、保證實時性
缺點
1、不能按照時間區分冷熱,當數據變爲冷數據,期間可能沒有進行任何操作。
2、需要修改所有數據寫操作的代碼。
監聽數據庫變更日誌
優點:1、與業務代碼解耦。2、可以做到低延時。
缺點:1、無法按照時間區分冷熱,當數據變爲冷數據,期間沒有進行任何操作。2、需要考慮數據併發操作的問題,即業務代碼與冷熱變更代碼同時操作同一數據。
定時掃描數據庫
優點:1、與業務代碼解耦。2、可以覆蓋根據時間區分冷熱數據的場景。
缺點:1、不能做到實時性
根據內容對比,我們可以得出每種出發邏輯的建議場景。
修改寫操作的業務代碼:建議在業務代碼比較簡單,並且不按照時間區分冷熱數據時使用。
監聽數據庫變更日誌:建議在業務代碼比較複雜,不能隨意變更,並且不按照時間區分冷熱數據時使用。
定時掃描數據庫:建議在按照時間區分冷熱數據時使用。
(三)如何分離冷熱數據?
分離冷熱數據的基本邏輯如下:
1、判斷數據是冷是熱;
2、將要分離的數據插入冷數據中;
3、再從熱數據庫中刪除分離的數據。
這個邏輯看起來簡單,而實際做方案時,以下三點我們都得考慮在內,這一點就不簡單了。
(1)一致性:同時修改過個數據庫,如何保證數據的一致性
這裏提到的一致性要求,指我們如何保證任何一步出錯後數據還是一致的,解決方案爲只要保證每一步都可以重試且操作都有冪等性就行,具體邏輯分爲四步。
在熱數據庫中,給要搬的數據加個標識: flag=1。(1代表冷數據,0代表熱數據)
找出所有待搬的數據(flag=1):這步是爲了確保前面有些線程因爲部分原因失敗,出現有些待搬的數據沒有搬的情況。
在冷數據庫中保存一份數據,但在保存邏輯中需加個判斷以此保證冪等性(這裏需要用事務包圍起來),通俗點說就是假如我們保存的數據在冷數據庫已經存在了,也要確保這個邏輯可以繼續進行。
從熱數據庫中刪除對應的數據。
(2)數據量大:假設數據量大,一次性處理不完,該怎麼辦?是否需要使用批量處理?
前面說的3種冷熱分離的觸發邏輯,前 2 種基本不會出現數據量大的問題,因爲每次只需要操作那一瞬間變更的數據,但如果採用定時掃描的邏輯就需要考慮數據量這個問題了。
這個實現邏輯也很簡單,在搬數據的地方我們加個批量邏輯就可以了。爲方便理解,我們來看一個示例。
假設我們每次可以搬 50 條數據:
a. 在熱數據庫中給要搬的數據加個標識:flag=1;
b. 找出前 50 條待搬的數據(flag=1);
c. 在冷數據庫中保存一份數據;
d. 從熱數據庫中刪除對應的數據;
e. 循環執行 b。
(3)併發性:假設數據量大到要分到多個地方並行處理,該怎麼辦?
在定時搬運冷熱數據的場景裏(比如每天),假設每天處理的數據量大到連單線程批量處理都來不及,我們該怎麼辦?這時我們就可以開多個線程併發處理了。(雖然大部分情況下多線程較快,但我曾碰到過這種情況:當單線程 batch size 到一定數值時效率特別高,比多線程任何 batch size 都快。所以,需要留意:如果遇到多線程速度不快,我們就考慮控制單線程。)
當多線程同時搬運冷熱數據,我們需要考慮如下實現邏輯。
第 1 步:如何啓動多線程?
因爲我們採用的是定時器觸發邏輯,這種觸發邏輯性價比最高的方式是設置多個定時器,並讓每個定時器之間的間隔短一些,然後每次定時啓動一個線程就開始搬運數據。
還有一個比較合適的方式是自建一個線程池,然後定時觸發後面的操作:先計算待搬動的熱數據的數量,再計算要同時啓動的線程數,如果大於線程池的數量就取線程池的線程數,假設這個數量爲 N,最後循環 N 次啓動線程池的線程搬運冷熱數據。
第 2 步:某線程宣佈某個數據正在操作,其他線程不要動(鎖)。
關於這個邏輯,我們需要考慮 3 個特性。
獲取鎖的原子性: 當一個線程發現某個待處理的數據沒有加鎖,然後給它加鎖,這 2 步操作必須是原子性的,即要麼一起成功,要麼一起失敗。實際操作爲先在表中加上 LockThread 和 LockTime 兩個字段,然後通過一條 SQL 語句找出待遷移的未加鎖或鎖超時的數據,再更新 LockThread=當前線程,LockTime=當前時間,最後利用 MySQL 的更新鎖機制實現原子性。
獲取鎖必須與開始處理保證一致性: 當前線程開始處理這條數據時,需要再次檢查下操作的數據是否由當前線程鎖定成功,實際操作爲再次查詢一下 LockThread= 當前線程的數據,再處理查詢出來的數據。
釋放鎖必須與處理完成保證一致性: 當前線程處理完數據後,必須保證鎖釋放出去。
第 3 步:某線程正常處理完後,數據不在熱庫,直接跑到了冷庫,這是正常的邏輯,倒沒有什麼特別需要注意的點。
第 4 步:某線程失敗退出了,結果鎖沒釋放怎麼辦(鎖超時)?
鎖無法釋放: 如果鎖定這個數據的線程異常退出了且來不及釋放鎖,導致其他線程無法處理這個數據,此時該怎麼辦?解決方案爲給鎖設置一個超時時間,如果鎖超時了還未釋放,其他線程可正常處理該數據。
設置超時時間時,我們還應考慮如果正在處理的線程並未退出,因還在處理數據導致了超時,此時又該怎麼辦?解決方案爲儘量給超時的時間設置成超過處理數據的合理時間,且處理冷熱數據的代碼裏必須保證是冪等性的。
最後,我們還得考慮一個極端情況:如果當前線程還在處理數據,此時正在處理的數據的鎖超時了,另外一個線程把正在處理的數據又進行了加鎖,此時該怎麼辦?我們只需要在每一步加判斷容錯即可,因爲搬運冷熱數據的代碼比較簡單,通過這樣的操作當前線程的處理就不會破壞數據的一致性。
(四)如何使用冷數據
在功能設計的查詢界面上,一般都會有一個選項供我們選擇需要查詢冷數據還是熱數據,如果界面上沒有提供,我們可以直接在業務代碼裏區分。(說明:在判斷是冷數據還是熱數據時,我們必須確保用戶不允許有同時讀冷熱數據的需求。)
歷史數據如何遷移?
一般而言,只要跟持久化層有關的架構方案,我們都需要考慮歷史數據的遷移問題,即如何讓舊架構的歷史數據適用於新的架構?
因爲前面的分離邏輯在考慮失敗重試的場景時,剛好覆蓋了這個問題,所以這個問題的解決方案也很簡單,我們只需要給所有的歷史數據加上標識:flag=1 後,程序就會自動遷移了。
冷熱分離解決方案的不足
冷熱分離解決方案確實能解決寫操作慢和熱數據慢的問題,但不足之處在於:
不足一: 用戶查詢冷數據速度依舊很慢,如果查詢冷數據的用戶比例很低,比如只有 1%,那麼這個方案就沒問題。
不足二: 業務無法再修改冷數據,因爲冷數據多到一定程度時,系統承受不住。
我們可以用另外一種解決方案——查詢分離。
場景二
系統裏有一個工單查詢功能,工單表中存放了幾千萬條數據,且查詢工單表數據時需要關聯十幾個子表,每個子表的數據也是超億條。
面對如此龐大的數據量,加上工單表中有些數據是幾年前的,但是這些數據涉及訴訟問題,需要繼續保持更新,因此無法將這些舊數據封存到別的地方,也就沒法通過前面的冷熱分離方案來解決。
最終採用了查詢分離的解決方案:將更新的數據放在一個數據庫裏,而查詢的數據放在另外一個系統裏。因爲數據的更新都是單表更新,不需要關聯也沒有外鍵,所以更新速度立馬得到提升,數據的查詢則通過一個專門處理大數據量的查詢引擎來解決,也快速地滿足了實際的查詢需求。
那麼什麼是查詢分離?
每次寫數據時保存一份數據到另外的存儲系統裏,用戶查詢數據時直接從另外的存儲系統裏獲取數據。示意圖如下:
何種場景下使用查詢分離?
數據量大;
所有寫數據的請求效率尚可;
查詢數據的請求效率很低;
所有的數據任何時候都可能被修改;
業務希望優化查詢數據的效率;
只有瞭解了查詢分離的真正使用場景,才能在遇到實際問題時採取最正確的解決方案。
查詢分離實現思路
查詢分離解決方案的實現思路如下:
如何觸發查詢分離?
如何實現查詢分離?
查詢數據如何存儲?
查詢數據如何使用?
我們一點點來討論。
(一)如何觸發查詢分離?
這個問題說明的是我們應該在什麼時候保存一份數據到查詢數據中,即什麼時候觸發查詢分離這個動作。
一般來說,查詢分離的觸發邏輯分爲3種。
(1)修改業務代碼:在寫入常規數據後,同步建立查詢數據。
(2)修改業務代碼:在寫入常規數據後,異步建立查詢數據。
(3)監控數據庫日誌:如有數據變更,更新查詢數據。
3種觸發邏輯的優缺點對比如下:
修改業務代碼同步建立查詢數據
優點 1、保證查詢數據的實時性和一致性。2、業務邏輯靈活可控
缺點 1、侵入業務代碼。2、減緩寫操作速度。
修改業務代碼異步建立查詢數據
優點:1、不影響主流程。
缺點:1、查詢數據更新前,用戶可能會查詢到過時的數據。
監控數據庫日誌
優點:1、不影響主流程。2、業務代碼0侵入
缺點:1、查詢數據更新前,用戶可能會查詢到過時的數據。2、架構複雜一些
什麼叫業務靈活邏輯可控?
一般來說,寫業務代碼的人能從業務邏輯中快速判斷出何種情況下更新查詢數據,而監控數據庫日誌的人並不能將全部的數據庫變更分支窮舉,再把所有的可能性關聯到對應的更新查詢數據邏輯中,最終導致任何數據的變更都需要重新建立查詢數據。
什麼叫減緩寫操作速度?
建立查詢數據的一個動作能減緩多少寫操作速度?答案:很多。舉個例子:當你只是簡單更新了訂單的一個標識,本來查詢數據時間只需要 2ms,而在查詢數據時可能會涉及重建(比如使用 ES 查詢數據時會涉及索引、分片、主從備份,其中每個動作又細分爲很多子動作,這些內容後面文章會聊到),這時建立查詢數據的過程可能就需要 1s 了,從 2ms 變成 1s,你說減緩幅度大不大?
查詢數據更新前,用戶可能查詢到過時數據。 這裏我們結合第 2 種觸發邏輯來講,比如某個操作正處於訂單更新狀態,狀態更新時會通過異步更新查詢數據,更新完後訂單才從“待審覈”狀態變爲“已審覈”狀態。假設查詢數據的更新時間需要 1 秒,這 1 秒中如果用戶正在查詢訂單狀態,這時主數據雖然已變爲“已審覈”狀態,但最終查詢的結果還是顯示“待審覈”狀態。
根據前面的對比表,總結每種觸發邏輯的適用場景如下:
觸發邏輯 適用場景
修改業務代碼,同步建立查詢數據 業務代碼比較簡單且對寫操作響應速度要求不高
修改業務代碼,異步建立查詢數據 業務代碼比較簡單且對寫操作響應
監控數據庫日誌 業務代碼比較複雜,或者改動代價太大
雖然我們對業務的代碼比較熟悉,但是在一個真實業務場景中,業務要求每次修改工單請求時響應速度快,我們最終就選擇了修改業務代碼異步建立查詢數據這種觸發邏輯。
(二)如何實現查詢分離?
關於第 2 種觸發方案:修改業務代碼異步建立查詢數據,最基本的實現方式是單獨起一個線程建立查詢數據,不過這種做法會出現如下情況:
寫操作較多且線程太多,最終撐爆JVM。
建查詢數據的線程出錯了,如何自動重試。
多線程併發時,很多併發場景需要解決。
面對以上三種情況,我們該如何處理?此時使用MQ管理這些這些線程即可解決。
MQ的具體操作思路爲每次主數據寫操作請求處理時,都會發一個通知給MQ,MQ收到通知後喚醒一個線程更新查詢數據,示意圖如下:
瞭解了MQ的具體操作思路後,我們還應該考慮以下5大問題。
問題一:MQ如何選型?
不管我們選擇哪個 MQ ,最終都能實現想要的功能,只不過是易用不易用、多寫少寫業務代碼的問題,因此我們從易用性和代碼工作量角度考量。
如果公司已使用 MQ,那選型問題也就不存在了。
問題二:MQ宕機了怎麼辦?
如果 MQ 宕機了,我們只需要保證主流程正常進行,且 MQ 恢復後數據正常處理即可,具體方案分爲三大步驟。
每次寫操作時,在主數據中加個標識:NeedUpdateQueryData=true,這樣發到 MQ 的消息就很簡單,只是一個簡單的信號告知更新數據,並不包含更新的數據 id。
MQ 的消費者獲取信號後,先批量查詢待更新的主數據,然後批量更新查詢數據,更新完後查詢數據的主數據標識 NeedUpdateQueryData 就更新成 false 了。
當然還存在多個消費者同時搬運動作的情況,這就涉及併發性的問題,因此問題與上一篇聊的冷熱分離中的併發性處理邏輯類似,這裏就不細聊了(有興趣的同學可以去看看)。
問題三:更新查詢數據的線程失敗了怎麼辦?
如果更新的線程失敗了,NeedUpdateQueryData 的標識就不會更新,後面的消費者會再次將有 NeedUpdateQueryData 標識的數據拿出來處理。但如果一直失敗,我們可以在主數據中多添加一個嘗試搬運次數,比如每次嘗試搬運時 +1,成功後就清零,以此監控那些嘗試搬運次數過多的數據。
問題四:消息的冪等消費
在編程中,一個冪等操作的特點是多次執行某個操作均與執行一次操作的影響相同。
舉個例子,比如主數據的訂單 A 更新後,我們在查詢數據中插入了 A,可是此時系統出問題了,系統誤以爲查詢數據沒更新,又把訂單 A 插入更新了一次。
所謂冪等,就是不管更新查詢數據的邏輯執行幾次,結果都是我們想要的結果。因此,考慮消費端併發性的問題時,我們需要保證更新查詢數據冪等。
問題五:消息的時序性問題
比如某個訂單 A 更新了 1 次數據變成 A1,線程甲將 A1 的數據搬到查詢數據中。不一會兒,後臺訂單 A 又更新了 1 次數據變成 A2,線程乙也啓動工作,將 A2 的數據搬到查詢數據中。
所謂的時序性就是如果線程甲啓動比乙早,但搬運數據動作比線程乙還晚完成,就有可能出現查詢數據最終變成過期的 A1。如下圖(動作前面的序號代表實際動作的先後順序):
此時解決方案爲主數據每次更新時,都更新上次更新時間 last_update_time,然後每個線程更新查詢數據後,檢查當前訂單 A 的 last_update_time 是否跟線程剛開始獲得的時間一樣,且 NeedUpdateQueryData 是否等於 false,如果都滿足的話,我們就將 NeedUpdateQueryData 改爲 true,然後再做一次搬運。
MQ 在這裏的作用只是一個觸發信號的工具,如果不用 MQ 好像也沒啥問題啊,這你就大錯特錯了。
服務的解耦:這樣主業務邏輯就不會依賴更新查詢數據這個服務了。
控制更新查詢數據服務的併發量:如果我們直接調用更新查詢數據服務,因寫操作速度快,更新查詢數據速度慢,寫操作一旦併發量高,會給更新查詢數據服務造成超負荷壓力。如果通過消息觸發更新查詢數據服務,我們就可以通過控制消息消費者的線程數來控制負載。
(三)查詢數據如何存儲?
我們應該使用什麼技術存儲查詢數據呢?目前,市面上主要使用 Elasticsearch 實現大數據量的搜索查詢,當然還可能會使用到MongoDB、HBase 這些技術,這就需要我們對各種技術的特性瞭如指掌,再進行技術選型。
關於技術選型這個問題,我覺得很多時候我們不能單單隻考慮業務功能的需求,還需要考慮組織結構。團隊最熟悉哪款中間件,花費的成本最小,優先考慮的就應該是這種。
(四)查詢數據如何使用?
因 ES 自帶 API,所以使用查詢數據時,我們在查詢業務代碼中直接調用 ES 的 API 就行。
不過,這個辦法會出現一個問題:數據查詢更新完前,查詢數據不一致怎麼辦?這裏分享 2 種解決思路。
在查詢數據更新到最新前,不允許用戶查詢。(我們沒用過這種設計,但我確實見過市面上有這樣的設計。
給用戶提示:您目前查詢到的數據可能是 1 秒前的數據,如果發現數據不準確,可以嘗試刷新一下,這種提示用戶一般比較容易接受。
整體方案
以上,我們已經把四個問題都討論完了,我們再一起看看查詢分離的整體方案,如下圖所示:
歷史數據遷移
新的架構方案上線後,舊的數據如何適用新的架構方案?這是實際業務中需要我們考慮的問題。
在這個方案裏,我們只需要把所有的歷史數據加上這個標識:NeedUpdateQueryData=true,程序就會自動處理了。
查詢分離解決方案的不足
查詢分離這個解決方案雖然能解決一些問題,但我們也要清醒地認識到它的不足。
不足一: 使用 Elasticsearch 存儲查詢數據時,注意事項是什麼(此方案並未詳細展開)?
不足二: 主數據量越來越大後,寫操作還是慢,到時還是會出問題。
不足三: 主數據和查詢數據不一致時,假設業務邏輯需要查詢數據保持一致性呢?
未完待續