:云原生搜索服務(wù)快速入門)
1. 為什么我會盯上 Quarkus 和 Elasticsearch 的組合第一次看到elasticsearch-quickstart這個示例項目名的時候我的直覺是又是一個跑通就完事的官方 Demo。但真正把代碼拉下來跑了一遍之后我發(fā)現(xiàn)它其實解決了一個很實際的問題——在云原生場景下如何讓一個 Java 服務(wù)以極低的內(nèi)存占用和極快的啟動速度去對接 Elasticsearch 做全文檢索。傳統(tǒng) Spring Boot 應(yīng)用打包出來動輒七八十兆啟動要好幾秒JVM 預(yù)熱之后內(nèi)存占用輕松上 G。如果只是做一個搜索接口這個成本實在太高。Quarkus 的定位就是把這個成本壓下來編譯期做盡可能多的事情運行期只留必要的邏輯。配合 GraalVM 做原生鏡像啟動時間可以壓到幾十毫秒級別內(nèi)存占用也能降到幾十兆。這個 quickstart 適合誰看我認(rèn)為有三類人值得花時間研究一是正在做微服務(wù)拆分、手里有一堆小服務(wù)需要瘦身的后端開發(fā)者二是想給搜索功能找一個輕量級宿主框架的工程師三是單純想了解 Quarkus 這套編譯期增強思路到底怎么落地的人。它不復(fù)雜但麻雀雖小五臟俱全REST 層、數(shù)據(jù)訪問層、配置管理、原生鏡像適配該有的都有。我打算按我自己實際跑這個項目的順序來寫先講整體設(shè)計思路再拆核心細(xì)節(jié)然后是完整的實操過程最后把我踩過的坑和排查方法整理出來。你跟著走一遍基本就能把這個 quickstart 改造成自己的東西。2. 整體設(shè)計與思路拆解2.1 為什么是 Quarkus 而不是 Spring Boot先說選型邏輯。Elasticsearch 的 Java 客戶端本身不重重的是宿主框架。Spring Boot 的自動配置機制在啟動時要掃描大量類路徑、解析條件注解、構(gòu)建 Bean 定義這些工作在開發(fā)階段很方便但在生產(chǎn)環(huán)境、尤其是容器化部署時就是純粹的浪費——配置又不會在運行時變。Quarkus 的核心思路是把能提前做的事全部挪到構(gòu)建期。依賴注入的綁定關(guān)系、REST 路由的映射、配置項的解析這些在編譯階段就確定下來運行期直接查表。這帶來的直接好處是啟動快、內(nèi)存低副作用是構(gòu)建慢一點、對反射和動態(tài)代理的支持需要顯式聲明。對于elasticsearch-quickstart這種場景我認(rèn)為 Quarkus 是更合適的選擇。搜索服務(wù)通常是 IO 密集型業(yè)務(wù)邏輯不復(fù)雜啟動速度和資源占用比開發(fā)便利性更重要。而且這個 quickstart 用的是 REST 客戶端而不是完整的 Spring Data 抽象本身就沒有太多需要自動配置的東西。2.2 這個 quickstart 的分層結(jié)構(gòu)項目本身的分層很清晰我把它拆成四層來看資源層Resource暴露 REST 接口處理 HTTP 請求和響應(yīng)對應(yīng) JAX-RS 的注解體系。服務(wù)層Service封裝業(yè)務(wù)邏輯比如索引的創(chuàng)建、文檔的增刪改查。客戶端層ClientElasticsearch 的 REST 客戶端封裝負(fù)責(zé)和 ES 集群通信。配置層Config連接地址、超時、索引名等外部化配置。這個分層不是強制的但我覺得它合理。資源層只做參數(shù)校驗和響應(yīng)組裝服務(wù)層管業(yè)務(wù)客戶端層管通信職責(zé)邊界清楚。很多人在寫 quickstart 的時候喜歡把邏輯全塞進 Resource跑起來是沒問題但一旦要加緩存、加重試、加事務(wù)就會亂成一團。2.3 依賴選型的考量quickstart 里用到的關(guān)鍵依賴我逐個說一下為什么這么選依賴作用選型理由quarkus-rest-client聲明式 HTTP 客戶端比手寫 HttpClient 簡潔支持響應(yīng)式elasticsearch-javaES 官方 Java 客戶端官方維護API 穩(wěn)定支持類型安全quarkus-arcCDI 依賴注入Quarkus 內(nèi)置編譯期完成注入quarkus-jacksonJSON 序列化和 REST 層無縫集成這里有個細(xì)節(jié)值得說Elasticsearch 官方客戶端從 8.x 開始換成了新的co.elastic.clients包和老的RestHighLevelClient是兩套 API。quickstart 用的是新客戶端好處是類型安全、支持 lambda 風(fēng)格的查詢構(gòu)建壞處是網(wǎng)上大部分教程還是老 API遇到問題搜到的答案可能對不上。我建議直接用新 API別回頭。提示新客戶端依賴 Jackson 做序列化如果你的項目里同時引入了其他 JSON 庫注意版本沖突尤其是jackson-databind的版本要統(tǒng)一。3. 核心細(xì)節(jié)解析與實操要點3.1 客戶端初始化別在每次請求里 new 一個這是我最想強調(diào)的一點。Elasticsearch 客戶端的創(chuàng)建成本不低它內(nèi)部維護連接池、線程池、節(jié)點發(fā)現(xiàn)邏輯。如果你在每次請求里都new一個性能會慘不忍睹而且連接泄漏是遲早的事。正確的做法是把它做成單例交給 CDI 管理。在 Quarkus 里可以這樣寫ApplicationScoped public class ElasticsearchClientProducer { ConfigProperty(name elasticsearch.host) String host; ConfigProperty(name elasticsearch.port, defaultValue 9200) int port; private ElasticsearchClient client; PostConstruct void init() { RestClient restClient RestClient.builder( new HttpHost(host, port)).build(); ElasticsearchTransport transport new RestClientTransport( restClient, new JacksonJsonpMapper()); this.client new ElasticsearchClient(transport); } Produces ApplicationScoped public ElasticsearchClient client() { return client; } }ApplicationScoped保證整個應(yīng)用只有一個實例PostConstruct在 Bean 創(chuàng)建時初始化連接。這樣客戶端在應(yīng)用啟動時就建好了后續(xù)所有請求復(fù)用同一個。3.2 索引操作的冪等性處理quickstart 里有個容易被忽略的點創(chuàng)建索引。如果索引已經(jīng)存在直接創(chuàng)建會報錯。生產(chǎn)環(huán)境里服務(wù)重啟是常態(tài)所以創(chuàng)建索引的邏輯必須冪等。我的做法是先判斷存在性再決定是否創(chuàng)建public void createIndexIfNotExists(String indexName) throws IOException { Boolean exists client.indices() .exists(e - e.index(indexName)) .value(); if (!exists) { client.indices().create(c - c.index(indexName)); } }這里用到了新客戶端的 lambda 風(fēng)格構(gòu)建器e - e.index(indexName)這種寫法比老的CreateIndexRequest直觀很多。exists返回的是一個包裝對象.value()取布爾值。注意判斷存在性和創(chuàng)建索引之間存在競態(tài)條件。如果多個實例同時啟動可能都判斷為不存在然后同時創(chuàng)建其中一個會失敗。生產(chǎn)環(huán)境要么用分布式鎖要么捕獲resource_already_exists_exception異常忽略掉。3.3 文檔的增刪改查文檔操作是搜索服務(wù)的核心。quickstart 里演示了基本的 CRUD我補充幾個實操中容易出問題的細(xì)節(jié)。寫入文檔時索引名和文檔 ID 的指定方式IndexResponse response client.index(i - i .index(products) .id(product.getId()) .document(product) );如果不指定 IDES 會自動生成一個。指定 ID 的好處是冪等——同樣的 ID 重復(fù)寫入會覆蓋而不是新增。但要注意覆蓋是整體替換不是字段合并。查詢文檔時新客戶端的返回類型是泛型化的GetResponseProduct response client.get(g - g .index(products) .id(id), Product.class); if (response.found()) { Product product response.source(); }found()判斷文檔是否存在source()取反序列化后的對象。這里有個坑如果索引的 mapping 和你的實體類字段對不上反序列化會靜默丟字段不會報錯。我建議在開發(fā)階段打開客戶端的日志確認(rèn)字段映射正確。3.4 配置管理別把連接信息寫死在代碼里Quarkus 的配置體系基于 MicroProfile Config支持多種來源application.properties、環(huán)境變量、系統(tǒng)屬性。優(yōu)先級是系統(tǒng)屬性 環(huán)境變量 配置文件。application.properties里的典型配置elasticsearch.hostlocalhost elasticsearch.port9200 elasticsearch.index.productsproducts elasticsearch.timeout5000在代碼里用ConfigProperty注入。這樣做的好處是容器化部署時可以通過環(huán)境變量覆蓋不用重新打包。提示配置項命名建議用點分層級比如elasticsearch.index.products比esProductsIndex可讀性好得多。Quarkus 會自動做駝峰轉(zhuǎn)換兩種寫法都能注入但配置文件里保持統(tǒng)一風(fēng)格。4. 實操過程與核心環(huán)節(jié)實現(xiàn)4.1 環(huán)境準(zhǔn)備與項目初始化我實際跑這個 quickstart 的環(huán)境是JDK 17、Maven 3.9、Elasticsearch 8.x 單節(jié)點。JDK 版本很重要Quarkus 3.x 最低要求 JDK 17用 JDK 11 會直接編譯失敗。初始化項目有兩種方式。一種是用 Quarkus 的 Maven 插件生成骨架mvn io.quarkus.platform:quarkus-maven-plugin:3.x:create \ -DprojectGroupIdcom.example \ -DprojectArtifactIdelasticsearch-quickstart \ -Dextensionsrest-client,rest-jackson另一種是直接拉官方 quickstart 倉庫。我建議用第一種因為生成的骨架版本和你的 Quarkus 版本匹配不會出現(xiàn)依賴沖突。生成之后目錄結(jié)構(gòu)是這樣的elasticsearch-quickstart/ ├── src/main/java/com/example/ │ ├── GreetingResource.java │ └── ... ├── src/main/resources/ │ └── application.properties ├── src/test/java/ └── pom.xml4.2 添加 Elasticsearch 依賴在pom.xml里加上官方客戶端dependency groupIdco.elastic.clients/groupId artifactIdelasticsearch-java/artifactId version8.x.x/version /dependency dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId /dependency版本號要和你的 ES 服務(wù)端匹配??蛻舳撕头?wù)端大版本必須一致小版本可以不同。比如服務(wù)端是 8.11客戶端用 8.10 或 8.12 都行但用 7.x 的客戶端連 8.x 的服務(wù)端會報兼容性錯誤。4.3 編寫客戶端生產(chǎn)者前面 3.1 已經(jīng)給了代碼這里補充一個細(xì)節(jié)連接超時和 socket 超時的設(shè)置。RestClient restClient RestClient.builder(new HttpHost(host, port)) .setRequestConfigCallback(builder - builder .setConnectTimeout(5000) .setSocketTimeout(60000)) .build();connectTimeout是建立 TCP 連接的超時socketTimeout是等待響應(yīng)的超時。搜索請求可能比較慢socketTimeout要給足我一般設(shè) 60 秒。connectTimeout可以短一點5 秒足夠連不上就快速失敗。4.4 實現(xiàn) REST 接口資源層的代碼大概長這樣Path(/products) Produces(MediaType.APPLICATION_JSON) Consumes(MediaType.APPLICATION_JSON) public class ProductResource { Inject ProductService productService; POST public Response create(Product product) throws IOException { productService.index(product); return Response.status(Response.Status.CREATED).build(); } GET Path(/{id}) public Response getById(PathParam(id) String id) throws IOException { Product product productService.findById(id); if (product null) { return Response.status(Response.Status.NOT_FOUND).build(); } return Response.ok(product).build(); } GET Path(/search) public ListProduct search(QueryParam(q) String keyword) throws IOException { return productService.search(keyword); } }注意Inject注入的是服務(wù)層不是直接注入客戶端。這樣資源層不關(guān)心 ES 的細(xì)節(jié)測試的時候也容易 mock。4.5 實現(xiàn)搜索邏輯搜索是重點我詳細(xì)說一下查詢構(gòu)建。最簡單的全文搜索public ListProduct search(String keyword) throws IOException { SearchResponseProduct response client.search(s - s .index(products) .query(q - q .multiMatch(m - m .fields(name, description) .query(keyword))), Product.class); return response.hits().hits().stream() .map(Hit::source) .collect(Collectors.toList()); }multiMatch會在多個字段里搜索關(guān)鍵詞fields指定字段列表。ES 默認(rèn)會對搜索結(jié)果做相關(guān)性排序_score高的排前面。如果要加分頁.from(0) .size(20)from是起始偏移size是返回條數(shù)。注意from size不能超過index.max_result_window默認(rèn) 10000超過會報錯。深度分頁要用search_after這個 quickstart 里沒涉及但生產(chǎn)環(huán)境遲早會遇到。4.6 打包與運行開發(fā)模式直接跑./mvnw quarkus:dev這個模式支持熱重載改代碼保存后自動重新編譯調(diào)試很方便。生產(chǎn)打包./mvnw package會生成一個quarkus-run.jar這是 Quarkus 特有的可執(zhí)行 jar內(nèi)部結(jié)構(gòu)經(jīng)過優(yōu)化啟動比普通 fat jar 快。如果要原生鏡像./mvnw package -Pnative這個需要本地裝了 GraalVM或者用容器構(gòu)建。原生鏡像的啟動速度是 JVM 模式的幾十倍但構(gòu)建時間長而且反射、動態(tài)代理相關(guān)的代碼需要額外配置。quickstart 里的代碼基本都能直接編譯成原生鏡像因為 Quarkus 的擴展已經(jīng)處理好了大部分適配。注意原生鏡像模式下Jackson 的反序列化需要注冊反射信息。如果你的實體類字段沒有對應(yīng)的 getter/setter可能會反序列化失敗。Quarkus 的 Jackson 擴展會自動掃描但自定義的序列化器需要手動注冊。5. 常見問題與排查技巧實錄5.1 連接被拒絕先查端口再查綁定地址最常見的報錯是Connection refused。排查順序確認(rèn) ES 服務(wù)在跑curl localhost:9200確認(rèn)端口對默認(rèn) 9200有些部署會改確認(rèn)綁定地址ES 默認(rèn)只綁localhost容器里跑的話要改成0.0.0.0第三步是最容易踩的坑。本地開發(fā)時 ES 和 Quarkus 在同一臺機器localhost沒問題一旦 ES 跑在容器里Quarkus 跑在宿主機localhost就指向宿主機自己了連不上容器。這時候要用容器的 IP 或者服務(wù)名。5.2 版本不匹配客戶端和服務(wù)端大版本必須一致報錯信息通常是UnsupportedProductException或者響應(yīng)解析失敗。ES 客戶端會在握手時檢查服務(wù)端版本大版本不一致直接拒絕。解決辦法就是對齊版本。查服務(wù)端版本curl localhost:9200返回的 JSON 里有version.number字段。然后改pom.xml里的客戶端版本。5.3 索引 mapping 沖突字段類型對不上如果你先寫入了一條price是數(shù)字的文檔再寫入一條price是字符串的ES 會報mapper_parsing_exception。因為 ES 的 mapping 一旦確定就不能改字段類型。解決辦法是提前定義好 mapping或者在開發(fā)階段直接刪索引重建curl -X DELETE localhost:9200/products生產(chǎn)環(huán)境不能這么干要用 reindex 或者別名切換。5.4 中文搜索分詞問題默認(rèn)的分詞器對中文是按字切分的搜索手機會匹配到所有包含手或機的文檔召回率高但準(zhǔn)確率低。要裝中文分詞插件然后在 mapping 里指定分詞器。這個 quickstart 沒涉及但如果你要做中文搜索這是繞不過去的。我的建議是先用默認(rèn)分詞器跑通流程再根據(jù)實際效果決定要不要上插件。5.5 常見問題速查表現(xiàn)象可能原因排查方向Connection refused服務(wù)未啟動/端口錯/綁定地址錯curl 測試連通性UnsupportedProductException客戶端服務(wù)端版本不匹配對齊大版本號mapper_parsing_exception字段類型沖突檢查 mapping必要時重建索引反序列化丟字段字段名不匹配/缺 getter打開客戶端日志核對字段原生鏡像啟動報錯反射信息缺失檢查是否需要 RegisterForReflection搜索無結(jié)果分詞問題/字段名錯用 _analyze API 測試分詞5.6 我踩過的幾個坑第一個坑是忘記關(guān)客戶端。應(yīng)用關(guān)閉時如果不調(diào)用client.close()連接池不會釋放在容器里反復(fù)重啟會積累大量 TIME_WAIT 連接。解決辦法是加PreDestroy鉤子PreDestroy void cleanup() throws IOException { if (client ! null) { client._transport().close(); } }第二個坑是在循環(huán)里調(diào)用 ES。我一開始寫批量導(dǎo)入的時候for 循環(huán)里一條條 index一千條數(shù)據(jù)跑了幾十秒。后來改成bulk批量接口同樣的數(shù)據(jù)一秒不到。ES 的 bulk API 一次可以提交幾千條性能差距是數(shù)量級的。第三個坑是忽略了 refresh 機制。ES 寫入文檔后默認(rèn) 1 秒才可見這叫 refresh interval。如果你寫完立刻查可能查不到。測試的時候可以用refreshwait_for參數(shù)強制刷新但生產(chǎn)環(huán)境別這么干會影響性能。6. 從 quickstart 到生產(chǎn)可用的改造方向quickstart 跑通只是起點真要上生產(chǎn)還有幾件事要做。連接池和重試。官方客戶端自帶節(jié)點發(fā)現(xiàn)和故障轉(zhuǎn)移但重試策略需要配置。我一般會設(shè)置最大重試次數(shù)和重試間隔避免網(wǎng)絡(luò)抖動導(dǎo)致請求直接失敗。健康檢查。Quarkus 有quarkus-smallrye-health擴展可以自定義健康檢查探針定期 ping 一下 ES不健康時讓負(fù)載均衡摘掉這個實例。指標(biāo)監(jiān)控。客戶端的請求耗時、成功率、連接池狀態(tài)都值得監(jiān)控。Quarkus 的 Micrometer 擴展可以很方便地接入。配置外置。把 ES 地址、索引名、超時參數(shù)全部放到環(huán)境變量里不同環(huán)境用不同配置代碼不用改。異常處理。ES 的異常類型很多ElasticsearchException是基類下面有ConnectionException、ResponseException等。建議在服務(wù)層統(tǒng)一捕獲轉(zhuǎn)換成業(yè)務(wù)異常別讓底層異常直接透到 REST 層。安全加固。生產(chǎn)環(huán)境的 ES 一般會開認(rèn)證和 TLS客戶端要配置用戶名密碼和證書。這部分 quickstart 沒涉及但實際部署時是必須的。我個人在實際操作中的體會是Quarkus 這套東西的學(xué)習(xí)曲線主要在編譯期思維的轉(zhuǎn)變上。習(xí)慣了 Spring 的運行期魔法之后一開始會不習(xí)慣什么都得顯式聲明。但一旦適應(yīng)了你會發(fā)現(xiàn)代碼更可控啟動更快內(nèi)存更省尤其是做微服務(wù)的時候這種優(yōu)勢會隨著服務(wù)數(shù)量增加而放大。Elasticsearch 客戶端本身不難難的是把索引設(shè)計、查詢優(yōu)化、集群運維這些周邊事情做好那是另一個話題了。