← 返回首页
文章已发布85 次阅读admin2026年10月31日47 分钟阅读

内容全文检索:从入库到检索的全链路实现

双写不双源的 ES 全文检索:写入五路径同步灌索引、读路径三口径一颗 DSL、ES 挂了降级 DB、_id=contentId 幂等重建,附 3 张架构图与 7 段源码。

附件

  • es-fig1-arch.png(227 KB)下载
  • es-fig2-write.png(196 KB)下载
  • es-fig3-read.png(221 KB)下载

TL;DR:内容中心的全文检索是一套「双写不双源」的实现——PostgreSQL 始终是唯一真相源,Elasticsearch 只是从聚合投影出来的可重建索引;所有写路径在事务完成后同步灌一次索引(_id = contentId 幂等 upsert),索引失败只 warn 不阻断;读路径按「匿名 / 登录个人 / 全站公开」三种口径拼 bool 查询,ES 挂了自动降级回数据库分页。全文约 4000 字、3 张图、7 段源码,读完可以对照本仓库完整复现。

01 为什么要做这套东西

被 LIKE 逼出来的。内容中心的第一版检索就是 title LIKE '%kw%' OR body LIKE '%kw%',数据量小的时候能用,但三个硬伤很早就露出来了:

  1. 不分词——搜「全文检索」匹配不到「检索全景」,反之亦然;
  2. 没有相关度——标题命中和正文第 900 字命中同分,无法排序;
  3. 扫全表——%kw% 两边都有通配符,索引形同虚设。

业务上首页信息流要按关键词出文章卡片、命中词要高亮,这三件事 LIKE 都做不了,所以上了 Elasticsearch。以下所有代码都来自 backend/domains/content,版本 1.5.26(单体口径)。

02 整体架构:一圈两叉

先给全景图:

全文检索整体架构

架构上有三条设计原则,后面每个环节都能对上:

  • 端口隔离。ES 的所有交互收口在 ContentIndexPort(domain 层端口)+ ContentIndexPortImpl(infrastructure 实现),应用服务层完全看不到 RestClient。哪天换 OpenSearch 或者去掉 ES,只动 infrastructure 一个模块。
  • 双写不双源。PG 和 ES 都有数据,但 PG 是真相源:索引文档随时可以从 PG 全量重建(见 06 节补偿)。ES 数据没有权威性,也永远不需要"对账保真"的心理负担。
  • 失败不传染。写库成功、索引失败时主流程照常返回,索引靠补偿兜底;检索失败时读请求降级 DB,用户无感。

03 写链路:入库时机与索引文档

写入时机全景

写入链路

ContentServiceImpl 里所有会改聚合的路径,同一个调用点收尾:

// application/.../ContentServiceImpl.java —— 五个写路径的同一收尾
create(...)    { ...; contentRepository.save(content); indexContent(content); }   // L135
update(...)    { ...; contentRepository.save(content); indexContent(content); }   // L156
publish/unpublish/archive/unarchive → applyTransition(...) { ...; indexContent(content); }  // L469
revert(...)    { ...; contentRepository.save(content); indexContent(content); }   // L656
addAttachment(...) { ...; attachmentRepository.save(attachment); reindexContent(content); }  // L329
delete(...)    { ...; contentRepository.delete(content); removeIndex(id); }       // L299

索引方法本身极其克制——失败只打 warn,不往上层抛:

// ContentServiceImpl.java L747 —— 索引失败不阻断主流程
private void indexContent(Content content) {
    try {
        contentIndexPort.index(toIndexEntry(content));
    } catch (RuntimeException e) {
        domainLogger.warn("内容索引失败 contentId={} error={}",
                content.getId().getValue(), e.getMessage());
    }
}

这是个取舍:宁可短暂检索不到(很快会被下一次写操作或补偿重建覆盖),也不让 ES 故障打断用户的保存操作。对内容工具这种低频写、允许秒级延迟的场景,同步索引换来的语义简单性(没有队列、没有消费者、没有最终一致性窗口)是划算的。

附件文本:Tika 抽取后随文档一起索引

上传 PDF/DOCX 类附件时,TikaTextExtractionPortImpl 在上传事务里抽出全文文本存进 content_attachment.extracted_text;索引时聚合拼接:

// ContentServiceImpl.java —— 把所有附件抽取文本拼进索引文档
private String aggregateAttachmentText(String contentId) {
    List<ContentAttachment> attachments = attachmentRepository.findByContentId(contentId);
    return attachments.stream()
            .filter(a -> a.getExtractedText() != null && !a.getExtractedText().isBlank())
            .map(ContentAttachment::getExtractedText)
            .collect(Collectors.joining("\n"));
}

效果:附件内容也参与全文检索与高亮。这在家目录工具场景下等于白送了一个「文件内容搜索」。

索引文档结构与幂等 upsert

文档就 10 个字段,全部是聚合上的投影:

字段来源说明
contentId聚合 ID同时作为 ES _id,保证幂等
ownerId聚合个人口径过滤用
type枚举名ARTICLE/NOTE/…,term 过滤
title / summary / body聚合检索主字段
attachmentText附件抽取文本聚合附件可检索
status / visibility枚举名 /PUBLISHED 恒定过滤、PUBLIC 口径过滤
updatedAtepoch millis二级排序
// infrastructure/.../ContentIndexPortImpl.java —— _id=contentId 幂等 upsert
@Override
public void index(ContentIndexEntry entry) {
    try {
        IndexRequest<ContentIndexEntry> request = IndexRequest.of(
                builder -> builder.index(index).id(entry.getContentId()).document(entry));
        elasticsearchClient.index(request);
    } catch (Exception e) {
        throw new RuntimeException("内容索引写入失败 contentId=" + entry.getContentId(), e);
    }
}

新建、更新、reindex 重灌走同一个 API、同一个 _id——不存在"先删再插"的中间态。ES 侧没建过显式 mapping,靠 dynamic mapping 自动给字符串字段生成 .keyword 子字段,term 过滤直接用 status.keyword 这类路径(见检索代码),省了一个 index bootstrap 组件 + mapping schema 文件。

ES 客户端装配也很薄:drizzlepal.content.search.{host,port,index} 三个配置(默认 127.0.0.1:19200 / drizzlepal-content),RestClientTransport + JacksonJsonpMapper 包出 ElasticsearchClient。

04 读链路:三种口径一颗 DSL

检索链路

入口是 GET /api/contents?search=true&keyword=…&scope=...,服务层先做口径判定:

// ContentServiceImpl.java —— 检索分发
// 匿名:必须先过匿名公开读授权
if (AuthConstants.ANONYMOUS_USER_ID.equals(ownerId)) {
    ensureAnonymousReadGranted(ownerId);
    ...
    return searchFromIndex(page, size, keyword, type, ownerId, true);  // publicOnly
}
// 登录 + scope=public:全站公开口径,忽略归属
// publicSearch: owner = null, publicOnly = true
return enrichWithAuthor(searchFromIndex(page, size, keyword, type, null, true));
// 登录个人:ownerId 过滤
return searchFromIndex(page, size, keyword, type, ownerId);

三种口径最终都汇到同一个端口方法,DSL 的骨架是 bool.must(multi_match) + bool.filter(…):

// ContentIndexPortImpl.search —— 权重与过滤
bool.must(must -> must.multiMatch(match -> match
        .fields("title^3", "summary^2", "body^1", "attachmentText^1")
        .query(keyword)));
if (criteria.isPublicOnly()) {
    // 匿名公开检索:仅 PUBLISHED + PUBLIC,不做归属过滤
    bool.filter(f -> f.term(t -> t.field("visibility.keyword").value("PUBLIC")));
} else if (criteria.getOwnerId() != null && !criteria.getOwnerId().isBlank()) {
    bool.filter(f -> f.term(t -> t.field("ownerId.keyword").value(criteria.getOwnerId())));
}
...
bool.filter(f -> f.term(t -> t.field("status.keyword").value("PUBLISHED")));  // 恒定

三个值得说的细节:

  1. title^3 / summary^2 / body^1 的字段权重:标题命中排最前,正文提及往后靠,首页信息流的命中率体感明显高于 LIKE 版本;
  2. status.keyword = PUBLISHED 是无条件 filter:不管什么口径,已发布是检索的硬前提,语义上和列表接口一致;
  3. 匿名与全站口径复用同一个 filter(visibility=PUBLIC、忽略 ownerId),首页信息流和登录用户逛博客看到的是同一个结果集,不会出现"登录后首页反而变少了"的口径漂移。

05 结果装配与前端高亮

// ContentServiceImpl.searchFromIndex —— 命中序保序的回库装配
ContentSearchOutcome outcome = contentIndexPort.search(criteria);
Map<String, Content> contentMap = contentQuery.findByIds(ids)  // 只按 id 回库
        .stream().collect(Collectors.toMap(c -> c.getId().getValue(), Function.identity()));
List<Content> ordered = outcome.getContentIds().stream()
        .map(contentMap::get).filter(Objects::nonNull).toList();  // 保 ES 命中序
List<ContentResult> records = ordered.stream()
        .map(c -> toResult(c, tagIndex, List.of(),
                toHighlightResult(outcome.getHighlights().get(c.getId().getValue()))))
        .toList();

ES 返回 ids + total + highlight 标记串,详细数据回 PG 取——索引里只放投影,正文详情、标签关联仍以库为准。命中序就是 ES 的打分序,用 contentMap.get 保序不吃排序的亏。

高亮协议:ES 端 pre_tags <em> / post_tags </em>,title、summary 全字段(numberOfFragments(0)),body、attachmentText 取 120 字符单片段。前端拿到的是带 <em> 的字符串,不 dangerouslySetInnerHTML,按标记拆段交给 React:

// frontend/apps/web/app/components/home-article-feed.tsx
function HitText({ text }: { text: string }) {
  return parseHighlight(text).map((segment, index) =>
    segment.em ? (
      <em key={index} className="rounded-sm bg-primary/10 px-0.5 not-italic text-primary">
        {segment.text}
      </em>
    ) : (
      <Fragment key={index}>{segment.text}</Fragment>
    ),
  );
}

其余 HTML 一律被 React 转义成字面文本,XSS 面为零。

1.5.26 版本顺带把作者展示名掺进了装配链路:enrichWithAuthor 走 AuthContentAuthorPort 一次性批量取用户简报,列表页卡片上直接渲染作者徽章——这也是首页信息流从"个人文档工具"长出"社区阅读"形态的最后一块拼图。

06 降级、补偿与运维

读降级是三层兜底,同一口径语义:

try {
    return searchFromIndex(page, size, keyword, type, null, true);
} catch (RuntimeException e) {
    domainLogger.warn("公开全文检索失败,降级为公开分页 keyword={} error={}", ...);
    return publicPage(page, size, keyword, type, tagIds);   // DB LIKE,同口径
}
  • 匿名搜不到 → findPublicPage(DB LIKE,仍限 PUBLISHED+PUBLIC);
  • 登录个人搜不到 → findPage(DB LIKE,带 ownerId 口径);
  • 全站公开搜不到 → publicPage。

有标签过滤时直接回退 DB 连表(标签语义 ES 里没有,不值得为它做宽表)。

写补偿是全量重建端点:POST /api/contents/reindex(超管权限)。ES 数据丢了、mapping 想换了、或者怀疑漂移了,一条命令从 PG 重新灌一遍——因为 _id = contentId 幂等,重复跑没有副作用。这就是「双写不双源」架构的红利:ES 永远可以被丢弃重建,而不承担任何数据迁移成本。

07 复现命令

登录后直接打接口即可验证:

TOKEN=$(curl -s -X POST http://127.0.0.1:9005/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"identifier":"admin","password":"***"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["accessToken"])')

# 全站公开口径检索(可匿名,这里带 token)
curl -s "http://127.0.0.1:9005/api/contents?page=1&size=5&search=true&keyword=elasticsearch&scope=public" \
  -H "Authorization: Bearer $TOKEN"
# 返回 records[i].highlight 中带 <em> 标记,authorName 已填充

# 个人口径检索(不带 scope)
curl -s "http://127.0.0.1:9005/api/contents?page=1&size=5&search=true&keyword=快照" \
  -H "Authorization: Bearer $TOKEN"

# 全量重建索引(超管)
curl -s -X POST "http://127.0.0.1:9005/api/contents/reindex" -H "Authorization: Bearer $TOKEN"

08 复盘

回头看,这套实现里最经得起时间考验的三个决策:端口收口(ES 可替换)、_id=contentId 幂等(补偿零成本)、口径常量收在 DSL filter 里(口径漂移无处遁形)。

还没做完的接下来排:

  1. 分词器:目前 dynamic mapping 走 ES 默认 standard 分析器,中文是单字切分,长词召回偏宽——下一步显式 mapping + ik_max_word(检索时 ik_smart);
  2. 索引写异步化:附件多的大文档同步灌索引会拉长写事务尾延迟,计划改成本地事件/批量队列,补偿端点顺势升级成对账工具;
  3. 搜索联想:completion suggester 做搜索框联想,顺手把高频零命中词拦在入口,减少无效请求。