ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SpringBoot整合Elasticsearch 7.2.0实战:RestHighLevelClient全套方案与避坑指南

SpringBoot整合Elasticsearch 7.2.0实战:RestHighLevelClient全套方案与避坑指南 简介这是一份针对 Spring Boot 与 Elasticsearch 7.2.0 整合开发的 PDF 技术笔记适合需要升级 Elasticsearch 版本或了解 REST 连接方式的 Java 后端开发者。内容围绕弃用 spring-boot-starter-data-elasticsearch、改直接使用 Spring Data Elasticsearch 展开重点说明 transport 与 rest 两种连接方式的差异并给出依赖引入、application.yml 配置以及基于 RestHighLevelClient 的客户端连接配置类示例。资源以 1 个 PDF 文件打包大小仅 58KB内容紧凑便于直接阅读和对照配置已有 6768 人学习下载说明其在版本适配场景下具有较高参考价值。阅读后可以快速掌握从依赖声明到客户端 Bean 创建的关键步骤同时避开 Spring Boot 默认 ES 版本过低带来的兼容性问题。1. SpringBoot整合Elasticsearch 7.2.0为什么这套方案绕不开RestHighLevelClientElasticsearch 7.2.0这个版本很特殊它正式把TransportClient标记为废弃官方推荐只保留RestHighLevelClient这一条路。很多SpringBoot项目在这个版本前后踩坑根源都是没搞懂SpringBoot自带的spring-boot-starter-data-elasticsearch只封装了low-level的RestClient和少量Repository模板真正能完成复杂查询、批量写入、索引管理的API都在elasticsearch-rest-high-level-client包里。这篇笔记要解决的就是你拿到一个SpringBoot空项目、要在上面接ES 7.2.0时从依赖、配置、CRUD到DSL查询和排错的整套落地路径适合正在做搜索功能、日志归集或业务数据同步的Java后端开发者。2. 依赖与配置类三个文件把客户端立起来2.1 依赖引入starter、rest-high-level-client和transport的三角关系SpringBoot整合ES最容易翻车的不是写代码而是pom依赖。spring-boot-starter-data-elasticsearch这个starter本身带的是spring-data-elasticsearch模块而spring-data-elasticsearch在SpringBoot 2.1.x里依赖的还是TransportClient连的是9300端口。ES 7.2.0虽然还留着TransportClient的包但官方已经明确标注废弃而且如果你依赖管理没做好它会把整个transport相关的类带进classpath运行期跟rest-high-level-client冲突。我的做法是这么配pom的dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-elasticsearch/artifactId exclusions exclusion groupIdorg.elasticsearch.client/groupId artifactIdtransport/artifactId /exclusion exclusion groupIdorg.elasticsearch/groupId artifactIdelasticsearch/artifactId /exclusion /exclusions /dependency dependency groupIdorg.elasticsearch.client/groupId artifactIdelasticsearch-rest-high-level-client/artifactId version7.2.0/version /dependency排除transport和elasticsearch这两个包是因为rest-high-level-client会传递引入它自己版本的elasticsearch核心包两套ES核心类同时在classpath里最容易出现NoSuchMethodError。第二个依赖的version必须显式写成7.2.0因为SpringBoot的dependencyManagement里管理的Spring Data ES版本对应的是别的ES版本你无法确定它与你本地启动的ES 7.2.0完全一致。参数说明exclusions里的两个groupId和artifactId组合是为了去掉9300端口那套老的传输协议类如果生产环境用的是SpringBoot 2.2.xstarter内部已经切换到了RestClient这时你只需要保留第二个依赖并指定7.2.0版本即可第一个依赖甚至可以不加。z这一小节的核心是rest-high-level-client的版本一定以你部署的ES服务端版本为准而不是以SpringBoot版本为准。2.2 配置类从连接池到超时一个bean管住全部有了依赖之后需要把RestHighLevelClient注册成Spring容器里的bean。常见的做法是拆两个类一个读配置文件的属性类一个负责组装客户端的配置类。这样连接地址、超时时间、连接池大小都能通过配置文件调整不用每次改代码重新打包。// ElasticsearchProperties.java Data Component ConfigurationProperties(prefix es.client) public class ElasticsearchProperties { private String host 127.0.0.1; private Integer port 9200; private String scheme http; private Integer connectTimeout 3000; private Integer socketTimeout 30000; }// ElasticsearchConfig.java Configuration RequiredArgsConstructor public class ElasticsearchConfig { private final ElasticsearchProperties properties; Bean(destroyMethod close) public RestHighLevelClient restHighLevelClient() { RestClientBuilder builder RestClient.builder( new HttpHost(properties.getHost(), properties.getPort(), properties.getScheme()) ).setRequestConfigCallback(requestConfigBuilder - requestConfigBuilder .setConnectTimeout(properties.getConnectTimeout()) .setSocketTimeout(properties.getSocketTimeout()) .setConnectionRequestTimeout(3000) ).setHttpClientConfigCallback(httpClientBuilder - { httpClientBuilder.setMaxConnTotal(100); httpClientBuilder.setMaxConnPerRoute(50); return httpClientBuilder; }); return new RestHighLevelClient(builder); } }配置类里的逻辑不复杂但有几个参数值得细讲。setConnectTimeout是建立TCP连接的超时默认只要1秒跨机房访问ES时经常不够我一般设3秒setSocketTimeout是请求发起后等待响应的超时默认10秒如果业务里有聚合查询或深分页30秒更稳setConnectionRequestTimeout是从连接池获取连接的等待时间这个参数最容易被忽略高并发下连接池耗尽线程会卡在这里设3秒能让问题快速暴露而不是拖死整个线程池。setMaxConnTotal和setMaxConnPerRoute这两个参数是真正的血泪经验。RestHighLevelClient底层的Apache HttpClient默认per route连接数只有1意味着同一时刻一个节点只能处理一个并发请求接口一有点压力就开始排队超时。maxConnTotal是总连接数上限maxConnPerRoute是单个ES节点能建立的连接数上限单节点集群这两值设成100和50足够大部分业务场景。2.3 验证连接客户端起来后先做一次连通性自检配置类写完后最好在应用启动阶段就验证客户端到底连上了没有而不是等第一个查询接口被调用才发现连不通。我一般用一个ApplicationRunner做启动自检顺便把ES的版本信息打到日志里Component RequiredArgsConstructor public class ElasticsearchHealthChecker implements ApplicationRunner { private final RestHighLevelClient client; private static final Logger log LoggerFactory.getLogger(ElasticsearchHealthChecker.class); Override public void run(ApplicationArguments args) { try { MainResponse response client.info(RequestOptions.DEFAULT); log.info(连接ES成功集群: {}, 版本: {}, response.getClusterName(), response.getVersion().getNumber()); } catch (Exception e) { log.error(连接ES失败请检查 es.client.host/port 配置, e); throw new RuntimeException(Elasticsearch客户端初始化失败, e); } } }这里的client.info()发的是一个GET /请求不需要任何索引权限就能响应返回的版本号可以确认服务端确实是7.2.0。如果版本号对不上比如服务端是7.17而客户端是7.2.0部分新特性会出现兼容性问题。把自检查放在启动流程里还有个好处CI/CD流水线里如果ES没有先启动应用直接启动失败比上线后发现接口报错要容易处理得多。参数上要注意RequestOptions.DEFAULT是线程安全的所有请求共用一份即可不需要为每个请求新建。3. 索引与文档操作CRUD里藏着7.2.0最容易翻车的参数3.1 创建索引settings与mapping一次性说清用代码创建索引比在Kibana里执行PUT请求更可控因为建索引的脚本可以进Git环境迁移时直接跑应用就能重建。创建索引的逻辑分两块settings管分片、副本、分词器mapping管字段类型。7.2.0的mapping支持动态映射但生产环境我强烈建议显式声明否则默认会把所有string字段映射成text加keyword子字段后期想改成精确匹配会非常被动。Service RequiredArgsConstructor public class IndexService { private final RestHighLevelClient client; public boolean createProductIndex() throws IOException { String indexName product; boolean exists client.indices().exists( new GetIndexRequest(indexName), RequestOptions.DEFAULT); if (exists) { return false; } CreateIndexRequest request new CreateIndexRequest(indexName); request.settings(Settings.builder() .put(index.number_of_shards, 3) .put(index.number_of_replicas, 1) ); MapString, Object mapping new HashMap(); MapString, Object properties new HashMap(); mapping.put(properties, properties); // title用text存分词后的结果用keyword做精确匹配和排序 properties.put(title, fieldMapping(text, ik_max_word, keyword)); properties.put(price, Map.of(type, double)); properties.put(status, Map.of(type, integer)); properties.put(createTime, Map.of(type, date, format, yyyy-MM-dd HH:mm:ss)); request.mapping(mapping); CreateIndexResponse response client.indices().create(request, RequestOptions.DEFAULT); return response.isAcknowledged(); } private MapString, Object fieldMapping(String type, String analyzer, String keyword) { MapString, Object field new HashMap(); field.put(type, type); if (analyzer ! null) { field.put(analyzer, analyzer); } if (keyword ! null) { MapString, Object subField new HashMap(); subField.put(type, keyword); MapString, Object fields Map.of(keyword, subField); field.put(fields, fields); } return field; } }注意mapping里的title字段我既写入了analyzer为ik_max_word又加了keyword子字段。这样title既能支持中文分词搜索又能用keyword做精确匹配和排序。如果没装IK分词器并且没改默认分词器analyzer可以去掉ES默认用standard分词器英文场景够用中文会被拆成单字。settings里的number_of_shards和number_of_replicas一旦索引创建成功就不能改这点跟7.2.0无关ES所有版本都这样。分片数建议按数据量和节点数估算单节点集群设1个分片即可设多了反而浪费三节点集群设3个分片、每个分片1个副本比较常规。3.2 写入文档单条IndexRequest与批量BulkRequest的取舍索引建好之后写入就简单了。ES建立索引文档没有insert的概念只有index同一个id重复index就是全量覆盖。单条写入最简单也最直观public IndexResponse index(String indexName, String id, MapString, Object doc) throws IOException { IndexRequest request new IndexRequest(indexName); request.id(id); request.source(doc); request.timeout(TimeValue.timeValueSeconds(5)); return client.index(request, RequestOptions.DEFAULT); }这里把doc声明成Map而不是Object是为了避免序列化问题。7.2.0的request.source(Object)默认用Jackson序列化如果你直接塞一个带LocalDateTime字段的实体类进去序列化结果十有八九不是你想要的这是第5章要展开讲的坑。所以我统一在Service层先把实体转成Map或JSON字符串再传给客户端序列化的控制权留在自己手里。批量写入的通途是BulkRequest批量接口的处理思路很直接把多个IndexRequest装进一个BulkRequest发出去。这里有一个真正的玄学调参点单批到底多少条合适。我给出一个经过多个项目验证的经验范围public void bulkIndex(String indexName, ListProduct products) throws IOException { BulkRequest bulkRequest new BulkRequest(); for (Product product : products) { IndexRequest request new IndexRequest(indexName); request.id(product.getId()); request.source(JSON.toJSONString(product), XContentType.JSON); bulkRequest.add(request); } bulkRequest.timeout(TimeValue.timeValueMinutes(2)); BulkResponse response client.bulk(bulkRequest, RequestOptions.DEFAULT); if (response.hasFailures()) { for (BulkItemResponse item : response.getItems()) { if (item.isFailed()) { log.error(批量写入失败: index{}, id{}, reason{}, item.getIndex(), item.getId(), item.getFailureMessage()); } } } }批量的大小我一般控制在1000到5000条之间总请求体不超过10MB。如果单批太小比如几十条网络往返次数太多吞吐上不去如果单批太大比如几万条ES需要更多的堆内存来解析请求体集群容易直接OOM而且Jackson序列化整批对象的时间也会拉长。这个经验值对7.2.0同样适用。代码里有个细节值得注意response.hasFailures()与item.isFailed()是两回事。hasFailures只告诉你整批里有没有失败的项isFailed才是逐项判断。批量请求是部分成功部分失败的系统设计上要保证失败项能拿到重试或记录到补偿表里直接吞异常会让数据静默丢失。3.3 更新与删除upsert、版本冲突与并发下的表现更新文档有两个APIUpdateRequest和IndexRequest。两者的区别IndexRequest是覆盖整条文档掉字段里的值会被重置UpdateRequest是局部更新只修改传入的字段其他字段保留不变。业务里更新场景用UpdateRequest更安全因为你不需要先查出来合并再写回减少了循环覆盖的损失。// 局部更新支持upsert语义 public void updateOrInsert(String indexName, String id, MapString, Object partialDoc) throws IOException { UpdateRequest request new UpdateRequest(indexName, id); request.doc(partialDoc); request.upsert(partialDoc); request.retryOnConflict(3); client.update(request, RequestOptions.DEFAULT); }retryOnConflict这个参数是并发更新场景下的后悔药。ES更新流程是读-改-写如果两个线程同时读到旧版本后写的那一方会被版本冲突拒绝默认直接抛VersionConflictEngineException。设置了retryOnConflict(3)之后ES会对冲突的文档最多重试3次更新显著减少并发失败率。注意它重试的是整个文档的重新索引不是部分字段所以请求体里务必要带上完整的upsert内容才算真正的插入失败兜底。删除文档相对简单DeleteRequest指定index和id即可。但要注意ES删除文档是逻辑删除先标记再定期清理所以删除后立刻做exists判断可能还是true。这个延迟与refresh_interval有关默认1秒大多数业务能接受。3.4 查询文档按ID查与exists判断按ID单查是写入后验证数据最常用的操作本质上是正排查询走的是Lucene的uid索引比DSL快得多public GetResponse getById(String indexName, String id) throws IOException { GetRequest request new GetRequest(indexName, id); return client.get(request, RequestOptions.DEFAULT); }GetRequest有个省流量的参数fetchSourceContext可以指定只返回某些字段。比如列表页只需要title和price就不应该把description和imageUrl全量拉回来否则网络带宽和GC压力都是不必要的。另外判断文档是否存在用client.exists(request, RequestOptions.DEFAULT)返回boolean比get再判空更高效因为它只发HEAD请求不传输文档体。4. 查询DSL进阶从关键词搜索到高亮展示4.1 matchQuery与termQuery分词和精确匹配怎么选这是使用ES频率最高、也是最容易选错的两种查询。matchQuery会把查询关键词先分词再用分出来的词去匹配分词后的索引适合title、content这类文本字段termQuery不分词把整个查询条件当做一个词去匹配适合keyword、integer、date这类不分词的字段。举一个真实场景商品搜索框里输入“苹果手机”用matchQuery会先分词成“苹果”和“手机”然后分别匹配这样能召回标题只有“苹果”或只有“手机”的商品如果改用termQuery整个“苹果手机”被当成一个词什么也查不到。反过来按状态过滤status1用matchQuery同样会分词可能把“1”拆成一个词匹配上但逻辑上不严谨用termQuery才是正解。4.2 boolQuery组合must、filter、should一个都不能少单个query条件几乎不能满足业务最常见的组合是关键词搜索加状态过滤加时间范围过滤。用boolQuery把这些条件装进去语义清晰而且filter条件还能走缓存大幅度提升查询性能。public SearchResponse searchProducts(String keyword, Integer status, DateRange range) throws IOException { SearchSourceBuilder sourceBuilder new SearchSourceBuilder(); BoolQueryBuilder bool QueryBuilders.boolQuery(); // must必须匹配参与评分 if (StringUtils.hasText(keyword)) { bool.must(QueryBuilders.matchQuery(title, keyword)); } // filter只过滤不评分缓存友好 if (status ! null) { bool.filter(QueryBuilders.termQuery(status, status)); } if (range ! null) { bool.filter(QueryBuilders.rangeQuery(createTime) .gte(range.getStart()) .lte(range.getEnd())); } sourceBuilder.query(bool); sourceBuilder.from(0); sourceBuilder.size(20); SearchRequest searchRequest new SearchRequest(product); searchRequest.source(sourceBuilder); return client.search(searchRequest, RequestOptions.DEFAULT); }must和filter的区别在于是否参与相关性评分。must里match的匹配程度会计算到_scorefilter只是硬性条件不影响评分但结果会被ES缓存。所以能放进filter的条件比如状态、分类、时间范围尽量避免放到must里否则同样一段查询的缓存命中率会低很多。should是可选条件默认不必须匹配但在没有must时至少要满足一个should条件才返回。实际业务里should常用来做标签加权比如“带‘防水’标签的商品排在前面”给should里的词配一个boost。4.3 分页与排序from/size的上限与search_after的正确姿势分页最常见的写法就是from和size但ES的fromsize有一个硬性上限默认不能超过index.max_result_window取值10000。也就是说from5000、size20合计5020没超过上限可以查from9999、size20合计10019已经超过上限直接报错。7.2.0里这个限制没有变化网上很多方案让你调大max_result_window但那是治标不治本深分页的本质问题是每个分片都要把全部结果取到协调节点再做全局排序代价是滚动后翻页越深内存开销越大。正确姿势是search_after它必须在排序条件中包含一个唯一值的字段通常加_id保证排序稳定public SearchResponse searchWithAfter(String keyword, Object[] searchAfterValues) throws IOException { SearchSourceBuilder sourceBuilder new SearchSourceBuilder(); sourceBuilder.query(QueryBuilders.matchQuery(title, keyword)); sourceBuilder.size(20); // 排序字段业务时间倒序 唯一id兜底 sourceBuilder.sort(createTime, SortOrder.DESC); sourceBuilder.sort(_id, SortOrder.ASC); if (searchAfterValues ! null) { sourceBuilder.searchAfter(searchAfterValues); } sourceBuilder.trackTotalHits(true); SearchRequest searchRequest new SearchRequest(product); searchRequest.source(sourceBuilder); SearchResponse response client.search(searchRequest, RequestOptions.DEFAULT); return response; }使用方式第一页searchAfter传null取回结果后从最后一行的getSortValues()拿到下一页的起点再请求下一页。pageSize不传的时候searchAfter就是逐条往下滑每次查20条性能与翻页深度无关。trackTotalHits(true)是另一个几乎每次都要设的参数7.x默认返回的总命中数是10000兜底如果你不需要精确总数保持默认就好需要精确值才真正打开。4.4 高亮结果解析把命中片段安全地拼回页面高亮是搜索系统的标配功能7.2.0内置高亮器使用起来不难但有很多版本细节要注意。核心思想是query里匹配到的关键词ES在返回结果时用preTag和postTag把命中片段包起来再由前端渲染成红色字体或其他样式。public void setHighlighter(SearchSourceBuilder sourceBuilder) { HighlightBuilder highlightBuilder new HighlightBuilder(); highlightBuilder.field(title) .field(content) .preTags(em classhl) .postTags(/em) .fragmentSize(100) .numOfFragments(3); sourceBuilder.highlighter(highlightBuilder); }fragmentSize控制在100到200之间比较合适太短关键词容易被截断太长又影响阅读。numOfFragments默认5对标题字段设1就够了对正文设3避免返回太多碎片。解析响应的时候要取的是hit.getHighlightFields()而不是直接取_source里的title。系统设计上高亮内容与_source的字段是两套数据安全响应时把高亮片段拼进JSON不要把完整_source原样返回避免大字段占满网络带宽。5. 避坑清单版本冲突、系列化、连接池的5个真实事故5.1 NoSuchMethodErrorstarter把transport带进来了现象应用启动或第一次调用ES时抛java.lang.NoSuchMethodError堆栈里能看到org.elasticsearch.common.transport.TransportAddress相关类。原因spring-boot-starter-data-elasticsearch传递依赖了旧的transport客户端与rest-high-level-client引入的新版elasticsearch核心包冲突类加载器加载到旧类调用新方法就报错。解决在starter的dependency里把transport和elasticsearch两个artifact排除或者干脆不用starter直接用elasticsearch-rest-high-level-client加Jackson的POM组合。关键是排查时先mvn dependency:tree看ES全家桶的版本统一成一个版本号。5.2 LocalDateTime写入后变成数组时间范围查询全失灵现象实体类里有LocalDateTime字段写入ES后查询时发现该字段被映射成了text用rangeQuery查时间范围返回空。原因7.2.0的RestHighLevelClient默认用Jackson做序列化而Jackson默认把LocalDateTime序列化成[2024, 1, 1, 0, 0, 0]这样的数组结构ES无法解析成date类型退化成text。解决在写入前统一把LocalDateTime转成yyyy-MM-dd HH:mm:ss字符串或转成毫秒时间戳long如果一定要用Jackson序列化对象则要单独配置ObjectMapper注册JavaTimeModule并禁用WRITE_DATES_AS_TIMESTAMPS。我的习惯是Service层转Map序列化控制权不交给客户端。5.3 连接池默认1条连接慢查询一多就ConnectionPoolTimeout现象接口平时正常一旦有聚合查询或批量任务并发执行日志大量出现org.apache.http.conn.ConnectionPoolTimeoutException响应时间从几十毫秒飙到几秒。原因RestClient底层的HttpClient默认per route最大连接数是1一个节点同一时间只能处理一个请求其余请求全部排队等连接。解决在setHttpClientConfigCallback里设置setMaxConnTotal和setMaxConnPerRoute单节点集群分别设成100和50同时把connectionRequestTimeout设成3秒让等待连接超时快速失败而不是无限阻塞。5.4 深分页报错max_result_window与search_after的选择现象前端表格翻到第100页接口返回报错Result window is too large, from size must be less than or equal to: [10000]。原因ES默认限制fromsize不超过10000防止深分页导致协调节点内存压力过大。解决严格来说业务上不应该允许用户翻到100页列表页前端限制最深层级改成search_after滚动加载如果确实需要兼容老业务可以调大index.max_result_window但这是把风险后置了。我的建议是直接改用search_after配合滚动加载不仅解决报错还能提升整体查询性能。5.5 Bulk写入失败不打印数据静默丢了一批现象批量导入数据后统计总数发现比源数据少了上百条应用日志里没有任何报错。原因BulkResponse本身是部分成功部分失败的ES不会因为个别文档写入失败就把整批请求标记为异常只有调用hasFailures()才能发现。解决bulk执行后必须遍历response.getItems()对isFailed()为true的项记录失败原因并把该文档的id存到重试表或消息队列里做补偿。这是ES接入里最容易被静静吃掉的一类坑任何批量写入逻辑都必须带上失败追踪而不是只检查返回是否为空。6. 进阶封装把查询抽成模板让业务层只关心参数6.1 一个泛型搜索模板当项目里有多个索引都要做分页搜索每个Service都写一遍QueryBuilders和SearchSourceBuilder的组装代码会非常枯燥且容易遗漏排序稳定字段。我一般在项目里抽一个EsPageQuery泛型模板把分页、排序、高亮、search_after的场景统一收口业务代码只需要提供query条件和类型转换函数public T EsPageResultT search(String indexName, QueryBuilder query, ClassT clazz, EsPageQuery pageQuery, FunctionSearchHit, T mapper) throws IOException { SearchSourceBuilder sourceBuilder new SearchSourceBuilder(); sourceBuilder.query(query); sourceBuilder.size(pageQuery.getSize()); sourceBuilder.sort(pageQuery.getSortField(), pageQuery.getSortOrder()); sourceBuilder.sort(_id, SortOrder.ASC); // 排序稳定兜底 if (pageQuery.getSearchAfter() ! null) { sourceBuilder.searchAfter(pageQuery.getSearchAfter()); } SearchRequest searchRequest new SearchRequest(indexName); searchRequest.source(sourceBuilder); SearchResponse response client.search(searchRequest, RequestOptions.DEFAULT); ListT list new ArrayList(); Object[] lastSortValues null; for (SearchHit hit : response.getHits().getHits()) { list.add(mapper.apply(hit)); lastSortValues hit.getSortValues(); } return new EsPageResult(list, lastSortValues, response.getHits().getTotalHits().value); }这个模板把ES的细节藏起来了业务Service里就变成一行代码esSearchService.search(product, boolQuery, ProductVO.class, pageQuery, hit - parse(hit))。函数式接口的好处是每个业务可以按自己的方式解析hit不受模板限制。注意返回的lastSortValues就是下一页search_after的参数前端只要把它原样带回来滚动翻页就闭环了。6.2 动态排序与高亮回调模板里再增加一个排序字段的白名单校验防止用户传入恶意字段名。ES对不存在的排序字段会直接报错所以业务层要对sortField做校验只放行允许排序的字段。高亮的场景可以在模板里预留一个HighlighterBuilder参数没传就不启用传了才加到sourceBuilder上避免每个请求都白白生成高亮配置。我到现在接手ES相关的项目第一件事还是先看版本号再决定客户端写法。这套7.2.0的整合模板是我在好几个业务系统里验证过、也踩过坑的记录。从依赖排除到连接池参数每个数字背后都有一次线上事故的教训。建议你在本地搭最小用例时把连接池、异常日志、失败补偿这三样先做扎实再往上层堆业务能少走很多弯路。希望帮到你。本文还有配套的精品资源点击获取
返回列表