第一阶段 09 · 原生 DSL 直查与客户端优化(withJson 直接吃 JSON,不依赖 TermQuery 等 Builder)

📅 2026/7/31 18:02:10
第一阶段 09 · 原生 DSL 直查与客户端优化(withJson 直接吃 JSON,不依赖 TermQuery 等 Builder)
阶段第一阶段 / 核心概念04–08 的补充篇目标教你在官方 Java 客户端里直接用一段 DSL(JSON) 字符串发查询绕开TermQuery/RangeQuery/BoolQuery这一堆 Builder同时汇总一套「客户端使用优化建议」。本篇是独立文档示例不依赖任何具体项目。⚠️ 定位说明原生 DSL 直查是builder 写法的补充不是替代。05/06 的 builder 适合「动态拼条件」本篇的withJson适合「已在 Kibana 调好、想原样搬进代码」的固定查询。1. 概念为什么想直接写 DSL在 05/06 篇里一个查询要拆成一层层 lambdaQueryqQuery.of(b-b.bool(bo-bo.filter(f-f.term(t-t.field(region).value(AP))).filter(f-f.range(r-r.field(amount).gte(JsonData.of(1000))))));但很多时候你的 DSL 已经在Kibana Dev Tools里调通了是一段现成的 JSON。再手动翻译成 lambda 既费时又容易翻错。此时更想要的是把这段 JSON 原样塞进请求就能跑。官方客户端co.elastic.clients对此有一等支持几乎所有 Builder 都带一个withJson(Reader/InputStream)方法能把 JSON 反序列化进当前对象。2. PostgreSQL 对照这就像 MyBatis / JDBC 里的两种风格风格ESMyBatis / JDBC 类比Builder 拼Query.of(q - q.term(...))MyBatisif动态标签逐段拼 SQL原生 DSLwithJson(new StringReader(dsl))直接jdbcTemplate.query(原生 SQL 字符串)原生 SQL 直观、可复制粘贴、但难以安全地动态拼接原生 DSL 也是同样的取舍。3. ES DSL(JSON)先给一段能在 Kibana 直接跑的完整查询体含 query 排序 分页 字段裁剪{query:{bool:{filter:[{term:{region:AP}},{range:{amount:{gte:1000}}}]}},sort:[{invoice_dt:{order:desc}}],from:0,size:20,_source:[id,region,amount,invoice_dt]}只写 query 部分后面 3 种写法会用到{bool:{filter:[{term:{region:AP}},{range:{amount:{gte:1000}}}]}}4. Spring Boot 实现3 种直查写法统一约定注入官方ElasticsearchClient返回值用Map接收也可换成 DTO。AutowiredprivateElasticsearchClientclient;4.1 写法 A整个 SearchRequest 用一段 JSON最省事把「query 排序 分页 裁剪」整体 JSON 一次性灌进SearchRequest.Builder。注意JSON 里不含 indexindex 仍要在 builder 上单独设置。publicListMapString,ObjectsearchByFullDsl(Stringindex,StringdslBody)throwsIOException{SearchRequestrequestSearchRequest.of(s-s.index(index).withJson(newStringReader(dslBody)));// 把整段 DSL 灌进来SearchResponseMaprespclient.search(request,Map.class);returnresp.hits().hits().stream().map(Hit::source).filter(Objects::nonNull).collect(Collectors.toList());}调用直接把第 3 节那段「完整查询体」JSON 传进来即可。Stringdsl{ \query\: { \bool\: { \filter\: [ { \term\: { \region\: \AP\ } }, { \range\: { \amount\: { \gte\: 1000 } } } ] } }, \size\: 20 };ListMapString,ObjectrowssearchByFullDsl(orders_idx,dsl);4.2 写法 B只有 query 部分用 JSON排序/分页仍用 builder推荐生产里最实用筛选条件用 JSON复用 Kibana排序分页字段裁剪用 builder更可控。publicListMapString,ObjectsearchByQueryDsl(Stringindex,StringqueryJson,intsize)throwsIOException{// 只把 query 那一段 JSON 反序列化成 Query 对象QueryquerynewQuery.Builder().withJson(newStringReader(queryJson)).build();SearchResponseMaprespclient.search(s-s.index(index).query(query)// 复用 DSL 得到的 Query.size(size).sort(so-so.field(f-f.field(invoice_dt).order(SortOrder.Desc))).source(src-src.filter(sf-sf.includes(id,region,amount))),Map.class);returnresp.hits().hits().stream().map(Hit::source).filter(Objects::nonNull).collect(Collectors.toList());}调用时queryJson就是第 3 节「只写 query 部分」的那段。4.3 写法 C从外部文件 / classpath 加载 DSL 模板把常用 DSL 放到resources/es-dsl/*.json运行时读进来代码和查询解耦publicListMapString,ObjectsearchByTemplate(Stringindex,StringclasspathJson)throwsIOException{try(InputStreamingetClass().getResourceAsStream(classpathJson)){if(innull){thrownewIllegalArgumentException(DSL 模板不存在: classpathJson);}SearchRequestrequestSearchRequest.of(s-s.index(index).withJson(in));SearchResponseMaprespclient.search(request,Map.class);returnresp.hits().hits().stream().map(Hit::source).filter(Objects::nonNull).collect(Collectors.toList());}}// 例resources/es-dsl/top_orders.jsonListMapString,ObjectrowssearchByTemplate(orders_idx,/es-dsl/top_orders.json);5. 参数化给 DSL 模板填占位符关键别用字符串硬拼直查最大的坑是「动态值」。绝对不要用拼用户输入到 JSON 字符串里——既会破坏 JSON 结构也有注入风险。推荐两种安全做法5.1 占位符 转义简单场景模板里留占位符替换前对值做 JSON 转义// top_orders.json: { query: { term: { region: ${region} } }, size: ${size} }Stringdsltemplate.replace(${region},jsonEscape(userRegion))// 字符串值要转义.replace(${size},String.valueOf(size));// 数值直接转字符串// 最简 JSON 字符串转义生产建议直接用 Jackson见 5.2privateStringjsonEscape(Stringraw){returnraw.replace(\\,\\\\).replace(\,\\\);}5.2 混合写法静态骨架用 JSON动态值用 builder最推荐把「结构固定的部分」用 JSON「会变的值」仍走 builder /FieldValue既复用了 Kibana 又保留了类型安全——这是本篇最推荐的落地姿势publicQuerybuildMixed(StringstaticFilterJson,Stringregion,ListStringstatusList){returnQuery.of(q-q.bool(b-{// 1) 固定的复杂片段直接吃 JSONb.filter(newQuery.Builder().withJson(newStringReader(staticFilterJson)).build());// 2) 动态值仍用 builder安全if(StringUtils.isNotBlank(region)){b.filter(f-f.term(t-t.field(region).value(region)));}if(CollectionUtils.isNotEmpty(statusList)){b.filter(f-f.terms(t-t.field(status).terms(tv-tv.value(statusList.stream().map(FieldValue::of).collect(Collectors.toList())))));}returnb;}));}6. Builder vs 原生 DSL怎么选维度Builder05/06 篇原生 DSLwithJson可读性层层 lambda稍啰嗦就是 Kibana 里那段 JSON直观复用 Kibana 调好的查询要手动翻译易错✅ 原样粘贴动态拼条件判空✅ 天生擅长❌ 字符串拼接危险编译期类型/字段名检查✅ 部分有❌ 运行期才报错复杂固定查询function_score 等冗长✅ 简洁注入风险无⚠️ 硬拼字符串有风险经验法则条件基本固定、来自 Kibana → 用原生 DSL4.1 / 4.3。条件随入参动态变化 → 用Builder05/06 篇。又固定又动态 → 用混合写法5.2推荐默认选它。7. 客户端使用优化建议通用最佳实践不局限于本篇是整套 ES Java 客户端的落地要点ElasticsearchClient全局单例复用它线程安全底层RestClient自带连接池。千万别每次请求 new 一个否则连接泄漏、性能骤降。连接池 Keep-Alive 显式配置在RestClientBuilder上设setDefaultRequestConfig连接超时、socket 超时和setHttpClientConfigCallback最大连接数、Keep-Alive。一定写size不写默认只回 10 条最容易被误判「数据不全」。只取需要的列用_sourceincludes 裁剪字段第 19 篇减少网络与反序列化开销。精确总数才开trackTotalHits默认封顶 10000不需要精确总数就别开省性能。过滤条件优先filter而非must不打分、可缓存、更快第 05 / 13 篇。大批量取数不要靠大size用search_after PIT第 18 / 40 篇避免深分页 OOM。批量写用BulkIngester攒批第 31 篇别单条index循环写。返回类型选型临时/灵活用Map.class长期维护的接口用强类型 DTO JsonProperty映射下划线字段。超时与重试为耗时聚合单独设更长 socket 超时对幂等读做有限重试写操作重试要防重复。异常分类处理IOException网络与ElasticsearchExceptionES 返回错误可读error().type()区别对待并记录。DSL 模板外置固定 DSL 放resources/es-dsl/*.json4.3改查询不必改 Java 代码、便于评审。别用字符串硬拼动态值进 DSL一律走占位符转义或混合 builder第 5 节。索引名集中管理索引名/别名抽成常量或配置避免散落魔法字符串。纯精确匹配就关掉打分字段全是keyword、只做「命中/不命中」时用filter/constant_score跳过 BM25 打分且命中缓存更快见 7.1。7.1 keyword 全精确匹配关闭打分性能优化场景如果索引字段基本都是keyword你的查询本质是「精确匹配 / 是否命中」根本用不到相关性打分。这时把条件全放进filter或constant_score性能会实打实提升。为什么更快3 个原因原因说明跳过打分计算must/match要算 BM25TF/IDF、字段长度归一…filter只判断命中与否是纯布尔运算省掉大量浮点计算。结果可缓存filter 上下文的结果会被缓存成bitsetnode query cache同样的 filter 再来直接复用位图几乎零成本打分查询不缓存。倒排直达keyword是精确词项term/terms走倒排表直接命中 posting list非常快。写法 Abool 全放 filter最常用QueryqQuery.of(b-b.bool(bo-bo.filter(f-f.term(t-t.field(region).value(AP))).filter(f-f.terms(t-t.field(status).terms(tv-tv.value(statusList.stream().map(FieldValue::of).collect(Collectors.toList())))))));写法 Bconstant_score语义更明确我不要打分QueryqQuery.of(b-b.constantScore(cs-cs.filter(f-f.term(t-t.field(region).value(AP)))));对应 DSL{query:{bool:{filter:[{term:{region:AP}},{terms:{status:[PAID,SHIPPED]}}]}}}注意事项全 filter 后每条_score都是0或常量排序不能依赖_score必须显式sort业务字段如invoice_dt。keyword不分词不能做match那种全文模糊搜索要模糊搜就给字段加text子字段。提升幅度看场景条件重复度高、并发大时filter 缓存收益最明显即使每次条件都不同「跳过打分」这部分收益仍在。8. 本项目落地示例脱敏下面基于本项目 ES 场景做了脱敏去掉业务表名/字段名/包名给出与本篇主题DSL 直查/直建契合的推荐写法。写入、深分页等内容已归位到对应篇见 8.2。8.1 用原生 DSL(JSON) 建索引建索引不手写TypeMappingbuilder而是把整段 mapping JSON 用withJson灌进去——正是本篇提倡的「原生 DSL 直建」思路mapping 外置成 JSON改结构不用改 Java 代码和 Kibana 完全一致。// mapping JSON 从资源文件加载见 4.3而不是硬编码在 Java 里privatevoidcreateIndexIfAbsent(StringindexName,StringmappingJson)throwsIOException{booleanexistsclient.indices().exists(e-e.index(indexName)).value();if(exists){log.info(ES 索引已存在跳过创建: {},indexName);return;}CreateIndexRequestrequestCreateIndexRequest.of(b-b.index(indexName).withJson(newStringReader(mappingJson)));// ← 整段 mapping DSL 直灌booleanacknowledgedclient.indices().create(request).acknowledged();if(!acknowledged){thrownewIllegalStateException(ES 索引创建失败: indexName);}}8.2 写入与大结果集取数见对应篇这两块和「原生 DSL 直查」主题关系不大已归位到对应文档避免重复Bulk 批量写入index只在BulkRequest设一次、id 空值校验、失败只抽id reason、量大用BulkIngester见第 07 篇写操作速查与第 31 篇bulk 深入。大结果集取数 / 深分页游标概念、PIT search_after、「取空才停」、scroll 对比见第 18 篇排序分页与第 40 篇深分页与性能。9. 坑与最佳实践本篇专属withJson里不要带indexSearchRequest的 index 必须用 builder 的.index(...)设JSON 只放 query/sort/from/size/_source 等请求体内容。Query.Builder().withJson(...)吃的是「query 那一段」不是整个请求体别把外层{query: ...}也塞进去。StringReader/InputStream用完记得关文件流务必 try-with-resources。JSON 非法会在解析期抛异常先在 Kibana 跑通再搬进代码。字段名/类型错误运行期才暴露原生 DSL 没有编译期保护务必有集成测试兜底。下一篇回到第二阶段查询能力主线10-match-全文匹配.md。遇到「已在 Kibana 调好的固定查询」时回来用本篇的withJson直查即可。