3C科技 娛樂遊戲 美食旅遊 時尚美妝 親子育兒 生活休閒 金融理財 健康運動 寰宇綜合

Zi 字媒體

2017-07-25T20:27:27+00:00
加入好友
作者|Umesh Dangat譯者|大愚若智雖然 Yelp 較新的搜索引擎通常使用 Elasticsearch 作為後端,但核心的商家搜索功能依然在使用基於 Lucene 自行開發的後端,這已經是 Yelp 生產環境中最古老的系統之一。這個自定義搜索引擎的部分功能包括:分散式 Lucene 實例主 - 從架構為多種語言提供自定義的文本分析支持大部分商家搜索功能依然在用的自定義商家評級功能(例如點評、名稱、hours_open、service_areas 等業務特性)通過派生出的 Yelp 分析數據改善搜索結果的質量,如某商家最常用的查詢遺留系統存在的問題對實時索引支持欠佳我們原本的系統使用了一種主 - 從架構,主系統負責處理寫入(索引查詢),從系統負責實時流量。一個主實例負責為 Lucene 索引創建快照並將其上傳至 S3,這樣從實例就可以定期下載進而刷新數據。因此更新后的索引需要延遲一段時間才能被用於搜索查詢。一些搜索功能,例如預訂和交易無法承受這樣的延遲(幾分鐘之久),需要索引數據能夠立刻使用(最多等待幾秒鐘)。為解決這一問題,我們只能使用另一種實時存儲:Elasticsearch,並將其與商家信息(遺留的搜索存儲)并行查詢,但這意味著應用程序服務必須根據兩個結果額外計算才能獲得最終結果。隨著業務增長,這種方法無法很好地擴展,我們開始在應用程序層對結果進行合併和排序時面臨性能問題。代碼推送緩慢我們有一個規模龐大的開發者團隊在不斷努力改善搜索結果的排名演算法。工作完成後編寫好的代碼會推送至底層的搜索排名演算法。在遺留系統中,這樣的推送每天進行多次,每次需要花費數小時的時間。現在,Yelp 幾乎所有微服務都使用 PaaSTA 部署,同時遺留系統可能是 Yelp 內部使用 PaaSTA 的最大「微服務」。我們的數據已經大到需要進行分片(Shard),為此使用了一種兩層的分片方法。Geosharding:根據地理位置將商家拆分為不同邏輯索引。例如舊金山和芝加哥的商家位於不同索引中,因為分片操作有可能會在同一個國家內部進行。Microsharding:我們會進一步將每個地區索引拆分為多個「微索引」或「微分片」,為此使用了一種基於簡單模塊的方法,例如:business_id % n, where 「n」 is the number of microshards desired<<geographical_shard>>_<<micro_shard>>每個 Lucene 索引支撐的進程都有自己的服務實例,為確保可用性,還需要考慮複製問題。例如,每個_會有多個名為「副本」的實例,藉此防範實例故障或中斷。這意味著我們的服務實例數量巨大,每個實例都需要花一些時間才能啟動,因為每個實例都需要:從 S3 下載數十 GB 的數據讓 Lucene 索引預熱,預載入 Lucene 緩存計算不同數據集並將其載入內存強制進行垃圾回收,因為啟動過程會創建大量暫存對象每次代碼推送意味著必須對工作進程進行循環操作,每次都要重複上述過程。無法執行某些功能性工作對所有數據重建索引需要耗費大量時間,這意味著增加新的功能需要付出更高成本。因此我們無法執行很多操作,例如:對分片演算法進行快速迭代。對分析程序進行迭代。我們使用針對不同語言自行開發的分析程序實現文字的令牌化(Tokenize),Lucene 這樣的搜索引擎會在索引時使用特定的分析程序(執行令牌生成、剔除字 [Stopword] 篩選等工作),同時在查詢時一般也會選擇使用同一個分析程序,這樣就可以在反轉后的索引中找到令牌化的查詢字元串。更改分析器意味著要對整個語料庫重建索引,因此我們通常會儘可能避免對分析代碼庫進行優化。通過索引更多欄位改善排名。商家特徵是對搜索結果進行排名的主要因素之一。隨著商家數據日漸豐富,我們可以使用這些數據來改善搜索排名。然而此時必須使用另一個實時存儲來查詢這些商家特徵,因為對遺留系統進行改動實在是一個讓人望而生怯的過程。隨著輔助數據結構中存儲的數據量越來越多,我們逐漸開始面臨 JVM 堆的上限。我們的自定義數據不能存儲在 Lucene 索引中,但這些數據也是商家排名所必須的(例如為每個商家存儲最常用的查詢)。隨著數據量繼續增長,擴展工作也愈加困難,因為 JVM 堆的大小本身也存在局限。因此我們確信遺留系統必須大幅翻新。可新系統又該如何設計?首先一起來看看現有系統,藉此了解新系統到底需要解決哪些問題,同時不會產生任何回退。遺留系統遺留的商家搜索棧一切從傳入協調器(Coordinator)服務的搜索查詢開始。該服務負責確定要使用的地域分片(基於商家地理位置),隨後會將查詢轉發至相應分片,例如上圖簡化后的用例中,查詢會被發送至西區或東區。查詢會被廣播至該地域分片內的所有微分片(為了進行橫向擴展而進行的第二層分片)。在從 1 到 N 個微分片獲得結果后,協調器會對結果進行匯總。微分片(單一遺留搜索節點)深入看看具體的一個節點,了解如何通過查詢得到結果。搜索查詢會被轉換為 Lucene 查詢,隨後發送至 Lucene 索引。Lucene 按照 Collector 的指令返回結果流。Collector 也可以看作是負責排名的機制(Ranker),決定了結果的顯示順序。這一過程中還將應用排名邏輯。Yelp 的排名邏輯會使用一系列啟髮式方法來確定最終結果排名。這些啟髮式方法還需要參考與商家有關的某些數據:商家欄位緩存:商家的前序索引(Forward index),例如商家特徵Top 查詢信息:從用戶活動中派生出的數據雜項數據:包括與 Yelp 業務有關的數據,例如 Yelp categories藉此我們已經可以定義新系統的設計目標。下一代商家搜索系統的目標根據上文內容,我們可以將一些高層次的目標總結如下:將應用程序邏輯與所用後端解耦更快速的代碼推送簡化自定義數據的存儲和驅動搜索結果排名的索引轉發(例如特定上下文數據)實時索引應對未來數據增長的線性性能擴展我們評估了 Elasticsearch 並發現該技術可以滿足我們的一些目標。挑戰應用程序邏輯與所用後端的解耦評級代碼本身不需要知道後端運行在哪裡,因此可將這些代碼與底層搜索後端的存儲進行解耦。在我們的用例中,這些都是 Java 代碼,因此我們可以將其部署為 jar。具體來說,我們可以在分散式搜索環境中運行評級 jar,這是通過 Elasticsearch 對插件的支持做到的。我們將評級代碼與 Elasticsearch 插件的實現細節進行了妥善的隔離。 介面我們通過兩個主要定義將評級代碼與底層 Elasticsearch 庫直接解耦,這樣評級代碼就不再硬性依賴 Elasticsearch(或 Lucene),藉此可靈活地通過任何後端運行這些代碼。public interface ScorerFactory { Scorer createScorer(Map<String, Object> params); } public interface Scorer { double score(Document document); } public interface Document { <T> T get(Class<T> clz, String field); }訪問 GitHub 查看 interfaces.javaDocument 介面可供模塊 / 評級代碼查詢商戶特徵。然而評級代碼並不知道其具體實現。Document 的具體實現是由 Elasticsearch 插件注入的。Scorer 介面由模塊實現,當然它也不依賴 Elasticsearch。該 Scorer 可由 Elasticsearch 插件內部的專用類載入器(Classloader)載入。 模塊模塊也是評級代碼,其中保存了與搜索有關的核心邏輯。正是這些代碼需要每天多次推送到生產環境。這也是一種部署在 Elasticsearch 集群上的 jar,隨後需要載入 Elasticsearch 插件。 插件Elasticsearch 插件承載了評級代碼。其中主要是與 Elasticsearch 有關的連接代碼,可用於載入模塊代碼並委派用於提供評價所需的 Document。更快速的推送如上文所述,我們每天多次推送代碼,但對我們來說,不能在每次推送后重啟 Elasticsearch。由於開發的相關模塊已與 Elasticsearch 解耦,因此可在無需重啟整個 Elasticsearch 集群的情況下重載這些模塊。首先將評級 jar 上傳至 S3。隨後增加了一個 Elasticsearch REST 端點,該端點會在每次部署過程中調用,藉此讓 Elasticsearch 插件重載指定的 jar。public class YelpSearchRestAction extends BaseRestHandler { @Override protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) { moduleLoader.loadModule; //a. invoke re-loading of module.jar return channel -> channel.sendResponse(new BytesRestResponse(RestStatus.OK, content)); } }訪問 GitHub 查看 deployModule.java端點調用后將會觸發 module.jar 的載入操作,這一過程會通過一個私有的類載入器進行,可從 module.jar 中載入入口點的頂級類。public final class ModuleLoader { public synchronized void loadModule{ final Path modulePath = downloadModule; //1. download the module.jar createClassloaderAndLoadModule(modulePath); //2. create classloader and use that to load the jar } private void createClassloaderAndLoadModule(final Path modulePath){ final URLClassLoader yelpySearchClassloader = new YelpSearchPrivateClassLoader( new URL{modulePath.toUri.toURL}, this.getClass.getClassLoader //3. Create URLClassloader ); scorerFactory = Class.forName("com.yelp.search.module.YelpSearchScorerFactoryImpl", true, yelpySearchClassloader) .asSubclass(ScorerFactory.class) .getDeclaredConstructor(new Class{Environment.class}) .newInstance(environment); //4. Create instance of ScorerFactory that return the Scorer } public Scorer createScorer(Map<String, Object> params) { return scorerFactory.createScorer(params); //5. Scorer factory returning scorer, called once per query } }訪問 GitHub 查看 createClassloaderAndLoadModule.java將 module jar 下載至 Path基於 Path 創建私有的類載入器使用 module.jar 的 URL 創建 URLClassloader創建實現 ScorerFactory 的實例。請注意 asSubclass 的使用以及 5、Environment 的參數傳遞。Environment 是另一個介面,提供了模塊代碼所需的更多資源ScorerFactory 通過一個 createScorer 方法返回 Scorer 實例隨後我們就有了可重載 Scorer 的 Elasticsearch 插件代碼。class YelpSearchNativeScriptFactory implements NativeScriptFactory { public ExecutableScript newScript(@Nullable Map<String, Object> params) { Scorer scorer = moduleLoader.createScorer(params)); return new ExecutableScriptImpl(scorer); //1. Create elasticsearch executable script } }訪問 GitHub 查看 ElasticsearchPlugin.java創建 Elasticsearch 可執行腳本,為它傳遞之前曾「熱載入」的 Scorer 實例。class ExecutableScriptImpl extends AbstractDoubleSearchScript implements Document { public ExecutableScriptImpl(final Scorer scorer) { this.scorer = scorer; } public double runAsDouble { return scorer.score(this); //1. score this document }訪問 GitHub 查看 ExecutableScriptImpl.javaoElasticsearch 可執行腳本最終使用 Scorer 計分並傳遞 Document。請注意「這裡」傳遞來的內容。藉此 Elasticsearch 中的 Document 特性查詢,即 doc 值查詢才可以真正在 Elasticsearch 插件內部進行,同時模塊代碼可直接使用相關介面。載入自定義數據遺留系統最初面臨的問題之一在於,隨著時間延長,單個搜索節點的內存佔用會逐漸變大,造成這個問題的主要原因在於 JVM 堆中載入了大量輔助數據。通過使用 Elasticsearch,我們可以將大部分此類內存中數據結構「卸載」至 doc 值。我們必須確保宿主機有足夠的內存,讓 Elasticsearch 檢索這些 doc 值時可以高效利用磁碟緩存。ScriptDocValues 適合大部分類型的屬性,例如 String、Long、Double,以及 Boolean,但我們還需要支持自定義數據格式。一些商家有上下文特定的數據存儲,每次搜索時需要單獨計算。這樣才能通過搜索功能幫助商家在某些情況下獲得更高評級,例如針對以往情況,「結合過去常用的查詢,為商家關聯某一搜索查詢的概率」。我們是這樣呈現這種結構的: 自定義數據格式如果要將每個商家的此類數據存儲為 doc 值,那麼就必須進行序列化: 每個商家的自定義數據序列化布局由於查詢字元串長度不固定,可能需要佔用更多空間,因此我們決定使用正整數來代表。我們確定了一種長度值單調遞增的字元串,藉此可以使用 Long 取代 String 進而節約空間,並確保記錄的長度為固定值。假設有兩個字元串「restaurants(餐廳)」和「mexican restaurants(墨西哥餐廳)」,我們的插件將「restaurants」視作 1,將「mexican restaurants」視作 2。字元串本身可以用查詢對應的 Long 值取代,因此最終看到的將會是「1」和「2」。藉此就可以使用固定長度的 Long.Bytes 代表字元串。這樣可以更容易地對與查詢有關的數據進行序列化或反序列化。這是個簡化的例子,實踐中需要根據不同語言存儲字元串的分析后表單,例如英文中的「restaurants」可以令牌化為「restaur」。因為字元串已經替換為相應的值,現在我們就可以更改數據結構只保存 Long 和 Double 數據了:用戶查詢以及每個商家相關的值可呈現為對象列表。Class QueryContextInfo { private long queryId; private double valueOne; private double valueTwo; }訪問 GitHub 查看 QueryContextInfo.java藉此可在 Elasticsearch 中使用自定義的序列化機制,對商家的所有記錄以二進位數據類型的方式進行索引。public static byte serialize(QueryContextInfo queryContextInfoRecords) { byte bytes = new byte[Integer.BYTES + (queryContextInfoRecords.length * (Long.BYTES + 2 * (Double.BYTES)))]; ByteBuffer.wrap(bytes, 0, Integer.BYTES).putInt(queryContextInfoRecords.length); int offset = Integer.BYTES; for (QueryContextInfo queryContextInfo : queryContextInfoRecords) { ByteBuffer.wrap(bytes, offset, Long.BYTES).putLong(queryContextInfo.getQueryId); ByteBuffer.wrap(bytes, offset + Long.BYTES, Double.BYTES).putDouble(queryContextInfo.getValueOne); ByteBuffer.wrap(bytes, offset + Long.BYTES + Double.BYTES, Double.BYTES).putDouble(queryContextInfo.getValueTwo); offset += Long.BYTES + 2 * (Double.BYTES); } return bytes; }訪問 GitHub 查看 QueryContextInfoSerialize.java但這又造成了一個問題:使用 ScriptDocValues 查找二進位數據。為了支持這種功能,我們向 Elasticsearch 提交了一個補丁,通過這個補丁將能實現類似下面這樣的操作:List<ByteBuffer> queryContext = document.getList(ByteBuffer.class, "query_context");訪問 GitHub 查看 QueryLookUp.java在從 Elasticsearch 中讀取 ByteBuffer 后,即可針對所需 query_id 進行搜索,例如用戶提供的,位於序列化后 QueryContextInfo 內部的 query_id。匹配的 query_id 可以幫助我們獲取對應的數據值,例如商家的 QueryContextInfo。性能方面的收穫在構建新系統的過程中,我們花了大量時間確保該系統能實現遠超遺留搜索系統的表現。這一過程中學到了很多經驗,例如:找到瓶頸Elasticsearch Profile API 可以幫助用戶快速找到查詢中存在的瓶頸。通過分片讓計分功能實現線性擴展在我們的用例中,計分功能存在瓶頸,因為我們需要通過多種功能才可以對結果評級。我們意識到可以通過增加分片數量的方式進行水平擴展,這也意味著可以提高查詢過程中 Elasticsearch 的并行度,而每個分片也可只對更少的商家進行計分。然而這樣做也需要注意:具體數量並沒有標準的最佳做法,這完全取決於檢索規模及計分邏輯,當然還有其他因素需要考慮。增加分片數量對性能的改善幅度並非無上限的。此時只能通過不斷增加分片數量並重建索引數據,不斷嘗試和評估找出最佳值。使用 Java Profiling 工具通過使用諸如 jstack、jmap,以及 jprofiler 等 Java 工具,我們可以更全面地了解代碼中的熱區(計算密集型組件)。例如我們首次實現的二進位數據查找功能需要對整個位元組數組進行反序列化,將其轉變為 Java 對象列表(主要針對 List 進行),隨後需要線性地搜索 query_id。我們發現這個過程很慢,並且造成了更多對短壽命對象的垃圾回收操作,因為每個查詢中的每個被檢索的商家都是這樣做的。我們調整了自己的演算法,在不進行反序列化的情況下,針對序列化的數據結構進行二進位搜索。藉此即可快速搜索商家 Blob 內的 query_id。同時這也意味著無需為了將整個 Blob 反序列化為 Java 對象而增加垃圾回收的成本。結論此次將 Yelp 的核心搜索功能遷移至 Elasticsearch,可能是 Yelp 搜索團隊近年來從事的最具挑戰性的項目之一。考慮到可行性,這個項目蘊含著大量技術挑戰,而我們在項目中採用的「快速失敗」迭代模式也就顯得更加重要。在每次簡短的迭代過程中,我們主要處理了那些高風險內容,例如熱代碼載入、Elasticsearch 對自定義數據的支持,以及 Elasticsearch 的性能問題,藉此我們就可以更自信地繼續推進整個項目了,不在其他次要問題上花費太多時間。最終這個項目取得了成功,現在我們已經可以定期重建數據索引,並輕鬆添加更多欄位,進而可以用以往無法想象的方式改善評級演算法。現在我們的代碼推送可在數分鐘完成,不再需要數小時。也許更重要的是,我們終於不再需要繼續維護那套難以理解的遺留系統,開發者也可以更輕鬆地學習並掌握 Elasticsearch。你也許嘗試過InfoQ網站的搜索方式,體驗並不好。你知道公眾號上有很多優質內容,但除了在歷史列表人肉檢索,查詢渠道並不多。有沒有一種搜索方式,能整合InfoQ中文站、微信公眾號矩陣的全部資源?極客搜索,這款針對極客邦科技全站內容資源的輕量級搜索引擎,做到了!掃描下方二維碼,極客(即刻)試用!細說雲計算「細說雲計算」是InfoQ旗下關注云計算技術的垂直社群,投稿請發郵件到editors@cn.infoq.com,註明「細說雲計算投稿」即可。

本文由yidianzixun提供 原文連結

寫了 5860316篇文章,獲得 23313次喜歡
精彩推薦