【大數(shu)據高并發(fa)核心場景實戰(zhan)】 數(shu)據持(chi)久化層(ceng) - 查詢(xun)分(fen)離
上一章(zhang)中(zhong)我們介紹(shao)到冷熱分離,旨在快(kuai)速交付。但是他仍存(cun)在一些(xie)問(wen)題,并不是完美(mei)的(de)方案,比如限制了業務(wu)的(de)操作,必須再特定的(de)業務(wu)場(chang)景下(冷數據(ju)不允許修改、冷數據(ju)查詢(xun)(xun)慢、不適合復雜查詢(xun)(xun))。本章(zhang)將介紹(shao)新的(de)方案,支(zhi)持千萬數據(ju)的(de)快(kuai)速查詢(xun)(xun)。
1. 業務場景
適用場景:
- 數據查詢緩慢(數據量大導致、數據聚合時調用外部系統過多導致等)
- 寫數據效率尚可
- 所有數據都可能修改(若存在冷數據,可使用上一章的冷熱分離方案)
基本思路:將更新的(de)數(shu)據放在主數(shu)據庫里,而(er)查(cha)(cha)詢的(de)數(shu)據放在另外一(yi)(yi)個專門(men)(men)針對(dui)搜索的(de)存儲系統(tong)里。主庫單表查(cha)(cha)詢,無關聯無外鍵,所以寫(xie)數(shu)據無壓力。數(shu)據查(cha)(cha)詢通過一(yi)(yi)個專門(men)(men)處理(li)大數(shu)據量的(de)查(cha)(cha)詢引擎來解(jie)決。
這里有同學可能會提到(dao)數(shu)(shu)據(ju)庫讀寫分(fen)離,這種情(qing)況下(xia)在(zai)千萬(wan)級別數(shu)(shu)據(ju)量下(xia)的(de)(de)速度提升并不(bu)大,并且只能解決數(shu)(shu)據(ju)庫查(cha)詢慢的(de)(de)問(wen)(wen)題(ti),不(bu)能解決其他如查(cha)詢詳情(qing)時調用外部系統耗時長導致(zhi)的(de)(de)查(cha)詢慢問(wen)(wen)題(ti)。
核心問題:
- 如何觸發查詢分離?
- 如何實現查詢分離?
- 查詢數據如何存儲?
- 查詢數據如何使用?
- 歷史數據如何遷移?
2. 查詢分離
2.1 如何觸發查詢分離
1)修改業務代碼,寫入同時同步更新查詢數據
圖2-1: 同步更新查詢數據示意(yi)圖
2)修改業務代碼,在寫入常規數據后,異步更新查詢數據
圖2-2: 異步更新查詢(xun)數據(ju)示(shi)意圖
3)監控數據庫日志,如有數據變更,則更新查詢數據
優點是不會(hui)影響(xiang)業務代碼。
圖2-3: 監(jian)控數據庫(ku)日志(zhi)更(geng)新查詢(xun)數據示意圖
優缺點對比
圖2-4: 三種觸發方(fang)式(shi)優缺點對比表
針對優缺點總結適用場景
圖2-5: 三種方法適(shi)用場(chang)景總結
2.2 如何實現查詢分離
這里(li)以方(fang)法二,業(ye)務代碼異步更新查詢數據的方(fang)式(shi)為例(li)講解實現方(fang)式(shi),這個(ge)方(fang)法需要考慮以下(xia)幾個(ge)問題:
- 寫操作較多且線程太多時,需要加以控制,否則太多線程最終會拖垮JVM
- 創建查詢數據的線程出錯時,如何自動重試?如何標識更新失敗的數據?
- 多線程并發時,需要解決很多并發場景
針對以上問題,可以考慮使用MQ來解決(jue):在短(duan)時間線程過多時,將任(ren)務暫存到MQ中間件進行(xing)削峰處(chu)理;業(ye)務失敗(bai)時可自動重新發送消(xiao)息重試。
圖(tu)2-6: MQ解決方案架構示意圖(tu)
具體方案
- 寫操作時,主數據表添加標識
NeedUpdateQueryData=true,MQ消息簡單,只是一個信號來告知更新數據,不包含更新的數據ID(如果包含業務信息,就需要考慮更多的冪等和消息丟失等問題) - 消費者獲取信號后,先批量查詢待更新的主數據,然后批量更新查詢數據,更新完成后將查詢數據的主數據標識
NeedUpdateQueryData更新為false - 若存在多個消費者同時有遷移動作的情況,就涉及并發性問題,這與前一場景冷熱分離中的并發性處理邏輯類似,這里不再贅述
消息的時序性問題:
- 生產者1 將數據A修改為A1,發送消息Q1
- 生產者2 將數據A1修改為A2,發送消息Q2
- 消費者1 收到Q1,查詢數據為A1(此時消費者2收到Q2,將數據A2遷移到緩存),A1遷移到緩存
即消(xiao)(xiao)費(fei)(fei)者查詢(xun)數(shu)據(ju)庫數(shu)據(ju)后,在未(wei)遷移(yi)數(shu)據(ju)時被后觸發的消(xiao)(xiao)費(fei)(fei)者線程更(geng)新了遷移(yi)了更(geng)新的數(shu)據(ju),而(er)后先消(xiao)(xiao)費(fei)(fei)的消(xiao)(xiao)費(fei)(fei)者會(hui)將(jiang)后消(xiao)(xiao)費(fei)(fei)消(xiao)(xiao)費(fei)(fei)者的遷移(yi)更(geng)新掉,導致緩存本該后遷移(yi)記錄丟(diu)失。
解決方法:消費者查詢 NeedUpdateQueryData=true 數據的同時查詢 lastUpdateTime 作為樂觀鎖字段進行更新。
2.3 查詢數據如何存儲
常用的兩個中間件是 MongoDB 和 ES,選(xuan)擇取決于團隊成員的技術結構(gou)。我們(men)團隊選(xuan)擇的是 ES。
| 特性維度 | MongoDB | Elasticsearch |
|---|---|---|
| 數據模型 | 文檔型數據庫,類似JSON,結構靈活 | 搜索引擎,擅長處理非結構化文本數據 |
| 核心優勢 | 高性能讀寫、靈活的數據模型、橫向擴展 | 強大的全文檢索、復雜查詢和數據分析 |
| 查詢場景 | 適合精確查詢、范圍查詢、事務和聚合操作 | 適合模糊匹配、全文搜索、多條件復雜檢索 |
| 寫入性能 | 寫入速度較快,支持高并發寫入 | 寫入吞吐量通常低于MongoDB,但近實時搜索(秒級) |
| 讀取性能 | 精確查詢和聚合操作性能優秀 | 復雜搜索和全文檢索性能卓越 |
| 事務支持 | 支持多文檔ACID事務 | 不支持事務,保證最終一致性 |
| 資源消耗 | 磁盤占用通常更小(高壓縮存儲引擎) | 磁盤和內存消耗相對較高 |
| 擴展性 | 支持分片集群,需手動配置 | 天生分布式,開箱即用,自動分片 |
| 管理維護 | 集群配置相對復雜,需要專業知識 | 管理相對簡單,有完善的監控工具 |
| 適用場景 | Web應用后端、用戶畫像、設備監控 | 搜索引擎、日志分析、實時監控 |
| 不適用場景 | 復雜的全文搜索需求 | 需要強事務一致性的場景 |
| 學習成本 | 中等,查詢語法相對簡單 | 較高,查詢DSL較復雜 |
| 社區生態 | 成熟穩定,社區活躍 | 生態豐富,插件眾多 |
| 成本考量 | 通常存儲成本更低 | 資源消耗大,總體成本可能更高 |
2.4 查詢數據如何使用
ES自帶查詢API,在業務代碼中直接調用ES即可。這里涉及到一個場景:緩存和數據庫數據不一致的問題。
兩種解決思路:
- 在查詢數據更新到最新前,不允許用戶查詢(在數據同步完成前,強制查詢走主數據源如MySQL,而不是ES)
- 給用戶提示"當前數據為2s前的數據,如發現數據不準確可嘗試刷新",通常用戶都能接受
2.5 歷史數據遷移
當前方案中,只需要把所有歷史數據加上標識 NeedUpdateQueryData=true,程序就(jiu)會自動處理(li)。
2.6 MQ+ES 整體方案
- 業務數據修改后,觸發異步線程數據同步
- 觸發異步方式使用MQ(解耦、削峰)
- 查詢數據到ES(適合大數據量的復雜查詢)
- 查詢數據同步到ES會有一定延時,用戶可能查詢到舊數據,需給用戶提示
- 歷史數據遷移,只需把所有歷史數據的標識改成true,系統會自動批量同步到ES
圖2-7: MQ+ES整體(ti)架構方案示意(yi)圖
這個整體方(fang)案看似簡單,但(dan)有(you)一些陷阱必須注意。下面著(zhu)重介(jie)紹使用Elasticsearch時的注意事項。
3. ElasticSearch注意事項
Elasticsearch的使用要點:
- 如何使用Elasticsearch設計表結構?
- Elasticsearch的存儲結構
- Elasticsearch如何修改表結構?
- Elasticsearch的準實時性
- Elasticsearch可能丟數據
- Elasticsearch分頁
3.1 如何使用Elasticsearch設計表結構
Elasticsearch基于索引設計(ji),無法像MySQL那(nei)樣使(shi)用(yong)join查詢,所(suo)以查詢數(shu)據時需要把每(mei)條主數(shu)據及關聯(lian)子(zi)表(biao)的數(shu)據全部整合在(zai)一(yi)條記錄中。
下面以常見的(de)訂單業(ye)務類講(jiang)解如何設計ES表結(jie)構(gou):
圖(tu)3-1: 訂單業(ye)務數(shu)據結(jie)構示意圖(tu)
雖然訂單數據(ju)(ju)在關系型(xing)數據(ju)(ju)庫中(zhong)涉(she)及多表(biao),但使用Elasticsearch存儲數據(ju)(ju)時不會設計多個表(biao),而是將所有表(biao)的相關字段(duan)數據(ju)(ju)匯(hui)集在一個Document中(zhong),即一個完整(zheng)的文(wen)檔結構:
{
"order_ID": "o2020103115214521",
"order_invoice": {},
"user": {
"user_ID": "U1099",
"user_name": "YiHuiComeOn"
},
"order_product_item": [
{
"product_name": "乒乓球拍",
"product_count": 1,
"product_price": 149
},
{
"product_name": "紙巾",
"product_count": 2,
"product_price": 1.4
}
],
"total_amount": 20
}
習慣關系型數(shu)據庫的(de)同學(xue)可能會有疑(yi)惑(huo):為(wei)什么匯(hui)聚到(dao)同一document中?為(wei)什么ES不需要關聯查詢?這就涉及到(dao)ES特殊(shu)的(de)存儲結構。
3.2 Elasticsearch的存儲結構
3.2.1 Lucene和MySQL的概念對照
Lucene是一個索引系統,此處把(ba)Lucene與MySQL的一些概(gai)念做簡單對照:
圖(tu)3-2: Lucene與MySQL概(gai)念對照表
3.2.2 無結構文檔的倒排索引
假設有(you)一些無(wu)結構(gou)文檔(dang)數據:
圖3-3: 無結構文(wen)檔示例(li)
簡(jian)單倒(dao)排索引后的結果(guo):
圖3-4: 無結構文檔倒排(pai)索引結果
無結構的文檔經過簡單的倒排索引后,字典表(biao)主(zhu)要存放關鍵字,而倒(dao)排表(biao)存放該(gai)關鍵字所在的文(wen)檔ID。業務(wu)數(shu)據通常不是無結構(gou)的文(wen)檔內容(rong),而是有結構(gou)的數(shu)據,此時如(ru)何倒(dao)排索引呢?
3.2.3 有結構文檔的倒排索引
更復雜的(de)例子:每(mei)個Doc都有多個Field,Field有不(bu)同的(de)值(包(bao)含不(bu)同的(de)Term,Term是經(jing)過文本分(fen)析處(chu)理(li)后不(bu)可再分(fen)割的(de)最小單位)。
圖(tu)3-5: 有結構文檔示例
倒排表:
- 性別倒排索引
圖3-6: 性別字段倒排索引示(shi)例
- 年齡倒排索引
圖3-7: 年齡字段倒排索引(yin)示(shi)例
- 武功倒排索引
圖3-8: 武(wu)功(gong)字段倒排索(suo)引示例
由此可見,有結構的文(wen)檔經過倒(dao)排索引后(hou),字段(duan)中的每個(ge)值都是(shi)一(yi)個(ge)關(guan)鍵(jian)字,存(cun)放在Term Dictionary中,且每個(ge)關(guan)鍵(jian)字都有對應地址指向所在文(wen)檔。
3.2.4 ES的Document如何定義結構和字段格式
設計ES的(de)Document結構時,不需要像MySQL那樣關聯表,而是把(ba)所(suo)有相關數據匯集在(zai)一個Document中。直接將(jiang)3.1節中訂單的(de)JSON文檔轉(zhuan)成一個ES文檔(SQL中的(de)子表數據在(zai)Elasticsearch中以嵌入式(shi)對(dui)象格式(shi)存儲):
{
"mappings": {
"doc": {
"properties": {
"order_ID": {
"type": "text"
},
"order_invoice": {
"type": "nested"
},
"order_product_item": {
"type": "nested",
"properties": {
"product_name": {
"type": "text"
}
}
},
"total_amount": {
"type": "long"
},
"user": {
"properties": {
"user_ID": {
"type": "text"
},
"user_name": {
"type": "text"
}
}
}
}
}
}
}
至(zhi)此,大(da)家已經了解了Elasticsearch表結(jie)構(gou)的設計(ji)。在實際業務中(zhong),主數據修改(gai)表結(jie)構(gou)時,ES也要求修改(gai)文檔結(jie)構(gou),這時該怎么(me)辦(ban)?
3.3 Elasticsearch如何修改表結構
- ES支持直接添加新字段
- 因為修改字段的類型會導致索引失效,所以ES不支持修改原字段類型
Elasticsearch底(di)層基于Lucene,Lucene的倒排(pai)(pai)索引一旦創(chuang)建(jian)就(jiu)是不可變的。就(jiu)像印刷(shua)好的書(shu)籍,你(ni)不能(neng)直接修改某(mou)一頁的排(pai)(pai)版,只能(neng)重(zhong)新印刷(shua)一本。
- 如果想修改字段的映射(表結構),需要新建一個索引,然后使用Elasticsearch的reindex功能將舊索引復制到新索引中
POST /_reindex
{
"source": {"index": "products_old"},
"dest": {"index": "products_new"}
}
reindex功能會使舊索引失效,直接重命名字段時可以使用alias索引功能。
注意:通(tong)常(chang)不會直(zhi)接(jie)刪除舊字段,常(chang)用做(zuo)法是新版本項(xiang)(xiang)目代碼兼容(rong)舊數(shu)據,在項(xiang)(xiang)目穩定運行(xing)后,再考慮清理舊字段。
3.4 陷阱一:Elasticsearch是準實時的嗎
當更(geng)新(xin)數據至Elasticsearch且返(fan)回(hui)成功提(ti)示時,通過Elasticsearch查詢返(fan)回(hui)的數據可能不是最新(xin)的。
這個過(guo)程涉(she)及Elasticsearch的Shard(分片),以及Lucene Index、Segment、Document三者之間(jian)的關(guan)系(xi)。
Elasticsearch的一(yi)(yi)個Shard就是一(yi)(yi)個Lucene Index,每一(yi)(yi)個Lucene Index由多個Segment構成。
分片(Shard)結構圖
圖3-9: Elasticsearch分片結構示意圖
Index、Segment、Document三者之間的關系
圖3-10: Index、Segment、Document關(guan)系圖
數據索引的過程詳解:
-
當新(xin)的Document被創建時(shi),數(shu)據首(shou)先會(hui)存放(fang)到新(xin)的Segment中,同時(shi)舊Document會(hui)被刪(shan)除,并在原來的Segment上標(biao)記刪(shan)除標(biao)識。當Document被更新(xin)時(shi),舊版(ban)Document會(hui)被標(biao)識為刪(shan)除,并將新(xin)版(ban)Document存放(fang)在新(xin)的Segment中
-
Shard收(shou)到(dao)寫請(qing)求(qiu)時,請(qing)求(qiu)會被寫入Translog中(zhong)(zhong),然后Document被存放在(zai)Memory Buffer中(zhong)(zhong)
圖3-11: Elasticsearch寫請(qing)求處理流程
注意:Memory Buffer 不會被查詢到(dao)
- 每隔1秒(默認設置),Refresh操作被執行一次,Memory Buffer中的數據會被寫入一個Segment,并存放在File System Cache中,這時新數據就可以被搜索到了
圖3-12: Refresh操作(zuo)數據刷新流程(cheng)
通俗理解整個過程:
名詞解釋:
- Document:ES中的基本數據單元,相當于一條記錄
- Segment:Lucene索引的基本單元,是不可變的
- Memory Buffer:臨時存儲新文檔的內存區域
- Translog:記錄所有寫操作的日志文件
- Refresh:將內存中的數據寫入新Segment并使其可搜索的操作
- File System Cache:操作系統級別的磁盤緩存
流程解釋:
- 新數據到達:先登記到Translog,再放到Memory Buffer
- 定期刷新:每1秒將Memory Buffer中的數據寫入Segment,放到File System Cache
- 此時數據可被搜索
通過以上數據索引過程的說明,可以發現Elasticsearch并不是實時的,而是有1秒延時。解決(jue)方案是提示用戶查詢的數據會有(you)一定延時(shi)。
3.5 陷阱二:Elasticsearch宕機恢復后,數據丟失
上一(yi)小節中提(ti)及每隔1秒Memory Buffer中數據(ju)會(hui)被(bei)刷到(dao)Segment中,此時數據(ju)可被(bei)用(yong)戶搜索到(dao),但(dan)沒有持久化(hua),一(yi)旦系(xi)統宕機,數據(ju)就會(hui)丟失。
如何防止數據丟失?使用Lucene中的Commit操作解決這個問題。
Commit操作方法:先將多個Segment合并保存到磁(ci)盤中,再進行持久化標(biao)記。
但(dan)commit有(you)兩個(ge)問題:
- 會占用IO資源,使得commit期間數據查詢變慢
- 無法解決數據保存時,在translog寫完還未寫入文件系統緩存情況的數據丟失
translog持(chi)久化到磁盤需要執行fsync操作,具體實現方法有兩種:
- 將index.translog.durability設置成request,缺點是耗費資源,性能差一些
- 將index.translog.durability設置為async,每隔index.translog.sync_interval時間執行一次fsync
配置建議
# 方案A:金融級安全(不能丟任何數據)
PUT /my_index/_settings
{
"index.translog.durability": "request"
}
# 方案B:普通業務(可容忍少量數據丟失)
PUT /my_index/_settings
{
"index.translog.durability": "async",
"index.translog.sync_interval": "5s"
}
實踐總結
根據業務需求選擇策略
| 業務類型 | 推薦配置 | 解釋 |
|---|---|---|
| 金融交易 | durability: request |
數據絕對不能丟失 |
| 電商訂單 | durability: async, sync_interval: 1s |
可容忍極短時間延遲 |
| 日志分析 | durability: async, sync_interval: 5s |
丟幾條日志沒關系 |
記住:沒(mei)有完(wan)美的(de)方案,只有適合你業務需(xu)求(qiu)的(de)方案!
3.6 陷阱三:分頁越深,查詢效率越低
Elasticsearch的讀操(cao)作流程主(zhu)要分為兩個階段:Query Phase、Fetch Phase。
- Query Phase:協調節點先把請求分發到所有分片,每個分片在本地查詢后建一個結果集隊列,將Document ID以及搜索分數存放在隊列中,再返回給協調節點,協調節點建全局隊列,歸并所有結果集并進行全局排序
Tips:在Elasticsearch查詢過程中(zhong),如果search方法帶(dai)有from和size參數(shu)(shu),Elasticsearch集(ji)群需要給(gei)協(xie)調節點(dian)返(fan)回分片(pian)數(shu)(shu)×(from+size)條數(shu)(shu)據(ju),然后(hou)在單(dan)機上進行排(pai)序,最后(hou)給(gei)客(ke)戶(hu)(hu)端返(fan)回size大小的(de)數(shu)(shu)據(ju)。比如客(ke)戶(hu)(hu)端請(qing)求10條數(shu)(shu)據(ju),有3個(ge)分片(pian),那么每個(ge)分片(pian)會(hui)返(fan)回10條數(shu)(shu)據(ju),協(xie)調節點(dian)最后(hou)會(hui)歸(gui)并30條數(shu)(shu)據(ju),但最終只返(fan)回10條數(shu)(shu)據(ju)給(gei)客(ke)戶(hu)(hu)端。
圖3-13: Elasticsearch讀操作(zuo)兩(liang)階段流(liu)程(cheng)
- Fetch Phase:協調節點先根據結果集里的Document ID向所有分片獲取完整的Document,然后所有分片返回完整的Document給協調節點,最后協調節點將結果返回給客戶端
比如有5個分片,需要查詢排序序號從10000到10010(from=10000,size=10)的結果,每個分片返回給協調節點計算的數據量是10010條。這是為了防止其他分片中沒有數據,考慮最壞情況10010條數據都在自己分片上,進而把10010條數據全部給協調節點去聚合計算。
也就是說(shuo),協調(diao)節點需要在內存(cun)中計算10010×5=50050條記(ji)錄,所以用戶分頁越(yue)深查詢速度會越(yue)慢,分頁并(bing)不是越(yue)多越(yue)好。
那如何更好地解決Elasticsearch分頁問題呢?為了控制性能,可以使用Elasticsearch中的max_result_window進行配置,這個數據默認為10000,當from+size > max_result_window時,Elasticsearch將返(fan)回錯誤。
如果用戶確實有深度翻頁的需求,使用Elasticsearch中search_after的(de)功能也(ye)能解決,只(zhi)是無法實現跳頁(這樣分片可以利用游標條件(jian)過(guo)濾部分數據(ju),從(cong)而減少數據(ju)計算的(de)數量提升查詢速度)。
舉例(li),查(cha)詢結果按照訂單總金(jin)額分頁(ye)(ye),上一(yi)頁(ye)(ye)最后一(yi)個訂單的(de)(de)總金(jin)額total_amount是(shi)10,那么下一(yi)頁(ye)(ye)的(de)(de)查(cha)詢示例(li)代(dai)碼如下:
{
"query": {
"bool": {
"must": [
{
"term": {
"user.user_name.keyword": "YiHuiComeOn"
}
}
],
"must_not": [],
"should": []
}
},
"from": 0,
"size": 2,
"search_after": ["10"],
"sort": [
{
"total_amount": "asc"
}
],
"aggs": {}
}
至此,Elasticsearch的(de)一(yi)些要(yao)點就(jiu)介(jie)紹完了(le)。MQ也有一(yi)些要(yao)點,比如確保時序、確保重試、確保消(xiao)息重復(fu)消(xiao)費不(bu)(bu)會影響業務,以(yi)及確保消(xiao)息不(bu)(bu)丟失等,后續各章節會有相應的(de)場(chang)景描述,這里就(jiu)不(bu)(bu)再展開了(le)。
4. 小結
查詢分離這個(ge)解(jie)決方案(an)雖然能解(jie)決一些問題,但也要(yao)認(ren)識到它的不(bu)足:
- 使用Elasticsearch存儲查詢數據時,要接受一些局限性:有一定延時,深度分頁不能自由跳頁,有丟數據的可能性
- 主數據量越來越大后,寫操作還是慢,到時還是會出問題。比如工單數據,雖然已經去掉所有外鍵,但當數據量上億時,插入還是會有問題
- 主數據和查詢數據不一致時,如果業務邏輯需要查詢數據保持一致性呢?查詢數據同步到最新數據會有約2秒延時,某些業務場景下用戶可能無法接受
架構(gou)"沒(mei)有(you)銀彈(dan)",不能(neng)(neng)期望一個(ge)解(jie)決(jue)方案既能(neng)(neng)覆(fu)蓋所有(you)的問(wen)題(ti),還能(neng)(neng)實現最小的成本(ben)損耗(hao)。
如(ru)果碰到一個場(chang)景(jing)不能接受(shou)上面某個或(huo)某些(xie)不足時,該怎么解決(jue)?接著看后面的章節。

上一章中我們介紹到冷熱分離,旨在快速交付。但是他仍存在一些問題,并不是完美的方案,比如限制了業務的操作,必須再特定的業務場景下(冷數據不允許修改、冷數據查詢慢、不適合復雜查詢)。本章將介紹新的方案,支持千萬數據的快速查詢。