Elasticsearch 从原理到 IPTV 媒资搜索实战:基于 ftmao-meta 项目的完整复盘
- 约 20 分钟读完 - 全文 7669 字Elasticsearch 从原理到 IPTV 媒资搜索实战:基于 ftmao-meta 项目的完整复盘
以我们线上运行的 IPTV 媒资元数据服务 ftmao-meta 为样本,讲清楚一个真实的 ES(实际为 AWS 托管 OpenSearch)应用是怎么设计索引、同步数据、支撑搜索和下游订阅的。所有配置、代码、索引设计均来自项目本身,踩过的坑也一并奉上。
一、项目背景:这是一个什么系统
ftmao-meta 是一个面向多地区运营的 IPTV/OTT 视频内容平台的媒资元数据微服务,Spring Boot 3.3 + Java 21,单模块 Gradle 工程,包结构 gtt.ftmao.meta,分 api / app / bean / core 四层。它管理的数据包括:
- 影视元数据:电影、剧集、季、集、短视频的多语言标题、简介、海报、演职员、分级、票房;
- 演职人员:多语言姓名、热度值(pgcHot / ugcHot / imdbHot)、代表作;
- 物料:剧照海报(artwork)、预告片(trailer);
- C 端搜索:全文检索 + 输入自动补全。
这些数据量巨大(千万级文档)、多语言(英语/西语/葡语,服务拉美市场)、多租户(多家运营商公司共用),而且需要被下游多个服务增量订阅。MySQL 是事实源(Source of Truth),ES 承担所有读路径的检索。
技术选型上的关键事实:
- 后端是 AWS 托管 OpenSearch,客户端用官方
opensearch-java 2.8.1(org.opensearch.client.opensearch.OpenSearchClient),没有用 spring-data-elasticsearch,也没有 RestHighLevelClient; - 全文检索与自动补全能力封装在公司内部库
searchengine-api/core/sync里,业务代码只调用SearchApi/SearchSync门面; - 数据同步不走 MQ(Kafka 依赖在启动类里被显式排除),而是"MySQL 任务表 + 定时轮询 + 分布式锁"。
graph TB
subgraph 上游["上游数据来源"]
BOSS["运营后台 / 内容录入"]
PP["PP 开放平台<br/>(REST 拉取外部媒资)"]
end
subgraph META["ftmao-meta 服务"]
API["REST API 层"]
SVC["业务 Service<br/>MetaService / ArtworkService ..."]
JOB["定时 Job<br/>PpMetaSyncJob 每 15s"]
LOCK["分布式锁<br/>AsyncJobExecutorManager"]
end
subgraph STORE["存储层"]
MYSQL[("MySQL<br/>事实源 + 同步任务表")]
subgraph OS["AWS OpenSearch"]
DETAIL["meta_detail_index<br/>媒资详情(重索引)"]
PERSON["meta_person_index<br/>人物详情"]
ART["meta_artwork_index<br/>剧照物料"]
TRAILER["meta_trailer_index<br/>预告片"]
SEARCH["search_engine_index<br/>C端搜索(轻索引)"]
end
end
subgraph DOWN["下游消费方"]
PORTAL["门户/推荐服务"]
SUB["增量订阅方<br/>(时间水位拉取变更)"]
end
BOSS --> SVC
PP --> JOB
SVC -->|"事务内:先写库,再直写 ES"| MYSQL
SVC --> DETAIL
SVC --> SEARCH
JOB --> LOCK --> MYSQL
JOB -->|"批量灌入"| SEARCH
API -->|"全文搜索/补全"| SEARCH
API -->|"mget 回查详情"| DETAIL
API --> PERSON & ART & TRAILER
DETAIL -->|"lastModifiedIndicator 水位"| SUB
SEARCH --> PORTAL
二、先补原理:理解后面设计决策的三个支点
在进入项目细节前,用三句话回顾《Elasticsearch in Action》里最重要的原理,后面每个设计决策都会用到它们。
2.1 倒排索引与分词
ES 搜索快的根本原因是倒排索引:term → 文档列表。而文本进入倒排索引前要经过分析链:
graph LR
A["原始文本<br/>O Auto da Compadecida"] --> B["Character Filter<br/>变音符号映射 á→a"]
B --> C["Tokenizer<br/>standard 分词"]
C --> D["Token Filter<br/>lowercase + stopword + stemmer"]
D --> E["词项 Term<br/>auto / compadecida"]
一个字段建索引时用哪个 analyzer、搜索时用哪个 analyzer,直接决定了"搜不搜得到"。这就是为什么 mapping 必须在灌数据之前设计好——改 analyzer 意味着重建索引。
2.2 近实时(NRT)与 refresh
文档写入后先进内存缓冲区,默认 1 秒后 refresh 生成 segment 才可被搜索。批量灌数据时调大 refresh_interval、或显式控制 refresh 时机,是写入性能优化的第一手段——后面会看到项目里批量写入怎么用它。
2.3 Query 与 Filter 的区别
- Query:算相关性得分
_score,用于全文匹配排序; - Filter:只判断 yes/no,不算分、结果可被缓存,用于精确过滤。
项目里几乎所有查询都是"filter 套 query"的 bool 结构:关键词进 must,租户、类型、软删除标记进 filter。
三、索引设计:一个搜索索引 + 四个详情索引
3.1 索引清单
项目中没有任何 @Document 注解或 mapping JSON 文件,索引 DDL 采用类似 Liquibase 的 changelog 形式管理(changelog/elasticsearch/*.yaml,HTTP method + path + body,由外部部署工具执行),别名字段统一管理:
| 代码中的别名 | 实体索引 | 分片 | 存什么 |
|---|---|---|---|
meta_detail_index |
ftmao-meta-detail-1 |
6 主 2 副 | 媒资详情全集 |
meta_person_index |
ftmao-meta-person-detail |
6 主 2 副 | 演职人员详情 |
meta_artwork_index |
ftmao-meta-media-artwork |
6 主 2 副 | 剧照物料 |
meta_trailer_index |
ftmao-meta-media-trailer |
6 主 2 副 | 预告片 |
search_engine_index |
ftmao-meta-search-engine |
1 分片 | C 端搜索/补全 |
别名与文档类的映射集中在一个枚举里(OpenSearchIndexEnum),业务代码只认别名——这是"换索引不改代码"的基本功,比如 ftmao-meta-detail-1 后面出现 -2,只需要 reindex 后切别名。
3.2 详情索引:重存储、轻检索
meta_detail_index 的 mapping 有个鲜明特征:几乎所有字段都是 keyword,标题、剧情简介都是 keyword + nested,不参与分词检索,大量 nested 对象(displayTitles / plots / posters / casts)配 dynamic: false。
为什么?因为详情索引的职责是**“按 ID/结构化条件精确取数”**,不是全文搜索。keyword 不做分词,聚合、terms 过滤、精确匹配都快;dynamic: false 防止上游数据乱塞字段把 mapping 撑爆(mapping explosion 是生产事故常客)。
3.3 搜索索引:整个项目最精彩的部分
search_engine_index 只有 1 个分片(轻量、追求查询 RT),但它的 analysis 配置是整个项目技术含量最高的地方。以主搜索字段 mainTitle 为例:
# 摘自 changelog(简化)
mainTitle:
type: text
store: true
term_vector: with_positions_offsets # 为 fast-vector-highlighter 高亮做准备
analyzer: en_es_pt # 英/西/葡三语混合分词器
fields:
cut_word: { analyzer: en_es_pt } # 正常分词匹配
all_letters: { analyzer: N_gram(4-15) } # 长 ngram:前缀/模糊匹配
word_letters:{ analyzer: N_gram(2-10) } # 短 ngram:更激进的模糊匹配
origin: { type: keyword, normalizer: lowercase } # 精确匹配/排序配套自定义组件:
- 多语分析器
en_es_pt:standard tokenizer + 英语/西语/葡语三套 stopword + stemmer; - 变音符号 char_filter:把
áéíóúàèç等西葡变音符号映射为 ASCII——拉美用户搜compadecida也能命中Compadecida,对多语言市场这是体验生死线; - ngram tokenizer 一批(1–10、4–15 等不同窗口),
max_ngram_diff=14; - completion suggester:
suggest字段配suggester_trigram分析器,带 3 个 category context(markCate/tenantCate/comboCate)——自动补全可以按内容类型和租户隔离,A 运营商的用户不会看到 B 运营商专有的片名提示。
设计启示:一个"能搜"的字段,实际需要四层子字段分工——正常分词管召回、长 ngram 管前缀联想、短 ngram 管纠错、keyword 管精确和排序。这也正是《Elasticsearch in Action》里"多字段映射"思想的生产级演绎。
3.4 双索引架构:为什么搜索和详情要分开
graph LR
subgraph 写入["写入侧"]
D["MetaDetail 完整文档<br/>(几十 KB,含 nested 海报/演员/多语言)"] --> DETAIL["详情索引<br/>6分片 重存储"]
D -->|"裁剪:docId + mainTitle +<br/>suggest + 过滤字段 + 租户"| SEARCH["搜索索引<br/>1分片 轻量"]
end
subgraph 查询["查询侧"]
U["用户搜索 'auto'"] -->|"① matchSearch 命中<br/>返回 docId 列表"| SEARCH
SEARCH -->|"② 按 docId mget"| DETAIL
DETAIL -->|"组装完整详情返回"| U
end
搜索索引里不存详情,只有:docId、mainTitle(及 ngram 子字段)、suggest、40+ 个 keyword 过滤字段(ext-field-to-index-map 里配置,如媒资类型、地区、年份、分级)、租户字段。好处有三个:
- 搜索索引小,1 个分片 + 内存友好,C 端 QPS 扛得住;
- 详情变更不用动搜索索引——海报换了只 update 详情索引;搜索索引只在标题/过滤字段变化时更新;
- 两类索引可以独立扩缩容、独立重建。
代价也很明显:多一次 mget 回查、数据一致性要维护两份。这个项目用下一节的同步机制来兜底。
四、数据同步:MySQL → ES 的三条路径
ES 是"读模型",MySQL 才是事实源。项目没用 Canal/MQ,而是自己实现了一套事务内直写 + 任务表轮询的混合方案:
flowchart TD
A["业务写请求<br/>MetaService.save()"] --> B{"@Transactional"}
B --> C["① 写 MySQL 业务表"]
B --> D["② 写 MySQL 同步任务表<br/>MixedMetaSyncTask"]
B --> E["③ 同事务内直写详情索引<br/>bulkOperationByBatch(refresh=True)"]
B --> F["④ saveMetaToSearchEngine<br/>同步搜索索引"]
E --> G{"ES 写失败?"}
G -->|"是,事务回滚或留下任务"| H["任务表残留记录"]
D --> I["PpMetaSyncJob<br/>@Scheduled 每 15s"]
H --> I
I --> J["分布式锁<br/>executeWithLock"]
J --> K["MetaTaskProcessService<br/>分批 Lists.partition(batchSize)"]
K -->|"searchSync.save(batch)"| L["搜索索引"]
M["失败重试 Job<br/>每小时"] --> J
三条路径各有分工:
- 同事务直写(主路径):保证"后台保存完,前台立刻能搜到"。写入时统一刷新
updateTime和lastModifiedIndicator(格式:时间戳_docId的版本游标,下一节详述); - 任务表 + 定时轮询(兜底路径):15 秒一轮,分布式锁防多实例并发,分批灌入;
- 失败重试 Job(保险):每小时扫描失败任务重放。
为什么不纯用 MQ? 这个系统的写入量级不大(媒资是低频高价值数据,不是日志),事务内直写的一致性收益远大于引入 MQ 的复杂度。这是"按场景选方案"而不是"按潮流选方案"的好例子。
4.1 批量写入的性能细节
工具类 OpenSearchUtil.bulkUpsertDocuments 里有两个值得抄的实践:
// 按配置的 bulkSize(默认 200)分批,避免单个 bulk 过大压垮集群
// 每批显式 refresh(WaitFor):写完后“等 refresh 完成再返回”,
// 保证同步接口的调用方“写完立即可见”,同时比 refresh=true(每文档刷新)省资源对照第 2.2 节的 NRT 原理:refresh=WaitFor 是吞吐和可见性之间的甜点——批量场景不要无脑 refresh=true。
五、查询实战:从业务代码反推 ES 用法
5.1 一切查询的起点:软删除过滤
项目里文档不物理删除,而是软删除——所有读路径都要叠一层 isDeleted=0:
// OpenSearchUtil.getUndeletedQuery:所有查询都要过这层包装
Query undeletedQuery = openSearchUtil.getUndeletedQuery(boolQueryBuilder.build()._toQuery());
// 底层就是 bool.filter(terms(isDeleted, [0])),可能再叠加 status=1(已发布)业务侧的典型用法(ArtworkService,按媒资 ID 查剧照):
Query metaIdQuery = Query.of(q -> q.terms(t -> t.field("metaId")
.terms(tv -> tv.value(Stream.of(request.getMetaId())
.map(FieldValue::of).toList()))));
boolQueryBuilder.filter(metaIdQuery);
Query undeletedQuery = openSearchUtil.getUndeletedQuery(boolQueryBuilder.build()._toQuery());
SortOptions sortOption = SortOptions.of(s -> s.field(
f -> f.field("pos").order(SortOrder.Asc)));
List<MediaArtwork> artworks = openSearchUtil.searchDoc(
OpenSearchIndexEnum.MEDIA_ARTWORK.getIndexName(),
undeletedQuery, MediaArtwork.class,
request.getPageId(), request.getPageSize(),
Collections.singletonList(sortOption), totalCount);这正是第 2.3 节"filter 不算分、可缓存"原理的落地:结构化条件全部进 filter。
5.2 软删除的实现:updateByQuery + Painless 脚本
删除不是 delete,而是 updateByQuery 打标记:
String script = " ctx._source.isDeleted = 1;"
+ "ctx._source.updateTime = params.currentTime;"
+ "ctx._source.lastModifiedIndicator = params.indicatorPrefix + '_' + ctx._id";
UpdateByQueryRequest request = new UpdateByQueryRequest.Builder()
.index(indexName).query(query)
.script(s -> s.inline(i -> i.source(finalScript)
.params("currentTime", JsonData.of(currentTime))
.params("indicatorPrefix", JsonData.of(String.valueOf(modifyTime)))))
.build();注意脚本里同时更新了 lastModifiedIndicator——软删除也是一次"变更",必须让下游订阅方感知到(下游才能同步删)。这个细节是订阅体系能闭环的关键。
5.3 两阶段全文搜索
C 端搜索入口 SearchService.searchMedia 的分流逻辑:
if (StringUtils.isNotEmpty(fuzzyMatchingParams.getKeyword())) {
// 有关键词:内部库全文检索 → 返回 docId + mark(类型)
resp = searchApi.matchSearch(keyword, marks, pageId, pageSize, tenantIds, params);
} else {
// 无关键词:纯结构化筛选(找片页、分类浏览)
resp = searchApi.find(marks, pageId, pageSize, tenantIds, params);
}
// 按 mark 分流 person / meta,回查详情索引取完整数据
openSearchUtil.batchGetDocuments(PERSON_DETAIL.getIndexName(), personDocIds, PersonDetail.class);
openSearchUtil.batchGetDocuments(META_DETAIL.getIndexName(), metaDocIds, MetaDetail.class);查询条件从哪来?API 层收的是 SQL 风格的 where 模板(MetaQuery.whereTemplate + parameters),WhereClauseUtil 用正则把 in (:x)、between and、JSON_CONTAINS_ALL/ANY 解析成 ES 的 Condition(IN / GREATER_THAN 等),字段名经 FieldNameMappingEnum 做外部名→索引字段名映射。
这个设计的好处:对前端/上游服务来说,查询语法是稳定的数据库风格,不暴露 ES DSL;ES mapping 改名、换引擎,外部无感。
5.4 自动补全:completion suggester + 租户隔离
// SearchService.suggest
List<Prompt> prompts = searchApi.getPromptList(keyword, marks, true, companyId, size, uuid);
// 底层 = 搜索索引 suggest 字段的 completion suggester,
// context 里带 tenantCate=companyId → 补全结果按运营商隔离
// 拿到 docId 后再回查详情索引组装标题和海报举例如"用户输入 int":
sequenceDiagram
participant U as 用户(运营商A)
participant S as SearchService
participant SE as 搜索索引 suggester
participant D as 详情索引
U->>S: suggest("int", companyId=A)
S->>SE: completion suggest<br/>context: tenantCate=A
SE-->>S: ["Interestelar", "Intocáveis"]<br/>+ docId 列表
S->>D: mget(docIds)
D-->>S: 标题 + 海报 URL
S-->>U: 补全列表(带封面)
5.5 一个常被忽略的性能点:只取 ID 就别加载 _source
// fastGetDocumentById:判断文档存在/取 ID 时
// storedFields("_id") + size(1) + trackTotalHits(false)
// 注释原话:类似 MySQL 的 LIMIT 1trackTotalHits(false):不精确统计总数(默认只算到 10000),省掉一次全量计数;storedFields("_id"):不加载_source,网络传输量从几十 KB 降到几十 B。
列表页"先查 ID 再回查"的场景,这两招能把响应时间砍掉一个量级。
六、增量订阅:ES 作为"变更数据源"
下游服务(推荐、缓存、其他中台)需要感知媒资变更。项目没有用 CDC 或 MQ 广播,而是让下游基于水位游标主动拉取 ES:
sequenceDiagram
participant SUB as 下游订阅方
participant Q as SubqueryService
participant ES as 详情索引
Note over SUB: 记录上次水位 W0<br/>(lastModifiedIndicator 字符串)
SUB->>Q: querySubQuery(from=W0, size=500)
Q->>ES: range(lastModifiedIndicator: gt W0, lt 当前时间-2s)<br/>+ 按 lastModifiedIndicator 升序
ES-->>Q: 本批变更文档(含软删除的)
Q-->>SUB: 文档 + 新水位 W1
SUB->>SUB: 落库/更新缓存,记录 W1
Note over Q,ES: 对账:subQueryCount 用 count + sort desc + size(1)<br/>取区间最大水位,生产方/消费方核对数量
几个工程细节值得细说:
- 水位是字符串而非时间戳:
lastModifiedIndicator = 时间戳 + "_" + docId。纯时间戳会在同一毫秒内有多个文档时漏数;拼上 docId 后字典序唯一,rangegt翻页不会漏、不会重。这是项目用手写游标替代search_after的原因——行为完全可控,且不依赖 ES 的 PIT(point in time)上下文。 lt 当前时间 - 2s的回调偏移:规避"刚写入还没 refresh 可见"的窗口(第 2.2 节 NRT 原理的直接应用),宁可晚 2 秒,不可漏数据。- 软删除文档也带着新水位出现,下游据此删除本地副本——和第 5.2 节的脚本闭环。
七、多租户设计:docId 里藏着租户
// OpenSearchUtil.getDocId:文档 ID = companyId + "#" + metaId
// 例:"1001#mv_8848123"、"public#mv_8848123"(公共库)- 同一部影片,A 运营商和 B 运营商是两份文档(各自的标题、定价、上架状态可能不同),天然物理隔离;
sourceType字段区分 PRIVATE(私有)/ PUBLIC(公共)/ MIXED(混合),公共内容用public#前缀,查询时按租户策略合并;- 所有查询 filter 里都带
companyId/tenant_id,补全用 context 隔离(第 5.4 节)。
经验:多租户搜索系统,把租户编进 docId 比单纯加过滤字段更稳——docId 冲突本身就是一道防串租户的保险。
八、回到原理:这个项目验证了哪些书里的论断
| 书中原理 | 项目里的体现 |
|---|---|
| Mapping 先行,改 analyzer 要重建索引 | 索引 DDL 用 changelog 版本化管理,上线流程化 |
| Filter 不算分、可缓存 | 租户/类型/软删除/状态全部进 filter,只有关键词算分 |
| 多字段映射解决"一个字段多种搜法" | mainTitle 四层子字段:分词/长ngram/短ngram/keyword |
| NRT 的 refresh 是性能旋钮 | 批量写入用 refresh=WaitFor;订阅端留 2s 偏移 |
| 深度翻页要避免 from+size 深分页 | 手写 lastModifiedIndicator 游标替代 search_after |
| 聚合昂贵,慎用 | 项目几乎零聚合;取最大值用 sort+size(1) 替代 max agg |
| 分片数创建时定死,规划要前置 | 详情索引 6 分片按数据增长预估;搜索索引刻意 1 分片 |
九、总结
如果把这套架构压缩成一张图:
graph LR
W["写路径<br/>MySQL 事务<br/>+ 任务表兜底"] --> ES
subgraph ES["OpenSearch"]
S["搜索索引<br/>小而快:ngram+suggester<br/>+租户 context"]
D["详情索引<br/>大而全:keyword+nested<br/>dynamic:false"]
end
ES --> R1["读路径 ①<br/>搜索/补全 → docId"]
R1 --> R2["读路径 ②<br/>mget 详情组装"]
ES --> R3["订阅路径<br/>水位游标增量拉取"]
三条核心经验:
- 索引按"查询模式"而不是"数据表"来设计:搜索索引和详情索引分开,一个为召回优化(ngram、suggester、多语分析器),一个为取数优化(keyword、nested、禁动态字段);
- 一致性靠机制不靠运气:事务内直写是主路径,任务表 + 定时 Job + 失败重试是兜底,水位游标让下游最终一致——三层防护各管一段;
- 所有"黑科技"都是基本原理的应用:refresh 偏移、filter 缓存、storedFields 取 ID、游标翻页……吃透《Elasticsearch in Action》那套原理,生产方案都是它的排列组合。
参考资料:Radu Gheorghe, Matthew Lee Hinman, Roy Russo. 《Elasticsearch in Action》. Manning Publications, 2015;ftmao-meta 项目源码(OpenSearchConfig / OpenSearchUtil / SearchService / SubqueryService / changelog/elasticsearch)。