一檢索層:多源搜索融合架構(gòu)與實踐)
UniSearch我如何用一套統(tǒng)一檢索層把三個“各自為政”的搜索系統(tǒng)揉成一個接到這個“UniSearch”項目的時候我一開始是想拒絕的并不是因為技術(shù)有多難而是我知道這類“統(tǒng)一搜索”的需求十有八九不是在做一個新搜索引擎而是在給一堆歷史包袱擦屁股。果不其然業(yè)務(wù)方那邊的情況非常典型訂單數(shù)據(jù)在MySQL里文檔和日志在Elasticsearch里商品和庫存部分?jǐn)?shù)據(jù)散落在幾個內(nèi)部API里用戶只想在一個輸入框里搜出所有東西并且希望結(jié)果按相關(guān)度混排。UniSearch這個名字聽起來很唬人落地就是一個把各種數(shù)據(jù)源串起來的“總調(diào)度層”只是這個調(diào)度層要處理的坑遠(yuǎn)比想象中多。如果你也在做類似的事或者正準(zhǔn)備碰企業(yè)內(nèi)部搜索聚合這類需求這篇東西可以幫你少走很多彎路。我講的是自己實際落地的一套實現(xiàn)覆蓋從整體架構(gòu)選型、索引策略、查詢分發(fā)、結(jié)果融合排序到上線后各種疑難雜癥的排查實錄。所有方案都基于我踩坑后的最終版不是說它唯一正確但至少是能扛得住線上流量和業(yè)務(wù)方花式蹂躪的一種設(shè)計。1. 統(tǒng)一搜索到底在解決什么問題先說一個最容易混淆的認(rèn)知UniSearch不是搜索引擎它是一層“調(diào)度和融合中間件”。搜索引擎關(guān)心的是怎么從海量文檔里快速撈相關(guān)文檔而UniSearch關(guān)心的是怎么把已經(jīng)存在于不同系統(tǒng)里的檢索能力以統(tǒng)一的接口暴露出去。你可以把它理解成公司前臺的接待員——你不會直接去找財務(wù)、去找法務(wù)、去找技術(shù)部而是把訴求告訴前臺由前臺判斷該找誰然后把多個部門的答復(fù)整理成一份簡報交給你。1.1 三種典型接入場景拆解第一類場景是結(jié)構(gòu)化數(shù)據(jù)源的直查。比如MySQL或PostgreSQL里的訂單表、用戶表檢索條件通常很固定按訂單號、按用戶名、按時間范圍、按狀態(tài)。這部分完全不值得自建倒排索引直接通過關(guān)鍵詞解析生成SQL條件即可難點只在于如何把用戶輸入的模糊關(guān)鍵詞安全且高效地轉(zhuǎn)換成查詢條件。第二類場景是全文檢索引擎的代理。Elasticsearch這類系統(tǒng)本身已經(jīng)做得很好UniSearch在這里的角色不是替代它而是把ES的DSL翻譯成內(nèi)部統(tǒng)一的查詢模型避免上游系統(tǒng)直接與ES耦合。這樣做的直接好處是哪天你想從ES換成OpenSearch或者把一部分索引遷到ZincSearch上游業(yè)務(wù)代碼一行都不用改。第三類場景是實時API的在線聚合。商品庫存狀態(tài)、配送進(jìn)度這類數(shù)據(jù)時效性極高而且經(jīng)常不在你自己掌控的數(shù)據(jù)庫里只能通過供應(yīng)商或兄弟團(tuán)隊提供的HTTP接口實時獲取。UniSearch需要在這種場景下做并發(fā)請求、超時控制和結(jié)果合并。1.2 為什么必須做統(tǒng)一查詢模型我見過很多團(tuán)隊做統(tǒng)一搜索最后做成了一個“大雜燴”網(wǎng)關(guān)每個數(shù)據(jù)源各寫一套查詢邏輯對外暴露的參數(shù)五花八門調(diào)用方得看文檔才能搞清楚什么時候傳filters什么時候傳q什么時候要拼search_type。這種方案上線半年后基本就進(jìn)入維護(hù)地獄。真正的UniSearch項目第一步不是寫代碼而是定義一套“萬能查詢中間格式”。這套格式一般包含query關(guān)鍵詞、filters結(jié)構(gòu)化過濾條件、sort排序規(guī)則、from/size分頁、highlight高亮需求、aggs聚合需求這幾個核心部分。所有數(shù)據(jù)源適配器只認(rèn)這一套格式對外也只暴露這一個格式。Elasticsearch適配器負(fù)責(zé)把它翻譯成DSLMySQL適配器負(fù)責(zé)把它拆解成WHERE條件API適配器負(fù)責(zé)把它映射成對方接口的請求參數(shù)。這才是“統(tǒng)一”二字的精髓。2. 架構(gòu)分層與核心技術(shù)選型確定要做統(tǒng)一查詢模型之后接下來的任務(wù)就是搭架子。UniSearch整體分為三層接入層、編排層、適配層。這三層各管各的事互不越界。2.1 接入層標(biāo)準(zhǔn)REST接口與參數(shù)設(shè)計接入層是一層薄薄的HTTP API核心就是參數(shù)校檢和統(tǒng)一鑒權(quán)。我建議接口體設(shè)計成這樣{ query: 華為手機, filters: { status: ON_SALE, category_id: [12, 13, 14] }, sort: _score, from: 0, size: 20, highlight: true, data_sources: [mysql_products, es_docs, api_stock] }這個設(shè)計里有一個容易被人忽略的點data_sources字段。默認(rèn)情況下UniSearch會查所有已注冊的數(shù)據(jù)源但很多調(diào)用方其實只關(guān)心其中某幾個。顯式聲明之后編排層能省掉一大半無意義的查詢分發(fā)。鑒權(quán)方面我只推薦兩種內(nèi)部系統(tǒng)用簽名校驗外部系統(tǒng)走OAuth2的client credentials模式。不要用簡單的API Key打在URL上線上日志一刷全是泄露的憑證。2.2 編排層查詢分發(fā)與并行調(diào)度編排層是整個UniSearch的心臟負(fù)責(zé)決定“這個查詢要怎么拆”。一個查詢進(jìn)來后先做意圖識別——業(yè)務(wù)簡單時可以用基于規(guī)則的方式來識別比如命中某些正則就路由到對應(yīng)數(shù)據(jù)源復(fù)雜度上來之后可以訓(xùn)練一個輕量級分類器來做這件事。我這次沒有上分類模型因為業(yè)務(wù)場景足夠收斂凡是帶status過濾的基本都查訂單和商品凡是帶highlight的在ES查詢里一定用得上。分發(fā)策略上我最想強調(diào)一點并行永遠(yuǎn)比串行好但要有下限保護(hù)。所有數(shù)據(jù)源同時發(fā)起請求等待最慢的那個返回后統(tǒng)一合并。這樣響應(yīng)時間等于最慢數(shù)據(jù)源的耗時而不是所有數(shù)據(jù)源耗時之和。但要給每個數(shù)據(jù)源的請求設(shè)置超時上限比如統(tǒng)一1000毫秒超時的數(shù)據(jù)源直接降級——返回空結(jié)果并記錄一條warning日志而不是讓整個UniSearch都跟著卡死。2.3 適配層五種數(shù)據(jù)源連接器的實現(xiàn)要點我這次接的適配器包括MySQL、Elasticsearch、HTTP API、Redis緩存和CSV靜態(tài)文件每種都有各自的實現(xiàn)要點MySQL連接器要處理的是動態(tài)SQL拼接。關(guān)鍵詞中的特殊字符必須用參數(shù)綁定而不是字符串拼接防止注入。排序字段要做白名單校驗絕不允許把前端傳來的字符串直接拼進(jìn)ORDER BY。Elasticsearch連接器要做的是查詢語法翻譯。用戶輸入的name:華為 AND price:[1000 TO 3000]這種Lucene語法需要由適配器解析后在內(nèi)部轉(zhuǎn)換成結(jié)構(gòu)化Query對象而不是直接把字符串透傳給ES的query_string查詢否則用戶一旦傳錯語法ES直接返回400。HTTP API連接器最麻煩的是參數(shù)映射和重試。不同系統(tǒng)對參數(shù)命名習(xí)慣完全不同有的叫keyword有的叫search_text有的叫q。適配器里維護(hù)一份字段映射表通過配置而不是硬編碼來管理。Redis連接器在這里不是做緩存而是做熱搜榜和聯(lián)想詞數(shù)據(jù)源。如果業(yè)務(wù)里需要“搜索框下面顯示熱門搜索詞”這種功能直接從Redis的ZSET里取top N即可。CSV連接器適合處理那種低頻更新的基礎(chǔ)數(shù)據(jù)比如產(chǎn)品規(guī)格表、地區(qū)代碼表。每當(dāng)文件更新后把數(shù)據(jù)加載進(jìn)內(nèi)存建立簡單的倒排索引查詢時直接在內(nèi)存里跑。2.4 數(shù)據(jù)一致性方案雙寫與事件同步做統(tǒng)一搜索時一個無法回避的問題如果一個數(shù)據(jù)源里的數(shù)據(jù)變了UniSearch怎么感知我采用的方案是“業(yè)務(wù)主庫負(fù)責(zé)寫UniSearch負(fù)責(zé)讀”的事件驅(qū)動模式。具體做法是業(yè)務(wù)系統(tǒng)在數(shù)據(jù)變更時發(fā)送MQ消息UniSearch消費消息后更新對應(yīng)的ES索引或清理Redis緩存。這里有一個大坑如果只依賴MQ很容易出現(xiàn)消息丟失導(dǎo)致數(shù)據(jù)不一致。所以我還保留了一個每天凌晨的全量對賬任務(wù)比對MySQL和ES里的記錄數(shù)及關(guān)鍵字段的校驗值發(fā)現(xiàn)不一致的自動重新同步。提示沒有對賬機制的統(tǒng)一搜索本質(zhì)上就是一個定時會“出現(xiàn)臟數(shù)據(jù)”的搜索。生產(chǎn)環(huán)境上線之前一定要把全量對賬定時任務(wù)安排上。3. 核心流程的工程化實現(xiàn)理念講得再多最后還是要落到代碼。我按一個真實的查詢請求從進(jìn)來到返回的完整鏈路把每個關(guān)鍵環(huán)節(jié)的工程實現(xiàn)都拆開講一遍。3.1 查詢預(yù)處理同義詞、糾錯、分詞策略原始查詢詞不能直接拿去查數(shù)據(jù)源。首先要做的是查詢預(yù)處理。其中同義詞擴(kuò)展能讓“筆記本”同時命中“筆記本電腦”和“l(fā)aptop”。實現(xiàn)上可以通過配置一個同義詞詞典在查詢時做檢索詞擴(kuò)展。其次是糾錯這里我用的是編輯距離算法配合業(yè)務(wù)詞表庫當(dāng)用戶輸入“華偽手機”時能識別出“華為手機”才是正確的查詢詞。需要注意的是糾錯一定要區(qū)分“建議糾正”和“自動糾正”前者是在搜索結(jié)果下方提示“您是不是要找華為手機”后者是直接拿修正后的詞去查詢。我個人的經(jīng)驗是默認(rèn)用建議式糾正只有用戶明確點擊后再執(zhí)行新查詢自動糾正太容易翻車。分詞策略上有一個重要原則索引期分詞和查詢期分詞必須保持一致否則會出現(xiàn)明明數(shù)據(jù)在那、卻搜不出來的情況。比如MySQL數(shù)據(jù)源針對中文商品名做檢索如果索引時期用的是普通的ngram分詞查詢時期的預(yù)處理分詞也必須支持同種切法。如果一致性無法保證寧可用簡單的contains匹配也不要上一個花哨的、不可控的分詞算法。3.2 多路結(jié)果的歸一化與字段對齊并行查詢返回的四五份結(jié)果形態(tài)各異需要把他們轉(zhuǎn)成統(tǒng)一的結(jié)果對象。這個過程我稱之為歸一元組。具體的歸一化規(guī)則如下表數(shù)據(jù)源類型原始返回結(jié)構(gòu)歸一化后的關(guān)鍵字段MySQL行記錄每行是一個dictid,title,summary,url,score,sourceElasticsearch命中_source及highlightid,title,summary,url,score,sourceHTTP API返回值自定義JSON結(jié)構(gòu)id,title,summary,url,score,sourceRedis熱詞ZSET member和scoreid為空title為熱詞score為熱度CSV內(nèi)存索引內(nèi)存對象id,title,summary,url,score,source這里有一個細(xì)節(jié)值得注意score的歸一化。MySQL和API適配器本來是不產(chǎn)生相關(guān)性分?jǐn)?shù)的我通常會在適配器里根據(jù)命中字段的重要程度去手動計算一個基礎(chǔ)分值。比如命中標(biāo)題給3分命中摘要給1分命中標(biāo)簽給2分這個邏輯放在適配層做。3.3 融合排序策略加權(quán)、歸一化與去重所有數(shù)據(jù)源的結(jié)果匯集到一起之后面臨的難題是這些分?jǐn)?shù)并不可直接比較。ES返回0.8的分?jǐn)?shù)和MySQL里我手算的3分量綱完全不同。直接按原始分?jǐn)?shù)排序沒有任何意義。所以我采用了先歸一化、再加權(quán)、再融合的三步策略。歸一化可以用min-max歸一化或者使用Wilson下限來平滑處理避免單條極端分?jǐn)?shù)帶偏整體。加權(quán)則需要按照業(yè)務(wù)定義數(shù)據(jù)源權(quán)重。比如訂單這個數(shù)據(jù)源權(quán)重是1.0商品是0.9文檔是0.6熱詞聯(lián)想是0.3最終綜合得分的公式大致是final_score normalized_score(source) * source_weight去重策略上面我處理的是跨數(shù)據(jù)源重復(fù)的情況。比如商品和文檔同時引用了同一篇文章的地址或訂單和商品數(shù)據(jù)源里回到同一個商品ID。去重邏輯是基于ID加URL的聯(lián)合唯一鍵來判斷。保留哪條優(yōu)先級順序是API結(jié)果 MySQL ES CSV高優(yōu)先級的保留其余的被排除。3.4 分頁與深翻頁問題的工程解法統(tǒng)一搜索還有個細(xì)節(jié)讓很多人頭疼分頁。Elasticsearch超過1萬條深翻頁時會報錯MySQL深度分頁也慢得離譜而API接口壓根不支持from加size特別大的查詢。所以UniSearch的默認(rèn)分頁上限是100頁每頁最多50條。超過這個范圍的查詢有兩種降級方案可選一種是返回提示讓用戶收窄條件另一種是利用ES的search_after做深翻頁游標(biāo)同時MySQL側(cè)改用“最后一個可見ID”來做keyset分頁。我的建議是優(yōu)先用方式一畢竟正經(jīng)用戶不會翻到100頁以后。3.5 結(jié)果緩存與性能兜底在緩存層上我用了兩級緩存策略。L1是進(jìn)程內(nèi)緩存使用TTL為30秒適合命中熱點查詢L2是Redis緩存TTL為5分鐘。查詢請求進(jìn)來先查L1沒命中再查L2最后才真正分發(fā)到底層數(shù)據(jù)源。但緩存粒度不應(yīng)該是整個查詢結(jié)果而應(yīng)該是數(shù)據(jù)源級別的結(jié)果片段。因為當(dāng)四個數(shù)據(jù)源里只有一個數(shù)據(jù)變了如果緩存的是合并后的完整結(jié)果其他三個數(shù)據(jù)源的數(shù)據(jù)會被無謂地過期。單獨緩存各數(shù)據(jù)源返回的原始片段再在編排層做融合可以最大程度提高緩存命中率。4. 上線調(diào)試與問題排查實錄UniSearch這種糅合了多個系統(tǒng)的中間層一旦出問題表象往往是一坨亂麻。這一章把我線上遇到過的典型問題和排查思路整理出來你可以當(dāng)做一個速查表來用。4.1 請求超時罪魁禍?zhǔn)淄恰按小钡谝淮紊暇€壓測時P99延遲高達(dá)4.8秒這顯然不可接受。排查發(fā)現(xiàn)問題不在某個數(shù)據(jù)源性能差而在于我的初版編排邏輯是按順序調(diào)用各個適配器。每個適配器平均耗時200毫秒串行調(diào)五個就是1秒加上網(wǎng)絡(luò)開銷和數(shù)據(jù)庫慢查詢延遲自然爆炸。改造方案是把串行調(diào)用改成Java虛擬線程或Python asyncio并發(fā)調(diào)度整體吞吐從每秒300漲到2000。如果你用的是Java項目CompletableFuture或者虛擬線程都行如果是Gogoroutine加channel天然支持Python的話我會夸一句asyncio。但我不推薦用裸線程池因為線程池耗盡太容易造成雪崩。經(jīng)驗編排層強制要求所有數(shù)據(jù)源調(diào)用必須并行發(fā)起并且設(shè)置統(tǒng)一的超時上限這個是UniSearch性能的第一命門。4.2 數(shù)據(jù)源返回慢不能拖死整體壓測中還發(fā)現(xiàn)如果其中一個API數(shù)據(jù)源響應(yīng)卡頓幾秒所有請求都會被阻塞。后來我把超時控制做成“逐源設(shè)置”的配置項默認(rèn)統(tǒng)一1000毫秒但對關(guān)鍵核心數(shù)據(jù)源可以放寬到1500毫秒對非關(guān)鍵數(shù)據(jù)源比如熱詞、聯(lián)想只給300毫秒。超時的數(shù)據(jù)源不再等待直接降級為空結(jié)果在返回體里帶上timeout_sources字段告訴前端這次查詢哪些源超時了。這樣即使某一個下游系統(tǒng)故障主鏈路依然是穩(wěn)定的。4.3 數(shù)據(jù)源返回慢不能拖死整體調(diào)參時發(fā)現(xiàn)ES查詢耗時最大的瓶頸來自兩次查詢一次普通請求一次獲取total命中數(shù)。ES默認(rèn)會在每次查詢時計算精確總數(shù)當(dāng)索引體量很大的時候這種計算代價很高。我把ES的track_total_hits設(shè)置為false返回總數(shù)改為近似值查詢性能提升35%。對搜索結(jié)果而言100頁以內(nèi)的總數(shù)用近似值完全能滿足需求沒必要讓精確計數(shù)拖慢整體響應(yīng)。4.4 Redis緩存穿透與更新風(fēng)暴上線初期緩存穿透非常嚴(yán)重。熱詞數(shù)據(jù)源本身數(shù)據(jù)量不大但是用戶搜索時經(jīng)常輸入一個冷門詞結(jié)果Redis沒緩存直接穿透到后端而MySQL又拿不到結(jié)果就變成“緩存擊穿空結(jié)果”的雙重浪費。我的解法除了常規(guī)的空值緩存之外還加了一層布隆過濾器在業(yè)務(wù)側(cè)維護(hù)一個基于所有已知有效查詢詞的布隆過濾器。攔截掉明顯不存在的查詢MySQL的壓力直接下降了一個數(shù)量級。4.5 結(jié)果排序不穩(wěn)定導(dǎo)致投訴有一次業(yè)務(wù)方反饋同一個關(guān)鍵詞搜索的結(jié)果過幾分鐘再搜就發(fā)現(xiàn)順序變了。查下來是融合排序時分段歸一化算法的問題對于數(shù)量很少的分片結(jié)果min-max歸一化會放大微小差異同一句話在不同時間點檢索甚至只因ES分片間的tie_breaker不同就讓分?jǐn)?shù)發(fā)生細(xì)微抖動。最后我把歸一化的下限做了鉗制設(shè)置MIN_SCORE0.1保證所有結(jié)果的基礎(chǔ)分不低于0.1就不會出現(xiàn)因為微小波動導(dǎo)致的排序漂移。4.6 日志與追蹤統(tǒng)一檢索必須帶TraceId這是我覺得整塊項目里最值得做的事情之一。UniSearch一次查詢會觸達(dá)多個數(shù)據(jù)源一旦線上有問題沒有日志鏈路就是在黑暗里摸大象。我強制要求日志系統(tǒng)里集成TraceId從接入層生成貫穿編排層、適配層直到底層數(shù)據(jù)源。每個數(shù)據(jù)源的耗時、返回條數(shù)、異常堆棧都掛在這個TraceId下。后續(xù)排查的時候只需要用TraceId搜索一次完整的調(diào)用鏈就能定位問題。這個習(xí)慣我從一開始就建了中間有一次線上故障能十分鐘之內(nèi)定位全靠它。5. 工具、參數(shù)與調(diào)優(yōu)細(xì)節(jié)總結(jié)最后把我在整個把UniSearch項目中實際使用過的工具和一些重要的調(diào)優(yōu)參數(shù)整理一下。不是每一家公司的技術(shù)棧一樣所以工具選型僅作參考。5.1 基礎(chǔ)設(shè)施選型參考組件選用方案理由接入層APIFastAPI / Spring Boot高并發(fā)性能好社區(qū)資源豐富異步編排Python asyncio / Java CompletableFuture天然支持并發(fā)調(diào)用全文檢索引擎Elasticsearch 8.x成熟穩(wěn)定生態(tài)完善業(yè)務(wù)數(shù)據(jù)庫MySQL 8.0業(yè)務(wù)主存儲穩(wěn)定可靠緩存Redis 7.x支持ZSET熱榜及多級緩存MQ消息RabbitMQ支撐雙寫事件同步監(jiān)控Prometheus Grafana覆蓋接口QPS、P99時延、各適配器狀態(tài)5.2 一鍵查詢鏈路耗時監(jiān)控每個適配器的查詢耗時必須拆開觀察。我在Grafana里建了十幾個看板但日常最常用的是三個總接口請求量及響應(yīng)時間曲線、各數(shù)據(jù)源平均耗時對比柱狀圖、各數(shù)據(jù)源超時次數(shù)統(tǒng)計表。每次發(fā)布新版本之后第一件事不是看功能對不對而是看適配器耗時有沒有異常波動。如果某個源的平均耗時從200毫秒突增到800毫秒不管業(yè)務(wù)方怎么說“沒問題”都要主動去查一下對方系統(tǒng)的負(fù)載。5.3 上線前必做的三件準(zhǔn)備工作第一件梳理清楚哪些數(shù)據(jù)源是強依賴、哪些是弱依賴。強依賴掛了整個搜索不可用弱依賴掛了可以容忍。這個梳理結(jié)果決定了超時和降級方案怎么設(shè)計。第二件建立一套全鏈路的自動化回歸用例。針對每個數(shù)據(jù)源準(zhǔn)備標(biāo)準(zhǔn)查詢集預(yù)期結(jié)果集鎖定在固定基線里。每次代碼改動先跑這組用例避免只修了A數(shù)據(jù)源卻把B數(shù)據(jù)源的適配邏輯弄壞了。第三件準(zhǔn)備好對賬腳本。我建議做兩級對賬數(shù)據(jù)記錄數(shù)對賬和字段級對賬。先比數(shù)量再比關(guān)鍵字段的哈希一致性這是防數(shù)據(jù)漂移的兜底。提示不要在下班前一小時首次灰度發(fā)布統(tǒng)一搜索項目。第一晚大概率會有各種數(shù)據(jù)源連接和權(quán)限的幺蛾子留足排查時間比什么都重要。6. 下一階段演進(jìn)方向UniSearch做到這里已經(jīng)能穩(wěn)定支撐日常查詢了。但如果業(yè)務(wù)量再往上走有幾個演進(jìn)點是我明確計劃要做的。第一個是引入真正的個人化排序基于用戶的歷史點擊、收藏行為訓(xùn)練一個輕量級的learning to rank模型把融合排序從手工加權(quán)升級為模型打分。第二個是增加搜索熱力統(tǒng)計從Redis熱詞出發(fā)把用戶每天的搜索詞、點擊結(jié)果、跳失率匯入數(shù)據(jù)倉庫形成業(yè)務(wù)洞察報表。第三個是更強的同義詞動態(tài)挖掘現(xiàn)在用的靜態(tài)詞表維護(hù)成本高后續(xù)計劃基于用戶搜索日志里的共現(xiàn)詞挖掘同義詞實現(xiàn)詞表的自我更新。我個人在這整件事中最大的體會是做統(tǒng)一搜索難點從來不在搜索本身而在“統(tǒng)一”二字。統(tǒng)一意味著你要面對各種歷史系統(tǒng)的怪癖面對無法控制的字段命名面對每一個數(shù)據(jù)源獨有的查詢語法和限制。與其想著一套代碼適配全部不如把適配層的邊界劃清楚把統(tǒng)一查詢模型設(shè)計得足夠通用。只要這兩件事做踏實UniSearch這個名字才真正配得上。最后再分享一個小技巧上線那天先在測試環(huán)境用真實用戶搜索日志回放壓測一遍不要用模擬流量因為只有真實搜索日志才會暴露出那些你完全想不到的奇葩查詢詞和冷門字段組合這一條比任何架構(gòu)設(shè)計都更能幫你省去后續(xù)的救火時間。