<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>小加号笔记</title>
  <icon>https://blog.searchdiff.com/images/avatar.jpg</icon>
  <subtitle>技术与思考的碎片</subtitle>
  <link href="https://blog.searchdiff.com/atom.xml" rel="self"/>
  
  <link href="https://blog.searchdiff.com/"/>
  <updated>2026-06-21T11:06:44.564Z</updated>
  <id>https://blog.searchdiff.com/</id>
  
  <author>
    <name>小加号笔记</name>
    
  </author>
  
  <generator uri="https://hexo.io/">Hexo</generator>
  
  <entry>
    <title>短信发送系统设计</title>
    <link href="https://blog.searchdiff.com/2026/06/21/%E7%9F%AD%E4%BF%A1%E5%8F%91%E9%80%81%E7%B3%BB%E7%BB%9F%E8%AE%BE%E8%AE%A1/"/>
    <id>https://blog.searchdiff.com/2026/06/21/%E7%9F%AD%E4%BF%A1%E5%8F%91%E9%80%81%E7%B3%BB%E7%BB%9F%E8%AE%BE%E8%AE%A1/</id>
    <published>2026-06-21T01:30:00.000Z</published>
    <updated>2026-06-21T11:06:44.564Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>面试题背景</strong>：设计一个短信发送系统。核心约束：同一手机号每 60s 最多发送一次、每天最多发送 10 条。</p><p>本题从”实现一个限流方法”切入，可一路深挖到并发、分布式限流、异步削峰、幂等、多通道路由、容灾、安全合规、监控成本等。下面按<strong>由浅入深</strong>展开：先解决单机并发限流，再演进到生产级短信系统设计。</p></blockquote><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">本文脉络：</span><br><span class="line">  一~五  从 0 到 1：并发问题 → 限流方案（单机/分布式）→ 内存治理 → 方案对比</span><br><span class="line">  六~十三 由点到面：系统架构 → 异步削峰 → 幂等 → 多通道路由 → 重试补偿 → 安全合规 → 监控 → 成本</span><br><span class="line">  十四    生产就绪 Checklist + 面试追问速答</span><br></pre></td></tr></table></figure><span id="more"></span><hr><h2 id="一、初始实现与问题分析"><a href="#一、初始实现与问题分析" class="headerlink" title="一、初始实现与问题分析"></a>一、初始实现与问题分析</h2><h3 id="原始代码"><a href="#原始代码" class="headerlink" title="原始代码"></a>原始代码</h3><figure class="highlight java"><table><tr><td class="code"><pre><span class="line"><span class="keyword">private</span> Map&lt;String, Long&gt; lastSendTimeMap = <span class="keyword">new</span> <span class="title class_">ConcurrentHashMap</span>&lt;&gt;();</span><br><span class="line"><span class="keyword">private</span> Map&lt;String, Integer&gt; sendCountMap = <span class="keyword">new</span> <span class="title class_">ConcurrentHashMap</span>&lt;&gt;();</span><br><span class="line"></span><br><span class="line"><span class="keyword">public</span> <span class="type">boolean</span> <span class="title function_">sendMessage</span><span class="params">(String phoneNo, String message)</span> &#123;</span><br><span class="line">    <span class="type">Long</span> <span class="variable">lastSendTime</span> <span class="operator">=</span> lastSendTimeMap.get(phoneNo);</span><br><span class="line">    <span class="keyword">if</span> (lastSendTime != <span class="literal">null</span> &amp;&amp; System.currentTimeMillis() - lastSendTime &lt; <span class="number">60000</span>) &#123;</span><br><span class="line">        <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="type">SimpleDateFormat</span> <span class="variable">sdf</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">SimpleDateFormat</span>(<span class="string">&quot;yyyy-MM-dd&quot;</span>);</span><br><span class="line">    <span class="type">String</span> <span class="variable">today</span> <span class="operator">=</span> sdf.format(<span class="keyword">new</span> <span class="title class_">java</span>.util.Date());</span><br><span class="line"></span><br><span class="line">    <span class="type">Integer</span> <span class="variable">sendCount</span> <span class="operator">=</span> sendCountMap.get(phoneNo + <span class="string">&quot;#&quot;</span> + today);</span><br><span class="line">    <span class="keyword">if</span> (sendCount == <span class="literal">null</span>) sendCount = <span class="number">0</span>;</span><br><span class="line">    <span class="keyword">if</span> (sendCount &gt;= <span class="number">10</span>) <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 发送短信（未实现）</span></span><br><span class="line"></span><br><span class="line">    lastSendTimeMap.put(phoneNo, System.currentTimeMillis());</span><br><span class="line">    sendCountMap.put(phoneNo + <span class="string">&quot;#&quot;</span> + today, sendCount + <span class="number">1</span>);</span><br><span class="line">    <span class="keyword">return</span> <span class="literal">true</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="问题清单"><a href="#问题清单" class="headerlink" title="问题清单"></a>问题清单</h3><table><thead><tr><th>优先级</th><th>问题</th><th>说明</th></tr></thead><tbody><tr><td>P0</td><td><strong>并发竞态</strong></td><td>check-then-act 非原子，多线程下限流失效</td></tr><tr><td>P0</td><td><strong>发送逻辑缺失</strong></td><td>实际短信发送代码为空，状态与结果不一致</td></tr><tr><td>P1</td><td><strong>单机限制</strong></td><td>Map 存 JVM 内存，多实例部署时限流形同虚设</td></tr><tr><td>P1</td><td><strong>内存泄漏</strong></td><td><code>phoneNo#date</code> key 永不清理，长期运行 OOM</td></tr><tr><td>P2</td><td><strong>参数校验缺失</strong></td><td>phoneNo&#x2F;message 为 null 时直接 NPE</td></tr><tr><td>P2</td><td><strong>SimpleDateFormat 非线程安全</strong></td><td>应改用 <code>java.time.LocalDate</code></td></tr><tr><td>P2</td><td><strong>时区问题</strong></td><td><code>new Date()</code> 依赖 JVM 默认时区，跨时区部署有风险</td></tr></tbody></table><hr><h2 id="二、并发问题深入分析"><a href="#二、并发问题深入分析" class="headerlink" title="二、并发问题深入分析"></a>二、并发问题深入分析</h2><h3 id="竞态条件复现"><a href="#竞态条件复现" class="headerlink" title="竞态条件复现"></a>竞态条件复现</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">时间轴：</span><br><span class="line">T1: get(phoneNo) → null（通过60s检查）</span><br><span class="line">T2: get(phoneNo) → null（通过60s检查）  ← 同时进入</span><br><span class="line">T1: get(count) → 9（通过日限检查）</span><br><span class="line">T2: get(count) → 9（通过日限检查）  ← 都读到9</span><br><span class="line">T1: put(count, 10)  ← 发送第10条</span><br><span class="line">T2: put(count, 10)  ← 发送第11条！超限</span><br></pre></td></tr></table></figure><p><strong>根因</strong>：<code>ConcurrentHashMap</code> 只保证单个操作的原子性，跨操作的”读-判断-写”三步组合不是原子的。</p><hr><h2 id="三、解决方案"><a href="#三、解决方案" class="headerlink" title="三、解决方案"></a>三、解决方案</h2><h3 id="方案一：合并状态-compute-原子操作（单机推荐）"><a href="#方案一：合并状态-compute-原子操作（单机推荐）" class="headerlink" title="方案一：合并状态 + compute 原子操作（单机推荐）"></a>方案一：合并状态 + compute 原子操作（单机推荐）</h3><p><strong>核心思路</strong>：将两个 Map 合并为一个，利用 <code>ConcurrentHashMap.compute()</code> 对同一 key 的操作加分段锁，保证原子性。</p><figure class="highlight java"><table><tr><td class="code"><pre><span class="line"><span class="keyword">import</span> java.time.LocalDate;</span><br><span class="line"><span class="keyword">import</span> java.time.ZoneId;</span><br><span class="line"><span class="keyword">import</span> java.util.concurrent.ConcurrentHashMap;</span><br><span class="line"></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">SendMessage</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">class</span> <span class="title class_">PhoneState</span> &#123;</span><br><span class="line">        <span class="type">long</span> <span class="variable">lastSendTime</span> <span class="operator">=</span> <span class="number">0</span>;       <span class="comment">// 最近一次预占/发送的时间戳</span></span><br><span class="line">        <span class="type">int</span> <span class="variable">dailyCount</span> <span class="operator">=</span> <span class="number">0</span>;          <span class="comment">// 当日已发送（含预占）计数</span></span><br><span class="line">        <span class="type">String</span> <span class="variable">lastSendDate</span> <span class="operator">=</span> <span class="string">&quot;&quot;</span>;    <span class="comment">// 最近一次的日期，用于跨天重置</span></span><br><span class="line">        <span class="type">long</span> <span class="variable">pendingTimestamp</span> <span class="operator">=</span> <span class="number">0</span>;   <span class="comment">// 正在发送中的那次预占时间戳（用于安全回滚）</span></span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> ConcurrentHashMap&lt;String, PhoneState&gt; stateMap = <span class="keyword">new</span> <span class="title class_">ConcurrentHashMap</span>&lt;&gt;();</span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> <span class="type">boolean</span> <span class="title function_">sendMessage</span><span class="params">(String phoneNo, String message)</span> &#123;</span><br><span class="line">        <span class="comment">// 参数校验</span></span><br><span class="line">        <span class="keyword">if</span> (phoneNo == <span class="literal">null</span> || !phoneNo.matches(<span class="string">&quot;^1[3-9]\\d&#123;9&#125;$&quot;</span>)) <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line">        <span class="keyword">if</span> (message == <span class="literal">null</span> || message.isEmpty() || message.length() &gt; <span class="number">500</span>) <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line"></span><br><span class="line">        <span class="type">String</span> <span class="variable">today</span> <span class="operator">=</span> LocalDate.now(ZoneId.of(<span class="string">&quot;Asia/Shanghai&quot;</span>)).toString();</span><br><span class="line">        <span class="type">long</span> <span class="variable">now</span> <span class="operator">=</span> System.currentTimeMillis();</span><br><span class="line">        <span class="type">boolean</span>[] allowed = &#123;<span class="literal">false</span>&#125;;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// compute 保证对同一 phoneNo 的操作原子执行</span></span><br><span class="line">        stateMap.compute(phoneNo, (k, state) -&gt; &#123;</span><br><span class="line">            <span class="keyword">if</span> (state == <span class="literal">null</span>) state = <span class="keyword">new</span> <span class="title class_">PhoneState</span>();</span><br><span class="line"></span><br><span class="line">            <span class="comment">// 60s 间隔检查</span></span><br><span class="line">            <span class="keyword">if</span> (now - state.lastSendTime &lt; <span class="number">60_000</span>) <span class="keyword">return</span> state;</span><br><span class="line"></span><br><span class="line">            <span class="comment">// 跨天重置日计数</span></span><br><span class="line">            <span class="keyword">if</span> (!today.equals(state.lastSendDate)) &#123;</span><br><span class="line">                state.dailyCount = <span class="number">0</span>;</span><br><span class="line">                state.lastSendDate = today;</span><br><span class="line">            &#125;</span><br><span class="line"></span><br><span class="line">            <span class="comment">// 日限 10 条</span></span><br><span class="line">            <span class="keyword">if</span> (state.dailyCount &gt;= <span class="number">10</span>) <span class="keyword">return</span> state;</span><br><span class="line"></span><br><span class="line">            <span class="comment">// 预占状态（乐观：先占位，发送失败再回滚）</span></span><br><span class="line">            state.lastSendTime = now;</span><br><span class="line">            state.pendingTimestamp = now;   <span class="comment">// 记下&quot;我这次预占的时间戳&quot;</span></span><br><span class="line">            state.dailyCount++;</span><br><span class="line">            allowed[<span class="number">0</span>] = <span class="literal">true</span>;</span><br><span class="line">            <span class="keyword">return</span> state;</span><br><span class="line">        &#125;);</span><br><span class="line"></span><br><span class="line">        <span class="keyword">if</span> (!allowed[<span class="number">0</span>]) <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 实际发送</span></span><br><span class="line">        <span class="keyword">try</span> &#123;</span><br><span class="line">            doSend(phoneNo, message);</span><br><span class="line">            <span class="keyword">return</span> <span class="literal">true</span>;</span><br><span class="line">        &#125; <span class="keyword">catch</span> (Exception e) &#123;</span><br><span class="line">            <span class="comment">// 发送失败，【条件回滚】：仅当状态未被他人覆盖时才撤销</span></span><br><span class="line">            <span class="comment">// 关键：doSend 耗时（几百 ms~秒），期间别的线程可能已合法修改此 key</span></span><br><span class="line">            <span class="keyword">final</span> <span class="type">long</span> <span class="variable">myTimestamp</span> <span class="operator">=</span> now;</span><br><span class="line">            stateMap.compute(phoneNo, (k, state) -&gt; &#123;</span><br><span class="line">                <span class="keyword">if</span> (state != <span class="literal">null</span> &amp;&amp; state.pendingTimestamp == myTimestamp) &#123;</span><br><span class="line">                    <span class="comment">// 中间没人改过，安全回滚我这次的预占</span></span><br><span class="line">                    state.lastSendTime = <span class="number">0</span>;</span><br><span class="line">                    state.dailyCount = Math.max(<span class="number">0</span>, state.dailyCount - <span class="number">1</span>);</span><br><span class="line">                    state.pendingTimestamp = <span class="number">0</span>;</span><br><span class="line">                &#125;</span><br><span class="line">                <span class="comment">// 否则：期间已有他人成功发送（pendingTimestamp 已变），我的预占已被</span></span><br><span class="line">                <span class="comment">// &quot;自然消化&quot;，不能再回滚，否则会误清他人合法的 lastSendTime</span></span><br><span class="line">                <span class="keyword">return</span> state;</span><br><span class="line">            &#125;);</span><br><span class="line">            <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">void</span> <span class="title function_">doSend</span><span class="params">(String phoneNo, String message)</span> &#123;</span><br><span class="line">        <span class="comment">// 调用短信服务商 API（阿里云/腾讯云等）</span></span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>为什么 <code>compute</code> 能解决并发</strong>：</p><ul><li><code>compute</code> 在执行期间对该 key 持有分段锁</li><li>不同 phoneNo 哈希到不同 segment，互不阻塞，并发性能好</li><li>同一 phoneNo 的多个并发请求串行执行，竞态消除</li></ul><blockquote><h3 id="⚠️-隐蔽竞态：无条件回滚会破坏-60s-限制（重要！）"><a href="#⚠️-隐蔽竞态：无条件回滚会破坏-60s-限制（重要！）" class="headerlink" title="⚠️ 隐蔽竞态：无条件回滚会破坏 60s 限制（重要！）"></a>⚠️ 隐蔽竞态：无条件回滚会破坏 60s 限制（重要！）</h3><p>上面代码用了<strong>条件回滚</strong>（<code>state.pendingTimestamp == myTimestamp</code> 才撤销）。如果写成<strong>无条件回滚</strong>（很多人第一反应会这么写），会引入一个隐蔽且严重的 bug：</p><figure class="highlight java"><table><tr><td class="code"><pre><span class="line"><span class="comment">// ❌ 错误写法：无条件清零</span></span><br><span class="line">stateMap.compute(phoneNo, (k, state) -&gt; &#123;</span><br><span class="line">    state.lastSendTime = <span class="number">0</span>;                          <span class="comment">// 无脑清零</span></span><br><span class="line">    state.dailyCount = Math.max(<span class="number">0</span>, state.dailyCount - <span class="number">1</span>);</span><br><span class="line">    <span class="keyword">return</span> state;</span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure><p><strong>根因</strong>：预占的 <code>compute</code> 和回滚的 <code>compute</code> 是<strong>两个独立的临界区</strong>，中间隔着耗时的 <code>doSend</code>（调短信通道，几百 ms~秒）。在这两次 <code>compute</code> 之间，<strong>别的线程可以合法地修改同一个 key 的状态</strong>。回滚时假设”状态还是我预占时的样子”，但实际早被改过。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">时间轴复现：</span><br><span class="line">T1 预占 → lastSendTime=t1, dailyCount=1</span><br><span class="line">T1 doSend 卡住（通道超时 5s）</span><br><span class="line">    ┊ 60 秒过去 ┊</span><br><span class="line">T2 预占（同号，t1 已过 60s，合法）→ lastSendTime=t2, dailyCount=2</span><br><span class="line">T2 doSend 成功 ✓</span><br><span class="line">T1 终于失败，无条件回滚 → lastSendTime=0   💥 把 T2 合法的 t2 清零！</span><br><span class="line"></span><br><span class="line">后果：T3 立刻请求，now-0 巨大 → 通过 → T2 刚发完 T3 立刻发</span><br><span class="line">     → 违反&quot;同号 60s 一次&quot; ❌</span><br></pre></td></tr></table></figure><p><strong>修复思路（CAS 思想）</strong>：回滚时带条件判断，只有”状态没被他人覆盖”才撤销——即上面的 <code>pendingTimestamp == myTimestamp</code>。这等价于一个版本号&#x2F;时间戳的 Compare-And-Swap：</p><ul><li>预占时记下自己的时间戳 <code>pendingTimestamp = now</code></li><li>回滚时若 <code>pendingTimestamp</code> 还是自己的值 → 中间没人改过 → 安全回滚</li><li>若已变 → 期间已有他人成功发送 → 我的预占已被自然消化 → <strong>不回滚</strong></li></ul><p><strong>生产实践更推荐</strong>：直接<strong>失败不回滚</strong>（60s 冷却也保留）。理由：短信失败多为号码&#x2F;通道问题，立即重试大概率还失败、徒增成本；保留冷却还能防”失败→立即重试”的刷量风暴，且彻底消除回滚竞态。本例的条件回滚适用于”失败必须让用户立即可重试”的强需求场景。</p><p>顺带一提：下面的 <strong>Redis 方案也存在同样的回滚竞态</strong>（<code>decrement</code> 减的是当前值），见其对应警告。</p></blockquote><hr><h3 id="方案二：Striped-细粒度锁（逻辑更清晰）"><a href="#方案二：Striped-细粒度锁（逻辑更清晰）" class="headerlink" title="方案二：Striped 细粒度锁（逻辑更清晰）"></a>方案二：Striped 细粒度锁（逻辑更清晰）</h3><p>适合判断逻辑复杂、不适合塞进 lambda 的场景。</p><figure class="highlight java"><table><tr><td class="code"><pre><span class="line"><span class="comment">// 依赖：com.google.guava:guava</span></span><br><span class="line"><span class="keyword">import</span> com.google.common.util.concurrent.Striped;</span><br><span class="line"><span class="keyword">import</span> java.util.concurrent.locks.Lock;</span><br><span class="line"></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">SendMessage</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 256 个锁条带，不同 phoneNo 大概率使用不同锁，冲突概率低</span></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> Striped&lt;Lock&gt; striped = Striped.lock(<span class="number">256</span>);</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> ConcurrentHashMap&lt;String, PhoneState&gt; stateMap = <span class="keyword">new</span> <span class="title class_">ConcurrentHashMap</span>&lt;&gt;();</span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> <span class="type">boolean</span> <span class="title function_">sendMessage</span><span class="params">(String phoneNo, String message)</span> &#123;</span><br><span class="line">        <span class="keyword">if</span> (phoneNo == <span class="literal">null</span> || message == <span class="literal">null</span>) <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line"></span><br><span class="line">        <span class="type">Lock</span> <span class="variable">lock</span> <span class="operator">=</span> striped.get(phoneNo);</span><br><span class="line">        lock.lock();</span><br><span class="line">        <span class="keyword">try</span> &#123;</span><br><span class="line">            <span class="type">String</span> <span class="variable">today</span> <span class="operator">=</span> LocalDate.now(ZoneId.of(<span class="string">&quot;Asia/Shanghai&quot;</span>)).toString();</span><br><span class="line">            <span class="type">long</span> <span class="variable">now</span> <span class="operator">=</span> System.currentTimeMillis();</span><br><span class="line"></span><br><span class="line">            <span class="type">PhoneState</span> <span class="variable">state</span> <span class="operator">=</span> stateMap.computeIfAbsent(phoneNo, k -&gt; <span class="keyword">new</span> <span class="title class_">PhoneState</span>());</span><br><span class="line"></span><br><span class="line">            <span class="keyword">if</span> (now - state.lastSendTime &lt; <span class="number">60_000</span>) <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line"></span><br><span class="line">            <span class="keyword">if</span> (!today.equals(state.lastSendDate)) &#123;</span><br><span class="line">                state.dailyCount = <span class="number">0</span>;</span><br><span class="line">                state.lastSendDate = today;</span><br><span class="line">            &#125;</span><br><span class="line"></span><br><span class="line">            <span class="keyword">if</span> (state.dailyCount &gt;= <span class="number">10</span>) <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line"></span><br><span class="line">            state.lastSendTime = now;</span><br><span class="line">            state.dailyCount++;</span><br><span class="line"></span><br><span class="line">            doSend(phoneNo, message);</span><br><span class="line">            <span class="keyword">return</span> <span class="literal">true</span>;</span><br><span class="line">        &#125; <span class="keyword">catch</span> (Exception e) &#123;</span><br><span class="line">            <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line">        &#125; <span class="keyword">finally</span> &#123;</span><br><span class="line">            lock.unlock();</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><blockquote><p>⚠️ 不要用 <code>synchronized(phoneNo.intern())</code>：<code>intern()</code> 会将字符串放入常量池，大量手机号会导致常量池膨胀，且 intern 本身有锁竞争。</p></blockquote><hr><h3 id="方案三：Redis-原子操作（分布式-生产必选）"><a href="#方案三：Redis-原子操作（分布式-生产必选）" class="headerlink" title="方案三：Redis 原子操作（分布式&#x2F;生产必选）"></a>方案三：Redis 原子操作（分布式&#x2F;生产必选）</h3><p><strong>单机方案的根本缺陷</strong>：多实例部署时每台机器独立计数，无法跨实例限流。</p><h4 id="3-1-Redis-数据结构设计"><a href="#3-1-Redis-数据结构设计" class="headerlink" title="3.1 Redis 数据结构设计"></a>3.1 Redis 数据结构设计</h4><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">sms:last:&#123;phoneNo&#125;       →  String，值为最后发送时间戳，TTL=60s</span><br><span class="line">sms:count:&#123;phoneNo&#125;:&#123;date&#125; →  String，值为当日发送次数，TTL到当天结束</span><br></pre></td></tr></table></figure><h4 id="3-2-Lua-脚本（保证原子性）"><a href="#3-2-Lua-脚本（保证原子性）" class="headerlink" title="3.2 Lua 脚本（保证原子性）"></a>3.2 Lua 脚本（保证原子性）</h4><p>Redis 单线程执行 Lua，脚本内的多步操作等价于原子事务：</p><figure class="highlight lua"><table><tr><td class="code"><pre><span class="line"><span class="comment">-- KEYS[1] = sms:last:&#123;phoneNo&#125;</span></span><br><span class="line"><span class="comment">-- KEYS[2] = sms:count:&#123;phoneNo&#125;:&#123;today&#125;</span></span><br><span class="line"><span class="comment">-- ARGV[1] = 当前时间戳(ms)</span></span><br><span class="line"><span class="comment">-- ARGV[2] = 今日结束时间戳(s)，用于 EXPIREAT</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">local</span> now = <span class="built_in">tonumber</span>(ARGV[<span class="number">1</span>])</span><br><span class="line"><span class="keyword">local</span> expireAt = <span class="built_in">tonumber</span>(ARGV[<span class="number">2</span>])</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 检查 60s 间隔</span></span><br><span class="line"><span class="keyword">local</span> lastTime = <span class="built_in">tonumber</span>(redis.call(<span class="string">&#x27;GET&#x27;</span>, KEYS[<span class="number">1</span>]) <span class="keyword">or</span> <span class="number">0</span>)</span><br><span class="line"><span class="keyword">if</span> now - lastTime &lt; <span class="number">60000</span> <span class="keyword">then</span></span><br><span class="line">    <span class="keyword">return</span> &#123;<span class="number">0</span>, <span class="string">&quot;rate_limit_60s&quot;</span>&#125;</span><br><span class="line"><span class="keyword">end</span></span><br><span class="line"></span><br><span class="line"><span class="comment">-- 检查日计数</span></span><br><span class="line"><span class="keyword">local</span> count = <span class="built_in">tonumber</span>(redis.call(<span class="string">&#x27;GET&#x27;</span>, KEYS[<span class="number">2</span>]) <span class="keyword">or</span> <span class="number">0</span>)</span><br><span class="line"><span class="keyword">if</span> count &gt;= <span class="number">10</span> <span class="keyword">then</span></span><br><span class="line">    <span class="keyword">return</span> &#123;<span class="number">0</span>, <span class="string">&quot;rate_limit_daily&quot;</span>&#125;</span><br><span class="line"><span class="keyword">end</span></span><br><span class="line"></span><br><span class="line"><span class="comment">-- 原子更新</span></span><br><span class="line">redis.call(<span class="string">&#x27;SET&#x27;</span>, KEYS[<span class="number">1</span>], now, <span class="string">&#x27;PX&#x27;</span>, <span class="number">60000</span>)</span><br><span class="line"><span class="keyword">local</span> newCount = redis.call(<span class="string">&#x27;INCR&#x27;</span>, KEYS[<span class="number">2</span>])</span><br><span class="line"><span class="keyword">if</span> newCount == <span class="number">1</span> <span class="keyword">then</span></span><br><span class="line">    redis.call(<span class="string">&#x27;EXPIREAT&#x27;</span>, KEYS[<span class="number">2</span>], expireAt)</span><br><span class="line"><span class="keyword">end</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">return</span> &#123;<span class="number">1</span>, <span class="string">&quot;ok&quot;</span>&#125;</span><br></pre></td></tr></table></figure><h4 id="3-3-Java-调用"><a href="#3-3-Java-调用" class="headerlink" title="3.3 Java 调用"></a>3.3 Java 调用</h4><figure class="highlight java"><table><tr><td class="code"><pre><span class="line"><span class="keyword">import</span> org.springframework.data.redis.core.RedisTemplate;</span><br><span class="line"><span class="keyword">import</span> org.springframework.data.redis.core.script.DefaultRedisScript;</span><br><span class="line"><span class="keyword">import</span> java.time.*;</span><br><span class="line"></span><br><span class="line"><span class="meta">@Service</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">class</span> <span class="title class_">SendMessage</span> &#123;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Autowired</span></span><br><span class="line">    <span class="keyword">private</span> RedisTemplate&lt;String, String&gt; redisTemplate;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="type">String</span> <span class="variable">LUA_SCRIPT</span> <span class="operator">=</span></span><br><span class="line">        <span class="string">&quot;local now = tonumber(ARGV[1])\n&quot;</span> +</span><br><span class="line">        <span class="string">&quot;local expireAt = tonumber(ARGV[2])\n&quot;</span> +</span><br><span class="line">        <span class="string">&quot;local lastTime = tonumber(redis.call(&#x27;GET&#x27;, KEYS[1]) or 0)\n&quot;</span> +</span><br><span class="line">        <span class="string">&quot;if now - lastTime &lt; 60000 then return &#123;0, &#x27;rate_limit_60s&#x27;&#125; end\n&quot;</span> +</span><br><span class="line">        <span class="string">&quot;local count = tonumber(redis.call(&#x27;GET&#x27;, KEYS[2]) or 0)\n&quot;</span> +</span><br><span class="line">        <span class="string">&quot;if count &gt;= 10 then return &#123;0, &#x27;rate_limit_daily&#x27;&#125; end\n&quot;</span> +</span><br><span class="line">        <span class="string">&quot;redis.call(&#x27;SET&#x27;, KEYS[1], now, &#x27;PX&#x27;, 60000)\n&quot;</span> +</span><br><span class="line">        <span class="string">&quot;local newCount = redis.call(&#x27;INCR&#x27;, KEYS[2])\n&quot;</span> +</span><br><span class="line">        <span class="string">&quot;if newCount == 1 then redis.call(&#x27;EXPIREAT&#x27;, KEYS[2], expireAt) end\n&quot;</span> +</span><br><span class="line">        <span class="string">&quot;return &#123;1, &#x27;ok&#x27;&#125;&quot;</span>;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> DefaultRedisScript&lt;List&gt; SCRIPT =</span><br><span class="line">        <span class="keyword">new</span> <span class="title class_">DefaultRedisScript</span>&lt;&gt;(LUA_SCRIPT, List.class);</span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> <span class="type">boolean</span> <span class="title function_">sendMessage</span><span class="params">(String phoneNo, String message)</span> &#123;</span><br><span class="line">        <span class="keyword">if</span> (phoneNo == <span class="literal">null</span> || message == <span class="literal">null</span>) <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line"></span><br><span class="line">        <span class="type">ZoneId</span> <span class="variable">zone</span> <span class="operator">=</span> ZoneId.of(<span class="string">&quot;Asia/Shanghai&quot;</span>);</span><br><span class="line">        <span class="type">LocalDate</span> <span class="variable">today</span> <span class="operator">=</span> LocalDate.now(zone);</span><br><span class="line">        <span class="type">long</span> <span class="variable">now</span> <span class="operator">=</span> System.currentTimeMillis();</span><br><span class="line">        <span class="comment">// 今日 23:59:59 的 Unix 时间戳（秒）</span></span><br><span class="line">        <span class="type">long</span> <span class="variable">expireAt</span> <span class="operator">=</span> today.atTime(LocalTime.MAX).atZone(zone).toEpochSecond();</span><br><span class="line"></span><br><span class="line">        <span class="type">String</span> <span class="variable">lastKey</span>  <span class="operator">=</span> <span class="string">&quot;sms:last:&quot;</span> + phoneNo;</span><br><span class="line">        <span class="type">String</span> <span class="variable">countKey</span> <span class="operator">=</span> <span class="string">&quot;sms:count:&quot;</span> + phoneNo + <span class="string">&quot;:&quot;</span> + today;</span><br><span class="line"></span><br><span class="line">        <span class="type">List</span> <span class="variable">result</span> <span class="operator">=</span> redisTemplate.execute(SCRIPT,</span><br><span class="line">            Arrays.asList(lastKey, countKey),</span><br><span class="line">            String.valueOf(now),</span><br><span class="line">            String.valueOf(expireAt));</span><br><span class="line"></span><br><span class="line">        <span class="keyword">if</span> (result == <span class="literal">null</span> || ((Number) result.get(<span class="number">0</span>)).intValue() == <span class="number">0</span>) &#123;</span><br><span class="line">            <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="keyword">try</span> &#123;</span><br><span class="line">            doSend(phoneNo, message);</span><br><span class="line">            <span class="keyword">return</span> <span class="literal">true</span>;</span><br><span class="line">        &#125; <span class="keyword">catch</span> (Exception e) &#123;</span><br><span class="line">            <span class="comment">// 【条件回滚】（用 Lua 保证原子 + 条件判断，避免误清他人状态）</span></span><br><span class="line">            <span class="comment">// 只在 lastKey 的值仍等于本次预占时间戳时才撤销，详见下方警告。</span></span><br><span class="line">            <span class="type">String</span> <span class="variable">rollbackScript</span> <span class="operator">=</span></span><br><span class="line">                <span class="string">&quot;if redis.call(&#x27;GET&#x27;, KEYS[1]) == ARGV[1] then\n&quot;</span> +  <span class="comment">// lastKey 仍是我的时间戳？</span></span><br><span class="line">                <span class="string">&quot;    redis.call(&#x27;DEL&#x27;, KEYS[1])\n&quot;</span> +                  <span class="comment">// 才删 lastKey</span></span><br><span class="line">                <span class="string">&quot;    redis.call(&#x27;DECR&#x27;, KEYS[2])\n&quot;</span> +                 <span class="comment">// 才减日计数</span></span><br><span class="line">                <span class="string">&quot;    return 1\n&quot;</span> +</span><br><span class="line">                <span class="string">&quot;end\n&quot;</span> +</span><br><span class="line">                <span class="string">&quot;return 0&quot;</span>;                                           <span class="comment">// 否则不回滚</span></span><br><span class="line">            DefaultRedisScript&lt;Long&gt; rollback = <span class="keyword">new</span> <span class="title class_">DefaultRedisScript</span>&lt;&gt;(rollbackScript, Long.class);</span><br><span class="line">            redisTemplate.execute(rollback,</span><br><span class="line">                Arrays.asList(lastKey, countKey),</span><br><span class="line">                String.valueOf(now));</span><br><span class="line">            <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">void</span> <span class="title function_">doSend</span><span class="params">(String phoneNo, String message)</span> &#123;</span><br><span class="line">        <span class="comment">// 调用短信服务商 SDK</span></span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><blockquote><h3 id="⚠️-Redis-回滚同样有竞态（与方案一同源）"><a href="#⚠️-Redis-回滚同样有竞态（与方案一同源）" class="headerlink" title="⚠️ Redis 回滚同样有竞态（与方案一同源）"></a>⚠️ Redis 回滚同样有竞态（与方案一同源）</h3><p>很多人写 Redis 方案的回滚会直接这样：</p><figure class="highlight java"><table><tr><td class="code"><pre><span class="line"><span class="comment">// ❌ 错误写法：delete + decrement 两步，既非原子也不判断</span></span><br><span class="line">redisTemplate.delete(lastKey);</span><br><span class="line">redisTemplate.opsForValue().decrement(countKey);</span><br></pre></td></tr></table></figure><p><strong>两个问题</strong>：</p><ol><li><strong>非原子</strong>：<code>delete</code> 和 <code>decrement</code> 是两条命令，中间别的线程可以插入。</li><li><strong>无条件</strong>：<code>decrement</code> 减的是”当前值”而非”我那次加的值”。</li></ol><p>竞态场景：T1 预占 lastKey&#x3D;t1；T1 发送卡住；60s 后 T2 合法预占 lastKey&#x3D;t2；T1 失败回滚 <code>delete(lastKey)</code> → 把 T2 合法的 t2 也删了 → 60s 限制被破坏（与方案一的危害完全一致）。</p><p><strong>修复</strong>：把回滚逻辑也写进 <strong>Lua 脚本</strong>，Redis 单线程执行 Lua 保证原子，并在脚本内加条件判断（<code>lastKey</code> 值仍等于本次预占时间戳才撤销）——即上面代码中的 <code>rollbackScript</code>。这同样是一种 CAS：只有”状态没被他人覆盖”时才回滚。</p><p><strong>生产推荐</strong>：与方案一一致，更简单的做法是<strong>失败不回滚</strong>（保留 60s 冷却），既消除竞态又防刷量。只有业务强要求”失败立即可重试”时才用条件回滚。</p></blockquote><hr><h2 id="四、内存泄漏问题"><a href="#四、内存泄漏问题" class="headerlink" title="四、内存泄漏问题"></a>四、内存泄漏问题</h2><p>单机方案中 stateMap 长期运行会积累大量手机号 entry，需定期清理：</p><figure class="highlight java"><table><tr><td class="code"><pre><span class="line"><span class="comment">// 方式一：Caffeine 缓存（推荐）</span></span><br><span class="line"><span class="comment">// 依赖：com.github.ben-manes.caffeine:caffeine</span></span><br><span class="line"><span class="keyword">private</span> <span class="keyword">final</span> Cache&lt;String, PhoneState&gt; stateMap = Caffeine.newBuilder()</span><br><span class="line">    .expireAfterAccess(<span class="number">25</span>, TimeUnit.HOURS)  <span class="comment">// 超过1天未访问自动淘汰</span></span><br><span class="line">    .maximumSize(<span class="number">100_000</span>)                   <span class="comment">// 最多缓存10万个号码</span></span><br><span class="line">    .build();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 方式二：定时清理过期 key</span></span><br><span class="line"><span class="type">ScheduledExecutorService</span> <span class="variable">scheduler</span> <span class="operator">=</span> Executors.newSingleThreadScheduledExecutor();</span><br><span class="line">scheduler.scheduleAtFixedRate(() -&gt; &#123;</span><br><span class="line">    <span class="type">String</span> <span class="variable">yesterday</span> <span class="operator">=</span> LocalDate.now().minusDays(<span class="number">1</span>).toString();</span><br><span class="line">    stateMap.entrySet().removeIf(e -&gt;</span><br><span class="line">        e.getValue().lastSendDate.compareTo(yesterday) &lt; <span class="number">0</span>);</span><br><span class="line">&#125;, <span class="number">1</span>, <span class="number">1</span>, TimeUnit.HOURS);</span><br></pre></td></tr></table></figure><p>Redis 方案中 TTL 自动过期，无需额外处理。</p><hr><h2 id="五、方案对比"><a href="#五、方案对比" class="headerlink" title="五、方案对比"></a>五、方案对比</h2><table><thead><tr><th>维度</th><th>compute 方案</th><th>Striped Lock</th><th>Redis Lua</th></tr></thead><tbody><tr><td>并发安全</td><td>✅</td><td>✅</td><td>✅</td></tr><tr><td>多实例支持</td><td>❌</td><td>❌</td><td>✅</td></tr><tr><td>内存泄漏</td><td>需手动清理</td><td>需手动清理</td><td>TTL 自动过期</td></tr><tr><td>实现复杂度</td><td>低</td><td>低</td><td>中</td></tr><tr><td>外部依赖</td><td>无</td><td>Guava</td><td>Redis</td></tr><tr><td>适用场景</td><td>单机&#x2F;测试</td><td>单机&#x2F;逻辑复杂</td><td><strong>生产环境</strong></td></tr></tbody></table><hr><h2 id="六、生产级系统整体架构"><a href="#六、生产级系统整体架构" class="headerlink" title="六、生产级系统整体架构"></a>六、生产级系统整体架构</h2><p>前面解决的是”单接口限流”，但一个真实的短信系统远不止于此。</p><h3 id="6-1-整体架构图"><a href="#6-1-整体架构图" class="headerlink" title="6.1 整体架构图"></a>6.1 整体架构图</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">┌──────────────┐   ┌─────────────────────────────────────────────────────┐   ┌──────────────┐</span><br><span class="line">│ 业务系统      │   │                    短信服务                          │   │  短信通道     │</span><br><span class="line">│ (下单/注册/  │──►│                                                      │   │              │</span><br><span class="line">│  营销/通知)  │   │  ┌─────────┐  限流   ┌─────────┐  MQ   ┌─────────┐  │   │ 阿里云短信   │</span><br><span class="line">└──────────────┘   │  │  API    │ ──────► │ 发送    │ ────► │ 消费者  │──┼──►│ 腾讯云短信   │</span><br><span class="line">                   │  │  网关   │         │  校验   │       │ Worker │  │   │ 华为云短信   │</span><br><span class="line">                   │  └─────────┘         └─────────┘       └─────────┘  │   │ 容联云/梦网  │</span><br><span class="line">                   │       │                  │                 │        │   └──────────────┘</span><br><span class="line">                   │       │                  │                 │        │</span><br><span class="line">                   │  ┌────▼──────┐  ┌────────▼─────┐  ┌────────▼──────┐ │</span><br><span class="line">                   │  │ Redis     │  │ 黑名单/签名   │  │ 回执/状态报告 │ │</span><br><span class="line">                   │  │ 限流计数  │  │ 模板/风控     │  │ 回调处理      │ │</span><br><span class="line">                   │  └───────────┘  └──────────────┘  └───────────────┘ │</span><br><span class="line">                   │                                                      │</span><br><span class="line">                   │  ┌───────────────────────────────────────────────┐  │</span><br><span class="line">                   │  │  监控：发送量 / 成功率 / 限流率 / 延迟 / 成本   │  │</span><br><span class="line">                   │  └───────────────────────────────────────────────┘  │</span><br><span class="line">                   └─────────────────────────────────────────────────────┘</span><br></pre></td></tr></table></figure><h3 id="6-2-核心组件职责"><a href="#6-2-核心组件职责" class="headerlink" title="6.2 核心组件职责"></a>6.2 核心组件职责</h3><table><thead><tr><th>组件</th><th>职责</th><th>关键点</th></tr></thead><tbody><tr><td><strong>API 网关</strong></td><td>接收发送请求、鉴权、入参校验</td><td>限流前置、防刷</td></tr><tr><td><strong>限流服务</strong></td><td>60s &#x2F; 日 10 条约束</td><td>Redis Lua 原子操作（见方案三）</td></tr><tr><td><strong>MQ</strong></td><td>削峰填谷、异步解耦</td><td>业务快速返回，发送异步进行</td></tr><tr><td><strong>消费者 Worker</strong></td><td>拉取消息、调用通道</td><td>幂等消费、失败重试</td></tr><tr><td><strong>通道路由</strong></td><td>选择通道、故障转移</td><td>多通道、负载均衡、熔断</td></tr><tr><td><strong>回执处理</strong></td><td>接收运营商送达状态</td><td>补全最终状态、触发重试</td></tr><tr><td><strong>风控&#x2F;黑名单</strong></td><td>防刷、防恶意</td><td>频次&#x2F;内容&#x2F;黑名单</td></tr><tr><td><strong>监控</strong></td><td>黄金四信号 + 成本</td><td>见第十二节</td></tr></tbody></table><h3 id="6-3-同步-vs-异步"><a href="#6-3-同步-vs-异步" class="headerlink" title="6.3 同步 vs 异步"></a>6.3 同步 vs 异步</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">同步发送（简单业务、低 QPS）：</span><br><span class="line">  业务 → 限流 → [阻塞] 调通道 → 拿到回执 → 返回</span><br><span class="line">  缺点：通道慢则业务卡住；通道抖动影响业务可用性</span><br><span class="line"></span><br><span class="line">异步发送（推荐，生产标配）：</span><br><span class="line">  业务 → 限流 → 写 MQ → 立即返回&quot;已受理&quot;</span><br><span class="line">                │</span><br><span class="line">                ▼</span><br><span class="line">        Worker 消费 → 调通道 → 记录结果 → 回执回调更新</span><br><span class="line">  优点：业务不阻塞；可削峰；通道故障不影响业务；可重试</span><br></pre></td></tr></table></figure><hr><h2 id="七、异步化与削峰（MQ）"><a href="#七、异步化与削峰（MQ）" class="headerlink" title="七、异步化与削峰（MQ）"></a>七、异步化与削峰（MQ）</h2><h3 id="7-1-为什么要-MQ"><a href="#7-1-为什么要-MQ" class="headerlink" title="7.1 为什么要 MQ"></a>7.1 为什么要 MQ</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">大促/营销场景：瞬间几十万条短信请求</span><br><span class="line">  无 MQ：同步调通道 → 通道限流 → 业务超时堆积 → 雪崩</span><br><span class="line">  有 MQ：请求先入队 → Worker 按通道能力匀速消费 → 平滑发送</span><br><span class="line"></span><br><span class="line">MQ 的作用：</span><br><span class="line">  ① 削峰填谷：瞬时洪峰进队列，消费端按节奏处理</span><br><span class="line">  ② 解耦：业务方只管&quot;投递&quot;，不关心通道细节</span><br><span class="line">  ③ 异步：业务快速响应，不等通道返回</span><br><span class="line">  ④ 缓冲重试：消费失败可重新入队/进重试队列</span><br></pre></td></tr></table></figure><h3 id="7-2-消息设计"><a href="#7-2-消息设计" class="headerlink" title="7.2 消息设计"></a>7.2 消息设计</h3><figure class="highlight json"><table><tr><td class="code"><pre><span class="line"><span class="comment">// 投递到 MQ 的消息</span></span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;msgId&quot;</span><span class="punctuation">:</span> <span class="string">&quot;sms-uuid-唯一&quot;</span><span class="punctuation">,</span>      <span class="comment">// 用于幂等</span></span><br><span class="line">  <span class="attr">&quot;phoneNo&quot;</span><span class="punctuation">:</span> <span class="string">&quot;13800138000&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;templateCode&quot;</span><span class="punctuation">:</span> <span class="string">&quot;SMS_LOGIN&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;params&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span><span class="attr">&quot;code&quot;</span><span class="punctuation">:</span> <span class="string">&quot;123456&quot;</span><span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;bizType&quot;</span><span class="punctuation">:</span> <span class="string">&quot;LOGIN&quot;</span><span class="punctuation">,</span>            <span class="comment">// 业务类型，影响通道/优先级</span></span><br><span class="line">  <span class="attr">&quot;timestamp&quot;</span><span class="punctuation">:</span> <span class="number">1718000000000</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;traceId&quot;</span><span class="punctuation">:</span> <span class="string">&quot;xxx&quot;</span>               <span class="comment">// 链路追踪</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><h3 id="7-3-消费幂等（防重复发送）"><a href="#7-3-消费幂等（防重复发送）" class="headerlink" title="7.3 消费幂等（防重复发送）"></a>7.3 消费幂等（防重复发送）</h3><p><strong>问题</strong>：MQ 保证 at-least-once（至少一次），消费者可能收到重复消息 → 用户收到两条。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">幂等方案：msgId 去重</span><br><span class="line">  消费前：SETNX sms:msgid:&#123;msgId&#125; 1 EX 86400</span><br><span class="line">    ├─ 返回 1（首次）→ 继续发送</span><br><span class="line">    └─ 返回 0（重复）→ 直接 ACK 丢弃，不再发送</span><br><span class="line"></span><br><span class="line">注意幂等窗口：&gt;= 短信允许的最大重试周期（如 24h）</span><br></pre></td></tr></table></figure><figure class="highlight java"><table><tr><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">consume</span><span class="params">(SmsMessage msg)</span> &#123;</span><br><span class="line">    <span class="type">String</span> <span class="variable">idKey</span> <span class="operator">=</span> <span class="string">&quot;sms:msgid:&quot;</span> + msg.getMsgId();</span><br><span class="line">    <span class="comment">// 原子占位：只有首个消费者能成功</span></span><br><span class="line">    <span class="type">Boolean</span> <span class="variable">first</span> <span class="operator">=</span> redisTemplate.opsForValue()</span><br><span class="line">        .setIfAbsent(idKey, <span class="string">&quot;1&quot;</span>, Duration.ofHours(<span class="number">24</span>));</span><br><span class="line">    <span class="keyword">if</span> (Boolean.FALSE.equals(first)) &#123;</span><br><span class="line">        <span class="keyword">return</span>;  <span class="comment">// 重复消息，幂等丢弃</span></span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">try</span> &#123;</span><br><span class="line">        sendViaChannel(msg);</span><br><span class="line">    &#125; <span class="keyword">catch</span> (Exception e) &#123;</span><br><span class="line">        redisTemplate.delete(idKey);  <span class="comment">// 失败则释放，允许重试</span></span><br><span class="line">        <span class="keyword">throw</span> e;  <span class="comment">// 抛出让 MQ 重投</span></span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="7-4-消费速率控制"><a href="#7-4-消费速率控制" class="headerlink" title="7.4 消费速率控制"></a>7.4 消费速率控制</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">Worker 并发数 = 通道允许的 QPS / 单 Worker 处理速率</span><br><span class="line">  例：通道允许 1000 QPS，单 Worker 处理 100 QPS → 起步 10 个 Worker</span><br><span class="line"></span><br><span class="line">弹性扩缩容：MQ 堆积量超阈值 → 自动扩 Worker</span><br><span class="line">  → 避免堆积过多导致短信严重延迟</span><br></pre></td></tr></table></figure><hr><h2 id="八、幂等性设计"><a href="#八、幂等性设计" class="headerlink" title="八、幂等性设计"></a>八、幂等性设计</h2><p>除了 MQ 消费幂等，整个链路有多处需要幂等，否则会出现重复发送。</p><h3 id="8-1-重复发送的来源"><a href="#8-1-重复发送的来源" class="headerlink" title="8.1 重复发送的来源"></a>8.1 重复发送的来源</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">┌────────────────────────────────────────────────────────────┐</span><br><span class="line">│                    重复发送的几种来源                        │</span><br><span class="line">├────────────────────────────────────────────────────────────┤</span><br><span class="line">│  ① MQ 重投递   消费超时/失败，MQ 重新投递同一消息            │</span><br><span class="line">│  ② 用户重试    用户点&quot;获取验证码&quot;连点多次                    │</span><br><span class="line">│  ③ 网络重试    调通道超时，重试时通道其实已收到              │</span><br><span class="line">│  ④ 主从切换    Redis 主从切换瞬间，限流计数未同步            │</span><br><span class="line">│  ⑤ 回执延迟    通道已发但回执未到，补偿任务误以为失败重发    │</span><br><span class="line">└────────────────────────────────────────────────────────────┘</span><br></pre></td></tr></table></figure><h3 id="8-2-分层幂等"><a href="#8-2-分层幂等" class="headerlink" title="8.2 分层幂等"></a>8.2 分层幂等</h3><table><thead><tr><th>层次</th><th>幂等键</th><th>实现</th></tr></thead><tbody><tr><td>业务请求</td><td>业务幂等号（如 <code>bizId+phone</code>）</td><td>业务层去重</td></tr><tr><td>MQ 消费</td><td><code>msgId</code></td><td>Redis SETNX 去重（见 7.3）</td></tr><tr><td>通道调用</td><td>短信平台 <code>outId</code></td><td>平台侧去重（提交时带唯一 outId）</td></tr></tbody></table><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">最可靠：通道调用带上唯一 outId，短信平台对相同 outId 只发一次</span><br><span class="line">  → 即使我方重试，平台也会识别为重复而拒发</span><br></pre></td></tr></table></figure><h3 id="8-3-幂等与限流的协同"><a href="#8-3-幂等与限流的协同" class="headerlink" title="8.3 幂等与限流的协同"></a>8.3 幂等与限流的协同</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">限流（60s/日10条）：面向&quot;用户行为&quot;，防止恶意刷</span><br><span class="line">幂等（msgId/outId）：面向&quot;系统重复&quot;，防止技术故障导致重发</span><br><span class="line"></span><br><span class="line">二者互补：限流挡不住 MQ 重投递，幂等挡不住用户连点。</span><br></pre></td></tr></table></figure><hr><h2 id="九、多通道路由与故障转移"><a href="#九、多通道路由与故障转移" class="headerlink" title="九、多通道路由与故障转移"></a>九、多通道路由与故障转移</h2><h3 id="9-1-为什么要多通道"><a href="#9-1-为什么要多通道" class="headerlink" title="9.1 为什么要多通道"></a>9.1 为什么要多通道</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">单通道的风险：</span><br><span class="line">  ① 通道故障/限流 → 短信全部发不出</span><br><span class="line">  ② 通道运营商跑路/调价 → 被绑定</span><br><span class="line">  ③ 通道被监管关停 → 业务中断</span><br><span class="line">  ④ 单通道并发上限低 → 撑不住大促</span><br><span class="line"></span><br><span class="line">→ 必须接入多个通道（阿里云、腾讯云、华为云、梦网、容联云等）</span><br></pre></td></tr></table></figure><h3 id="9-2-路由策略"><a href="#9-2-路由策略" class="headerlink" title="9.2 路由策略"></a>9.2 路由策略</h3><table><thead><tr><th>策略</th><th>说明</th><th>适用</th></tr></thead><tbody><tr><td><strong>主备</strong></td><td>优先主通道，主故障切备</td><td>简单，但主通道平时闲置浪费</td></tr><tr><td><strong>权重轮询</strong></td><td>按权重分配流量到各通道</td><td>平衡负载、压测新通道</td></tr><tr><td><strong>按类型分流</strong></td><td>验证码走 A、营销走 B</td><td>不同通道擅长的场景不同</td></tr><tr><td><strong>按地域&#x2F;运营商</strong></td><td>移动号走移动通道</td><td>提升到达率、降成本</td></tr><tr><td><strong>最低成本优先</strong></td><td>同等质量选最便宜</td><td>降本</td></tr></tbody></table><h3 id="9-3-故障转移（Failover）"><a href="#9-3-故障转移（Failover）" class="headerlink" title="9.3 故障转移（Failover）"></a>9.3 故障转移（Failover）</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">通道调用失败时的转移流程：</span><br><span class="line"></span><br><span class="line">  调通道 A → 失败（超时/返回错误/熔断器打开）</span><br><span class="line">    │</span><br><span class="line">    ├─ 是否可重试错误？</span><br><span class="line">    │    ├─ 否（号码空号/内容违规）→ 直接标记失败，不转移</span><br><span class="line">    │    └─ 是（超时/限流/网络）→ 切换到通道 B 重试</span><br><span class="line">    │</span><br><span class="line">    └─ 记录通道 A 故障次数 → 触发熔断 → 后续请求跳过 A</span><br><span class="line"></span><br><span class="line">熔断保护通道：</span><br><span class="line">  对每个通道维护熔断器（见 「熔断详解」）</span><br><span class="line">  A 连续失败 → 熔断 A → 流量自动切到 B/C → A 恢复后探测放回</span><br></pre></td></tr></table></figure><h3 id="9-4-通道质量评分"><a href="#9-4-通道质量评分" class="headerlink" title="9.4 通道质量评分"></a>9.4 通道质量评分</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">动态评估每个通道的质量，影响路由权重：</span><br><span class="line"></span><br><span class="line">  到达率   = 成功送达数 / 提交成功数      （最重要）</span><br><span class="line">  延迟     = 提交到送达的平均耗时</span><br><span class="line">  成本     = 单条价格</span><br><span class="line">  限流率   = 被通道限流的比例</span><br><span class="line"></span><br><span class="line">质量评分 = f(到达率, 延迟, 成本, 限流率)</span><br><span class="line">权重随评分动态调整 → 差的通道自动降权，好的通道多分配</span><br></pre></td></tr></table></figure><hr><h2 id="十、重试与补偿机制"><a href="#十、重试与补偿机制" class="headerlink" title="十、重试与补偿机制"></a>十、重试与补偿机制</h2><h3 id="10-1-重试策略"><a href="#10-1-重试策略" class="headerlink" title="10.1 重试策略"></a>10.1 重试策略</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">重试要解决&quot;瞬时故障&quot;（网络抖动、通道限流），但不能放大问题。</span><br><span class="line"></span><br><span class="line">  ① 区分错误类型：</span><br><span class="line">     可重试：超时、5xx、通道限流（429）</span><br><span class="line">     不重试：号码格式错误、内容违规、余额不足</span><br><span class="line"></span><br><span class="line">  ② 退避策略：指数退避 + 抖动，避免恢复瞬间被重试打挂</span><br><span class="line">     第1次：1s 后</span><br><span class="line">     第2次：2s 后</span><br><span class="line">     第3次：4s 后</span><br><span class="line">     第3次：8s 后</span><br><span class="line">     +随机抖动 ±20%</span><br><span class="line"></span><br><span class="line">  ③ 重试上限：3 次，超过进死信队列人工介入</span><br></pre></td></tr></table></figure><h3 id="10-2-死信队列（DLQ）"><a href="#10-2-死信队列（DLQ）" class="headerlink" title="10.2 死信队列（DLQ）"></a>10.2 死信队列（DLQ）</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">重试耗尽的消息进入 DLQ：</span><br><span class="line">  - 原因：多次重试失败 / 消息本身有毒（格式错误等）</span><br><span class="line">  - 处理：告警 + 人工排查 + 补发或丢弃</span><br><span class="line">  - 监控：DLQ 堆积量是核心告警指标</span><br></pre></td></tr></table></figure><h3 id="10-3-回执补偿"><a href="#10-3-回执补偿" class="headerlink" title="10.3 回执补偿"></a>10.3 回执补偿</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">问题：通道&quot;提交成功&quot;≠&quot;送达用户&quot;。可能：</span><br><span class="line">  - 提交成功但实际未送达（号码停机/被拦截）</span><br><span class="line">  - 提交成功但回执丢失</span><br><span class="line"></span><br><span class="line">补偿机制：</span><br><span class="line">  ① 短期：依赖通道异步回执（运营商→通道→我方回调）</span><br><span class="line">  ② 兜底：定时任务查询&quot;提交成功但无回执且超时&quot;的消息</span><br><span class="line">            → 主动查询通道接口补全状态</span><br><span class="line">  ③ 超时：超过 N 分钟仍无回执 → 标记&quot;状态未知&quot;，不重发</span><br><span class="line">            （避免幂等失效导致的重发）</span><br></pre></td></tr></table></figure><hr><h2 id="十一、安全与合规"><a href="#十一、安全与合规" class="headerlink" title="十一、安全与合规"></a>十一、安全与合规</h2><p>短信涉及用户隐私和资费，合规要求严格（尤其国内有《通信短消息服务规定》等法规）。</p><h3 id="11-1-防刷与风控"><a href="#11-1-防刷与风控" class="headerlink" title="11.1 防刷与风控"></a>11.1 防刷与风控</h3><table><thead><tr><th>手段</th><th>说明</th></tr></thead><tbody><tr><td><strong>频次限制</strong></td><td>60s&#x2F;日10条（本题核心）+ 业务层更细（如同一 IP 限频）</td></tr><tr><td><strong>IP 限频</strong></td><td>同一 IP 短时间大量请求不同号码 → 疑似撞库&#x2F;轰炸</td></tr><tr><td><strong>图形&#x2F;滑块验证码</strong></td><td>触发风控后要求人机校验</td></tr><tr><td><strong>设备指纹</strong></td><td>同设备频繁换号 → 可疑</td></tr><tr><td><strong>内容风控</strong></td><td>敏感词过滤、变量内容审核（防止营销内容违规）</td></tr></tbody></table><h3 id="11-2-黑名单管理"><a href="#11-2-黑名单管理" class="headerlink" title="11.2 黑名单管理"></a>11.2 黑名单管理</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">三类黑名单：</span><br><span class="line">  ① 平台黑名单    用户主动退订的号码（法规要求，必须支持）</span><br><span class="line">  ② 投诉黑名单    多次投诉/举报的号码</span><br><span class="line">  ③ 运营商黑名单  通道侧返回的黑名单号码</span><br><span class="line"></span><br><span class="line">发送前统一查黑名单，命中则拒绝（不计费、不发）。</span><br></pre></td></tr></table></figure><h3 id="11-3-签名与模板"><a href="#11-3-签名与模板" class="headerlink" title="11.3 签名与模板"></a>11.3 签名与模板</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">国内短信强制要求：</span><br><span class="line">  ① 签名：【公司名】或【产品名】，需在通道侧报备审核</span><br><span class="line">  ② 模板：内容必须用审核通过的模板，变量占位</span><br><span class="line">     例：【XX商城】您的验证码是$&#123;code&#125;，5分钟内有效。</span><br><span class="line"></span><br><span class="line">  ✗ 不能任意发文本内容（会被通道拒绝/封号）</span><br><span class="line">  ✓ 业务方只能选模板 + 填变量</span><br></pre></td></tr></table></figure><h3 id="11-4-营销短信合规"><a href="#11-4-营销短信合规" class="headerlink" title="11.4 营销短信合规"></a>11.4 营销短信合规</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">法规红线（国内）：</span><br><span class="line">  ① 必须用户明确授权同意接收营销短信</span><br><span class="line">  ② 必须提供退订方式（回 T 退订）</span><br><span class="line">  ③ 发送时间段限制（通常 8:00-21:00，避免扰民）</span><br><span class="line">  ④ 退订用户 24h 内不能再发</span><br><span class="line"></span><br><span class="line">→ 系统需支持退订指令处理（回 T、回 TD 等）</span><br></pre></td></tr></table></figure><h3 id="11-5-数据脱敏"><a href="#11-5-数据脱敏" class="headerlink" title="11.5 数据脱敏"></a>11.5 数据脱敏</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">手机号是个人隐私信息，全链路需脱敏：</span><br><span class="line">  日志：13800138000 → 138****8000</span><br><span class="line">  监控/报表：不展示完整号码</span><br><span class="line">  存储：明细表中号段哈希化处理</span><br></pre></td></tr></table></figure><hr><h2 id="十二、监控与告警"><a href="#十二、监控与告警" class="headerlink" title="十二、监控与告警"></a>十二、监控与告警</h2><p>短信系统是最容易被业务感知的系统之一（用户收不到验证码 &#x3D; 登录不了 &#x3D; 流失），监控必须到位。落地<strong>黄金四信号</strong>（见 「后端服务稳定性建设总览 3 5 可观测性（发现故障的前提）」）：</p><h3 id="12-1-核心监控指标"><a href="#12-1-核心监控指标" class="headerlink" title="12.1 核心监控指标"></a>12.1 核心监控指标</h3><table><thead><tr><th>信号</th><th>短信系统的具体指标</th></tr></thead><tbody><tr><td><strong>延迟 Latency</strong></td><td>接口响应 P99；从入队到发出的端到端延迟；验证码类要求 &lt; 10s</td></tr><tr><td><strong>流量 Traffic</strong></td><td>提交 QPS；分业务类型（验证码&#x2F;通知&#x2F;营销）；MQ 堆积量</td></tr><tr><td><strong>错误 Errors</strong></td><td>限流拒绝率；发送失败率；通道错误率；DLQ 堆积量</td></tr><tr><td><strong>饱和度 Saturation</strong></td><td>Worker 消费滞后；Redis 连接数；通道并发使用率</td></tr></tbody></table><h3 id="12-2-业务特有指标"><a href="#12-2-业务特有指标" class="headerlink" title="12.2 业务特有指标"></a>12.2 业务特有指标</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">送达率 = 实际送达数 / 提交成功数     （核心质量指标，应 &gt; 95%）</span><br><span class="line">通道到达率 = 各通道单独的到达率      （用于路由权重调整）</span><br><span class="line">到达延迟 = 提交 → 用户收到 的耗时    （验证码场景关键，影响转化）</span><br><span class="line">成本 = 当日累计花费 / 预算           （防超支）</span><br><span class="line">退订率 = 退订数 / 发送数             （营销短信健康度）</span><br></pre></td></tr></table></figure><h3 id="12-3-告警分级"><a href="#12-3-告警分级" class="headerlink" title="12.3 告警分级"></a>12.3 告警分级</h3><table><thead><tr><th>级别</th><th>触发条件</th><th>动作</th></tr></thead><tbody><tr><td>P0 电话</td><td>发送失败率 &gt; 5% 持续 3min；核心通道全挂</td><td>立即值班</td></tr><tr><td>P1 短信</td><td>MQ 堆积 &gt; 10万 持续 5min；送达率 &lt; 90%</td><td>值班跟进</td></tr><tr><td>P2 IM</td><td>单通道错误率 &gt; 10%；DLQ 增长异常</td><td>工作时间处理</td></tr></tbody></table><hr><h2 id="十三、成本与容量"><a href="#十三、成本与容量" class="headerlink" title="十三、成本与容量"></a>十三、成本与容量</h2><h3 id="13-1-成本构成"><a href="#13-1-成本构成" class="headerlink" title="13.1 成本构成"></a>13.1 成本构成</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">短信是按条收费的，成本敏感：</span><br><span class="line">  ① 通道单价     验证码 0.045/条、营销 0.04/条（各家不同）</span><br><span class="line">  ② 长短信计费   超 70 字按多条计（每 67 字一条）</span><br><span class="line">  ③ 失败重发     重试也计费（即使最终失败，通道可能已扣费）</span><br><span class="line">  ④ 通道保底     部分通道有月度保底消费</span><br><span class="line"></span><br><span class="line">降本手段：</span><br><span class="line">  - 多通道比价，按成本动态路由（见 9.2）</span><br><span class="line">  - 控制重试次数，避免无效重发</span><br><span class="line">  - 营销短信精准投放，提升转化（少发但有效）</span><br><span class="line">  - 内容合规，避免被拒产生无效计费</span><br></pre></td></tr></table></figure><h3 id="13-2-容量规划"><a href="#13-2-容量规划" class="headerlink" title="13.2 容量规划"></a>13.2 容量规划</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">压测要点：</span><br><span class="line">  ① 压通道真实 QPS 上限（通道有限流，超出被拒）</span><br><span class="line">  ② 压 Redis 限流 QPS（瓶颈通常在 Redis）</span><br><span class="line">  ③ 压 Worker 消费速率（决定最大吞吐）</span><br><span class="line"></span><br><span class="line">容量评估公式：</span><br><span class="line">  系统最大吞吐 = min(通道总 QPS, Redis 处理能力, Worker 处理能力)</span><br><span class="line"></span><br><span class="line">  大促预估：峰值 QPS × 3 倍 buffer = 需要支撑的能力</span><br><span class="line">  → 不足则扩 Worker / 增通道 / 提前预发（错峰发送营销短信）</span><br></pre></td></tr></table></figure><hr><h2 id="十四、生产就绪-Checklist"><a href="#十四、生产就绪-Checklist" class="headerlink" title="十四、生产就绪 Checklist"></a>十四、生产就绪 Checklist</h2><ul><li><input checked="" disabled="" type="checkbox"> <strong>并发安全</strong>：compute &#x2F; Striped &#x2F; Redis Lua 三选一</li><li><input checked="" disabled="" type="checkbox"> <strong>分布式限流</strong>：Redis 方案</li><li><input checked="" disabled="" type="checkbox"> <strong>内存泄漏</strong>：TTL 过期 &#x2F; Caffeine 淘汰策略</li><li><input checked="" disabled="" type="checkbox"> <strong>参数校验</strong>：phoneNo 格式、message 长度</li><li><input checked="" disabled="" type="checkbox"> <strong>状态一致性</strong>：发送失败后回滚计数</li><li><input checked="" disabled="" type="checkbox"> <strong>时区明确</strong>：<code>ZoneId.of(&quot;Asia/Shanghai&quot;)</code></li><li><input checked="" disabled="" type="checkbox"> <strong>线程安全日期</strong>：<code>java.time.LocalDate</code> 替代 <code>SimpleDateFormat</code></li><li><input checked="" disabled="" type="checkbox"> <strong>异步削峰</strong>：MQ 解耦 + Worker 消费</li><li><input checked="" disabled="" type="checkbox"> <strong>幂等保证</strong>：msgId &#x2F; outId 去重</li><li><input checked="" disabled="" type="checkbox"> <strong>多通道路由</strong>：主备&#x2F;权重&#x2F;故障转移</li><li><input checked="" disabled="" type="checkbox"> <strong>熔断保护</strong>：通道级熔断（参考 「熔断详解」）</li><li><input checked="" disabled="" type="checkbox"> <strong>重试补偿</strong>：指数退避 + 死信队列 + 回执兜底</li><li><input checked="" disabled="" type="checkbox"> <strong>安全合规</strong>：黑名单、签名模板、退订、脱敏</li><li><input checked="" disabled="" type="checkbox"> <strong>监控告警</strong>：黄金四信号 + 送达率&#x2F;成本</li><li><input checked="" disabled="" type="checkbox"> <strong>容量规划</strong>：压测出系统最大吞吐</li></ul><hr><h2 id="十五、面试追问速答"><a href="#十五、面试追问速答" class="headerlink" title="十五、面试追问速答"></a>十五、面试追问速答</h2><table><thead><tr><th>问题</th><th>速答要点</th></tr></thead><tbody><tr><td>并发下怎么保证 60s 限制？</td><td>单机用 <code>ConcurrentHashMap.compute</code>（分段锁）；分布式用 Redis Lua 脚本原子执行 check-then-act</td></tr><tr><td>多实例部署怎么办？</td><td>单机 Map 不行，必须用 Redis 集中计数，Lua 保证原子</td></tr><tr><td>为什么用 Lua 不用 Redis 事务？</td><td>WATCH&#x2F;MULTI 是乐观锁，高并发下冲突重试多；Lua 在 Redis 单线程内原子执行，更可靠</td></tr><tr><td>发送失败怎么回滚计数？</td><td>catch 异常后，单独再发一次命令把计数 -1 &#x2F; 删除 last key。注意回滚本身也要防并发</td></tr><tr><td>怎么防止用户重复收到验证码？</td><td>MQ 至少一次投递会重复 → 消费端用 msgId 去重（SETNX）；通道调用带 outId 让平台侧也去重</td></tr><tr><td>通道挂了怎么办？</td><td>多通道路由 + 故障转移：A 失败切 B；对每个通道配熔断器，熔断后自动切走，恢复后探测放回</td></tr><tr><td>怎么削峰？</td><td>业务请求先写 MQ 立即返回，Worker 按通道能力匀速消费；堆积超阈值扩 Worker</td></tr><tr><td>怎么保证短信真的送达？</td><td>不能只看”提交成功”，要靠异步回执；对超时无回执的做兜底查询；送达率是核心监控指标</td></tr><tr><td>成本怎么控制？</td><td>多通道比价动态路由；控制重试次数；长短信拆条计费注意；营销精准投放</td></tr><tr><td>营销短信合规要点？</td><td>用户授权、可退订（回 T）、时段限制（8-21 点）、退订 24h 不再发</td></tr><tr><td>跨天怎么重置日计数？</td><td>单机：compute 内比较 <code>lastSendDate</code>；Redis：key 带日期后缀 + EXPIREAT 到当天结束</td></tr></tbody></table><hr><h2 id="十六、延伸阅读"><a href="#十六、延伸阅读" class="headerlink" title="十六、延伸阅读"></a>十六、延伸阅读</h2><ul><li>「限流详解」 —— 限流算法（固定窗口&#x2F;滑动窗口&#x2F;漏桶&#x2F;令牌桶）</li><li>「熔断详解」 —— 通道熔断、故障转移</li><li>「降级详解」 —— Redis 不可用时降级单机限流</li><li>《通信短消息服务规定》（工信部）—— 国内短信合规依据</li><li>各通道官方文档：阿里云&#x2F;腾讯云&#x2F;华为云短信服务</li></ul>]]></content>
    
    
    <summary type="html">&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;面试题背景&lt;/strong&gt;：设计一个短信发送系统。核心约束：同一手机号每 60s 最多发送一次、每天最多发送 10 条。&lt;/p&gt;
&lt;p&gt;本题从”实现一个限流方法”切入，可一路深挖到并发、分布式限流、异步削峰、幂等、多通道路由、容灾、安全合规、监控成本等。下面按&lt;strong&gt;由浅入深&lt;/strong&gt;展开：先解决单机并发限流，再演进到生产级短信系统设计。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;figure class=&quot;highlight plaintext&quot;&gt;&lt;table&gt;&lt;tr&gt;&lt;td class=&quot;code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;line&quot;&gt;本文脉络：&lt;/span&gt;&lt;br&gt;&lt;span class=&quot;line&quot;&gt;  一~五  从 0 到 1：并发问题 → 限流方案（单机/分布式）→ 内存治理 → 方案对比&lt;/span&gt;&lt;br&gt;&lt;span class=&quot;line&quot;&gt;  六~十三 由点到面：系统架构 → 异步削峰 → 幂等 → 多通道路由 → 重试补偿 → 安全合规 → 监控 → 成本&lt;/span&gt;&lt;br&gt;&lt;span class=&quot;line&quot;&gt;  十四    生产就绪 Checklist + 面试追问速答&lt;/span&gt;&lt;br&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&lt;/figure&gt;</summary>
    
    
    
    <category term="系统设计" scheme="https://blog.searchdiff.com/categories/%E7%B3%BB%E7%BB%9F%E8%AE%BE%E8%AE%A1/"/>
    
    
    <category term="短信系统" scheme="https://blog.searchdiff.com/tags/%E7%9F%AD%E4%BF%A1%E7%B3%BB%E7%BB%9F/"/>
    
    <category term="限流" scheme="https://blog.searchdiff.com/tags/%E9%99%90%E6%B5%81/"/>
    
    <category term="幂等" scheme="https://blog.searchdiff.com/tags/%E5%B9%82%E7%AD%89/"/>
    
    <category term="分布式" scheme="https://blog.searchdiff.com/tags/%E5%88%86%E5%B8%83%E5%BC%8F/"/>
    
    <category term="后端" scheme="https://blog.searchdiff.com/tags/%E5%90%8E%E7%AB%AF/"/>
    
  </entry>
  
  <entry>
    <title>ElasticSearch基础知识</title>
    <link href="https://blog.searchdiff.com/2026/06/21/ElasticSearch%E5%9F%BA%E7%A1%80%E7%9F%A5%E8%AF%86/"/>
    <id>https://blog.searchdiff.com/2026/06/21/ElasticSearch%E5%9F%BA%E7%A1%80%E7%9F%A5%E8%AF%86/</id>
    <published>2026-06-21T01:11:51.000Z</published>
    <updated>2026-06-21T11:06:44.564Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p>Elasticsearch（简称 ES）是基于 Lucene 的分布式搜索与分析引擎，凭借倒排索引实现毫秒级全文检索，并支持聚合分析、地理查询和向量检索（kNN）。本文从核心概念出发，系统梳理 ES 的存储结构、写入与查询流程、BM25 评分、HNSW 向量索引原理、聚合与集群分片机制，以及脑裂防护、故障分级等分布式要点，是一份覆盖原理到实践的完整知识图谱。</p></blockquote><span id="more"></span><h2 id="一、基础概念"><a href="#一、基础概念" class="headerlink" title="一、基础概念"></a>一、基础概念</h2><h3 id="1-ES-是什么？核心概念"><a href="#1-ES-是什么？核心概念" class="headerlink" title="1. ES 是什么？核心概念"></a>1. ES 是什么？核心概念</h3><p><strong>定义</strong>：基于 <strong>Lucene</strong> 的分布式搜索和分析引擎，用 RESTful API 进行数据写入和查询。</p><h4 id="1-1-核心能力"><a href="#1-1-核心能力" class="headerlink" title="1.1 核心能力"></a>1.1 核心能力</h4><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">1. 全文搜索 — 倒排索引 + BM25 评分，毫秒级返回</span><br><span class="line">2. 近实时 — 默认 1s 延迟可见（NRT, Near Real Time）</span><br><span class="line">3. 聚合分析 — 类似 GROUP BY + 统函数，支持嵌套聚合</span><br><span class="line">4. 分布式 — 自动分片 + 副本，线性扩展</span><br><span class="line">5. 地理位置 — 支持 geo_distance / geo_bounding_box 查询</span><br><span class="line">6. 向量检索 — 8.0+ 支持 kNN 语义搜索</span><br></pre></td></tr></table></figure><!-- more --><h4 id="1-2-概念对应"><a href="#1-2-概念对应" class="headerlink" title="1.2 概念对应"></a>1.2 概念对应</h4><table><thead><tr><th>ES</th><th>关系型数据库</th><th>说明</th></tr></thead><tbody><tr><td>Index</td><td>Database</td><td>逻辑命名空间，创建后不可改名</td></tr><tr><td>Type（7.x 已废弃）</td><td>Table</td><td>8.0 已完全移除，一个 Index 只有一个 Type</td></tr><tr><td>Document</td><td>Row</td><td>JSON 格式，每个 doc 有唯一的 <code>_id</code></td></tr><tr><td>Field</td><td>Column</td><td>字段类型在 Mapping 中定义</td></tr><tr><td>Mapping</td><td>Schema</td><td>支持动态 Mapping（自动推断类型）</td></tr><tr><td>Shard</td><td>分片</td><td>Lucene 实例，物理存储单元</td></tr><tr><td>Replica</td><td>副本</td><td>主分片的完整拷贝，提供高可用和读负载均衡</td></tr></tbody></table><h4 id="1-3-ES-vs-数据库选场景"><a href="#1-3-ES-vs-数据库选场景" class="headerlink" title="1.3 ES vs 数据库选场景"></a>1.3 ES vs 数据库选场景</h4><table><thead><tr><th>场景</th><th>ES</th><th>关系型数据库</th></tr></thead><tbody><tr><td>全文搜索（模糊&#x2F;相关性）</td><td>✅ 首选</td><td>❌ LIKE 极慢</td></tr><tr><td>ACID 事务</td><td>❌ 不完全支持</td><td>✅ 首选</td></tr><tr><td>JOINs</td><td>❌ 有限（nested&#x2F;parent-child）</td><td>✅ 首选</td></tr><tr><td>聚合分析</td><td>✅ 适合大数据集</td><td>小数据集 OK</td></tr><tr><td>实时 OLTP 写入</td><td>❌ 1s 延迟</td><td>✅ 实时</td></tr></tbody></table><h4 id="1-4-基本架构"><a href="#1-4-基本架构" class="headerlink" title="1.4 基本架构"></a>1.4 基本架构</h4><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">┌────────────── Cluster ──────────────────┐</span><br><span class="line">│                                          │</span><br><span class="line">│  ┌── Node 1 ────┐  ┌── Node 2 ────┐     │</span><br><span class="line">│  │ P0 │ P1 │ R2 │  │ P2 │ R0 │ R1 │     │</span><br><span class="line">│  └───────────────┘  └───────────────┘     │</span><br><span class="line">│         ▲                                  │</span><br><span class="line">│         └── Node 3 (Client Node)           │</span><br><span class="line">│              └── 协调节点：分发请求、合并结果 │</span><br><span class="line">└──────────────────────────────────────────┘</span><br><span class="line"></span><br><span class="line">每个 Index 由多个 Shard 组成，每个 Shard = 一个完整的 Lucene 实例</span><br><span class="line">P = Primary Shard（写入主分片）</span><br><span class="line">R = Replica Shard（副本分片，从 P 复制）</span><br></pre></td></tr></table></figure><h3 id="2-倒排索引详解"><a href="#2-倒排索引详解" class="headerlink" title="2. 倒排索引详解"></a>2. 倒排索引详解</h3><h4 id="2-1-什么是倒排索引"><a href="#2-1-什么是倒排索引" class="headerlink" title="2.1 什么是倒排索引"></a>2.1 什么是倒排索引</h4><p><strong>正排索引</strong>（传统数据库）：文档 ID → 文档内容。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">doc1 → &quot;北京烤鸭很好吃&quot;</span><br><span class="line">doc2 → &quot;北京的烤鸭店推荐&quot;</span><br><span class="line">doc3 → &quot;今天天气很好&quot;</span><br></pre></td></tr></table></figure><p><strong>倒排索引</strong>：词条（Term）→ 包含该词条的文档列表。搜索时以词条为入口定位文档。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">&quot;北京&quot;    → [doc1, doc2]</span><br><span class="line">&quot;烤鸭&quot;    → [doc1, doc2]</span><br><span class="line">&quot;好吃&quot;    → [doc1]</span><br><span class="line">&quot;推荐&quot;    → [doc2]</span><br><span class="line">&quot;天气&quot;    → [doc3]</span><br></pre></td></tr></table></figure><p><strong>为什么叫”倒排”</strong>？因为数据组织方向与正排相反——正排是”文档→词”，倒排是”词→文档”。</p><h4 id="2-2-完整数据结构"><a href="#2-2-完整数据结构" class="headerlink" title="2.2 完整数据结构"></a>2.2 完整数据结构</h4><p>Lucene 中一个倒排索引由四层组成：</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">┌─────────────────────────────────────────────────────┐</span><br><span class="line">│               倒排索引四层结构                          │</span><br><span class="line">├─────────────────────────────────────────────────────┤</span><br><span class="line">│  ① Term Index (FST)         ← 内存常驻，快速定位 Term    │</span><br><span class="line">│     [前缀树，压缩存储，~1MB/数亿 Term]                   │</span><br><span class="line">├─────────────────────────────────────────────────────┤</span><br><span class="line">│  ② Term Dictionary          ← 磁盘，Term 到 Posting 的映射│</span><br><span class="line">│     [有序 Term 列表，二分查找]                          │</span><br><span class="line">├─────────────────────────────────────────────────────┤</span><br><span class="line">│  ③ Posting List              ← 磁盘，文档 ID 列表 + 位置  │</span><br><span class="line">│     [docID | position | payload] 用跳表加速             │</span><br><span class="line">├─────────────────────────────────────────────────────┤</span><br><span class="line">│  ④ DocValues                 ← 磁盘列存，排序/聚合用      │</span><br><span class="line">│     [docID → value，不需要从倒排索引反推]                │</span><br><span class="line">└─────────────────────────────────────────────────────┘</span><br></pre></td></tr></table></figure><h5 id="①-Term-Index：FST（Finite-State-Transducer）"><a href="#①-Term-Index：FST（Finite-State-Transducer）" class="headerlink" title="① Term Index：FST（Finite State Transducer）"></a>① Term Index：FST（Finite State Transducer）</h5><p><strong>解决的问题</strong>：Term Dictionary 很大（磁盘），不能在内存中全量加载。Term Index 压缩到内存，快速定位 Term 在 Term Dictionary 中的位置。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">词典：[&quot;cat&quot;, &quot;cats&quot;, &quot;catty&quot;, &quot;deep&quot;, &quot;deeper&quot;, &quot;depth&quot;]</span><br><span class="line"></span><br><span class="line">FST 压缩后：</span><br><span class="line">  c → a → t → s (cats)</span><br><span class="line">          ↓</span><br><span class="line">          t → y (catty)</span><br><span class="line">  d → e → e → p (deep, 共享 de 路径)</span><br><span class="line">          ↓</span><br><span class="line">          e → r (deeper)</span><br><span class="line">          p → t → h (depth)</span><br></pre></td></tr></table></figure><p><strong>关键特点</strong>：</p><ul><li>公共前缀只存一次（如 <code>ca</code> 被所有 ca 开头的 term 共享）</li><li>每个节点存的是一个字符（或字节），边存的是输出的 payload（如文件中的偏移量）</li><li>内存占用极低：1MB 内存可索引数亿 term</li><li>查找复杂度 O(len(term))，与 term 总数无关</li></ul><h5 id="②-Term-Dictionary"><a href="#②-Term-Dictionary" class="headerlink" title="② Term Dictionary"></a>② Term Dictionary</h5><p>有序列表，每个 entry &#x3D; <code>Term → (doc_freq, file_pointer_to_posting)</code>。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">&quot;cat&quot;      → freq=10,  offset=0x00A0</span><br><span class="line">&quot;cats&quot;     → freq=5,   offset=0x0110</span><br><span class="line">&quot;catty&quot;    → freq=2,   offset=0x0150</span><br><span class="line">...</span><br><span class="line">&quot;depth&quot;    → freq=15,  offset=0x2000</span><br></pre></td></tr></table></figure><p>有了 FST 知道 Term 在 Dictionary 中的位置后，从磁盘加载 Term 的 Posting List。</p><h5 id="③-Posting-List（倒排表）"><a href="#③-Posting-List（倒排表）" class="headerlink" title="③ Posting List（倒排表）"></a>③ Posting List（倒排表）</h5><p><strong>最小结构</strong>：只存文档 ID 列表。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">&quot;北京&quot; → [doc1, doc2, doc5, doc8, doc10, ...]</span><br></pre></td></tr></table></figure><p><strong>完整结构</strong>：每个文档 ID 后面附加了多项信息：</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">&quot;北京&quot; → [</span><br><span class="line">    docId=1:  freq=2, pos=[5, 12], offsets=[(0,2), (15,17)]</span><br><span class="line">    docId=2:  freq=1, pos=[3],      offsets=[(8,10)]</span><br><span class="line">    docId=5:  freq=3, pos=[0,7,15], offsets=[...]</span><br><span class="line">    ...</span><br><span class="line">]</span><br></pre></td></tr></table></figure><p>每条记录共包含五层信息：</p><table><thead><tr><th>信息</th><th>含义</th><th align="center">是否默认存储</th><th>用途</th></tr></thead><tbody><tr><td><strong>docId</strong></td><td>文档 ID（Delta 编码 + 分块压缩）</td><td align="center">✅ 必须</td><td>定位到具体文档</td></tr><tr><td><strong>termFreq</strong></td><td>该 term 在此文档中出现的次数</td><td align="center">✅ 默认</td><td>BM25 评分需要词频</td></tr><tr><td><strong>positions</strong></td><td>term 每次出现的位置（token 序号，第几个词）</td><td align="center">✅ 默认</td><td><strong>短语查询</strong>（match_phrase）、<strong>临近查询</strong>（slop）</td></tr><tr><td><strong>offsets</strong></td><td>term 的起止字符偏移量（startOffset, endOffset）</td><td align="center">❌ 默认关闭</td><td>高亮（fast vector highlighter）、前缀匹配</td></tr><tr><td><strong>payloads</strong></td><td>自定义二进制数据（每个 position 可附加）</td><td align="center">❌ 默认关闭</td><td>自定义评分（如给标题中的 term 更高权重）</td></tr></tbody></table><p><strong>index_options 控制存储粒度</strong>：</p><p>在 Mapping 中通过 <code>index_options</code> 控制 Posting List 存哪些信息：</p><figure class="highlight json"><table><tr><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;mappings&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;properties&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;title&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;text&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;index_options&quot;</span><span class="punctuation">:</span> <span class="string">&quot;positions&quot;</span>   <span class="comment">// 控制存储粒度</span></span><br><span class="line">      <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><table><thead><tr><th>index_options</th><th>存储内容</th><th>适用场景</th></tr></thead><tbody><tr><td><strong>docs</strong></td><td>仅 docId</td><td>不需要评分、不需要短语匹配（等同于设置 <code>norms: false</code>）</td></tr><tr><td><strong>freqs</strong></td><td>docId + termFreq</td><td>需要 BM25 评分但不需要短语匹配</td></tr><tr><td><strong>positions</strong>（默认）</td><td>docId + freq + positions</td><td>全文搜索（match、match_phrase、高亮）</td></tr><tr><td><strong>offsets</strong></td><td>docId + freq + positions + offsets</td><td>需要字符级高亮定位（fast vector highlighter）</td></tr></tbody></table><p><strong>positions 和 offsets 的区别</strong>：</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">文档: &quot;北京烤鸭很好吃&quot;</span><br><span class="line">分词: [&quot;北京&quot;(0,2), &quot;烤鸭&quot;(2,4), &quot;很&quot;(4,5), &quot;好吃&quot;(5,7)]</span><br><span class="line">             ↑            ↑          ↑          ↑</span><br><span class="line">          offsets     offsets    offsets    offsets</span><br><span class="line"></span><br><span class="line">position:  position=0  position=1  position=2  position=3</span><br><span class="line">           (第0个token) (第1个token)           (第3个token)</span><br></pre></td></tr></table></figure><ul><li><strong>position</strong>：token 的序号（第几个词），用于判断词之间是否相邻。match_phrase 检查 <code>position_word_a + 1 == position_word_b</code></li><li><strong>offset</strong>：在原文字中的字符起止位置，用于高亮时精确标记原文 <code>&lt;em&gt;北京&lt;/em&gt;烤鸭</code></li></ul><p><strong>存储开销对比</strong>：</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">仅 docId（docs 模式）：      ~8-10 字节/doc（Delta + 压缩）</span><br><span class="line">docId + freq + positions：  ~12-20 字节/doc（默认）</span><br><span class="line">+ offsets：                 额外 ~4-8 字节/position</span><br></pre></td></tr></table></figure><p><strong>Posting List 的编码优化（Frame of Reference）</strong>：</p><figure class="highlight python"><table><tr><td class="code"><pre><span class="line"><span class="comment"># 原始 Posting List（递增文档 ID）</span></span><br><span class="line">docs = [<span class="number">1</span>, <span class="number">5</span>, <span class="number">12</span>, <span class="number">18</span>, <span class="number">25</span>, <span class="number">32</span>]</span><br><span class="line"></span><br><span class="line"><span class="comment"># 差值编码（Delta Encoding）后</span></span><br><span class="line">deltas = [<span class="number">1</span>, <span class="number">4</span>, <span class="number">7</span>, <span class="number">6</span>, <span class="number">7</span>, <span class="number">7</span>]   <span class="comment"># 相邻差值更小</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># 分块压缩（每块 128 个文档）</span></span><br><span class="line"><span class="comment"># 块内最大值只需更少的 bit 表示，大块用更多 bit</span></span><br></pre></td></tr></table></figure><p><strong>跳表加速</strong>：Posting List 用跳表实现，做交集（AND）时快速跳过不匹配的文档。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">跳表（示例）：</span><br><span class="line">  [1] ────────────────────── [18] ────────── [32]  ← 高层（skip pointer）</span><br><span class="line">    ↓                          ↓</span><br><span class="line">  [1] ───── [5] ───── [12] ── [18] ── [25] ── [32]  ← 底层（全量）</span><br></pre></td></tr></table></figure><h5 id="④-DocValues"><a href="#④-DocValues" class="headerlink" title="④ DocValues"></a>④ DocValues</h5><p><strong>解决的问题</strong>：倒排索引是从 Term 找 Doc，但排序和聚合需要从 Doc 找 Field 值（正排）。如果每行数据都从 _source 里解析，性能极差。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">DocValues 是列式存储（类似列数据库）：</span><br><span class="line"></span><br><span class="line">  doc:   0      1      2      3      4      5</span><br><span class="line">  price: 12     45     23     67     34     89</span><br><span class="line"></span><br><span class="line">  → 内存连续数组，可直接按 docID 随机访问</span><br><span class="line">  → 排序时直接读 DocValues，不需要打开 _source</span><br></pre></td></tr></table></figure><h4 id="2-3-完整查找过程"><a href="#2-3-完整查找过程" class="headerlink" title="2.3 完整查找过程"></a>2.3 完整查找过程</h4><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">搜索 &quot;北京烤鸭&quot;</span><br><span class="line"></span><br><span class="line">Step 1: FST → 查找 Term &quot;北京&quot; 的偏移量</span><br><span class="line">        路径: b-e-i-j-i-n-g → 输出: 0x00A0（Term Dictionary 中位置）</span><br><span class="line"></span><br><span class="line">Step 2: Term Dictionary → 在偏移 0x00A0 读取 &quot;北京&quot; 的元信息</span><br><span class="line">        doc_freq=1000, posting_offset=0x1000</span><br><span class="line"></span><br><span class="line">Step 3: Posting List → 读取 docID 列表（用跳表快速定位）</span><br><span class="line">        [doc1, doc2, doc5, doc8, ...]</span><br><span class="line"></span><br><span class="line">Step 4: 对 &quot;烤鸭&quot; 同样操作，用跳表做交集</span><br><span class="line">        &quot;北京&quot;: [doc1, doc2, doc5, doc8, doc10, ...]</span><br><span class="line">        &quot;烤鸭&quot;: [doc1, doc2, doc5, doc9, doc10, ...]</span><br><span class="line">        AND  : [doc1, doc2, doc5, doc10, ...]</span><br><span class="line"></span><br><span class="line">Step 5: BM25 打分，排序，返回 Top N</span><br></pre></td></tr></table></figure><h4 id="2-4-倒排链合并机制（核心：AND-OR-NOT-怎么算）"><a href="#2-4-倒排链合并机制（核心：AND-OR-NOT-怎么算）" class="headerlink" title="2.4 倒排链合并机制（核心：AND&#x2F;OR&#x2F;NOT 怎么算）"></a>2.4 倒排链合并机制（核心：AND&#x2F;OR&#x2F;NOT 怎么算）</h4><p>上面的 Step 4 “用跳表做交集” 是 ES 查询的内核心脏，但一笔带过了。这里展开讲清楚 <strong>多条倒排链到底怎么合并</strong>。这是面试高频追问点。</p><h5 id="2-4-1-前置条件：倒排链是有序的"><a href="#2-4-1-前置条件：倒排链是有序的" class="headerlink" title="2.4.1 前置条件：倒排链是有序的"></a>2.4.1 前置条件：倒排链是有序的</h5><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">每个 term 的 Posting List（倒排链）是【按 docId 严格升序】排列的。</span><br><span class="line"></span><br><span class="line">  &quot;elasticsearch&quot; → [1, 3, 7, 9, 15, 22, 30]</span><br><span class="line">  &quot;query&quot;         → [2, 3, 5, 9, 12, 15, 22, 28]</span><br><span class="line"></span><br><span class="line">这个&quot;有序&quot;特性是后面所有合并算法的前提。</span><br></pre></td></tr></table></figure><h5 id="2-4-2-基础算法：拉链合并（Zipper-Merge）"><a href="#2-4-2-基础算法：拉链合并（Zipper-Merge）" class="headerlink" title="2.4.2 基础算法：拉链合并（Zipper Merge）"></a>2.4.2 基础算法：拉链合并（Zipper Merge）</h5><p>求两条有序链的交集（AND），就像拉拉链——两个指针各指一条链头部，谁小谁前进，相等即命中。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">求 &quot;elasticsearch&quot; AND &quot;query&quot;：</span><br><span class="line"></span><br><span class="line">链A: [1, 3, 7, 9, 15, 22, 30]      指针 i</span><br><span class="line">链B: [2, 3, 5, 9, 12, 15, 22, 28]  指针 j</span><br><span class="line"></span><br><span class="line">i=0(A=1), j=0(B=2): 1&lt;2 → i++（A 小，A 前进）</span><br><span class="line">i=1(A=3), j=0(B=2): 3&gt;2 → j++（B 小，B 前进）</span><br><span class="line">i=1(A=3), j=1(B=3): 相等！命中 docId=3，i++, j++</span><br><span class="line">i=2(A=7), j=2(B=5): 7&gt;5 → j++</span><br><span class="line">... 依此类推 ...</span><br><span class="line"></span><br><span class="line">最终交集：[3, 9, 15, 22]</span><br></pre></td></tr></table></figure><p>伪代码（求交 &#x2F; 求并）：</p><figure class="highlight java"><table><tr><td class="code"><pre><span class="line"><span class="comment">// AND 求交</span></span><br><span class="line">List&lt;Integer&gt; <span class="title function_">intersect</span><span class="params">(List&lt;Integer&gt; a, List&lt;Integer&gt; b)</span> &#123;</span><br><span class="line">    List&lt;Integer&gt; result = <span class="keyword">new</span> <span class="title class_">ArrayList</span>&lt;&gt;();</span><br><span class="line">    <span class="type">int</span> <span class="variable">i</span> <span class="operator">=</span> <span class="number">0</span>, j = <span class="number">0</span>;</span><br><span class="line">    <span class="keyword">while</span> (i &lt; a.size() &amp;&amp; j &lt; b.size()) &#123;</span><br><span class="line">        <span class="keyword">if</span> (a.get(i).equals(b.get(j))) &#123; result.add(a.get(i)); i++; j++; &#125;</span><br><span class="line">        <span class="keyword">else</span> <span class="keyword">if</span> (a.get(i) &lt; b.get(j)) i++;</span><br><span class="line">        <span class="keyword">else</span> j++;</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> result;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// OR 求并（任一链耗尽即可结束，剩余全部加入）</span></span><br><span class="line">List&lt;Integer&gt; <span class="title function_">union</span><span class="params">(List&lt;Integer&gt; a, List&lt;Integer&gt; b)</span> &#123;</span><br><span class="line">    List&lt;Integer&gt; result = <span class="keyword">new</span> <span class="title class_">ArrayList</span>&lt;&gt;();</span><br><span class="line">    <span class="type">int</span> <span class="variable">i</span> <span class="operator">=</span> <span class="number">0</span>, j = <span class="number">0</span>;</span><br><span class="line">    <span class="keyword">while</span> (i &lt; a.size() &amp;&amp; j &lt; b.size()) &#123;</span><br><span class="line">        <span class="keyword">if</span> (a.get(i).equals(b.get(j))) &#123; result.add(a.get(i)); i++; j++; &#125;</span><br><span class="line">        <span class="keyword">else</span> <span class="keyword">if</span> (a.get(i) &lt; b.get(j)) result.add(a.get(i++));</span><br><span class="line">        <span class="keyword">else</span> result.add(b.get(j++));</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">while</span> (i &lt; a.size()) result.add(a.get(i++));</span><br><span class="line">    <span class="keyword">while</span> (j &lt; b.size()) result.add(b.get(j++));</span><br><span class="line">    <span class="keyword">return</span> result;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>复杂度</strong>：两条链长 m、n，求交最坏 <code>O(m+n)</code>。短链高效，但长链（百万级 docId）仍慢——所以 Lucene 在此基础上加了跳表。</p><h5 id="2-4-3-关键优化：跳表（Skip-List）让合并提速"><a href="#2-4-3-关键优化：跳表（Skip-List）让合并提速" class="headerlink" title="2.4.3 关键优化：跳表（Skip List）让合并提速"></a>2.4.3 关键优化：跳表（Skip List）让合并提速</h5><p>光有归并不够。<code>O(m+n)</code> 在大数据量下仍慢，Lucene 在倒排链上构建了<strong>跳表</strong>，支持 <strong>skipTo(target)</strong> 跳跃式前进。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">带跳表的倒排链（示意，实际 Lucene 用分块 + skip pointer）：</span><br><span class="line"></span><br><span class="line">链A docId: 1   3   7   9   15  22  30  45  67  89 ...</span><br><span class="line">skip指针:  └─────►15         └─────►30         ...</span><br><span class="line"></span><br><span class="line">合并时，另一条链当前值是 28，链A 当前在 7：</span><br><span class="line">  普通归并：7→9→15→22→30（前进 4 步）</span><br><span class="line">  带跳表：  7 --skipTo(28)--&gt; 30（1 步跳跃，中间 9/15/22 全跳过）</span><br></pre></td></tr></table></figure><p>带跳表的合并（Lucene 实际做法）：</p><figure class="highlight java"><table><tr><td class="code"><pre><span class="line"><span class="type">int</span> <span class="variable">i</span> <span class="operator">=</span> <span class="number">0</span>, j = <span class="number">0</span>;</span><br><span class="line"><span class="keyword">while</span> (i &lt; a.size() &amp;&amp; j &lt; b.size()) &#123;</span><br><span class="line">    <span class="keyword">if</span> (a.docId(i) == b.docId(j)) &#123;</span><br><span class="line">        collect(a.docId(i));</span><br><span class="line">        i++; j++;</span><br><span class="line">    &#125; <span class="keyword">else</span> <span class="keyword">if</span> (a.docId(i) &lt; b.docId(j)) &#123;</span><br><span class="line">        i = a.skipTo(b.docId(j));   <span class="comment">// 关键：不是 i++，而是跳表直接跳到 &gt;= 目标的位置</span></span><br><span class="line">    &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">        j = b.skipTo(a.docId(i));</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>复杂度</strong>：从 <code>O(m+n)</code> 降到接近 <code>O(min(m,n)·log(max))</code>。<strong>两条链长度悬殊时收益最大</strong>——短链的每个元素用 skipTo 在长链里二分跳跃。</p><blockquote><p>Lucene 实际的 postings 格式（默认 Lucene90）：docId 列表切成固定大小的块（如每 128 个 docId 一块），每块记录起始 docId 作为 skip pointer，块内用 PForDelta 压缩，解压后才能精确比较。</p></blockquote><h5 id="2-4-4-AND-OR-NOT-的不同合并策略"><a href="#2-4-4-AND-OR-NOT-的不同合并策略" class="headerlink" title="2.4.4 AND &#x2F; OR &#x2F; NOT 的不同合并策略"></a>2.4.4 AND &#x2F; OR &#x2F; NOT 的不同合并策略</h5><table><thead><tr><th>查询语义</th><th>合并方式</th><th>何时结束</th><th>特点</th></tr></thead><tbody><tr><td><strong>AND（must）</strong></td><td>求交 intersect</td><td>任一链耗尽</td><td>短链主导，越长越快收敛</td></tr><tr><td><strong>OR（should）</strong></td><td>求并 union</td><td>所有链耗尽</td><td>必须遍历所有链，慢</td></tr><tr><td><strong>NOT（must_not）</strong></td><td>求差 subtract</td><td>遍历主链</td><td>从主链剔除 NOT 链的 docId</td></tr></tbody></table><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">bool query: A AND B AND NOT C</span><br><span class="line"></span><br><span class="line">执行顺序优化（Lucene 的 BooleanQuery 会做启发式重排）：</span><br><span class="line">  1. 先求最短的两条链 A ∩ B（交集快速缩小结果集）</span><br><span class="line">  2. 再用 (A∩B) - C（在缩小的集合上做差，C 的开销变小）</span><br><span class="line"></span><br><span class="line">经验：AND 查询中【先合并最短的两条链】，结果集快速缩小，后续越来越快。</span><br><span class="line">      这也是为什么高频词（长链）单独用 query 慢——应转成 filter 缓存。</span><br></pre></td></tr></table></figure><h5 id="2-4-5-短语查询的特殊合并：docId-求交-position-验证"><a href="#2-4-5-短语查询的特殊合并：docId-求交-position-验证" class="headerlink" title="2.4.5 短语查询的特殊合并：docId 求交 + position 验证"></a>2.4.5 短语查询的特殊合并：docId 求交 + position 验证</h5><p><code>match_phrase &quot;北京烤鸭&quot;</code> 不能简单求 docId 交集，还要验证”北京”和”烤鸭”在文档中<strong>位置连续</strong>。所以短语查询 &#x3D; <strong>先按 docId 求交 + 再按 position 验证相邻关系</strong>。这正是 Posting List 要存 <code>positions</code> 的原因，详见 2.5 节。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">&quot;北京&quot;: doc3 → positions [5, 20]</span><br><span class="line">&quot;烤鸭&quot;: doc3 → positions [6, 21]</span><br><span class="line"></span><br><span class="line">短语匹配（连续）：北京@5 → 期望烤鸭在 5+1=6 → 烤鸭确实@6 ✓ 命中</span><br></pre></td></tr></table></figure><h5 id="2-4-6-跨-Segment-与跨分片的合并"><a href="#2-4-6-跨-Segment-与跨分片的合并" class="headerlink" title="2.4.6 跨 Segment 与跨分片的合并"></a>2.4.6 跨 Segment 与跨分片的合并</h5><p>倒排链合并在 ES 中是<strong>两层归并</strong>：</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">第 1 层：单分片内，跨多个 Segment 合并</span><br><span class="line">  一个 term 的倒排链分散在每个 Segment 里（见 7.5 节）</span><br><span class="line">  → 遍历所有 Segment 的同 term 倒排链，归并（同时过滤 .del 标记）</span><br><span class="line">  → 这就是为什么 Segment 太多会拖慢查询（每个 Segment 的 FST 都要查一遍）</span><br><span class="line"></span><br><span class="line">第 2 层：协调节点跨分片合并</span><br><span class="line">  各分片本地做完上述合并 + BM25 打分 → 返回 TopN 的 &#123;docId, score&#125;</span><br><span class="line">  → 协调节点用堆（优先队列）归并各分片的 TopN，取全局 TopN</span><br><span class="line">  → 注意：分片返回的不是完整倒排链，而是 TopN，所以协调节点的归并是</span><br><span class="line">         N 个有序小数组的归并，开销可控（见第 9 节 Query/Fetch 两阶段）</span><br></pre></td></tr></table></figure><h5 id="2-4-7-加速合并的工程手段"><a href="#2-4-7-加速合并的工程手段" class="headerlink" title="2.4.7 加速合并的工程手段"></a>2.4.7 加速合并的工程手段</h5><table><thead><tr><th>手段</th><th>原理</th><th>适用</th></tr></thead><tbody><tr><td><strong>filter 缓存</strong></td><td>filter 结果缓存为 bitset（位图），下次用位运算合并，比 postings 合并快得多</td><td>重复过滤条件（status&#x3D;1）</td></tr><tr><td><strong>高频词用 filter</strong></td><td>长链求交慢，缓存成 bitset 后变成位与运算</td><td>高频 term</td></tr><tr><td><strong>控制 Segment 数</strong></td><td>Segment 越少，第 1 层跨 Segment 合并的开销越小</td><td>定期 force_merge</td></tr><tr><td><strong>控制分片数</strong></td><td>分片越多并行度越高，但协调节点归并 + fetch 开销增大</td><td>不是越多越好</td></tr><tr><td><strong>routing 路由</strong></td><td>查询带 routing 只命中单分片，跳过跨分片归并</td><td>按用户维度查询</td></tr></tbody></table><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">filter 缓存 vs postings 合并（性能量级差异）：</span><br><span class="line">  postings 合并：解压 docId 块 + 跳表跳跃 → 微秒~毫秒</span><br><span class="line">  bitset 位运算：AND/OR 就是 CPU 位与/位或 → 纳秒级，且命中缓存零开销</span><br><span class="line"></span><br><span class="line">→ 这就是&quot;能用 filter 就别用 query&quot;的底层原因（见第 8 节 Query vs Filter）</span><br></pre></td></tr></table></figure><h5 id="2-4-8-一张图总结三层合并机制"><a href="#2-4-8-一张图总结三层合并机制" class="headerlink" title="2.4.8 一张图总结三层合并机制"></a>2.4.8 一张图总结三层合并机制</h5><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">┌─────────────────────────────────────────────────────┐</span><br><span class="line">│  第 1 层：分片内 postings 合并                       │</span><br><span class="line">│    带 skip list 的拉链归并（求交/并/差）              │</span><br><span class="line">│    Lucene 的 DocIdStream + 跳表实现                 │</span><br><span class="line">├─────────────────────────────────────────────────────┤</span><br><span class="line">│  第 2 层：filter 缓存的位图合并（可选加速）           │</span><br><span class="line">│    filter 结果缓存为 bitset，用位运算合并            │</span><br><span class="line">│    适合重复过滤条件（如 status=1）                   │</span><br><span class="line">├─────────────────────────────────────────────────────┤</span><br><span class="line">│  第 3 层：跨分片结果归并（协调节点）                 │</span><br><span class="line">│    各分片返回 TopN，协调节点用堆归并取全局 TopN      │</span><br><span class="line">│    Query Then Fetch 两阶段                          │</span><br><span class="line">└─────────────────────────────────────────────────────┘</span><br></pre></td></tr></table></figure><h4 id="2-5-短语查询（Phrase-Query）原理"><a href="#2-5-短语查询（Phrase-Query）原理" class="headerlink" title="2.5 短语查询（Phrase Query）原理"></a>2.5 短语查询（Phrase Query）原理</h4><p><strong>问题</strong>：搜索 <code>&quot;北京烤鸭&quot;</code>（带引号的精确短语），要求”北京”和”烤鸭”在文档中<strong>连续出现、顺序一致</strong>。</p><p><strong>普通 match 查询</strong>：只要文档同时包含”北京”和”烤鸭”就返回，不管相对位置。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">文档：&quot;北京的烤鸭很有名&quot;</span><br><span class="line">普通 match → ✅ 匹配（同时包含&quot;北京&quot;和&quot;烤鸭&quot;）</span><br><span class="line">短语 match → ❌ 不匹配（&quot;北京&quot;和&quot;烤鸭&quot;中间隔了&quot;的&quot;）</span><br></pre></td></tr></table></figure><p><strong>短语查询的实现</strong>：利用 Posting List 中存储的<strong>position 信息</strong>。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">doc1 的分词结果（每个 token 有 position 和 offset）：</span><br><span class="line">  doc1: [北京(0:0-2), 的(1:2-3), 烤鸭(2:3-5), 很(3:5-6), 有名(4:6-8)]</span><br><span class="line">         ↑           ↑         ↑           ↑          ↑</span><br><span class="line">      position=0   pos=1     pos=2       pos=3      pos=4</span><br><span class="line"></span><br><span class="line">Posting List 存储：</span><br><span class="line">  &quot;北京&quot; → [doc1: freq=1, pos=[0]]</span><br><span class="line">  &quot;烤鸭&quot; → [doc1: freq=1, pos=[2]]</span><br><span class="line"></span><br><span class="line">match_phrase 查询 &quot;北京烤鸭&quot;：</span><br><span class="line">  Step 1: 分别查&quot;北京&quot;和&quot;烤鸭&quot;的 Posting List</span><br><span class="line">  Step 2: 找到同时包含两个 term 的文档 → doc1</span><br><span class="line">  Step 3: 检查位置关系：</span><br><span class="line">           &quot;北京&quot; 在位置 1</span><br><span class="line">           &quot;烤鸭&quot; 在位置 3</span><br><span class="line">           期望偏移 = &quot;北京&quot;的位置 + 1 = 2</span><br><span class="line">           实际偏移 = 3</span><br><span class="line">           2 ≠ 3 → ❌ 不匹配</span><br></pre></td></tr></table></figure><p><strong>算法本质</strong>：短语查询就是 Posting List 按文档 ID 做交集，再按位置判断偏移量是否连续。</p><figure class="highlight python"><table><tr><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">match_phrase</span>(<span class="params">term1_posting, term2_posting</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;</span></span><br><span class="line"><span class="string">    term1_posting: &#123;&quot;doc1&quot;: [1], &quot;doc2&quot;: [3, 5]&#125;</span></span><br><span class="line"><span class="string">    term2_posting: &#123;&quot;doc1&quot;: [2], &quot;doc2&quot;: [4, 8]&#125;</span></span><br><span class="line"><span class="string">    &quot;&quot;&quot;</span></span><br><span class="line">    result = []</span><br><span class="line">    <span class="keyword">for</span> doc_id <span class="keyword">in</span> intersect(term1_posting.keys(), term2_posting.keys()):</span><br><span class="line">        pos1 = term1_posting[doc_id]  <span class="comment"># [1]</span></span><br><span class="line">        pos2 = term2_posting[doc_id]  <span class="comment"># [2]</span></span><br><span class="line"></span><br><span class="line">        <span class="comment"># 检查是否存在连续的两个位置</span></span><br><span class="line">        <span class="keyword">for</span> p1 <span class="keyword">in</span> pos1:</span><br><span class="line">            <span class="keyword">if</span> p1 + <span class="number">1</span> <span class="keyword">in</span> pos2:  <span class="comment"># position 连续，且顺序一致</span></span><br><span class="line">                result.append(doc_id)</span><br><span class="line">                <span class="keyword">break</span>  <span class="comment"># 找到一个就够</span></span><br><span class="line">    <span class="keyword">return</span> result</span><br><span class="line"></span><br><span class="line"><span class="comment"># doc1: term1_pos=1, term2_pos=2 → 1+1=2 → ✅</span></span><br><span class="line"><span class="comment"># doc2: p1=3,p2=4 → 3+1=4 → ✅; p1=5,p2=8 → ❌（但已有匹配）</span></span><br></pre></td></tr></table></figure><h4 id="2-6-临近查询（Proximity-Query）原理"><a href="#2-6-临近查询（Proximity-Query）原理" class="headerlink" title="2.6 临近查询（Proximity Query）原理"></a>2.6 临近查询（Proximity Query）原理</h4><p><strong>问题</strong>：搜索 <code>&quot;北京 烤鸭&quot;~3</code>，要求”北京”和”烤鸭”之间<strong>最多间隔 3 个词</strong>。</p><p><strong>match_phrase 的限制</strong>：</p><ul><li><code>match_phrase: &quot;北京烤鸭&quot;</code> → 必须严格连续（slop&#x3D;0）</li><li>文档”北京的烤鸭” → ❌ 不匹配</li></ul><p><strong>match_phrase 带 slop 参数</strong>：</p><figure class="highlight json"><table><tr><td class="code"><pre><span class="line">GET /_search</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;query&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;match_phrase&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;title&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;query&quot;</span><span class="punctuation">:</span> <span class="string">&quot;北京 烤鸭&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;slop&quot;</span><span class="punctuation">:</span> <span class="number">2</span>       <span class="comment">// 允许最多移动 2 步来对齐</span></span><br><span class="line">      <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>slop 的计算方式（Wagner-Fischer 编辑距离）</strong>：</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">文档：    &quot;北京&quot;  &quot;的&quot;    &quot;烤鸭&quot;  &quot;很&quot;    &quot;好吃&quot;</span><br><span class="line">位置：      1      2       3      4       5</span><br><span class="line"></span><br><span class="line">查询： &quot;北京&quot; &quot;烤鸭&quot;</span><br><span class="line">目标位置：  pos=1   pos=2（期望连续）</span><br><span class="line"></span><br><span class="line">实际位置：  pos=1   pos=3（&quot;烤鸭&quot;在位置3）</span><br><span class="line"></span><br><span class="line">需要移动&quot;烤鸭&quot;从 3 到 2 → 移动 1 步</span><br><span class="line">slop 至少需要 1 才能匹配 → match_phrase 默认 slop=0，所以不匹配</span><br><span class="line">                           → 设置 slop=2，则匹配</span><br></pre></td></tr></table></figure><p><strong>slop 越大性能越差</strong>，因为需要检查更多的位置组合。</p><p><strong>实际应用</strong>：</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">slop=0  → 精确短语匹配（&quot;北京烤鸭&quot;）</span><br><span class="line">slop=1  → 允许一个虚词插入（&quot;北京的烤鸭&quot;）</span><br><span class="line">slop=2  → 更宽松的语序（&quot;北京 烤鸭店&quot;）</span><br><span class="line">slop=10 → 宽松匹配（&quot;北京 好吃 的 烤鸭&quot;）</span><br></pre></td></tr></table></figure><h4 id="2-7-多词短语与-Wave-算法"><a href="#2-7-多词短语与-Wave-算法" class="headerlink" title="2.7 多词短语与 Wave 算法"></a>2.7 多词短语与 Wave 算法</h4><p>对于超过 2 个词的短语，Lucene 使用 <strong>Wagner-Fischer 算法</strong>（本质是动态规划 + 滑动窗口）来计算多词之间的编辑距离，保证短语内所有词的位置关系满足 slop 约束。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">查询：&quot;北京&quot; &quot;地道&quot; &quot;烤鸭&quot;</span><br><span class="line">位置：   1       2       3</span><br><span class="line"></span><br><span class="line">文档：&quot;北京&quot; &quot;烤鸭&quot; &quot;很&quot; &quot;地道&quot;</span><br><span class="line">        1       2      3      4</span><br><span class="line"></span><br><span class="line">Wagner-Fischer 矩阵：</span><br><span class="line">  初始化: 每位置期望是 1, 2, 3</span><br><span class="line">  实际:   北京=1, 烤鸭=2, 很=3, 地道=4</span><br><span class="line"></span><br><span class="line">  计算将查询词映射到文档位置的最小编辑代价：</span><br><span class="line">    &quot;北京&quot; 在1 → 0</span><br><span class="line">    &quot;地道&quot; 在4 → 需要从4移到2 → 2步</span><br><span class="line">    &quot;烤鸭&quot; 在2 → 需要从2移到3 → 1步</span><br><span class="line">  总 slop = 2 + 1 = 3</span><br></pre></td></tr></table></figure><h3 id="3-数据类型-→-存储结构-→-查询方式对照表"><a href="#3-数据类型-→-存储结构-→-查询方式对照表" class="headerlink" title="3. 数据类型 → 存储结构 → 查询方式对照表"></a>3. 数据类型 → 存储结构 → 查询方式对照表</h3><h4 id="3-1-存储方式速查"><a href="#3-1-存储方式速查" class="headerlink" title="3.1 存储方式速查"></a>3.1 存储方式速查</h4><table><thead><tr><th>底层结构</th><th>用途</th><th>存储位置</th></tr></thead><tbody><tr><td><strong>FST（倒排索引）</strong></td><td>分词后的 term → docID 映射</td><td>FST 常驻内存，Posting List 在磁盘</td></tr><tr><td><strong>BKD Tree</strong></td><td>数值&#x2F;日期&#x2F;地理的多维范围索引</td><td>索引在内存，数据块在磁盘</td></tr><tr><td><strong>DocValues</strong></td><td>正排列存（排序&#x2F;聚合&#x2F;脚本）</td><td>磁盘（列式，按 docID 连续存储）</td></tr><tr><td><strong>Stored Fields</strong></td><td>原始字段值（<code>_source</code>）</td><td>磁盘</td></tr><tr><td><strong>HNSW 图</strong></td><td>向量 ANN 索引</td><td>内存 + 磁盘</td></tr></tbody></table><h4 id="3-2-完整对照表"><a href="#3-2-完整对照表" class="headerlink" title="3.2 完整对照表"></a>3.2 完整对照表</h4><table><thead><tr><th>类型</th><th>索引结构</th><th>DocValues</th><th>支持哪些查询（Query）</th><th align="center">支持聚合&#x2F;排序</th></tr></thead><tbody><tr><td><strong>text</strong></td><td><strong>FST 倒排索引</strong>（分词）</td><td>仅 norms（评分用）</td><td><code>match</code>、<code>match_phrase</code>、<code>match_phrase_prefix</code>、<code>query_string</code>、<code>simple_query_string</code>、<code>fuzzy</code>、<code>exist</code>、<code>prefix</code>、<code>regexp</code>、<code>wildcard</code>、<code>term</code>（精确词查找但效率低）</td><td align="center">❌（用 <code>fields</code> 转 keyword）</td></tr><tr><td><strong>keyword</strong></td><td><strong>FST 倒排索引</strong>（不分词）</td><td>✅ 默认开启</td><td><code>term</code>、<code>terms</code>、<code>prefix</code>、<code>wildcard</code>、<code>regexp</code>、<code>fuzzy</code>、<code>exists</code>、<code>range</code></td><td align="center">✅</td></tr><tr><td><strong>long &#x2F; integer &#x2F; short &#x2F; byte</strong></td><td><strong>BKD Tree</strong></td><td>✅ 默认开启</td><td><code>term</code>、<code>terms</code>、<code>range</code>、<code>exists</code></td><td align="center">✅</td></tr><tr><td><strong>double &#x2F; float &#x2F; half_float &#x2F; scaled_float</strong></td><td><strong>BKD Tree</strong></td><td>✅ 默认开启</td><td><code>term</code>、<code>terms</code>、<code>range</code>、<code>exists</code></td><td align="center">✅</td></tr><tr><td><strong>boolean</strong></td><td>FST 倒排索引（存 <code>true</code>&#x2F;<code>false</code> 两个 term）</td><td>✅ 默认开启</td><td><code>term</code>、<code>terms</code>、<code>exists</code></td><td align="center">✅</td></tr><tr><td><strong>binary</strong></td><td>❌ 不索引</td><td>❌ 默认关闭</td><td>仅 <code>exists</code>（查是否有值）</td><td align="center">❌</td></tr><tr><td><strong>date</strong></td><td><strong>BKD Tree</strong></td><td>✅ 默认开启</td><td><code>term</code>、<code>terms</code>、<code>range</code>、<code>exists</code>（支持日期数学，如 <code>now-7d</code>）</td><td align="center">✅</td></tr><tr><td><strong>date_nanos</strong></td><td><strong>BKD Tree</strong></td><td>✅ 默认开启</td><td>同上（纳秒精度）</td><td align="center">✅</td></tr><tr><td><strong>integer_range &#x2F; float_range &#x2F; long_range &#x2F; double_range &#x2F; date_range</strong></td><td><strong>BKD Tree</strong></td><td>✅ 默认开启</td><td><code>term</code>（精确匹配区间）、<code>range</code>（判断重叠）、<code>exists</code></td><td align="center">❌</td></tr><tr><td><strong>ip</strong></td><td><strong>BKD Tree</strong></td><td>✅ 默认开启</td><td><code>term</code>、<code>terms</code>、<code>range</code>、<code>exists</code>、<code>CIDR</code>（<code>ip_range</code> 匹配，如 <code>192.168.1.0/24</code>）</td><td align="center">✅</td></tr><tr><td><strong>version</strong></td><td><strong>BKD Tree</strong></td><td>✅ 默认开启</td><td><code>term</code>、<code>range</code>、<code>exists</code>（按语义版本比较，如 <code>&quot;1.2.3&quot; &gt; &quot;1.2.10&quot;</code> 正确）</td><td align="center">✅ 排序</td></tr><tr><td><strong>murmur3</strong></td><td>❌ 不索引</td><td>✅ 开启（存哈希值）</td><td>❌</td><td align="center">仅加速 <code>cardinality</code> 聚合</td></tr><tr><td><strong>geo_point</strong></td><td><strong>BKD Tree</strong>（经纬度二维索引）</td><td>✅ 默认开启</td><td><code>geo_distance</code>、<code>geo_bounding_box</code>、<code>geo_polygon</code>、<code>exists</code></td><td align="center">✅ <code>_geo_distance</code> 排序</td></tr><tr><td><strong>geo_shape</strong></td><td><strong>BKD Tree</strong>（图形顶点索引）</td><td>❌</td><td><code>geo_shape</code>（<code>intersects</code>&#x2F;<code>within</code>&#x2F;<code>contains</code>&#x2F;<code>disjoint</code>）</td><td align="center">❌</td></tr><tr><td><strong>object</strong></td><td>无独立索引，子字段各自按类型索引</td><td>按子字段</td><td>点号访问子字段（如 <code>user.name</code>）</td><td align="center">按子字段</td></tr><tr><td><strong>nested</strong></td><td>每个对象存为独立隐藏文档（Lucene 层）</td><td>✅</td><td><code>nested</code> 查询（<code>path</code> + 内部 query，确保数组内边界正确）</td><td align="center"><code>nested</code> 聚合</td></tr><tr><td><strong>flattened</strong></td><td>整个 JSON 当 keyword-like 存储</td><td>✅</td><td><code>term</code>、<code>terms</code>、<code>exists</code></td><td align="center">✅（但精度有限）</td></tr><tr><td><strong>join</strong></td><td>父&#x2F;子 doc 关系编码</td><td>✅</td><td><code>has_child</code>、<code>has_parent</code>、<code>parent_id</code></td><td align="center">❌</td></tr><tr><td><strong>dense_vector</strong></td><td><strong>HNSW</strong>（8.0+）&#x2F; <strong>IVF</strong>（7.x）图索引</td><td>❌（向量数据独立存储）</td><td><code>knn</code>（近似最近邻）、<code>script_score</code>（<code>cosineSimilarity</code>&#x2F;<code>l1</code>&#x2F;<code>l2</code>&#x2F;<code>dotProduct</code>）</td><td align="center">❌</td></tr><tr><td><strong>sparse_vector</strong></td><td>类似倒排索引（稀疏高维向量）</td><td>❌</td><td><code>knn</code>（稀疏向量 ANN 搜索）</td><td align="center">❌</td></tr><tr><td><strong>completion</strong></td><td><strong>FST</strong>（Trie 前缀树，常驻内存）</td><td>❌</td><td><code>suggest</code>（<code>prefix</code> 自动补全，毫秒级返回）</td><td align="center">❌</td></tr><tr><td><strong>search_as_you_type</strong></td><td>多子字段（<code>_2gram</code>&#x2F;<code>_3gram</code>&#x2F;<code>_index_prefix</code>）均存为 FST 倒排索引</td><td>同 text</td><td><code>match_bool_prefix</code>、<code>match_phrase_prefix</code></td><td align="center">❌</td></tr><tr><td><strong>token_count</strong></td><td>❌ 不索引</td><td>✅ 存 token 数量</td><td><code>term</code>、<code>range</code>、<code>exists</code>（如 <code>match 至少 3 个词</code>）</td><td align="center">✅</td></tr><tr><td><strong>alias</strong></td><td>无存储，指向目标字段</td><td>无</td><td>任何目标字段支持的查询</td><td align="center">同目标字段</td></tr><tr><td><strong>histogram</strong></td><td>❌ 不索引</td><td>✅</td><td>仅聚合（<code>percentiles</code>、<code>min</code>、<code>max</code>）</td><td align="center">✅ 仅限直方图聚合</td></tr></tbody></table><h4 id="3-3-关键设计原则"><a href="#3-3-关键设计原则" class="headerlink" title="3.3 关键设计原则"></a>3.3 关键设计原则</h4><ol><li><strong>FST 倒排索引</strong>只服务于文本类字段（text&#x2F;keyword）和 completion。数值&#x2F;日期&#x2F;地理用 <strong>BKD Tree</strong>，不做 FST，因为连续值不适合分词倒排。</li><li><strong>DocValues</strong> 是排序&#x2F;聚合&#x2F;脚本的执行基础，默认为 keyword&#x2F;数值&#x2F;日期&#x2F;ip&#x2F;布尔开启。text 类型不开 DocValues（norms 除外），所以 text 不能直接排序聚合——需要 <code>fields</code> 配一个 keyword 子字段。</li><li><strong>dense_vector</strong> 不走 FST 也不走 DocValues，它有自己独立的向量存储和 HNSW 图索引。</li><li><strong>nested</strong> 和 <strong>join</strong> 是为了解决 object 类型数组的”边界丢失”问题，但代价不同：nested 查询慢（隐藏文档反规范化），join 更慢（父子文档分离查询）。</li></ol><hr><h2 id="二、写入流程"><a href="#二、写入流程" class="headerlink" title="二、写入流程"></a>二、写入流程</h2><h3 id="4-写入流程"><a href="#4-写入流程" class="headerlink" title="4. 写入流程"></a>4. 写入流程</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">客户端 → Coordination Node → Primary Shard → Replica Shard</span><br><span class="line"></span><br><span class="line">1. 请求路由到 Primary Shard（通过 _id hash）</span><br><span class="line">2. Primary 写入 Lucene 内存 Buffer</span><br><span class="line">3. 同时写入 Translog（防止宕机丢数据）</span><br><span class="line">4. Primary 转发给 Replica</span><br><span class="line">5. Replica 确认写入后 → Primary 确认客户端</span><br><span class="line"></span><br><span class="line">写入过程：</span><br><span class="line">  Buffer → refresh（1s 间隔）→ Segment（可见，但未 fsync）</span><br><span class="line">                                        → flush（30min/512MB）→ 落盘 + commit point + translog 清空</span><br></pre></td></tr></table></figure><h3 id="5-近实时（NRT）原理"><a href="#5-近实时（NRT）原理" class="headerlink" title="5. 近实时（NRT）原理"></a>5. 近实时（NRT）原理</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">ES 写入后默认 1s 才可见。</span><br><span class="line"></span><br><span class="line">流程：</span><br><span class="line">  Index → Buffer（不可见）</span><br><span class="line">         → refresh（默认 1s 自动触发）→ 生成 Segment（打开搜索可见）</span><br><span class="line">         → flush（~30min/512MB）→ 写入磁盘</span><br><span class="line"></span><br><span class="line">为什么不是实时？</span><br><span class="line">  每次 refresh 生成一个新 Segment，高频 refresh 会导致</span><br><span class="line">  小 Segment 过多，影响查询性能和合并效率。</span><br><span class="line"></span><br><span class="line">调优：</span><br><span class="line">  PUT /index/_settings</span><br><span class="line">  &#123; &quot;refresh_interval&quot;: &quot;30s&quot; &#125;    -- 写入吞吐优先</span><br><span class="line">  &#123; &quot;refresh_interval&quot;: &quot;-1&quot;  &#125;    -- 批量导入时关闭 refresh</span><br></pre></td></tr></table></figure><h3 id="6-Translog"><a href="#6-Translog" class="headerlink" title="6. Translog"></a>6. Translog</h3><p><strong>作用</strong>：防止 refresh 到 flush 之间宕机丢数据。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">每次写入：写 Lucene Buffer + 写 Translog（落盘）</span><br><span class="line">宕机恢复：重放 Translog 恢复未 flush 的数据</span><br><span class="line"></span><br><span class="line">问题：Translog 太大 → 恢复慢</span><br><span class="line">解决：flush 后清空 Translog</span><br></pre></td></tr></table></figure><hr><h3 id="7-增量索引原理（Segment-Immutability）"><a href="#7-增量索引原理（Segment-Immutability）" class="headerlink" title="7. 增量索引原理（Segment Immutability）"></a>7. 增量索引原理（Segment Immutability）</h3><p><strong>核心原则</strong>：Lucene 的 Segment 是<strong>不可变的（immutable）</strong>，FST 一旦构建就不会被修改。增量索引通过<strong>不断创建新 Segment</strong> 实现，而不是在已有 FST 上追加或修改。</p><h4 id="7-1-为什么-Segment-不可变？"><a href="#7-1-为什么-Segment-不可变？" class="headerlink" title="7.1 为什么 Segment 不可变？"></a>7.1 为什么 Segment 不可变？</h4><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">Lucene 的设计铁律：</span><br><span class="line">  Segment = 最小的独立搜索单元</span><br><span class="line">  Segment 一旦写入磁盘（或 refresh 后开放搜索），内容永不修改</span><br><span class="line"></span><br><span class="line">为什么不直接修改内存中的 FST？</span><br><span class="line">  1. 并发控制简单：读不阻塞写，写不阻塞读（天然的 MVCC）</span><br><span class="line">  2. 缓存友好：Segment 不变 → 文件系统缓存（page cache）不会失效</span><br><span class="line">  3. 崩溃恢复简单：Segment 要么完整可用，要么直接丢弃，没有&quot;写了一半&quot;的状态</span><br><span class="line">  4. FST 增量修改成本极高：FST 是有向无环图，插入新 term 需要重建整条路径</span><br></pre></td></tr></table></figure><h4 id="7-2-增量索引的完整流程"><a href="#7-2-增量索引的完整流程" class="headerlink" title="7.2 增量索引的完整流程"></a>7.2 增量索引的完整流程</h4><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">写入请求</span><br><span class="line">  │</span><br><span class="line">  ▼</span><br><span class="line">┌─────────────────────────────────────────────────────┐</span><br><span class="line">│ ① Buffer（内存，不可搜索）                            │</span><br><span class="line">│   写入 Lucene In-Memory Buffer                       │</span><br><span class="line">│   同时写入 Translog（磁盘，防宕机丢失）                │</span><br><span class="line">└─────────────────────────────────────────────────────┘</span><br><span class="line">  │  ← refresh（默认 1s）</span><br><span class="line">  ▼</span><br><span class="line">┌─────────────────────────────────────────────────────┐</span><br><span class="line">│ ② 生成新 Segment（内存中构建，开放搜索）              │</span><br><span class="line">│   在内存中为 buffer 内的数据单独构建：                 │</span><br><span class="line">│     - FST（Term Index）                             │</span><br><span class="line">│     - Term Dictionary                               │</span><br><span class="line">│     - Posting List（含 position）                    │</span><br><span class="line">│     - DocValues                                     │</span><br><span class="line">│   → 这个新 Segment 现在可被搜索                       │</span><br><span class="line">│   → 旧的 Segment 不受任何影响                         │</span><br><span class="line">└─────────────────────────────────────────────────────┘</span><br><span class="line">  │  ← flush（默认 30min 或 translog 达 512MB）</span><br><span class="line">  ▼</span><br><span class="line">┌─────────────────────────────────────────────────────┐</span><br><span class="line">│ ③ fsync 到磁盘 + 写 commit point                    │</span><br><span class="line">│   新 Segment 持久化到磁盘                            │</span><br><span class="line">│   Translog 清空（数据已安全落盘）                     │</span><br><span class="line">└─────────────────────────────────────────────────────┘</span><br></pre></td></tr></table></figure><h4 id="7-3-时间线视角：每次-refresh-创建一个全新-Segment"><a href="#7-3-时间线视角：每次-refresh-创建一个全新-Segment" class="headerlink" title="7.3 时间线视角：每次 refresh 创建一个全新 Segment"></a>7.3 时间线视角：每次 refresh 创建一个全新 Segment</h4><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">时间线：每秒 refresh 一次，每次创建一个新 Segment</span><br><span class="line"></span><br><span class="line">t=0s    写入 doc1, doc2</span><br><span class="line">        Buffer: [doc1, doc2]</span><br><span class="line">        Segments: (空)</span><br><span class="line"></span><br><span class="line">t=1s    refresh →</span><br><span class="line">        创建 Segment_1 &#123; FST, TermDict, Posting, DocValues &#125;</span><br><span class="line">        包含: doc1, doc2</span><br><span class="line">        Buffer: (清空)</span><br><span class="line">        Segments: [Segment_1]</span><br><span class="line"></span><br><span class="line">t=1.5s  写入 doc3, doc4</span><br><span class="line">        Buffer: [doc3, doc4]</span><br><span class="line">        Segments: [Segment_1]        ← Segment_1 不变</span><br><span class="line"></span><br><span class="line">t=2s    refresh →</span><br><span class="line">        创建 Segment_2 &#123; FST, TermDict, Posting, DocValues &#125;</span><br><span class="line">        包含: doc3, doc4</span><br><span class="line">        Segments: [Segment_1, Segment_2]</span><br><span class="line"></span><br><span class="line">t=2.3s  写入 doc5</span><br><span class="line">        Buffer: [doc5]</span><br><span class="line">        Segments: [Segment_1, Segment_2]  ← 两个都不变</span><br><span class="line"></span><br><span class="line">t=3s    refresh →</span><br><span class="line">        创建 Segment_3 &#123; ... &#125;</span><br><span class="line">        包含: doc5</span><br><span class="line">        Segments: [Segment_1, Segment_2, Segment_3]</span><br></pre></td></tr></table></figure><p><strong>关键点</strong>：</p><ul><li>每次 refresh 创建一个<strong>全新的 Segment</strong>，包含该时间段内写入的所有文档</li><li>旧 Segment 的 FST &#x2F; Posting &#x2F; DocValues <strong>完全不动</strong></li><li>搜索时需要遍历<strong>所有 Segment</strong>，合并结果</li></ul><h4 id="7-4-更新和删除怎么处理？"><a href="#7-4-更新和删除怎么处理？" class="headerlink" title="7.4 更新和删除怎么处理？"></a>7.4 更新和删除怎么处理？</h4><p>Lucene 中没有原地更新，更新和删除通过 <strong>标记删除 + 新写入</strong> 实现：</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">更新文档（实际是&quot;删除旧版本 + 写入新版本&quot;）：</span><br><span class="line">  UPDATE doc1 →</span><br><span class="line">    1. 在 .del 文件中标记 old_doc1 为&quot;已删除&quot;（bitmap）</span><br><span class="line">    2. 写入新版本 new_doc1 到 Buffer</span><br><span class="line">    3. 下次 refresh 时，new_doc1 出现在新 Segment 中</span><br><span class="line">    4. 旧 doc1 还在旧 Segment 中，但搜索时被 .del 过滤掉</span><br><span class="line"></span><br><span class="line">删除文档：</span><br><span class="line">  DELETE doc1 →</span><br><span class="line">    在 .del 文件中标记 doc1 为&quot;已删除&quot;</span><br><span class="line">    不修改任何 Segment</span><br><span class="line">    搜索时跳过已标记的 doc</span><br><span class="line"></span><br><span class="line">搜索时过滤：</span><br><span class="line">  遍历每个 Segment → 查 .del 文件的 bitmap → 跳过已标记的 doc</span><br></pre></td></tr></table></figure><h5 id="7-4-1-增量更新端到端示例（以商户改名为例）"><a href="#7-4-1-增量更新端到端示例（以商户改名为例）" class="headerlink" title="7.4.1 增量更新端到端示例（以商户改名为例）"></a>7.4.1 增量更新端到端示例（以商户改名为例）</h5><p>上面的描述偏抽象，这里用一个<strong>具体场景</strong>把整条链路串起来，回答两个核心问题：</p><ol><li><strong>改了 name 后，检索怎么搜到新名字的词？</strong></li><li><strong>旧名字的词什么时候物理消失？</strong></li></ol><p><strong>场景</strong>：商户 doc1，name 原本是 <code>&quot;北京烤鸭店&quot;</code>，现改成 <code>&quot;上海小笼包&quot;</code>。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">初始状态（T0）：doc1 已在 Segment_1 中，分词后 FST 含 term：</span><br><span class="line">  &quot;北京&quot;(→[doc1]), &quot;烤鸭&quot;(→[doc1]), &quot;店&quot;(→[doc1])</span><br><span class="line"></span><br><span class="line">━━━ T1: UPDATE merchant/_doc/1 &#123; &quot;name&quot;: &quot;上海小笼包&quot; &#125; ━━━</span><br><span class="line"></span><br><span class="line">  Step 1【路由】协调节点按 _id=1 hash → 定位到 Primary Shard</span><br><span class="line">  Step 2【标记删除】在 Segment_1 的 .del 位图把 doc1 置 1（旧文档物理不动）</span><br><span class="line">  Step 3【写新文档】把新内容写入 Lucene Buffer，同时写 Translog 防丢</span><br><span class="line">  Step 4【分词】对 name=&quot;上海小笼包&quot; 走 analyzer(ik_max_word)：</span><br><span class="line">               → &quot;上海&quot;, &quot;小笼包&quot;, &quot;小笼&quot;, &quot;笼包&quot;</span><br><span class="line">  Step 5【refresh，默认 1s】为这批 buffer 构建全新的 Segment_2：</span><br><span class="line">               Segment_2 的 FST 含 term：</span><br><span class="line">               &quot;上海&quot;(→[doc1&#x27;]), &quot;小笼包&quot;(→[doc1&#x27;]), &quot;小笼&quot;(→[doc1&#x27;]), ...</span><br><span class="line">  Step 6【可见】Segment_2 开放搜索 → 新名字立即能被搜到（1s 近实时延迟）</span><br><span class="line"></span><br><span class="line">此刻磁盘真实状态（搜索视角）：</span><br><span class="line">  Segment_1:  &quot;北京&quot;→[doc1], &quot;烤鸭&quot;→[doc1]   （doc1 已被 .del 标记）</span><br><span class="line">  Segment_2:  &quot;上海&quot;→[doc1], &quot;小笼包&quot;→[doc1] （新版本，活的）</span><br></pre></td></tr></table></figure><p><strong>核心：检索是如何搜到新词的？</strong></p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">查询 match name:&quot;上海&quot;</span><br><span class="line"></span><br><span class="line">  1. 协调节点分发到各分片</span><br><span class="line">  2. 每个分片遍历所有 Segment 的 FST：</span><br><span class="line">       Segment_1.FST → 查 &quot;上海&quot; → 不存在（旧文档没有这个词）</span><br><span class="line">       Segment_2.FST → 查 &quot;上海&quot; → 命中，倒排链 [doc1]</span><br><span class="line">  3. 对倒排链做 .del 过滤：</span><br><span class="line">       doc1 在 Segment_2 是新版本，.del=0 → 保留 ✅</span><br><span class="line">  4. BM25 打分、排序、返回 doc1</span><br><span class="line"></span><br><span class="line">→ 用户成功搜到新名字 &quot;上海&quot;</span><br><span class="line"></span><br><span class="line">本质：更新 = 写入新文档 → 新文档在 refresh 时被分词器重新切分</span><br><span class="line">      → 新词进入新 Segment 的倒排索引（FST + Posting List）</span><br><span class="line">      → 检索时遍历所有 Segment，新 Segment 里自然有新词的倒排链</span><br></pre></td></tr></table></figure><p><strong>旧词 “北京” 什么时候物理消失？</strong>（容易被忽略的关键点）</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">更新后立即查 &quot;北京&quot;：</span><br><span class="line">  Segment_1.FST → &quot;北京&quot;→[doc1]</span><br><span class="line">  → 倒排链 [doc1] → 查 Segment_1 的 .del → doc1=1（已删）→ 过滤掉</span><br><span class="line">  → 结果为空 ✅（逻辑上旧词立刻&quot;搜不到&quot;了）</span><br><span class="line"></span><br><span class="line">但物理上：</span><br><span class="line">  &quot;北京&quot;→[doc1] 这条倒排项还躺在 Segment_1 的磁盘上！</span><br><span class="line">  只是 .del 把 doc1 屏蔽了，FST 里的 term 仍在</span><br><span class="line"></span><br><span class="line">物理清除要等 Segment Merge（见 7.6 节）：</span><br><span class="line">  后台把 Segment_1 等小段合并成大段时，重读数据、丢弃 .del 标记的 doc</span><br><span class="line">  → &quot;北京&quot;→[doc1] 这条记录才真正从磁盘消失</span><br><span class="line"></span><br><span class="line">→ 这解释了一个常见困惑：为什么文档改了名字，磁盘空间不降反涨？</span><br><span class="line">  因为新旧两份数据并存，旧版本赖在旧 Segment 里没被回收，要等 merge。</span><br></pre></td></tr></table></figure><p><strong>端到端时序图</strong>：</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">时间 ──────────────────────────────────────────────────►</span><br><span class="line"></span><br><span class="line">T0     doc1=&quot;北京烤鸭店&quot; 已在 Segment_1</span><br><span class="line">         │</span><br><span class="line">T1      UPDATE name=&quot;上海小笼包&quot;</span><br><span class="line">         │ ├─ 标记 Segment_1 的 doc1 为 deleted（.del）</span><br><span class="line">         │ └─ 新内容入 Buffer + Translog</span><br><span class="line">         │</span><br><span class="line">T1+1s   refresh → Segment_2 生成（含新 doc1）</span><br><span class="line">         │   ★ 此刻起：搜&quot;上海&quot;命中，搜&quot;北京&quot;被 .del 过滤为空</span><br><span class="line">         │</span><br><span class="line">T1+30m  flush → Segment 落盘，Translog 清空（持久化，但不回收旧数据）</span><br><span class="line">         │</span><br><span class="line">T?      后台 Segment Merge</span><br><span class="line">         │   合并 Segment_1 + ... → 新大段，doc1 旧版本被彻底丢弃</span><br><span class="line">         │   &quot;北京&quot;→[doc1] 物理删除，磁盘释放</span><br><span class="line">         ▼</span><br></pre></td></tr></table></figure><p><strong>实战要点</strong>：</p><table><thead><tr><th>问题</th><th>说明</th></tr></thead><tbody><tr><td><strong>改一个字段为什么要重建整篇文档？</strong></td><td>Lucene 文档不可变，只能整条删除+重写。哪怕只改一个字段，ES 也会把完整 <code>_source</code> 重新索引。Update API 支持部分字段的 doc 合并，但底层仍是整条重建。</td></tr><tr><td><strong><code>_update_by_query</code> 为什么慢？</strong></td><td>本质是对每条命中文档做”删旧+写新”，逐条重建代价高。大批量更新建议直接 <code>_reindex</code> 到新索引。</td></tr><tr><td><strong>更新后立刻搜不到？</strong></td><td>1s 近实时延迟。需要强实时可手动 <code>POST /_refresh</code>，但频繁 refresh 会产生大量小 Segment，拖慢查询。</td></tr><tr><td><strong>副本也要同步更新</strong></td><td>Primary 处理后，同样的”删旧+写新”会转发到 Replica，保证主副本一致。</td></tr><tr><td><strong>高频更新导致 Segment 爆炸？</strong></td><td>频繁更新 → 大量带 .del 的 Segment → 查询要遍历更多 Segment + 过滤更多已删 doc。解法：低峰期 <code>force_merge</code> 合并小段。</td></tr></tbody></table><h4 id="7-5-多个-Segment-如何搜索？"><a href="#7-5-多个-Segment-如何搜索？" class="headerlink" title="7.5 多个 Segment 如何搜索？"></a>7.5 多个 Segment 如何搜索？</h4><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">搜索 &quot;北京&quot; 时：</span><br><span class="line"></span><br><span class="line">  Segment_1 的 FST → &quot;北京&quot; 的 Posting List → [doc1, doc2]</span><br><span class="line">  Segment_2 的 FST → &quot;北京&quot; 的 Posting List → [doc3]</span><br><span class="line">  Segment_3 的 FST → &quot;北京&quot; 的 Posting List → [doc5]</span><br><span class="line"></span><br><span class="line">  合并 → [doc1, doc2, doc3, doc5]</span><br><span class="line">  ↓ (过滤各 Segment 的 .del 标记)</span><br><span class="line">  最终结果 → [doc1, doc3, doc5]   （doc2 可能已被标记删除）</span><br></pre></td></tr></table></figure><p>这就是为什么 Segment 太多会影响性能——<strong>每个 Segment 的 FST 都要查一遍</strong>。</p><h4 id="7-6-Segment-Merge（合并）—-解决-Segment-过多"><a href="#7-6-Segment-Merge（合并）—-解决-Segment-过多" class="headerlink" title="7.6 Segment Merge（合并）— 解决 Segment 过多"></a>7.6 Segment Merge（合并）— 解决 Segment 过多</h4><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">后台线程定期合并小 Segment 为一个大的：</span><br><span class="line"></span><br><span class="line">Segments: [S1(2 docs), S2(3 docs), S3(1 doc), S4(5 docs), S5(2 docs)]</span><br><span class="line">                        ↓ merge</span><br><span class="line">Segments: [S_merged(10 docs), S4(5 docs)]</span><br><span class="line"></span><br><span class="line">合并时：</span><br><span class="line">  1. 读取小 Segment 的全部数据</span><br><span class="line">  2. 在内存中为合并后的数据集重新构建一套完整的 FST + Posting + DocValues</span><br><span class="line">  3. 写入一个新的 Segment</span><br><span class="line">  4. 删除旧的小 Segment</span><br><span class="line"></span><br><span class="line">合并后的新 Segment：</span><br><span class="line">  - FST 是全新构建的（合并了多个 Segment 的 Term 信息）</span><br><span class="line">  - 被标记删除的 doc 在合并时彻底丢弃（释放空间）</span><br><span class="line">  - Posting List 更紧凑，跳表更高效</span><br></pre></td></tr></table></figure><p><strong>合并不是增量修改 FST，而是完全重建</strong>。只是这个重建过程复用旧 Segment 的数据。</p><h4 id="7-7-不可变-Segment-vs-可变索引"><a href="#7-7-不可变-Segment-vs-可变索引" class="headerlink" title="7.7 不可变 Segment vs 可变索引"></a>7.7 不可变 Segment vs 可变索引</h4><table><thead><tr><th>对比</th><th>不可变 Segment（Lucene）</th><th>可变索引（假设存在）</th></tr></thead><tbody><tr><td>并发</td><td>读不阻塞写，不需要锁</td><td>需要复杂的读写锁</td></tr><tr><td>缓存</td><td>Page Cache 稳定，不会因修改而失效</td><td>频繁 Cache 失效</td></tr><tr><td>崩溃恢复</td><td>新 Segment 要么完整要么丢弃</td><td>需要考虑”写了一半”的恢复</td></tr><tr><td>事务</td><td>天然支持（旧 Segment 还在，读不受影响）</td><td>需要 MVCC 机制</td></tr><tr><td>FST 增量修改</td><td>❌ 不可能，FST 是有向无环图</td><td>需要重新平衡 FST 结构，开销极大</td></tr></tbody></table><blockquote><p>增量索引不是”修改内存中的 FST”，而是<strong>不停地创建新的 Segment</strong>（每个 Segment 有自己独立的 FST），搜索时遍历所有 Segment 合并结果，后台通过 Segment Merge 把多个小 Segment 合并成大 Segment（重建 FST，丢弃已删除的文档）。</p></blockquote><hr><h2 id="三、查询流程"><a href="#三、查询流程" class="headerlink" title="三、查询流程"></a>三、查询流程</h2><h3 id="8-Query-vs-Filter"><a href="#8-Query-vs-Filter" class="headerlink" title="8. Query vs Filter"></a>8. Query vs Filter</h3><table><thead><tr><th>维度</th><th>Query</th><th>Filter</th></tr></thead><tbody><tr><td>相关性打分</td><td>✅ 计算 _score</td><td>❌ 不计算，只有 yes&#x2F;no</td></tr><tr><td>缓存</td><td>不缓存</td><td>✅ <strong>结果可缓存</strong>（bitset）</td></tr><tr><td>性能</td><td>慢（打分）</td><td>快</td></tr><tr><td>场景</td><td>搜索排序</td><td>精确过滤（时间、状态、范围）</td></tr></tbody></table><figure class="highlight json"><table><tr><td class="code"><pre><span class="line"><span class="comment">// Query（打分）</span></span><br><span class="line">GET /_search</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;query&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;match&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;title&quot;</span><span class="punctuation">:</span> <span class="string">&quot;北京烤鸭&quot;</span> <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// Filter（不打分，可缓存）</span></span><br><span class="line">GET /_search</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;query&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;bool&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;filter&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">        <span class="punctuation">&#123;</span> <span class="attr">&quot;term&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;status&quot;</span><span class="punctuation">:</span> <span class="string">&quot;active&quot;</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="punctuation">&#123;</span> <span class="attr">&quot;range&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;price&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;gte&quot;</span><span class="punctuation">:</span> <span class="number">100</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span></span><br><span class="line">      <span class="punctuation">]</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><h3 id="9-查询流程（两阶段）"><a href="#9-查询流程（两阶段）" class="headerlink" title="9. 查询流程（两阶段）"></a>9. 查询流程（两阶段）</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">Query Phase（查询阶段）：</span><br><span class="line">  1. 协调节点接收请求</span><br><span class="line">  2. 转发到所有相关 shard（primary 或 replica）</span><br><span class="line">  3. 各 shard 本地查询，返回 &#123;_id, _score&#125; 给协调节点</span><br><span class="line"></span><br><span class="line">Fetch Phase（取回阶段）：</span><br><span class="line">  4. 协调节点合并排序（取 Top N）</span><br><span class="line">  5. 向各 shard 发送 GET 请求取回完整 _source</span><br><span class="line">  6. 返回客户端</span><br><span class="line"></span><br><span class="line">为什么分两阶段？</span><br><span class="line">  查询结果不需要全量传输，先取文档 ID 排序，再取需要的文档内容，</span><br><span class="line">  减少跨节点传输量。</span><br></pre></td></tr></table></figure><h3 id="10-相关性评分（BM25）"><a href="#10-相关性评分（BM25）" class="headerlink" title="10. 相关性评分（BM25）"></a>10. 相关性评分（BM25）</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">ES 5.0+ 默认 BM25（之前 TF-IDF）。</span><br><span class="line"></span><br><span class="line">BM25 核心公式：</span><br><span class="line">  score = IDF * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * |d|/avgdl))</span><br><span class="line"></span><br><span class="line">  关键参数：</span><br><span class="line">    k1（默认 1.2）：控制词频饱和度（k1=0 → 只算 IDF）</span><br><span class="line">    b（默认 0.75）：控制文档长度归一化（b=0 → 不考虑长度）</span><br><span class="line"></span><br><span class="line">对比 TF-IDF：</span><br><span class="line">  TF-IDF：词频线性增长，长文档天然有优势</span><br><span class="line">  BM25：词频有上界（非线性），长度归一化更好</span><br></pre></td></tr></table></figure><hr><h2 id="四、向量索引（dense-vector）"><a href="#四、向量索引（dense-vector）" class="headerlink" title="四、向量索引（dense_vector）"></a>四、向量索引（dense_vector）</h2><h3 id="11-什么是向量索引"><a href="#11-什么是向量索引" class="headerlink" title="11. 什么是向量索引"></a>11. 什么是向量索引</h3><p>ES 从 7.x 开始支持 <code>dense_vector</code> 类型，8.0+ 引入 HNSW 算法实现高效的近似最近邻（ANN）搜索。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">传统倒排索引：精确匹配（词条 → 文档）</span><br><span class="line">向量索引：    相似度匹配（向量 → 最近邻文档）</span><br><span class="line"></span><br><span class="line">适用场景：</span><br><span class="line">  语义搜索（&quot;开心的饭馆&quot; → 匹配&quot;氛围好的餐厅&quot;）</span><br><span class="line">  图片搜索（图片 embedding → 找相似图片）</span><br><span class="line">  推荐系统（用户 embedding → 找相似内容）</span><br><span class="line">  多模态搜索（文本→向量 匹配 图片→向量）</span><br></pre></td></tr></table></figure><h3 id="12-Mapping-定义"><a href="#12-Mapping-定义" class="headerlink" title="12. Mapping 定义"></a>12. Mapping 定义</h3><figure class="highlight json"><table><tr><td class="code"><pre><span class="line">PUT /my_index</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;mappings&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;properties&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;title&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;text&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;analyzer&quot;</span><span class="punctuation">:</span> <span class="string">&quot;ik_smart&quot;</span></span><br><span class="line">      <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;title_vector&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;dense_vector&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;dims&quot;</span><span class="punctuation">:</span> <span class="number">768</span><span class="punctuation">,</span>             <span class="comment">// 向量维度（BERT 768, OpenAI ada 1536）</span></span><br><span class="line">        <span class="attr">&quot;index&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">true</span></span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;similarity&quot;</span><span class="punctuation">:</span> <span class="string">&quot;cosine&quot;</span><span class="punctuation">,</span>  <span class="comment">// 相似度度量：cosine / dot_product / l2_norm</span></span><br><span class="line">        <span class="attr">&quot;index_options&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">          <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;hnsw&quot;</span><span class="punctuation">,</span></span><br><span class="line">          <span class="attr">&quot;m&quot;</span><span class="punctuation">:</span> <span class="number">16</span><span class="punctuation">,</span>               <span class="comment">// HNSW 每层最大连接数（越大越准越耗内存）</span></span><br><span class="line">          <span class="attr">&quot;ef_construction&quot;</span><span class="punctuation">:</span> <span class="number">100</span> <span class="comment">// 构建时候选集大小（越大越准越慢）</span></span><br><span class="line">        <span class="punctuation">&#125;</span></span><br><span class="line">      <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;price&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;integer&quot;</span></span><br><span class="line">      <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><h3 id="13-向量检索（kNN）"><a href="#13-向量检索（kNN）" class="headerlink" title="13. 向量检索（kNN）"></a>13. 向量检索（kNN）</h3><figure class="highlight json"><table><tr><td class="code"><pre><span class="line">GET /my_index/_search</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;query&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;knn&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;field&quot;</span><span class="punctuation">:</span> <span class="string">&quot;title_vector&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;query_vector&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="number">0.12</span><span class="punctuation">,</span> <span class="number">0.45</span><span class="punctuation">,</span> ...<span class="punctuation">,</span> <span class="number">0.78</span><span class="punctuation">]</span><span class="punctuation">,</span>  <span class="comment">// 768 维向量</span></span><br><span class="line">      <span class="attr">&quot;k&quot;</span><span class="punctuation">:</span> <span class="number">10</span><span class="punctuation">,</span>            <span class="comment">// 返回前 10 个最近邻</span></span><br><span class="line">      <span class="attr">&quot;num_candidates&quot;</span><span class="punctuation">:</span> <span class="number">100</span>  <span class="comment">// 候选集大小（越大越准越慢）</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><table><thead><tr><th>参数</th><th>作用</th><th>越大越</th><th>推荐</th></tr></thead><tbody><tr><td>k</td><td>返回结果数</td><td>返回更多</td><td>根据业务需求</td></tr><tr><td>num_candidates</td><td>每分片候选集</td><td>越准但越慢</td><td>k 的 3-10 倍</td></tr></tbody></table><h3 id="14-底层算法：NSW-→-HNSW-详解"><a href="#14-底层算法：NSW-→-HNSW-详解" class="headerlink" title="14. 底层算法：NSW → HNSW 详解"></a>14. 底层算法：NSW → HNSW 详解</h3><h4 id="13-1-问题定义"><a href="#13-1-问题定义" class="headerlink" title="13.1 问题定义"></a>13.1 问题定义</h4><p>向量检索的目标：给定查询向量 <strong>q</strong>，在 N 个向量构成的集合中找到最相似的 <strong>k</strong> 个。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">暴力解法：计算 q 与所有 N 个向量的距离 → O(N × dim) → N=1000万 时不可行</span><br><span class="line">ANN（近似最近邻）：用索引结构将复杂度降到 O(log N) 或 O(sqrt(N))</span><br></pre></td></tr></table></figure><h4 id="13-2-NSW（Navigable-Small-World）—-可导航小世界图"><a href="#13-2-NSW（Navigable-Small-World）—-可导航小世界图" class="headerlink" title="13.2 NSW（Navigable Small World）— 可导航小世界图"></a>13.2 NSW（Navigable Small World）— 可导航小世界图</h4><h6 id="什么是小世界"><a href="#什么是小世界" class="headerlink" title="什么是小世界"></a>什么是小世界</h6><p>社交网络中的”六度分隔”现象：<strong>任意两个人之间平均只需 6 步就能建立联系</strong>。</p><p>NSW 借鉴了这个思想：<strong>给 N 个向量构建一张图，让任意两个向量之间通过少量跳转就能到达</strong>。</p><h6 id="NSW-的结构"><a href="#NSW-的结构" class="headerlink" title="NSW 的结构"></a>NSW 的结构</h6><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">NSW 图（每个点是一个向量）：</span><br><span class="line"></span><br><span class="line">         A ───── B ───── C</span><br><span class="line">        /│       │       │\</span><br><span class="line">       D │       │       │ E</span><br><span class="line">        ││       │       ││</span><br><span class="line">       F─┼───────G───────┼─H</span><br><span class="line">        ││       │       ││</span><br><span class="line">       I │       │       │ J</span><br><span class="line">        \│       │       │/</span><br><span class="line">         K ───── L ───── M</span><br></pre></td></tr></table></figure><p><strong>特点</strong>：每个节点连接若干”邻居”，长程连接实现快速跳转，短程连接实现精细搜索。</p><h6 id="NSW-构建过程"><a href="#NSW-构建过程" class="headerlink" title="NSW 构建过程"></a>NSW 构建过程</h6><p><strong>核心思想：逐个插入新节点，每次插入时用贪婪搜索找到最近的 m 个节点，建立双向连接。</strong></p><figure class="highlight python"><table><tr><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">insert_node</span>(<span class="params">graph, new_node, m=<span class="number">5</span></span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;向 NSW 图中插入一个新节点&quot;&quot;&quot;</span></span><br><span class="line">    <span class="comment"># 1. 从随机入口点开始，贪婪搜索找到最近的 m 个节点</span></span><br><span class="line">    entry_point = random.choice(graph.nodes)</span><br><span class="line">    candidates = greedy_search(graph, entry_point, new_node, m)</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 2. 将新节点连接到这 m 个候选（双向图）</span></span><br><span class="line">    <span class="keyword">for</span> candidate <span class="keyword">in</span> candidates:</span><br><span class="line">        graph.add_edge(new_node, candidate)</span><br><span class="line">        graph.add_edge(candidate, new_node)</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 3. 如果候选节点连接数 &gt; M_max，剪枝保留最近的</span></span><br><span class="line">    <span class="keyword">for</span> candidate <span class="keyword">in</span> candidates:</span><br><span class="line">        <span class="keyword">if</span> degree(candidate) &gt; M_max:</span><br><span class="line">            prune_connections(graph, candidate, M_max)</span><br></pre></td></tr></table></figure><table><thead><tr><th>参数</th><th>作用</th><th>越大</th><th>越小</th></tr></thead><tbody><tr><td>m（每节点邻居数）</td><td>图的稠密程度</td><td>召回率越高</td><td>可能断开</td></tr><tr><td>M_max</td><td>最大邻居数上限</td><td>内存越大</td><td>图可能断开</td></tr></tbody></table><h6 id="NSW-搜索过程"><a href="#NSW-搜索过程" class="headerlink" title="NSW 搜索过程"></a>NSW 搜索过程</h6><figure class="highlight python"><table><tr><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">nsw_search</span>(<span class="params">graph, entry_point, query_vector, k=<span class="number">10</span></span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;贪婪搜索：从入口点出发，每次走到比当前更近的点&quot;&quot;&quot;</span></span><br><span class="line">    current = entry_point</span><br><span class="line">    visited = <span class="built_in">set</span>()</span><br><span class="line"></span><br><span class="line">    <span class="keyword">while</span> <span class="literal">True</span>:</span><br><span class="line">        visited.add(current)</span><br><span class="line">        nearest = <span class="literal">None</span></span><br><span class="line">        min_dist = distance(query_vector, current.vector)</span><br><span class="line"></span><br><span class="line">        <span class="keyword">for</span> neighbor <span class="keyword">in</span> current.neighbors:</span><br><span class="line">            <span class="keyword">if</span> neighbor <span class="keyword">in</span> visited:</span><br><span class="line">                <span class="keyword">continue</span></span><br><span class="line">            dist = distance(query_vector, neighbor.vector)</span><br><span class="line">            <span class="keyword">if</span> dist &lt; min_dist:</span><br><span class="line">                min_dist = dist</span><br><span class="line">                nearest = neighbor</span><br><span class="line"></span><br><span class="line">        <span class="keyword">if</span> nearest <span class="keyword">is</span> <span class="literal">None</span>:  <span class="comment"># 到达局部最优</span></span><br><span class="line">            <span class="keyword">break</span></span><br><span class="line">        current = nearest</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> <span class="built_in">sorted</span>(visited, key=<span class="keyword">lambda</span> n: distance(query_vector, n.vector))[:k]</span><br></pre></td></tr></table></figure><p><strong>为什么 NSW 能支持向量检索</strong>：</p><p>核心在于<strong>小世界特性</strong>——在随机图中，两点之间的最短路径 ≈ O(log N) 跳。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">时间复杂度的直观理解：</span><br><span class="line">  暴力搜索：把所有 N 个点翻一遍 → O(N)</span><br><span class="line">  NSW 搜索：从入口走 O(log N) 步到达目标区域 → O(log N)</span><br><span class="line">         │</span><br><span class="line">         ▼</span><br><span class="line">    每步要检查邻居（平均 m 个）</span><br><span class="line">    → 总复杂度 O(m × log N)</span><br></pre></td></tr></table></figure><p><strong>贪婪搜索的局限性（NSW 的入口点问题）</strong>：</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">搜索从 S 出发找离 Q 最近的点：</span><br><span class="line"></span><br><span class="line">    S</span><br><span class="line">    │  dist(S,Q) = 10      ← 当前最近</span><br><span class="line">    │</span><br><span class="line">    └──→ A  dist(A,Q) = 7  ← 更近，往前走</span><br><span class="line">          │</span><br><span class="line">          └──→ B  dist(B,Q) = 4  ← 更近</span><br><span class="line">                │</span><br><span class="line">                └──→ C  dist(C,Q) = 5  ← 不比 B 更近</span><br><span class="line">                      │</span><br><span class="line">                      └──→ D  dist(D,Q) = 6  ← 不比 B 更近</span><br><span class="line"></span><br><span class="line">    → 到达局部最优 B，返回结果</span><br><span class="line"></span><br><span class="line">问题：入口点 S 如果是随机的，S 离 Q 很远时，</span><br><span class="line">      需要走很多步才能到达目标区域。</span><br></pre></td></tr></table></figure><h4 id="13-3-HNSW（Hierarchical-NSW）—-分层可导航小世界"><a href="#13-3-HNSW（Hierarchical-NSW）—-分层可导航小世界" class="headerlink" title="13.3 HNSW（Hierarchical NSW）— 分层可导航小世界"></a>13.3 HNSW（Hierarchical NSW）— 分层可导航小世界</h4><p><strong>HNSW 是 NSW 的改进版，用分层结构解决入口点问题，灵感来自跳表（SkipList）。</strong></p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">跳表：     3 ───────────────────── 9</span><br><span class="line">            ───────── 5 ────────── 9</span><br><span class="line">            ─── 3 ── 5 ── 7 ── 9  ← 底层全量</span><br><span class="line"></span><br><span class="line">HNSW：    Layer 2（顶层）: 少量节点，长程连接</span><br><span class="line">          Layer 1（中间层）: 更多节点</span><br><span class="line">          Layer 0（底层）: 全量节点，精细连接</span><br></pre></td></tr></table></figure><h6 id="层级分配"><a href="#层级分配" class="headerlink" title="层级分配"></a>层级分配</h6><figure class="highlight python"><table><tr><td class="code"><pre><span class="line"><span class="keyword">import</span> random, math</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">random_level</span>(<span class="params">level_lambda=<span class="number">1.0</span> / math.log(<span class="params"><span class="number">16</span></span>)</span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;按指数衰减概率分配层数，~97% 在 layer 0&quot;&quot;&quot;</span></span><br><span class="line">    level = -math.log(random.random()) * level_lambda</span><br><span class="line">    <span class="keyword">return</span> <span class="built_in">int</span>(level)</span><br></pre></td></tr></table></figure><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">N=10000 个向量：</span><br><span class="line">  Layer 2（顶层）：~10 个节点（最有代表性的点，约 0.1%）</span><br><span class="line">  Layer 1（中间层）：~300 个节点（约 3%）</span><br><span class="line">  Layer 0（底层）：10000 个节点（全量 100%）</span><br><span class="line"></span><br><span class="line">每个节点只在分配的层及以下出现。</span><br><span class="line">层级越高的节点越&quot;重要&quot;——它们被选中作为多个节点的邻居，</span><br><span class="line">自然成为图的&quot;高速公路&quot;节点。</span><br></pre></td></tr></table></figure><pre><code>level = -math.log(random.random()) * level_lambdareturn int(level)</code></pre><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line"></span><br></pre></td></tr></table></figure><p>N&#x3D;10000 个向量：<br>  Layer 2（顶层）：<del>10 个节点（最有代表性的点）<br>  Layer 1（中间层）：</del>300 个节点<br>  Layer 0（底层）：10000 个节点（全量）</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line"></span><br><span class="line">###### HNSW 构建过程</span><br><span class="line"></span><br><span class="line">```python</span><br><span class="line">def hnsw_insert(graph_hierarchical, new_node, M=16, M_max=32):</span><br><span class="line">    node_level = random_level()                # 1. 随机分配层数</span><br><span class="line"></span><br><span class="line">    entry_point = graph_hierarchical.entry_point</span><br><span class="line">    # 2. 从顶层逐层下到 node_level+1（每层找 1 个最近点做下一层入口）</span><br><span class="line">    for layer in reversed(range(graph_hierarchical.max_level, node_level, -1)):</span><br><span class="line">        entry_point = search_layer(graph_hierarchical, entry_point, new_node,</span><br><span class="line">                                   ef=1, layer=layer)[0]</span><br><span class="line"></span><br><span class="line">    # 3. 从 node_level 到 layer 0，逐层连接最近邻居</span><br><span class="line">    for layer in reversed(range(0, node_level + 1)):</span><br><span class="line">        candidates = search_layer(graph_hierarchical, entry_point,</span><br><span class="line">                                  new_node, ef=ef_construction, layer=layer)</span><br><span class="line">        neighbors = candidates[:M]</span><br><span class="line">        for neighbor in neighbors:</span><br><span class="line">            graph_hierarchical.add_edge(new_node, neighbor, layer)</span><br><span class="line">            graph_hierarchical.add_edge(neighbor, new_node, layer)</span><br><span class="line">        for neighbor in neighbors:</span><br><span class="line">            if len(graph_hierarchical.get_edges(neighbor, layer)) &gt; M_max:</span><br><span class="line">                prune_connections(graph_hierarchical, neighbor, layer, M_max)</span><br><span class="line">        entry_point = candidates[0]</span><br></pre></td></tr></table></figure><h6 id="HNSW-搜索过程"><a href="#HNSW-搜索过程" class="headerlink" title="HNSW 搜索过程"></a>HNSW 搜索过程</h6><figure class="highlight python"><table><tr><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">hnsw_search</span>(<span class="params">graph_hierarchical, query_vector, k=<span class="number">10</span>, ef=<span class="number">100</span></span>):</span><br><span class="line">    entry_point = graph_hierarchical.entry_point</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 1. 从顶层逐层下探（每层 ef=1，只找入口）</span></span><br><span class="line">    <span class="keyword">for</span> layer <span class="keyword">in</span> <span class="built_in">reversed</span>(<span class="built_in">range</span>(graph_hierarchical.max_level, <span class="number">0</span>, -<span class="number">1</span>)):</span><br><span class="line">        entry_point = search_layer(</span><br><span class="line">            graph_hierarchical, entry_point, query_vector, ef=<span class="number">1</span>, layer=layer</span><br><span class="line">        )[<span class="number">0</span>]</span><br><span class="line"></span><br><span class="line">    <span class="comment"># 2. 在底层用更大的 ef 做精细搜索</span></span><br><span class="line">    candidates = search_layer(</span><br><span class="line">        graph_hierarchical, entry_point, query_vector, ef=ef, layer=<span class="number">0</span></span><br><span class="line">    )</span><br><span class="line">    <span class="keyword">return</span> candidates[:k]</span><br></pre></td></tr></table></figure><h6 id="分层搜索的直观示意"><a href="#分层搜索的直观示意" class="headerlink" title="分层搜索的直观示意"></a>分层搜索的直观示意</h6><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">搜索 Q 的最近邻：</span><br><span class="line"></span><br><span class="line">Layer 2（~10 nodes）:  从入口进入顶层，找最近的点</span><br><span class="line">Layer 1（~300 nodes）: 从上层的最近点进入本层，贪婪搜索到最近区域</span><br><span class="line">Layer 0（10000 nodes, 全量）: 在底层精细搜索 ef 个候选，取 Top-k</span><br></pre></td></tr></table></figure><h6 id="HNSW-的参数"><a href="#HNSW-的参数" class="headerlink" title="HNSW 的参数"></a>HNSW 的参数</h6><table><thead><tr><th>参数</th><th>默认</th><th>作用</th><th>越大</th></tr></thead><tbody><tr><td><strong>m</strong></td><td>16</td><td>每层每个节点的最大邻居数</td><td>召回率 ↑、内存 ↑、构建慢</td></tr><tr><td><strong>ef_construction</strong></td><td>100</td><td>构建时候选集大小</td><td>构建质量 ↑、构建慢</td></tr><tr><td><strong>ef_search</strong></td><td>(查询时指定)</td><td>搜索时动态候选集大小</td><td>召回率 ↑、搜索慢</td></tr></tbody></table><p><strong>ef 调优经验</strong>：ef&#x3D;k 刚好够返回；ef&#x3D;3k 推荐平衡点（召回率 95%+）；ef&#x3D;10k 接近暴力搜索。</p><h6 id="为什么-HNSW-比-NSW-快"><a href="#为什么-HNSW-比-NSW-快" class="headerlink" title="为什么 HNSW 比 NSW 快"></a>为什么 HNSW 比 NSW 快</h6><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">NSW 的问题：</span><br><span class="line">  插入第 10000 个点时，从随机入口出发</span><br><span class="line">  需要先经过&quot;长程连接&quot;跳到目标区域附近</span><br><span class="line">  → 如果入口点离目标很远，要走很多步</span><br><span class="line"></span><br><span class="line">HNSW 的优化：</span><br><span class="line">  顶层只含少量节点（最&quot;有代表性&quot;的点）</span><br><span class="line">  从顶层开始搜索，只需走几步就进入目标区域</span><br><span class="line">  → 不需要长程连接来&quot;远跳&quot;，分层结构本身就做到：</span><br><span class="line">    顶层跳远距离 → 中层跳中距离 → 底层精细搜索</span><br></pre></td></tr></table></figure><p><strong>复杂度对比</strong>：</p><table><thead><tr><th>算法</th><th>搜索时间</th><th>构建时间</th><th>内存</th></tr></thead><tbody><tr><td>暴力搜索</td><td>O(N)</td><td>O(1)</td><td>O(N)</td></tr><tr><td>NSW</td><td>O(log² N) ~ O(sqrt(N))</td><td>O(N log N)</td><td>O(N × M)</td></tr><tr><td><strong>HNSW</strong></td><td><strong>O(log N)</strong></td><td><strong>O(N log N)</strong></td><td><strong>O(N × M)</strong></td></tr></tbody></table><p><strong>HNSW vs NSW 关键区别</strong>：</p><table><thead><tr><th>维度</th><th>NSW</th><th>HNSW</th></tr></thead><tbody><tr><td>层数</td><td>单层图</td><td>多层图（类似跳表）</td></tr><tr><td>入口点</td><td>随机选</td><td><strong>从顶层进入，逐层下探</strong></td></tr><tr><td>长程连接</td><td>靠部分节点连接远距离邻居</td><td><strong>靠高层节点天然实现”长程跳转”</strong></td></tr><tr><td>搜索策略</td><td>单层贪婪搜索</td><td>顶层粗定位 → 底层精细搜索</td></tr><tr><td>搜索质量</td><td>依赖入口点选择</td><td><strong>稳定（不依赖入口点）</strong></td></tr><tr><td>复杂度</td><td>O(log² N)</td><td><strong>O(log N)</strong></td></tr></tbody></table><h4 id="13-4-HNSW-vs-IVF（倒排文件索引）"><a href="#13-4-HNSW-vs-IVF（倒排文件索引）" class="headerlink" title="13.4 HNSW vs IVF（倒排文件索引）"></a>13.4 HNSW vs IVF（倒排文件索引）</h4><p>IVF（Inverted File Index）是另一种主流的 ANN 算法（Faiss 的核心算法之一），和 HNSW 的思路完全不同。</p><p><strong>IVF 的核心思想</strong>：把向量空间划分成多个区域（聚类），搜索时只在最近的几个区域里找。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">IVF 结构：</span><br><span class="line"></span><br><span class="line">┌──────────────────────────────────────────────┐</span><br><span class="line">│               向量空间                         │</span><br><span class="line">│                                                │</span><br><span class="line">│      [C1] ● ● ●    [C2] ● ● ●                │</span><br><span class="line">│            ● ● ●          ● ● ●               │</span><br><span class="line">│      ──────────●────C1────●────────────────    │</span><br><span class="line">│      [C3] ● ● ●    [C4] ● ● ●                │</span><br><span class="line">│            ● ● ●          ● ● ●               │</span><br><span class="line">│                  C3              C4            │</span><br><span class="line">└──────────────────────────────────────────────┘</span><br><span class="line"></span><br><span class="line">C1~C4 = 聚类中心（k-means 计算）</span><br><span class="line">每个向量属于最近的聚类中心</span><br><span class="line">搜索：计算 Q 到 C1~C4 的距离 → 找到最近的 2 个聚类 → 只在这 2 个聚类内搜索</span><br></pre></td></tr></table></figure><p><strong>IVF 的搜索流程</strong>：</p><figure class="highlight python"><table><tr><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">ivf_search</span>(<span class="params">ivf_index, query_vector, k=<span class="number">10</span>, nprobe=<span class="number">2</span></span>):</span><br><span class="line">    <span class="string">&quot;&quot;&quot;IVF 搜索：nprobe = 搜索时检查的聚类数&quot;&quot;&quot;</span></span><br><span class="line">    <span class="comment"># Step 1: 计算 query 到所有聚类中心的距离</span></span><br><span class="line">    dist_to_centroids = [distance(query_vector, c) <span class="keyword">for</span> c <span class="keyword">in</span> ivf_index.centroids]</span><br><span class="line">    <span class="comment"># Step 2: 找到最近的 nprobe 个聚类</span></span><br><span class="line">    nearest_clusters = argsort(dist_to_centroids)[:nprobe]</span><br><span class="line">    <span class="comment"># Step 3: 只在选中的聚类内搜索</span></span><br><span class="line">    candidates = []</span><br><span class="line">    <span class="keyword">for</span> cluster_id <span class="keyword">in</span> nearest_clusters:</span><br><span class="line">        <span class="keyword">for</span> vec <span class="keyword">in</span> ivf_index.clusters[cluster_id]:</span><br><span class="line">            candidates.append((vec, distance(query_vector, vec)))</span><br><span class="line">    <span class="comment"># Step 4: 排序返回 Top-k</span></span><br><span class="line">    <span class="keyword">return</span> <span class="built_in">sorted</span>(candidates, key=<span class="keyword">lambda</span> x: x[<span class="number">1</span>])[:k]</span><br></pre></td></tr></table></figure><p><strong>HNSW vs IVF 详细对比</strong>：</p><table><thead><tr><th>维度</th><th>HNSW</th><th>IVF</th></tr></thead><tbody><tr><td><strong>数据结构</strong></td><td>多层图（节点连接）</td><td>聚类 + 倒排列表</td></tr><tr><td><strong>搜索方式</strong></td><td>图遍历（逐节点跳转）</td><td>先选聚类，再在聚类内搜索</td></tr><tr><td><strong>召回率</strong></td><td><strong>高</strong>（&gt; 95%）</td><td>中-高（依赖 nprobe）</td></tr><tr><td><strong>搜索速度</strong></td><td>O(log N)</td><td>O(nprobe × (N&#x2F;nlist))</td></tr><tr><td><strong>构建速度</strong></td><td>慢（逐节点插入，图构建复杂）</td><td><strong>快</strong>（k-means + 分配）</td></tr><tr><td><strong>内存</strong></td><td>高（存邻接表，O(N × M)）</td><td><strong>低</strong>（只存向量 + 倒排索引）</td></tr><tr><td><strong>插入新数据</strong></td><td><strong>动态插入</strong>（增量构建）</td><td>❌ 需要重建索引（聚类会变）</td></tr><tr><td><strong>删除</strong></td><td>支持（标记删除）</td><td>❌ 困难</td></tr><tr><td><strong>精确度调优</strong></td><td>调 ef &#x2F; m</td><td>调 nprobe &#x2F; nlist</td></tr><tr><td><strong>适合场景</strong></td><td>动态数据、高召回率要求</td><td>静态数据、内存受限</td></tr></tbody></table><p><strong>HNSW 的优势逐条展开</strong>：</p><p><strong>1. 召回率更高</strong></p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">相同的搜索时间下，HNSW 的召回率通常比 IVF 高 3-10 个百分点。</span><br><span class="line"></span><br><span class="line">原因：</span><br><span class="line">  IVF 的聚类边界问题：Q 实际属于 C1，但距离 C2 更近</span><br><span class="line">  → 只在 C2 内搜 → 错过 C1 里的真正最近邻</span><br><span class="line"></span><br><span class="line">  HNSW 没有聚类边界问题：图结构天然覆盖整个空间，</span><br><span class="line">  搜索可以跨区域连续跳转。</span><br></pre></td></tr></table></figure><p><strong>2. 动态插入（HNSW 核心优势）</strong></p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">IVF 的痛点：</span><br><span class="line">  插入新向量 → k-means 聚类中心需要重新计算 → 整个索引要重建</span><br><span class="line">  做不到&quot;实时增量&quot;</span><br><span class="line"></span><br><span class="line">HNSW 的方案：</span><br><span class="line">  新向量直接插入图结构，逐层找到最近邻居并连接</span><br><span class="line">  → 增量构建，不影响已有索引</span><br><span class="line">  → 适合流式数据、实时更新场景</span><br></pre></td></tr></table></figure><p><strong>3. 搜索速度随数据量增长更稳定</strong></p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">HNSW:   O(log N) → N 从 100 万到 1 亿，搜索步数从 ~10 增加到 ~15</span><br><span class="line">IVF:    O(nprobe × (N/nlist)) → N 增加时要么增大 nlist（聚类变多）</span><br><span class="line">          要么每个聚类内向量变多（搜索变慢），需要重新调整参数</span><br></pre></td></tr></table></figure><p><strong>4. 扩展性：不需要重训练</strong></p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">IVF 的问题：</span><br><span class="line">  数据量增长后 k-means 聚类不再最优 → 需要重新聚类 → 重建索引</span><br><span class="line">  需要保留全部原始数据用于重聚类（内存压力）</span><br><span class="line"></span><br><span class="line">HNSW 没有这个问题：图结构随数据量自然扩展</span><br></pre></td></tr></table></figure><p><strong>IVF 的优势</strong>：</p><table><thead><tr><th>IVF 优势</th><th>说明</th><th>适用场景</th></tr></thead><tbody><tr><td><strong>构建快</strong></td><td>k-means 比 HNSW 建图快 2-10x</td><td>一次构建，多次查询</td></tr><tr><td><strong>内存省</strong></td><td>不存邻接表，内存少 30-50%</td><td>内存受限的场景</td></tr><tr><td><strong>适合批量</strong></td><td>一次性全量构建，IVF 效率高</td><td>离线索引，定时重建</td></tr><tr><td><strong>技术成熟</strong></td><td>Faiss 中优化充分（IVFPQ 等变体）</td><td>成熟稳定</td></tr></tbody></table><p><strong>选型建议</strong>：</p><table><thead><tr><th>场景</th><th>推荐算法</th><th>原因</th></tr></thead><tbody><tr><td><strong>ES 在线搜索</strong></td><td><strong>HNSW</strong></td><td>动态更新、ES 默认、召回率高</td></tr><tr><td><strong>离线推荐召回</strong></td><td>IVF + PQ</td><td>内存省、召回率够用、Faiss 生态</td></tr><tr><td><strong>亿级向量、实时写入</strong></td><td><strong>HNSW</strong></td><td>动态增量插入是刚需</td></tr><tr><td><strong>千万级、只读</strong></td><td>IVF</td><td>构建快、内存省</td></tr><tr><td><strong>高召回率要求（&gt; 99%）</strong></td><td><strong>HNSW</strong></td><td>图结构精度上限更高</td></tr><tr><td><strong>内存受限（&lt; 1GB）</strong></td><td>IVF + PQ</td><td>量化后内存极低</td></tr></tbody></table><blockquote><p>HNSW 适合<strong>在线动态场景</strong>——召回率高、支持增量插入、搜索速度稳定。<br>IVF 适合<strong>离线批量场景</strong>——构建快、内存省、一次建好反复查。<br>ES 选 HNSW 是对的：搜索索引需要动态更新，不能频繁重建。</p></blockquote><p><strong>选型建议</strong>：</p><table><thead><tr><th>场景</th><th>推荐</th><th>原因</th></tr></thead><tbody><tr><td>ES 在线搜索</td><td><strong>HNSW</strong></td><td>动态更新、ES 默认、召回率高</td></tr><tr><td>离线推荐召回</td><td>IVF + PQ</td><td>内存省、Faiss 生态</td></tr><tr><td>亿级向量、实时写入</td><td><strong>HNSW</strong></td><td>动态增量插入是刚需</td></tr><tr><td>高召回率要求（&gt; 99%）</td><td><strong>HNSW</strong></td><td>图结构精度上限更高</td></tr><tr><td>内存受限（&lt; 1GB）</td><td>IVF + PQ</td><td>量化后内存极低</td></tr></tbody></table><blockquote><p>HNSW 适合<strong>在线动态场景</strong>——召回率高、支持增量插入、搜索速度稳定。<br>IVF 适合<strong>离线批量场景</strong>——构建快、内存省、一次建好反复查。<br>ES 选 HNSW 是对的：搜索索引需要动态更新，不能频繁重建。</p></blockquote><h3 id="15-向量检索-普通查询混合（Hybrid-Search）"><a href="#15-向量检索-普通查询混合（Hybrid-Search）" class="headerlink" title="15. 向量检索 + 普通查询混合（Hybrid Search）"></a>15. 向量检索 + 普通查询混合（Hybrid Search）</h3><p><strong>ES 8.0+ 原生支持，将向量检索和普通 filter 放在一起。</strong></p><figure class="highlight json"><table><tr><td class="code"><pre><span class="line">GET /my_index/_search</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;query&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;bool&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;must&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">        <span class="punctuation">&#123;</span> <span class="attr">&quot;knn&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;field&quot;</span><span class="punctuation">:</span> <span class="string">&quot;title_vector&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;query_vector&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span>...<span class="punctuation">]</span><span class="punctuation">,</span> <span class="attr">&quot;k&quot;</span><span class="punctuation">:</span> <span class="number">10</span><span class="punctuation">,</span> <span class="attr">&quot;num_candidates&quot;</span><span class="punctuation">:</span> <span class="number">100</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="punctuation">&#123;</span> <span class="attr">&quot;term&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;city&quot;</span><span class="punctuation">:</span> <span class="string">&quot;北京&quot;</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="punctuation">&#123;</span> <span class="attr">&quot;range&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;price&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;gte&quot;</span><span class="punctuation">:</span> <span class="number">50</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span></span><br><span class="line">      <span class="punctuation">]</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>执行流程</strong>：</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">Step 1: HNSW 粗筛 → 每分片返回 num_candidates 个候选</span><br><span class="line">Step 2: 精确过滤 → 对候选做 bool 过滤，剔除不符合条件的</span><br><span class="line">Step 3: 打分合并 → 按向量相似度排序，返回 top k</span><br><span class="line"></span><br><span class="line">注意：过滤是在 HNSW 召回之后做的！</span><br><span class="line">如果过滤条件很严格，可能 100 条候选中都不满足条件 → 结果为空</span><br></pre></td></tr></table></figure><h3 id="16-混合搜索的准确性问题：Post-Filter-vs-Pre-Filter"><a href="#16-混合搜索的准确性问题：Post-Filter-vs-Pre-Filter" class="headerlink" title="16. 混合搜索的准确性问题：Post-Filter vs Pre-Filter"></a>16. 混合搜索的准确性问题：Post-Filter vs Pre-Filter</h3><p>上面的做法是 <strong>post-filter</strong>（先 ANN 再过滤），有召回风险。<strong>ES 8.12+ 支持 filtered HNSW（pre-filter）</strong>：在 HNSW 图搜索时就跳过不符合 filter 的节点。</p><figure class="highlight json"><table><tr><td class="code"><pre><span class="line"><span class="comment">// ES 8.12+ Pre-filter</span></span><br><span class="line">GET /my_index/_search</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;query&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;knn&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;field&quot;</span><span class="punctuation">:</span> <span class="string">&quot;title_vector&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;query_vector&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span>...<span class="punctuation">]</span><span class="punctuation">,</span> <span class="attr">&quot;k&quot;</span><span class="punctuation">:</span> <span class="number">10</span><span class="punctuation">,</span> <span class="attr">&quot;num_candidates&quot;</span><span class="punctuation">:</span> <span class="number">100</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;filter&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;bool&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">          <span class="attr">&quot;filter&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">            <span class="punctuation">&#123;</span> <span class="attr">&quot;term&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;city&quot;</span><span class="punctuation">:</span> <span class="string">&quot;北京&quot;</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">            <span class="punctuation">&#123;</span> <span class="attr">&quot;range&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;price&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;gte&quot;</span><span class="punctuation">:</span> <span class="number">50</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span></span><br><span class="line">          <span class="punctuation">]</span></span><br><span class="line">        <span class="punctuation">&#125;</span></span><br><span class="line">      <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>Reciprocal Rank Fusion（RRF）</strong> — 混合排名融合：</p><figure class="highlight json"><table><tr><td class="code"><pre><span class="line">GET /my_index/_search</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;query&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;bool&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;must&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="punctuation">&#123;</span> <span class="attr">&quot;match&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;title&quot;</span><span class="punctuation">:</span> <span class="string">&quot;北京烤鸭&quot;</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span><span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">                        <span class="attr">&quot;filter&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="punctuation">&#123;</span> <span class="attr">&quot;term&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;city&quot;</span><span class="punctuation">:</span> <span class="string">&quot;北京&quot;</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span><span class="punctuation">]</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;knn&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;field&quot;</span><span class="punctuation">:</span> <span class="string">&quot;title_vector&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;query_vector&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span>...<span class="punctuation">]</span><span class="punctuation">,</span> <span class="attr">&quot;k&quot;</span><span class="punctuation">:</span> <span class="number">10</span><span class="punctuation">,</span> <span class="attr">&quot;num_candidates&quot;</span><span class="punctuation">:</span> <span class="number">100</span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;rank&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;rrf&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;rank_constant&quot;</span><span class="punctuation">:</span> <span class="number">60</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>RRF 融合公式：<code>RRF score = 1/(rank_constant + rank_position)</code>，同时对 BM25 排名和向量排名加权融合。</p><h3 id="17-向量索引的内存问题"><a href="#17-向量索引的内存问题" class="headerlink" title="17. 向量索引的内存问题"></a>17. 向量索引的内存问题</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">1000 万条 × 768 维 × 4 字节（float32） ≈ 30 GB（仅向量数据）</span><br><span class="line">+HNSW 图结构（邻接表）≈ 额外 10-15 GB</span><br><span class="line">= 总计 40-45 GB 内存</span><br><span class="line"></span><br><span class="line">超过内存时走 mmap，性能下降 10-100 倍</span><br></pre></td></tr></table></figure><p><strong>优化手段</strong>：</p><table><thead><tr><th>手段</th><th>效果</th><th>代价</th></tr></thead><tbody><tr><td>降维（768→256）</td><td>内存减少 3x</td><td>召回率降低 1-3%</td></tr><tr><td>PQ 量化</td><td>内存减少 4-8x</td><td>召回率降低 2-5%</td></tr><tr><td>int8 量化</td><td>内存减少 4x</td><td>召回率降低 1-2%</td></tr><tr><td>分片到多节点</td><td>每节点负载降低</td><td>跨节点查询增加</td></tr></tbody></table><h3 id="18-应用场景（与搜索背景关联）"><a href="#18-应用场景（与搜索背景关联）" class="headerlink" title="18. 应用场景（与搜索背景关联）"></a>18. 应用场景（与搜索背景关联）</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">搜索中的语义检索：</span><br><span class="line">  用户搜索 &quot;好吃不贵的川菜馆&quot;</span><br><span class="line">  → BERT/Transformer 编码为 768 维向量</span><br><span class="line">  → ES 向量检索找到语义最相似的商品/商户</span><br><span class="line">  → 配合传统 BM25 做混合搜索（RRF 融合排序）</span><br><span class="line"></span><br><span class="line">  在传统倒排索引之外增加一路语义召回通道：</span><br><span class="line">  倒排（词匹配）+ 向量（语义匹配）</span><br><span class="line">  → 两路结果融合排序，提升召回率和相关性</span><br></pre></td></tr></table></figure><hr><h2 id="五、聚合（Aggregation）"><a href="#五、聚合（Aggregation）" class="headerlink" title="五、聚合（Aggregation）"></a>五、聚合（Aggregation）</h2><h3 id="19-Bucket-Metric-聚合"><a href="#19-Bucket-Metric-聚合" class="headerlink" title="19. Bucket + Metric 聚合"></a>19. Bucket + Metric 聚合</h3><figure class="highlight json"><table><tr><td class="code"><pre><span class="line"><span class="comment">// 按城市分组，统计每个城市的平均价格</span></span><br><span class="line">GET /restaurants/_search</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;size&quot;</span><span class="punctuation">:</span> <span class="number">0</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;aggs&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;by_city&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;terms&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;field&quot;</span><span class="punctuation">:</span> <span class="string">&quot;city&quot;</span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;aggs&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;avg_price&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;avg&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;field&quot;</span><span class="punctuation">:</span> <span class="string">&quot;price&quot;</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span></span><br><span class="line">      <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><table><thead><tr><th>聚合类型</th><th>说明</th><th>示例</th></tr></thead><tbody><tr><td><strong>Bucket</strong></td><td>分桶分组</td><td>terms、range、date_histogram</td></tr><tr><td><strong>Metric</strong></td><td>计算指标</td><td>avg、sum、max、min、cardinality</td></tr><tr><td><strong>Pipeline</strong></td><td>聚合结果再聚合</td><td>avg_bucket、cumulative_sum</td></tr></tbody></table><h3 id="20-Cardinality-聚合（去重计数）"><a href="#20-Cardinality-聚合（去重计数）" class="headerlink" title="20. Cardinality 聚合（去重计数）"></a>20. Cardinality 聚合（去重计数）</h3><figure class="highlight json"><table><tr><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span> <span class="attr">&quot;cardinality&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;field&quot;</span><span class="punctuation">:</span> <span class="string">&quot;user_id&quot;</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>原理</strong>：HyperLogLog（近似算法）</p><ul><li>精度：默认 5% 误差</li><li>内存：100M 数据只需要 12KB</li></ul><hr><h2 id="六、索引与-Mapping"><a href="#六、索引与-Mapping" class="headerlink" title="六、索引与 Mapping"></a>六、索引与 Mapping</h2><h3 id="21-Mapping-定义"><a href="#21-Mapping-定义" class="headerlink" title="21. Mapping 定义"></a>21. Mapping 定义</h3><figure class="highlight json"><table><tr><td class="code"><pre><span class="line">PUT /my_index</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;mappings&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;properties&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;title&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;text&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;analyzer&quot;</span><span class="punctuation">:</span> <span class="string">&quot;ik_max_word&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;fields&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">          <span class="attr">&quot;keyword&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;keyword&quot;</span> <span class="punctuation">&#125;</span>   <span class="comment">// 精确匹配用 .keyword</span></span><br><span class="line">        <span class="punctuation">&#125;</span></span><br><span class="line">      <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;price&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;integer&quot;</span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;created_at&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;date&quot;</span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;tags&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;keyword&quot;</span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;description&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;text&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;index&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">false</span></span>       <span class="comment">// 不索引（只存不搜）</span></span><br><span class="line">      <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><h3 id="22-keyword-vs-text"><a href="#22-keyword-vs-text" class="headerlink" title="22. keyword vs text"></a>22. keyword vs text</h3><table><thead><tr><th>类型</th><th>分词</th><th>适用</th><th>排序&#x2F;聚合</th><th>查询方式</th></tr></thead><tbody><tr><td><strong>text</strong></td><td>✅ 分词</td><td>全文搜索（标题、内容）</td><td>❌ 不能直接排序&#x2F;聚合</td><td>match 查询</td></tr><tr><td><strong>keyword</strong></td><td>❌ 不分词</td><td>精确匹配（状态、标签）</td><td>✅ 可以</td><td>term 查询</td></tr></tbody></table><h3 id="23-动态-Mapping"><a href="#23-动态-Mapping" class="headerlink" title="23. 动态 Mapping"></a>23. 动态 Mapping</h3><figure class="highlight json"><table><tr><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;mappings&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;dynamic&quot;</span><span class="punctuation">:</span> <span class="string">&quot;true&quot;</span><span class="punctuation">,</span>        <span class="comment">// 新字段自动加入（默认）</span></span><br><span class="line">    <span class="comment">// &quot;dynamic&quot;: &quot;runtime&quot;,  // 新字段按 runtime 处理（7.11+）</span></span><br><span class="line">    <span class="comment">// &quot;dynamic&quot;: &quot;strict&quot;    // 新字段拒绝写入</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><h3 id="24-Analyzer-分词"><a href="#24-Analyzer-分词" class="headerlink" title="24. Analyzer 分词"></a>24. Analyzer 分词</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">Analyzer = Character Filters + Tokenizer + Token Filters</span><br><span class="line"></span><br><span class="line">常用 Analyzer：</span><br><span class="line">  standard（默认）：按空格/标点切分，小写化</span><br><span class="line">  ik_smart / ik_max_word（中文）：IK 分词器</span><br><span class="line">  keyword：不分词（整个 field 作为一个 term）</span><br><span class="line">  whitespace：按空格切分</span><br><span class="line">  ngram：N-gram 分词（用于模糊匹配/搜索建议）</span><br><span class="line"></span><br><span class="line">分词流程（以 &quot;北京烤鸭&quot; 为例）：</span><br><span class="line">  input: &quot;北京烤鸭&quot;</span><br><span class="line">  → ik_smart: [&quot;北京&quot;, &quot;烤鸭&quot;]</span><br><span class="line">  → ik_max_word: [&quot;北京&quot;, &quot;烤鸭&quot;, &quot;北京烤鸭&quot;]</span><br></pre></td></tr></table></figure><hr><h2 id="七、集群与分片"><a href="#七、集群与分片" class="headerlink" title="七、集群与分片"></a>七、集群与分片</h2><h3 id="25-分片设计"><a href="#25-分片设计" class="headerlink" title="25. 分片设计"></a>25. 分片设计</h3><table><thead><tr><th>决策</th><th>推荐</th><th>原因</th></tr></thead><tbody><tr><td>分片数量</td><td>节点数的 1-3 倍</td><td>分片越多，查询并发越高，但协调成本也高</td></tr><tr><td>单分片大小</td><td>20-50GB</td><td>过大 → 恢复慢；过小 → 分片太多</td></tr><tr><td>主分片</td><td><strong>创建后不可修改</strong></td><td>所以初期要有合理预估</td></tr><tr><td>副本数</td><td>1-2（生产环境）</td><td>0（写入吞吐优先）</td></tr></tbody></table><h3 id="26-路由（Routing）"><a href="#26-路由（Routing）" class="headerlink" title="26. 路由（Routing）"></a>26. 路由（Routing）</h3><figure class="highlight json"><table><tr><td class="code"><pre><span class="line"><span class="comment">// 按 user_id 路由：同一个用户的文档落到同一分片</span></span><br><span class="line">PUT /my_index/_doc/<span class="number">1</span>?routing=user123</span><br><span class="line">GET /my_index/_search?routing=user123  <span class="comment">// 避免广播到所有分片</span></span><br></pre></td></tr></table></figure><h3 id="27-脑裂与集群健康"><a href="#27-脑裂与集群健康" class="headerlink" title="27. 脑裂与集群健康"></a>27. 脑裂与集群健康</h3><h4 id="27-1-集群健康状态"><a href="#27-1-集群健康状态" class="headerlink" title="27.1 集群健康状态"></a>27.1 集群健康状态</h4><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">green：  所有主分片 + 副本分片都正常分配</span><br><span class="line">yellow： 主分片正常，有副本未分配（常见原因：节点数不够）</span><br><span class="line">red：    有主分片未分配（需要立即排查，部分数据不可用）</span><br></pre></td></tr></table></figure><h4 id="27-2-脑裂的原因与-ES-的防护"><a href="#27-2-脑裂的原因与-ES-的防护" class="headerlink" title="27.2 脑裂的原因与 ES 的防护"></a>27.2 脑裂的原因与 ES 的防护</h4><p>脑裂的根本原因：<strong>网络分区导致集群分裂为多个子集群，每个子集群都认为自己合法</strong>。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">ES 中的脑裂场景：</span><br><span class="line">  3 节点集群，Node 1 是 active master</span><br><span class="line">  网络抖动 → Node 1 与 Node 2、3 断开</span><br><span class="line">  → Node 2、3 发现 master 失联 → 发起选举 → Node 2 当选新 master</span><br><span class="line">  → Node 1 还不知道自己被&quot;罢免&quot;，仍在执行 master 任务</span><br><span class="line">  → 两个 master 同时存在 = 脑裂</span><br></pre></td></tr></table></figure><p><strong>ES 的防护</strong>：<strong>Quorum 多数派机制</strong></p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">discovery.zen.minimum_master_nodes = N/2 + 1</span><br><span class="line"></span><br><span class="line">  3 节点 → minimum_master_nodes = 2</span><br><span class="line">  → 选举必须获得至少 2 个节点的同意</span><br><span class="line">  → 出现网络分区时只有多数派能选出 master</span><br><span class="line"></span><br><span class="line">  5 节点分区为 &#123;A,B,C&#125; 和 &#123;D,E&#125;：</span><br><span class="line">  → &#123;A,B,C&#125; 3 票 ≥ 3，可以选举 → 继续服务</span><br><span class="line">  → &#123;D,E&#125;   2 票 &lt; 3，无法选举 → 拒绝服务，保护数据一致性</span><br></pre></td></tr></table></figure><h4 id="27-3-分布式系统处理脑裂的四种通用策略"><a href="#27-3-分布式系统处理脑裂的四种通用策略" class="headerlink" title="27.3 分布式系统处理脑裂的四种通用策略"></a>27.3 分布式系统处理脑裂的四种通用策略</h4><h5 id="策略一：Quorum-选举（多数派投票）"><a href="#策略一：Quorum-选举（多数派投票）" class="headerlink" title="策略一：Quorum 选举（多数派投票）"></a>策略一：Quorum 选举（多数派投票）</h5><p>最通用的方案。选举&#x2F;决策必须获得<strong>半数以上</strong>节点的同意才生效：</p><table><thead><tr><th>系统</th><th>机制</th><th>关键参数</th></tr></thead><tbody><tr><td><strong>ES</strong></td><td>master-eligible 节点数 ≥ N&#x2F;2+1 才选举</td><td><code>minimum_master_nodes</code></td></tr><tr><td><strong>ZooKeeper</strong></td><td>Zab 协议，Leader 需获半数以上投票</td><td>节点数必须为奇数</td></tr><tr><td><strong>etcd</strong></td><td>Raft 协议，Leader 需获多数票</td><td>同上</td></tr><tr><td><strong>Redis Sentinel</strong></td><td>多个哨兵协商，半数以上确认才判定客观下线</td><td><code>quorum = N/2+1</code></td></tr><tr><td><strong>Kafka</strong></td><td>Controller 选举 + ISR 最小副本数</td><td><code>min.insync.replicas</code></td></tr></tbody></table><h5 id="策略二：仲裁盘-Witness-节点"><a href="#策略二：仲裁盘-Witness-节点" class="headerlink" title="策略二：仲裁盘 &#x2F; Witness 节点"></a>策略二：仲裁盘 &#x2F; Witness 节点</h5><p>偶数节点时引入<strong>不存数据的轻量见证者</strong>来打破平局：</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">2 节点集群 + 1 个 Witness：</span><br><span class="line">  正常：A(1票) + B(1票) + Witness(1票) = 3 票</span><br><span class="line">  脑裂时 A 能连通 Witness → 2 票 ≥ N/2+1 → A 胜出</span><br><span class="line">         B 连不通 Witness → 1 票 → 拒绝服务</span><br><span class="line"></span><br><span class="line">Witness 本身不存数据，只参与投票</span><br><span class="line">典型实现：etcd learner 节点、Windows Server 仲裁盘</span><br></pre></td></tr></table></figure><h5 id="策略三：Fencing（资源锁-隔离）"><a href="#策略三：Fencing（资源锁-隔离）" class="headerlink" title="策略三：Fencing（资源锁 &#x2F; 隔离）"></a>策略三：Fencing（资源锁 &#x2F; 隔离）</h5><p>通过<strong>共享资源的排他锁</strong>确保只有一个 leader 能真正写：</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">Fencing Token 机制：</span><br><span class="line">  1. Leader A 获得 token=100（单调递增版本号）</span><br><span class="line">  2. 网络恢复后，旧 A 尝试继续写共享存储</span><br><span class="line">  3. 共享存储检查 → 发现已经有 token=101 的 leader B</span><br><span class="line">  4. 拒绝 A 的写入 → A 自杀或降级为 follower</span><br><span class="line"></span><br><span class="line">典型实现：</span><br><span class="line">  - etcd 的 lease + revision（key 带 lease，过期自动失效）</span><br><span class="line">  - HDFS NameNode 的 edit log fencing</span><br><span class="line">  - SAN/NFS 共享存储上的 lock file + 版本号</span><br></pre></td></tr></table></figure><h5 id="策略四：STONITH（物理隔离）"><a href="#策略四：STONITH（物理隔离）" class="headerlink" title="策略四：STONITH（物理隔离）"></a>策略四：STONITH（物理隔离）</h5><p>最暴力的方案——<strong>直接 kill 掉旧 master</strong>：</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">两个子集群各自认为自己是主：</span><br><span class="line">  → 集群管理器检测到脑裂</span><br><span class="line">  → 向旧 master 物理机发送断电指令（BMC/IPMI）</span><br><span class="line">  → 物理关机，强制确保旧 master 已死</span><br><span class="line">  → 新 master 确认唯一后开始服务</span><br><span class="line"></span><br><span class="line">典型实现：</span><br><span class="line">  - Pacemaker + Corosync（Linux HA 集群）</span><br><span class="line">  - AWS EC2 auto-recovery（detach ENI → attach 新实例）</span><br><span class="line">  - Oracle RAC node eviction</span><br></pre></td></tr></table></figure><h4 id="27-4-脑裂的三层防线"><a href="#27-4-脑裂的三层防线" class="headerlink" title="27.4 脑裂的三层防线"></a>27.4 脑裂的三层防线</h4><p>实际生产系统<strong>组合多层防线</strong>，不是只用一种：</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">第一层：奇数节点 + Quorum</span><br><span class="line">  3/5/7 个节点，多数派选举，少数派自动停服</span><br><span class="line"></span><br><span class="line">第二层：资源隔离（Fencing）</span><br><span class="line">  旧 leader 持有的 lease/锁过期后自动失效</span><br><span class="line">  → 即使旧 leader 还在运行也无法执行写操作</span><br><span class="line"></span><br><span class="line">第三层：物理隔离（STONITH）</span><br><span class="line">  超时后直接 kill -9 或关机</span><br><span class="line">  → 兜底保障，确保不会有&quot;僵尸 leader&quot;</span><br></pre></td></tr></table></figure><h4 id="27-5-面试追问：ES-真的脑裂了会怎样？"><a href="#27-5-面试追问：ES-真的脑裂了会怎样？" class="headerlink" title="27.5 面试追问：ES 真的脑裂了会怎样？"></a>27.5 面试追问：ES 真的脑裂了会怎样？</h4><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">少数派子集群：</span><br><span class="line">  → 无法满足 minimum_master_nodes → 拒绝选举</span><br><span class="line">  → 所有 API 返回 503（master_not_discovered_exception）</span><br><span class="line">  → 读操作：不可用（无法获取最新路由表）</span><br><span class="line">  → 写操作：不可用</span><br><span class="line"></span><br><span class="line">多数派子集群：</span><br><span class="line">  → 成功选出新 master</span><br><span class="line">  → 更新路由表，将少数派节点标记为 lost</span><br><span class="line">  → 将丢失节点上的主分片对应的副本提升为主分片</span><br><span class="line">  → 正常服务</span><br><span class="line"></span><br><span class="line">网络恢复后：</span><br><span class="line">  → 少数派重新加入集群</span><br><span class="line">  → 发现自己数据落后 → 从多数派的节点同步数据</span><br><span class="line">  → 恢复正常</span><br></pre></td></tr></table></figure><h3 id="28-分布式系统故障分级"><a href="#28-分布式系统故障分级" class="headerlink" title="28. 分布式系统故障分级"></a>28. 分布式系统故障分级</h3><p>按影响范围和数据安全风险从轻到重排列：</p><table><thead><tr><th align="center">级别</th><th>故障类型</th><th align="center">数据丢失风险</th><th align="center">可用性影响</th><th align="center">检测难度</th><th align="center">恢复难度</th></tr></thead><tbody><tr><td align="center">L1</td><td>瞬时网络抖动</td><td align="center">无</td><td align="center">秒级延迟</td><td align="center">自愈</td><td align="center">无需恢复</td></tr><tr><td align="center">L2</td><td>慢节点（GC&#x2F;IO 打满）</td><td align="center">无</td><td align="center">拖慢请求</td><td align="center">中等</td><td align="center">自动&#x2F;手动踢出</td></tr><tr><td align="center">L3</td><td>单节点宕机</td><td align="center">无（有副本时）</td><td align="center">秒~分钟级</td><td align="center">容易（心跳超时）</td><td align="center"><strong>自动恢复</strong></td></tr><tr><td align="center">L4</td><td>磁盘故障</td><td align="center">可能（无其他副本时）</td><td align="center">分钟~小时</td><td align="center">容易（IO 报错）</td><td align="center">从副本重建</td></tr><tr><td align="center">L5</td><td>静默数据损坏</td><td align="center"><strong>有（读到错数据）</strong></td><td align="center">无感知</td><td align="center"><strong>极难</strong></td><td align="center"><strong>需 checksum 修复</strong></td></tr><tr><td align="center">L6</td><td>网络分区&#x2F;脑裂</td><td align="center">有（双写后丢弃）</td><td align="center">少数派不可用</td><td align="center">中等</td><td align="center">依赖 Quorum</td></tr><tr><td align="center">L7</td><td>级联失败&#x2F;雪崩</td><td align="center">无</td><td align="center"><strong>全面不可用</strong></td><td align="center">中等</td><td align="center">需熔断&#x2F;降级</td></tr><tr><td align="center">L8</td><td>人为误操作</td><td align="center"><strong>高</strong></td><td align="center">不定</td><td align="center">容易（操作记录）</td><td align="center"><strong>需备份&#x2F;快照</strong></td></tr><tr><td align="center">L9</td><td>机房级故障</td><td align="center">有（单机房无容灾）</td><td align="center"><strong>长时间不可用</strong></td><td align="center">容易</td><td align="center">多机房容灾</td></tr></tbody></table><h4 id="28-1-L1-瞬时网络抖动"><a href="#28-1-L1-瞬时网络抖动" class="headerlink" title="28.1 L1 瞬时网络抖动"></a>28.1 L1 瞬时网络抖动</h4><p><strong>症状</strong>：节点间个别心跳超时、请求偶发 timeout，但很快自动恢复。</p><p><strong>影响</strong>：瞬间的延迟升高或无响应，不影响数据安全，不影响可用性（重试即可）。</p><p><strong>恢复机制</strong>：客户端重试 + 指数退避；心跳超时时间设置合理（不因短暂抖动触发选举）。ES 的 <code>zen.fd.ping_timeout</code> 默认 30s。</p><h4 id="28-2-L2-慢节点-Straggler"><a href="#28-2-L2-慢节点-Straggler" class="headerlink" title="28.2 L2 慢节点 &#x2F; Straggler"></a>28.2 L2 慢节点 &#x2F; Straggler</h4><p><strong>症状</strong>：个别节点因 GC 停顿、磁盘 IO 打满、CPU 飙高而响应极慢，但未宕机。</p><p><strong>影响</strong>：拖慢整个请求链路（分布式任务等最慢的那个节点），可能导致超时风暴。</p><p><strong>恢复机制</strong>：</p><ul><li><strong>Hedged Read（对冲读）</strong>：同时向两个副本发请求，谁先返回用谁的结果</li><li><strong>Backup Request</strong>：超时后立刻向另一个节点重发</li><li>慢节点连续慢多次后踢出集群（但慎用，可能引发连锁反应）</li></ul><p><strong>真实案例</strong>：ES Data 节点 Full GC → 30s+ 停顿 → master 探测超时 → 误判宕机 → 触发不必要选举。</p><h4 id="28-3-L3-单节点宕机"><a href="#28-3-L3-单节点宕机" class="headerlink" title="28.3 L3 单节点宕机"></a>28.3 L3 单节点宕机</h4><p><strong>症状</strong>：进程崩溃 &#x2F; OOM 被杀 &#x2F; 机器重启，节点彻底死亡。</p><p><strong>影响</strong>：<strong>不丢数据</strong>（有副本），短暂不可用（故障转移期间）。如果无副本，该节点上的主分片全部丢失 → red 状态 → 数据丢失。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">无副本：主分片全部丢失 → red → 数据丢失</span><br><span class="line">有副本：副本自动提升为主分片 → yellow（副本数不足）→ 自动重建副本 → green</span><br></pre></td></tr></table></figure><p><strong>恢复机制</strong>：</p><ul><li>ES：master 30s 后感知失联 → 副本提升 → 在其他节点重建缺失副本</li><li>Redis Sentinel：哨兵检测主观下线 → 协商客观下线 → 故障转移</li><li>Kafka：Broker 宕机 → Controller 重新分配 Leader Partition</li></ul><h4 id="28-4-L4-磁盘故障"><a href="#28-4-L4-磁盘故障" class="headerlink" title="28.4 L4 磁盘故障"></a>28.4 L4 磁盘故障</h4><p><strong>症状</strong>：磁盘坏道、读写错误、IO 彻底挂死（等级比 Node Crash 更高，数据可能永久丢失）。</p><p><strong>影响</strong>：坏盘上所有分片数据全部损坏。如果有副本 → 自动从副本恢复；<strong>所有副本恰好在这块坏盘上</strong>（概率低但可能）→ 数据永久丢失。</p><p><strong>恢复机制</strong>：多副本（至少 1 个 replica）；ES 检测到磁盘故障 → 自动 exclude 该节点 → 从副本在其他节点重建数据。</p><h4 id="28-5-L5-静默数据损坏"><a href="#28-5-L5-静默数据损坏" class="headerlink" title="28.5 L5 静默数据损坏"></a>28.5 L5 静默数据损坏</h4><p><strong>症状</strong>：磁盘返回<strong>错误数据但没报错</strong>（最隐蔽、最危险）。读出的数据是错的，但系统认为正常，错误会传播到下游。</p><p><strong>恢复机制</strong>：</p><ul><li><strong>端到端校验</strong>：写入时存 checksum，读取时验证，不匹配则从副本修复</li><li>ES：Lucene 在 Segment 写入时写 CRC32 checksum，读取时验证</li><li>HDFS：每个数据块存 MD5 checksum，定期校验</li><li>MySQL InnoDB：page checksum，崩溃恢复时验证</li></ul><h4 id="28-6-L6-网络分区-脑裂"><a href="#28-6-L6-网络分区-脑裂" class="headerlink" title="28.6 L6 网络分区 &#x2F; 脑裂"></a>28.6 L6 网络分区 &#x2F; 脑裂</h4><p>详见前文「27. 脑裂与集群健康」（Quorum + Fencing + STONITH 三层防线）。</p><h4 id="28-7-L7-级联失败-雪崩"><a href="#28-7-L7-级联失败-雪崩" class="headerlink" title="28.7 L7 级联失败 &#x2F; 雪崩"></a>28.7 L7 级联失败 &#x2F; 雪崩</h4><p><strong>症状</strong>：一个服务挂掉 → 调用方超时等待 → 线程池耗尽 → 调用方也挂掉 → 继续向上传播。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">服务 A 调用 ES → ES 慢 → A 的线程全在等 ES → A 的 health check 超时</span><br><span class="line">→ 上游 B 调用 A 也超时 → B 挂掉 → ... → 整个链路全挂</span><br></pre></td></tr></table></figure><p><strong>恢复机制（四件套）</strong>：</p><ul><li><strong>熔断（Circuit Breaker）</strong>：连续失败 N 次后直接快速失败</li><li><strong>限流（Rate Limiting）</strong>：超过 QPS 阈值直接拒绝</li><li><strong>超时（Timeout）</strong>：设置合理最大等待时间，不无限等待</li><li><strong>降级（Degradation）</strong>：ES 不可用时返回缓存数据或热门推荐兜底</li></ul><h4 id="28-8-L8-人为误操作"><a href="#28-8-L8-人为误操作" class="headerlink" title="28.8 L8 人为误操作"></a>28.8 L8 人为误操作</h4><p><strong>症状</strong>：rm -rf &#x2F; DROP TABLE &#x2F; 错误的线上配置 &#x2F; 批量删除脚本写错 where（比机器故障严重得多——机器宕机有自动化恢复，人为一次删除千万行不会自动回滚）。</p><p><strong>恢复机制</strong>：</p><ul><li><strong>事前</strong>：权限控制、操作审批、二次确认</li><li><strong>事后</strong>：快速回滚、快照恢复、binlog 回放</li><li>ES 8.0 的 DELETE index 是软删除，有回收期可恢复</li></ul><h4 id="28-9-L9-机房级故障"><a href="#28-9-L9-机房级故障" class="headerlink" title="28.9 L9 机房级故障"></a>28.9 L9 机房级故障</h4><p><strong>症状</strong>：交换机故障、光纤被挖断、整机房断电、地震洪水。<strong>所有副本跟着一起挂</strong>。</p><p><strong>恢复机制</strong>：</p><ul><li><strong>多机房&#x2F;多可用区部署</strong>：副本分布在不同的物理机架&#x2F;机房</li><li>ES 的 rack awareness 配置：</li></ul><figure class="highlight json"><table><tr><td class="code"><pre><span class="line"><span class="comment">// bin/elasticsearch -Enode.attr.rack_id=rack1</span></span><br><span class="line"></span><br><span class="line">PUT /_cluster/settings</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;transient&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;cluster.routing.allocation.awareness.attributes&quot;</span><span class="punctuation">:</span> <span class="string">&quot;rack_id&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;cluster.routing.allocation.awareness.force.rack_id.values&quot;</span><span class="punctuation">:</span> <span class="string">&quot;rack1,rack2&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br><span class="line"><span class="comment">// ES 确保同一个分片的主副本不在同一个 rack 上</span></span><br></pre></td></tr></table></figure><h3 id="29-分布式协议"><a href="#29-分布式协议" class="headerlink" title="29. 分布式协议"></a>29. 分布式协议</h3><p>ES 没有用 ZK&#x2F;Raft&#x2F;Paxos，而是<strong>自建协议组合</strong>：</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">┌────────────────────────────────────────────────┐</span><br><span class="line">│              ES 分布式协议栈                      │</span><br><span class="line">├────────────────────────────────────────────────┤</span><br><span class="line">│  节点发现    → Zen Discovery（基于单播/种子节点）   │</span><br><span class="line">│  主节点选举  → Bully 算法变体（node ID 最小者胜出）│</span><br><span class="line">│  状态同步    → Gossip 协议（定期交换集群元数据）    │</span><br><span class="line">│  数据复制    → Primary-Backup（主分片写入 → 副本同步）│</span><br><span class="line">└────────────────────────────────────────────────┘</span><br></pre></td></tr></table></figure><h4 id="29-1-节点发现（Zen-Discovery）"><a href="#29-1-节点发现（Zen-Discovery）" class="headerlink" title="29.1 节点发现（Zen Discovery）"></a>29.1 节点发现（Zen Discovery）</h4><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">每个节点配置 discovery.seed_hosts（种子节点列表）：</span><br><span class="line"></span><br><span class="line">discovery.seed_hosts: [&quot;node1:9300&quot;, &quot;node2:9300&quot;, &quot;node3:9300&quot;]</span><br><span class="line"></span><br><span class="line">启动流程：</span><br><span class="line">  Node 启动 → ping 种子节点 → 获取集群中所有节点列表</span><br><span class="line">  → 选择主节点 → 加入集群 → 开始接收分片分配</span><br></pre></td></tr></table></figure><h4 id="29-2-主节点选举（Bully-算法变体）"><a href="#29-2-主节点选举（Bully-算法变体）" class="headerlink" title="29.2 主节点选举（Bully 算法变体）"></a>29.2 主节点选举（Bully 算法变体）</h4><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">规则：node ID 最小的 master-eligible 节点当选</span><br><span class="line"></span><br><span class="line">  节点发现阶段，所有 master-eligible 节点互发 ping</span><br><span class="line">  → 各自收集到集群中所有符合资格的节点 ID</span><br><span class="line">  → 比较 node ID，最小值者自我宣布为 master</span><br><span class="line">  → 得票数 ≥ N/2+1（多数派）才正式当选</span><br><span class="line"></span><br><span class="line">  如果当前 master 宕机：</span><br><span class="line">  → 其他 master-eligible 节点 ping 不通 master（默认 30s 超时）</span><br><span class="line">  → 发起新一轮选举，node ID 最小者胜出</span><br><span class="line"></span><br><span class="line">为什么 node ID 最小者胜出？</span><br><span class="line">  node ID 在节点启动时生成（持久化到磁盘），重启不变</span><br><span class="line">  → 集群重启后同一个节点通常再次当选，减少不必要的 master 切换</span><br></pre></td></tr></table></figure><h4 id="29-3-状态同步（Gossip-协议）"><a href="#29-3-状态同步（Gossip-协议）" class="headerlink" title="29.3 状态同步（Gossip 协议）"></a>29.3 状态同步（Gossip 协议）</h4><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">每个节点定期（默认 1s）向随机选中的另一个节点发送 Gossip 消息：</span><br><span class="line"></span><br><span class="line">Gossip 消息内容：</span><br><span class="line">  - 集群状态版本号</span><br><span class="line">  - 已知的节点列表（含存活状态）</span><br><span class="line">  - 分片路由表（哪个分片在哪个节点上）</span><br><span class="line">  - 索引元数据（mapping、settings）</span><br><span class="line"></span><br><span class="line">  版本号低的节点从版本号高的节点拉取最新状态</span><br><span class="line">  → 最终一致性：数秒内所有节点状态一致</span><br><span class="line">  → 无中心节点，任意节点宕机不影响信息传播</span><br></pre></td></tr></table></figure><h4 id="29-4-为什么不依赖-ZK？"><a href="#29-4-为什么不依赖-ZK？" class="headerlink" title="29.4 为什么不依赖 ZK？"></a>29.4 为什么不依赖 ZK？</h4><table><thead><tr><th>对比</th><th>ES（自建）</th><th>依赖 ZK（如 Solr&#x2F;Kafka 旧版）</th></tr></thead><tbody><tr><td>部署</td><td>零依赖，开箱即用</td><td>需要额外部署 ZK 集群</td></tr><tr><td>运维</td><td>不需要维护外部系统</td><td>需要维护 ZK 集群</td></tr><tr><td>故障域</td><td>单一系统</td><td>两个系统关联故障</td></tr></tbody></table><h3 id="30-节点角色"><a href="#30-节点角色" class="headerlink" title="30. 节点角色"></a>30. 节点角色</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">ES 7.x+ 节点角色由 node.roles 配置：</span><br><span class="line"></span><br><span class="line">node.roles: [master, data, ingest, ml, remote_cluster_client, transform]</span><br></pre></td></tr></table></figure><table><thead><tr><th>角色</th><th>配置值</th><th>职责</th><th>资源需求</th></tr></thead><tbody><tr><td><strong>Master-eligible</strong></td><td><code>master</code></td><td>集群管理（创建&#x2F;删除索引、分片分配、节点增删）</td><td>CPU 轻，内存小</td></tr><tr><td><strong>Data</strong></td><td><code>data</code></td><td>存储数据、执行数据操作（CRUD、搜索、聚合）</td><td><strong>CPU 高、内存大、磁盘 IO 高</strong></td></tr><tr><td><strong>Ingest</strong></td><td><code>ingest</code></td><td>预处理管道（字段修改、数据丰富、格式转换）</td><td>CPU 中，内存中</td></tr><tr><td><strong>Coordinating</strong></td><td>（无特殊角色）</td><td>请求路由、结果合并（默认所有节点都是）</td><td>CPU 中，内存小</td></tr><tr><td><strong>ML</strong></td><td><code>ml</code></td><td>机器学习作业（异常检测、预测）</td><td>CPU 极高，内存高</td></tr><tr><td><strong>Transform</strong></td><td><code>transform</code></td><td>数据转换（聚合后存到目标索引）</td><td>CPU 中</td></tr><tr><td><strong>Remote-cluster-client</strong></td><td><code>remote_cluster_client</code></td><td>跨集群搜索的连接出口</td><td>网络带宽</td></tr></tbody></table><h4 id="常见角色组合"><a href="#常见角色组合" class="headerlink" title="常见角色组合"></a>常见角色组合</h4><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">生产环境 3 节点小集群（每个节点都允许参与选举）：</span><br><span class="line">  Node 1: [master, data, ingest]</span><br><span class="line">  Node 2: [master, data, ingest]</span><br><span class="line">  Node 3: [master, data, ingest]</span><br><span class="line"></span><br><span class="line">  3 个节点都是 master-eligible，但同一时刻只有 1 个 active master</span><br><span class="line">  → 挂 1 个节点仍可用，剩余 2 个足以选举出新 master（≥ N/2+1 = 2 票）</span><br><span class="line"></span><br><span class="line">10+ 节点大集群专用角色：</span><br><span class="line">  3 个 Master-only:  [master]       ← 只做集群管理，不存数据</span><br><span class="line">  N 个 Data-only:    [data, ingest] ← 只存数据和预处理</span><br><span class="line">  2 个 Coord-only:   []             ← 只做请求协调（负载均衡入口）</span><br><span class="line">  1 个 ML-only:      [ml]           ← 只跑机器学习</span><br></pre></td></tr></table></figure><p><strong>master-eligible vs active master</strong>：</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">master-eligible：配置了 [master] 角色的节点，有资格参与选举（可以有多个）</span><br><span class="line">active master：  当前真正执行集群管理的节点（同一时刻只有一个）</span><br><span class="line"></span><br><span class="line">3 节点都配 [master] 不是为了&quot;多个 master 同时干活&quot;，</span><br><span class="line">而是为了容错——挂 1 个后，剩余 2 个仍能选出新 master。</span><br><span class="line"></span><br><span class="line">master 节点数的多数派规则：</span><br><span class="line">  N=1 → 容忍 0 宕机（单点故障）</span><br><span class="line">  N=2 → 容忍 0 宕机（挂 1 个后凑不出多数票 N/2+1=2）</span><br><span class="line">  N=3 → 容忍 1 宕机 ✅</span><br><span class="line">  N=5 → 容忍 2 宕机</span><br></pre></td></tr></table></figure><p><strong>专用角色的好处</strong>：</p><ul><li>Master 节点不存数据 → 不会因为 GC 停顿被误判为宕机，避免不必要的选举（小集群混合部署的风险正在于此）</li><li>Data 节点专做数据处理 → 不受管理任务干扰</li><li>Coordinating 节点做负载均衡入口 → 客户端连接分散，减少 Data 节点的 HTTP 连接压力</li></ul><h3 id="31-扩容与缩容"><a href="#31-扩容与缩容" class="headerlink" title="31. 扩容与缩容"></a>31. 扩容与缩容</h3><h4 id="31-1-扩容（添加-Data-节点）"><a href="#31-1-扩容（添加-Data-节点）" class="headerlink" title="31.1 扩容（添加 Data 节点）"></a>31.1 扩容（添加 Data 节点）</h4><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">新节点 node-4 加入集群：</span><br><span class="line"></span><br><span class="line">Step 1: 启动 node-4, 配置相同的 cluster.name 和 discovery.seed_hosts</span><br><span class="line">Step 2: node-4 通过种子节点发现集群</span><br><span class="line">        → 向 master 发送加入请求</span><br><span class="line">Step 3: master 将 node-4 加入集群状态</span><br><span class="line">        → Gossip 传播到所有节点（1-2 秒内全部感知）</span><br><span class="line">Step 4: master 触发 rebalance（重新分片分配）</span><br><span class="line">        → 从现有 Data 节点迁移部分分片到 node-4</span><br><span class="line"></span><br><span class="line">━━━━ 分片迁移细节 ━━━━</span><br><span class="line"></span><br><span class="line">  迁移单个分片（以 P0 从 node-1 迁到 node-4 为例）：</span><br><span class="line">    1. master 向 node-1 发指令：将 P0 迁到 node-4</span><br><span class="line">    2. node-1 在 Lucene 层创建 P0 的快照（不阻塞写入）</span><br><span class="line">    3. node-4 从 node-1 拉取分片数据</span><br><span class="line">    4. 拉取期间，P0 的写入同时发给 node-1 和 node-4（双写）</span><br><span class="line">    5. 数据同步完成后，master 更新路由表 → P0 移到 node-4</span><br><span class="line">    6. node-1 删除 P0 的本地数据</span><br></pre></td></tr></table></figure><p><strong>迁移期间的读写</strong>：旧节点继续服务直到新节点数据追上；迁移期间双写保证不丢数据。</p><p><strong>扩容感知时间</strong>：</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">T+0s     node-4 启动，ping 种子节点</span><br><span class="line">T+1s     master 感知新节点，更新集群状态</span><br><span class="line">T+2s     Gossip 传播，全集群感知新节点</span><br><span class="line">T+3s     master 开始 rebalance</span><br><span class="line">T+N min  分片迁移完成（取决于数据量和带宽）</span><br></pre></td></tr></table></figure><p><strong>控制迁移速度</strong>：</p><figure class="highlight json"><table><tr><td class="code"><pre><span class="line">PUT /_cluster/settings</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;transient&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;cluster.routing.allocation.node_concurrent_recoveries&quot;</span><span class="punctuation">:</span> <span class="number">2</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;indices.recovery.max_bytes_per_sec&quot;</span><span class="punctuation">:</span> <span class="string">&quot;40mb&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br><span class="line"><span class="comment">// 过低 → 迁移慢；过高 → 占用 IO/网络，影响正常读写</span></span><br></pre></td></tr></table></figure><h4 id="31-2-缩容（安全下线-Data-节点）"><a href="#31-2-缩容（安全下线-Data-节点）" class="headerlink" title="31.2 缩容（安全下线 Data 节点）"></a>31.2 缩容（安全下线 Data 节点）</h4><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">Step 1: 通知 master 该节点即将下线</span><br><span class="line"></span><br><span class="line">PUT /_cluster/settings</span><br><span class="line">&#123;</span><br><span class="line">  &quot;transient&quot;: &#123;</span><br><span class="line">    &quot;cluster.routing.allocation.exclude._ip&quot;: &quot;10.0.0.5&quot;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">Step 2: master 执行 decommission — 将该节点上的所有分片迁移到其他节点</span><br><span class="line">Step 3: 监控迁移进度</span><br><span class="line">        GET /_cat/shards?v      → 确认已排除节点上无分片</span><br><span class="line">        GET /_cat/recovery?v    → 确认无进行中的迁移</span><br><span class="line">Step 4: 停止节点进程（安全下线）</span><br></pre></td></tr></table></figure><p><strong>直接 kill 的风险</strong>：节点上如有主分片 → master 30s 后感知失联 → 副本提升为主分片 → 未复制完的新数据（还在 Translog）丢失。安全下线必须走上述流程，给 master 时间迁移数据。</p><h4 id="31-3-Master-节点变更"><a href="#31-3-Master-节点变更" class="headerlink" title="31.3 Master 节点变更"></a>31.3 Master 节点变更</h4><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">添加 Master-eligible 节点：启动后自动参与选举，不存数据无需迁移</span><br><span class="line">移除 Master-eligible 节点：直接停进程即可，不存数据</span><br><span class="line"></span><br><span class="line">重要：Master 节点数必须为奇数（3/5/7），避免脑裂时两派票数相等</span><br></pre></td></tr></table></figure><h4 id="31-4-扩容缩容速查"><a href="#31-4-扩容缩容速查" class="headerlink" title="31.4 扩容缩容速查"></a>31.4 扩容缩容速查</h4><table><thead><tr><th>操作</th><th>关键步骤</th><th>影响</th><th>时长</th></tr></thead><tbody><tr><td>加 Data 节点</td><td>启动 → 自动发现 → rebalance</td><td>短暂 IO&#x2F;网络升高</td><td>分钟~小时</td></tr><tr><td>减 Data 节点</td><td>exclude IP → 迁移分片 → 停机</td><td>同上</td><td>分钟~小时</td></tr><tr><td>加 Master 节点</td><td>启动 → 加入选举</td><td>无数据迁移</td><td>秒级</td></tr><tr><td>减 Master 节点</td><td>停进程</td><td>无数据迁移</td><td>秒级</td></tr><tr><td>加 Coord 节点</td><td>启动 → 自动发现</td><td>零影响</td><td>秒级</td></tr><tr><td>强制 kill -9</td><td>直接杀进程</td><td><strong>可能丢数据</strong></td><td>立刻</td></tr></tbody></table><hr><h2 id="八、地理位置查询"><a href="#八、地理位置查询" class="headerlink" title="八、地理位置查询"></a>八、地理位置查询</h2><h3 id="32-ES-地理位置索引原理"><a href="#32-ES-地理位置索引原理" class="headerlink" title="32. ES 地理位置索引原理"></a>32. ES 地理位置索引原理</h3><p>ES 支持两种地理字段类型：</p><figure class="highlight json"><table><tr><td class="code"><pre><span class="line">PUT /my_index</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;mappings&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;properties&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;location&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;geo_point&quot;</span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;area&quot;</span><span class="punctuation">:</span>     <span class="punctuation">&#123;</span> <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;geo_shape&quot;</span> <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><figure class="highlight json"><table><tr><td class="code"><pre><span class="line"><span class="comment">// geo_distance 查询（附近 5km 的商户）</span></span><br><span class="line">GET /restaurant/_search</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;query&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;bool&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;filter&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;geo_distance&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">          <span class="attr">&quot;distance&quot;</span><span class="punctuation">:</span> <span class="string">&quot;5km&quot;</span><span class="punctuation">,</span></span><br><span class="line">          <span class="attr">&quot;location&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;lat&quot;</span><span class="punctuation">:</span> <span class="number">39.9</span><span class="punctuation">,</span> <span class="attr">&quot;lon&quot;</span><span class="punctuation">:</span> <span class="number">116.4</span> <span class="punctuation">&#125;</span></span><br><span class="line">        <span class="punctuation">&#125;</span></span><br><span class="line">      <span class="punctuation">&#125;</span><span class="punctuation">]</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// geo_bounding_box（矩形范围查询）</span></span><br><span class="line">GET /restaurant/_search</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;query&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;geo_bounding_box&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;location&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;top_left&quot;</span><span class="punctuation">:</span>     <span class="punctuation">&#123;</span> <span class="attr">&quot;lat&quot;</span><span class="punctuation">:</span> <span class="number">40.0</span><span class="punctuation">,</span> <span class="attr">&quot;lon&quot;</span><span class="punctuation">:</span> <span class="number">116.0</span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;bottom_right&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;lat&quot;</span><span class="punctuation">:</span> <span class="number">39.5</span><span class="punctuation">,</span> <span class="attr">&quot;lon&quot;</span><span class="punctuation">:</span> <span class="number">116.8</span> <span class="punctuation">&#125;</span></span><br><span class="line">      <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><h3 id="33-地理位置不是存在-FST-里的"><a href="#33-地理位置不是存在-FST-里的" class="headerlink" title="33. 地理位置不是存在 FST 里的"></a>33. 地理位置不是存在 FST 里的</h3><p>FST 存的是<strong>倒排索引的 term</strong>（分词后的词条），而经纬度是连续的数值，不适合做精确分词。</p><p>ES 用两种方式实现地理索引，都<strong>不是 FST</strong>：</p><table><thead><tr><th>索引方式</th><th>ES 版本</th><th>数据结构</th><th>存储位置</th></tr></thead><tbody><tr><td><strong>Geohash</strong></td><td>5.x+</td><td>Trie 前缀树</td><td>Lucene BKD Tree 之前的方案</td></tr><tr><td><strong>BKD Tree（默认）</strong></td><td>6.x+</td><td><strong>BKD Tree（Block KD Tree）</strong></td><td>列存（DocValues）</td></tr></tbody></table><h4 id="28-1-Geohash-编码"><a href="#28-1-Geohash-编码" class="headerlink" title="28.1 Geohash 编码"></a>28.1 Geohash 编码</h4><p>Geohash 将经纬度二维坐标编码为一维字符串，前缀越长精度越高。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">北京天安门：lat=39.9, lon=116.4</span><br><span class="line"></span><br><span class="line">Geohash 编码过程（base32，交替二分经纬度，5位一组）：</span><br><span class="line">  Step 1: 纬度 [-90, 90] 二分 → 39.9 在右半区 → 1</span><br><span class="line">  Step 2: 经度 [-180, 180] 二分 → 116.4 在右半区 → 1</span><br><span class="line">  Step 3: 纬度 [0, 90] 二分 → 39.9 在右半区 → 1</span><br><span class="line">  Step 4: 经度 [90, 180] 二分 → 116.4 在右半区 → 1</span><br><span class="line">  ... 重复 20 次得 20 位二进制序列，再 base32 编码</span><br><span class="line"></span><br><span class="line">  结果: wx4g0f（~1.2km 精度）</span><br><span class="line">        wx4g0f8（~100m 精度）</span><br><span class="line">        wx4g0f8d（~2m 精度）</span><br><span class="line">        wx4g0f8d（~2m 精度）</span><br></pre></td></tr></table></figure><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">// 索引时按多级精度拆分为多个 term（存入倒排索引的 FST）</span><br><span class="line">&quot;location&quot; → [</span><br><span class="line">    term: &quot;w&quot;,       精度 ~5000km    (level 1)</span><br><span class="line">    term: &quot;wx&quot;,      精度 ~1000km    (level 2)</span><br><span class="line">    term: &quot;wx4&quot;,     精度 ~100km     (level 3)</span><br><span class="line">    term: &quot;wx4g&quot;,    精度 ~30km      (level 4)</span><br><span class="line">    term: &quot;wx4g0&quot;,   精度 ~5km       (level 5)</span><br><span class="line">    term: &quot;wx4g0f&quot;,  精度 ~1.2km    (level 6)</span><br><span class="line">]</span><br><span class="line"></span><br><span class="line">// geo_distance 查询 5km 内：</span><br><span class="line">// → 用 level 5 的 geohash 前缀 &quot;wx4g0&quot; 去 FST 中匹配</span><br></pre></td></tr></table></figure><p><strong>Geohash 的问题</strong>：</p><ul><li><strong>边界问题</strong>：两个很近的点如果在 geohash 边界上，前缀可能完全不同</li><li>精度不连续：从一个级别跳到下一个，精度变化不连续</li></ul><h4 id="28-2-BKD-Tree（ES-6-x-默认方案）"><a href="#28-2-BKD-Tree（ES-6-x-默认方案）" class="headerlink" title="28.2 BKD Tree（ES 6.x+ 默认方案）"></a>28.2 BKD Tree（ES 6.x+ 默认方案）</h4><p>BKD Tree &#x3D; <strong>Block KD Tree</strong>，是 KD Tree 的磁盘优化版本，专门处理<strong>多维范围查询</strong>。</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">KD Tree 分割示意（二维空间，交替按经纬度分割）：</span><br><span class="line"></span><br><span class="line">第一次分割（经度）：</span><br><span class="line">  116.0─┬──────106.0─────────────────125.0──</span><br><span class="line">        │        │                      │</span><br><span class="line">        │    A组 │                  B组  │</span><br><span class="line">  39.0──┼────────┼──────────────────────┼──</span><br><span class="line">        │    A组 │                  B组  │</span><br><span class="line">  37.0──┴────────┴──────────────────────┴──</span><br><span class="line"></span><br><span class="line">第二次分割（纬度），在 A/B 组内各自再分</span><br></pre></td></tr></table></figure><p><strong>BKD Tree 的搜索过程</strong>：</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">查询：geo_bounding_box, lat=[39.0, 40.0], lon=[116.0, 116.5]</span><br><span class="line"></span><br><span class="line">Step 1: 检查根节点 → 有重叠 → 继续</span><br><span class="line">Step 2: 左子树（经度 &lt; 106）→ 查询范围经度 116.0+ → 不重叠 → 剪枝跳过</span><br><span class="line">Step 3: 右子树（经度 &gt;= 106）→ 重叠 → 继续</span><br><span class="line">Step 4: 右子树的左子节点（纬度 &lt; 38）→ 查询纬度 39.0+ → 不重叠 → 剪枝</span><br><span class="line">Step 5: 右子树的右子节点（纬度 &gt;= 38）→ 重叠 → 到达叶子</span><br><span class="line">Step 6: 在 Leaf Block 中逐条检查经纬度是否在范围内</span><br></pre></td></tr></table></figure><p><strong>坐标距离计算</strong>：</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">小范围（&lt; 1° 约 100km）：近似为平面 Euclidean 距离，快</span><br><span class="line">中范围（&lt; 10°）：Haversine 公式（球面三角）</span><br><span class="line">大范围：Vincenty 公式（椭球体，最精确）</span><br><span class="line"></span><br><span class="line">Haversine(lat1, lon1, lat2, lon2)：</span><br><span class="line">  a = sin²(Δlat/2) + cos(lat1)·cos(lat2)·sin²(Δlon/2)</span><br><span class="line">  c = 2 · atan2(√a, √(1-a))</span><br><span class="line">  d = 6371 · c     // 地球半径 6371km</span><br></pre></td></tr></table></figure><h4 id="28-3-BKD-Tree-vs-FST"><a href="#28-3-BKD-Tree-vs-FST" class="headerlink" title="28.3 BKD Tree vs FST"></a>28.3 BKD Tree vs FST</h4><table><thead><tr><th>维度</th><th>FST（倒排索引）</th><th>BKD Tree（数值&#x2F;地理索引）</th></tr></thead><tbody><tr><td>数据结构</td><td>有限状态转换器</td><td>块状 KD Tree</td></tr><tr><td>存储位置</td><td>Segment 的 Terms 索引</td><td><strong>DocValues（列存）</strong></td></tr><tr><td>适用类型</td><td><strong>离散词条</strong>（文本&#x2F;关键词）</td><td><strong>连续数值</strong>（int&#x2F;long&#x2F;float&#x2F;geo）</td></tr><tr><td>查询方式</td><td>term 精确匹配 + 前缀匹配</td><td><strong>范围剪枝</strong>（range&#x2F;bounding box）</td></tr><tr><td>内存</td><td>FST 常驻内存（~1MB）</td><td>BKD 索引在内存，数据块在磁盘</td></tr></tbody></table><h4 id="28-4-DocValues-中的-Segment-全景"><a href="#28-4-DocValues-中的-Segment-全景" class="headerlink" title="28.4 DocValues 中的 Segment 全景"></a>28.4 DocValues 中的 Segment 全景</h4><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">一个 Document 被索引到 Segment 时，不同 field 类型走不同的索引结构：</span><br><span class="line"></span><br><span class="line">┌──────────────── Segment ─────────────────┐</span><br><span class="line">│                                           │</span><br><span class="line">│  FST（倒排索引 Term Index）                │</span><br><span class="line">│    ├── text 字段的 term（分词后）            │</span><br><span class="line">│    ├── keyword 字段的 term（整体作为 term）  │</span><br><span class="line">│    └── geohash 的 term（如果启用）          │</span><br><span class="line">│                                           │</span><br><span class="line">│  BKD Tree（多维数值索引，DocValues 内）     │</span><br><span class="line">│    ├── int/long 字段（范围查询）             │</span><br><span class="line">│    ├── float/double 字段（范围查询）         │</span><br><span class="line">│    └── geo_point（经纬度范围查询）           │</span><br><span class="line">│                                           │</span><br><span class="line">│  DocValues（列存，排序/聚合）               │</span><br><span class="line">│    ├── 所有 fields 的列式存储               │</span><br><span class="line">│    └── BKD Tree 作为 DocValues 的索引       │</span><br><span class="line">│                                           │</span><br><span class="line">│  Stored Fields（_source）                 │</span><br><span class="line">│    └── 原始 JSON 数据                      │</span><br><span class="line">└───────────────────────────────────────────┘</span><br></pre></td></tr></table></figure><h4 id="28-5-geo-distance-完整执行流程"><a href="#28-5-geo-distance-完整执行流程" class="headerlink" title="28.5 geo_distance 完整执行流程"></a>28.5 geo_distance 完整执行流程</h4><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">查询：附近 5km 内的商户，按距离排序</span><br><span class="line"></span><br><span class="line">Step 1: BKD Tree 范围剪枝</span><br><span class="line">        计算查询中心点 [39.9, 116.4] 的 5km 范围边界</span><br><span class="line">        经纬度范围：lat=[39.85, 39.95], lon=[116.35, 116.45]</span><br><span class="line">        用 BKD Tree 快速找到在此范围内的 docID 列表</span><br><span class="line"></span><br><span class="line">Step 2: DocValues 读取精确位置</span><br><span class="line">        对候选 docID 从 DocValues（列存）读取精确的经纬度</span><br><span class="line"></span><br><span class="line">Step 3: Haversine 精确过滤</span><br><span class="line">        对每个候选 doc 计算 Haversine 距离</span><br><span class="line">        剔除 5km 边缘附近 BKD 范围模糊的文档</span><br><span class="line"></span><br><span class="line">Step 4: 排序（_geo_distance sort）</span><br><span class="line">        按实际距离升序排列</span><br><span class="line"></span><br><span class="line">如果查询中带有其他条件（如 bool + term filter），</span><br><span class="line">则在 Step 1 之前先做其他条件的过滤，再在过滤结果上做 geo 查询。</span><br></pre></td></tr></table></figure><h4 id="28-6-地理位置查询的优化"><a href="#28-6-地理位置查询的优化" class="headerlink" title="28.6 地理位置查询的优化"></a>28.6 地理位置查询的优化</h4><table><thead><tr><th>优化手段</th><th>效果</th><th>说明</th></tr></thead><tbody><tr><td><strong>优先 filter</strong></td><td>用 filter 而非 query</td><td>geo 查询通常不需要打分，filter 结果可缓存</td></tr><tr><td><strong>缩小 BKD 范围</strong></td><td>范围小则 BKD 剪枝快</td><td>小范围（1km）比大范围（100km）快 N 倍</td></tr><tr><td><strong>结合其他条件</strong></td><td>先过滤非地理条件</td><td>先 term filter 缩小数据量，再 geo 查</td></tr><tr><td><strong>调整精度</strong></td><td>提高精度会慢</td><td><code>distance_type: arc</code>（精确球面）vs <code>plane</code>（近似平面）</td></tr></tbody></table><hr><h2 id="九、性能优化"><a href="#九、性能优化" class="headerlink" title="九、性能优化"></a>九、性能优化</h2><h3 id="34-写入优化"><a href="#34-写入优化" class="headerlink" title="34. 写入优化"></a>34. 写入优化</h3><table><thead><tr><th>优化手段</th><th>配置</th><th>效果</th><th>原理</th></tr></thead><tbody><tr><td>批量写入</td><td>bulk API（每批 1-15MB&#x2F;1000-5000 条）</td><td>减少网络往返</td><td>一次网络请求处理多个 document，减少 RTT 开销</td></tr><tr><td>降低 refresh 间隔</td><td><code>refresh_interval: 30s</code></td><td>减少 Segment 数量</td><td>延长 refresh 周期，让更多数据积累在一个 Segment，减少合并开销</td></tr><tr><td>关闭副本</td><td><code>number_of_replicas: 0</code>（写入完成后再开启）</td><td>减少数据复制</td><td>写入时不额外消耗副本同步的 IO&#x2F;网络，批量导入后再开启</td></tr><tr><td>异步刷盘</td><td><code>translog.durability: async</code></td><td>减少 fsync 开销</td><td>默认每次写入都 fsync translog，改为异步可大幅提升吞吐（代价：宕机可能丢少量数据）</td></tr><tr><td>多线程写入</td><td>每个节点 4-8 线程</td><td>充分利用 CPU</td><td>ES 的单索引写入是串行的，多线程批量写入不同文档</td></tr></tbody></table><p><strong>批量导入的完整流程</strong>：</p><figure class="highlight bash"><table><tr><td class="code"><pre><span class="line"><span class="comment"># 1. 创建索引时关闭副本和 refresh</span></span><br><span class="line">PUT /my_index</span><br><span class="line">&#123;</span><br><span class="line">  <span class="string">&quot;settings&quot;</span>: &#123;</span><br><span class="line">    <span class="string">&quot;number_of_replicas&quot;</span>: 0,</span><br><span class="line">    <span class="string">&quot;refresh_interval&quot;</span>: <span class="string">&quot;-1&quot;</span>    <span class="comment"># 关闭自动 refresh</span></span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment"># 2. bulk 导入数据</span></span><br><span class="line">POST /_bulk</span><br><span class="line">&#123; <span class="string">&quot;index&quot;</span>: &#123; <span class="string">&quot;_index&quot;</span>: <span class="string">&quot;my_index&quot;</span> &#125; &#125;</span><br><span class="line">&#123; <span class="string">&quot;title&quot;</span>: <span class="string">&quot;...&quot;</span>, ... &#125;</span><br><span class="line"></span><br><span class="line"><span class="comment"># 3. 导入完成后手动 refresh + 开启副本</span></span><br><span class="line">POST /my_index/_refresh</span><br><span class="line">PUT /my_index/_settings</span><br><span class="line">&#123;</span><br><span class="line">  <span class="string">&quot;refresh_interval&quot;</span>: <span class="string">&quot;30s&quot;</span>,</span><br><span class="line">  <span class="string">&quot;number_of_replicas&quot;</span>: 1</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="35-查询优化"><a href="#35-查询优化" class="headerlink" title="35. 查询优化"></a>35. 查询优化</h3><table><thead><tr><th>问题</th><th>原因</th><th>解决</th><th>代码</th></tr></thead><tbody><tr><td>查询慢</td><td>扫描数据多</td><td>加 filter 缩小范围，filter 结果可 bitSet 缓存</td><td><code>&quot;filter&quot;: [{ &quot;term&quot;: { &quot;city&quot;: &quot;北京&quot; } }]</code></td></tr><tr><td>聚合慢</td><td>fielddata&#x2F;global ordinals 计算</td><td>用 DocValues（keyword 默认开启）</td><td>避免对 text 字段做聚合</td></tr><tr><td>深度分页慢</td><td>ES 需要从每个 shard 取 <code>from+size</code> 条再合并</td><td>用 search_after 替代</td><td>见下方详细说明</td></tr><tr><td>通配符查询慢</td><td>扫描大量 term（<code>*abc*</code> 扫描全部）</td><td>用 ngram&#x2F;edge_ngram 分词</td><td>在索引时预生成 ngram 子串</td></tr><tr><td>过大 <code>_source</code></td><td>传输全量数据</td><td><code>_source: { &quot;excludes&quot;: [...] }</code> 或 <code>stored_fields</code></td><td>只返回需要的字段</td></tr><tr><td>无意义打分</td><td>不需要相关性时仍打分</td><td>filter 上下文代替 query</td><td>bool 中用 filter 不用 must</td></tr><tr><td>大 key 查询</td><td>单个 key 过大导致序列化&#x2F;网络开销</td><td>拆分字段或用 <code>_source</code> filter</td><td>避免单字段超过 10KB</td></tr></tbody></table><p><strong>通配符优化——用 ngram 替代通配符</strong>：</p><figure class="highlight json"><table><tr><td class="code"><pre><span class="line"><span class="comment">// ❌ 通配符：每次查询扫描全部 term</span></span><br><span class="line">GET /_search</span><br><span class="line"><span class="punctuation">&#123;</span> <span class="attr">&quot;query&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;wildcard&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;title&quot;</span><span class="punctuation">:</span> <span class="string">&quot;*烤鸭*&quot;</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// ✅ ngram 分词：索引时预生成词段，查询时 term 精确匹配</span></span><br><span class="line">PUT /my_index</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;settings&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;analysis&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;filter&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;ngram_filter&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;ngram&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;min_gram&quot;</span><span class="punctuation">:</span> <span class="number">2</span><span class="punctuation">,</span> <span class="attr">&quot;max_gram&quot;</span><span class="punctuation">:</span> <span class="number">3</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;analyzer&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;ngram_analyzer&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;tokenizer&quot;</span><span class="punctuation">:</span> <span class="string">&quot;standard&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;filter&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="string">&quot;ngram_filter&quot;</span><span class="punctuation">]</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;mappings&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;properties&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;title&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;text&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;analyzer&quot;</span><span class="punctuation">:</span> <span class="string">&quot;ngram_analyzer&quot;</span> <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br><span class="line"><span class="comment">// 查询 &quot;烤鸭&quot;：在索引中已经存了 &quot;烤鸭&quot; 的 2-3gram 子串，直接 term 匹配</span></span><br></pre></td></tr></table></figure><h3 id="36-深度分页"><a href="#36-深度分页" class="headerlink" title="36. 深度分页"></a>36. 深度分页</h3><p><strong>为什么深度分页慢</strong>？</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">查询 from=10000, size=20，3 个 shard：</span><br><span class="line"></span><br><span class="line">协调节点向 3 个 shard 请求 top 10020 条（from + size）</span><br><span class="line">  每个 shard 搜索 10020 条 → 总共 30060 条</span><br><span class="line">  协调节点合并 30060 条，取 [10000-10020) → 只返回 20 条</span><br><span class="line"></span><br><span class="line">问题：99.9% 的操作在内存合并中丢弃了！</span><br><span class="line">      from 越大，浪费越严重。</span><br><span class="line">     ES 默认 max_result_window = 10000，不允许超过。</span><br></pre></td></tr></table></figure><figure class="highlight json"><table><tr><td class="code"><pre><span class="line"><span class="comment">// ❌ 传统分页（越往后越慢）</span></span><br><span class="line">GET /_search?from=<span class="number">10000</span>&amp;size=<span class="number">10</span></span><br><span class="line"><span class="comment">// ES 默认 max_result_window = 10000</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// ✅ search_after（游标分页）</span></span><br><span class="line">GET /_search</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;size&quot;</span><span class="punctuation">:</span> <span class="number">10</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;sort&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;id&quot;</span><span class="punctuation">:</span> <span class="string">&quot;asc&quot;</span> <span class="punctuation">&#125;</span> <span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;search_after&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="number">100</span><span class="punctuation">]</span>   <span class="comment">// 上一页最后一个 id</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// ✅ Scroll（批量导出，不适合实时查询）</span></span><br><span class="line">POST /_search/scroll</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;scroll&quot;</span><span class="punctuation">:</span> <span class="string">&quot;1m&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;scroll_id&quot;</span><span class="punctuation">:</span> <span class="string">&quot;...&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><hr><h2 id="十、索引生命周期管理"><a href="#十、索引生命周期管理" class="headerlink" title="十、索引生命周期管理"></a>十、索引生命周期管理</h2><h3 id="37-索引模板（Index-Template）"><a href="#37-索引模板（Index-Template）" class="headerlink" title="37. 索引模板（Index Template）"></a>37. 索引模板（Index Template）</h3><figure class="highlight json"><table><tr><td class="code"><pre><span class="line"><span class="comment">// 按日期自动创建索引，统一配置</span></span><br><span class="line">PUT /_template/logs_template</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;index_patterns&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="string">&quot;logs-*&quot;</span><span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;settings&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;number_of_shards&quot;</span><span class="punctuation">:</span> <span class="number">3</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;number_of_replicas&quot;</span><span class="punctuation">:</span> <span class="number">1</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;refresh_interval&quot;</span><span class="punctuation">:</span> <span class="string">&quot;30s&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;mappings&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;properties&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;@timestamp&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;date&quot;</span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;message&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;text&quot;</span> <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><h3 id="38-索引生命周期管理（ILM）"><a href="#38-索引生命周期管理（ILM）" class="headerlink" title="38. 索引生命周期管理（ILM）"></a>38. 索引生命周期管理（ILM）</h3><figure class="highlight json"><table><tr><td class="code"><pre><span class="line"><span class="comment">// 自动管理索引生命周期</span></span><br><span class="line">PUT /_ilm/policy/logs_policy</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;policy&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;phases&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;hot&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;min_age&quot;</span><span class="punctuation">:</span> <span class="string">&quot;0ms&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;actions&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;rollover&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;max_size&quot;</span><span class="punctuation">:</span> <span class="string">&quot;50GB&quot;</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span></span><br><span class="line">      <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;warm&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;min_age&quot;</span><span class="punctuation">:</span> <span class="string">&quot;7d&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;actions&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;forcemerge&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;max_num_segments&quot;</span><span class="punctuation">:</span> <span class="number">1</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span></span><br><span class="line">      <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;cold&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;min_age&quot;</span><span class="punctuation">:</span> <span class="string">&quot;30d&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;actions&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;freeze&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span><span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span></span><br><span class="line">      <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;delete&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;min_age&quot;</span><span class="punctuation">:</span> <span class="string">&quot;90d&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;actions&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;delete&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span><span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span></span><br><span class="line">      <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><hr><h2 id="十一、搜索业务场景（与简历关联）"><a href="#十一、搜索业务场景（与简历关联）" class="headerlink" title="十一、搜索业务场景（与简历关联）"></a>十一、搜索业务场景（与简历关联）</h2><h3 id="39-搜索提示（SUG）"><a href="#39-搜索提示（SUG）" class="headerlink" title="39. 搜索提示（SUG）"></a>39. 搜索提示（SUG）</h3><figure class="highlight json"><table><tr><td class="code"><pre><span class="line"><span class="comment">// completion suggester（前缀搜索，基于 FST）</span></span><br><span class="line">PUT /my_index</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;mappings&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;properties&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;suggest&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;completion&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;analyzer&quot;</span><span class="punctuation">:</span> <span class="string">&quot;ik_smart&quot;</span></span><br><span class="line">      <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br><span class="line"></span><br><span class="line">GET /my_index/_search</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;suggest&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;my_suggest&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;prefix&quot;</span><span class="punctuation">:</span> <span class="string">&quot;北京&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;completion&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;field&quot;</span><span class="punctuation">:</span> <span class="string">&quot;suggest&quot;</span> <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><h3 id="40-拼写纠错"><a href="#40-拼写纠错" class="headerlink" title="40. 拼写纠错"></a>40. 拼写纠错</h3><figure class="highlight json"><table><tr><td class="code"><pre><span class="line"><span class="comment">// term suggester（基于编辑距离）</span></span><br><span class="line">GET /my_index/_search</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;suggest&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;spell_check&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;text&quot;</span><span class="punctuation">:</span> <span class="string">&quot;beiign&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;term&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;field&quot;</span><span class="punctuation">:</span> <span class="string">&quot;title&quot;</span> <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><h3 id="41-搜索高亮"><a href="#41-搜索高亮" class="headerlink" title="41. 搜索高亮"></a>41. 搜索高亮</h3><figure class="highlight json"><table><tr><td class="code"><pre><span class="line">GET /my_index/_search</span><br><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;query&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;match&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;title&quot;</span><span class="punctuation">:</span> <span class="string">&quot;北京烤鸭&quot;</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;highlight&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;fields&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;title&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span><span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;pre_tags&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="string">&quot;&lt;em&gt;&quot;</span><span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;post_tags&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="string">&quot;&lt;/em&gt;&quot;</span><span class="punctuation">]</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><hr><h2 id="十二、ES-与搜索引擎架构"><a href="#十二、ES-与搜索引擎架构" class="headerlink" title="十二、ES 与搜索引擎架构"></a>十二、ES 与搜索引擎架构</h2><h3 id="42-ES-在搜索链路中的位置"><a href="#42-ES-在搜索链路中的位置" class="headerlink" title="42. ES 在搜索链路中的位置"></a>42. ES 在搜索链路中的位置</h3><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">用户输入 → Query Understanding → ES 召回 → 粗排 → 精排 → 重排 → 展示</span><br><span class="line">                              ↓</span><br><span class="line">                          ES 负责：</span><br><span class="line">                          1. 倒排索引匹配（TF-IDF / BM25）</span><br><span class="line">                          2. 词条级召回（match / match_phrase）</span><br><span class="line">                          3. 向量语义召回（kNN）</span><br><span class="line">                          4. 过滤（category/地理/价格/状态）</span><br><span class="line">                          5. 基础排序（BM25 _score + 自定义 script）</span><br><span class="line"></span><br><span class="line">                          不负责：</span><br><span class="line">                          1. 深度学习排序（交给精排服务）</span><br><span class="line">                          2. 多模态理解（交给 embedding 服务）</span><br><span class="line">                          3. 个性化重排（交给重排服务）</span><br></pre></td></tr></table></figure><p><strong>搜索链路各阶段职责</strong>：</p><table><thead><tr><th>阶段</th><th>负责组件</th><th>输入</th><th>输出</th><th>量级</th></tr></thead><tbody><tr><td><strong>QU</strong></td><td>Query Understanding 服务</td><td>原始 query “好吃的烤鸭”</td><td>标准化的词条信号</td><td>—</td></tr><tr><td><strong>召回</strong></td><td><strong>ES</strong></td><td>词条信号 + 过滤条件</td><td>候选 docID 列表</td><td>~1000-5000</td></tr><tr><td><strong>粗排</strong></td><td>轻量级模型</td><td>docID 列表</td><td>排序后的 docID</td><td>~500-1000</td></tr><tr><td><strong>精排</strong></td><td>深度学习模型</td><td>粗排 topN</td><td>打分排序结果</td><td>~100-200</td></tr><tr><td><strong>重排</strong></td><td>规则引擎&#x2F;个性化</td><td>精排结果</td><td>最终展示列表</td><td>~20-50</td></tr></tbody></table><p><strong>两阶段召回（现代搜索标配）</strong>：</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">传统的单路倒排召回：</span><br><span class="line">  ES 的普通 match 查询 → 只靠词匹配，可能漏掉语义相关的文档</span><br><span class="line"></span><br><span class="line">现代的多路召回：</span><br><span class="line">  ┌─ ES 倒排召回（词匹配，BM25）─────────┐</span><br><span class="line">  │  查询 &quot;好吃不贵的川菜馆&quot;              │</span><br><span class="line">  │  → 匹配到词 &quot;川菜&quot;、&quot;馆&quot; 的文档       │</span><br><span class="line">  └────────────────────────────────────┘</span><br><span class="line">  ┌─ ES 向量召回（语义匹配，kNN）─────────┐</span><br><span class="line">  │  查询 → embedding → HNSW 搜索        │</span><br><span class="line">  │  → 匹配到 &quot;重庆火锅&quot;、&quot;成都小吃&quot; 等     │</span><br><span class="line">  │    语义相近但没有词重叠的文档          │</span><br><span class="line">  └────────────────────────────────────┘</span><br><span class="line">  → RRF 融合排序 → 返回融合后的 Top N</span><br></pre></td></tr></table></figure><h3 id="43-ES-vs-其他搜索引擎"><a href="#43-ES-vs-其他搜索引擎" class="headerlink" title="43. ES vs 其他搜索引擎"></a>43. ES vs 其他搜索引擎</h3><table><thead><tr><th>维度</th><th>ES</th><th>Solr</th><th>自研搜索引擎</th></tr></thead><tbody><tr><td>部署</td><td>开箱即用，分布式原生</td><td>需配置 SolrCloud</td><td>需要自建</td></tr><tr><td>实时性</td><td>近实时（1s refresh）</td><td>类似</td><td>毫秒级</td></tr><tr><td>扩展性</td><td>自动分片 + rebalance</td><td>类似</td><td>自建</td></tr><tr><td>聚合分析</td><td>强（支持嵌套聚合）</td><td>较弱</td><td>自建</td></tr><tr><td>中文分词</td><td>IK&#x2F;阿里分词</td><td>IK</td><td>自建</td></tr><tr><td>维护成本</td><td>低</td><td>中</td><td>高</td></tr><tr><td>大数据量</td><td>百亿级</td><td>百亿级</td><td>千亿级</td></tr></tbody></table><h3 id="44-搜索链路中的常见问题与-ES-解决方案"><a href="#44-搜索链路中的常见问题与-ES-解决方案" class="headerlink" title="44. 搜索链路中的常见问题与 ES 解决方案"></a>44. 搜索链路中的常见问题与 ES 解决方案</h3><p><strong>问题 1：多路召回结果如何融合？</strong></p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">方案一：RRF（Reciprocal Rank Fusion）</span><br><span class="line">  ES 8.8+ 原生支持，无需应用层合并</span><br><span class="line">  两路召回的结果通过 rank_position 融合打分</span><br><span class="line"></span><br><span class="line">方案二：线性加和</span><br><span class="line">  在应用层获取两路结果，加权求和</span><br><span class="line">  score = w1 * BM25_score + w2 * cosine_similarity</span><br><span class="line"></span><br><span class="line">方案三：瀑布流（cascade）</span><br><span class="line">  先用倒排缩小范围，再在结果集上做向量检索（pre-filter）</span><br><span class="line">  ES 8.12+ 原生支持 filtered kNN</span><br></pre></td></tr></table></figure><p><strong>问题 2：搜索场景中 ES 怎么配合缓存？</strong></p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">L1 缓存（本地 Caffeine）：</span><br><span class="line">  热点 query 的结果直接缓存在服务内存</span><br><span class="line">  TTL 1-2 分钟，命中率 30-50%</span><br><span class="line"></span><br><span class="line">L2 缓存（Redis）：</span><br><span class="line">  查询 → 先查 Redis（key = &quot;search:query_hash&quot;）</span><br><span class="line">  命中 → 直接返回（TTL 5-10 分钟）</span><br><span class="line">  未命中 → 查 ES → 写入 Redis</span><br><span class="line"></span><br><span class="line">注意事项：</span><br><span class="line">  只有高并发查询才需要缓存（头部 query）</span><br><span class="line">  缓存失效要跟数据更新协调（监听 binlog 更新）</span><br></pre></td></tr></table></figure><p><strong>问题 3：搜索服务怎么处理 ES 故障？</strong></p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">降级策略（从轻到重）：</span><br><span class="line">  1. ES 某个 shard 不可用 → 主副本自动提升，对终端透明</span><br><span class="line">  2. ES 节点宕机 → 路由到其他节点，部分 shard 返回空结果</span><br><span class="line">  3. ES 整个集群不可用 → 降级到 MySQL like 查询（慢但可用）</span><br><span class="line">  4. 全部不可用 → 返回热门推荐 + 网络搜索结果兜底</span><br><span class="line"></span><br><span class="line">熔断机制：</span><br><span class="line">  连续 N 次超时 → 打开熔断器 → 请求快速失败 → 返回缓存/兜底</span><br><span class="line">  半分钟后尝试恢复 → 自动关闭熔断器</span><br></pre></td></tr></table></figure><hr><h2 id="面试常问（针对搜索方向）"><a href="#面试常问（针对搜索方向）" class="headerlink" title="面试常问（针对搜索方向）"></a>面试常问（针对搜索方向）</h2><ol><li><strong>ES 写入流程</strong> — Buffer → refresh(1s) → Segment → flush → commit point。写入时如何保证不丢数据（Translog）？<br>1b. <strong>ES 增量更新</strong> — 改了商户 name 后，检索是怎么搜到新词的？为什么旧词改完磁盘空间反而变大？（答案：更新&#x3D;删旧+写新，新文档 refresh 时重新分词进新 Segment；旧词靠 .del 逻辑屏蔽，物理回收要等 Segment Merge，见 7.4.1）</li><li><strong>ES 查询流程</strong> — Query Phase（取 ID+score）+ Fetch Phase（取 _source）。为什么分两阶段？</li><li><strong>倒排索引</strong> — FST → Term Dictionary → Posting List → DocValues 四层结构，每层做什么？</li><li><strong>FST vs BKD Tree</strong> — 文本用 FST（倒排），数值&#x2F;地理用 BKD Tree（DocValues），为什么不同？</li><li><strong>BM25 vs TF-IDF</strong> — BM25 词频饱和度（k1）+ 长度归一化（b），比 TF-IDF 好在哪？</li><li><strong>向量检索（HNSW）</strong> — 为什么用 HNSW 而不是 IVF？HNSW 的分层结构和搜索过程是怎样的？</li><li><strong>混合搜索</strong> — 倒排 + 向量怎么融合？RRF 公式是什么？post-filter 和 pre-filter 的区别？</li><li><strong>短语查询</strong> — match_phrase 如何用 position 信息实现？slop 参数怎么计算？</li><li><strong>倒排链合并</strong> — AND&#x2F;OR&#x2F;NOT 三条有序倒排链怎么合并？拉链归并 + 跳表 skipTo 如何提速？为什么高频词要用 filter 缓存成 bitset？（见 2.4 节）</li><li><strong>深度分页</strong> — from+size 为什么越往后越慢？search_after 怎么解决？</li><li><strong>地理位置查询</strong> — geo_point 用的是 BKD Tree 还是 FST？geo_distance 的完整执行流程？</li><li><strong>缓存问题</strong> — 缓存穿透&#x2F;击穿&#x2F;雪崩在 ES 场景下怎么处理？ES filter 缓存是什么机制？</li><li><strong>ES 在你的搜索链路中怎么用的</strong> — QU 后信号的召回（倒排 + 向量），多路融合，与精排的关系</li></ol><hr><h2 id="常用运维命令"><a href="#常用运维命令" class="headerlink" title="常用运维命令"></a>常用运维命令</h2><figure class="highlight bash"><table><tr><td class="code"><pre><span class="line"><span class="comment"># 集群状态</span></span><br><span class="line">GET _cluster/health</span><br><span class="line"></span><br><span class="line"><span class="comment"># 查看索引</span></span><br><span class="line">GET _cat/indices?v</span><br><span class="line"></span><br><span class="line"><span class="comment"># 查看分片分配</span></span><br><span class="line">GET _cat/shards?v</span><br><span class="line"></span><br><span class="line"><span class="comment"># 慢查询日志配置</span></span><br><span class="line">PUT /my_index/_settings</span><br><span class="line">&#123;</span><br><span class="line">  <span class="string">&quot;index.search.slowlog.threshold.query.warn&quot;</span>: <span class="string">&quot;10s&quot;</span>,</span><br><span class="line">  <span class="string">&quot;index.search.slowlog.threshold.query.info&quot;</span>: <span class="string">&quot;1s&quot;</span>,</span><br><span class="line">  <span class="string">&quot;index.search.slowlog.threshold.query.debug&quot;</span>: <span class="string">&quot;500ms&quot;</span>,</span><br><span class="line">  <span class="string">&quot;index.indexing.slowlog.threshold.index.info&quot;</span>: <span class="string">&quot;1s&quot;</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment"># Force Merge（段合并，减少 Segment 数量）</span></span><br><span class="line">POST /my_index/_forcemerge?max_num_segments=1</span><br><span class="line"></span><br><span class="line"><span class="comment"># 重建索引</span></span><br><span class="line">POST /_reindex</span><br><span class="line">&#123;</span><br><span class="line">  <span class="string">&quot;source&quot;</span>: &#123; <span class="string">&quot;index&quot;</span>: <span class="string">&quot;old_index&quot;</span> &#125;,</span><br><span class="line">  <span class="string">&quot;dest&quot;</span>: &#123; <span class="string">&quot;index&quot;</span>: <span class="string">&quot;new_index&quot;</span> &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment"># 查看集群资源</span></span><br><span class="line">GET _nodes/stats</span><br><span class="line">GET _cat/nodes?v</span><br></pre></td></tr></table></figure>]]></content>
    
    
    <summary type="html">&lt;blockquote&gt;
&lt;p&gt;Elasticsearch（简称 ES）是基于 Lucene 的分布式搜索与分析引擎，凭借倒排索引实现毫秒级全文检索，并支持聚合分析、地理查询和向量检索（kNN）。本文从核心概念出发，系统梳理 ES 的存储结构、写入与查询流程、BM25 评分、HNSW 向量索引原理、聚合与集群分片机制，以及脑裂防护、故障分级等分布式要点，是一份覆盖原理到实践的完整知识图谱。&lt;/p&gt;
&lt;/blockquote&gt;</summary>
    
    
    
    <category term="数据库" scheme="https://blog.searchdiff.com/categories/%E6%95%B0%E6%8D%AE%E5%BA%93/"/>
    
    
    <category term="Elasticsearch" scheme="https://blog.searchdiff.com/tags/Elasticsearch/"/>
    
    <category term="搜索引擎" scheme="https://blog.searchdiff.com/tags/%E6%90%9C%E7%B4%A2%E5%BC%95%E6%93%8E/"/>
    
    <category term="搜索" scheme="https://blog.searchdiff.com/tags/%E6%90%9C%E7%B4%A2/"/>
    
  </entry>
  
  <entry>
    <title>在 Cloudflare Pages 上部署 Hexo 博客全记录</title>
    <link href="https://blog.searchdiff.com/2026/06/20/deploy-hexo-cloudflare/"/>
    <id>https://blog.searchdiff.com/2026/06/20/deploy-hexo-cloudflare/</id>
    <published>2026-06-20T16:00:00.000Z</published>
    <updated>2026-06-21T11:06:44.564Z</updated>
    
    <content type="html"><![CDATA[<p>本文记录从零搭建 Hexo 博客并部署到 Cloudflare Pages 的完整过程，包含踩坑记录和最终可用配置。</p><span id="more"></span><h2 id="一、技术选型"><a href="#一、技术选型" class="headerlink" title="一、技术选型"></a>一、技术选型</h2><table><thead><tr><th>组件</th><th>选择</th><th>理由</th></tr></thead><tbody><tr><td>静态站点生成器</td><td><a href="https://hexo.io/">Hexo</a></td><td>生态成熟，中文友好</td></tr><tr><td>主题</td><td><a href="https://theme-next.js.org/">NexT.Gemini</a></td><td>简洁、功能丰富、维护活跃</td></tr><tr><td>托管平台</td><td><a href="https://pages.cloudflare.com/">Cloudflare Pages</a></td><td>免费、全球 CDN、自定义域名含 SSL</td></tr></tbody></table><hr><h2 id="二、本地初始化"><a href="#二、本地初始化" class="headerlink" title="二、本地初始化"></a>二、本地初始化</h2><figure class="highlight bash"><table><tr><td class="code"><pre><span class="line">npm install -g hexo-cli</span><br><span class="line">hexo init tiny-blog</span><br><span class="line"><span class="built_in">cd</span> tiny-blog</span><br><span class="line">npm install</span><br><span class="line"></span><br><span class="line"><span class="comment"># 安装 NexT 主题</span></span><br><span class="line">npm install hexo-theme-next</span><br></pre></td></tr></table></figure><p><code>_config.yml</code> 指定主题：</p><figure class="highlight yaml"><table><tr><td class="code"><pre><span class="line"><span class="attr">theme:</span> <span class="string">next</span></span><br></pre></td></tr></table></figure><p>新建 <code>_config.next.yml</code> 覆盖主题配置（Hexo 5+ 支持独立主题配置文件，不修改 node_modules 内文件）。</p><hr><h2 id="三、功能配置"><a href="#三、功能配置" class="headerlink" title="三、功能配置"></a>三、功能配置</h2><h3 id="3-1-本地搜索"><a href="#3-1-本地搜索" class="headerlink" title="3.1 本地搜索"></a>3.1 本地搜索</h3><figure class="highlight bash"><table><tr><td class="code"><pre><span class="line">npm install hexo-generator-searchdb</span><br></pre></td></tr></table></figure><p><code>_config.yml</code>：</p><figure class="highlight yaml"><table><tr><td class="code"><pre><span class="line"><span class="attr">search:</span></span><br><span class="line">  <span class="attr">path:</span> <span class="string">search.xml</span></span><br><span class="line">  <span class="attr">field:</span> <span class="string">post</span></span><br><span class="line">  <span class="attr">content:</span> <span class="literal">true</span></span><br><span class="line">  <span class="attr">format:</span> <span class="string">striptags</span>   <span class="comment"># 去除 HTML 标签，搜索更准确</span></span><br></pre></td></tr></table></figure><p><code>_config.next.yml</code>：</p><figure class="highlight yaml"><table><tr><td class="code"><pre><span class="line"><span class="attr">local_search:</span></span><br><span class="line">  <span class="attr">enable:</span> <span class="literal">true</span></span><br><span class="line">  <span class="attr">trigger:</span> <span class="string">auto</span></span><br><span class="line">  <span class="attr">top_n_per_article:</span> <span class="number">3</span></span><br><span class="line">  <span class="attr">unescape:</span> <span class="literal">false</span></span><br><span class="line">  <span class="attr">preload:</span> <span class="literal">false</span></span><br></pre></td></tr></table></figure><blockquote><p><code>format: striptags</code> 比 <code>html</code> 更干净，避免搜索结果中夹杂 HTML 标签。</p></blockquote><h3 id="3-2-RSS-订阅"><a href="#3-2-RSS-订阅" class="headerlink" title="3.2 RSS 订阅"></a>3.2 RSS 订阅</h3><figure class="highlight bash"><table><tr><td class="code"><pre><span class="line">npm install hexo-generator-feed</span><br></pre></td></tr></table></figure><p><code>_config.yml</code>：</p><figure class="highlight yaml"><table><tr><td class="code"><pre><span class="line"><span class="attr">feed:</span></span><br><span class="line">  <span class="attr">enable:</span> <span class="literal">true</span></span><br><span class="line">  <span class="attr">type:</span> <span class="string">atom</span></span><br><span class="line">  <span class="attr">path:</span> <span class="string">atom.xml</span></span><br><span class="line">  <span class="attr">limit:</span> <span class="number">20</span></span><br></pre></td></tr></table></figure><h3 id="3-3-深色模式手动切换"><a href="#3-3-深色模式手动切换" class="headerlink" title="3.3 深色模式手动切换"></a>3.3 深色模式手动切换</h3><p>NexT 的 <code>darkmode</code> 默认跟随系统，但无法手动切换。通过自定义 CSS + 注入脚本实现：</p><p><strong><code>source/_data/styles.styl</code></strong> — 定义深色变量和过渡动画：</p><figure class="highlight stylus"><table><tr><td class="code"><pre><span class="line"><span class="selector-pseudo">:root</span> &#123;</span><br><span class="line">  <span class="attr">--bg-color</span>: <span class="number">#fff</span>;</span><br><span class="line">  <span class="attr">--text-color</span>: <span class="number">#333</span>;</span><br><span class="line">  <span class="comment">/* ... 其他变量 */</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="selector-attr">[data-theme=<span class="string">&quot;dark&quot;</span>]</span> &#123;</span><br><span class="line">  <span class="attr">--bg-color</span>: <span class="number">#1a1a2e</span>;</span><br><span class="line">  <span class="attr">--text-color</span>: <span class="number">#e0e0e0</span>;</span><br><span class="line">  <span class="comment">/* ... */</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="selector-tag">body</span> &#123;</span><br><span class="line">  <span class="attribute">transition</span>: background-color <span class="number">0.3s</span>, color <span class="number">0.3s</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong><code>source/_data/body-end.njk</code></strong> — 在 <code>&lt;/body&gt;</code> 前注入切换按钮和逻辑：</p><figure class="highlight html"><table><tr><td class="code"><pre><span class="line"><span class="tag">&lt;<span class="name">button</span> <span class="attr">id</span>=<span class="string">&quot;theme-toggle&quot;</span> <span class="attr">aria-label</span>=<span class="string">&quot;切换深色模式&quot;</span>&gt;</span>🌙<span class="tag">&lt;/<span class="name">button</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">script</span>&gt;</span><span class="language-javascript"></span></span><br><span class="line"><span class="language-javascript">  <span class="keyword">const</span> btn = <span class="variable language_">document</span>.<span class="title function_">getElementById</span>(<span class="string">&#x27;theme-toggle&#x27;</span>);</span></span><br><span class="line"><span class="language-javascript">  <span class="keyword">const</span> <span class="title function_">apply</span> = (<span class="params">dark</span>) =&gt; &#123;</span></span><br><span class="line"><span class="language-javascript">    <span class="variable language_">document</span>.<span class="property">documentElement</span>.<span class="title function_">setAttribute</span>(<span class="string">&#x27;data-theme&#x27;</span>, dark ? <span class="string">&#x27;dark&#x27;</span> : <span class="string">&#x27;light&#x27;</span>);</span></span><br><span class="line"><span class="language-javascript">    btn.<span class="property">textContent</span> = dark ? <span class="string">&#x27;☀️&#x27;</span> : <span class="string">&#x27;🌙&#x27;</span>;</span></span><br><span class="line"><span class="language-javascript">  &#125;;</span></span><br><span class="line"><span class="language-javascript">  <span class="comment">// 优先读 localStorage，其次跟随系统</span></span></span><br><span class="line"><span class="language-javascript">  <span class="keyword">const</span> stored = <span class="variable language_">localStorage</span>.<span class="title function_">getItem</span>(<span class="string">&#x27;theme&#x27;</span>);</span></span><br><span class="line"><span class="language-javascript">  <span class="keyword">const</span> prefersDark = <span class="variable language_">window</span>.<span class="title function_">matchMedia</span>(<span class="string">&#x27;(prefers-color-scheme: dark)&#x27;</span>).<span class="property">matches</span>;</span></span><br><span class="line"><span class="language-javascript">  <span class="title function_">apply</span>(stored ? stored === <span class="string">&#x27;dark&#x27;</span> : prefersDark);</span></span><br><span class="line"><span class="language-javascript">  btn.<span class="title function_">addEventListener</span>(<span class="string">&#x27;click&#x27;</span>, <span class="function">() =&gt;</span> &#123;</span></span><br><span class="line"><span class="language-javascript">    <span class="keyword">const</span> isDark = <span class="variable language_">document</span>.<span class="property">documentElement</span>.<span class="title function_">getAttribute</span>(<span class="string">&#x27;data-theme&#x27;</span>) === <span class="string">&#x27;dark&#x27;</span>;</span></span><br><span class="line"><span class="language-javascript">    <span class="variable language_">localStorage</span>.<span class="title function_">setItem</span>(<span class="string">&#x27;theme&#x27;</span>, isDark ? <span class="string">&#x27;light&#x27;</span> : <span class="string">&#x27;dark&#x27;</span>);</span></span><br><span class="line"><span class="language-javascript">    <span class="title function_">apply</span>(!isDark);</span></span><br><span class="line"><span class="language-javascript">  &#125;);</span></span><br><span class="line"><span class="language-javascript"></span><span class="tag">&lt;/<span class="name">script</span>&gt;</span></span><br></pre></td></tr></table></figure><h3 id="3-4-中文界面-站点信息"><a href="#3-4-中文界面-站点信息" class="headerlink" title="3.4 中文界面 &amp; 站点信息"></a>3.4 中文界面 &amp; 站点信息</h3><p><code>_config.yml</code>：</p><figure class="highlight yaml"><table><tr><td class="code"><pre><span class="line"><span class="attr">language:</span> <span class="string">zh-CN</span></span><br><span class="line"><span class="attr">author:</span> <span class="string">小加号笔记</span></span><br></pre></td></tr></table></figure><h3 id="3-5-社交链接"><a href="#3-5-社交链接" class="headerlink" title="3.5 社交链接"></a>3.5 社交链接</h3><p><code>_config.next.yml</code>：</p><figure class="highlight yaml"><table><tr><td class="code"><pre><span class="line"><span class="attr">social:</span></span><br><span class="line">  <span class="attr">GitHub:</span> <span class="string">https://github.com/&lt;your-name&gt;</span> <span class="string">||</span> <span class="string">fab</span> <span class="string">fa-github</span></span><br><span class="line">  <span class="attr">E-Mail:</span> <span class="string">mailto:your@email.com</span> <span class="string">||</span> <span class="string">fa</span> <span class="string">fa-envelope</span></span><br><span class="line">  <span class="attr">RSS:</span> <span class="string">/atom.xml</span> <span class="string">||</span> <span class="string">fa</span> <span class="string">fa-rss</span></span><br></pre></td></tr></table></figure><hr><h2 id="四、部署到-Cloudflare-Pages"><a href="#四、部署到-Cloudflare-Pages" class="headerlink" title="四、部署到 Cloudflare Pages"></a>四、部署到 Cloudflare Pages</h2><h3 id="4-1-推送到-GitHub"><a href="#4-1-推送到-GitHub" class="headerlink" title="4.1 推送到 GitHub"></a>4.1 推送到 GitHub</h3><figure class="highlight bash"><table><tr><td class="code"><pre><span class="line">git remote add origin git@github.com:&lt;your-name&gt;/tiny-blog.git</span><br><span class="line">git branch -M main</span><br><span class="line">git push -u origin main</span><br></pre></td></tr></table></figure><h3 id="4-2-创建-Pages-项目"><a href="#4-2-创建-Pages-项目" class="headerlink" title="4.2 创建 Pages 项目"></a>4.2 创建 Pages 项目</h3><ol><li>Cloudflare Dashboard → <strong>Workers &amp; Pages</strong> → <strong>创建</strong> → <strong>Pages</strong> → <strong>连接到 Git</strong></li><li>授权并选中仓库</li></ol><h3 id="4-3-构建配置"><a href="#4-3-构建配置" class="headerlink" title="4.3 构建配置"></a>4.3 构建配置</h3><table><thead><tr><th>配置项</th><th>值</th></tr></thead><tbody><tr><td><strong>Framework preset</strong></td><td><code>None</code></td></tr><tr><td><strong>Build command</strong></td><td><code>npm install --no-fund --no-audit &amp;&amp; npx hexo generate</code></td></tr><tr><td><strong>Build output directory</strong></td><td><code>public</code></td></tr><tr><td><strong>Root directory</strong></td><td>（留空）</td></tr></tbody></table><h3 id="4-4-环境变量"><a href="#4-4-环境变量" class="headerlink" title="4.4 环境变量"></a>4.4 环境变量</h3><p>在 <strong>Settings → Variables and Secrets</strong> 中添加：</p><table><thead><tr><th>变量名</th><th>值</th><th>说明</th></tr></thead><tbody><tr><td><code>NODE_VERSION</code></td><td><code>22</code></td><td>指定 Node 版本，与本地一致</td></tr></tbody></table><hr><h2 id="五、踩坑：npm-ci-“Exit-handler-never-called-”-Bug"><a href="#五、踩坑：npm-ci-“Exit-handler-never-called-”-Bug" class="headerlink" title="五、踩坑：npm ci “Exit handler never called!” Bug"></a>五、踩坑：npm ci “Exit handler never called!” Bug</h2><h3 id="问题现象"><a href="#问题现象" class="headerlink" title="问题现象"></a>问题现象</h3><p>Cloudflare Pages 构建日志报错：</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">npm error Exit handler never called!</span><br><span class="line">npm error This is an error with npm itself. Please report this issue at:</span><br><span class="line">npm error   https://github.com/npm/cli/issues</span><br></pre></td></tr></table></figure><p>构建失败，无法部署。</p><h3 id="根因"><a href="#根因" class="headerlink" title="根因"></a>根因</h3><p>Cloudflare Pages 检测到仓库中存在 <code>package-lock.json</code> 时，会<strong>自动执行 <code>npm ci</code></strong> 而非 <code>npm install</code>。<br><code>npm ci</code> 在 <strong>Node 22 + npm 10.x</strong> 组合下存在已知 bug（<a href="https://github.com/npm/cli/issues/8404">npm&#x2F;cli#8404</a>），会触发上述错误。</p><h3 id="排查过程"><a href="#排查过程" class="headerlink" title="排查过程"></a>排查过程</h3><p><strong>尝试一：重新生成 package-lock.json + 添加 engines 字段</strong></p><figure class="highlight bash"><table><tr><td class="code"><pre><span class="line"><span class="built_in">rm</span> package-lock.json &amp;&amp; npm install</span><br></pre></td></tr></table></figure><p>同时在 <code>package.json</code> 添加：</p><figure class="highlight json"><table><tr><td class="code"><pre><span class="line"><span class="attr">&quot;engines&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;node&quot;</span><span class="punctuation">:</span> <span class="string">&quot;&gt;=18&quot;</span> <span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>结果：仍然失败。<code>npm ci</code> 本身有 bug，版本声明无法绕过。</p><p><strong>尝试二：添加 <code>build:ci</code> 脚本，设置 <code>SKIP_DEPENDENCIES_INSTALL=true</code></strong></p><p>在 <code>package.json</code> 添加：</p><figure class="highlight json"><table><tr><td class="code"><pre><span class="line"><span class="attr">&quot;scripts&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;build:ci&quot;</span><span class="punctuation">:</span> <span class="string">&quot;npm install &amp;&amp; hexo generate&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>Build command 改为 <code>npm run build:ci</code>，并设置环境变量 <code>SKIP_DEPENDENCIES_INSTALL=true</code> 跳过 Cloudflare 的自动安装步骤。</p><p>结果：仍然失败。Cloudflare 的环境变量文档不明确，行为不一致。</p><p><strong>最终方案：将 <code>package-lock.json</code> 加入 <code>.gitignore</code></strong></p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">package-lock.json</span><br></pre></td></tr></table></figure><p>不提交 lockfile → Cloudflare 检测不到 lockfile → 不触发 <code>npm ci</code> → 回退到 <code>npm install</code> → 构建成功。</p><p>Build command 最终定为：</p><figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">npm install --no-fund --no-audit &amp;&amp; npx hexo generate</span><br></pre></td></tr></table></figure><p><code>--no-fund --no-audit</code> 减少无关日志输出，加速构建。</p><hr><h2 id="六、自定义域名"><a href="#六、自定义域名" class="headerlink" title="六、自定义域名"></a>六、自定义域名</h2><ol><li>Pages 项目 → <strong>自定义域</strong> → <strong>设置自定义域</strong></li><li>输入域名（如 <code>blog.searchdiff.com</code>）</li><li>若域名 DNS 已托管在 Cloudflare → 自动添加 CNAME；否则手动在域名商处添加：<figure class="highlight plaintext"><table><tr><td class="code"><pre><span class="line">blog  CNAME  &lt;your-project&gt;.pages.dev</span><br></pre></td></tr></table></figure></li><li>等待几分钟证书签发，HTTPS 自动生效</li></ol><hr><h2 id="七、日常发布流程"><a href="#七、日常发布流程" class="headerlink" title="七、日常发布流程"></a>七、日常发布流程</h2><p>本地写完文章后：</p><figure class="highlight bash"><table><tr><td class="code"><pre><span class="line">git add -A &amp;&amp; git commit -m <span class="string">&quot;new post: 文章标题&quot;</span> &amp;&amp; git push</span><br></pre></td></tr></table></figure><p>Cloudflare Pages 自动拉取、构建、发布，约 1-2 分钟上线。</p><h3 id="常用命令"><a href="#常用命令" class="headerlink" title="常用命令"></a>常用命令</h3><figure class="highlight bash"><table><tr><td class="code"><pre><span class="line">hexo new <span class="string">&quot;文章标题&quot;</span>   <span class="comment"># 新建文章</span></span><br><span class="line">npm run server       <span class="comment"># 本地预览 http://localhost:4000</span></span><br><span class="line">npm run build        <span class="comment"># 本地生成静态文件</span></span><br><span class="line">npm run clean        <span class="comment"># 清理缓存</span></span><br></pre></td></tr></table></figure><hr><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>Hexo + NexT + Cloudflare Pages 是一套低成本、高可用的静态博客方案。主要坑点是 Cloudflare 的 <code>npm ci</code> 自动行为与 npm 10 的兼容性问题——移除 <code>package-lock.json</code> 是最简洁的绕过方式。</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;本文记录从零搭建 Hexo 博客并部署到 Cloudflare Pages 的完整过程，包含踩坑记录和最终可用配置。&lt;/p&gt;</summary>
    
    
    
    <category term="建站" scheme="https://blog.searchdiff.com/categories/%E5%BB%BA%E7%AB%99/"/>
    
    
    <category term="Hexo" scheme="https://blog.searchdiff.com/tags/Hexo/"/>
    
    <category term="Cloudflare Pages" scheme="https://blog.searchdiff.com/tags/Cloudflare-Pages/"/>
    
    <category term="NexT" scheme="https://blog.searchdiff.com/tags/NexT/"/>
    
    <category term="部署" scheme="https://blog.searchdiff.com/tags/%E9%83%A8%E7%BD%B2/"/>
    
  </entry>
  
</feed>
