Elasticsearch Java实战手册:搭建、条件构建与分页优化

Elasticsearch(简称ES)作为分布式搜索与分析引擎,在Java后端体系中占据重要地位。本文摒弃零散知识点堆砌,以"实战落地"为核心,分为ES环境搭建与配置、核心查询条件构建、大数据量分页优化三大部分,全程配套可直接复用的代码,助力开发者快速解决实际问题。

第一部分:ES环境搭建与Java客户端配置

环境搭建是ES开发的基础,本节涵盖ES服务部署核心要点与Java客户端配置全流程,基于ES 8.x版本(当前稳定版)展开,该版本默认开启安全认证,需特别注意配置细节。

1.1 ES服务部署核心配置

ES服务部署支持单机与集群模式,此处以单机部署为例,核心配置文件(elasticsearch.yml)关键参数如下,集群模式可在此基础上扩展节点配置:

java 复制代码
# 集群名称(客户端连接时需一致)
cluster.name: my-elasticsearch-cluster
# 节点名称
node.name: node-1
# 绑定地址(允许外部访问需设为0.0.0.0)
network.host: 0.0.0.0
# HTTP端口(默认9200,客户端连接端口)
http.port: 9200
# 传输端口(集群节点通信,默认9300)
transport.port: 9300
# 初始化主节点
cluster.initial_master_nodes: ["node-1"]
# 安全认证配置(ES 8.x默认开启)
xpack.security.enabled: true
xpack.security.transport.ssl.enabled: true

配置完成后启动ES服务,首次启动需执行初始化密码命令(生成elastic用户密码,客户端连接时使用):

bash 复制代码
# Linux/Mac环境
bin/elasticsearch-setup-passwords interactive
# Windows环境
bin\elasticsearch-setup-passwords.bat interactive

1.2 Java客户端依赖与初始化

Java开发推荐使用官方High Level REST Client,封装了高频API,易用性远超低级客户端。

1.2.1 依赖配置(Maven)

需确保客户端版本与ES服务端版本完全一致,避免兼容性问题:

java 复制代码
<!-- ES高级REST客户端 -->
<dependency>
    <groupId>org.elasticsearch.client</groupId>
    <artifactId>elasticsearch-rest-high-level-client</artifactId>
    <version>8.10.4</version>
</dependency>
<!-- ES核心依赖 -->
<dependency>
    <groupId>org.elasticsearch</groupId>
    <artifactId>elasticsearch</artifactId>
    <version>8.10.4</version>
</dependency>
<!-- JSON解析依赖(用于结果映射) -->
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.15.3</version>
</dependency>
1.2.2 客户端单例封装

ES客户端创建成本较高,需封装为单例模式,避免频繁创建连接。同时处理安全认证、超时等核心配置:

java 复制代码
import org.apache.http.HttpHost;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestHighLevelClient;
import java.io.IOException;

/**
 * ES客户端工具类(单例模式)
 */
public class EsClientFactory {
    // 单例实例
    private static volatile RestHighLevelClient client;
    // ES配置常量(实际项目建议放在配置文件中)
    private static final String HOST = "127.0.0.1";
    private static final int PORT = 9200;
    private static final String SCHEME = "http";
    private static final String USERNAME = "elastic";
    private static final String PASSWORD = "你的ES密码";

    // 私有构造方法(禁止外部实例化)
    private EsClientFactory() {}

    /**
     * 获取客户端实例
     */
    public static RestHighLevelClient getClient() {
        if (client == null) {
            synchronized (EsClientFactory.class) {
                if (client == null) {
                    // 配置安全认证
                    CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
                    credentialsProvider.setCredentials(AuthScope.ANY,
                            new UsernamePasswordCredentials(USERNAME, PASSWORD));

                    // 构建客户端
                    client = new RestHighLevelClient(
                            RestClient.builder(new HttpHost(HOST, PORT, SCHEME))
                                    .setHttpClientConfigCallback(httpClientBuilder ->
                                            httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider))
                                    // 可选:设置超时时间
                                    .setRequestConfigCallback(requestConfigBuilder ->
                                            requestConfigBuilder.setConnectTimeout(5000)
                                                    .setSocketTimeout(10000))
                    );
                }
            }
        }
        return client;
    }

    /**
     * 关闭客户端(应用销毁时调用)
     */
    public static void closeClient() throws IOException {
        if (client != null) {
            client.close();
            client = null;
        }
    }
}

第二部分:核心查询条件构建(QueryBuilders全解析)

QueryBuilders是ES Java客户端的查询条件构建核心工具类,开发者常因方法众多而混淆。本节先通过表格清晰区分高频方法的作用与适用场景,再通过一个综合代码示例集中演示用法。

2.1 高频QueryBuilders方法对比表

下表涵盖开发中最常用的查询方法,重点标注各方法的核心差异与适用场景:

查询方法 核心作用 适用字段类型 关键特性 适用场景
termQuery 单值精确匹配 keyword、数字、日期、布尔 不分词,完全匹配字段原始值 根据状态(如status=1)、ID查询
termsQuery 多值精确匹配 keyword、数字、日期、布尔 匹配字段值在指定集合中,类似SQL的IN 多ID查询(如userId in [1001,1002])
matchQuery 标准全文检索 text 对查询词分词,匹配任意分词结果,支持相关性评分 商品模糊搜索(如"小米手机")
matchPhraseQuery 短语精确匹配 text 对查询词分词,要求分词连续且顺序一致 搜索完整短语(如"Java开发")
wildcardQuery 通配符模糊匹配 text、keyword 支持*(任意字符)和?(单个字符),不依赖分词器 前缀/后缀匹配(如"张*""138?5*")
matchAllQuery 匹配所有文档 任意类型 无查询条件,需配合分页使用 全量数据导出、索引文档统计
rangeQuery 范围匹配 数字、日期、字符串 支持gt(>)、gte(≥)、lt(<)、lte(≤)等条件 价格区间、时间范围查询
geoBoundingBoxQuery 地理边界框查询 geo_point(经纬度) 查询位于指定矩形区域内的地理数据 周边商家、地图POI筛选

2.2 综合代码示例:多查询方法实战

以下示例基于"电商商品索引(product_index)",该索引包含商品ID、名称、分类、价格、上架时间、商家经纬度等字段,集中演示上述查询方法的使用,配合布尔查询(must/should)实现多条件组合:

2.2.1 索引映射(Mapping)参考

先明确索引结构,确保字段类型与查询方法匹配:

java 复制代码
{
  "mappings": {
    "properties": {
      "productId": { "type": "keyword" }, // 商品ID(精确匹配)
      "productName": { "type": "text", "analyzer": "ik_max_word" }, // 商品名称(全文检索)
      "categoryId": { "type": "integer" }, // 分类ID(精确/范围匹配)
      "price": { "type": "double" }, // 价格(范围匹配)
      "createTime": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss" }, // 上架时间(范围匹配)
      "merchantLocation": { "type": "geo_point" } // 商家经纬度(地理查询)
    }
  }
}
2.2.2 综合查询代码
java 复制代码
import com.fasterxml.jackson.databind.ObjectMapper;
import org.elasticsearch.action.search.SearchRequest;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.common.geo.GeoPoint;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.builder.SearchSourceBuilder;
import org.elasticsearch.search.sort.SortOrder;
import java.io.IOException;

/**
 * QueryBuilders综合查询示例
 * 需求:查询"分类ID为10或20、价格在100-500元、名称包含'无线'且不是'无线鼠标'、2024年上架、位于北京某区域内"的商品
 */
public class EsQueryComprehensiveDemo {
    // JSON解析工具(用于结果映射)
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    public static void main(String[] args) {
        RestHighLevelClient client = null;
        try {
            // 1. 获取客户端实例
            client = EsClientFactory.getClient();

            // 2. 构建布尔查询(多条件组合核心)
            BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();

            // 2.1 must条件:必须满足(核心筛选条件)
            // 价格范围:100 ≤ price ≤ 500(rangeQuery)
            boolQuery.must(QueryBuilders.rangeQuery("price")
                    .gte(100.0)
                    .lte(500.0));
            // 上架时间:2024年1月1日之后(rangeQuery)
            boolQuery.must(QueryBuilders.rangeQuery("createTime")
                    .gte("2024-01-01 00:00:00"));
            // 商品名称包含"无线"(matchQuery,全文检索)
            boolQuery.must(QueryBuilders.matchQuery("productName", "无线"));

            // 2.2 should条件:满足任意一个(可选筛选,提升相关性)
            // 分类ID为10或20(termsQuery,多值精确匹配)
            boolQuery.should(QueryBuilders.termsQuery("categoryId", 10, 20));
            // 设置should最小匹配数(确保至少满足一个)
            boolQuery.minimumShouldMatch(1);

            // 2.3 must_not条件:排除(禁止出现的条件)
            // 商品名称不是"无线鼠标"(matchPhraseQuery,短语精确匹配)
            boolQuery.mustNot(QueryBuilders.matchPhraseQuery("productName", "无线鼠标"));

            // 2.4 filter条件:过滤(不影响评分,提升性能)
            // 商家位于北京某矩形区域内(geoBoundingBoxQuery,地理查询)
            GeoPoint topLeft = new GeoPoint(39.93, 116.38); // 矩形左上角经纬度
            GeoPoint bottomRight = new GeoPoint(39.90, 116.42); // 矩形右下角经纬度
            boolQuery.filter(QueryBuilders.geoBoundingBoxQuery("merchantLocation")
                    .setCorners(topLeft, bottomRight));

            // 3. 构建查询源(配置分页、排序、返回字段等)
            SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
            sourceBuilder.query(boolQuery);
            // 分页:第1页,每页10条(from从0开始)
            sourceBuilder.from(0);
            sourceBuilder.size(10);
            // 排序:按价格升序、上架时间降序
            sourceBuilder.sort("price", SortOrder.ASC);
            sourceBuilder.sort("createTime", SortOrder.DESC);
            // 只返回需要的字段(减少数据传输)
            sourceBuilder.fetchSource(new String[]{"productId", "productName", "price", "createTime"}, null);

            // 4. 构建查询请求(指定索引)
            SearchRequest searchRequest = new SearchRequest("product_index");
            searchRequest.source(sourceBuilder);

            // 5. 执行查询并处理结果
            SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT);
            handleSearchResult(response);

        } catch (IOException e) {
            e.printStackTrace();
            System.err.println("ES查询异常:" + e.getMessage());
        } finally {
            // 6. 关闭客户端(实际项目中建议在应用销毁时统一关闭)
            if (client != null) {
                try {
                    EsClientFactory.closeClient();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    /**
     * 处理查询结果,映射为Java对象
     */
    private static void handleSearchResult(SearchResponse response) throws IOException {
        // 总命中数
        long totalHits = response.getHits().getTotalHits().value;
        System.out.println("查询总命中商品数:" + totalHits);

        // 遍历结果并映射为Product对象
        for (var hit : response.getHits()) {
            Product product = OBJECT_MAPPER.readValue(hit.getSourceAsString(), Product.class);
            System.out.printf("商品ID:%s,名称:%s,价格:%.2f,上架时间:%s%n",
                    product.getProductId(), product.getProductName(),
                    product.getPrice(), product.getCreateTime());
        }
    }

    /**
     * 商品实体类(与索引字段对应)
     */
    static class Product {
        private String productId;
        private String productName;
        private Integer categoryId;
        private Double price;
        private String createTime;

        // getter和setter方法(省略,实际开发需补充)
        public String getProductId() { return productId; }
        public void setProductId(String productId) { this.productId = productId; }
        public String getProductName() { return productName; }
        public void setProductName(String productName) { this.productName = productName; }
        public Double getPrice() { return price; }
        public void setPrice(Double price) { this.price = price; }
        public String getCreateTime() { return createTime; }
        public void setCreateTime(String createTime) { this.createTime = createTime; }
    }
}

2.3 核心注意事项

  1. 字段类型匹配:text类型字段不可用termQuery(会因分词导致匹配失败),keyword/数字字段优先用term/termsQuery; 2. 性能优化:纯筛选场景用filter上下文(boolQuery.filter())替代must,避免计算相关性评分; 3. 地理查询前提:geo_point字段需正确存储经纬度(格式为[经度,纬度]或"纬度,经度"); 4. 通配符使用:wildcardQuery避免将*放在开头(如*无线),会导致全表扫描,性能极差。

第三部分:大数据量分页查询优化(几十万条数据场景)

当ES索引数据量达到几十万甚至上百万条时,传统的"from+size"分页会面临严重的性能问题,甚至出现内存溢出。本节先分析传统分页的缺陷,再给出两种工业级优化方案。

3.1 传统分页(from+size)的缺陷

日常开发中最常用的分页方式是通过SearchSourceBuilder的from()和size()方法设置,例如from=1000,size=10表示查询第101页(每页10条)的数据。其核心缺陷在于:

  1. 数据重复加载:ES为获取第101页数据,需要在每个分片上加载前1010条数据(1000+10),然后汇总到协调节点进行排序和截断,最终只返回10条数据,大量数据加载和排序造成资源浪费;

  2. 深度分页性能暴跌:当from值超过10000时,ES默认会抛出异常(index.max_result_window默认值为10000),即使修改该参数,from值越大,性能下降越明显;

  3. 内存溢出风险:分片加载大量数据到内存进行排序,容易触发OOM。

结论:from+size仅适用于分页深度较浅的场景(如前100页),不适用于几十万条数据的深度分页。

3.2 优化方案一:Scroll分页(滚动分页)

Scroll分页是ES专门为"全量数据导出"或"深度分页"设计的方案,其核心原理是:查询时生成一个临时的"滚动上下文(Scroll Context)",记录当前查询的分片位置和排序状态,后续分页仅需通过滚动ID(scroll_id)获取下一批数据,避免重复加载和排序。

3.2.1 适用场景

全量数据导出(如每日同步ES数据到数据库)、无需跳页的深度分页(如日志按时间顺序滚动查看)。

3.2.2 实现步骤与代码

Scroll分页分为"初始化滚动查询"和"循环获取下一批数据"两个阶段,需注意滚动上下文有过期时间(避免资源泄露):

java 复制代码
import org.elasticsearch.action.search.ClearScrollRequest;
import org.elasticsearch.action.search.SearchScrollRequest;
import org.elasticsearch.common.unit.TimeValue;

/**
 * Scroll分页优化示例(适用于全量数据导出/深度无跳页分页)
 */
public class EsScrollPaginationDemo {
    // 滚动上下文过期时间(建议设置为1-5分钟,避免过长占用资源)
    private static final TimeValue SCROLL_TIMEOUT = TimeValue.timeValueMinutes(2);
    // 每页数据量
    private static final int PAGE_SIZE = 1000;

    public static void main(String[] args) {
        RestHighLevelClient client = null;
        String scrollId = null;
        try {
            client = EsClientFactory.getClient();

            // 1. 初始化滚动查询(获取第一页数据和scrollId)
            SearchRequest initRequest = new SearchRequest("product_index");
            SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
            // 查询条件(可根据需求调整)
            sourceBuilder.query(QueryBuilders.matchAllQuery());
            sourceBuilder.size(PAGE_SIZE);
            // 按productId升序排序(Scroll分页必须指定排序字段,避免数据重复/遗漏)
            sourceBuilder.sort("productId", SortOrder.ASC);
            // 启用滚动查询
            initRequest.scroll(SCROLL_TIMEOUT);
            initRequest.source(sourceBuilder);

            // 执行初始化查询
            SearchResponse initResponse = client.search(initRequest, RequestOptions.DEFAULT);
            // 获取滚动ID(后续分页核心)
            scrollId = initResponse.getScrollId();
            // 处理第一页数据
            long totalHits = initResponse.getHits().getTotalHits().value;
            System.out.println("总数据量:" + totalHits);
            handleSearchResult(initResponse);

            // 2. 循环获取下一批数据(直到无数据返回)
            while (initResponse.getHits().getHits().length > 0) {
                // 构建滚动查询请求
                SearchScrollRequest scrollRequest = new SearchScrollRequest(scrollId);
                scrollRequest.scroll(SCROLL_TIMEOUT);

                // 执行滚动查询
                SearchResponse scrollResponse = client.searchScroll(scrollRequest, RequestOptions.DEFAULT);
                // 更新scrollId(部分场景下ES会返回新的scrollId)
                scrollId = scrollResponse.getScrollId();
                // 处理当前页数据
                handleSearchResult(scrollResponse);
            }

            // 3. 数据获取完成,清除滚动上下文(释放资源,必须执行)
            ClearScrollRequest clearScrollRequest = new ClearScrollRequest();
            clearScrollRequest.addScrollId(scrollId);
            client.clearScroll(clearScrollRequest, RequestOptions.DEFAULT);
            System.out.println("Scroll分页完成,已释放滚动上下文");

        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (client != null) {
                try {
                    EsClientFactory.closeClient();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    // 结果处理方法(复用前文的handleSearchResult)
    private static void handleSearchResult(SearchResponse response) throws IOException {
        // 实现同前文,此处省略
    }
}
3.2.3 Scroll分页注意事项
  • 必须指定排序字段:建议使用唯一字段(如productId、_id)排序,避免数据重复或遗漏;

  • 及时释放资源:数据获取完成后必须调用clearScroll清除滚动上下文,否则ES会持续占用资源;

  • 不支持跳页:Scroll分页仅能按顺序获取数据,无法直接跳转到第100页,适合无跳页场景。

3.3 优化方案二:Search After分页(基于上一页最后一条数据的排序值)

Search After是ES 5.0+推出的分页方案,核心原理是:基于上一页最后一条数据的"排序字段值"作为查询条件,获取下一页数据,完全避免了from参数带来的性能问题,支持跳页(需记录中间页的排序值)。

3.3.1 适用场景

需要跳页的深度分页场景(如电商平台的商品列表分页)、大数据量下的分页查询,支持实时数据(Scroll分页依赖快照,无法获取查询后的新增数据)。

3.3.2 实现条件与代码

实现前提:必须指定唯一的排序字段(如_id或业务唯一字段+_id),确保每条数据的排序值唯一,避免分页遗漏。

java 复制代码
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.sort.SortField;

/**
 * Search After分页优化示例(适用于需跳页的深度分页场景)
 * 需求:查询第5页商品数据(每页10条),基于productId升序排序
 */
public class EsSearchAfterPaginationDemo {
    // 每页数据量
    private static final int PAGE_SIZE = 10;
    // 目标页码(从1开始)
    private static final int TARGET_PAGE = 5;

    public static void main(String[] args) {
        RestHighLevelClient client = null;
        try {
            client = EsClientFactory.getClient();

            // 排序字段配置(必须包含唯一字段,此处用productId)
            String[] sortFields = {"productId"};
            SortOrder[] sortOrders = {SortOrder.ASC};

            // 存储上一页最后一条数据的排序值(初始为null,即第一页)
            Object[] lastSortValues = null;

            // 循环获取到目标页码
            for (int page = 1; page <= TARGET_PAGE; page++) {
                SearchRequest searchRequest = new SearchRequest("product_index");
                SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();

                // 查询条件(可根据需求调整)
                sourceBuilder.query(QueryBuilders.rangeQuery("price").lte(1000.0));
                // 分页配置:size固定为PAGE_SIZE,from必须设为0(Search After不依赖from)
                sourceBuilder.size(PAGE_SIZE);
                sourceBuilder.from(0);

                // 设置排序(与上一页一致)
                for (int i = 0; i < sortFields.length; i++) {
                    sourceBuilder.sort(sortFields[i], sortOrders[i]);
                }

                // 设置Search After条件(非第一页时添加)
                if (lastSortValues != null) {
                    sourceBuilder.searchAfter(lastSortValues);
                }

                // 执行查询
                SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT);

                // 处理当前页数据(仅目标页需要详细处理,其他页仅记录lastSortValues)
                if (page == TARGET_PAGE) {
                    System.out.printf("第%d页数据:%n", TARGET_PAGE);
                    handleSearchResult(response);
                }

                // 更新lastSortValues(获取当前页最后一条数据的排序值)
                SearchHit[] hits = response.getHits().getHits();
                if (hits.length == 0) {
                    System.out.println("已无更多数据");
                    break;
                }
                lastSortValues = hits[hits.length - 1].getSortValues();
            }

        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (client != null) {
                try {
                    EsClientFactory.closeClient();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    // 结果处理方法(复用前文的handleSearchResult)
    private static void handleSearchResult(SearchResponse response) throws IOException {
        // 实现同前文,此处省略
    }
}
优势:
  • 性能优异:无需加载前N条数据,仅基于上一页排序值查询,支持无限深度分页;

  • 支持实时数据:查询基于最新数据,不会像Scroll那样依赖快照;

  • 支持跳页:只要记录中间页的lastSortValues,即可跳转到任意页。

注意事项:
  • 排序字段唯一:必须使用唯一字段(如_id)作为排序条件之一,否则可能出现数据重复或遗漏;

  • 需记录排序值:跳页时需保存上一页的lastSortValues,建议存储在前端或缓存中;

  • 不支持总条数:Search After无法直接获取总数据量(需单独查询),适合无需显示总页数的场景。

3.4 分页方案对比与选型建议

分页方案 性能(深度分页) 支持跳页 支持实时数据 适用场景
from+size 差(from越大越慢) 浅分页(前100页)、需显示总页数
Scroll 中(基于快照,稳定) 全量数据导出、无跳页深度分页
Search After 优(无性能衰减) 是(需记录排序值) 需跳页的深度分页、实时数据查询

选型口诀:浅分页用from+size,全量导出用Scroll,深度跳页用Search After; 特殊场景:若需显示总页数且分页深度较大,可结合"from+size获取前100页+Search After获取深度页"的混合方案。

总结

本文从ES环境搭建、核心查询条件构建到大数据量分页优化,形成了完整的Java实战链路。重点在于:

  1. 环境配置需注意ES 8.x的安全认证,客户端采用单例模式避免资源浪费;

  2. 查询条件构建的核心是"字段类型与查询方法匹配",通过表格对比可快速区分易混淆方法;

  3. 大数据量分页需摒弃传统的from+size,根据是否需要跳页选择Scroll或Search After方案。

ES的Java开发本质是"场景匹配"------结合业务场景选择合适的API与优化方案,才能充分发挥其高性能优势。后续可进一步学习聚合查询、高亮查询等高级特性,构建更完整的ES技术体系。

相关推荐
小梁努力敲代码2 小时前
Java多线程--单例模式
java·开发语言
老华带你飞2 小时前
学生宿舍管理|基于java + vue学生宿舍管理系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot·后端
Filotimo_2 小时前
在java后端开发中,redis的用处
java·开发语言·redis
lkbhua莱克瓦243 小时前
TCP通信练习4-上传文件名重复问题
java·网络·网络协议·tcp/ip·tcp
INGg__3 小时前
Java面试现场:从简单到复杂
java·面试·技术
毕设源码-赖学姐3 小时前
【开题答辩全过程】以 高校图书馆座位预约管理系统为例,包含答辩的问题和答案
java·spring boot
网安_秋刀鱼3 小时前
【java安全】java安全基础
java·开发语言·安全·web安全
ZePingPingZe3 小时前
不使用Spring事务的管理—原生JDBC实现事务管理
java·数据库·spring
吃喝不愁霸王餐APP开发者3 小时前
外卖API对接过程中时间戳与时区处理的最佳实践(避免核销失效)
java