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