forum-search-service 搜索服务
搜素使用 Elasticsearch
为什么搜索服务要用 Elasticsearch
MySQL 搜索的问题:
| 问题 | 说明 | 影响 |
|---|---|---|
| 全表扫描 | LIKE '%keyword%' 无法使用索引 | 百万级数据时查询极慢(秒级) |
| 不支持分词 | 搜索"Spring框架"无法匹配"Spring Boot" | 搜索结果不准确 |
| 不支持相关性排序 | 无法按匹配度排序 | 用户体验差 |
| 不支持拼音搜索 | 输入"spring"无法匹配"Spring" | 大小写敏感 |
| 不支持高亮 | 需要手动处理 | 开发成本高 |
| 不支持同义词 | 搜索"电脑"无法匹配"计算机" | 召回率低 |
Elasticsearch vs MySQL 对比
| 对比项 | MySQL | Elasticsearch |
|---|---|---|
| 本质 | 关系型数据库 | 分布式搜索引擎 |
| 数据量 | 千万级性能下降 | PB级仍保持毫秒响应 |
| 搜索速度 | 秒级到分钟级 | 毫秒级 |
| 分词 | 不支持 | 支持中文分词(IK) |
| 相关性排序 | 不支持 | 支持(TF-IDF/BM25) |
| 高亮 | 需手动实现 | 自动支持 |
| 拼音搜索 | 需手动实现 | 支持拼音插件 |
| 同义词 | 需手动实现 | 支持同义词配置 |
| 分布式 | 需手动分库分表 | 原生支持 |
搜索流程

ES详解链接:
ES详解
1.模块结构
text
forum-search-service/
├── pom.xml
├── src/main/java/com/forum/search/
│ ├── SearchServiceApplication.java
│ ├── controller/
│ │ └── SearchController.java
│ ├── dto/
│ │ ├── SearchDTO.java
│ │ └── SearchResultVO.java
│ ├── service/
│ │ ├── SearchService.java
│ │ └── impl/
│ │ └── SearchServiceImpl.java
│ ├── config/
│ │ ├── ElasticsearchConfig.java
│ │ └── Knife4jConfig.java
│ └── listener/
│ └── PostSyncListener.java
└── src/main/resources/
└── application.yml
2.具体实现
pom
java
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>forum-backend</artifactId>
<groupId>org.example</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>forum-search-service</artifactId>
<packaging>jar</packaging>
<description>搜索服务 - 提供全文搜索功能</description>
<dependencies>
<!-- Service模块 -->
<dependency>
<groupId>org.example</groupId>
<artifactId>forum-service</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<!-- Core模块 -->
<dependency>
<groupId>org.example</groupId>
<artifactId>forum-core</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Elasticsearch -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<!-- Knife4j -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.forum.search.SearchServiceApplication</mainClass>
<finalName>forum-search-service</finalName>
</configuration>
</plugin>
</plugins>
</build>
</project>
启动类
java
package com.forum.search;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
@SpringBootApplication
@ComponentScan(basePackages = {"com.forum"})
@MapperScan("com.forum.dao.mapper")
public class SearchServiceApplication {
public static void main(String[] args) {
SpringApplication.run(SearchServiceApplication.class, args);
System.out.println("========================================");
System.out.println("搜索服务启动成功!");
System.out.println("访问地址: http://localhost:8085");
System.out.println("API文档: http://localhost:8085/doc.html");
System.out.println("========================================");
}
}
DTO类
java
package com.forum.search.dto;
import com.forum.common.result.PageRequest;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@ApiModel(description = "搜索请求")
public class SearchDTO extends PageRequest {
@ApiModelProperty(value = "搜索关键词", required = true)
private String keyword;
@ApiModelProperty(value = "搜索类型 1:帖子 2:用户", example = "1")
private Integer type = 1;
@ApiModelProperty(value = "版块ID过滤")
private Long categoryId;
@ApiModelProperty(value = "用户ID过滤")
private Long userId;
}
SearchResultVO
java
package com.forum.search.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@ApiModel(description = "搜索结果")
public class SearchResultVO {
@ApiModelProperty(value = "ID")
private Long id;
@ApiModelProperty(value = "标题")
private String title;
@ApiModelProperty(value = "内容摘要")
private String summary;
@ApiModelProperty(value = "用户ID")
private Long userId;
@ApiModelProperty(value = "用户名")
private String username;
@ApiModelProperty(value = "版块ID")
private Long categoryId;
@ApiModelProperty(value = "版块名称")
private String categoryName;
@ApiModelProperty(value = "浏览量")
private Integer viewCount;
@ApiModelProperty(value = "回复数")
private Integer replyCount;
@ApiModelProperty(value = "高亮标题")
private String highlightTitle;
@ApiModelProperty(value = "高亮内容")
private String highlightContent;
@ApiModelProperty(value = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdTime;
@ApiModelProperty(value = "相关性得分")
private Float score;
}
Elasticsearch文档实体
java
package com.forum.search.entity;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
import java.time.LocalDateTime;
@Data
@Document(indexName = "forum_post")
public class PostDocument {
@Id
private Long id;
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String title;
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String content;
@Field(type = FieldType.Long)
private Long userId;
@Field(type = FieldType.Long)
private Long categoryId;
@Field(type = FieldType.Integer)
private Integer viewCount;
@Field(type = FieldType.Integer)
private Integer replyCount;
@Field(type = FieldType.Integer)
private Integer likeCount;
@Field(type = FieldType.Date)
private LocalDateTime createdTime;
@Field(type = FieldType.Date)
private LocalDateTime updatedTime;
}
Repository
java
package com.forum.search.repository;
import com.forum.search.entity.PostDocument;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.elasticsearch.annotations.Query;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface PostSearchRepository extends ElasticsearchRepository<PostDocument, Long> {
/**
* 根据标题或内容搜索
*/
Page<PostDocument> findByTitleContainingOrContentContaining(String title, String content, Pageable pageable);
/**
* 自定义搜索查询
*/
@Query("{\"bool\": {\"must\": [{\"match\": {\"title\": \"?0\"}}]}}")
Page<PostDocument> searchByTitle(String keyword, Pageable pageable);
/**
* 按版块搜索
*/
Page<PostDocument> findByCategoryId(Long categoryId, Pageable pageable);
/**
* 按用户搜索
*/
Page<PostDocument> findByUserId(Long userId, Pageable pageable);
}
服务类
java
package com.forum.search.service;
import com.forum.common.result.PageResult;
import com.forum.search.dto.SearchDTO;
import com.forum.search.dto.SearchResultVO;
public interface SearchService {
/**
* 全文搜索
*/
PageResult<SearchResultVO> search(SearchDTO dto);
/**
* 索引帖子
*/
void indexPost(Long postId);
/**
* 批量索引帖子
*/
void indexAllPosts();
/**
* 删除索引
*/
void deletePostIndex(Long postId);
/**
* 清空所有索引
*/
void clearAllIndexes();
}
实现类
java
package com.forum.search.service.impl;
import cn.hutool.core.bean.BeanUtil;
import com.forum.common.exception.BusinessException;
import com.forum.common.result.PageResult;
import com.forum.dao.entity.PostEntity;
import com.forum.dao.mapper.PostMapper;
import com.forum.search.dto.SearchDTO;
import com.forum.search.dto.SearchResultVO;
import com.forum.search.entity.PostDocument;
import com.forum.search.repository.PostSearchRepository;
import com.forum.search.service.SearchService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class SearchServiceImpl implements SearchService {
private final PostSearchRepository postSearchRepository;
private final PostMapper postMapper;
@Override
public PageResult<SearchResultVO> search(SearchDTO dto) {
log.info("搜索: keyword={}, type={}, page={}, size={}",
dto.getKeyword(), dto.getType(), dto.getPageNum(), dto.getPageSize());
if (StringUtils.isBlank(dto.getKeyword())) {
throw new BusinessException("搜索关键词不能为空");
}
Pageable pageable = PageRequest.of(dto.getPageNum() - 1, dto.getPageSize());
Page<PostDocument> page;
// 根据搜索类型执行不同搜索
if (dto.getType() == 1) {
// 帖子搜索
page = postSearchRepository.findByTitleContainingOrContentContaining(
dto.getKeyword(), dto.getKeyword(), pageable);
} else {
// 用户搜索(待实现)
page = Page.empty();
}
List<SearchResultVO> records = page.getContent().stream()
.map(this::convertToVO)
.collect(Collectors.toList());
return PageResult.of(dto.getPageNum(), dto.getPageSize(), page.getTotalElements(), records);
}
@Override
public void indexPost(Long postId) {
log.info("索引帖子: postId={}", postId);
PostEntity post = postMapper.selectById(postId);
if (post == null || post.getStatus() != 1) {
log.warn("帖子不存在或已删除,跳过索引: postId={}", postId);
return;
}
PostDocument document = new PostDocument();
BeanUtil.copyProperties(post, document);
postSearchRepository.save(document);
log.info("帖子索引成功: postId={}", postId);
}
@Override
public void indexAllPosts() {
log.info("开始批量索引所有帖子");
List<PostEntity> posts = postMapper.selectList(null);
List<PostDocument> documents = posts.stream()
.filter(post -> post.getStatus() == 1)
.map(post -> {
PostDocument doc = new PostDocument();
BeanUtil.copyProperties(post, doc);
return doc;
})
.collect(Collectors.toList());
postSearchRepository.saveAll(documents);
log.info("批量索引完成,共索引{}条帖子", documents.size());
}
@Override
public void deletePostIndex(Long postId) {
log.info("删除帖子索引: postId={}", postId);
postSearchRepository.deleteById(postId);
}
@Override
public void clearAllIndexes() {
log.info("清空所有索引");
postSearchRepository.deleteAll();
}
private SearchResultVO convertToVO(PostDocument document) {
SearchResultVO vo = new SearchResultVO();
BeanUtil.copyProperties(document, vo);
// 高亮处理
vo.setHighlightTitle(document.getTitle());
vo.setHighlightContent(getSummary(document.getContent()));
return vo;
}
private String getSummary(String content) {
if (StringUtils.isBlank(content)) {
return "";
}
return content.length() > 200 ? content.substring(0, 200) + "..." : content;
}
}
controller
java
package com.forum.search.controller;
import com.forum.common.base.BaseController;
import com.forum.common.result.PageResult;
import com.forum.common.result.Result;
import com.forum.search.dto.SearchDTO;
import com.forum.search.dto.SearchResultVO;
import com.forum.search.service.SearchService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@Slf4j
@Api(tags = "搜索服务")
@RestController
@RequestMapping("/api/search")
@RequiredArgsConstructor
public class SearchController extends BaseController {
private final SearchService searchService;
@ApiOperation("全文搜索")
@GetMapping("/posts")
public Result<PageResult<SearchResultVO>> search(@Valid SearchDTO dto) {
PageResult<SearchResultVO> result = searchService.search(dto);
return success(result);
}
@ApiOperation("索引帖子(管理员)")
@PostMapping("/index/{postId}")
public Result<Void> indexPost(@PathVariable Long postId) {
if (!isAdmin()) {
return error("无权限操作");
}
searchService.indexPost(postId);
return success();
}
@ApiOperation("批量索引所有帖子(管理员)")
@PostMapping("/index/all")
public Result<Void> indexAllPosts() {
if (!isAdmin()) {
return error("无权限操作");
}
searchService.indexAllPosts();
return success();
}
@ApiOperation("健康检查")
@GetMapping("/health")
public Result<String> health() {
return success("搜索服务正常运行");
}
}