【搜索文章】:搜索(es)+ 搜索记录(mongodb)+ 搜索联想词

需求

用户输入关键字时,可以检索出结果,

并且可以查看历史搜索情况,

还可以进行联想词展示。

ElasticSearch(搜索)

准备工作

  1. 使用docker安装es,配置ik分词器
  2. 重新建一个search模块,用来写搜索微服务的业务代码
  3. 导入es的依赖
  4. 配置RestHighLevelClient
java 复制代码
@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "elasticsearch")
public class ElasticSearchConfig {
    private String host;
    private int port;

    @Bean
    public RestHighLevelClient client(){
        System.out.println(host);
        System.out.println(port);
        return new RestHighLevelClient(RestClient.builder(
                new HttpHost(
                        host,
                        port,
                        "http"
                )
        ));
    }
}
yml 复制代码
spring:
  autoconfigure:
    exclude: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
elasticsearch:
  host: 192.168.140.102
  port: 9200
  1. 初始化索引库数据(项目上线之前需要批量导入):
java 复制代码
@Autowired
private ApArticleMapper apArticleMapper;

  @Autowired
  private RestHighLevelClient restHighLevelClient;
  /**
   * 注意:数据量的导入,如果数据量过大,需要分页导入
   * @throws Exception
   */
  @Test
  public void init() throws Exception {
      // 1. 查询所有符合条件的文章数据
      List<SearchArticleVo> searchArticleVos = apArticleMapper.loadArticleList();
      // 2. 批量导入es索引库中
      BulkRequest bulkRequest = new BulkRequest("app_info_article");
      for (SearchArticleVo searchArticleVo : searchArticleVos) {
          IndexRequest indexRequest = new IndexRequest().id(searchArticleVo.getId().toString())
                  .source(JSON.toJSONString(searchArticleVo), XContentType.JSON);
          bulkRequest.add(indexRequest); // 批量添加数据
      }
      restHighLevelClient.bulk(bulkRequest, RequestOptions.DEFAULT);
  }

文章搜索

  1. 单一条件查询:直接放入SearchSourceBuilder
    如果查询逻辑简单,只有一个独立条件,可以直接将条件放入SearchSourceBuilder的query方法中
java 复制代码
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.termQuery("status", "active"));
  1. 组合多个条件:必须使用BoolQueryBuilder,当需要组合多个条件(如 AND/OR/NOT 逻辑)时,必须显式使用 BoolQueryBuilder。
类型 作用 是否影响评分 是否可缓存
must 子条件,必须满足,类似逻辑 AND ✅ 是 ❌ 否
filter 子条件 必须满足,但不参与相关性评分 ❌ 否 ✅ 是(可缓存)
java 复制代码
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery()
    .must(QueryBuilders.termQuery("status", "active")) // AND 条件
    .must(QueryBuilders.rangeQuery("age").gte(18)) // 另一个 AND 条件
    .should(QueryBuilders.termQuery("tag", "urgent")) // OR 条件
    .mustNot(QueryBuilders.termQuery("deleted", true)); // NOT 条件

SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(boolQuery);

虽然技术上可以将所有查询都包装成 BoolQuery,但直接使用单一条件更简洁

java 复制代码
private final RestHighLevelClient restHighLevelClient;
@Override
public ResponseResult search(UserSearchDto dto) throws IOException {
    // 1. 检查参数
    if(dto == null || StringUtils.isBlank(dto.getSearchWords())) {
        return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
    }
    // 2. 设置查询条件
    SearchRequest searchRequest = new SearchRequest("app_info_article");
    // searchSourceBuilder主要是对查询结果处理(分页、排序、高亮),不参与查询逻辑的构建
    SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
    // boolQuery主要是构建复杂的查询逻辑
    BoolQueryBuilder boolQuery = QueryBuilders.boolQuery(); // 布尔查询
    // 2-1. 关键词分词后查询
    QueryStringQueryBuilder queryStringQueryBuilder = QueryBuilders.queryStringQuery(dto.getSearchWords()) // 分词之后再查询
            .field("title") // 对标题分词
            .field("content") // 对内容分词
            .defaultOperator(Operator.OR);// 分词之后的条件(或的关系)
    boolQuery.must(queryStringQueryBuilder); // 2-1. 放入布尔查询中(must:参与算分)
    // 2-2. 查询小于minBehotTime的数据
    RangeQueryBuilder rangeQueryBuilder = QueryBuilders.rangeQuery("publishTime") // 发布时间
            .lt(dto.getMinBehotTime().getTime());// 小于minBehotTime
    boolQuery.filter(rangeQueryBuilder); // 2-2. 放入布尔查询中(filter:不参与算分)
    // 2-3. 分页查询
    searchSourceBuilder.from(0);
    searchSourceBuilder.size(dto.getPageSize());
    // 2-4. 按照发布时间倒叙查询
    searchSourceBuilder.sort("publishTime", SortOrder.DESC);
    // 2-5. 设置高亮
    HighlightBuilder highlightBuilder = new HighlightBuilder();
    highlightBuilder.field("title");// 哪个字段高亮
    highlightBuilder.preTags("<font style='color: red; font-size: inherit;'>"); // 高亮字段前缀
    highlightBuilder.postTags("</font>"); // 高亮字段的后缀
    searchSourceBuilder.highlighter(highlightBuilder);
    searchSourceBuilder.query(boolQuery);
    searchRequest.source(searchSourceBuilder);
    SearchResponse searchResponse = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
    // 3. 结果封装返回
    SearchHit[] hits = searchResponse.getHits().getHits();
    List<Map> list = new ArrayList<>();
    for (SearchHit hit : hits) {
        String json = hit.getSourceAsString();
        Map map = JSON.parseObject(json, Map.class);
        // 处理高亮
        if(hit.getHighlightFields() != null && hit.getHighlightFields().size() > 0) {
            Text[] titles = hit.getHighlightFields().get("title").getFragments();
            String title = StringUtils.join(titles); // 高亮之后的title
            map.put("h_title", title); // 设置高亮标题
        }else {
            map.put("h_title", map.get("title")); // 没有设置高亮,就把原本的标题放入h_title中
        }
        list.add(map);
    }
    return ResponseResult.okResult(list);
}

新增文章创建索引

思路:文章审核成功后使用kafka发送消息,文章微服务是消息的生产者;搜索微服务接收到消息后,添加数据到索引库,搜索微服务是消息的消费者。

  1. 文章微服务(生产者)

到yml中配置生产者:

yml 复制代码
spring:
  kafka:
	bootstrap-servers: 192.168.140.102:9092
	producer:
	  # 重试次数
	  retries: 10
	  # key、value的序列化器
	  key-serializer: org.apache.kafka.common.serialization.StringSerializer
	  value-serializer: org.apache.kafka.common.serialization.StringSerializer

往消息队列中发送消息:

java 复制代码
// 发送消息,创建索引
SearchArticleVo searchArticleVo = new SearchArticleVo();
BeanUtils.copyProperties(article, searchArticleVo);
searchArticleVo.setContent(dto.getContent());
searchArticleVo.setStaticUrl(path);
kafkaTemplate.send(ArticleConstants.ARTICLE_ES_SYNC_TOPIC, JSON.toJSONString(searchArticleVo));
  1. 搜索微服务(消费者)

到yml中配置消费者:

yml 复制代码
spring:
  kafka:
    bootstrap-servers: 192.168.140.102:9092
    consumer:
      # 消费组
      group-id: ${spring.application.name}
      # key、value的反序列化器
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer

mongodb(搜索记录)

需要给每个用户保存一份搜索记录,数据量大,要求加载速度快,通常这样的数据存储到mongodb更合适,不建议存到mysql中。

  1. mongodb:
    • 支持分片,适合存储用户搜索日志这种持续写入的场景
    • 基于磁盘存储,成本低
  2. mysql:
    • 对高频写入(如每秒数千次插入)的支持较弱
    • 搜索记录通常是半结构化或非结构化数据,需频繁变更表结构来适应新字段
  3. redis:
    • redis基于内存的,内存成本高,适合存储热数据(如缓存)
    • Redis 的 RDB 快照和 AOF 日志是异步持久化机制,在宕机时可能丢失部分数据
    • 数据量过大时,从磁盘加载备份到内存的恢复过程耗时较长
  • MongoDB:适合作为主存储,满足海量数据、灵活查询、低成本持久化的核心需求。
  • Redis:适合作为缓存层,加速近期数据的访问,但无法替代 MongoDB 的长期存储角色。
  • MySQL:不适合高频写入和非结构化日志场景。

准备工作

1. 配置环境

使用docker安装mongodb:

docker run -di \
--name mongo-service \
--restart=always \
-p 27017:27017 \
-v ~/data/mongodata:/data \
mongo

2. springboot集成mongodb

  1. 添加mongodb依赖:
xml 复制代码
<dependency>
	<groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
  1. 配置mongodb:
yml 复制代码
spring:
  data:
    mongodb:
      host: 192.168.140.102
      port: 27017
      database: leadnews-history
  1. 映射
java 复制代码
@Data
@Document("ap_associate_words") // 映射哪个集合【mongodb表名】
public class ApAssociateWords implements Serializable {
    private static final long serialVersionUID = 1L;
    private String id;
    /**
     * 联想词
     */
    private String associateWords;
    /**
     * 创建时间
     */
    private Date createdTime;

}
  1. 核心方法
  • 保存或修改:
java 复制代码
@Autowired
private MongoTemplate mongoTemplate;

//保存
@Test
public void saveTest(){
    ApAssociateWords apAssociateWords = new ApAssociateWords();
    apAssociateWords.setAssociateWords("黑马头条");
    apAssociateWords.setCreatedTime(new Date());
    mongoTemplate.save(apAssociateWords);
}
  • 查询一个对象
java 复制代码
@Test
public void saveFindOne(){
    ApAssociateWords apAssociateWords = mongoTemplate.findById("67a330c35faec30826dcbe8e", ApAssociateWords.class);
    System.out.println(apAssociateWords);
}
  • 多条件查询
java 复制代码
@Test
public void testQuery(){
    Query query = Query.query(Criteria.where("associateWords").is("黑马头条"))
            .with(Sort.by(Sort.Direction.DESC,"createdTime"));
    List<ApAssociateWords> apAssociateWordsList = mongoTemplate.find(query, ApAssociateWords.class);
    System.out.println(apAssociateWordsList);
}
  • 删除
java 复制代码
@Test
public void testDel(){
    mongoTemplate.remove(Query.query(Criteria.where("associateWords").is("黑马头条")),ApAssociateWords.class);
}

保存搜索记录

用户搜索后,为了让用户能更快的得到搜索的结果,异步发送请求记录关键字。

java 复制代码
private final MongoTemplate mongoTemplate;
// 保存搜索记录
@Override
@Async
public void save(String keyword, Integer userId) {
    // 1. 查询当前用户搜索关键字
    Query query = Query.query(Criteria.where("userId").is(userId)
                                        .and("keyword").is(keyword));
    ApUserSearch apUserSearch = mongoTemplate.findOne(query, ApUserSearch.class);
    // 2. 存在 - 更新时间
    if(apUserSearch != null) {
        apUserSearch.setCreatedTime(new Date());
        mongoTemplate.save(apUserSearch); // 有id-修改、没有id-新增
        return;
    }
    // 3. 不存在 - 判断该用户的当前历史总数量是否 > 10
    apUserSearch = new ApUserSearch();
    apUserSearch.setUserId(userId);
    apUserSearch.setKeyword(keyword);
    apUserSearch.setCreatedTime(new Date());
    // 4. 当前用户的当前历史总数量 < 10 - 直接保存
    Query query1 = Query.query(Criteria.where("userId").is(userId));
    query1.with(Sort.by(Sort.Direction.DESC, "createdTime")); // 按照时间倒序排列
    List<ApUserSearch> apUserSearches = mongoTemplate.find(query1, ApUserSearch.class);
    if(apUserSearches == null || apUserSearches.size() < 10) {
        mongoTemplate.save(apUserSearch); // 直接保存
    }else {
        // 5. 当前用户的当前历史总数量 >= 10 - 替换最后一条记录
        ApUserSearch lastUserSearch = apUserSearches.get(apUserSearches.size() - 1);
        mongoTemplate.findAndReplace(Query.query(Criteria.where("id").is(lastUserSearch.getId())), apUserSearch); // 修改最后一条记录
    }
}

在之前写的文章搜索的业务代码中,异步调用"保存搜索记录"的方法。

其中:userId通过app网关的过滤器拦截到前端发过来的userId,并把userId放到请求头中传给搜索微服务,搜索微服务的拦截器获取app网关发来的userId,存到ThreadLocal中。

注意:由于是异步调用save方法,是又开了一个线程,此时这个线程是没办法从ThreadLocal中获取到userId,只能通过主线程传过来。

查询搜索历史

java 复制代码
public ResponseResult findUserSearch() {
        // 获取当前用户
        ApUser user = AppThreadLocalUtil.getUser();
        // 根据用户查询当前数据(按照时间倒叙)
        if(user == null) {
            return ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
        }
        List<ApUserSearch> list = mongoTemplate.find(Query.query(Criteria.where("userId").is(user.getId()))
                                                .with(Sort.by(Sort.Direction.DESC, "createdTime")), ApUserSearch.class);
        return ResponseResult.okResult(list);
    }

根据用户id和当前某个用户的id查找记录,并按照创建时间降序排列。

删除某一个历史记录

java 复制代码
public ResponseResult delUserSearch(HistorySearchDto dto) {
    // 检查参数
    if (dto.getId() == null) {
        return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
    }
    // 获取当前用户
    ApUser user = AppThreadLocalUtil.getUser();
    // 判断是否登录
    if(user == null) {
        return ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
    }
    // 删除
    mongoTemplate.remove(Query.query(Criteria.where("userId").is(user.getId())
                                            .and("id").is(dto.getId())), ApUserSearch.class);
    return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
}

根据用户id和当前某个搜索记录的id进行删除

搜索联想词

搜索词(数据来源)

使用网上搜索频率较高的一些词:

  1. 自己维护联想词:通过分析用户搜索频率较高的词,按照排名作为搜索词
  2. 第三方获取:5118...

导入联想词

实现

正则表达式:

java 复制代码
// 搜索联想词
@Override
public ResponseResult search(UserSearchDto dto) {
    // 1. 检查参数
    if(StringUtils.isBlank(dto.getSearchWords())) {
        return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
    }
    // 2. 分页检查(最多只能查询20条)
    if(dto.getPageSize() > 20) {
        dto.setPageSize(20);
    }
    // 3. 执行模糊查询
    String regexStr = ".*?\\" + dto.getSearchWords() + ".*";
    Query query = Query.query(Criteria.where("associateWords")
                        .regex(regexStr))
                        .limit(dto.getPageSize());
    List<ApAssociateWords> list = mongoTemplate.find(query, ApAssociateWords.class);
    return ResponseResult.okResult(list);
}

其实搜索联想词,就是提前先把词库导入到mongodb表中,用户在输入的时候,就会对这个表进行模糊查询,遇到符合条件的就立马匹配。

相关推荐
Elastic 中国社区官方博客1 小时前
使用 Elastic Cloud Hosted 优化长期数据保留:确保政府合规性和效率
大数据·数据库·elasticsearch·搜索引擎·全文检索
risc1234567 小时前
【Elasticsearch】 Composite Aggregation 详解
elasticsearch
forestsea7 小时前
【Elasticsearch】索引性能优化
大数据·elasticsearch·性能优化
risc1234567 小时前
【Elasticsearch】date range聚合
elasticsearch
喜欢猪猪15 小时前
基于 Java 开发的 MongoDB 企业级应用全解析
java·开发语言·mongodb
dongba815 小时前
MongoDB 聚合
数据库·mongodb
利刃大大1 天前
【Git】一、初识Git && Git基本操作详解
大数据·git·elasticsearch
Elasticsearch1 天前
Elastic Playground:使用 Elastic 连接器与你的数据聊天
elasticsearch
risc1234562 天前
Elasticsearch 指南 [8.17] | Search APIs
elasticsearch