内容全文检索:从入库到检索的全链路实现
双写不双源的 ES 全文检索:写入五路径同步灌索引、读路径三口径一颗 DSL、ES 挂了降级 DB、_id=contentId 幂等重建,附 3 张架构图与 7 段源码。
TL;DR:内容中心的全文检索是一套「双写不双源」的实现——PostgreSQL 始终是唯一真相源,Elasticsearch 只是从聚合投影出来的可重建索引;所有写路径在事务完成后同步灌一次索引(
_id = contentId幂等 upsert),索引失败只 warn 不阻断;读路径按「匿名 / 登录个人 / 全站公开」三种口径拼 bool 查询,ES 挂了自动降级回数据库分页。全文约 4000 字、3 张图、7 段源码,读完可以对照本仓库完整复现。
01 为什么要做这套东西
被 LIKE 逼出来的。内容中心的第一版检索就是 title LIKE '%kw%' OR body LIKE '%kw%',数据量小的时候能用,但三个硬伤很早就露出来了:
- 不分词——搜「全文检索」匹配不到「检索全景」,反之亦然;
- 没有相关度——标题命中和正文第 900 字命中同分,无法排序;
- 扫全表——
%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 口径过滤 |
updatedAt | epoch 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"))); // 恒定三个值得说的细节:
title^3 / summary^2 / body^1的字段权重:标题命中排最前,正文提及往后靠,首页信息流的命中率体感明显高于 LIKE 版本;status.keyword = PUBLISHED是无条件 filter:不管什么口径,已发布是检索的硬前提,语义上和列表接口一致;- 匿名与全站口径复用同一个 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 里(口径漂移无处遁形)。
还没做完的接下来排:
- 分词器:目前 dynamic mapping 走 ES 默认 standard 分析器,中文是单字切分,长词召回偏宽——下一步显式 mapping +
ik_max_word(检索时ik_smart); - 索引写异步化:附件多的大文档同步灌索引会拉长写事务尾延迟,计划改成本地事件/批量队列,补偿端点顺势升级成对账工具;
- 搜索联想:
completionsuggester 做搜索框联想,顺手把高频零命中词拦在入口,减少无效请求。