返回博客

源码精读:SearchService — 6 种搜索模式的统一抽象

DefaultSearchService 是 Data Layer 中的全文搜索服务,基于 Quarkus 3.x 实现,底层使用 Doris OLAP 引擎的全文索引能力和 Redis 的 ZSET 结构。它支持 6 种搜索模式(BESTMATCH/EXACT/PREFIX/FUZZY/WILDCARD/REGEX),通过 SearchQueryBuilder 生成参数化 SQL,在多实体类型间并行搜索并合并结果。核心特性包括:并行多类型搜索(线程池 + CompletableFuture)、结果高亮(HighlightProcessor)、三种 Facet 聚合(Terms/Range/DateRange)、Redis 驱动的最近搜索和热门搜索追踪、稀疏结果时的模糊拼写建议,以及保存搜索的 CRUD。本文将深入分析其七步搜索流程、并行搜索的线程池设计、Redis ZSET 的最近/热门搜索实现、Facet 的三种分桶计算,以及自动拼写建议的回退策略。

Coomia发布于 2025年12月8日8 分钟阅读
分享本文Twitter / X

源码精读:SearchService — 6 种搜索模式的统一抽象

系列:S9 源码精读 · 第 8 篇 | 难度:高级 | 阅读时间:25 分钟

#TL;DR

DefaultSearchService 是 Data Layer 中的全文搜索服务,基于 Quarkus 3.x 实现,底层使用 Doris OLAP 引擎的全文索引能力和 Redis 的 ZSET 结构。它支持 6 种搜索模式(BEST_MATCH/EXACT/PREFIX/FUZZY/WILDCARD/REGEX),通过 SearchQueryBuilder 生成参数化 SQL,在多实体类型间并行搜索并合并结果。核心特性包括:并行多类型搜索(线程池 + CompletableFuture)、结果高亮(HighlightProcessor)、三种 Facet 聚合(Terms/Range/DateRange)、Redis 驱动的最近搜索和热门搜索追踪、稀疏结果时的模糊拼写建议,以及保存搜索的 CRUD。本文将深入分析其七步搜索流程、并行搜索的线程池设计、Redis ZSET 的最近/热门搜索实现、Facet 的三种分桶计算,以及自动拼写建议的回退策略。

#目录

  1. 整体架构与五大协作者
  2. 搜索主流程:searchInstances 七步法
  3. 并行多类型搜索:线程池 + CompletableFuture
  4. 6 种搜索模式
  5. 结果高亮:HighlightProcessor
  6. Facet 聚合:Terms / Range / DateRange
  7. Redis 最近搜索与热门搜索
  8. 自动补全建议:searchSuggest
  9. 拼写建议的模糊回退
  10. 保存搜索的 CRUD
  11. Key Takeaways

#1. 整体架构与五大协作者

Java
@ApplicationScoped
public class DefaultSearchService implements SearchService {
    private final DorisClient dorisClient;
    private final RedisDataSource redisDataSource;
    private final SavedSearchRepository savedSearchRepository;
    private final HighlightProcessor highlightProcessor;
    private final SearchQueryBuilder queryBuilder;

    private final ExecutorService searchExecutor =
            Executors.newFixedThreadPool(
                    Math.min(10, Runtime.getRuntime().availableProcessors() * 2));
}
协作者职责
DorisClient执行参数化搜索 SQL
RedisDataSource最近搜索/热门搜索的 ZSET 存储
SavedSearchRepository保存搜索的持久化
HighlightProcessor搜索结果名称字段高亮
SearchQueryBuilder根据搜索模式构建参数化 SQL

#2. 搜索主流程:searchInstances 七步法

Java
@Override
public SearchInstancesResponse searchInstances(SearchInstancesRequest request) {
    long startMs = System.currentTimeMillis();

    // 1. 确定搜索的实体类型
    List<String> entityTypes = resolveEntityTypes(worldId, filter);

    // 2. 并行执行多类型搜索,收集所有命中
    List<CompletableFuture<List<SearchHit>>> futures = new ArrayList<>();
    for (String entityType : entityTypes) {
        futures.add(CompletableFuture.supplyAsync(() -> {
            SearchQueryBuilder.BuildResult result = queryBuilder.build(
                    worldId, entityType, query, mode, filter, pageSize, pageToken);
            List<Map<String, Object>> rows = dorisClient.executeQuery(
                    result.sql(), result.params().toArray());
            return rows.stream().map(this::mapSearchHit).toList();
        }, searchExecutor));
    }
    List<SearchHit> allHits = collectResults(futures);

    // 3. 按分数降序排序,截断到页大小
    allHits.sort(Comparator.comparingDouble(SearchHit::getScore).reversed());
    if (allHits.size() > pageSize) allHits = allHits.subList(0, pageSize);

    // 4. 高亮
    if (request.getIncludeHighlights()) {
        allHits = applyHighlights(allHits, query, preTag, postTag);
    }

    // 5. 计算总命中数和分页
    long totalHits = computeTotalHits(worldId, entityTypes, query, mode, filter);
    String nextPageToken = (currentOffset + pageSize < totalHits)
            ? String.valueOf(currentOffset + pageSize) : "";

    // 6. 稀疏结果时的拼写建议
    List<String> spellSuggestions = new ArrayList<>();
    if (allHits.size() < 5 && mode != SearchMode.FUZZY) {
        spellSuggestions = collectSpellSuggestions(worldId, entityTypes, query, filter);
    }

    // 7. Facet 聚合
    List<FacetResult> facetResults = computeFacets(worldId, query, mode, filter,
            request.getFacetsList());

    return SearchInstancesResponse.newBuilder()
            .addAllHits(allHits).setTotalHits(totalHits)
            .setNextPageToken(nextPageToken)
            .addAllSpellSuggestions(spellSuggestions)
            .addAllFacetResults(facetResults)
            .setTookMs(System.currentTimeMillis() - startMs)
            .build();
}

#3. 并行多类型搜索:线程池 + CompletableFuture

Java
private final ExecutorService searchExecutor =
        Executors.newFixedThreadPool(
                Math.min(10, Runtime.getRuntime().availableProcessors() * 2));

线程池大小min(10, CPU * 2) 在高核心数机器上限制最大并行度为 10,避免对 Doris 产生过大连接压力。

@PreDestroy 优雅关闭searchExecutor.shutdown() + 5 秒等待 + shutdownNow() 确保服务停止时不丢失进行中的搜索。

#4. 6 种搜索模式

模式SQL 策略适用场景
BEST_MATCH全文索引 + 相关性评分默认搜索
EXACT= ? 精确匹配精确查找
PREFIXLIKE 'query%'前缀搜索
FUZZY编辑距离模糊匹配容错搜索
WILDCARDLIKE '%query%'通配符搜索
REGEX正则表达式匹配高级搜索

默认模式为 BEST_MATCH(当请求未指定 SearchMode 时)。

#5. 结果高亮:HighlightProcessor

Java
if (request.getIncludeHighlights() && query != null && !query.isBlank()) {
    allHits = applyHighlights(allHits, query, preTag, postTag);
}

高亮处理器在 name 字段中标记匹配的查询词,使用可自定义的前后标签(如 <em> / </em>)。

#6. Facet 聚合:Terms / Range / DateRange

Java
private FacetResult executeFacet(String worldId, String query, SearchMode mode,
        SearchFilter filter, FacetRequest facetReq) {
    FacetType type = facetReq.getType();
    if (type == FacetType.FACET_TERMS) {
        // GROUP BY field LIMIT size
    } else if (type == FacetType.FACET_RANGE) {
        // CASE WHEN field BETWEEN ... ranges
    } else if (type == FacetType.FACET_DATE_RANGE) {
        // DATE_FORMAT(field, granularity) GROUP BY
    }
}

三种 Facet 类型:

类型说明用途
FACET_TERMS按字段值分组计数按类型、状态筛选
FACET_RANGE按数值范围分桶价格区间
FACET_DATE_RANGE按日期粒度分桶按年/月/周

#7. Redis 最近搜索与热门搜索

Java
private static final String REDIS_RECENT_PREFIX = "search:recent:";
private static final String REDIS_POPULAR_PREFIX = "search:popular:";
private static final int MAX_RECENT_SIZE = 100;
  • 最近搜索search:recent:{userId} ZSET,score = 时间戳,保留最近 100 条
  • 热门搜索search:popular:{worldId} ZSET,score = 搜索次数
Java
// 记录最近搜索
getSortedSetCommands().zadd(key, (double) System.currentTimeMillis(), query);
// 裁剪到最大大小
getSortedSetCommands().zremrangebyrank(key, 0L, -(MAX_RECENT_SIZE + 1));

Double-Check Locking 初始化:Redis SortedSet 命令句柄使用 volatile + 同步块的双重检查锁定模式延迟初始化。

#8. 自动补全建议:searchSuggest

Java
@Override
public SearchSuggestResponse searchSuggest(SearchSuggestRequest request) {
    List<Suggestion> suggestions = new ArrayList<>();

    // 1. 实例建议(Doris 前缀搜索)
    suggestions.add(Suggestion.newBuilder()
            .setType(SuggestionType.INSTANCE).setScore(1.0)...);

    // 2. 最近搜索关键字(Redis)
    suggestions.add(Suggestion.newBuilder()
            .setType(SuggestionType.KEYWORD).setScore(0.9)...);

    // 3. 热门搜索关键字(Redis)
    suggestions.add(Suggestion.newBuilder()
            .setType(SuggestionType.KEYWORD).setScore(0.7)...);
}

三类建议按 score 排序:实例建议(1.0)> 最近关键字(0.9)> 热门关键字(0.7)。

#9. 拼写建议的模糊回退

Java
if (allHits.size() < 5 && mode != SearchMode.FUZZY) {
    spellSuggestions = collectSpellSuggestions(worldId, entityTypes, query, filter);
}

当搜索结果少于 5 条且当前模式不是 FUZZY 时,自动执行一次 FUZZY 搜索,提取前 3 个名称作为拼写建议——类似 Google 的"您是不是要搜索..."。

#10. 保存搜索的 CRUD

保存搜索功能允许用户命名和保存搜索查询以供后续复用,通过 SavedSearchRepository 持久化到数据库。

#11. Key Takeaways

  1. 并行多类型搜索:线程池 + CompletableFuture 并行查询多个实体类型,合并后按分数排序
  2. 6 种搜索模式:从精确匹配到正则表达式,覆盖全场景搜索需求
  3. Redis ZSET 追踪:最近搜索(per-user)和热门搜索(per-world)使用 ZSET 高效管理
  4. 三种 Facet:Terms/Range/DateRange 覆盖分类、数值和时间维度的聚合筛选
  5. 自动拼写建议:稀疏结果时自动回退到 FUZZY 搜索,提供"您是不是要搜索..."体验
  6. 结果高亮:支持自定义前后标签的关键词高亮

#下一篇

S9-09:MetricRegistryService — 指标计算策略路由。我们将深入指标注册服务,看它如何管理指标的注册、验证、策略推荐,以及与物化视图的自动联动。

Tags: #coomia-dip #source-code-reading #data-Layer #search #full-text #doris #redis #facets #autocomplete #spell-suggest