- 從程序員到架構師:大數據量、緩存、高并發、微服務、多團隊協同等核心場景實戰
- 王偉杰編著
- 4638字
- 2022-06-17 17:04:17
2.3 查詢分離實現思路
如圖2-2所示,查詢分離的實現思路如下。
1)如何觸發查詢分離?
2)如何實現查詢分離?
3)查詢數據如何存儲?
4)查詢數據如何使用?
5)歷史數據如何遷移?

? 圖2-2 查詢分離需要考慮的問題
下面針對以上5個問題的解決方案進行展開。
2.3.1 如何觸發查詢分離
這個問題是說應該在什么時候保存一份數據到查詢數據庫,即什么時候觸發查詢分離這個動作。
一般來說,查詢分離的觸發邏輯分為3種。
1)修改業務代碼,在寫入常規數據后同步更新查詢數據。如圖2-3所示,每次客服單擊更新工單的按鈕后,在處理該動作的請求線程當中,除了更新工單數據外,還要調用一個更新工單查詢數據的操作。直到這些操作都完成以后,再返回請求結果給客服。

? 圖2-3 修改業務代碼同步更新查詢數據
2)修改業務代碼,在寫入常規數據后,異步更新查詢數據。如圖2-4所示,客服單擊更新工單的按鈕后,在處理該動作的請求線程當中,更新工單數據,而后異步發起另外一個線程去更新工單數據到查詢數據庫。不用等到查詢數據更新完成,就直接返回請求結果給客服。

? 圖2-4 修改業務代碼異步更新查詢數據
3)監控數據庫日志,如有數據變更,則更新查詢數據。這個設計不會影響業務代碼。如圖2-5所示,監控主數據庫的數據庫日志文件(binlog),一旦發現有變更,就觸發工單數據的更新操作,去更新查詢數據。

? 圖2-5 監控數據庫日志更新查詢數據
以上3種觸發邏輯的優缺點見表2-1。
表2-1 3種觸發邏輯的優缺點

為方便理解表2-1中的內容,下面就其中幾個概念展開說明。
(1)業務邏輯靈活可控
一般來說,寫業務代碼的人能從業務邏輯中快速判斷出何種情況下更新查詢數據,而監控數據庫日志的人并不能將全部的數據庫變更分支窮舉出來,再把所有的可能性關聯到對應的查詢數據更新邏輯中,最終導致任何數據的變更都需要重新建立查詢數據。
(2)減緩寫操作速度
更新查詢數據的一個動作能減緩多少寫操作速度?答案是很多。舉個例子:當只是簡單更新了訂單的一個標識時,本來更新這個字段的時間只需要2毫秒,但是去更新訂單的查詢數據時,可能會涉及索引重建(比如使用Elasticsearch查詢數據庫時,會涉及索引、分片、主從備份,其中每個動作又細分為很多子動作,這些內容后面的場景會講到),這時更新查詢數據的過程可能就需要1秒了。
(3)查詢數據更新前,用戶可能查詢到過時數據
這里結合第2種觸發邏輯來講。比如某個操作正處于訂單更新狀態,狀態更新時會異步更新查詢數據,更新完后訂單才從“待審核”狀態變為“已審核”狀態。假設查詢數據的更新時間需要1秒,這1秒中如果用戶正在查詢訂單狀態,這時主數據雖然已變為“已審核”狀態,但最終查詢的結果還是顯示為“待審核”狀態。
根據表1-2中的對比,可總結出3種觸發邏輯的適用場景,見表2-2。
表2-2 3種觸發邏輯的適用場景

在與客服的溝通中得知,她們的工作狀態一般是一邊接線一邊修改工單,所以希望工單頁面的反應要快一些,也就是說,她們對寫操作的響應速度有要求;另外,項目組成員對業務代碼比較熟悉,有辦法找到所有修改工單的代碼。
基于這兩點考慮,項目組最后選擇了第2種方案:修改所有與工單寫操作有關的業務代碼,在更新完工單數據后,異步觸發更新查詢數據的邏輯,而后不等查詢數據更新完成,就直接返回結果給客服。
觸發查詢分離的方式考慮清楚了,接下來就要考慮如何實現查詢分離。
2.3.2 如何實現查詢分離
項目組選擇的是第2種觸發方案:修改業務代碼異步更新查詢數據。最基本的實現方式是單獨啟動一個線程來創建查詢數據,不過使用這種做法要考慮以下情況。
1)寫操作較多且線程太多時,就需要加以控制,否則太多的線程最終會拖垮JVM。
2)創建查詢數據的線程出錯時,如何自動重試?如果要自動重試,是不是要有個地方標識更新失敗的數據?
3)多線程并發時,很多并發場景需要解決。
面對以上3種情況,該如何處理?此時就可以考慮使用MQ(Message Queue,消息隊列)來解決這些問題了。
MQ的具體操作思路為,每次程序處理主數據寫操作請求時,都會發一個通知給MQ,MQ收到通知后喚醒一個線程來更新查詢數據,如圖2-6所示。

? 圖2-6 MQ觸發查詢數據更新示意圖
了解MQ的具體操作思路后,還應該考慮以下5個問題。
問題1:MQ如何選型?
如果公司已經使用MQ,那選型問題也就不存在了,畢竟技術部門不會同時維護兩套MQ中間件,如果公司還沒有使用MQ,就需要考慮選型的問題了。
MQ的選型建議如下。
1)召集技術中心所有能做技術決策的人共同投票選型。
2)在并發量不高的情況下,不管選擇哪個MQ,最終都能實現想要的功能,只不過存在是否易用、業務代碼多少的區別,因此從易用性考量即可。當然,前提是支持自己使用的編程語言。
Rabbit MQ、Rocket MQ、Kafka、Active MQ、Redis都有實際應用,當然每一家公司都會有一份很嚴謹的選型報告,證明它們的選型是最正確的。
問題2:MQ宕機了怎么辦?
考慮MQ宕機的情況有以下場景。
1)工單A更新后要通知MQ,但是MQ宕機了,于是MQ沒有這條消息,出現消息丟失的情況。
2)MQ收到消息,然后消費者讀到消息,但是MQ宕機了,于是MQ不知道消費者是不是已經消費成功,可能造成消息重復投遞。
如果MQ宕機了,項目組只需要保證主流程正常進行,且MQ恢復后數據正常處理即可,具體方案分為3步。
1)每次進行寫操作時,在主數據中加標識NeedUpdateQueryData=true,這樣發到MQ的消息就很簡單,只是一個簡單的信號來告知更新數據,并不包含更新的數據ID。
2)MQ的消費者獲取信號后,先批量查詢待更新的主數據,然后批量更新查詢數據,更新完成后查詢數據的主數據標識NeedUpdateQueryData更新為false。
3)若存在多個消費者同時有遷移動作的情況,就涉及并發性的問題,這與前一場景冷熱分離中的并發性處理邏輯類似,這里不再贅述。
結合以上處理過程,再分析一下前面的兩個MQ宕機場景。
1)工單A更新后要通知MQ,但是MQ宕機了,于是MQ沒有這條消息,出現消息丟失的情況。等MQ恢復后,假設工單B也更新了,此時觸發了一個消費者線程,這個線程會查詢NeedUpdateQueryData=true的數據,結果工單A和B都被查詢到了。這兩個工單都將被同步到查詢數據庫。
2)MQ收到消息,然后消費者讀到消息,但是MQ宕機了,于是MQ不知道消費者是不是已經消費成功,可能造成消息重復投遞。假設工單A更新后,MQ收到一條消息,然后消費者消費了這條消息,同步了工單A,但是在回調給MQ告知消費成功時,MQ宕機了,于是MQ不知道這條消息已經被消費,它恢復后又投遞了同步工單的消息。此時消費者收到消息后,去查詢數據庫,但是其實工單A已經同步,NeedUpdateQueryData標識改成了false,待更新工單不再包含工單A,所以消息重復投遞問題也解決了。
問題3:更新查詢數據的線程失敗了怎么辦?
如果更新的線程失敗了,NeedUpdateQueryData標識就不會更新,后面的消費者會再次將有NeedUpdateQueryData標識的數據拿出來處理。但如果一直失敗,可以在主數據中添加一個嘗試遷移次數,每次嘗試遷移時將其加1,成功后就清零,以此監控那些嘗試遷移次數過多的數據。
問題4:消息的冪等消費。
再梳理一下同步步驟。
1)更新工單并且將工單的NeedUpdateQueryData改為true。
2)連接MQ生產消息。
3)MQ投遞消息給消費者。
4)消費者獲取NeedUpdateQueryData為true的工單。
5)消費者將前面獲取的工單同步到查詢數據庫。
6)消費者將主數據庫中相應工單的字段NeedUpdateQueryData改為false。
7)消費者回調給MQ,告知消息已經被消費。
為什么要考慮冪等的情況呢?舉一個例子,當執行完上面的步驟5)之后,突然網絡出問題了,接下來的步驟6)、7)就沒有被執行。這種情況下,經過一定時間后,這條消息就會被重試,那么上面的步驟5)就會重復執行。
這里的冪等就是要保證步驟5)可以重復執行多次,而且得到的最終結果是一致的。
問題5:消息的時序性問題。
比如某個訂單A更新了一次數據變成A1,線程甲將A1的數據遷移到查詢數據中。不一會兒,后臺訂單A又更新了一次數據變成A2,線程乙也啟動工作,將A2的數據遷移到查詢數據中。
這里的時序性問題是,如果線程甲啟動比乙早,但遷移數據的動作比線程乙還要慢,就有可能導致查詢數據最終變成過期的A1,如圖2-7所示,動作前面的序號代表實際動作的先后順序。

? 圖2-7 時序性問題示意圖
此時解決方案為主數據每次更新時,都更新上次更新的時間last_update_time,然后每個線程更新查詢數據后,檢查當前工單A的last_update_time是否與線程剛開始獲得的時間相同,以及NeedUpdateQueryData是否等于false,如果都滿足,就將NeedUpdateQueryData改為true,然后再做一次遷移。
此處讀者心中可能有個疑問:MQ在這里的作用只是一個觸發信號的工具,如果不用MQ似乎也可以。其實不然,這里MQ的作用如下。
1)服務的解耦:這樣主業務邏輯就不會依賴更新查詢數據這個服務了。
2)控制更新查詢數據服務的并發量:如果直接調用更新查詢數據服務,因寫操作速度快,更新查詢數據速度慢,寫操作一旦并發量高,就會造成更新查詢數據服務的超載。如果通過消息觸發更新查詢數據服務,就可以通過控制消息消費者的線程數來控制負載。
接下來再看一下,查詢數據如何存儲。
2.3.3 查詢數據如何存儲
應該使用什么技術來存儲查詢數據呢?目前開發者們主要使用Elasticsearch實現大數據量的搜索查詢,當然還可能用到MongoDB、HBase這些技術,這就需要開發者對各種技術的特性了如指掌后再進行技術選型。
前面已經介紹了HBase。它可以存儲海量數據,但是其設計初衷并不是用來做復雜查詢,即使可以做到,效率也不高。而此處的工單查詢復雜度很高,所以項目組最后鎖定的兩個選項是MongoDB和Elasticsearch。
關于技術選型這個問題,很多時候不能只考慮業務功能的需求,還需要考慮人員的技術結構。比如在這個項目中,設計架構方案時選用了Elasticsearch,之所以這樣,除Elasticsearch對查詢的擴展性支持外,最關鍵的一點是團隊對Elasticsearch很熟悉,但是沒有人熟悉MongoDB。運維人員也沒有MongoDB的運維經驗。
現在查詢分離中的寫部分已經完成了,接下來考慮讀的部分。
2.3.4 查詢數據如何使用
數據存到Elasticsearch以后,就要查詢了。那查詢的時候要注意什么呢?
因Elasticsearch自帶API,所以使用查詢數據時,在查詢業務代碼中直接調用Elasticsearch的API即可。至于Elasticsearch的API怎么用,這里就不講了。
不過要考慮一個場景:數據查詢更新完前,查詢數據不一致怎么辦?舉一個例子:假設更新工單的操作可以在100毫秒內完成,但是將新的工單同步到Elasticsearch需要2秒,那么在這2秒內,如果用戶去查詢,就可能查詢到舊的工單數據。
這里分享兩種解決思路。
1)在查詢數據更新到最新前,不允許用戶查詢。筆者團隊沒用過這種方案,但在其他實際項目中見到過。
2)給用戶提示:“您目前查詢到的數據可能是2秒前的數據,如果發現數據不準確,可以嘗試刷新一下。”這種提示用戶一般都能接受。
2.3.5 歷史數據遷移
新的架構方案上線后,舊的數據如何適應新的架構方案?這是實際業務中需要考慮的問題。
在這個方案里,只需要把所有的歷史數據加上標識NeedUpdateQueryData=true,程序就會自動處理了。
2.3.6 MQ+Elasticsearch的整體方案
以上小節已經把5個問題都討論完了,再一起看下查詢分離的整體方案。整個方案的要點如下。
1)使用異步方式觸發查詢數據的同步。當工單修改后,會異步啟動一個線程來同步工單數據到查詢數據庫。
2)通過MQ來實現異步的效果。MQ還做了兩件事:①服務的解耦,將工單主業務系統和查詢系統的服務解耦;②削峰,當修改工單的并發請求太多時,通過MQ控制同步查詢數據庫的線程數,防止查詢數據庫的同步請求太大。
3)將工單的查詢數據存儲在Elasticsearch中。因為Elasticsearch是一個分布式索引系統,天然就是用來做大數據的復雜查詢的。
4)因為查詢數據同步到Elasticsearch會有一定的延時,所以用戶可能會查詢到舊的工單數據,所以要給用戶一些提示。
5)關于歷史數據的遷移,因為是用字段NeedUpdateQueryData來標識工單是否需要同步,所以只要把所有歷史數據的標識改成true,系統就會自動批量將歷史數據同步到Elasticsearch。
整個方案如圖2-8所示。

? 圖2-8 整體方案示意圖
這個整體方案看似簡單,但是其中有一些陷阱必須注意。下面著重介紹一下使用Elasticsearch時的注意事項。