<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>视频系统 on 方糖猫Blog</title>
    <link>https://ftmao.com/blog/tags/%E8%A7%86%E9%A2%91%E7%B3%BB%E7%BB%9F/</link>
    <description>AI 视频、AI Agent 与工程实践笔记</description>
    <generator>Hugo</generator>
    <language>zh-CN</language>
    <lastBuildDate>Fri, 23 Aug 2024 17:00:00 +0800</lastBuildDate><atom:link href="https://ftmao.com/blog/tags/%E8%A7%86%E9%A2%91%E7%B3%BB%E7%BB%9F/index.xml" rel="self" type="application/rss+xml" /><item>
      <title>视频系统 DDD 实战：巴西 IPTV 项目的领域拆分</title>
      <link>https://ftmao.com/blog/posts/iptv/</link>
      <pubDate>Fri, 23 Aug 2024 17:00:00 +0800</pubDate>
      <guid>https://ftmao.com/blog/posts/iptv/</guid>
      <description>&lt;h1 id=&#34;视频系统-ddd-实战巴西-iptv-项目的领域拆分&#34;&gt;视频系统 DDD 实战：巴西 IPTV 项目的领域拆分&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;2021 年到 2025 年，我负责面向巴西市场的 IPTV 视频点播系统。这是一个用户量过百万、业务持续演进的系统。在几次大的产品迭代中，我们引入了领域驱动设计（DDD）来控制系统复杂度。这篇文章会完整复盘当时的领域拆分过程。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&#34;一项目背景&#34;&gt;一、项目背景&lt;/h2&gt;
&lt;p&gt;系统核心功能：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;视频上传：支持 tus / FTP / 网盘 / m3u8 等多种来源&lt;/li&gt;
&lt;li&gt;视频转码：h264 / h265 多码率输出&lt;/li&gt;
&lt;li&gt;DRM 加密：永新 / 自研加密方案&lt;/li&gt;
&lt;li&gt;元数据管理：同步 IMDb / TMDB / PP 等外部数据源&lt;/li&gt;
&lt;li&gt;播放列表：专题、分类、推荐&lt;/li&gt;
&lt;li&gt;播放鉴权：用户权限、设备管理、防盗链&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;服务用户：&lt;strong&gt;200 万+&lt;/strong&gt;，峰值日活 &lt;strong&gt;40 万&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;二为什么需要-ddd&#34;&gt;二、为什么需要 DDD？&lt;/h2&gt;
&lt;h3 id=&#34;21-早期的问题&#34;&gt;2.1 早期的问题&lt;/h3&gt;
&lt;p&gt;系统最初是一个单体应用，模块按技术分层组织：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;controller/
service/
dao/
entity/&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;这种结构在业务简单时没问题，但随着功能增加，开始出现：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一个需求改动涉及多个「层」，改哪里不确定&lt;/li&gt;
&lt;li&gt;业务逻辑散落在 Service 中，相似功能重复实现&lt;/li&gt;
&lt;li&gt;转码、鉴权、元数据互相耦合，改一个模块影响多个模块&lt;/li&gt;
&lt;li&gt;新成员上手慢，需要全局理解才敢改代码&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;22-ddd-带来的改变&#34;&gt;2.2 DDD 带来的改变&lt;/h3&gt;
&lt;p&gt;DDD 不是银弹，但它帮我们做了两件事：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;按业务边界组织代码&lt;/strong&gt;，而不是按技术分层&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;显式定义领域概念&lt;/strong&gt;，让业务逻辑有明确的归属&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2 id=&#34;三领域划分&#34;&gt;三、领域划分&lt;/h2&gt;
&lt;h3 id=&#34;31-核心域识别&#34;&gt;3.1 核心域识别&lt;/h3&gt;
&lt;p&gt;我们先识别出系统的核心业务域：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TD
    content[&amp;#34;内容域 (Content)&amp;lt;br/&amp;gt;媒资管理、视频文件、转码任务、DRM 加密&amp;#34;]
    metadata[&amp;#34;元数据域 (Metadata)&amp;lt;br/&amp;gt;影片信息、演员、分类、标签、外部数据源同步&amp;#34;]
    playback[&amp;#34;播放域 (Playback)&amp;lt;br/&amp;gt;播放鉴权、播放地址生成、防盗链、码率适配&amp;#34;]
    user[&amp;#34;用户域 (User)&amp;lt;br/&amp;gt;用户账户、订阅、设备、播放历史&amp;#34;]
    recommendation[&amp;#34;推荐域 (Recommendation)&amp;lt;br/&amp;gt;播放列表、专题、个性化推荐&amp;#34;]
    billing[&amp;#34;计费域 (Billing)&amp;lt;br/&amp;gt;套餐、订单、支付、用量统计&amp;#34;]
    content --- metadata --- playback --- user --- recommendation --- billing&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;h3 id=&#34;32-域间关系&#34;&gt;3.2 域间关系&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart LR
    playback[&amp;#34;播放域&amp;#34;] -- 依赖 --&amp;gt; content[&amp;#34;内容域&amp;#34;]
    playback -- 依赖 --&amp;gt; user[&amp;#34;用户域&amp;#34;]
    recommendation[&amp;#34;推荐域&amp;#34;] -- 依赖 --&amp;gt; metadata[&amp;#34;元数据域&amp;#34;]
    recommendation -- &amp;#34;依赖（获取热门数据）&amp;#34; --&amp;gt; playback
    billing[&amp;#34;计费域&amp;#34;] -- 依赖 --&amp;gt; user
    metadata -- 依赖 --&amp;gt; external[&amp;#34;外部数据源&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;依赖方向尽量保持单向，避免循环依赖。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;四内容域详细设计&#34;&gt;四、内容域详细设计&lt;/h2&gt;
&lt;p&gt;内容域是最复杂的域之一，我们以它为例展开。&lt;/p&gt;
&lt;h3 id=&#34;41-聚合根设计&#34;&gt;4.1 聚合根设计&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TD
    root[&amp;#34;MediaAsset（媒资聚合根）&amp;#34;]
    file[&amp;#34;MediaFile（视频文件）&amp;#34;]
    raw[&amp;#34;原始文件&amp;#34;]
    transcoded[&amp;#34;转码后文件（多码率）&amp;#34;]
    drmfile[&amp;#34;DRM 加密文件&amp;#34;]
    job[&amp;#34;TranscodingJob（转码任务）&amp;#34;]
    license[&amp;#34;DrmLicense（DRM 许可证信息）&amp;#34;]
    root --&amp;gt; file
    file --&amp;gt; raw
    file --&amp;gt; transcoded
    file --&amp;gt; drmfile
    root --&amp;gt; job
    root --&amp;gt; license&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;code&gt;MediaAsset&lt;/code&gt; 是聚合根，外部系统只能通过它访问内部实体。&lt;/p&gt;
&lt;h3 id=&#34;42-关键领域事件&#34;&gt;4.2 关键领域事件&lt;/h3&gt;
&lt;p&gt;内容域会产生大量领域事件，其他域可以订阅：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;事件&lt;/th&gt;
					&lt;th&gt;含义&lt;/th&gt;
					&lt;th&gt;订阅方&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;MediaAssetUploaded&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;媒资上传完成&lt;/td&gt;
					&lt;td&gt;转码服务&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;TranscodingCompleted&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;转码完成&lt;/td&gt;
					&lt;td&gt;内容域自身、播放域&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;DrmEncryptionCompleted&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;DRM 加密完成&lt;/td&gt;
					&lt;td&gt;播放域&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;MetadataSynced&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;元数据同步完成&lt;/td&gt;
					&lt;td&gt;推荐域&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;MediaAssetPublished&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;媒资发布上线&lt;/td&gt;
					&lt;td&gt;推荐域、搜索服务&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&#34;43-状态流转&#34;&gt;4.3 状态流转&lt;/h3&gt;
&lt;p&gt;媒资从上传到发布的状态机：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TD
    UPLOADED --&amp;gt; PENDING_TRANSCODE --&amp;gt; TRANSCODING --&amp;gt; PENDING_DRM --&amp;gt; DRM_ENCRYPTED --&amp;gt; PENDING_METADATA --&amp;gt; METADATA_READY --&amp;gt; FINAL[&amp;#34;PUBLISHED / REJECTED&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;每个状态变更都通过领域事件触发下一步操作。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;五分层架构&#34;&gt;五、分层架构&lt;/h2&gt;
&lt;p&gt;每个领域内部采用 DDD 经典四层架构：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TD
    ui[&amp;#34;用户接口层&amp;lt;br/&amp;gt;REST Controller / DTO&amp;#34;]
    app[&amp;#34;应用层&amp;lt;br/&amp;gt;Application Service / 用例编排&amp;#34;]
    domain[&amp;#34;领域层&amp;lt;br/&amp;gt;Entity / Value Object / Domain Service / Repository Interface / Domain Event&amp;#34;]
    infra[&amp;#34;基础设施层&amp;lt;br/&amp;gt;Repository Impl / MQ Client / External API Client / DB / Cache&amp;#34;]
    ui --- app --- domain --- infra&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;h3 id=&#34;51-领域层示例&#34;&gt;5.1 领域层示例&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;MediaAsset&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;private&lt;/span&gt; MediaAssetId id;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;private&lt;/span&gt; String title;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;private&lt;/span&gt; MediaStatus status;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;private&lt;/span&gt; List&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt;MediaFile&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; files;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;private&lt;/span&gt; List&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt;TranscodingJob&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; transcodingJobs;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;void&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;upload&lt;/span&gt;(MediaFile file) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#66d9ef&#34;&gt;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;files&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;add&lt;/span&gt;(file);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#66d9ef&#34;&gt;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;status&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; MediaStatus.&lt;span style=&#34;color:#a6e22e&#34;&gt;UPLOADED&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        registerEvent(&lt;span style=&#34;color:#66d9ef&#34;&gt;new&lt;/span&gt; MediaAssetUploadedEvent(&lt;span style=&#34;color:#66d9ef&#34;&gt;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;id&lt;/span&gt;));
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;void&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;completeTranscoding&lt;/span&gt;(TranscodingJob job) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#66d9ef&#34;&gt;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;transcodingJobs&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;add&lt;/span&gt;(job);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#66d9ef&#34;&gt;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;status&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; MediaStatus.&lt;span style=&#34;color:#a6e22e&#34;&gt;TRANSCODING_COMPLETED&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        registerEvent(&lt;span style=&#34;color:#66d9ef&#34;&gt;new&lt;/span&gt; TranscodingCompletedEvent(&lt;span style=&#34;color:#66d9ef&#34;&gt;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;id&lt;/span&gt;, job.&lt;span style=&#34;color:#a6e22e&#34;&gt;getOutputFiles&lt;/span&gt;()));
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;h3 id=&#34;52-防腐层设计&#34;&gt;5.2 防腐层设计&lt;/h3&gt;
&lt;p&gt;与外部系统（IMDb / TMDB / PP）对接时，使用防腐层（Anti-Corruption Layer）：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;interface&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;MetadataSourceClient&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    ExternalMetadata &lt;span style=&#34;color:#a6e22e&#34;&gt;fetch&lt;/span&gt;(String externalId);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// IMDb 适配器&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;ImdbClientAdapter&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;implements&lt;/span&gt; MetadataSourceClient {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;private&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;final&lt;/span&gt; ImdbApiClient imdbClient;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#a6e22e&#34;&gt;@Override&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; ExternalMetadata &lt;span style=&#34;color:#a6e22e&#34;&gt;fetch&lt;/span&gt;(String externalId) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        ImdbResponse response &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; imdbClient.&lt;span style=&#34;color:#a6e22e&#34;&gt;fetch&lt;/span&gt;(externalId);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#66d9ef&#34;&gt;return&lt;/span&gt; MetadataConverter.&lt;span style=&#34;color:#a6e22e&#34;&gt;fromImdb&lt;/span&gt;(response);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;这样外部 API 的数据结构变化不会直接影响到我们内部的元数据模型。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;六微服务拆分过程&#34;&gt;六、微服务拆分过程&lt;/h2&gt;
&lt;h3 id=&#34;61-第一次拆分按技术边界&#34;&gt;6.1 第一次拆分：按技术边界&lt;/h3&gt;
&lt;p&gt;早期我们按技术栈拆：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;upload-service&lt;/li&gt;
&lt;li&gt;transcoding-service&lt;/li&gt;
&lt;li&gt;metadata-service&lt;/li&gt;
&lt;li&gt;playback-service&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;结果导致一个业务操作要跨多个服务，分布式事务复杂。&lt;/p&gt;
&lt;h3 id=&#34;62-第二次拆分按领域边界&#34;&gt;6.2 第二次拆分：按领域边界&lt;/h3&gt;
&lt;p&gt;调整后按 DDD 领域拆分：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;content-service（内容域）&lt;/li&gt;
&lt;li&gt;metadata-service（元数据域）&lt;/li&gt;
&lt;li&gt;playback-service（播放域）&lt;/li&gt;
&lt;li&gt;user-service（用户域）&lt;/li&gt;
&lt;li&gt;recommendation-service（推荐域）&lt;/li&gt;
&lt;li&gt;billing-service（计费域）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;每个服务有清晰的业务边界和聚合根。&lt;/p&gt;
&lt;h3 id=&#34;63-服务间通信&#34;&gt;6.3 服务间通信&lt;/h3&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;场景&lt;/th&gt;
					&lt;th&gt;通信方式&lt;/th&gt;
					&lt;th&gt;说明&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;领域事件传播&lt;/td&gt;
					&lt;td&gt;RocketMQ&lt;/td&gt;
					&lt;td&gt;异步、最终一致&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;同步查询&lt;/td&gt;
					&lt;td&gt;HTTP / gRPC&lt;/td&gt;
					&lt;td&gt;强一致查询&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;配置/字典数据&lt;/td&gt;
					&lt;td&gt;Redis 缓存&lt;/td&gt;
					&lt;td&gt;高频读取&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id=&#34;七领域事件实现&#34;&gt;七、领域事件实现&lt;/h2&gt;
&lt;h3 id=&#34;71-事件定义&#34;&gt;7.1 事件定义&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;TranscodingCompletedEvent&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;extends&lt;/span&gt; DomainEvent {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;private&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;final&lt;/span&gt; MediaAssetId mediaAssetId;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;private&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;final&lt;/span&gt; List&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt;FileUrl&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; outputFiles;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;private&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;final&lt;/span&gt; Instant completedAt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;h3 id=&#34;72-事件发布&#34;&gt;7.2 事件发布&lt;/h3&gt;
&lt;p&gt;领域层只负责注册事件，应用层统一发布：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;@Transactional&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;void&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;handleTranscodingCallback&lt;/span&gt;(TranscodingJobId jobId, List&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt;FileUrl&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; outputs) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    MediaAsset asset &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; mediaAssetRepository.&lt;span style=&#34;color:#a6e22e&#34;&gt;findByJobId&lt;/span&gt;(jobId);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    asset.&lt;span style=&#34;color:#a6e22e&#34;&gt;completeTranscoding&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;new&lt;/span&gt; TranscodingJob(jobId, outputs));
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    mediaAssetRepository.&lt;span style=&#34;color:#a6e22e&#34;&gt;save&lt;/span&gt;(asset);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    eventPublisher.&lt;span style=&#34;color:#a6e22e&#34;&gt;publish&lt;/span&gt;(asset.&lt;span style=&#34;color:#a6e22e&#34;&gt;getDomainEvents&lt;/span&gt;());
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    asset.&lt;span style=&#34;color:#a6e22e&#34;&gt;clearDomainEvents&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;h3 id=&#34;73-事件消费&#34;&gt;7.3 事件消费&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;@Component&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;PlaybackEventListener&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#a6e22e&#34;&gt;@RocketMQMessageListener&lt;/span&gt;(topic &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;content-events&amp;#34;&lt;/span&gt;, selectorExpression &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;TranscodingCompleted&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;Listener&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;implements&lt;/span&gt; RocketMQListener&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt;TranscodingCompletedEvent&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#a6e22e&#34;&gt;@Override&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;void&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;onMessage&lt;/span&gt;(TranscodingCompletedEvent event) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            playbackService.&lt;span style=&#34;color:#a6e22e&#34;&gt;preparePlayUrls&lt;/span&gt;(event.&lt;span style=&#34;color:#a6e22e&#34;&gt;getMediaAssetId&lt;/span&gt;(), event.&lt;span style=&#34;color:#a6e22e&#34;&gt;getOutputFiles&lt;/span&gt;());
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;hr&gt;
&lt;h2 id=&#34;八几次关键演进&#34;&gt;八、几次关键演进&lt;/h2&gt;
&lt;h3 id=&#34;81-从单体到领域化单体&#34;&gt;8.1 从单体到领域化单体&lt;/h3&gt;
&lt;p&gt;第一步不是拆微服务，而是先在单体内部按领域组织代码包：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;com.ftmao.iptv.content
com.ftmao.iptv.metadata
com.ftmao.iptv.playback&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;这样可以在不引入分布式复杂度的前提下，先验证领域划分是否合理。&lt;/p&gt;
&lt;h3 id=&#34;82-从同步调用到事件驱动&#34;&gt;8.2 从同步调用到事件驱动&lt;/h3&gt;
&lt;p&gt;转码完成后，原本播放域通过接口轮询内容域查询状态。后来改为内容域发布 &lt;code&gt;TranscodingCompleted&lt;/code&gt; 事件，播放域订阅。&lt;/p&gt;
&lt;p&gt;收益：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;内容域和播放域解耦&lt;/li&gt;
&lt;li&gt;新增订阅方不需要改内容域&lt;/li&gt;
&lt;li&gt;系统扩展性更好&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;83-引入-cqrs-处理播放查询&#34;&gt;8.3 引入 CQRS 处理播放查询&lt;/h3&gt;
&lt;p&gt;播放接口对性能要求很高，我们单独做了读模型：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;写模型：MediaAsset 聚合根&lt;/li&gt;
&lt;li&gt;读模型：Redis 中的播放信息缓存&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;当内容发布或更新时，通过事件同步更新读模型。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;九踩过的坑&#34;&gt;九、踩过的坑&lt;/h2&gt;
&lt;h3 id=&#34;91-领域划分过细&#34;&gt;9.1 领域划分过细&lt;/h3&gt;
&lt;p&gt;早期我们划分了 10 多个领域，导致服务数量爆炸，运维成本剧增。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;教训&lt;/strong&gt;：领域数量要匹配团队规模。小团队 4-6 个领域足够。&lt;/p&gt;
&lt;h3 id=&#34;92-领域事件丢失&#34;&gt;9.2 领域事件丢失&lt;/h3&gt;
&lt;p&gt;消息队列配置不当，出现过领域事件丢失，导致播放域看不到新发布的内容。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;教训&lt;/strong&gt;：关键领域事件必须持久化，支持重放和补偿。&lt;/p&gt;
&lt;h3 id=&#34;93-贫血领域模型&#34;&gt;9.3 贫血领域模型&lt;/h3&gt;
&lt;p&gt;很多「DDD」项目最后变成了 Transaction Script 套壳，实体只有 getter/setter。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;教训&lt;/strong&gt;：要把业务规则和状态变更放在领域对象里，而不是 Service 里。&lt;/p&gt;
&lt;h3 id=&#34;94-过度设计聚合根&#34;&gt;9.4 过度设计聚合根&lt;/h3&gt;
&lt;p&gt;一开始想把所有内容相关的东西都放到 &lt;code&gt;MediaAsset&lt;/code&gt; 聚合根里，结果聚合根过大，事务边界混乱。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;教训&lt;/strong&gt;：聚合根要小而聚焦，跨聚合用领域事件协作。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;十效果与反思&#34;&gt;十、效果与反思&lt;/h2&gt;
&lt;h3 id=&#34;101-量化效果&#34;&gt;10.1 量化效果&lt;/h3&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;指标&lt;/th&gt;
					&lt;th&gt;优化前&lt;/th&gt;
					&lt;th&gt;优化后&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;新需求平均改动文件数&lt;/td&gt;
					&lt;td&gt;40 个&lt;/td&gt;
					&lt;td&gt;20 个&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;线上因耦合导致的 Bug&lt;/td&gt;
					&lt;td&gt;10 次/月&lt;/td&gt;
					&lt;td&gt;2 次/月&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;新成员独立上手时间&lt;/td&gt;
					&lt;td&gt;3 周&lt;/td&gt;
					&lt;td&gt;1 周&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&#34;102-核心反思&#34;&gt;10.2 核心反思&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;DDD 不是先拆分微服务，而是先拆清楚业务边界&lt;/strong&gt;。微服务是结果，不是起点。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;领域事件是域间协作的核心&lt;/strong&gt;，但事件丢失和顺序问题必须重视。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不要追求完美的领域模型&lt;/strong&gt;，先有一个可用的模型，再迭代优化。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DDD 需要团队共识&lt;/strong&gt;，不是架构师一个人画几张图就行的。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2 id=&#34;十一总结&#34;&gt;十一、总结&lt;/h2&gt;
&lt;p&gt;巴西 IPTV 项目让我对 DDD 有了真实的体感。它不是教科书上的概念，而是解决复杂业务系统演化问题的工程方法。&lt;/p&gt;
&lt;p&gt;对于视频系统这类业务复杂、流程长、多团队协作的系统，DDD 能带来的最大价值是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;让每个业务概念都有清晰的归属&lt;/li&gt;
&lt;li&gt;让系统变更的影响范围可控&lt;/li&gt;
&lt;li&gt;让新成员能更快理解业务&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;如果你也在做 DDD 实践，欢迎在评论区交流领域划分和事件驱动的经验。&lt;/em&gt;&lt;/p&gt;
</description>
    </item>
    <item>
      <title>Elasticsearch 从原理到 IPTV 媒资搜索实战：基于 ftmao-meta 项目的完整复盘</title>
      <link>https://ftmao.com/blog/posts/elasticsearch-in-action-iptv/</link>
      <pubDate>Sat, 28 Aug 2021 14:00:00 +0800</pubDate>
      <guid>https://ftmao.com/blog/posts/elasticsearch-in-action-iptv/</guid>
      <description>&lt;h1 id=&#34;elasticsearch-从原理到-iptv-媒资搜索实战基于-ftmao-meta-项目的完整复盘&#34;&gt;Elasticsearch 从原理到 IPTV 媒资搜索实战：基于 ftmao-meta 项目的完整复盘&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;以我们线上运行的 IPTV 媒资元数据服务 &lt;strong&gt;ftmao-meta&lt;/strong&gt; 为样本，讲清楚一个真实的 ES（实际为 AWS 托管 OpenSearch）应用是怎么设计索引、同步数据、支撑搜索和下游订阅的。所有配置、代码、索引设计均来自项目本身，踩过的坑也一并奉上。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&#34;一项目背景这是一个什么系统&#34;&gt;一、项目背景：这是一个什么系统&lt;/h2&gt;
&lt;p&gt;ftmao-meta 是一个面向多地区运营的 IPTV/OTT 视频内容平台的&lt;strong&gt;媒资元数据微服务&lt;/strong&gt;，Spring Boot 3.3 + Java 21，单模块 Gradle 工程，包结构 &lt;code&gt;gtt.ftmao.meta&lt;/code&gt;，分 &lt;code&gt;api / app / bean / core&lt;/code&gt; 四层。它管理的数据包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;影视元数据&lt;/strong&gt;：电影、剧集、季、集、短视频的多语言标题、简介、海报、演职员、分级、票房；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;演职人员&lt;/strong&gt;：多语言姓名、热度值（pgcHot / ugcHot / imdbHot）、代表作；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;物料&lt;/strong&gt;：剧照海报（artwork）、预告片（trailer）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;C 端搜索&lt;/strong&gt;：全文检索 + 输入自动补全。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些数据量巨大（千万级文档）、多语言（英语/西语/葡语，服务拉美市场）、多租户（多家运营商公司共用），而且需要被下游多个服务&lt;strong&gt;增量订阅&lt;/strong&gt;。MySQL 是事实源（Source of Truth），ES 承担所有读路径的检索。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;技术选型上的关键事实&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;后端是 &lt;strong&gt;AWS 托管 OpenSearch&lt;/strong&gt;，客户端用官方 &lt;code&gt;opensearch-java 2.8.1&lt;/code&gt;（&lt;code&gt;org.opensearch.client.opensearch.OpenSearchClient&lt;/code&gt;），&lt;strong&gt;没有用 spring-data-elasticsearch，也没有 RestHighLevelClient&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;全文检索与自动补全能力封装在公司内部库 &lt;code&gt;searchengine-api/core/sync&lt;/code&gt; 里，业务代码只调用 &lt;code&gt;SearchApi&lt;/code&gt; / &lt;code&gt;SearchSync&lt;/code&gt; 门面；&lt;/li&gt;
&lt;li&gt;数据同步&lt;strong&gt;不走 MQ&lt;/strong&gt;（Kafka 依赖在启动类里被显式排除），而是&amp;quot;MySQL 任务表 + 定时轮询 + 分布式锁&amp;quot;。&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;graph TB
    subgraph 上游[&amp;#34;上游数据来源&amp;#34;]
        BOSS[&amp;#34;运营后台 / 内容录入&amp;#34;]
        PP[&amp;#34;PP 开放平台&amp;lt;br/&amp;gt;（REST 拉取外部媒资）&amp;#34;]
    end

    subgraph META[&amp;#34;ftmao-meta 服务&amp;#34;]
        API[&amp;#34;REST API 层&amp;#34;]
        SVC[&amp;#34;业务 Service&amp;lt;br/&amp;gt;MetaService / ArtworkService ...&amp;#34;]
        JOB[&amp;#34;定时 Job&amp;lt;br/&amp;gt;PpMetaSyncJob 每 15s&amp;#34;]
        LOCK[&amp;#34;分布式锁&amp;lt;br/&amp;gt;AsyncJobExecutorManager&amp;#34;]
    end

    subgraph STORE[&amp;#34;存储层&amp;#34;]
        MYSQL[(&amp;#34;MySQL&amp;lt;br/&amp;gt;事实源 + 同步任务表&amp;#34;)]
        subgraph OS[&amp;#34;AWS OpenSearch&amp;#34;]
            DETAIL[&amp;#34;meta_detail_index&amp;lt;br/&amp;gt;媒资详情（重索引）&amp;#34;]
            PERSON[&amp;#34;meta_person_index&amp;lt;br/&amp;gt;人物详情&amp;#34;]
            ART[&amp;#34;meta_artwork_index&amp;lt;br/&amp;gt;剧照物料&amp;#34;]
            TRAILER[&amp;#34;meta_trailer_index&amp;lt;br/&amp;gt;预告片&amp;#34;]
            SEARCH[&amp;#34;search_engine_index&amp;lt;br/&amp;gt;C端搜索（轻索引）&amp;#34;]
        end
    end

    subgraph DOWN[&amp;#34;下游消费方&amp;#34;]
        PORTAL[&amp;#34;门户/推荐服务&amp;#34;]
        SUB[&amp;#34;增量订阅方&amp;lt;br/&amp;gt;（时间水位拉取变更）&amp;#34;]
    end

    BOSS --&amp;gt; SVC
    PP --&amp;gt; JOB
    SVC --&amp;gt;|&amp;#34;事务内：先写库，再直写 ES&amp;#34;| MYSQL
    SVC --&amp;gt; DETAIL
    SVC --&amp;gt; SEARCH
    JOB --&amp;gt; LOCK --&amp;gt; MYSQL
    JOB --&amp;gt;|&amp;#34;批量灌入&amp;#34;| SEARCH
    API --&amp;gt;|&amp;#34;全文搜索/补全&amp;#34;| SEARCH
    API --&amp;gt;|&amp;#34;mget 回查详情&amp;#34;| DETAIL
    API --&amp;gt; PERSON &amp;amp; ART &amp;amp; TRAILER
    DETAIL --&amp;gt;|&amp;#34;lastModifiedIndicator 水位&amp;#34;| SUB
    SEARCH --&amp;gt; PORTAL&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;hr&gt;
&lt;h2 id=&#34;二先补原理理解后面设计决策的三个支点&#34;&gt;二、先补原理：理解后面设计决策的三个支点&lt;/h2&gt;
&lt;p&gt;在进入项目细节前，用三句话回顾《Elasticsearch in Action》里最重要的原理，后面每个设计决策都会用到它们。&lt;/p&gt;
&lt;h3 id=&#34;21-倒排索引与分词&#34;&gt;2.1 倒排索引与分词&lt;/h3&gt;
&lt;p&gt;ES 搜索快的根本原因是&lt;strong&gt;倒排索引&lt;/strong&gt;：term → 文档列表。而文本进入倒排索引前要经过分析链：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;graph LR
    A[&amp;#34;原始文本&amp;lt;br/&amp;gt;O Auto da Compadecida&amp;#34;] --&amp;gt; B[&amp;#34;Character Filter&amp;lt;br/&amp;gt;变音符号映射 á→a&amp;#34;]
    B --&amp;gt; C[&amp;#34;Tokenizer&amp;lt;br/&amp;gt;standard 分词&amp;#34;]
    C --&amp;gt; D[&amp;#34;Token Filter&amp;lt;br/&amp;gt;lowercase + stopword + stemmer&amp;#34;]
    D --&amp;gt; E[&amp;#34;词项 Term&amp;lt;br/&amp;gt;auto / compadecida&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;一个字段建索引时用哪个 analyzer、搜索时用哪个 analyzer，直接决定了&amp;quot;搜不搜得到&amp;quot;。&lt;strong&gt;这就是为什么 mapping 必须在灌数据之前设计好&lt;/strong&gt;——改 analyzer 意味着重建索引。&lt;/p&gt;
&lt;h3 id=&#34;22-近实时nrt与-refresh&#34;&gt;2.2 近实时（NRT）与 refresh&lt;/h3&gt;
&lt;p&gt;文档写入后先进内存缓冲区，默认 1 秒后 refresh 生成 segment 才可被搜索。批量灌数据时调大 &lt;code&gt;refresh_interval&lt;/code&gt;、或显式控制 &lt;code&gt;refresh&lt;/code&gt; 时机，是写入性能优化的第一手段——后面会看到项目里批量写入怎么用它。&lt;/p&gt;
&lt;h3 id=&#34;23-query-与-filter-的区别&#34;&gt;2.3 Query 与 Filter 的区别&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Query&lt;/strong&gt;：算相关性得分 &lt;code&gt;_score&lt;/code&gt;，用于全文匹配排序；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Filter&lt;/strong&gt;：只判断 yes/no，&lt;strong&gt;不算分、结果可被缓存&lt;/strong&gt;，用于精确过滤。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;项目里几乎所有查询都是&amp;quot;filter 套 query&amp;quot;的 bool 结构：关键词进 &lt;code&gt;must&lt;/code&gt;，租户、类型、软删除标记进 &lt;code&gt;filter&lt;/code&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;三索引设计一个搜索索引--四个详情索引&#34;&gt;三、索引设计：一个搜索索引 + 四个详情索引&lt;/h2&gt;
&lt;h3 id=&#34;31-索引清单&#34;&gt;3.1 索引清单&lt;/h3&gt;
&lt;p&gt;项目中&lt;strong&gt;没有任何 &lt;code&gt;@Document&lt;/code&gt; 注解或 mapping JSON 文件&lt;/strong&gt;，索引 DDL 采用类似 Liquibase 的 changelog 形式管理（&lt;code&gt;changelog/elasticsearch/*.yaml&lt;/code&gt;，HTTP method + path + body，由外部部署工具执行），别名字段统一管理：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;代码中的别名&lt;/th&gt;
					&lt;th&gt;实体索引&lt;/th&gt;
					&lt;th&gt;分片&lt;/th&gt;
					&lt;th&gt;存什么&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;meta_detail_index&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;ftmao-meta-detail-1&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;6 主 2 副&lt;/td&gt;
					&lt;td&gt;媒资详情全集&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;meta_person_index&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;ftmao-meta-person-detail&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;6 主 2 副&lt;/td&gt;
					&lt;td&gt;演职人员详情&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;meta_artwork_index&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;ftmao-meta-media-artwork&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;6 主 2 副&lt;/td&gt;
					&lt;td&gt;剧照物料&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;meta_trailer_index&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;ftmao-meta-media-trailer&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;6 主 2 副&lt;/td&gt;
					&lt;td&gt;预告片&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;search_engine_index&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;ftmao-meta-search-engine&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;1 分片&lt;/strong&gt;&lt;/td&gt;
					&lt;td&gt;C 端搜索/补全&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;别名与文档类的映射集中在一个枚举里（&lt;code&gt;OpenSearchIndexEnum&lt;/code&gt;），业务代码只认别名——这是&amp;quot;换索引不改代码&amp;quot;的基本功，比如 &lt;code&gt;ftmao-meta-detail-1&lt;/code&gt; 后面出现 &lt;code&gt;-2&lt;/code&gt;，只需要 reindex 后切别名。&lt;/p&gt;
&lt;h3 id=&#34;32-详情索引重存储轻检索&#34;&gt;3.2 详情索引：重存储、轻检索&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;meta_detail_index&lt;/code&gt; 的 mapping 有个鲜明特征：&lt;strong&gt;几乎所有字段都是 keyword，标题、剧情简介都是 keyword + nested，不参与分词检索&lt;/strong&gt;，大量 nested 对象（displayTitles / plots / posters / casts）配 &lt;code&gt;dynamic: false&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;为什么？因为详情索引的职责是**&amp;ldquo;按 ID/结构化条件精确取数&amp;rdquo;**，不是全文搜索。keyword 不做分词，聚合、terms 过滤、精确匹配都快；&lt;code&gt;dynamic: false&lt;/code&gt; 防止上游数据乱塞字段把 mapping 撑爆（mapping explosion 是生产事故常客）。&lt;/p&gt;
&lt;h3 id=&#34;33-搜索索引整个项目最精彩的部分&#34;&gt;3.3 搜索索引：整个项目最精彩的部分&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;search_engine_index&lt;/code&gt; 只有 1 个分片（轻量、追求查询 RT），但它的 analysis 配置是整个项目技术含量最高的地方。以主搜索字段 &lt;code&gt;mainTitle&lt;/code&gt; 为例：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-yaml&#34; data-lang=&#34;yaml&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;# 摘自 changelog（简化）&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;mainTitle&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;type&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;text&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;store&lt;/span&gt;: &lt;span style=&#34;color:#66d9ef&#34;&gt;true&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;term_vector&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;with_positions_offsets  &lt;/span&gt; &lt;span style=&#34;color:#75715e&#34;&gt;# 为 fast-vector-highlighter 高亮做准备&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;analyzer&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;en_es_pt                    &lt;/span&gt; &lt;span style=&#34;color:#75715e&#34;&gt;# 英/西/葡三语混合分词器&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;fields&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;cut_word&lt;/span&gt;:    { &lt;span style=&#34;color:#f92672&#34;&gt;analyzer&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;en_es_pt }             &lt;/span&gt; &lt;span style=&#34;color:#75715e&#34;&gt;# 正常分词匹配&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;all_letters&lt;/span&gt;: { &lt;span style=&#34;color:#f92672&#34;&gt;analyzer&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;N_gram(4-15) }         &lt;/span&gt; &lt;span style=&#34;color:#75715e&#34;&gt;# 长 ngram：前缀/模糊匹配&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#ae81ff&#34;&gt;word_letters:{ analyzer: N_gram(2-10) }         &lt;/span&gt; &lt;span style=&#34;color:#75715e&#34;&gt;# 短 ngram：更激进的模糊匹配&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;origin&lt;/span&gt;:      { &lt;span style=&#34;color:#f92672&#34;&gt;type: keyword, normalizer&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;lowercase } &lt;/span&gt; &lt;span style=&#34;color:#75715e&#34;&gt;# 精确匹配/排序&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;配套自定义组件：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;多语分析器 &lt;code&gt;en_es_pt&lt;/code&gt;&lt;/strong&gt;：standard tokenizer + 英语/西语/葡语三套 stopword + stemmer；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;变音符号 char_filter&lt;/strong&gt;：把 &lt;code&gt;áéíóúàèç&lt;/code&gt; 等西葡变音符号映射为 ASCII——拉美用户搜 &lt;code&gt;compadecida&lt;/code&gt; 也能命中 &lt;code&gt;Compadecida&lt;/code&gt;，&lt;strong&gt;对多语言市场这是体验生死线&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ngram tokenizer&lt;/strong&gt; 一批（1–10、4–15 等不同窗口），&lt;code&gt;max_ngram_diff=14&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;completion suggester&lt;/strong&gt;：&lt;code&gt;suggest&lt;/code&gt; 字段配 &lt;code&gt;suggester_trigram&lt;/code&gt; 分析器，带 3 个 &lt;strong&gt;category context&lt;/strong&gt;（&lt;code&gt;markCate&lt;/code&gt; / &lt;code&gt;tenantCate&lt;/code&gt; / &lt;code&gt;comboCate&lt;/code&gt;）——自动补全可以按内容类型和租户隔离，A 运营商的用户不会看到 B 运营商专有的片名提示。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;设计启示&lt;/strong&gt;：一个&amp;quot;能搜&amp;quot;的字段，实际需要四层子字段分工——正常分词管召回、长 ngram 管前缀联想、短 ngram 管纠错、keyword 管精确和排序。这也正是《Elasticsearch in Action》里&amp;quot;多字段映射&amp;quot;思想的生产级演绎。&lt;/p&gt;
&lt;h3 id=&#34;34-双索引架构为什么搜索和详情要分开&#34;&gt;3.4 双索引架构：为什么搜索和详情要分开&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;graph LR
    subgraph 写入[&amp;#34;写入侧&amp;#34;]
        D[&amp;#34;MetaDetail 完整文档&amp;lt;br/&amp;gt;（几十 KB，含 nested 海报/演员/多语言）&amp;#34;] --&amp;gt; DETAIL[&amp;#34;详情索引&amp;lt;br/&amp;gt;6分片 重存储&amp;#34;]
        D --&amp;gt;|&amp;#34;裁剪：docId + mainTitle +&amp;lt;br/&amp;gt;suggest + 过滤字段 + 租户&amp;#34;| SEARCH[&amp;#34;搜索索引&amp;lt;br/&amp;gt;1分片 轻量&amp;#34;]
    end
    subgraph 查询[&amp;#34;查询侧&amp;#34;]
        U[&amp;#34;用户搜索 &amp;#39;auto&amp;#39;&amp;#34;] --&amp;gt;|&amp;#34;① matchSearch 命中&amp;lt;br/&amp;gt;返回 docId 列表&amp;#34;| SEARCH
        SEARCH --&amp;gt;|&amp;#34;② 按 docId mget&amp;#34;| DETAIL
        DETAIL --&amp;gt;|&amp;#34;组装完整详情返回&amp;#34;| U
    end&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;搜索索引里&lt;strong&gt;不存详情&lt;/strong&gt;，只有：docId、mainTitle（及 ngram 子字段）、suggest、40+ 个 keyword 过滤字段（&lt;code&gt;ext-field-to-index-map&lt;/code&gt; 里配置，如媒资类型、地区、年份、分级）、租户字段。好处有三个：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;搜索索引小&lt;/strong&gt;，1 个分片 + 内存友好，C 端 QPS 扛得住；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;详情变更不用动搜索索引&lt;/strong&gt;——海报换了只 update 详情索引；搜索索引只在标题/过滤字段变化时更新；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;两类索引可以独立扩缩容、独立重建&lt;/strong&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;代价也很明显：多一次 mget 回查、数据一致性要维护两份。这个项目用下一节的同步机制来兜底。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;四数据同步mysql--es-的三条路径&#34;&gt;四、数据同步：MySQL → ES 的三条路径&lt;/h2&gt;
&lt;p&gt;ES 是&amp;quot;读模型&amp;quot;，MySQL 才是事实源。项目没用 Canal/MQ，而是自己实现了一套&lt;strong&gt;事务内直写 + 任务表轮询&lt;/strong&gt;的混合方案：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TD
    A[&amp;#34;业务写请求&amp;lt;br/&amp;gt;MetaService.save()&amp;#34;] --&amp;gt; B{&amp;#34;@Transactional&amp;#34;}
    B --&amp;gt; C[&amp;#34;① 写 MySQL 业务表&amp;#34;]
    B --&amp;gt; D[&amp;#34;② 写 MySQL 同步任务表&amp;lt;br/&amp;gt;MixedMetaSyncTask&amp;#34;]
    B --&amp;gt; E[&amp;#34;③ 同事务内直写详情索引&amp;lt;br/&amp;gt;bulkOperationByBatch(refresh=True)&amp;#34;]
    B --&amp;gt; F[&amp;#34;④ saveMetaToSearchEngine&amp;lt;br/&amp;gt;同步搜索索引&amp;#34;]
    E --&amp;gt; G{&amp;#34;ES 写失败?&amp;#34;}
    G --&amp;gt;|&amp;#34;是，事务回滚或留下任务&amp;#34;| H[&amp;#34;任务表残留记录&amp;#34;]
    D --&amp;gt; I[&amp;#34;PpMetaSyncJob&amp;lt;br/&amp;gt;@Scheduled 每 15s&amp;#34;]
    H --&amp;gt; I
    I --&amp;gt; J[&amp;#34;分布式锁&amp;lt;br/&amp;gt;executeWithLock&amp;#34;]
    J --&amp;gt; K[&amp;#34;MetaTaskProcessService&amp;lt;br/&amp;gt;分批 Lists.partition(batchSize)&amp;#34;]
    K --&amp;gt;|&amp;#34;searchSync.save(batch)&amp;#34;| L[&amp;#34;搜索索引&amp;#34;]
    M[&amp;#34;失败重试 Job&amp;lt;br/&amp;gt;每小时&amp;#34;] --&amp;gt; J&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;三条路径各有分工：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;同事务直写&lt;/strong&gt;（主路径）：保证&amp;quot;后台保存完，前台立刻能搜到&amp;quot;。写入时统一刷新 &lt;code&gt;updateTime&lt;/code&gt; 和 &lt;code&gt;lastModifiedIndicator&lt;/code&gt;（格式：&lt;code&gt;时间戳_docId&lt;/code&gt; 的版本游标，下一节详述）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;任务表 + 定时轮询&lt;/strong&gt;（兜底路径）：15 秒一轮，分布式锁防多实例并发，分批灌入；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;失败重试 Job&lt;/strong&gt;（保险）：每小时扫描失败任务重放。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;为什么不纯用 MQ？&lt;/strong&gt; 这个系统的写入量级不大（媒资是低频高价值数据，不是日志），事务内直写的一致性收益远大于引入 MQ 的复杂度。这是&amp;quot;按场景选方案&amp;quot;而不是&amp;quot;按潮流选方案&amp;quot;的好例子。&lt;/p&gt;
&lt;h3 id=&#34;41-批量写入的性能细节&#34;&gt;4.1 批量写入的性能细节&lt;/h3&gt;
&lt;p&gt;工具类 &lt;code&gt;OpenSearchUtil.bulkUpsertDocuments&lt;/code&gt; 里有两个值得抄的实践：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 按配置的 bulkSize（默认 200）分批，避免单个 bulk 过大压垮集群&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 每批显式 refresh(WaitFor)：写完后“等 refresh 完成再返回”，&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;//&lt;/span&gt; 保证同步接口的调用方&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;“&lt;/span&gt;写完立即可见&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;”，&lt;/span&gt;同时比 refresh&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;true&lt;/span&gt;&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;（&lt;/span&gt;每文档刷新&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;）&lt;/span&gt;省资源&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;对照第 2.2 节的 NRT 原理：&lt;code&gt;refresh=WaitFor&lt;/code&gt; 是吞吐和可见性之间的甜点——批量场景不要无脑 &lt;code&gt;refresh=true&lt;/code&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;五查询实战从业务代码反推-es-用法&#34;&gt;五、查询实战：从业务代码反推 ES 用法&lt;/h2&gt;
&lt;h3 id=&#34;51-一切查询的起点软删除过滤&#34;&gt;5.1 一切查询的起点：软删除过滤&lt;/h3&gt;
&lt;p&gt;项目里文档不物理删除，而是软删除——所有读路径都要叠一层 &lt;code&gt;isDeleted=0&lt;/code&gt;：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// OpenSearchUtil.getUndeletedQuery：所有查询都要过这层包装&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;Query undeletedQuery &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; openSearchUtil.&lt;span style=&#34;color:#a6e22e&#34;&gt;getUndeletedQuery&lt;/span&gt;(boolQueryBuilder.&lt;span style=&#34;color:#a6e22e&#34;&gt;build&lt;/span&gt;().&lt;span style=&#34;color:#a6e22e&#34;&gt;_toQuery&lt;/span&gt;());
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;//&lt;/span&gt; 底层就是 bool.&lt;span style=&#34;color:#a6e22e&#34;&gt;filter&lt;/span&gt;(terms(isDeleted, &lt;span style=&#34;color:#f92672&#34;&gt;[&lt;/span&gt;0&lt;span style=&#34;color:#f92672&#34;&gt;]&lt;/span&gt;))&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;，&lt;/span&gt;可能再叠加 status&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;1&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;（&lt;/span&gt;已发布&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;）&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;业务侧的典型用法（&lt;code&gt;ArtworkService&lt;/code&gt;，按媒资 ID 查剧照）：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;Query metaIdQuery &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; Query.&lt;span style=&#34;color:#a6e22e&#34;&gt;of&lt;/span&gt;(q &lt;span style=&#34;color:#f92672&#34;&gt;-&amp;gt;&lt;/span&gt; q.&lt;span style=&#34;color:#a6e22e&#34;&gt;terms&lt;/span&gt;(t &lt;span style=&#34;color:#f92672&#34;&gt;-&amp;gt;&lt;/span&gt; t.&lt;span style=&#34;color:#a6e22e&#34;&gt;field&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;metaId&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    .&lt;span style=&#34;color:#a6e22e&#34;&gt;terms&lt;/span&gt;(tv &lt;span style=&#34;color:#f92672&#34;&gt;-&amp;gt;&lt;/span&gt; tv.&lt;span style=&#34;color:#a6e22e&#34;&gt;value&lt;/span&gt;(Stream.&lt;span style=&#34;color:#a6e22e&#34;&gt;of&lt;/span&gt;(request.&lt;span style=&#34;color:#a6e22e&#34;&gt;getMetaId&lt;/span&gt;())
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;map&lt;/span&gt;(FieldValue::of).&lt;span style=&#34;color:#a6e22e&#34;&gt;toList&lt;/span&gt;()))));
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;boolQueryBuilder.&lt;span style=&#34;color:#a6e22e&#34;&gt;filter&lt;/span&gt;(metaIdQuery);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;Query undeletedQuery &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; openSearchUtil.&lt;span style=&#34;color:#a6e22e&#34;&gt;getUndeletedQuery&lt;/span&gt;(boolQueryBuilder.&lt;span style=&#34;color:#a6e22e&#34;&gt;build&lt;/span&gt;().&lt;span style=&#34;color:#a6e22e&#34;&gt;_toQuery&lt;/span&gt;());
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;SortOptions sortOption &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; SortOptions.&lt;span style=&#34;color:#a6e22e&#34;&gt;of&lt;/span&gt;(s &lt;span style=&#34;color:#f92672&#34;&gt;-&amp;gt;&lt;/span&gt; s.&lt;span style=&#34;color:#a6e22e&#34;&gt;field&lt;/span&gt;(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    f &lt;span style=&#34;color:#f92672&#34;&gt;-&amp;gt;&lt;/span&gt; f.&lt;span style=&#34;color:#a6e22e&#34;&gt;field&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;pos&amp;#34;&lt;/span&gt;).&lt;span style=&#34;color:#a6e22e&#34;&gt;order&lt;/span&gt;(SortOrder.&lt;span style=&#34;color:#a6e22e&#34;&gt;Asc&lt;/span&gt;)));
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;List&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt;MediaArtwork&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; artworks &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; openSearchUtil.&lt;span style=&#34;color:#a6e22e&#34;&gt;searchDoc&lt;/span&gt;(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    OpenSearchIndexEnum.&lt;span style=&#34;color:#a6e22e&#34;&gt;MEDIA_ARTWORK&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;getIndexName&lt;/span&gt;(),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    undeletedQuery, MediaArtwork.&lt;span style=&#34;color:#a6e22e&#34;&gt;class&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    request.&lt;span style=&#34;color:#a6e22e&#34;&gt;getPageId&lt;/span&gt;(), request.&lt;span style=&#34;color:#a6e22e&#34;&gt;getPageSize&lt;/span&gt;(),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    Collections.&lt;span style=&#34;color:#a6e22e&#34;&gt;singletonList&lt;/span&gt;(sortOption), totalCount);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;这正是第 2.3 节&amp;quot;filter 不算分、可缓存&amp;quot;原理的落地：&lt;strong&gt;结构化条件全部进 filter&lt;/strong&gt;。&lt;/p&gt;
&lt;h3 id=&#34;52-软删除的实现updatebyquery--painless-脚本&#34;&gt;5.2 软删除的实现：updateByQuery + Painless 脚本&lt;/h3&gt;
&lt;p&gt;删除不是 &lt;code&gt;delete&lt;/code&gt;，而是 &lt;code&gt;updateByQuery&lt;/code&gt; 打标记：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;String script &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34; ctx._source.isDeleted = 1;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      &lt;span style=&#34;color:#f92672&#34;&gt;+&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;ctx._source.updateTime = params.currentTime;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      &lt;span style=&#34;color:#f92672&#34;&gt;+&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;ctx._source.lastModifiedIndicator = params.indicatorPrefix + &amp;#39;_&amp;#39; + ctx._id&amp;#34;&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;UpdateByQueryRequest request &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;new&lt;/span&gt; UpdateByQueryRequest.&lt;span style=&#34;color:#a6e22e&#34;&gt;Builder&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    .&lt;span style=&#34;color:#a6e22e&#34;&gt;index&lt;/span&gt;(indexName).&lt;span style=&#34;color:#a6e22e&#34;&gt;query&lt;/span&gt;(query)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    .&lt;span style=&#34;color:#a6e22e&#34;&gt;script&lt;/span&gt;(s &lt;span style=&#34;color:#f92672&#34;&gt;-&amp;gt;&lt;/span&gt; s.&lt;span style=&#34;color:#a6e22e&#34;&gt;inline&lt;/span&gt;(i &lt;span style=&#34;color:#f92672&#34;&gt;-&amp;gt;&lt;/span&gt; i.&lt;span style=&#34;color:#a6e22e&#34;&gt;source&lt;/span&gt;(finalScript)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;params&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;currentTime&amp;#34;&lt;/span&gt;, JsonData.&lt;span style=&#34;color:#a6e22e&#34;&gt;of&lt;/span&gt;(currentTime))
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;params&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;indicatorPrefix&amp;#34;&lt;/span&gt;, JsonData.&lt;span style=&#34;color:#a6e22e&#34;&gt;of&lt;/span&gt;(String.&lt;span style=&#34;color:#a6e22e&#34;&gt;valueOf&lt;/span&gt;(modifyTime)))))
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    .&lt;span style=&#34;color:#a6e22e&#34;&gt;build&lt;/span&gt;();&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;注意脚本里同时更新了 &lt;code&gt;lastModifiedIndicator&lt;/code&gt;——&lt;strong&gt;软删除也是一次&amp;quot;变更&amp;quot;，必须让下游订阅方感知到&lt;/strong&gt;（下游才能同步删）。这个细节是订阅体系能闭环的关键。&lt;/p&gt;
&lt;h3 id=&#34;53-两阶段全文搜索&#34;&gt;5.3 两阶段全文搜索&lt;/h3&gt;
&lt;p&gt;C 端搜索入口 &lt;code&gt;SearchService.searchMedia&lt;/code&gt; 的分流逻辑：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;if&lt;/span&gt; (StringUtils.&lt;span style=&#34;color:#a6e22e&#34;&gt;isNotEmpty&lt;/span&gt;(fuzzyMatchingParams.&lt;span style=&#34;color:#a6e22e&#34;&gt;getKeyword&lt;/span&gt;())) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#75715e&#34;&gt;// 有关键词：内部库全文检索 → 返回 docId + mark（类型）&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    resp &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; searchApi.&lt;span style=&#34;color:#a6e22e&#34;&gt;matchSearch&lt;/span&gt;(keyword, marks, pageId, pageSize, tenantIds, params);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;} &lt;span style=&#34;color:#66d9ef&#34;&gt;else&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#75715e&#34;&gt;// 无关键词：纯结构化筛选（找片页、分类浏览）&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    resp &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; searchApi.&lt;span style=&#34;color:#a6e22e&#34;&gt;find&lt;/span&gt;(marks, pageId, pageSize, tenantIds, params);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 按 mark 分流 person / meta，回查详情索引取完整数据&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;openSearchUtil.&lt;span style=&#34;color:#a6e22e&#34;&gt;batchGetDocuments&lt;/span&gt;(PERSON_DETAIL.&lt;span style=&#34;color:#a6e22e&#34;&gt;getIndexName&lt;/span&gt;(), personDocIds, PersonDetail.&lt;span style=&#34;color:#a6e22e&#34;&gt;class&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;openSearchUtil.&lt;span style=&#34;color:#a6e22e&#34;&gt;batchGetDocuments&lt;/span&gt;(META_DETAIL.&lt;span style=&#34;color:#a6e22e&#34;&gt;getIndexName&lt;/span&gt;(), metaDocIds, MetaDetail.&lt;span style=&#34;color:#a6e22e&#34;&gt;class&lt;/span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;查询条件从哪来？API 层收的是 &lt;strong&gt;SQL 风格的 where 模板&lt;/strong&gt;（&lt;code&gt;MetaQuery.whereTemplate&lt;/code&gt; + &lt;code&gt;parameters&lt;/code&gt;），&lt;code&gt;WhereClauseUtil&lt;/code&gt; 用正则把 &lt;code&gt;in (:x)&lt;/code&gt;、&lt;code&gt;between and&lt;/code&gt;、&lt;code&gt;JSON_CONTAINS_ALL/ANY&lt;/code&gt; 解析成 ES 的 Condition（IN / GREATER_THAN 等），字段名经 &lt;code&gt;FieldNameMappingEnum&lt;/code&gt; 做外部名→索引字段名映射。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;这个设计的好处&lt;/strong&gt;：对前端/上游服务来说，查询语法是稳定的数据库风格，不暴露 ES DSL；ES mapping 改名、换引擎，外部无感。&lt;/p&gt;
&lt;h3 id=&#34;54-自动补全completion-suggester--租户隔离&#34;&gt;5.4 自动补全：completion suggester + 租户隔离&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// SearchService.suggest&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;List&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt;Prompt&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; prompts &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; searchApi.&lt;span style=&#34;color:#a6e22e&#34;&gt;getPromptList&lt;/span&gt;(keyword, marks, &lt;span style=&#34;color:#66d9ef&#34;&gt;true&lt;/span&gt;, companyId, size, uuid);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 底层 = 搜索索引 suggest 字段的 completion suggester，&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// context 里带 tenantCate=companyId → 补全结果按运营商隔离&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;//&lt;/span&gt; 拿到 docId 后再回查详情索引组装标题和海报&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;举例如&amp;quot;用户输入 &lt;code&gt;int&lt;/code&gt;&amp;quot;：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;sequenceDiagram
    participant U as 用户(运营商A)
    participant S as SearchService
    participant SE as 搜索索引 suggester
    participant D as 详情索引
    U-&amp;gt;&amp;gt;S: suggest(&amp;#34;int&amp;#34;, companyId=A)
    S-&amp;gt;&amp;gt;SE: completion suggest&amp;lt;br/&amp;gt;context: tenantCate=A
    SE--&amp;gt;&amp;gt;S: [&amp;#34;Interestelar&amp;#34;, &amp;#34;Intocáveis&amp;#34;]&amp;lt;br/&amp;gt;+ docId 列表
    S-&amp;gt;&amp;gt;D: mget(docIds)
    D--&amp;gt;&amp;gt;S: 标题 + 海报 URL
    S--&amp;gt;&amp;gt;U: 补全列表（带封面）&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;h3 id=&#34;55-一个常被忽略的性能点只取-id-就别加载-_source&#34;&gt;5.5 一个常被忽略的性能点：只取 ID 就别加载 _source&lt;/h3&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// fastGetDocumentById：判断文档存在/取 ID 时&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// storedFields(&amp;#34;_id&amp;#34;) + size(1) + trackTotalHits(false)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;//&lt;/span&gt; 注释原话&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;：&lt;/span&gt;类似 MySQL 的 LIMIT 1&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;trackTotalHits(false)&lt;/code&gt;：不精确统计总数（默认只算到 10000），省掉一次全量计数；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;storedFields(&amp;quot;_id&amp;quot;)&lt;/code&gt;：不加载 &lt;code&gt;_source&lt;/code&gt;，网络传输量从几十 KB 降到几十 B。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;列表页&amp;quot;先查 ID 再回查&amp;quot;的场景，这两招能把响应时间砍掉一个量级。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;六增量订阅es-作为变更数据源&#34;&gt;六、增量订阅：ES 作为&amp;quot;变更数据源&amp;quot;&lt;/h2&gt;
&lt;p&gt;下游服务（推荐、缓存、其他中台）需要感知媒资变更。项目没有用 CDC 或 MQ 广播，而是让下游&lt;strong&gt;基于水位游标主动拉取 ES&lt;/strong&gt;：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;sequenceDiagram
    participant SUB as 下游订阅方
    participant Q as SubqueryService
    participant ES as 详情索引

    Note over SUB: 记录上次水位 W0&amp;lt;br/&amp;gt;（lastModifiedIndicator 字符串）
    SUB-&amp;gt;&amp;gt;Q: querySubQuery(from=W0, size=500)
    Q-&amp;gt;&amp;gt;ES: range(lastModifiedIndicator: gt W0, lt 当前时间-2s)&amp;lt;br/&amp;gt;+ 按 lastModifiedIndicator 升序
    ES--&amp;gt;&amp;gt;Q: 本批变更文档（含软删除的）
    Q--&amp;gt;&amp;gt;SUB: 文档 + 新水位 W1
    SUB-&amp;gt;&amp;gt;SUB: 落库/更新缓存，记录 W1
    Note over Q,ES: 对账：subQueryCount 用 count + sort desc + size(1)&amp;lt;br/&amp;gt;取区间最大水位，生产方/消费方核对数量&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;几个工程细节值得细说：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;水位是字符串而非时间戳&lt;/strong&gt;：&lt;code&gt;lastModifiedIndicator = 时间戳 + &amp;quot;_&amp;quot; + docId&lt;/code&gt;。纯时间戳会在同一毫秒内有多个文档时漏数；拼上 docId 后字典序唯一，range &lt;code&gt;gt&lt;/code&gt; 翻页不会漏、不会重。这是项目&lt;strong&gt;用手写游标替代 &lt;code&gt;search_after&lt;/code&gt;&lt;/strong&gt; 的原因——行为完全可控，且不依赖 ES 的 PIT（point in time）上下文。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;lt 当前时间 - 2s&lt;/code&gt; 的回调偏移&lt;/strong&gt;：规避&amp;quot;刚写入还没 refresh 可见&amp;quot;的窗口（第 2.2 节 NRT 原理的直接应用），宁可晚 2 秒，不可漏数据。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;软删除文档也带着新水位出现&lt;/strong&gt;，下游据此删除本地副本——和第 5.2 节的脚本闭环。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2 id=&#34;七多租户设计docid-里藏着租户&#34;&gt;七、多租户设计：docId 里藏着租户&lt;/h2&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// OpenSearchUtil.getDocId：文档 ID = companyId + &amp;#34;#&amp;#34; + metaId&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;//&lt;/span&gt; 例&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;：&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;1001#mv_8848123&amp;#34;&lt;/span&gt;&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;、&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;public#mv_8848123&amp;#34;&lt;/span&gt;&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;（&lt;/span&gt;公共库&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;）&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;ul&gt;
&lt;li&gt;同一部影片，A 运营商和 B 运营商是&lt;strong&gt;两份文档&lt;/strong&gt;（各自的标题、定价、上架状态可能不同），天然物理隔离；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sourceType&lt;/code&gt; 字段区分 PRIVATE（私有）/ PUBLIC（公共）/ MIXED（混合），公共内容用 &lt;code&gt;public#&lt;/code&gt; 前缀，查询时按租户策略合并；&lt;/li&gt;
&lt;li&gt;所有查询 filter 里都带 &lt;code&gt;companyId&lt;/code&gt; / &lt;code&gt;tenant_id&lt;/code&gt;，补全用 context 隔离（第 5.4 节）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;经验&lt;/strong&gt;：多租户搜索系统，把租户编进 docId 比单纯加过滤字段更稳——docId 冲突本身就是一道防串租户的保险。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;八回到原理这个项目验证了哪些书里的论断&#34;&gt;八、回到原理：这个项目验证了哪些书里的论断&lt;/h2&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;书中原理&lt;/th&gt;
					&lt;th&gt;项目里的体现&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;Mapping 先行，改 analyzer 要重建索引&lt;/td&gt;
					&lt;td&gt;索引 DDL 用 changelog 版本化管理，上线流程化&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Filter 不算分、可缓存&lt;/td&gt;
					&lt;td&gt;租户/类型/软删除/状态全部进 filter，只有关键词算分&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;多字段映射解决&amp;quot;一个字段多种搜法&amp;quot;&lt;/td&gt;
					&lt;td&gt;mainTitle 四层子字段：分词/长ngram/短ngram/keyword&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;NRT 的 refresh 是性能旋钮&lt;/td&gt;
					&lt;td&gt;批量写入用 &lt;code&gt;refresh=WaitFor&lt;/code&gt;；订阅端留 2s 偏移&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;深度翻页要避免 from+size 深分页&lt;/td&gt;
					&lt;td&gt;手写 &lt;code&gt;lastModifiedIndicator&lt;/code&gt; 游标替代 search_after&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;聚合昂贵，慎用&lt;/td&gt;
					&lt;td&gt;项目几乎零聚合；取最大值用 sort+size(1) 替代 max agg&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;分片数创建时定死，规划要前置&lt;/td&gt;
					&lt;td&gt;详情索引 6 分片按数据增长预估；搜索索引刻意 1 分片&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id=&#34;九总结&#34;&gt;九、总结&lt;/h2&gt;
&lt;p&gt;如果把这套架构压缩成一张图：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;graph LR
    W[&amp;#34;写路径&amp;lt;br/&amp;gt;MySQL 事务&amp;lt;br/&amp;gt;+ 任务表兜底&amp;#34;] --&amp;gt; ES
    subgraph ES[&amp;#34;OpenSearch&amp;#34;]
        S[&amp;#34;搜索索引&amp;lt;br/&amp;gt;小而快：ngram+suggester&amp;lt;br/&amp;gt;+租户 context&amp;#34;]
        D[&amp;#34;详情索引&amp;lt;br/&amp;gt;大而全：keyword+nested&amp;lt;br/&amp;gt;dynamic:false&amp;#34;]
    end
    ES --&amp;gt; R1[&amp;#34;读路径 ①&amp;lt;br/&amp;gt;搜索/补全 → docId&amp;#34;]
    R1 --&amp;gt; R2[&amp;#34;读路径 ②&amp;lt;br/&amp;gt;mget 详情组装&amp;#34;]
    ES --&amp;gt; R3[&amp;#34;订阅路径&amp;lt;br/&amp;gt;水位游标增量拉取&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;三条核心经验：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;索引按&amp;quot;查询模式&amp;quot;而不是&amp;quot;数据表&amp;quot;来设计&lt;/strong&gt;：搜索索引和详情索引分开，一个为召回优化（ngram、suggester、多语分析器），一个为取数优化（keyword、nested、禁动态字段）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;一致性靠机制不靠运气&lt;/strong&gt;：事务内直写是主路径，任务表 + 定时 Job + 失败重试是兜底，水位游标让下游最终一致——三层防护各管一段；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;所有&amp;quot;黑科技&amp;quot;都是基本原理的应用&lt;/strong&gt;：refresh 偏移、filter 缓存、storedFields 取 ID、游标翻页……吃透《Elasticsearch in Action》那套原理，生产方案都是它的排列组合。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;参考资料：Radu Gheorghe, Matthew Lee Hinman, Roy Russo. 《Elasticsearch in Action》. Manning Publications, 2015；ftmao-meta 项目源码（OpenSearchConfig / OpenSearchUtil / SearchService / SubqueryService / changelog/elasticsearch）。&lt;/em&gt;&lt;/p&gt;
</description>
    </item>
    
  </channel>
</rss>
